Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e04ac7c
Add PPTExporter::ToData to export PPTX into an in-memory buffer witho…
OnionsYu Jul 27, 2026
adeb226
Support exporting multiple PAGXDocuments into a multi-slide PPTX.
OnionsYu Jul 27, 2026
c213ea2
Remove single-document PPTExporter overloads in favor of the multi-do…
OnionsYu Jul 27, 2026
1b4f0ba
Support exporting multiple PAGX inputs into a multi-slide PPTX deck f…
OnionsYu Jul 27, 2026
13af07e
Reformat PPTExporter include ordering and long lines to match code st…
OnionsYu Jul 27, 2026
47dafd9
Add PPT export option to ignore Text GlyphRuns and emit native editab…
OnionsYu Jul 29, 2026
935603e
Preserve GlyphRun pen offset when exporting glyph text as native PPT …
OnionsYu Jul 29, 2026
ca32b6b
Suppress TextBox alignment for glyph-origin native text so centered t…
OnionsYu Jul 29, 2026
cbc3745
Drop TextBox padding and paragraph anchor for glyph-origin native tex…
OnionsYu Jul 29, 2026
845a879
Fix too-narrow native PPT text boxes for pre-shaped glyph runs by mea…
OnionsYu Jul 29, 2026
dea71db
Skip emitting a layer's mask as visible content in the no-bake PPT pa…
OnionsYu Jul 29, 2026
c7c43fd
Skip emitting a layer's mask as visible content in the SVG and HTML e…
OnionsYu Jul 30, 2026
8d18867
Fix oversized native PPT text frames in multi-line TextBoxes by deriv…
OnionsYu Jul 30, 2026
d1fa87f
Preserve line-box vertical alignment for native PPT text exported fro…
OnionsYu Jul 30, 2026
dfa2651
Fix misplaced bitmap emoji glyphs in native PPT text by keeping the p…
OnionsYu Jul 30, 2026
c7bc1e4
Combine modifier-only TextBox runs into one native PPT rich-text shap…
OnionsYu Jul 30, 2026
53c736b
Keep native PowerPoint wrapping enabled for text with an authored inl…
OnionsYu Jul 31, 2026
39b9dc0
Reformat the native PPT inline-extent assignment onto a single line t…
OnionsYu Jul 31, 2026
2fab40d
Restore native PPT wrapping for justified paragraphs in text boxes th…
OnionsYu Jul 31, 2026
2de4d75
Fix auto-sized native PPT text frames from shifting in Web/WASM build…
OnionsYu Jul 31, 2026
aa4b7c6
Merge branch 'main' into feature/onionsyu_export_ppt3
OnionsYu Jul 31, 2026
43c8cdb
Fail the whole PPT export when a document has unresolved imports or r…
OnionsYu Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions .codebuddy/skills/pagx/references/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,26 +473,34 @@ Format-specific options (e.g. `--svg-*`) are shared with `pagx import`; see abov
Export a PAGX file to another format (SVG, PPTX, or HTML). The output format is inferred from the
output file extension. If neither `--format` nor an output file with a recognizable extension is
provided, the command reports an error (there is no implicit default format — `--input icon.pagx`
alone fails). Once the format is known, `--output` defaults to `<input>.<format>`.
alone fails). Once the format is known, `--output` defaults to `<first input>.<format>`.

For **PPTX only**, `--input` may be repeated to build a multi-slide deck — each `--input` becomes one
slide, in the order given, and the deck adopts the first document's canvas size (PPTX stores a single
slide size for the whole presentation). SVG and HTML take a single document, so passing more than one
`--input` for those formats is rejected. If any input fails to load, the whole export aborts.

```bash
pagx export --format svg --input icon.pagx # PAGX to icon.svg
pagx export --input icon.pagx --output out.svg # PAGX to out.svg
pagx export --input icon.pagx --output out.pptx # PAGX to out.pptx
pagx export --format svg --input icon.pagx # force SVG output format
pagx export --format pptx --input icon.pagx # force PPTX output format
pagx export --input a.pagx --input b.pagx --output deck.pptx # multi-slide deck (one slide per input)
pagx export --input icon.pagx --svg-indent 4 # 4-space indent
pagx export --input icon.pagx --text-to-path # convert text to paths
pagx export --input icon.pagx --output out.pptx --ppt-no-bake-unsupported # keep unsupported features editable
pagx export --input icon.pagx --output out.pptx --ppt-ignore-glyphruns # editable text instead of glyph paths
pagx export --input icon.pagx --output out.html # PAGX to HTML
```

| Option | Description |
|--------|-------------|
| `--input <file>` | Input PAGX file (required) |
| `--output <file>` | Output file (default: `<input>.<format>`) |
| `--input <file>` | Input PAGX file (required). Repeat to add more slides — **pptx only**; each `--input` becomes one slide in deck order |
| `--output <file>` | Output file (default: `<first input>.<format>`) |
| `--format <format>` | Output format (`svg`, `pptx`, or `html`; inferred from output extension). Required if output has no extension |
| `--text-to-path` | Convert text to path geometry using pre-shaped glyph outlines (default: native text rendering) |
| `--ppt-ignore-glyphruns` | Ignore the GlyphRun geometry carried by Text nodes and emit native, editable PowerPoint text derived from the `text` attribute instead. By default a Text that carries GlyphRun data is written as custom glyph paths, because `a:r` runs cannot express arbitrary glyph IDs, per-glyph offsets, anchors, or rotations; this flag trades that glyph-level fidelity for text the reader can still edit. Text nodes without GlyphRun data are unaffected. Opposite of `--text-to-path` — if both are passed, `--text-to-path` wins |
| `--svg-indent <n>` | Indentation spaces (default: 2, valid range: 0–16) |
| `--svg-no-xml-declaration` | Omit the `<?xml ...?>` declaration |
| `--ppt-no-bake-unsupported` | Disable the default baking of layers that use features OOXML cannot represent natively — masks, scrollRect clipping, blend modes outside of `Normal`/`Multiply`/`Screen`/`Darken`/`Lighten`, wide-gamut color, and `BackgroundBlurStyle`. By default the exporter bakes these layers into PNG patches so the slide matches the tgfx renderer (for unsupported blend modes and `BackgroundBlurStyle` the backdrop beneath the layer is baked into the PNG too, so the blend/frosted-glass composites against the real scene, at the cost of turning native content under the patch into pixels). Pass this flag to silently drop those features and emit the layer as editable shapes instead (mask ignored, scrollRect dropped, blend falls back to `Normal`, wide-gamut clamped to sRGB). Tiled image patterns are always baked regardless of this flag, and features with no vector fallback (TextPath, ColorMatrix, conic/diamond gradient, shear transform) always bake regardless of this flag |
Expand Down
52 changes: 44 additions & 8 deletions include/pagx/PPTExporter.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@

#pragma once

#include <memory>
#include <string>
#include <vector>
#include "pagx/PAGXDocument.h"

namespace pagx {

class FontConfig;
class Data;

/**
* Export options for PPTExporter.
Expand All @@ -37,6 +40,19 @@ struct PPTExportOptions {
*/
bool convertTextToPath = false;

/**
* Whether to ignore the GlyphRun elements carried by a Text node and always render it as native
* PPTX text runs derived from the Text's `text` attribute. When a Text carries GlyphRun data the
* exporter normally treats those pre-shaped glyphs as the authoritative geometry and emits them
* as custom paths, because native a:r runs cannot express arbitrary glyph IDs / per-glyph offsets
* / anchors / rotations. Enabling this flag discards the GlyphRun geometry and falls back to
* native, editable PowerPoint text instead, at the cost of exact glyph-level fidelity. Text nodes
* that have no GlyphRun data are unaffected. This is the direct opposite of convertTextToPath, so
* setting both leaves convertTextToPath in effect and this flag is ignored. The default value is
* false.
*/
bool ignoreGlyphRuns = false;

/**
* Whether to bridge nested contours within a single path element. When enabled, contours that
* contain inner holes are connected by bridge edges so the hole is expressed as a single
Expand Down Expand Up @@ -96,24 +112,44 @@ struct PPTExportOptions {
};

/**
* PPTExporter converts a PAGXDocument into PPTX (PowerPoint) format.
* All PAGX layers are placed in a single slide.
* PPTExporter converts one or more PAGXDocuments into PPTX (PowerPoint) format. Each PAGXDocument
* becomes one slide, in the order supplied; all layers of a document are placed in its slide. The
* presentation slide size is taken from the first document — PPTX stores a single slide size for
* the whole deck, so documents with a different width/height are laid out against that size.
*/
class PPTExporter {
public:
using Options = PPTExportOptions;

/**
* Exports a PAGXDocument to a PPTX file at the specified path.
* @param document the PAGXDocument to export. Passed as non-const because internal layout
* computation may cache intermediate results.
* Exports a sequence of PAGXDocuments to a multi-slide PPTX file at the specified path. Each
* document produces one slide in the order given; a single-element list yields a one-slide deck.
* @param documents the PAGXDocuments to export, one slide per entry. Pointers are non-const
* because internal layout computation may cache intermediate results. Must not be empty
* and must not contain nullptr entries.
* @param filePath the output file path. The file will be created or overwritten.
* @param options export options controlling text rendering and mask handling.
* @return true if the PPTX file was written successfully, false if the file could not be created
* or a write error occurred.
* @return true if the PPTX file was written successfully, false if the document list was empty /
* contained a nullptr, the file could not be created, or a write error occurred.
*/
static bool ToFile(PAGXDocument& document, const std::string& filePath,
static bool ToFile(const std::vector<PAGXDocument*>& documents, const std::string& filePath,
const Options& options = {});

/**
* Exports a sequence of PAGXDocuments to an in-memory multi-slide PPTX buffer without writing to
* disk. This lets the caller decide what to do with the bytes — persist them to a file, upload
* them over the network, hand them to another library, etc. Each document produces one slide in
* the order given; a single-element list yields a one-slide deck.
* @param documents the PAGXDocuments to export, one slide per entry. Pointers are non-const
* because internal layout computation may cache intermediate results. Must not be empty
* and must not contain nullptr entries.
* @param options export options controlling text rendering and mask handling.
* @return a Data object holding the complete PPTX (OOXML .zip) payload, or nullptr if the
* document list was empty / contained a nullptr, or the documents could not be
* serialized.
*/
static std::shared_ptr<Data> ToData(const std::vector<PAGXDocument*>& documents,
const Options& options = {});
};

} // namespace pagx
76 changes: 57 additions & 19 deletions src/cli/CommandExport.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
#include <filesystem>
#include <fstream>
#include <iostream>
#include <memory>
#include <string>
#include <vector>
#include "cli/CliUtils.h"
#include "pagx/HTMLExporter.h"
#include "pagx/PAGXImporter.h"
Expand All @@ -30,13 +32,14 @@
namespace pagx::cli {

struct ExportOptions {
std::string inputFile = {};
std::vector<std::string> inputFiles = {};
std::string outputFile = {};
std::string format = {};
int svgIndent = 2;
bool svgNoXmlDeclaration = false;
bool textToPath = false;
bool pptBakeUnsupported = true;
bool pptIgnoreGlyphRuns = false;
};

static void PrintUsage() {
Expand All @@ -46,8 +49,9 @@ static void PrintUsage() {
<< "Export a PAGX file to another format.\n"
<< "\n"
<< "Options:\n"
<< " --input <file> Input PAGX file (required)\n"
<< " --output <file> Output file (default: <input>.<format>)\n"
<< " --input <file> Input PAGX file (required; repeat to add more slides,\n"
<< " pptx only). Each --input becomes one slide in the deck.\n"
<< " --output <file> Output file (default: <first input>.<format>)\n"
<< " --format <format> Output format (svg, pptx, html; inferred from --output "
"extension)\n"
<< " --text-to-path Convert text to path geometry (default: native text)\n"
Expand All @@ -71,18 +75,33 @@ static void PrintUsage() {
<< " drop those features and emit the layer as editable\n"
<< " shapes instead (mask ignored, scrollRect dropped, blend\n"
<< " falls back to Normal, wide-gamut clamped to sRGB).\n"
<< " --ppt-ignore-glyphruns\n"
<< " Ignore the GlyphRun geometry carried by Text nodes and\n"
<< " emit native, editable PowerPoint text derived from the\n"
<< " Text's text attribute instead. By default a Text with\n"
<< " GlyphRun data is rendered as custom glyph paths for exact\n"
<< " fidelity; pass this flag to trade that fidelity for\n"
<< " editable text. Text nodes without GlyphRun data are\n"
<< " unaffected. Opposite of --text-to-path; if both are\n"
<< " given, --text-to-path wins.\n"
<< "\n"
<< "Examples:\n"
<< " pagx export --input icon.pagx # PAGX to icon.svg\n"
<< " pagx export --input icon.pagx --output out.svg # PAGX to out.svg\n"
<< " pagx export --input icon.pagx --output out.pptx # PAGX to out.pptx\n"
<< " pagx export --format svg --input icon.pagx # force SVG output format\n"
<< " pagx export --format pptx --input icon.pagx # force PPTX output format\n"
<< " pagx export --input a.pagx --input b.pagx --output deck.pptx\n"
<< " # multi-slide deck (one slide per "
"input)\n"
<< " pagx export --input icon.pagx --svg-indent 4 # 4-space indent\n"
<< " pagx export --input icon.pagx --text-to-path # convert text to paths\n"
<< " pagx export --input icon.pagx --output out.pptx --ppt-no-bake-unsupported\n"
<< " # keep unsupported features "
"editable\n"
<< " pagx export --input icon.pagx --output out.pptx --ppt-ignore-glyphruns\n"
<< " # emit editable text, ignore "
"GlyphRuns\n"
<< " pagx export --input icon.pagx --output icon.html # PAGX to HTML\n";
}

Expand All @@ -91,7 +110,7 @@ static int ParseOptions(int argc, char* argv[], ExportOptions* options) {
while (i < argc) {
std::string arg = argv[i];
if (arg == "--input" && i + 1 < argc) {
options->inputFile = argv[++i];
options->inputFiles.emplace_back(argv[++i]);
} else if (arg == "--output" && i + 1 < argc) {
options->outputFile = argv[++i];
} else if (arg == "--format" && i + 1 < argc) {
Expand All @@ -110,6 +129,8 @@ static int ParseOptions(int argc, char* argv[], ExportOptions* options) {
options->textToPath = true;
} else if (arg == "--ppt-no-bake-unsupported") {
options->pptBakeUnsupported = false;
} else if (arg == "--ppt-ignore-glyphruns") {
options->pptIgnoreGlyphRuns = true;
} else if (arg == "--help" || arg == "-h") {
PrintUsage();
return -1;
Expand All @@ -124,7 +145,7 @@ static int ParseOptions(int argc, char* argv[], ExportOptions* options) {
i++;
}

if (options->inputFile.empty()) {
if (options->inputFiles.empty()) {
std::cerr << "pagx export: error: missing --input file\n";
return 1;
}
Expand All @@ -138,15 +159,20 @@ static int ParseOptions(int argc, char* argv[], ExportOptions* options) {
return 1;
}

if (options->format != "pptx" && options->inputFiles.size() > 1) {
std::cerr << "pagx export: error: multiple --input files are only supported for pptx output\n";
return 1;
}

if (options->outputFile.empty()) {
options->outputFile = ReplaceExtension(options->inputFile, options->format);
options->outputFile = ReplaceExtension(options->inputFiles.front(), options->format);
}

return 0;
}

static int ExportToSVG(const ExportOptions& options) {
auto document = LoadDocument(options.inputFile, "pagx export");
auto document = LoadDocument(options.inputFiles.front(), "pagx export");
if (document == nullptr) {
return 1;
}
Expand All @@ -170,9 +196,10 @@ static int ExportToSVG(const ExportOptions& options) {
}

static int ExportToHTML(const ExportOptions& options) {
auto document = PAGXImporter::FromFile(options.inputFile);
const auto& inputFile = options.inputFiles.front();
auto document = PAGXImporter::FromFile(inputFile);
if (document == nullptr) {
std::cerr << "pagx export: error: failed to load '" << options.inputFile << "'\n";
std::cerr << "pagx export: error: failed to load '" << inputFile << "'\n";
return 1;
}
for (auto& error : document->errors) {
Expand All @@ -194,26 +221,37 @@ static int ExportToHTML(const ExportOptions& options) {
}

static int ExportToPPT(const ExportOptions& options) {
auto document = PAGXImporter::FromFile(options.inputFile);
if (document == nullptr) {
std::cerr << "pagx export: error: failed to load '" << options.inputFile << "'\n";
return 1;
}
if (!document->errors.empty()) {
std::vector<std::shared_ptr<PAGXDocument>> documents = {};
documents.reserve(options.inputFiles.size());
for (const auto& inputFile : options.inputFiles) {
auto document = PAGXImporter::FromFile(inputFile);
if (document == nullptr) {
std::cerr << "pagx export: error: failed to load '" << inputFile << "'\n";
return 1;
}
for (auto& error : document->errors) {
std::cerr << "pagx export: warning: " << error << "\n";
}
if (document->hasUnresolvedImports()) {
std::cerr << "pagx export: error: unresolved import directive in '" << inputFile
<< "', run 'pagx resolve' first\n";
return 1;
}
documents.emplace_back(std::move(document));
}
if (document->hasUnresolvedImports()) {
std::cerr << "pagx export: error: unresolved import directive, run 'pagx resolve' first\n";
return 1;

std::vector<PAGXDocument*> documentPtrs = {};
documentPtrs.reserve(documents.size());
for (const auto& document : documents) {
documentPtrs.emplace_back(document.get());
}

PPTExporter::Options pptOptions = {};
pptOptions.convertTextToPath = options.textToPath;
pptOptions.bakeUnsupported = options.pptBakeUnsupported;
pptOptions.ignoreGlyphRuns = options.pptIgnoreGlyphRuns;

if (!PPTExporter::ToFile(*document, options.outputFile, pptOptions)) {
if (!PPTExporter::ToFile(documentPtrs, options.outputFile, pptOptions)) {
std::cerr << "pagx export: error: failed to write '" << options.outputFile << "'\n";
return 1;
}
Expand Down
5 changes: 3 additions & 2 deletions src/pagx/TextLayout.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1711,13 +1711,14 @@ static Rect MergeEmbeddedBounds(const std::vector<TextElement>& textElements) {
}

TextLayoutResult TextLayout::Layout(const std::vector<TextElement>& textElements,
const TextLayoutParams& params, LayoutContext* context) {
const TextLayoutParams& params, LayoutContext* context,
bool useEmbeddedGlyphRuns) {
if (textElements.empty()) {
return {};
}
TextLayoutContext layoutContext(context);
TextLayoutResult result = {};
if (AllHaveEmbeddedGlyphRuns(textElements)) {
if (useEmbeddedGlyphRuns && AllHaveEmbeddedGlyphRuns(textElements)) {
// Embedded path: only compute bounds. TextBlob generation is deferred to the caller
// (TextBox::updateLayout or LayerBuilder) which applies the inverse matrix.
result.bounds = MergeEmbeddedBounds(textElements);
Expand Down
8 changes: 7 additions & 1 deletion src/pagx/TextLayout.h
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,15 @@ class TextLayout {
* Performs text layout and returns TextLayoutResult with bounds and positioned glyph runs.
* Shapes text, computes line/column breaks, but does not build TextBlob. The caller uses
* GlyphRunRenderer to convert layout glyph runs to TextBlob with the appropriate inverse matrix.
*
* When useEmbeddedGlyphRuns is true (the default), a set of Text elements that all carry
* pre-shaped GlyphRuns takes the embedded fast path and returns only their authored bounds.
* Pass false when a caller intentionally needs fresh line-box and baseline metadata from the
* readable Text content (for example, editable-text export with GlyphRuns explicitly ignored).
*/
static TextLayoutResult Layout(const std::vector<TextElement>& textElements,
const TextLayoutParams& params, LayoutContext* context);
const TextLayoutParams& params, LayoutContext* context,
bool useEmbeddedGlyphRuns = true);

/**
* Collects all Text elements from an element list (including nested Groups).
Expand Down
10 changes: 10 additions & 0 deletions src/pagx/html/HTMLWriterLayer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2676,6 +2676,16 @@ void HTMLWriter::writeLayerInner(HTMLBuilder& out, const Layer* layer, float con
// savedChildLayerOffset* was set by the parent writeLayer immediately after computing the
// Repeater union-bounds shift; for layers without a Repeater it is (0,0).
for (auto* child : layer->children) {
// The mask layer is authored as an invisible child of the layer it masks (the PAGX / HTML
// importer attaches the rebuilt mask this way). It defines the clip shape, not visible content:
// its geometry is already emitted into the CSS mask / <clipPath> def (see writeClipDef /
// writeMaskCSS above) and referenced by this layer's style. Emitting it again here as a normal
// child would paint the mask's own fill (e.g. a solid rounded rect) on top of the masked
// content as an opaque patch. The node stays in `children` because the clip-def writer
// (writeClipContent) and the tgfx bake path both still need it.
if (child == layer->mask) {
continue;
}
bool childIsFlexItem = isFlexContainer && child->includeInLayout;
_ctx->childLayerOffsetX = childOffX;
_ctx->childLayerOffsetY = childOffY;
Expand Down
Loading
Loading