From e04ac7c6a9b01d0fd89c21317412cef35cb1175f Mon Sep 17 00:00:00 2001 From: OnionsYu Date: Mon, 27 Jul 2026 10:52:26 +0800 Subject: [PATCH 01/21] Add PPTExporter::ToData to export PPTX into an in-memory buffer without writing to disk. --- include/pagx/PPTExporter.h | 14 +++ src/pagx/ppt/PPTExporter.cpp | 184 ++++++++++++++++++++++++++++++++--- test/src/PAGXPPTTest.cpp | 130 +++++++++++++++++++++++++ 3 files changed, 313 insertions(+), 15 deletions(-) diff --git a/include/pagx/PPTExporter.h b/include/pagx/PPTExporter.h index 3a10042f27..514013dbae 100644 --- a/include/pagx/PPTExporter.h +++ b/include/pagx/PPTExporter.h @@ -18,12 +18,14 @@ #pragma once +#include #include #include "pagx/PAGXDocument.h" namespace pagx { class FontConfig; +class Data; /** * Export options for PPTExporter. @@ -114,6 +116,18 @@ class PPTExporter { */ static bool ToFile(PAGXDocument& document, const std::string& filePath, const Options& options = {}); + + /** + * Exports a PAGXDocument to an in-memory 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. + * @param document the PAGXDocument to export. Passed as non-const because internal layout + * computation may cache intermediate results. + * @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 could not be serialized. + */ + static std::shared_ptr ToData(PAGXDocument& document, const Options& options = {}); }; } // namespace pagx diff --git a/src/pagx/ppt/PPTExporter.cpp b/src/pagx/ppt/PPTExporter.cpp index 2a05501ecd..b405d6a29b 100644 --- a/src/pagx/ppt/PPTExporter.cpp +++ b/src/pagx/ppt/PPTExporter.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -47,6 +48,7 @@ #include "pagx/utils/StrokeGeometryUtils.h" #include "pagx/utils/TextUtils.h" #include "pagx/xml/XMLBuilder.h" +#include "pagx/types/Data.h" #include "renderer/LayerBuilder.h" #include "tgfx/layers/DisplayList.h" #include "zip.h" @@ -814,25 +816,117 @@ static bool AddZipString(zipFile zf, const char* name, const std::string& conten } //============================================================================== -// PPTExporter::ToFile +// In-memory ZIP backend //============================================================================== -bool PPTExporter::ToFile(PAGXDocument& doc, const std::string& filePath, const Options& options) { - if (!doc.isLayoutApplied()) { - doc.applyLayout(); +namespace { + +// Growable byte buffer with a read/write cursor, driving minizip through a +// custom zlib_filefunc_def so a PPTX can be assembled entirely in RAM. minizip +// seeks backward to patch each local file header's CRC / sizes once the entry +// is closed, so this must support seek/tell/overwrite in addition to append. +struct MemZipBuffer { + std::string data; + size_t position = 0; +}; + +voidpf ZCALLBACK MemZipOpen(voidpf opaque, const char*, int) { + // A single buffer is shared for the whole archive; reset the cursor so the + // stream starts writing at the beginning. + auto* buffer = static_cast(opaque); + buffer->data.clear(); + buffer->position = 0; + return opaque; +} + +uLong ZCALLBACK MemZipRead(voidpf, voidpf stream, void* buf, uLong size) { + auto* buffer = static_cast(stream); + if (buffer->position >= buffer->data.size()) { + return 0; } + uLong available = static_cast(buffer->data.size() - buffer->position); + uLong toRead = std::min(size, available); + std::memcpy(buf, buffer->data.data() + buffer->position, toRead); + buffer->position += toRead; + return toRead; +} - auto layoutContext = std::make_unique(options.fontConfig); +uLong ZCALLBACK MemZipWrite(voidpf, voidpf stream, const void* buf, uLong size) { + auto* buffer = static_cast(stream); + size_t end = buffer->position + size; + if (end > buffer->data.size()) { + buffer->data.resize(end); + } + std::memcpy(&buffer->data[buffer->position], buf, size); + buffer->position += size; + return size; +} - PPTWriterContext context; - PPTWriter writer(&context, &doc, options, layoutContext.get()); +long ZCALLBACK MemZipTell(voidpf, voidpf stream) { + auto* buffer = static_cast(stream); + return static_cast(buffer->position); +} + +long ZCALLBACK MemZipSeek(voidpf, voidpf stream, uLong offset, int origin) { + auto* buffer = static_cast(stream); + size_t base = 0; + switch (origin) { + case ZLIB_FILEFUNC_SEEK_SET: + base = 0; + break; + case ZLIB_FILEFUNC_SEEK_CUR: + base = buffer->position; + break; + case ZLIB_FILEFUNC_SEEK_END: + base = buffer->data.size(); + break; + default: + return -1; + } + buffer->position = base + offset; + return 0; +} + +int ZCALLBACK MemZipClose(voidpf, voidpf) { + return 0; +} + +int ZCALLBACK MemZipError(voidpf, voidpf) { + return 0; +} + +zlib_filefunc_def MakeMemZipFileFunc(MemZipBuffer* buffer) { + zlib_filefunc_def def = {}; + def.zopen_file = MemZipOpen; + def.zread_file = MemZipRead; + def.zwrite_file = MemZipWrite; + def.ztell_file = MemZipTell; + def.zseek_file = MemZipSeek; + def.zclose_file = MemZipClose; + def.zerror_file = MemZipError; + def.opaque = buffer; + return def; +} + +} // namespace + +//============================================================================== +// Shared assembly +//============================================================================== + +namespace { + +// Serializes the document into the single slide XML string and populates +// `context` with the media entries referenced by that slide. Shared by ToFile +// and ToData so both entry points produce byte-identical archives. +std::string BuildSlideXml(PAGXDocument& doc, const PPTExportOptions& options, + PPTWriterContext& context, LayoutContext* layoutContext) { + PPTWriter writer(&context, &doc, options, layoutContext); - // Build slide body content XMLBuilder body(false, 2, 0, 16384); writer.writeDocument(body); std::string bodyContent = body.release(); - // Assemble slide XML std::string slide; slide.reserve(2048 + bodyContent.size()); slide += ""; @@ -849,13 +943,14 @@ bool PPTExporter::ToFile(PAGXDocument& doc, const std::string& filePath, const O slide += ""; slide += ""; slide += ""; + return slide; +} - // Write ZIP via minizip - zipFile zf = zipOpen(filePath.c_str(), APPEND_STATUS_CREATE); - if (!zf) { - return false; - } - +// Writes every OOXML part (boilerplate XML + media) into the already-opened zip +// handle. Returns false on the first write failure so callers can discard the +// partial archive. +bool WriteZipEntries(zipFile zf, PPTWriterContext& context, PAGXDocument& doc, + const std::string& slide) { bool ok = true; ok = ok && AddZipString(zf, "[Content_Types].xml", GenerateContentTypes(context)); ok = ok && AddZipString(zf, "_rels/.rels", GenerateRootRels()); @@ -891,6 +986,32 @@ bool PPTExporter::ToFile(PAGXDocument& doc, const std::string& filePath, const O static_cast(img.cachedData->size())); } } + return ok; +} + +} // namespace + +//============================================================================== +// PPTExporter::ToFile +//============================================================================== + +bool PPTExporter::ToFile(PAGXDocument& doc, const std::string& filePath, const Options& options) { + if (!doc.isLayoutApplied()) { + doc.applyLayout(); + } + + auto layoutContext = std::make_unique(options.fontConfig); + + PPTWriterContext context; + std::string slide = BuildSlideXml(doc, options, context, layoutContext.get()); + + // Write ZIP via minizip + zipFile zf = zipOpen(filePath.c_str(), APPEND_STATUS_CREATE); + if (!zf) { + return false; + } + + bool ok = WriteZipEntries(zf, context, doc, slide); if (!ok) { zipClose(zf, nullptr); @@ -907,4 +1028,37 @@ bool PPTExporter::ToFile(PAGXDocument& doc, const std::string& filePath, const O return true; } +//============================================================================== +// PPTExporter::ToData +//============================================================================== + +std::shared_ptr PPTExporter::ToData(PAGXDocument& doc, const Options& options) { + if (!doc.isLayoutApplied()) { + doc.applyLayout(); + } + + auto layoutContext = std::make_unique(options.fontConfig); + + PPTWriterContext context; + std::string slide = BuildSlideXml(doc, options, context, layoutContext.get()); + + // Assemble the archive into a growable RAM buffer instead of a file on disk. + MemZipBuffer memBuffer; + zlib_filefunc_def fileFunc = MakeMemZipFileFunc(&memBuffer); + zipFile zf = zipOpen2("in-memory.pptx", APPEND_STATUS_CREATE, nullptr, &fileFunc); + if (!zf) { + return nullptr; + } + + bool ok = WriteZipEntries(zf, context, doc, slide); + if (zipClose(zf, nullptr) != ZIP_OK) { + ok = false; + } + if (!ok) { + return nullptr; + } + + return Data::MakeWithCopy(memBuffer.data.data(), memBuffer.data.size()); +} + } // namespace pagx diff --git a/test/src/PAGXPPTTest.cpp b/test/src/PAGXPPTTest.cpp index 7b053673b2..c2ec9fb1e7 100644 --- a/test/src/PAGXPPTTest.cpp +++ b/test/src/PAGXPPTTest.cpp @@ -20,6 +20,7 @@ #include #include +#include #include #include #include "pagx/PAGXExporter.h" @@ -5842,6 +5843,135 @@ PAGX_TEST(PAGXPPTTest, ImagePatternFill_TileFlipNone) { ASSERT_TRUE(ExportAndVerify(*doc, "imagepattern_tile_flip_none")); } +//============================================================================== +// PPTExporter::ToData — in-memory export +//============================================================================== + +static std::string ReadFileBytes(const std::string& path) { + std::ifstream file(path, std::ios::binary | std::ios::ate); + if (!file.good()) { + return {}; + } + auto size = static_cast(file.tellg()); + std::string contents(static_cast(size), '\0'); + file.seekg(0); + file.read(contents.data(), size); + return contents; +} + +// Every ZIP (and therefore every PPTX/OOXML container) begins with the local +// file header signature "PK\x03\x04". +static bool HasZipMagic(const pagx::Data* data) { + if (data == nullptr || data->size() < 4) { + return false; + } + const auto* bytes = data->bytes(); + return bytes[0] == 0x50 && bytes[1] == 0x4B && bytes[2] == 0x03 && bytes[3] == 0x04; +} + +static std::shared_ptr MakeSimplePPTDoc() { + auto doc = pagx::PAGXDocument::Make(400, 300); + auto* layer = doc->makeNode(); + auto* rect = doc->makeNode(); + rect->position = {200, 150}; + rect->size = {200, 100}; + layer->contents.push_back(rect); + layer->contents.push_back(MakeSolidFill(doc.get(), {1.0f, 0.0f, 0.0f, 1.0f})); + doc->layers.push_back(layer); + return doc; +} + +PAGX_TEST(PAGXPPTTest, ToData_SimpleDocument) { + auto doc = MakeSimplePPTDoc(); + auto data = pagx::PPTExporter::ToData(*doc); + ASSERT_NE(data, nullptr); + EXPECT_GT(data->size(), 0u); + EXPECT_TRUE(HasZipMagic(data.get())); +} + +PAGX_TEST(PAGXPPTTest, ToData_EmptyDocument) { + auto doc = pagx::PAGXDocument::Make(800, 600); + auto data = pagx::PPTExporter::ToData(*doc); + ASSERT_NE(data, nullptr); + EXPECT_GT(data->size(), 0u); + EXPECT_TRUE(HasZipMagic(data.get())); +} + +// The in-memory archive is assembled from the same parts as the on-disk one and +// minizip zeroes the per-entry timestamps (zip_fileinfo{}), so ToData must be +// byte-for-byte identical to ToFile. +PAGX_TEST(PAGXPPTTest, ToData_MatchesToFile) { + auto doc = MakeSimplePPTDoc(); + + auto data = pagx::PPTExporter::ToData(*doc); + ASSERT_NE(data, nullptr); + ASSERT_GT(data->size(), 0u); + + auto path = PPTOutDir() + "/to_data_parity.pptx"; + ASSERT_TRUE(pagx::PPTExporter::ToFile(*doc, path)); + auto fileBytes = ReadFileBytes(path); + ASSERT_FALSE(fileBytes.empty()); + + ASSERT_EQ(data->size(), fileBytes.size()); + EXPECT_EQ(0, std::memcmp(data->data(), fileBytes.data(), fileBytes.size())); +} + +// A document with an embedded image produces media entries in the archive; the +// in-memory path must handle those binary parts the same way ToFile does. +PAGX_TEST(PAGXPPTTest, ToData_WithImageMedia) { + auto doc = pagx::PAGXDocument::Make(400, 300); + auto* layer = doc->makeNode(); + auto* rect = doc->makeNode(); + rect->position = {200, 150}; + rect->size = {200, 150}; + + auto* image = MakeTestPNGImage(doc.get()); + auto* pattern = doc->makeNode(); + pattern->image = image; + pattern->matrix = {1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f}; + auto* fill = doc->makeNode(); + fill->color = pattern; + + layer->contents.push_back(rect); + layer->contents.push_back(fill); + doc->layers.push_back(layer); + + auto data = pagx::PPTExporter::ToData(*doc); + ASSERT_NE(data, nullptr); + EXPECT_TRUE(HasZipMagic(data.get())); + + auto path = PPTOutDir() + "/to_data_with_image.pptx"; + ASSERT_TRUE(pagx::PPTExporter::ToFile(*doc, path)); + auto fileBytes = ReadFileBytes(path); + ASSERT_FALSE(fileBytes.empty()); + ASSERT_EQ(data->size(), fileBytes.size()); + EXPECT_EQ(0, std::memcmp(data->data(), fileBytes.data(), fileBytes.size())); +} + +// The buffer is a real ZIP whose local file headers store each part name in the +// clear, so we can spot-check the slide part without pulling in an unzip lib. +PAGX_TEST(PAGXPPTTest, ToData_ContainsSlidePart) { + auto doc = MakeSimplePPTDoc(); + auto data = pagx::PPTExporter::ToData(*doc); + ASSERT_NE(data, nullptr); + + std::string bytes(reinterpret_cast(data->bytes()), data->size()); + EXPECT_NE(bytes.find("ppt/slides/slide1.xml"), std::string::npos); + EXPECT_NE(bytes.find("[Content_Types].xml"), std::string::npos); +} + +// Options that route through the rasterizer (bakeUnsupported) must not crash the +// in-memory path and still yield a valid archive. +PAGX_TEST(PAGXPPTTest, ToData_RespectsOptions) { + auto doc = MakeSimplePPTDoc(); + pagx::PPTExportOptions options; + options.bakeUnsupported = false; + options.convertTextToPath = true; + auto data = pagx::PPTExporter::ToData(*doc, options); + ASSERT_NE(data, nullptr); + EXPECT_TRUE(HasZipMagic(data.get())); +} + } // namespace pag #endif From adeb2269bae9b9ea905668ceaab17505fdbf59c8 Mon Sep 17 00:00:00 2001 From: OnionsYu Date: Mon, 27 Jul 2026 12:36:42 +0800 Subject: [PATCH 02/21] Support exporting multiple PAGXDocuments into a multi-slide PPTX. --- include/pagx/PPTExporter.h | 43 +++++++-- src/pagx/ppt/PPTBoilerplate.cpp | 52 ++++++++--- src/pagx/ppt/PPTBoilerplate.h | 16 +++- src/pagx/ppt/PPTExporter.cpp | 141 ++++++++++++++++++++-------- src/pagx/ppt/PPTWriterContext.h | 15 ++- test/src/PAGXPPTTest.cpp | 157 ++++++++++++++++++++++++++++++++ 6 files changed, 360 insertions(+), 64 deletions(-) diff --git a/include/pagx/PPTExporter.h b/include/pagx/PPTExporter.h index 514013dbae..6a8a1a1954 100644 --- a/include/pagx/PPTExporter.h +++ b/include/pagx/PPTExporter.h @@ -20,6 +20,7 @@ #include #include +#include #include "pagx/PAGXDocument.h" namespace pagx { @@ -98,15 +99,17 @@ 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. + * Exports a single PAGXDocument to a one-slide PPTX file at the specified path. * @param document the PAGXDocument to export. Passed as non-const because internal layout * computation may cache intermediate results. * @param filePath the output file path. The file will be created or overwritten. @@ -118,9 +121,23 @@ class PPTExporter { const Options& options = {}); /** - * Exports a PAGXDocument to an in-memory 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. + * Exports a sequence of PAGXDocuments to a multi-slide PPTX file at the specified path. Each + * document produces one slide in the order given. + * @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 document list was empty / + * contained a nullptr, the file could not be created, or a write error occurred. + */ + static bool ToFile(const std::vector& documents, const std::string& filePath, + const Options& options = {}); + + /** + * Exports a single PAGXDocument to an in-memory one-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. * @param document the PAGXDocument to export. Passed as non-const because internal layout * computation may cache intermediate results. * @param options export options controlling text rendering and mask handling. @@ -128,6 +145,20 @@ class PPTExporter { * document could not be serialized. */ static std::shared_ptr ToData(PAGXDocument& document, const Options& options = {}); + + /** + * Exports a sequence of PAGXDocuments to an in-memory multi-slide PPTX buffer without writing to + * disk. Each document produces one slide in the order given. + * @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 ToData(const std::vector& documents, + const Options& options = {}); }; } // namespace pagx diff --git a/src/pagx/ppt/PPTBoilerplate.cpp b/src/pagx/ppt/PPTBoilerplate.cpp index cf7eb737cb..4bd88d3d82 100644 --- a/src/pagx/ppt/PPTBoilerplate.cpp +++ b/src/pagx/ppt/PPTBoilerplate.cpp @@ -65,7 +65,7 @@ static void AppendOfficeRel(std::string& s, const char* id, const char* typeSuff s += "\"/>"; } -std::string GenerateContentTypes(const PPTWriterContext& ctx) { +std::string GenerateContentTypes(bool hasPNG, bool hasJPEG, size_t slideCount) { std::string s; s.reserve(2048); s += XML_DECL; @@ -73,17 +73,20 @@ std::string GenerateContentTypes(const PPTWriterContext& ctx) { s += ""; s += ""; - if (ctx.hasPNG()) { + if (hasPNG) { s += ""; } - if (ctx.hasJPEG()) { + if (hasJPEG) { s += ""; } s += ""; - s += ""; + for (size_t i = 1; i <= slideCount; i++) { + s += ""; + } s += ""; @@ -123,7 +126,7 @@ std::string GenerateRootRels() { return s; } -std::string GeneratePresentation(float w, float h) { +std::string GeneratePresentation(float w, float h, size_t slideCount) { int64_t cx = PxToEMU(w); int64_t cy = PxToEMU(h); if (cx > MAX_SLIDE_SIZE_EMU || cy > MAX_SLIDE_SIZE_EMU) { @@ -147,7 +150,14 @@ std::string GeneratePresentation(float w, float h) { s += NS_PRESENTATIONML; s += ">"; s += ""; - s += ""; + s += ""; + for (size_t i = 1; i <= slideCount; i++) { + // Slide object ids must be unique and >= 256; the slide relationships start + // at rId2 (rId1 is the slideMaster), so slide i maps to rId{i + 1}. + s += ""; + } + s += ""; s += ""; s += ""; s += "" @@ -170,17 +180,27 @@ std::string GeneratePresentation(float w, float h) { return s; } -std::string GeneratePresentationRels() { +std::string GeneratePresentationRels(size_t slideCount) { std::string s; s.reserve(1024); s += XML_DECL; s += RELATIONSHIPS_OPEN; AppendOfficeRel(s, "rId1", "slideMaster", "slideMasters/slideMaster1.xml"); - AppendOfficeRel(s, "rId2", "slide", "slides/slide1.xml"); - AppendOfficeRel(s, "rId3", "presProps", "presProps.xml"); - AppendOfficeRel(s, "rId4", "viewProps", "viewProps.xml"); - AppendOfficeRel(s, "rId5", "theme", "theme/theme1.xml"); - AppendOfficeRel(s, "rId6", "tableStyles", "tableStyles.xml"); + // Slides occupy rId2 .. rId{slideCount + 1}; the fixed parts follow after them + // so their ids shift with the slide count. + for (size_t i = 1; i <= slideCount; i++) { + std::string id = "rId" + std::to_string(i + 1); + AppendOfficeRel(s, id.c_str(), "slide", "slides/slide" + std::to_string(i) + ".xml"); + } + size_t next = slideCount + 2; + std::string presPropsId = "rId" + std::to_string(next++); + std::string viewPropsId = "rId" + std::to_string(next++); + std::string themeId = "rId" + std::to_string(next++); + std::string tableStylesId = "rId" + std::to_string(next++); + AppendOfficeRel(s, presPropsId.c_str(), "presProps", "presProps.xml"); + AppendOfficeRel(s, viewPropsId.c_str(), "viewProps", "viewProps.xml"); + AppendOfficeRel(s, themeId.c_str(), "theme", "theme/theme1.xml"); + AppendOfficeRel(s, tableStylesId.c_str(), "tableStyles", "tableStyles.xml"); s += ""; return s; } @@ -408,7 +428,7 @@ std::string GenerateCoreProps() { return s; } -std::string GenerateAppProps() { +std::string GenerateAppProps(size_t slideCount) { std::string s; s.reserve(512); s += XML_DECL; @@ -419,7 +439,9 @@ std::string GenerateAppProps() { "0" "PAGX" "0" - "1" + "" + + std::to_string(slideCount) + + "" "0" "0" "0" diff --git a/src/pagx/ppt/PPTBoilerplate.h b/src/pagx/ppt/PPTBoilerplate.h index 9125518362..71038fa04b 100644 --- a/src/pagx/ppt/PPTBoilerplate.h +++ b/src/pagx/ppt/PPTBoilerplate.h @@ -18,16 +18,24 @@ #pragma once +#include #include namespace pagx { class PPTWriterContext; -std::string GenerateContentTypes(const PPTWriterContext& ctx); +// `hasPNG` / `hasJPEG` are aggregated across every slide's context so the deck +// declares each media default extension exactly once. `slideCount` controls how +// many `/ppt/slides/slideN.xml` overrides are emitted. +std::string GenerateContentTypes(bool hasPNG, bool hasJPEG, size_t slideCount); std::string GenerateRootRels(); -std::string GeneratePresentation(float w, float h); -std::string GeneratePresentationRels(); +// `w` / `h` are the deck's slide size (taken from the first document). `slideCount` +// controls the number of entries in the slide id list. +std::string GeneratePresentation(float w, float h, size_t slideCount); +// Emits the slideMaster relationship followed by one relationship per slide, then +// the presProps / viewProps / theme / tableStyles relationships. +std::string GeneratePresentationRels(size_t slideCount); std::string GenerateSlideRels(const PPTWriterContext& ctx); std::string GenerateSlideMaster(); std::string GenerateSlideMasterRels(); @@ -38,6 +46,6 @@ std::string GeneratePresProps(); std::string GenerateViewProps(); std::string GenerateTableStyles(); std::string GenerateCoreProps(); -std::string GenerateAppProps(); +std::string GenerateAppProps(size_t slideCount); } // namespace pagx diff --git a/src/pagx/ppt/PPTExporter.cpp b/src/pagx/ppt/PPTExporter.cpp index b405d6a29b..1a63ca45f6 100644 --- a/src/pagx/ppt/PPTExporter.cpp +++ b/src/pagx/ppt/PPTExporter.cpp @@ -946,18 +946,76 @@ std::string BuildSlideXml(PAGXDocument& doc, const PPTExportOptions& options, return slide; } -// Writes every OOXML part (boilerplate XML + media) into the already-opened zip -// handle. Returns false on the first write failure so callers can discard the -// partial archive. -bool WriteZipEntries(zipFile zf, PPTWriterContext& context, PAGXDocument& doc, - const std::string& slide) { +// One serialized slide: the slide XML plus the media context that XML +// references. The context is kept alive because WriteZipEntries streams the +// slide's media (and its slideN.xml.rels) out of it; the LayoutContext that +// produced the XML is short-lived and freed as soon as the XML is built. +struct SlideBuild { + std::string xml; + std::unique_ptr context; +}; + +// Serializes every document into its own slide. Returns an empty vector when the +// input is invalid (empty list or a nullptr entry) so callers can bail out. Each +// slide's media numbering is offset by the running image total so all slides can +// share the single ppt/media/ directory without file-name collisions. +std::vector BuildSlides(const std::vector& documents, + const PPTExportOptions& options) { + std::vector slides; + if (documents.empty()) { + return slides; + } + slides.reserve(documents.size()); + int imageBase = 0; + for (auto* doc : documents) { + if (doc == nullptr) { + return {}; + } + if (!doc->isLayoutApplied()) { + doc->applyLayout(); + } + SlideBuild slide; + slide.context = std::make_unique(imageBase); + // The LayoutContext only backs the writer while the slide XML is produced; + // once BuildSlideXml returns the XML is self-contained, so scope it to this + // iteration instead of holding one per slide alive for the whole deck. + auto layoutContext = std::make_unique(options.fontConfig); + slide.xml = BuildSlideXml(*doc, options, *slide.context, layoutContext.get()); + imageBase += static_cast(slide.context->images().size()); + slides.push_back(std::move(slide)); + } + return slides; +} + +// Writes every OOXML part (boilerplate XML + per-slide XML + media) into the +// already-opened zip handle. `slideW` / `slideH` are the deck-wide slide size +// taken from the first document. Returns false on the first write failure so +// callers can discard the partial archive. +bool WriteZipEntries(zipFile zf, const std::vector& slides, float slideW, + float slideH) { + size_t slideCount = slides.size(); + bool hasPNG = false; + bool hasJPEG = false; + for (const auto& slide : slides) { + hasPNG = hasPNG || slide.context->hasPNG(); + hasJPEG = hasJPEG || slide.context->hasJPEG(); + } + bool ok = true; - ok = ok && AddZipString(zf, "[Content_Types].xml", GenerateContentTypes(context)); + ok = ok && AddZipString(zf, "[Content_Types].xml", + GenerateContentTypes(hasPNG, hasJPEG, slideCount)); ok = ok && AddZipString(zf, "_rels/.rels", GenerateRootRels()); - ok = ok && AddZipString(zf, "ppt/presentation.xml", GeneratePresentation(doc.width, doc.height)); - ok = ok && AddZipString(zf, "ppt/_rels/presentation.xml.rels", GeneratePresentationRels()); - ok = ok && AddZipString(zf, "ppt/slides/slide1.xml", slide); - ok = ok && AddZipString(zf, "ppt/slides/_rels/slide1.xml.rels", GenerateSlideRels(context)); + ok = ok && + AddZipString(zf, "ppt/presentation.xml", GeneratePresentation(slideW, slideH, slideCount)); + ok = ok && AddZipString(zf, "ppt/_rels/presentation.xml.rels", + GeneratePresentationRels(slideCount)); + for (size_t i = 0; i < slideCount && ok; i++) { + std::string n = std::to_string(i + 1); + std::string slidePath = "ppt/slides/slide" + n + ".xml"; + std::string slideRelsPath = "ppt/slides/_rels/slide" + n + ".xml.rels"; + ok = ok && AddZipString(zf, slidePath.c_str(), slides[i].xml); + ok = ok && AddZipString(zf, slideRelsPath.c_str(), GenerateSlideRels(*slides[i].context)); + } ok = ok && AddZipString(zf, "ppt/slideMasters/slideMaster1.xml", GenerateSlideMaster()); ok = ok && AddZipString(zf, "ppt/slideMasters/_rels/slideMaster1.xml.rels", GenerateSlideMasterRels()); @@ -969,21 +1027,26 @@ bool WriteZipEntries(zipFile zf, PPTWriterContext& context, PAGXDocument& doc, ok = ok && AddZipString(zf, "ppt/viewProps.xml", GenerateViewProps()); ok = ok && AddZipString(zf, "ppt/tableStyles.xml", GenerateTableStyles()); ok = ok && AddZipString(zf, "docProps/core.xml", GenerateCoreProps()); - ok = ok && AddZipString(zf, "docProps/app.xml", GenerateAppProps()); + ok = ok && AddZipString(zf, "docProps/app.xml", GenerateAppProps(slideCount)); - for (const auto& img : context.images()) { - if (!ok) { - break; - } - if (img.cachedData && img.cachedData->size() > 0) { - // minizip's zipWriteInFileInZip takes a 32-bit length. Reject entries - // that would be silently truncated rather than writing a corrupt PPTX. - if (img.cachedData->size() > std::numeric_limits::max()) { - ok = false; + for (const auto& slide : slides) { + for (const auto& img : slide.context->images()) { + if (!ok) { break; } - ok = AddZipEntry(zf, img.mediaPath.c_str(), img.cachedData->bytes(), - static_cast(img.cachedData->size())); + if (img.cachedData && img.cachedData->size() > 0) { + // minizip's zipWriteInFileInZip takes a 32-bit length. Reject entries + // that would be silently truncated rather than writing a corrupt PPTX. + if (img.cachedData->size() > std::numeric_limits::max()) { + ok = false; + break; + } + ok = AddZipEntry(zf, img.mediaPath.c_str(), img.cachedData->bytes(), + static_cast(img.cachedData->size())); + } + } + if (!ok) { + break; } } return ok; @@ -996,14 +1059,15 @@ bool WriteZipEntries(zipFile zf, PPTWriterContext& context, PAGXDocument& doc, //============================================================================== bool PPTExporter::ToFile(PAGXDocument& doc, const std::string& filePath, const Options& options) { - if (!doc.isLayoutApplied()) { - doc.applyLayout(); - } - - auto layoutContext = std::make_unique(options.fontConfig); + return ToFile(std::vector{&doc}, filePath, options); +} - PPTWriterContext context; - std::string slide = BuildSlideXml(doc, options, context, layoutContext.get()); +bool PPTExporter::ToFile(const std::vector& documents, const std::string& filePath, + const Options& options) { + auto slides = BuildSlides(documents, options); + if (slides.empty()) { + return false; + } // Write ZIP via minizip zipFile zf = zipOpen(filePath.c_str(), APPEND_STATUS_CREATE); @@ -1011,7 +1075,7 @@ bool PPTExporter::ToFile(PAGXDocument& doc, const std::string& filePath, const O return false; } - bool ok = WriteZipEntries(zf, context, doc, slide); + bool ok = WriteZipEntries(zf, slides, documents.front()->width, documents.front()->height); if (!ok) { zipClose(zf, nullptr); @@ -1033,14 +1097,15 @@ bool PPTExporter::ToFile(PAGXDocument& doc, const std::string& filePath, const O //============================================================================== std::shared_ptr PPTExporter::ToData(PAGXDocument& doc, const Options& options) { - if (!doc.isLayoutApplied()) { - doc.applyLayout(); - } - - auto layoutContext = std::make_unique(options.fontConfig); + return ToData(std::vector{&doc}, options); +} - PPTWriterContext context; - std::string slide = BuildSlideXml(doc, options, context, layoutContext.get()); +std::shared_ptr PPTExporter::ToData(const std::vector& documents, + const Options& options) { + auto slides = BuildSlides(documents, options); + if (slides.empty()) { + return nullptr; + } // Assemble the archive into a growable RAM buffer instead of a file on disk. MemZipBuffer memBuffer; @@ -1050,7 +1115,7 @@ std::shared_ptr PPTExporter::ToData(PAGXDocument& doc, const Options& opti return nullptr; } - bool ok = WriteZipEntries(zf, context, doc, slide); + bool ok = WriteZipEntries(zf, slides, documents.front()->width, documents.front()->height); if (zipClose(zf, nullptr) != ZIP_OK) { ok = false; } diff --git a/src/pagx/ppt/PPTWriterContext.h b/src/pagx/ppt/PPTWriterContext.h index f32516c60b..6b4efd3cc3 100644 --- a/src/pagx/ppt/PPTWriterContext.h +++ b/src/pagx/ppt/PPTWriterContext.h @@ -39,6 +39,17 @@ struct ImageEntry { class PPTWriterContext { public: + PPTWriterContext() = default; + + // `imageIndexBase` offsets the media file numbering so that media emitted by + // this context (image{N}.png / .jpeg) never collides with media emitted by an + // earlier slide's context. Every slide in a multi-slide deck writes into the + // shared ppt/media/ directory, so the caller passes the running total of + // images produced by previous slides. Relationship IDs stay per-context (each + // slide has its own slideN.xml.rels namespace) so they are not offset. + explicit PPTWriterContext(int imageIndexBase) : _imageIndexBase(imageIndexBase) { + } + int nextShapeId() { return _shapeId++; } @@ -87,7 +98,7 @@ class PPTWriterContext { // addRawImage (pre-encoded PNG blobs with no source Image, e.g. layer bakes / // tiled-pattern bakes). std::string registerImage(const Image* image, std::shared_ptr data, bool jpeg) { - int idx = static_cast(_images.size()) + 1; + int idx = _imageIndexBase + static_cast(_images.size()) + 1; std::string relId = "rId" + std::to_string(_nextRelId++); const char* ext = jpeg ? "jpeg" : "png"; std::string mediaPath = "ppt/media/image" + std::to_string(idx) + "." + ext; @@ -100,6 +111,8 @@ class PPTWriterContext { int _shapeId = 2; // Relationship ID rId1 is reserved for the slideLayout reference. int _nextRelId = 2; + // Running offset applied to media file numbering so slides don't collide. + int _imageIndexBase = 0; bool _hasJPEG = false; bool _hasPNG = false; std::vector _images; diff --git a/test/src/PAGXPPTTest.cpp b/test/src/PAGXPPTTest.cpp index c2ec9fb1e7..220505a6fc 100644 --- a/test/src/PAGXPPTTest.cpp +++ b/test/src/PAGXPPTTest.cpp @@ -5972,6 +5972,163 @@ PAGX_TEST(PAGXPPTTest, ToData_RespectsOptions) { EXPECT_TRUE(HasZipMagic(data.get())); } +//============================================================================== +// PPTExporter — multi-slide export +//============================================================================== + +// Counts non-overlapping occurrences of `needle` in `haystack`. Used to spot- +// check how many times a part name appears in the raw ZIP bytes. +static size_t CountOccurrences(const std::string& haystack, const std::string& needle) { + if (needle.empty()) { + return 0; + } + size_t count = 0; + for (size_t pos = haystack.find(needle); pos != std::string::npos; + pos = haystack.find(needle, pos + needle.size())) { + ++count; + } + return count; +} + +// Builds a one-rectangle document with a distinct fill colour so each slide in a +// deck is visually different. +static std::shared_ptr MakeColoredPPTDoc(float r, float g, float b) { + auto doc = pagx::PAGXDocument::Make(400, 300); + auto* layer = doc->makeNode(); + auto* rect = doc->makeNode(); + rect->position = {200, 150}; + rect->size = {200, 100}; + layer->contents.push_back(rect); + layer->contents.push_back(MakeSolidFill(doc.get(), {r, g, b, 1.0f})); + doc->layers.push_back(layer); + return doc; +} + +PAGX_TEST(PAGXPPTTest, MultiPage_ToFileThreeSlides) { + auto page1 = MakeColoredPPTDoc(1.0f, 0.0f, 0.0f); + auto page2 = MakeColoredPPTDoc(0.0f, 1.0f, 0.0f); + auto page3 = MakeColoredPPTDoc(0.0f, 0.0f, 1.0f); + std::vector docs = {page1.get(), page2.get(), page3.get()}; + + auto path = PPTOutDir() + "/multi_page_three.pptx"; + ASSERT_TRUE(pagx::PPTExporter::ToFile(docs, path)); + EXPECT_TRUE(std::filesystem::exists(path)); + EXPECT_GT(std::filesystem::file_size(path), 0u); + + auto bytes = ReadFileBytes(path); + ASSERT_FALSE(bytes.empty()); + EXPECT_NE(bytes.find("ppt/slides/slide1.xml"), std::string::npos); + EXPECT_NE(bytes.find("ppt/slides/slide2.xml"), std::string::npos); + EXPECT_NE(bytes.find("ppt/slides/slide3.xml"), std::string::npos); +} + +PAGX_TEST(PAGXPPTTest, MultiPage_ToDataContainsAllSlideParts) { + auto page1 = MakeColoredPPTDoc(1.0f, 0.0f, 0.0f); + auto page2 = MakeColoredPPTDoc(0.0f, 1.0f, 0.0f); + std::vector docs = {page1.get(), page2.get()}; + + auto data = pagx::PPTExporter::ToData(docs); + ASSERT_NE(data, nullptr); + EXPECT_TRUE(HasZipMagic(data.get())); + + std::string bytes(reinterpret_cast(data->bytes()), data->size()); + // Both slide parts and both slide-rels parts must be present as local file + // entries in the archive. + EXPECT_NE(bytes.find("ppt/slides/slide1.xml"), std::string::npos); + EXPECT_NE(bytes.find("ppt/slides/slide2.xml"), std::string::npos); + EXPECT_NE(bytes.find("ppt/slides/_rels/slide1.xml.rels"), std::string::npos); + EXPECT_NE(bytes.find("ppt/slides/_rels/slide2.xml.rels"), std::string::npos); + // A third slide part must not exist for a two-document deck. + EXPECT_EQ(bytes.find("ppt/slides/slide3.xml"), std::string::npos); +} + +// A one-element vector must produce byte-identical output to the single-document +// overload, proving the wrapper simply forwards to the vector path. +PAGX_TEST(PAGXPPTTest, MultiPage_SingleElementMatchesSingleOverload) { + auto single = MakeSimplePPTDoc(); + auto vectorDoc = MakeSimplePPTDoc(); + + auto singleData = pagx::PPTExporter::ToData(*single); + ASSERT_NE(singleData, nullptr); + + std::vector docs = {vectorDoc.get()}; + auto vectorData = pagx::PPTExporter::ToData(docs); + ASSERT_NE(vectorData, nullptr); + + ASSERT_EQ(singleData->size(), vectorData->size()); + EXPECT_EQ(0, std::memcmp(singleData->data(), vectorData->data(), vectorData->size())); +} + +// Media from different slides must land in distinct ppt/media/ files so nothing +// is overwritten inside the shared media directory. +PAGX_TEST(PAGXPPTTest, MultiPage_ImageMediaNamesAreUnique) { + auto MakeImageDoc = []() { + auto doc = pagx::PAGXDocument::Make(400, 300); + auto* layer = doc->makeNode(); + auto* rect = doc->makeNode(); + rect->position = {200, 150}; + rect->size = {200, 150}; + auto* image = MakeTestPNGImage(doc.get()); + auto* pattern = doc->makeNode(); + pattern->image = image; + pattern->matrix = {1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f}; + auto* fill = doc->makeNode(); + fill->color = pattern; + layer->contents.push_back(rect); + layer->contents.push_back(fill); + doc->layers.push_back(layer); + return doc; + }; + + auto page1 = MakeImageDoc(); + auto page2 = MakeImageDoc(); + std::vector docs = {page1.get(), page2.get()}; + + auto data = pagx::PPTExporter::ToData(docs); + ASSERT_NE(data, nullptr); + std::string bytes(reinterpret_cast(data->bytes()), data->size()); + + // Each slide contributes its own media file with a distinct index; the second + // slide's image must not reuse image1's path. + EXPECT_NE(bytes.find("ppt/media/image1.png"), std::string::npos); + EXPECT_NE(bytes.find("ppt/media/image2.png"), std::string::npos); + // The full media path "ppt/media/image1.png" is stored once in each entry's + // local file header and once in the central directory — so exactly twice. + // Seeing it more than that would mean a name collision wrote it repeatedly. + EXPECT_EQ(CountOccurrences(bytes, "ppt/media/image1.png"), 2u); + EXPECT_EQ(CountOccurrences(bytes, "ppt/media/image2.png"), 2u); +} + +PAGX_TEST(PAGXPPTTest, MultiPage_EmptyListFails) { + std::vector docs; + auto path = PPTOutDir() + "/multi_page_empty.pptx"; + EXPECT_FALSE(pagx::PPTExporter::ToFile(docs, path)); + EXPECT_EQ(pagx::PPTExporter::ToData(docs), nullptr); +} + +PAGX_TEST(PAGXPPTTest, MultiPage_NullEntryFails) { + auto page1 = MakeSimplePPTDoc(); + std::vector docs = {page1.get(), nullptr}; + auto path = PPTOutDir() + "/multi_page_null.pptx"; + EXPECT_FALSE(pagx::PPTExporter::ToFile(docs, path)); + EXPECT_EQ(pagx::PPTExporter::ToData(docs), nullptr); +} + +// Slides may declare different canvas sizes; the deck adopts the first document's +// size and still produces a valid archive with a slide per document. +PAGX_TEST(PAGXPPTTest, MultiPage_MixedDocumentSizes) { + auto page1 = pagx::PAGXDocument::Make(800, 600); + auto page2 = MakeColoredPPTDoc(0.2f, 0.4f, 0.6f); // 400x300 + std::vector docs = {page1.get(), page2.get()}; + + auto data = pagx::PPTExporter::ToData(docs); + ASSERT_NE(data, nullptr); + EXPECT_TRUE(HasZipMagic(data.get())); + + std::string bytes(reinterpret_cast(data->bytes()), data->size()); + EXPECT_NE(bytes.find("ppt/slides/slide2.xml"), std::string::npos); +} + } // namespace pag #endif From c213ea2b4f3abb3fd374a40eae942c23a7d759c2 Mon Sep 17 00:00:00 2001 From: OnionsYu Date: Mon, 27 Jul 2026 12:43:21 +0800 Subject: [PATCH 03/21] Remove single-document PPTExporter overloads in favor of the multi-document API. --- include/pagx/PPTExporter.h | 30 +++------------------ src/cli/CommandExport.cpp | 2 +- src/pagx/ppt/PPTExporter.cpp | 8 ------ test/src/PAGXPPTTest.cpp | 51 ++++++++++++++++++------------------ 4 files changed, 30 insertions(+), 61 deletions(-) diff --git a/include/pagx/PPTExporter.h b/include/pagx/PPTExporter.h index 6a8a1a1954..8efa7b41ee 100644 --- a/include/pagx/PPTExporter.h +++ b/include/pagx/PPTExporter.h @@ -108,21 +108,9 @@ class PPTExporter { public: using Options = PPTExportOptions; - /** - * Exports a single PAGXDocument to a one-slide PPTX file at the specified path. - * @param document the PAGXDocument to export. Passed as non-const because internal layout - * computation may cache intermediate results. - * @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. - */ - static bool ToFile(PAGXDocument& document, const std::string& filePath, - const Options& options = {}); - /** * Exports a sequence of PAGXDocuments to a multi-slide PPTX file at the specified path. Each - * document produces one slide in the order given. + * 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. @@ -134,21 +122,11 @@ class PPTExporter { static bool ToFile(const std::vector& documents, const std::string& filePath, const Options& options = {}); - /** - * Exports a single PAGXDocument to an in-memory one-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. - * @param document the PAGXDocument to export. Passed as non-const because internal layout - * computation may cache intermediate results. - * @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 could not be serialized. - */ - static std::shared_ptr ToData(PAGXDocument& document, const Options& options = {}); - /** * Exports a sequence of PAGXDocuments to an in-memory multi-slide PPTX buffer without writing to - * disk. Each document produces one slide in the order given. + * 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. diff --git a/src/cli/CommandExport.cpp b/src/cli/CommandExport.cpp index 137768d1ff..54269e7e7f 100644 --- a/src/cli/CommandExport.cpp +++ b/src/cli/CommandExport.cpp @@ -213,7 +213,7 @@ static int ExportToPPT(const ExportOptions& options) { pptOptions.convertTextToPath = options.textToPath; pptOptions.bakeUnsupported = options.pptBakeUnsupported; - if (!PPTExporter::ToFile(*document, options.outputFile, pptOptions)) { + if (!PPTExporter::ToFile({document.get()}, options.outputFile, pptOptions)) { std::cerr << "pagx export: error: failed to write '" << options.outputFile << "'\n"; return 1; } diff --git a/src/pagx/ppt/PPTExporter.cpp b/src/pagx/ppt/PPTExporter.cpp index 1a63ca45f6..9e4677109d 100644 --- a/src/pagx/ppt/PPTExporter.cpp +++ b/src/pagx/ppt/PPTExporter.cpp @@ -1058,10 +1058,6 @@ bool WriteZipEntries(zipFile zf, const std::vector& slides, float sl // PPTExporter::ToFile //============================================================================== -bool PPTExporter::ToFile(PAGXDocument& doc, const std::string& filePath, const Options& options) { - return ToFile(std::vector{&doc}, filePath, options); -} - bool PPTExporter::ToFile(const std::vector& documents, const std::string& filePath, const Options& options) { auto slides = BuildSlides(documents, options); @@ -1096,10 +1092,6 @@ bool PPTExporter::ToFile(const std::vector& documents, const std: // PPTExporter::ToData //============================================================================== -std::shared_ptr PPTExporter::ToData(PAGXDocument& doc, const Options& options) { - return ToData(std::vector{&doc}, options); -} - std::shared_ptr PPTExporter::ToData(const std::vector& documents, const Options& options) { auto slides = BuildSlides(documents, options); diff --git a/test/src/PAGXPPTTest.cpp b/test/src/PAGXPPTTest.cpp index 220505a6fc..6285a35e8d 100644 --- a/test/src/PAGXPPTTest.cpp +++ b/test/src/PAGXPPTTest.cpp @@ -79,7 +79,7 @@ static std::string PPTOutDir() { static bool ExportAndVerify(pagx::PAGXDocument& doc, const std::string& name, const pagx::PPTExportOptions& options = {}) { auto path = PPTOutDir() + "/" + name + ".pptx"; - bool ok = pagx::PPTExporter::ToFile(doc, path, options); + bool ok = pagx::PPTExporter::ToFile({&doc}, path, options); if (ok) { ok = std::filesystem::exists(path) && std::filesystem::file_size(path) > 0; } @@ -128,7 +128,7 @@ PAGX_TEST(PAGXPPTTest, PPTExport_FromSVG) { ASSERT_NE(reimported, nullptr) << baseName << " PAGX re-import failed"; auto pptxPath = outDir + "/" + baseName + ".pptx"; - ASSERT_TRUE(pagx::PPTExporter::ToFile(*reimported, pptxPath)) + ASSERT_TRUE(pagx::PPTExporter::ToFile({reimported.get()}, pptxPath)) << baseName << " PPT export failed"; EXPECT_TRUE(std::filesystem::exists(pptxPath)); EXPECT_GT(std::filesystem::file_size(pptxPath), 0u) << baseName << " PPTX file is empty"; @@ -1478,7 +1478,7 @@ PAGX_TEST(PAGXPPTTest, MultipleElementsInLayer) { PAGX_TEST(PAGXPPTTest, InvalidFilePath) { auto doc = pagx::PAGXDocument::Make(400, 300); - EXPECT_FALSE(pagx::PPTExporter::ToFile(*doc, "/nonexistent/dir/file.pptx")); + EXPECT_FALSE(pagx::PPTExporter::ToFile({doc.get()}, "/nonexistent/dir/file.pptx")); } PAGX_TEST(PAGXPPTTest, LargeCanvas) { @@ -2033,11 +2033,11 @@ PAGX_TEST(PAGXPPTTest, MaskNoBakeProducesSmaller) { pagx::PPTExportOptions bakedOpts; bakedOpts.bakeUnsupported = true; - ASSERT_TRUE(pagx::PPTExporter::ToFile(*doc, bakedPath, bakedOpts)); + ASSERT_TRUE(pagx::PPTExporter::ToFile({doc.get()}, bakedPath, bakedOpts)); pagx::PPTExportOptions vectorOpts; vectorOpts.bakeUnsupported = false; - ASSERT_TRUE(pagx::PPTExporter::ToFile(*doc, vectorPath, vectorOpts)); + ASSERT_TRUE(pagx::PPTExporter::ToFile({doc.get()}, vectorPath, vectorOpts)); auto bakedSize = std::filesystem::file_size(bakedPath); auto vectorSize = std::filesystem::file_size(vectorPath); @@ -5883,7 +5883,7 @@ static std::shared_ptr MakeSimplePPTDoc() { PAGX_TEST(PAGXPPTTest, ToData_SimpleDocument) { auto doc = MakeSimplePPTDoc(); - auto data = pagx::PPTExporter::ToData(*doc); + auto data = pagx::PPTExporter::ToData({doc.get()}); ASSERT_NE(data, nullptr); EXPECT_GT(data->size(), 0u); EXPECT_TRUE(HasZipMagic(data.get())); @@ -5891,7 +5891,7 @@ PAGX_TEST(PAGXPPTTest, ToData_SimpleDocument) { PAGX_TEST(PAGXPPTTest, ToData_EmptyDocument) { auto doc = pagx::PAGXDocument::Make(800, 600); - auto data = pagx::PPTExporter::ToData(*doc); + auto data = pagx::PPTExporter::ToData({doc.get()}); ASSERT_NE(data, nullptr); EXPECT_GT(data->size(), 0u); EXPECT_TRUE(HasZipMagic(data.get())); @@ -5903,12 +5903,12 @@ PAGX_TEST(PAGXPPTTest, ToData_EmptyDocument) { PAGX_TEST(PAGXPPTTest, ToData_MatchesToFile) { auto doc = MakeSimplePPTDoc(); - auto data = pagx::PPTExporter::ToData(*doc); + auto data = pagx::PPTExporter::ToData({doc.get()}); ASSERT_NE(data, nullptr); ASSERT_GT(data->size(), 0u); auto path = PPTOutDir() + "/to_data_parity.pptx"; - ASSERT_TRUE(pagx::PPTExporter::ToFile(*doc, path)); + ASSERT_TRUE(pagx::PPTExporter::ToFile({doc.get()}, path)); auto fileBytes = ReadFileBytes(path); ASSERT_FALSE(fileBytes.empty()); @@ -5936,12 +5936,12 @@ PAGX_TEST(PAGXPPTTest, ToData_WithImageMedia) { layer->contents.push_back(fill); doc->layers.push_back(layer); - auto data = pagx::PPTExporter::ToData(*doc); + auto data = pagx::PPTExporter::ToData({doc.get()}); ASSERT_NE(data, nullptr); EXPECT_TRUE(HasZipMagic(data.get())); auto path = PPTOutDir() + "/to_data_with_image.pptx"; - ASSERT_TRUE(pagx::PPTExporter::ToFile(*doc, path)); + ASSERT_TRUE(pagx::PPTExporter::ToFile({doc.get()}, path)); auto fileBytes = ReadFileBytes(path); ASSERT_FALSE(fileBytes.empty()); ASSERT_EQ(data->size(), fileBytes.size()); @@ -5952,7 +5952,7 @@ PAGX_TEST(PAGXPPTTest, ToData_WithImageMedia) { // clear, so we can spot-check the slide part without pulling in an unzip lib. PAGX_TEST(PAGXPPTTest, ToData_ContainsSlidePart) { auto doc = MakeSimplePPTDoc(); - auto data = pagx::PPTExporter::ToData(*doc); + auto data = pagx::PPTExporter::ToData({doc.get()}); ASSERT_NE(data, nullptr); std::string bytes(reinterpret_cast(data->bytes()), data->size()); @@ -5967,7 +5967,7 @@ PAGX_TEST(PAGXPPTTest, ToData_RespectsOptions) { pagx::PPTExportOptions options; options.bakeUnsupported = false; options.convertTextToPath = true; - auto data = pagx::PPTExporter::ToData(*doc, options); + auto data = pagx::PPTExporter::ToData({doc.get()}, options); ASSERT_NE(data, nullptr); EXPECT_TRUE(HasZipMagic(data.get())); } @@ -6042,21 +6042,20 @@ PAGX_TEST(PAGXPPTTest, MultiPage_ToDataContainsAllSlideParts) { EXPECT_EQ(bytes.find("ppt/slides/slide3.xml"), std::string::npos); } -// A one-element vector must produce byte-identical output to the single-document -// overload, proving the wrapper simply forwards to the vector path. -PAGX_TEST(PAGXPPTTest, MultiPage_SingleElementMatchesSingleOverload) { - auto single = MakeSimplePPTDoc(); - auto vectorDoc = MakeSimplePPTDoc(); - - auto singleData = pagx::PPTExporter::ToData(*single); - ASSERT_NE(singleData, nullptr); +// A one-element list yields a valid single-slide deck: exactly one slide part, +// and no second slide. This is the common single-document case expressed through +// the multi-document API. +PAGX_TEST(PAGXPPTTest, MultiPage_SingleElementYieldsOneSlide) { + auto doc = MakeSimplePPTDoc(); + std::vector docs = {doc.get()}; - std::vector docs = {vectorDoc.get()}; - auto vectorData = pagx::PPTExporter::ToData(docs); - ASSERT_NE(vectorData, nullptr); + auto data = pagx::PPTExporter::ToData(docs); + ASSERT_NE(data, nullptr); + EXPECT_TRUE(HasZipMagic(data.get())); - ASSERT_EQ(singleData->size(), vectorData->size()); - EXPECT_EQ(0, std::memcmp(singleData->data(), vectorData->data(), vectorData->size())); + std::string bytes(reinterpret_cast(data->bytes()), data->size()); + EXPECT_NE(bytes.find("ppt/slides/slide1.xml"), std::string::npos); + EXPECT_EQ(bytes.find("ppt/slides/slide2.xml"), std::string::npos); } // Media from different slides must land in distinct ppt/media/ files so nothing From 1b4f0ba99e3069c073814c5d67d029a2908b3fa9 Mon Sep 17 00:00:00 2001 From: OnionsYu Date: Mon, 27 Jul 2026 20:36:00 +0800 Subject: [PATCH 04/21] Support exporting multiple PAGX inputs into a multi-slide PPTX deck from the pagx export CLI. --- .codebuddy/skills/pagx/references/cli.md | 12 +++-- src/cli/CommandExport.cpp | 60 ++++++++++++++------- test/src/PAGXCliTest.cpp | 67 ++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 22 deletions(-) diff --git a/.codebuddy/skills/pagx/references/cli.md b/.codebuddy/skills/pagx/references/cli.md index d6a9354e8d..99ae2ec8f4 100644 --- a/.codebuddy/skills/pagx/references/cli.md +++ b/.codebuddy/skills/pagx/references/cli.md @@ -473,7 +473,12 @@ 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 `.`. +alone fails). Once the format is known, `--output` defaults to `.`. + +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 @@ -481,6 +486,7 @@ 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 @@ -489,8 +495,8 @@ pagx export --input icon.pagx --output out.html # PAGX to HTML | Option | Description | |--------|-------------| -| `--input ` | Input PAGX file (required) | -| `--output ` | Output file (default: `.`) | +| `--input ` | Input PAGX file (required). Repeat to add more slides — **pptx only**; each `--input` becomes one slide in deck order | +| `--output ` | Output file (default: `.`) | | `--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) | | `--svg-indent ` | Indentation spaces (default: 2, valid range: 0–16) | diff --git a/src/cli/CommandExport.cpp b/src/cli/CommandExport.cpp index 54269e7e7f..ee237939ee 100644 --- a/src/cli/CommandExport.cpp +++ b/src/cli/CommandExport.cpp @@ -20,7 +20,9 @@ #include #include #include +#include #include +#include #include "cli/CliUtils.h" #include "pagx/HTMLExporter.h" #include "pagx/PAGXImporter.h" @@ -30,7 +32,7 @@ namespace pagx::cli { struct ExportOptions { - std::string inputFile = {}; + std::vector inputFiles = {}; std::string outputFile = {}; std::string format = {}; int svgIndent = 2; @@ -46,8 +48,9 @@ static void PrintUsage() { << "Export a PAGX file to another format.\n" << "\n" << "Options:\n" - << " --input Input PAGX file (required)\n" - << " --output Output file (default: .)\n" + << " --input Input PAGX file (required; repeat to add more slides,\n" + << " pptx only). Each --input becomes one slide in the deck.\n" + << " --output Output file (default: .)\n" << " --format Output format (svg, pptx, html; inferred from --output " "extension)\n" << " --text-to-path Convert text to path geometry (default: native text)\n" @@ -78,6 +81,9 @@ static void PrintUsage() { << " 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" @@ -91,7 +97,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) { @@ -124,7 +130,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; } @@ -138,15 +144,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; } @@ -170,9 +181,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) { @@ -194,26 +206,36 @@ 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> 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 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; - if (!PPTExporter::ToFile({document.get()}, options.outputFile, pptOptions)) { + if (!PPTExporter::ToFile(documentPtrs, options.outputFile, pptOptions)) { std::cerr << "pagx export: error: failed to write '" << options.outputFile << "'\n"; return 1; } diff --git a/test/src/PAGXCliTest.cpp b/test/src/PAGXCliTest.cpp index 112380b824..3d64eb7be6 100644 --- a/test/src/PAGXCliTest.cpp +++ b/test/src/PAGXCliTest.cpp @@ -1518,6 +1518,61 @@ CLI_TEST(PAGXCliTest, Export_PagxToPptx_ValidateSimple) { EXPECT_GT(std::filesystem::file_size(outputPath), 0u); } +// Multiple --input flags produce a multi-slide deck, one slide per input in the +// order given. slide2.xml only appears when the second document became its own slide. +CLI_TEST(PAGXCliTest, Export_PagxToPptx_MultipleInputs) { + auto firstInput = TestResourcePath("render_basic.pagx"); + auto secondInput = TestResourcePath("render_gradient.pagx"); + auto outputPath = TempDir() + "/ExportPPTX_MultiSlide.pptx"; + auto ret = CallRun(pagx::cli::RunExport, {"export", "--input", firstInput, "--input", secondInput, + "--output", outputPath}); + EXPECT_EQ(ret, 0); + ASSERT_TRUE(std::filesystem::exists(outputPath)); + auto bytes = ReadFile(outputPath); + EXPECT_NE(bytes.find("ppt/slides/slide1.xml"), std::string::npos); + EXPECT_NE(bytes.find("ppt/slides/slide2.xml"), std::string::npos); + EXPECT_EQ(bytes.find("ppt/slides/slide3.xml"), std::string::npos); +} + +// Repeating --input three times yields three slides; the default output name is +// derived from the first input. +CLI_TEST(PAGXCliTest, Export_PagxToPptx_ThreeInputsDefaultOutput) { + auto firstInput = CopyToTemp("render_basic.pagx", "ExportPPTXThree.pagx"); + auto secondInput = TestResourcePath("render_gradient.pagx"); + auto thirdInput = TestResourcePath("render_text.pagx"); + auto ret = CallRun(pagx::cli::RunExport, {"export", "--format", "pptx", "--input", firstInput, + "--input", secondInput, "--input", thirdInput}); + EXPECT_EQ(ret, 0); + auto defaultOutput = TempDir() + "/ExportPPTXThree.pptx"; + ASSERT_TRUE(std::filesystem::exists(defaultOutput)); + auto bytes = ReadFile(defaultOutput); + EXPECT_NE(bytes.find("ppt/slides/slide3.xml"), std::string::npos); + EXPECT_EQ(bytes.find("ppt/slides/slide4.xml"), std::string::npos); +} + +// A single --input still yields a valid one-slide deck (no slide2.xml), confirming +// the vector refactor did not regress the single-document path. +CLI_TEST(PAGXCliTest, Export_PagxToPptx_SingleInputOneSlide) { + auto inputPath = TestResourcePath("render_basic.pagx"); + auto outputPath = TempDir() + "/ExportPPTX_SingleSlide.pptx"; + auto ret = + CallRun(pagx::cli::RunExport, {"export", "--input", inputPath, "--output", outputPath}); + EXPECT_EQ(ret, 0); + auto bytes = ReadFile(outputPath); + EXPECT_NE(bytes.find("ppt/slides/slide1.xml"), std::string::npos); + EXPECT_EQ(bytes.find("ppt/slides/slide2.xml"), std::string::npos); +} + +// If one of several inputs fails to load, the whole export aborts with an error +// rather than silently dropping the bad slide. +CLI_TEST(PAGXCliTest, Export_PagxToPptx_MultipleInputsOneMissing) { + auto firstInput = TestResourcePath("render_basic.pagx"); + auto outputPath = TempDir() + "/ExportPPTX_MultiMissing.pptx"; + auto ret = CallRun(pagx::cli::RunExport, {"export", "--input", firstInput, "--input", + "nonexistent.pagx", "--output", outputPath}); + EXPECT_NE(ret, 0); +} + #endif // PAG_BUILD_PPT CLI_TEST(PAGXCliTest, Export_NoConvertTextToPath) { @@ -1560,6 +1615,18 @@ CLI_TEST(PAGXCliTest, Export_WriteFailure) { EXPECT_NE(ret, 0); } +// Multiple --input flags are only meaningful for the multi-slide pptx deck; every +// other format takes a single document, so a repeated --input is rejected up front. +CLI_TEST(PAGXCliTest, Export_MultipleInputsRejectedForSvg) { + auto firstInput = TestResourcePath("render_basic.pagx"); + auto secondInput = TestResourcePath("render_gradient.pagx"); + auto outputPath = TempDir() + "/ExportMultiSvg.svg"; + auto ret = CallRun(pagx::cli::RunExport, {"export", "--input", firstInput, "--input", secondInput, + "--output", outputPath}); + EXPECT_NE(ret, 0); + EXPECT_FALSE(std::filesystem::exists(outputPath)); +} + CLI_TEST(PAGXCliTest, Import_WriteFailure) { auto svgPath = ExportToSVG("render_basic.pagx", "ImportSVG_WriteFail.svg"); auto outputPath = "/nonexistent_dir_xyz/output.pagx"; From 13af07ed0bd522ebd67ff963f330b07a5f190e5b Mon Sep 17 00:00:00 2001 From: OnionsYu Date: Mon, 27 Jul 2026 20:37:38 +0800 Subject: [PATCH 05/21] Reformat PPTExporter include ordering and long lines to match code style. --- src/pagx/ppt/PPTExporter.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pagx/ppt/PPTExporter.cpp b/src/pagx/ppt/PPTExporter.cpp index 9e4677109d..d3f25a89e4 100644 --- a/src/pagx/ppt/PPTExporter.cpp +++ b/src/pagx/ppt/PPTExporter.cpp @@ -42,13 +42,13 @@ #include "pagx/ppt/PPTGeomEmitter.h" #include "pagx/ppt/PPTWriter.h" #include "pagx/ppt/PPTWriterContext.h" +#include "pagx/types/Data.h" #include "pagx/utils/ExporterUtils.h" #include "pagx/utils/RasterUtils.h" #include "pagx/utils/StringParser.h" #include "pagx/utils/StrokeGeometryUtils.h" #include "pagx/utils/TextUtils.h" #include "pagx/xml/XMLBuilder.h" -#include "pagx/types/Data.h" #include "renderer/LayerBuilder.h" #include "tgfx/layers/DisplayList.h" #include "zip.h" @@ -1002,13 +1002,13 @@ bool WriteZipEntries(zipFile zf, const std::vector& slides, float sl } bool ok = true; - ok = ok && AddZipString(zf, "[Content_Types].xml", - GenerateContentTypes(hasPNG, hasJPEG, slideCount)); + ok = ok && + AddZipString(zf, "[Content_Types].xml", GenerateContentTypes(hasPNG, hasJPEG, slideCount)); ok = ok && AddZipString(zf, "_rels/.rels", GenerateRootRels()); ok = ok && AddZipString(zf, "ppt/presentation.xml", GeneratePresentation(slideW, slideH, slideCount)); - ok = ok && AddZipString(zf, "ppt/_rels/presentation.xml.rels", - GeneratePresentationRels(slideCount)); + ok = ok && + AddZipString(zf, "ppt/_rels/presentation.xml.rels", GeneratePresentationRels(slideCount)); for (size_t i = 0; i < slideCount && ok; i++) { std::string n = std::to_string(i + 1); std::string slidePath = "ppt/slides/slide" + n + ".xml"; From 47dafd9ca73f60fec7452d027533dc83c2e28002 Mon Sep 17 00:00:00 2001 From: OnionsYu Date: Wed, 29 Jul 2026 12:14:28 +0800 Subject: [PATCH 06/21] Add PPT export option to ignore Text GlyphRuns and emit native editable text. --- include/pagx/PPTExporter.h | 11 +++++++++ src/cli/CommandExport.cpp | 15 ++++++++++++ src/pagx/ppt/PPTExporter.cpp | 5 +++- src/pagx/ppt/PPTWriter.h | 5 ++-- test/src/PAGXPPTTest.cpp | 44 ++++++++++++++++++++++++++++++++++++ 5 files changed, 77 insertions(+), 3 deletions(-) diff --git a/include/pagx/PPTExporter.h b/include/pagx/PPTExporter.h index 8efa7b41ee..20e51906ad 100644 --- a/include/pagx/PPTExporter.h +++ b/include/pagx/PPTExporter.h @@ -40,6 +40,17 @@ 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. 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 diff --git a/src/cli/CommandExport.cpp b/src/cli/CommandExport.cpp index ee237939ee..33da1f3b1c 100644 --- a/src/cli/CommandExport.cpp +++ b/src/cli/CommandExport.cpp @@ -39,6 +39,7 @@ struct ExportOptions { bool svgNoXmlDeclaration = false; bool textToPath = false; bool pptBakeUnsupported = true; + bool pptIgnoreGlyphRuns = false; }; static void PrintUsage() { @@ -74,6 +75,14 @@ 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.\n" << "\n" << "Examples:\n" << " pagx export --input icon.pagx # PAGX to icon.svg\n" @@ -89,6 +98,9 @@ static void PrintUsage() { << " 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"; } @@ -116,6 +128,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; @@ -234,6 +248,7 @@ static int ExportToPPT(const ExportOptions& options) { PPTExporter::Options pptOptions = {}; pptOptions.convertTextToPath = options.textToPath; pptOptions.bakeUnsupported = options.pptBakeUnsupported; + pptOptions.ignoreGlyphRuns = options.pptIgnoreGlyphRuns; if (!PPTExporter::ToFile(documentPtrs, options.outputFile, pptOptions)) { std::cerr << "pagx export: error: failed to write '" << options.outputFile << "'\n"; diff --git a/src/pagx/ppt/PPTExporter.cpp b/src/pagx/ppt/PPTExporter.cpp index d3f25a89e4..5d522958f5 100644 --- a/src/pagx/ppt/PPTExporter.cpp +++ b/src/pagx/ppt/PPTExporter.cpp @@ -405,7 +405,10 @@ void PPTWriter::emitGeometryWithFs(XMLBuilder& out, const AccumulatedGeometry& e // The convertTextToPath flag has the same effect, but only when GlyphRun // data is available to walk — without glyphRuns there is no geometry to // emit and we must fall back to native text anyway. - if (!text->glyphRuns.empty()) { + // The ignoreGlyphRuns flag is the inverse override: it discards the + // GlyphRun geometry and forces native text even when GlyphRun data is + // present, trading glyph-level fidelity for editable PowerPoint text. + if (!text->glyphRuns.empty() && !_ignoreGlyphRuns) { writeTextAsPath(out, text, localFs, entry.transform, alpha, filters, styles); } else { writeNativeText(out, text, localFs, entry.transform, alpha, filters, styles); diff --git a/src/pagx/ppt/PPTWriter.h b/src/pagx/ppt/PPTWriter.h index b84f1727d7..fb1d3eb7c6 100644 --- a/src/pagx/ppt/PPTWriter.h +++ b/src/pagx/ppt/PPTWriter.h @@ -658,8 +658,8 @@ class PPTWriter { PPTWriter(PPTWriterContext* ctx, PAGXDocument* doc, const PPTExporter::Options& options, LayoutContext* layoutContext) : _ctx(ctx), _doc(doc), _convertTextToPath(options.convertTextToPath), - _bridgeContours(options.bridgeContours), _resolveModifiers(options.resolveModifiers), - _bakeUnsupported(options.bakeUnsupported), + _ignoreGlyphRuns(options.ignoreGlyphRuns), _bridgeContours(options.bridgeContours), + _resolveModifiers(options.resolveModifiers), _bakeUnsupported(options.bakeUnsupported), _rasterScale(std::clamp(options.rasterScale, 0.01f, 4.0f)), _layoutContext(layoutContext), _resolver(doc) { } @@ -690,6 +690,7 @@ class PPTWriter { PPTWriterContext* _ctx = nullptr; PAGXDocument* _doc = nullptr; bool _convertTextToPath = false; + bool _ignoreGlyphRuns = false; bool _bridgeContours = false; bool _resolveModifiers = true; bool _bakeUnsupported = true; diff --git a/test/src/PAGXPPTTest.cpp b/test/src/PAGXPPTTest.cpp index 6285a35e8d..34e859c41e 100644 --- a/test/src/PAGXPPTTest.cpp +++ b/test/src/PAGXPPTTest.cpp @@ -1442,6 +1442,50 @@ PAGX_TEST(PAGXPPTTest, TextWithGlyphRuns) { ASSERT_TRUE(ExportAndVerify(*doc, "text_glyph_runs", options)); } +// ignoreGlyphRuns forces the native-text path even when a Text carries GlyphRun +// data, so the exporter derives editable runs from Text::text instead of +// walking the pre-shaped glyphs. Exercises the branch in emitGeometryWithFs. +PAGX_TEST(PAGXPPTTest, TextIgnoreGlyphRuns) { + auto doc = pagx::PAGXDocument::Make(400, 300); + auto* layer = doc->makeNode(); + + auto* font = doc->makeNode(); + font->unitsPerEm = 1000; + auto* glyph = doc->makeNode(); + glyph->advance = 500; + glyph->path = doc->makeNode(); + glyph->path->moveTo(100, 0); + glyph->path->lineTo(400, 0); + glyph->path->lineTo(400, 800); + glyph->path->lineTo(100, 800); + glyph->path->close(); + font->glyphs.push_back(glyph); + + auto* text = doc->makeNode(); + text->text = "A"; + text->position = {100, 200}; + text->fontSize = 48.0f; + + auto* run = doc->makeNode(); + run->font = font; + run->fontSize = 48.0f; + run->glyphs = {1}; + text->glyphRuns.push_back(run); + + auto* fill = doc->makeNode(); + auto* solid = doc->makeNode(); + solid->color = {0.0f, 0.0f, 0.0f, 1.0f}; + fill->color = solid; + + layer->contents.push_back(text); + layer->contents.push_back(fill); + doc->layers.push_back(layer); + + pagx::PPTExportOptions options; + options.ignoreGlyphRuns = true; + ASSERT_TRUE(ExportAndVerify(*doc, "text_ignore_glyph_runs", options)); +} + PAGX_TEST(PAGXPPTTest, MultipleElementsInLayer) { auto doc = pagx::PAGXDocument::Make(500, 400); auto* layer = doc->makeNode(); From 935603ef206a82ba656b8bf119d2bcc81c8ca7f3 Mon Sep 17 00:00:00 2001 From: OnionsYu Date: Wed, 29 Jul 2026 12:40:16 +0800 Subject: [PATCH 07/21] Preserve GlyphRun pen offset when exporting glyph text as native PPT text so per-line runs no longer overlap. --- src/pagx/ppt/PPTTextWriter.cpp | 30 ++++++++++++++++++++++++++++++ test/src/PAGXPPTTest.cpp | 4 ++++ 2 files changed, 34 insertions(+) diff --git a/src/pagx/ppt/PPTTextWriter.cpp b/src/pagx/ppt/PPTTextWriter.cpp index 0580b8a8fb..b186fe2758 100644 --- a/src/pagx/ppt/PPTTextWriter.cpp +++ b/src/pagx/ppt/PPTTextWriter.cpp @@ -178,6 +178,36 @@ PPTWriter::NativeTextGeometry PPTWriter::computeNativeTextGeometry( const Text* text, Text* mutableText, const FillStrokeInfo& fs, const TextLayoutResult* precomputed) { NativeTextGeometry geom; + + // A Text that carries pre-shaped GlyphRuns encodes its placement in the run's + // pen origin (x, y), exactly like the authoritative writeTextAsPath path, + // which positions glyphs at text->renderPosition() + run offset via WalkGlyphs + // and ignores any modifier TextBox. Sibling per-line Texts commonly share a + // single modifier TextBox while separating their lines purely through + // run->y (e.g. 27 / 63 / 99 for a 36px line height), so routing them through + // the TextBox branch below would collapse every line onto the box's single + // origin. Mirror writeTextAsPath here so native fallback keeps the same + // vertical layout. Only the first run carries the block-level offset. + if (!text->glyphRuns.empty() && text->glyphRuns.front() != nullptr) { + const GlyphRun* firstRun = text->glyphRuns.front(); + auto renderPos = text->renderPosition(); + auto textBounds = precomputed->getTextBounds(mutableText); + if (textBounds.width > 0 && textBounds.height > 0) { + geom.posX = renderPos.x + firstRun->x + textBounds.x; + geom.posY = renderPos.y + firstRun->y + textBounds.y; + geom.estWidth = textBounds.width; + geom.estHeight = textBounds.height; + } else { + float effectiveFontSize = text->renderFontSize(); + geom.estWidth = + static_cast(CountUTF8Characters(text->text)) * effectiveFontSize * 0.6f; + geom.estHeight = effectiveFontSize * 1.4f; + geom.posX = renderPos.x + firstRun->x; + geom.posY = renderPos.y + firstRun->y - effectiveFontSize * 0.85f; + } + return geom; + } + float boxWidth = fs.textBox ? EffectiveTextBoxWidth(fs.textBox) : NAN; float boxHeight = fs.textBox ? EffectiveTextBoxHeight(fs.textBox) : NAN; geom.hasTextBox = fs.textBox && !std::isnan(boxWidth) && boxWidth > 0; diff --git a/test/src/PAGXPPTTest.cpp b/test/src/PAGXPPTTest.cpp index 34e859c41e..a4e31aa255 100644 --- a/test/src/PAGXPPTTest.cpp +++ b/test/src/PAGXPPTTest.cpp @@ -1470,6 +1470,10 @@ PAGX_TEST(PAGXPPTTest, TextIgnoreGlyphRuns) { run->font = font; run->fontSize = 48.0f; run->glyphs = {1}; + // Non-zero run offset: native fallback must fold this into the shape frame + // (mirroring writeTextAsPath) so per-line Texts that separate purely through + // run->y don't collapse onto a shared origin. + run->y = 36.0f; text->glyphRuns.push_back(run); auto* fill = doc->makeNode(); From ca32b6bdcc069f78a5d3c29225bd2cbd7b5c7729 Mon Sep 17 00:00:00 2001 From: OnionsYu Date: Wed, 29 Jul 2026 13:22:30 +0800 Subject: [PATCH 08/21] Suppress TextBox alignment for glyph-origin native text so centered titles are not centered twice. --- src/pagx/ppt/PPTTextWriter.cpp | 49 ++++++++++++++++++++-------------- src/pagx/ppt/PPTWriter.h | 6 +++++ 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/src/pagx/ppt/PPTTextWriter.cpp b/src/pagx/ppt/PPTTextWriter.cpp index b186fe2758..fbf1974a7e 100644 --- a/src/pagx/ppt/PPTTextWriter.cpp +++ b/src/pagx/ppt/PPTTextWriter.cpp @@ -190,6 +190,7 @@ PPTWriter::NativeTextGeometry PPTWriter::computeNativeTextGeometry( // vertical layout. Only the first run carries the block-level offset. if (!text->glyphRuns.empty() && text->glyphRuns.front() != nullptr) { const GlyphRun* firstRun = text->glyphRuns.front(); + geom.originFromGlyphRun = true; auto renderPos = text->renderPosition(); auto textBounds = precomputed->getTextBounds(mutableText); if (textBounds.width > 0 && textBounds.height > 0) { @@ -437,33 +438,41 @@ void PPTWriter::writeNativeText(XMLBuilder& out, const Text* text, const FillStr // and to the left for End. Center / Justify are direction-symmetric. // OOXML's default algn is "l", so we leave style.algn as nullptr for the // physical-Start case and emit explicit "r" only for the physical-End case. - switch (ResolveLogicalAnchor(text->textAnchor, rtl)) { - case TextAnchor::Center: - style.algn = "ctr"; - break; - case TextAnchor::End: - style.algn = "r"; - break; - case TextAnchor::Start: - default: - break; - } - if (fs.textBox) { - switch (ResolveLogicalAlign(fs.textBox->textAlign, rtl)) { - case TextAlign::Center: + // When the shape frame is anchored to the GlyphRun pen origin, the modifier + // TextBox's horizontal alignment (and the Text's own anchor) is already baked + // into that origin — the frame's left edge sits exactly where the first glyph + // starts. Re-emitting an OOXML algn here would center/right-align the text a + // second time inside the frame, so leave algn at its default (left) and let + // the origin do the positioning. + if (!geom.originFromGlyphRun) { + switch (ResolveLogicalAnchor(text->textAnchor, rtl)) { + case TextAnchor::Center: style.algn = "ctr"; break; - case TextAlign::End: + case TextAnchor::End: style.algn = "r"; break; - case TextAlign::Justify: - style.algn = "just"; - break; - case TextAlign::Start: + case TextAnchor::Start: default: - style.algn = nullptr; break; } + if (fs.textBox) { + switch (ResolveLogicalAlign(fs.textBox->textAlign, rtl)) { + case TextAlign::Center: + style.algn = "ctr"; + break; + case TextAlign::End: + style.algn = "r"; + break; + case TextAlign::Justify: + style.algn = "just"; + break; + case TextAlign::Start: + default: + style.algn = nullptr; + break; + } + } } // PAGX lineHeight maps onto OOXML in both writing modes: in horizontal mode each // "line" is a row so lnSpc is the row pitch (vertical advance), in eaVert each "line" is a diff --git a/src/pagx/ppt/PPTWriter.h b/src/pagx/ppt/PPTWriter.h index fb1d3eb7c6..eab6bff2eb 100644 --- a/src/pagx/ppt/PPTWriter.h +++ b/src/pagx/ppt/PPTWriter.h @@ -768,6 +768,12 @@ class PPTWriter { float estWidth = 0; float estHeight = 0; bool hasTextBox = false; + // True when posX/posY were taken from the GlyphRun pen origin (glyphRun- + // carrying Text rendered as native fallback). In that case the modifier + // TextBox's horizontal alignment is already baked into the origin, so the + // caller must NOT re-apply it as an OOXML algn or the text is centered + // twice. + bool originFromGlyphRun = false; }; NativeTextGeometry computeNativeTextGeometry(const Text* text, Text* mutableText, From cbc3745bcc310d094648d791fb6f3a262207b5f3 Mon Sep 17 00:00:00 2001 From: OnionsYu Date: Wed, 29 Jul 2026 14:19:35 +0800 Subject: [PATCH 09/21] Drop TextBox padding and paragraph anchor for glyph-origin native text to match the authoritative glyph-path placement. --- src/pagx/ppt/PPTTextWriter.cpp | 11 +++++++---- src/pagx/ppt/PPTWriter.h | 18 ++++++++++++++++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/pagx/ppt/PPTTextWriter.cpp b/src/pagx/ppt/PPTTextWriter.cpp index fbf1974a7e..7f2bcd66a8 100644 --- a/src/pagx/ppt/PPTTextWriter.cpp +++ b/src/pagx/ppt/PPTTextWriter.cpp @@ -249,7 +249,7 @@ PPTWriter::NativeTextGeometry PPTWriter::computeNativeTextGeometry( } void PPTWriter::emitTextShapeEnvelope(XMLBuilder& out, const Xform& xf, const TextBox* textBox, - const char* wrap) { + const char* wrap, bool suppressBoxLayout) { int id = _ctx->nextShapeId(); out.openElement("p:sp").closeElementStart(); out.openElement("p:nvSpPr").closeElementStart(); @@ -282,8 +282,11 @@ void PPTWriter::emitTextShapeEnvelope(XMLBuilder& out, const Xform& xf, const Te // OOXML's default insets are non-zero, so we always emit explicit values // -- zeros for standalone text (no TextBox parent) and for TextBoxes with // zero padding, to preserve the pre-padding-support behavior. + // When suppressBoxLayout is set the frame is already anchored to the glyph + // pen origin (which the box padding was baked into during shaping), so the + // insets are forced to zero to avoid indenting the text a second time. int64_t lIns = 0, tIns = 0, rIns = 0, bIns = 0; - if (textBox) { + if (textBox && !suppressBoxLayout) { lIns = PxToEMU(textBox->padding.left); tIns = PxToEMU(textBox->padding.top); rIns = PxToEMU(textBox->padding.right); @@ -295,7 +298,7 @@ void PPTWriter::emitTextShapeEnvelope(XMLBuilder& out, const Xform& xf, const Te .addRequiredAttribute("tIns", tIns) .addRequiredAttribute("rIns", rIns) .addRequiredAttribute("bIns", bIns); - AddBodyPrAttrsForTextBox(out, textBox); + AddBodyPrAttrsForTextBox(out, textBox, suppressBoxLayout); out.closeElementSelfClosing(); out.openElement("a:lstStyle").closeElementSelfClosing(); } @@ -312,7 +315,7 @@ void PPTWriter::emitNativeTextShapeFrame(XMLBuilder& out, const Matrix& m, bool justifyAlign = textBox && textBox->textAlign == TextAlign::Justify; const char* wrap = useLineLayout ? (justifyAlign ? "square" : "none") : (geom.hasTextBox ? "square" : "none"); - emitTextShapeEnvelope(out, xf, textBox, wrap); + emitTextShapeEnvelope(out, xf, textBox, wrap, geom.originFromGlyphRun); } void PPTWriter::emitNativeTextBody(XMLBuilder& out, const Text* text, diff --git a/src/pagx/ppt/PPTWriter.h b/src/pagx/ppt/PPTWriter.h index eab6bff2eb..3a231f8094 100644 --- a/src/pagx/ppt/PPTWriter.h +++ b/src/pagx/ppt/PPTWriter.h @@ -491,7 +491,12 @@ inline void WriteParagraphProperties(XMLBuilder& out, const char* algn, int64_t // Adds the TextBox-derived attributes to a currently-open : vertical // writing mode, paragraph anchoring, and the vertical-mode anchorCtr override // for TextAlign::Center. Returns true when the box uses vertical writing mode. -inline bool AddBodyPrAttrsForTextBox(XMLBuilder& out, const TextBox* box) { +// When `suppressAnchor` is true, only the writing-mode `vert` attribute is +// emitted (glyph orientation), while the paragraph anchor and anchorCtr — which +// place the text block within the frame — are skipped. Callers that anchor the +// frame to the exact glyph pen origin pass true so the block is not re-offset. +inline bool AddBodyPrAttrsForTextBox(XMLBuilder& out, const TextBox* box, + bool suppressAnchor = false) { if (box == nullptr) { return false; } @@ -502,6 +507,9 @@ inline bool AddBodyPrAttrsForTextBox(XMLBuilder& out, const TextBox* box) { if (isVertical) { out.addRequiredAttribute("vert", "eaVert"); } + if (suppressAnchor) { + return isVertical; + } // OOXML's "anchor" describes alignment along the block-flow axis, which // matches paragraphAlign in both writing modes: // - Horizontal: block axis is top->bottom (Near=top, Far=bottom). @@ -907,8 +915,14 @@ class PPTWriter { // supplies the decomposed Xform, the in-scope TextBox (for bodyPr paragraph // attributes), and the pre-computed wrap value ("square" vs "none"). Leaves // open so the caller can stream children into it. + // When `suppressBoxLayout` is true the TextBox's padding insets and paragraph + // anchor are dropped (the frame is already positioned at the exact glyph pen + // origin, so re-applying them would offset the text a second time); the + // vertical writing mode is still honoured because it drives glyph orientation + // rather than placement. Used by the glyphRun-origin native fallback so it + // matches the authoritative writeTextAsPath path, which ignores the box. void emitTextShapeEnvelope(XMLBuilder& out, const Xform& xf, const TextBox* textBox, - const char* wrap); + const char* wrap, bool suppressBoxLayout = false); // p:pic helpers (declared after Xform) void beginPicture(XMLBuilder& out, const char* name); From 845a879a4a540dadab8daa27ca152901f55111c0 Mon Sep 17 00:00:00 2001 From: OnionsYu Date: Wed, 29 Jul 2026 20:02:08 +0800 Subject: [PATCH 10/21] Fix too-narrow native PPT text boxes for pre-shaped glyph runs by measuring the real advance-width span instead of a crude per-character estimate. --- src/pagx/ppt/PPTTextWriter.cpp | 27 +++++-- src/pagx/utils/TextUtils.cpp | 88 ++++++++++++++++++++ src/pagx/utils/TextUtils.h | 14 ++++ test/src/PAGXUtilsTest.cpp | 144 +++++++++++++++++++++++++++++++++ 4 files changed, 267 insertions(+), 6 deletions(-) diff --git a/src/pagx/ppt/PPTTextWriter.cpp b/src/pagx/ppt/PPTTextWriter.cpp index 7f2bcd66a8..0aa77abab1 100644 --- a/src/pagx/ppt/PPTTextWriter.cpp +++ b/src/pagx/ppt/PPTTextWriter.cpp @@ -199,12 +199,27 @@ PPTWriter::NativeTextGeometry PPTWriter::computeNativeTextGeometry( geom.estWidth = textBounds.width; geom.estHeight = textBounds.height; } else { - float effectiveFontSize = text->renderFontSize(); - geom.estWidth = - static_cast(CountUTF8Characters(text->text)) * effectiveFontSize * 0.6f; - geom.estHeight = effectiveFontSize * 1.4f; - geom.posX = renderPos.x + firstRun->x; - geom.posY = renderPos.y + firstRun->y - effectiveFontSize * 0.85f; + // No layout-computed perTextBounds (embedded glyph runs skip the layout + // pass, so getTextBounds is empty). Derive the box directly from the + // pre-shaped runs: advance-width span + authored linebox height, in the + // Text's local space (pen positions already include firstRun->x, so we + // only add renderPosition here — no separate firstRun->x term). + auto glyphBounds = ComputeGlyphRunTextBounds(*text); + if (glyphBounds.width > 0 && glyphBounds.height > 0) { + geom.posX = renderPos.x + glyphBounds.x; + geom.posY = renderPos.y + glyphBounds.y; + geom.estWidth = glyphBounds.width; + geom.estHeight = glyphBounds.height; + } else { + // Last-resort estimate when the runs carry no usable glyph metrics + // (e.g. font glyph list unavailable): fall back to a font-size guess. + float effectiveFontSize = text->renderFontSize(); + geom.estWidth = + static_cast(CountUTF8Characters(text->text)) * effectiveFontSize * 0.6f; + geom.estHeight = effectiveFontSize * 1.4f; + geom.posX = renderPos.x + firstRun->x; + geom.posY = renderPos.y + firstRun->y - effectiveFontSize * 0.85f; + } } return geom; } diff --git a/src/pagx/utils/TextUtils.cpp b/src/pagx/utils/TextUtils.cpp index 078f3357ba..bd6b5335bc 100644 --- a/src/pagx/utils/TextUtils.cpp +++ b/src/pagx/utils/TextUtils.cpp @@ -19,6 +19,7 @@ #include "pagx/utils/TextUtils.h" #include #include +#include #include "base/utils/MathUtil.h" #include "pagx/nodes/Font.h" #include "pagx/nodes/GlyphRun.h" @@ -238,6 +239,93 @@ std::vector ComputeGlyphImages(const Text& text, float textPosX, flo return result; } +Rect ComputeGlyphRunTextBounds(const Text& text) { + // Horizontal span: walk the pen exactly like WalkGlyphs (same positions / + // xOffsets / advance-accumulator fallback and the same missing-glyph skips), + // tracking each rendered glyph's pen origin and its advance edge. This yields + // the advance-width span that TextLayout records in perTextBounds, rather than + // the ink-only width, so start/centre/end anchoring reproduces the interactive + // layout. Per-glyph rotation / skew / scale doesn't move the pen and is + // intentionally ignored here (it only reshapes the glyph, not its advance). + float minX = std::numeric_limits::max(); + float maxX = std::numeric_limits::lowest(); + bool hasGlyph = false; + for (const auto* run : text.glyphRuns) { + if (!run->font || run->font->unitsPerEm <= 0 || run->glyphs.empty()) { + continue; + } + float scale = run->fontSize / static_cast(run->font->unitsPerEm); + float baseX = run->x; + float baseY = run->y; + float currentX = baseX; + for (size_t i = 0; i < run->glyphs.size(); i++) { + uint16_t glyphID = run->glyphs[i]; + if (glyphID == 0) { + continue; + } + auto glyphIndex = static_cast(glyphID) - 1; + if (glyphIndex >= run->font->glyphs.size()) { + continue; + } + auto* glyph = run->font->glyphs[glyphIndex]; + if (!glyph) { + continue; + } + float posX = 0; + float posY = 0; + ResolveGlyphPosition(run, i, baseX, baseY, currentX, &posX, &posY); + float advance = glyph->advance * scale; + currentX += advance; + minX = std::min(minX, posX); + maxX = std::max(maxX, posX + advance); + hasGlyph = true; + } + } + if (!hasGlyph) { + return {}; + } + float width = std::max(0.0f, maxX - minX); + + // Vertical extent: the authored linebox height on the first GlyphRun that + // carries bounds is the value layout produced, so prefer it. Its top is at + // bounds.y (in the same local space) so we keep that offset. Fall back to the + // union of glyph path ink heights only when no authored bounds exist (e.g. a + // hand-authored document that never went through the layout/embed pass). + float top = std::numeric_limits::max(); + float bottom = std::numeric_limits::lowest(); + for (const auto* run : text.glyphRuns) { + if (run->bounds.height > 0) { + top = run->bounds.y; + bottom = run->bounds.y + run->bounds.height; + break; + } + } + if (bottom <= top) { + top = std::numeric_limits::max(); + bottom = std::numeric_limits::lowest(); + auto paths = ComputeGlyphPaths(text, 0.0f, 0.0f); + for (const auto& gp : paths) { + if (!gp.pathData || gp.pathData->isEmpty()) { + continue; + } + auto pb = const_cast(gp.pathData)->getBounds(); + const Point corners[4] = {{pb.x, pb.y}, + {pb.x + pb.width, pb.y}, + {pb.x, pb.y + pb.height}, + {pb.x + pb.width, pb.y + pb.height}}; + for (const auto& corner : corners) { + Point p = gp.transform.mapPoint(corner); + top = std::min(top, p.y); + bottom = std::max(bottom, p.y); + } + } + } + if (bottom <= top) { + return {}; + } + return Rect::MakeXYWH(minX, top, width, bottom - top); +} + bool HasNonASCII(const std::string& str) { for (unsigned char c : str) { if (c > 127) { diff --git a/src/pagx/utils/TextUtils.h b/src/pagx/utils/TextUtils.h index 6fe8d5a1f5..3cbf167277 100644 --- a/src/pagx/utils/TextUtils.h +++ b/src/pagx/utils/TextUtils.h @@ -124,6 +124,20 @@ std::vector ComputeGlyphImages(const Text& text, float textPosX, flo void ComputeGlyphPathsAndImages(const Text& text, float textPosX, float textPosY, std::vector* paths, std::vector* images); +/** + * Computes the bounding box of a Text's pre-shaped GlyphRuns in the Text's local coordinate space + * (i.e. as if textPos were (0, 0), so callers add renderPosition() themselves). The horizontal span + * runs from each glyph's pen origin to its advance edge (pen origin + scaled advance), matching the + * pen-advance span that layout uses and independent of per-glyph rotation / skew / scale. The height + * prefers the first GlyphRun's authored `bounds.height` (the linebox height written by the layout + * pass) and falls back to the union of glyph path ink heights when no authored bounds are present. + * Returns an empty Rect when the text carries no positioned glyphs. This mirrors the advance-width + + * linebox-height + * semantics that TextLayout uses to populate perTextBounds, so native-text export (which cannot + * re-run layout on embedded glyph runs) reproduces the same box the interactive renderer lays out. + */ +Rect ComputeGlyphRunTextBounds(const Text& text); + bool HasNonASCII(const std::string& str); /** diff --git a/test/src/PAGXUtilsTest.cpp b/test/src/PAGXUtilsTest.cpp index 4383002402..d89714a59a 100644 --- a/test/src/PAGXUtilsTest.cpp +++ b/test/src/PAGXUtilsTest.cpp @@ -1066,6 +1066,150 @@ PAGX_TEST(PAGXUtilsTest, ComputeGlyphPaths_PositionWithXOffset) { EXPECT_FLOAT_EQ(result[0].transform.ty, 235.0f); } +// --------------------------------------------------------------------------- +// ComputeGlyphRunTextBounds +// --------------------------------------------------------------------------- + +PAGX_TEST(PAGXUtilsTest, ComputeGlyphRunTextBounds_EmptyText) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto bounds = pagx::ComputeGlyphRunTextBounds(*text); + EXPECT_FLOAT_EQ(bounds.width, 0.0f); + EXPECT_FLOAT_EQ(bounds.height, 0.0f); +} + +// The advance-width span must run from the first glyph's pen origin to the last +// glyph's advance edge, NOT the (much wider) authored container width that a +// tool like ardot bakes into GlyphRun::bounds for centering. This is the exact +// regression that produced too-narrow CJK text boxes in the PPT native path. +PAGX_TEST(PAGXUtilsTest, ComputeGlyphRunTextBounds_AdvanceWidthIgnoresContainerBounds) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto font = doc->makeNode(); + font->unitsPerEm = 1000; + auto glyph = doc->makeNode(); + glyph->path = doc->makeNode(); + *glyph->path = pagx::PathDataFromSVGString("M0 0 L1000 0 L1000 800 L0 800 Z"); + glyph->advance = 1000.0f; + font->glyphs.push_back(glyph); + + auto run = doc->makeNode(); + run->font = font; + run->fontSize = 72.0f; + run->glyphs = {1, 1, 1}; + run->x = 248.0f; + run->positions = {{0.0f, 0.0f}, {72.0f, 0.0f}, {144.0f, 0.0f}}; + // Authored container bounds are far wider than the ink (1000 vs the real + // advance span) and carry the linebox height on y/height. + run->bounds = pagx::Rect::MakeXYWH(0.0f, 0.0f, 1000.0f, 87.0f); + text->glyphRuns.push_back(run); + + auto bounds = pagx::ComputeGlyphRunTextBounds(*text); + // scale = 72/1000, advance px = 72. minX = run->x + positions[0].x = 248. + // maxX = run->x + positions[2].x + advance = 248 + 144 + 72 = 464. width = 216. + EXPECT_FLOAT_EQ(bounds.x, 248.0f); + EXPECT_FLOAT_EQ(bounds.width, 216.0f); + // Height comes from the authored linebox bounds, not the container width. + EXPECT_FLOAT_EQ(bounds.y, 0.0f); + EXPECT_FLOAT_EQ(bounds.height, 87.0f); +} + +// GlyphID 0 (missing glyph) is skipped and does not contribute an advance, +// mirroring WalkGlyphs (there is no glyph entry to read an advance from). In the +// no-positions fallback the pen therefore only advances for rendered glyphs, so +// the width reflects the two real glyphs and not the skipped slot. +PAGX_TEST(PAGXUtilsTest, ComputeGlyphRunTextBounds_MissingGlyphSkipped) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto font = doc->makeNode(); + font->unitsPerEm = 1000; + auto glyph = doc->makeNode(); + glyph->path = doc->makeNode(); + *glyph->path = pagx::PathDataFromSVGString("M0 0 L1000 0 L1000 1000 L0 1000 Z"); + glyph->advance = 1000.0f; + font->glyphs.push_back(glyph); + + auto run = doc->makeNode(); + run->font = font; + run->fontSize = 10.0f; + run->glyphs = {1, 0, 1}; // gap in the middle + run->bounds = pagx::Rect::MakeXYWH(0.0f, 0.0f, 40.0f, 12.0f); + text->glyphRuns.push_back(run); + + auto bounds = pagx::ComputeGlyphRunTextBounds(*text); + // advance px = 10. Glyph 0 pen at 0..10; glyph id 0 is skipped without + // advancing; glyph 2 pen at 10..20. Rendered span = [0, 20]. width = 20. + EXPECT_FLOAT_EQ(bounds.x, 0.0f); + EXPECT_FLOAT_EQ(bounds.width, 20.0f); +} + +// With no authored bounds the height falls back to the union of glyph path ink +// extents (mapped through the per-glyph transform), so hand-authored documents +// that never went through the layout/embed pass still get a usable box. +PAGX_TEST(PAGXUtilsTest, ComputeGlyphRunTextBounds_FallsBackToInkHeight) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto font = doc->makeNode(); + font->unitsPerEm = 1000; + auto glyph = doc->makeNode(); + glyph->path = doc->makeNode(); + // Ink spans y in [-800, 0] in design space (ascender-negative-Y). + *glyph->path = pagx::PathDataFromSVGString("M0 0 L500 0 L500 -800 L0 -800 Z"); + glyph->advance = 500.0f; + font->glyphs.push_back(glyph); + + auto run = doc->makeNode(); + run->font = font; + run->fontSize = 10.0f; // scale = 10/1000 = 0.01 + run->glyphs = {1}; + // No run->bounds: force the ink-height fallback. + text->glyphRuns.push_back(run); + + auto bounds = pagx::ComputeGlyphRunTextBounds(*text); + // Ink y in [-800, 0] * 0.01 = [-8, 0]. height = 8, top = -8. + EXPECT_FLOAT_EQ(bounds.y, -8.0f); + EXPECT_FLOAT_EQ(bounds.height, 8.0f); + // width = advance * scale = 500 * 0.01 = 5. + EXPECT_FLOAT_EQ(bounds.width, 5.0f); +} + +// The horizontal span must union across all runs of a Text, using the first +// run that carries authored bounds for the height. +PAGX_TEST(PAGXUtilsTest, ComputeGlyphRunTextBounds_UnionsMultipleRuns) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto font = doc->makeNode(); + font->unitsPerEm = 1000; + auto glyph = doc->makeNode(); + glyph->path = doc->makeNode(); + *glyph->path = pagx::PathDataFromSVGString("M0 0 L1000 0 L1000 1000 L0 1000 Z"); + glyph->advance = 1000.0f; + font->glyphs.push_back(glyph); + + auto run1 = doc->makeNode(); + run1->font = font; + run1->fontSize = 10.0f; // advance px = 10 + run1->glyphs = {1}; + run1->x = 0.0f; + run1->bounds = pagx::Rect::MakeXYWH(0.0f, 2.0f, 100.0f, 12.0f); + text->glyphRuns.push_back(run1); + + auto run2 = doc->makeNode(); + run2->font = font; + run2->fontSize = 10.0f; + run2->glyphs = {1}; + run2->x = 50.0f; // second run starts far to the right + text->glyphRuns.push_back(run2); + + auto bounds = pagx::ComputeGlyphRunTextBounds(*text); + // run1 span [0, 10], run2 span [50, 60]. Union width = 60 - 0 = 60. + EXPECT_FLOAT_EQ(bounds.x, 0.0f); + EXPECT_FLOAT_EQ(bounds.width, 60.0f); + // Height/top taken from the first run carrying authored bounds. + EXPECT_FLOAT_EQ(bounds.y, 2.0f); + EXPECT_FLOAT_EQ(bounds.height, 12.0f); +} + // --------------------------------------------------------------------------- // GetPNGDimensionsFromPath (data URI variant) // --------------------------------------------------------------------------- From dea71db1a9cd340d8cf9339dd5156cf895455b86 Mon Sep 17 00:00:00 2001 From: OnionsYu Date: Wed, 29 Jul 2026 20:34:53 +0800 Subject: [PATCH 11/21] Skip emitting a layer's mask as visible content in the no-bake PPT path so the mask fill no longer paints an opaque patch over the masked content. --- src/pagx/ppt/PPTExporter.cpp | 10 ++++++ test/src/PAGXPPTTest.cpp | 69 ++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/pagx/ppt/PPTExporter.cpp b/src/pagx/ppt/PPTExporter.cpp index 5d522958f5..9153ecee67 100644 --- a/src/pagx/ppt/PPTExporter.cpp +++ b/src/pagx/ppt/PPTExporter.cpp @@ -764,6 +764,16 @@ void PPTWriter::writeLayer(XMLBuilder& out, const Layer* layer, childTgfx = (*tgfxChildren)[tgfxChildIndex]; } ++tgfxChildIndex; + // 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, + // so tgfx never draws it (LayerBuilder marks it visible only for hasValidMask() and skips it via + // maskOwner). When the mask clip itself is dropped here (bakeUnsupported off, or an unbakeable + // environment) we must still skip emitting the mask layer's own geometry — otherwise its fill + // (e.g. a solid rounded rect) paints as an opaque patch over the masked content. The tgfx child + // slot is still consumed above so later children stay aligned with the tgfx subtree. + if (child == layer->mask) { + continue; + } writeLayer(out, child, childTgfx, layerMatrix, layerAlpha, effectiveFilters, effectiveStyles); } diff --git a/test/src/PAGXPPTTest.cpp b/test/src/PAGXPPTTest.cpp index a4e31aa255..87a5cb5a9c 100644 --- a/test/src/PAGXPPTTest.cpp +++ b/test/src/PAGXPPTTest.cpp @@ -2145,6 +2145,75 @@ PAGX_TEST(PAGXPPTTest, MaskNoBakeWithChildLayers) { ASSERT_TRUE(ExportAndVerify(*doc, "mask_no_bake_children", options)); } +// Regression: a mask layer authored as an INVISIBLE CHILD of the layer it masks +// (the pattern the PAGX / HTML importer produces) must not be emitted as visible +// content when the mask clip is dropped in the no-bake vector path. Previously +// the mask layer's own fill (here a solid rect covering the whole slide) painted +// on top of the masked content as an opaque patch — the "white cover layer" seen +// in exported website decks. The fix skips the child that equals layer->mask. +// +// Verify via deck size: exporting with the mask layer also linked as a child +// must match exporting without the mask child present at all (the mask +// contributes zero visible shapes either way). Both paths keep bakeUnsupported +// off so the clip is dropped rather than rasterized, isolating the child- +// emission behaviour. +PAGX_TEST(PAGXPPTTest, MaskAsChildNotEmittedAsContent) { + // Build a layer masked by a full-slide white rectangle that is ALSO one of the + // layer's children (the real-world importer pattern that triggered the bug). + auto makeDoc = [](bool linkMaskAsChild) { + auto doc = pagx::PAGXDocument::Make(400, 300); + + auto* contentLayer = doc->makeNode(); + auto* contentEllipse = doc->makeNode(); + contentEllipse->position = {200, 150}; + contentEllipse->size = {250, 200}; + auto* contentFill = doc->makeNode(); + auto* contentSolid = doc->makeNode(); + contentSolid->color = {0.0f, 0.5f, 1.0f, 1.0f}; + contentFill->color = contentSolid; + contentLayer->contents.push_back(contentEllipse); + contentLayer->contents.push_back(contentFill); + + // The mask: a full-slide opaque white rect. If wrongly emitted it covers all. + auto* maskLayer = doc->makeNode(); + auto* maskRect = doc->makeNode(); + maskRect->position = {200, 150}; + maskRect->size = {400, 300}; + auto* maskFill = doc->makeNode(); + auto* maskSolid = doc->makeNode(); + maskSolid->color = {1.0f, 1.0f, 1.0f, 1.0f}; + maskFill->color = maskSolid; + maskLayer->contents.push_back(maskRect); + maskLayer->contents.push_back(maskFill); + + contentLayer->mask = maskLayer; + if (linkMaskAsChild) { + contentLayer->children.push_back(maskLayer); + } + doc->layers.push_back(contentLayer); + return doc; + }; + + auto outDir = PPTOutDir(); + auto withChildPath = outDir + "/mask_as_child.pptx"; + auto withoutChildPath = outDir + "/mask_not_child.pptx"; + + pagx::PPTExportOptions opts; + opts.bakeUnsupported = false; + auto docWithChild = makeDoc(true); + auto docWithoutChild = makeDoc(false); + ASSERT_TRUE(pagx::PPTExporter::ToFile({docWithChild.get()}, withChildPath, opts)); + ASSERT_TRUE(pagx::PPTExporter::ToFile({docWithoutChild.get()}, withoutChildPath, opts)); + + // The mask layer must contribute no visible shapes in either case, so linking + // it as a child must not change the exported deck size. Before the fix the + // child path emitted two extra shapes (the mask rect + its fill), inflating + // the archive; equal sizes prove the mask child is now skipped. + auto withChildSize = std::filesystem::file_size(withChildPath); + auto withoutChildSize = std::filesystem::file_size(withoutChildPath); + EXPECT_EQ(withChildSize, withoutChildSize); +} + PAGX_TEST(PAGXPPTTest, MaskNoBakeWithTransformAndAlpha) { auto doc = pagx::PAGXDocument::Make(400, 400); From c7c43fd57f4ae573cccbe66b348c3d2b18982485 Mon Sep 17 00:00:00 2001 From: OnionsYu Date: Thu, 30 Jul 2026 11:04:55 +0800 Subject: [PATCH 12/21] Skip emitting a layer's mask as visible content in the SVG and HTML exporters so the mask fill no longer paints an opaque patch over the masked content. --- src/pagx/html/HTMLWriterLayer.cpp | 10 ++++++++ src/pagx/svg/SVGExporter.cpp | 9 +++++++ test/src/PAGXHtmlTest.cpp | 31 ++++++++++++++++++++++++ test/src/PAGXSVGTest.cpp | 39 +++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+) diff --git a/src/pagx/html/HTMLWriterLayer.cpp b/src/pagx/html/HTMLWriterLayer.cpp index db6eeef19d..4418a6abc5 100644 --- a/src/pagx/html/HTMLWriterLayer.cpp +++ b/src/pagx/html/HTMLWriterLayer.cpp @@ -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 / 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; diff --git a/src/pagx/svg/SVGExporter.cpp b/src/pagx/svg/SVGExporter.cpp index 010268227c..f45e23238d 100644 --- a/src/pagx/svg/SVGExporter.cpp +++ b/src/pagx/svg/SVGExporter.cpp @@ -3383,6 +3383,15 @@ void SVGWriter::writeLayerBody(SVGBuilder& out, const Layer* layer, float perChi } } for (const 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 / def via writeLayerOuterAttributes + // and referenced by the wrapping . 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 mask-def writers and the tgfx bake path both need it. + if (child == layer->mask) { + continue; + } if (perChildAlpha < 1.0f) { out.openElement("g"); out.addAttribute("opacity", FloatToString(perChildAlpha)); diff --git a/test/src/PAGXHtmlTest.cpp b/test/src/PAGXHtmlTest.cpp index 339cc98a97..ce75fa8135 100644 --- a/test/src/PAGXHtmlTest.cpp +++ b/test/src/PAGXHtmlTest.cpp @@ -760,6 +760,37 @@ CLI_TEST(PAGXHtmlTest, ClipAndMask) { << "Inline SVG elements should not have xmlns in HTML5"; } +// Regression: a mask layer authored as an INVISIBLE CHILD of the layer it masks +// (the pattern the PAGX / HTML importer produces — the mask="@id" target is a +// descendant of the masked layer) must feed only the CSS mask / def, +// never be emitted again as a visible child div. Otherwise the mask's own fill +// (here a solid white rect) paints over the masked content as an opaque patch — +// the "white cover layer" seen in exported website decks. +CLI_TEST(PAGXHtmlTest, MaskAsChildNotEmittedAsContent) { + // The masked layer references @coverMask; coverMask is its own child Layer with a + // full-size white fill. Only its clip/mask def should reach the output. + std::string xml = + "" + " " + " " + " " + " " + " " + " " + " " + " " + ""; + auto html = LoadXMLAndConvert(xml); + ASSERT_FALSE(html.empty()); + // The mask must still drive a clip/mask (the def is present). + EXPECT_TRUE(html.find("clip-path") != std::string::npos || + html.find("mask-image") != std::string::npos) + << "the contour mask must still produce a clip-path / mask-image"; + // The mask layer must NOT be emitted as a visible element in the body. + EXPECT_EQ(html.find("id=\"coverMask\""), std::string::npos) + << "the mask layer must not appear as visible content"; +} + CLI_TEST(PAGXHtmlTest, ScrollRectLayoutKeepsChildrenInFlexFlow) { pagx::HTMLExportOptions options; options.extractStyleSheet = false; diff --git a/test/src/PAGXSVGTest.cpp b/test/src/PAGXSVGTest.cpp index 811db66c8a..072dbb8bab 100644 --- a/test/src/PAGXSVGTest.cpp +++ b/test/src/PAGXSVGTest.cpp @@ -2625,6 +2625,45 @@ PAGX_TEST(PAGXSVGTest, SVGExport_MaskContour) { SaveFile(svg, "PAGXSVGTest/svg_export_mask_contour.svg"); } +// Regression: a mask layer authored as an INVISIBLE CHILD of the layer it masks +// (the pattern the PAGX / HTML importer produces) must be emitted only into the +// / def, never again as a visible in the body. Otherwise +// the mask's own fill paints on top of the masked content as an opaque patch +// (the "white cover layer" seen in exported website decks). The def still uses +// the mask layer's id, so we assert the clip def references it while no visible +// group carries it. +PAGX_TEST(PAGXSVGTest, SVGExport_MaskAsChildNotEmittedAsContent) { + auto doc = pagx::PAGXDocument::Make(200, 200); + + auto* maskLayer = doc->makeNode(); + maskLayer->id = "coverMask"; + auto* maskRect = doc->makeNode(); + maskRect->position = {100, 100}; + maskRect->size = {200, 200}; + maskLayer->contents.push_back(maskRect); + maskLayer->contents.push_back(MakeSolidFillSVG(doc.get(), 1, 1, 1)); + + auto* user = doc->makeNode(); + user->mask = maskLayer; + user->maskType = pagx::MaskType::Contour; + auto* rect = doc->makeNode(); + rect->position = {100, 100}; + rect->size = {180, 180}; + user->contents.push_back(rect); + user->contents.push_back(MakeSolidFillSVG(doc.get(), 0.2f, 0.6f, 0.9f)); + // The mask is ALSO one of the layer's children — the real-world importer shape. + user->children.push_back(maskLayer); + doc->layers.push_back(user); + + auto svg = pagx::SVGExporter::ToSVG(*doc); + // The clip def is emitted from the mask layer (its id seeds the clipPath id). + EXPECT_NE(svg.find(" Date: Thu, 30 Jul 2026 14:43:37 +0800 Subject: [PATCH 13/21] Fix oversized native PPT text frames in multi-line TextBoxes by deriving line-box tops from embedded baselines. --- src/pagx/TextLayout.cpp | 5 +- src/pagx/TextLayout.h | 8 ++- src/pagx/ppt/PPTExporter.cpp | 31 ++++++++++ src/pagx/ppt/PPTTextWriter.cpp | 106 +++++++++++++++++++++++++-------- src/pagx/ppt/PPTWriter.h | 6 ++ test/src/PAGXTest.cpp | 41 +++++++++++++ 6 files changed, 168 insertions(+), 29 deletions(-) diff --git a/src/pagx/TextLayout.cpp b/src/pagx/TextLayout.cpp index 448872ed5c..7f85704a7b 100644 --- a/src/pagx/TextLayout.cpp +++ b/src/pagx/TextLayout.cpp @@ -1711,13 +1711,14 @@ static Rect MergeEmbeddedBounds(const std::vector& textElements) { } TextLayoutResult TextLayout::Layout(const std::vector& 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); diff --git a/src/pagx/TextLayout.h b/src/pagx/TextLayout.h index e1e8a2942a..fa7ef6cfaf 100644 --- a/src/pagx/TextLayout.h +++ b/src/pagx/TextLayout.h @@ -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& 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). diff --git a/src/pagx/ppt/PPTExporter.cpp b/src/pagx/ppt/PPTExporter.cpp index 9153ecee67..79c0fd89b0 100644 --- a/src/pagx/ppt/PPTExporter.cpp +++ b/src/pagx/ppt/PPTExporter.cpp @@ -442,6 +442,37 @@ void PPTWriter::processVectorScope(XMLBuilder& out, const std::vector& if (localTextBox == nullptr) { localTextBox = parentTextBox; } + // A modifier-only TextBox may lay out several Text nodes as one fixed-line-height block, while + // the embedded file stores the whole block bounds only on the first Text's first GlyphRun. + // Capture that first line's baseline offset before painters emit any sibling. Later native-text + // shapes can then derive line-box tops from their embedded baselines without depending on the + // fallback font metrics used to re-shape editable text. + if (_ignoreGlyphRuns && localTextBox != nullptr && + localTextBox->writingMode == WritingMode::Horizontal && localTextBox->lineHeight > 0 && + _embeddedBaselineOffsets.count(localTextBox) == 0) { + // Inspect only Text nodes in this exact vector scope. Nested Group/TextBox scopes run this + // same pre-scan recursively with their own effective modifier, so a nested override cannot + // accidentally seed the parent TextBox's baseline cache. + for (const auto* element : elements) { + if (element->nodeType() != NodeType::Text) { + continue; + } + const auto* text = static_cast(element); + float baselineY = 0.0f; + if (!firstEmbeddedBaselineY(*text, &baselineY)) { + continue; + } + for (const auto* run : text->glyphRuns) { + if (run != nullptr && run->bounds.height > 0) { + _embeddedBaselineOffsets[localTextBox] = baselineY - run->bounds.y; + break; + } + } + if (_embeddedBaselineOffsets.count(localTextBox) > 0) { + break; + } + } + } for (auto* element : elements) { auto type = element->nodeType(); diff --git a/src/pagx/ppt/PPTTextWriter.cpp b/src/pagx/ppt/PPTTextWriter.cpp index 0aa77abab1..bd97cd830e 100644 --- a/src/pagx/ppt/PPTTextWriter.cpp +++ b/src/pagx/ppt/PPTTextWriter.cpp @@ -54,6 +54,25 @@ void WriteRunTypeface(XMLBuilder& out, const std::string& typeface) { } // namespace +// Returns the baseline of the first rendered glyph in the embedded run coordinate system. +// GlyphRun::positions stores offsets relative to run->y, so this remains valid for both the +// common one-baseline run and a run whose later glyphs move onto subsequent lines. +bool PPTWriter::firstEmbeddedBaselineY(const Text& text, float* baselineY) { + for (const auto* run : text.glyphRuns) { + if (run == nullptr) { + continue; + } + for (size_t i = 0; i < run->glyphs.size(); ++i) { + if (run->glyphs[i] == 0) { + continue; + } + *baselineY = run->y + (i < run->positions.size() ? run->positions[i].y : 0.0f); + return true; + } + } + return false; +} + void PPTWriter::writeTextAsPath(XMLBuilder& out, const Text* text, const FillStrokeInfo& fs, const Matrix& m, float alpha, const std::vector& /*filters*/, @@ -193,33 +212,64 @@ PPTWriter::NativeTextGeometry PPTWriter::computeNativeTextGeometry( geom.originFromGlyphRun = true; auto renderPos = text->renderPosition(); auto textBounds = precomputed->getTextBounds(mutableText); - if (textBounds.width > 0 && textBounds.height > 0) { + auto glyphBounds = ComputeGlyphRunTextBounds(*text); + auto* lines = precomputed->getTextLines(mutableText); + float embeddedBaseline = 0.0f; + bool hasRuntimeLineBox = + fs.textBox != nullptr && fs.textBox->writingMode == WritingMode::Horizontal && + fs.textBox->lineHeight > 0 && textBounds.width > 0 && textBounds.height > 0 && + lines != nullptr && !lines->empty() && firstEmbeddedBaselineY(*text, &embeddedBaseline); + if (hasRuntimeLineBox) { + // The editable-text path deliberately re-shapes Text::text to recover per-Text line-box + // bounds and line metadata that the embedded-layout fast path does not provide. Keep the + // embedded advance span for X (it is the authoritative pre-shaped placement). + // + // For a fixed-line-height modifier TextBox, layout may split one block across sibling Text + // nodes. Their embedded baselines share the first line's baseline-to-line-box-top offset, + // cached before the scope is emitted. Recovering Y from that offset is independent of the + // runtime fallback font's metrics: + // + // frameTop = embeddedBaseline - firstLineBaselineOffset + // + // If no sibling reference carries authored bounds, fall back to aligning the freshly shaped + // runtime baseline. Either path prevents a block-level GlyphRun::bounds (for example + // 0,0,440,108 on the first Text of a three-line block) from becoming that Text's own + // 108px-high PowerPoint frame. + auto baselineOffset = _embeddedBaselineOffsets.find(fs.textBox); + if (baselineOffset != _embeddedBaselineOffsets.end()) { + geom.posY = renderPos.y + embeddedBaseline - baselineOffset->second; + } else { + geom.posY = + renderPos.y + textBounds.y + embeddedBaseline - lines->front().baselineY; + } + geom.posX = renderPos.x + (glyphBounds.width > 0 ? glyphBounds.x : textBounds.x); + geom.estWidth = glyphBounds.width > 0 ? glyphBounds.width : textBounds.width; + geom.estHeight = textBounds.height; + } else if (glyphBounds.width > 0 && glyphBounds.height > 0) { + // No layout-computed perTextBounds (embedded glyph runs skip the layout + // pass, so getTextBounds is empty), or the TextBox uses automatic line height. Derive the + // box directly from the pre-shaped runs: advance-width span + authored linebox/ink height, + // in the Text's local space. Keeping this path for automatic line height avoids changing + // the geometry of ordinary single-line titles merely because editable export re-shaped + // their readable content. + geom.posX = renderPos.x + glyphBounds.x; + geom.posY = renderPos.y + glyphBounds.y; + geom.estWidth = glyphBounds.width; + geom.estHeight = glyphBounds.height; + } else if (textBounds.width > 0 && textBounds.height > 0) { geom.posX = renderPos.x + firstRun->x + textBounds.x; geom.posY = renderPos.y + firstRun->y + textBounds.y; geom.estWidth = textBounds.width; geom.estHeight = textBounds.height; } else { - // No layout-computed perTextBounds (embedded glyph runs skip the layout - // pass, so getTextBounds is empty). Derive the box directly from the - // pre-shaped runs: advance-width span + authored linebox height, in the - // Text's local space (pen positions already include firstRun->x, so we - // only add renderPosition here — no separate firstRun->x term). - auto glyphBounds = ComputeGlyphRunTextBounds(*text); - if (glyphBounds.width > 0 && glyphBounds.height > 0) { - geom.posX = renderPos.x + glyphBounds.x; - geom.posY = renderPos.y + glyphBounds.y; - geom.estWidth = glyphBounds.width; - geom.estHeight = glyphBounds.height; - } else { - // Last-resort estimate when the runs carry no usable glyph metrics - // (e.g. font glyph list unavailable): fall back to a font-size guess. - float effectiveFontSize = text->renderFontSize(); - geom.estWidth = - static_cast(CountUTF8Characters(text->text)) * effectiveFontSize * 0.6f; - geom.estHeight = effectiveFontSize * 1.4f; - geom.posX = renderPos.x + firstRun->x; - geom.posY = renderPos.y + firstRun->y - effectiveFontSize * 0.85f; - } + // Last-resort estimate when the runs carry no usable glyph metrics + // (e.g. font glyph list unavailable): fall back to a font-size guess. + float effectiveFontSize = text->renderFontSize(); + geom.estWidth = + static_cast(CountUTF8Characters(text->text)) * effectiveFontSize * 0.6f; + geom.estHeight = effectiveFontSize * 1.4f; + geom.posX = renderPos.x + firstRun->x; + geom.posY = renderPos.y + firstRun->y - effectiveFontSize * 0.85f; } return geom; } @@ -425,8 +475,12 @@ void PPTWriter::writeNativeText(XMLBuilder& out, const Text* text, const FillStr TextLayoutResult localResult; if (!precomputed) { auto params = hasTextBox ? MakeTextBoxParams(fs.textBox) : MakeStandaloneParams(text); - localResult = - TextLayout::Layout({{mutableText, MakeGlyphParams(mutableText)}}, params, _layoutContext); + // --ppt-ignore-glyphruns requests editable native text. Re-shape the readable Text content + // even when GlyphRuns are present so the native frame gets real per-Text line-box/baseline + // metadata; computeNativeTextGeometry still uses the embedded advances and pen origin for + // authoritative placement. + localResult = TextLayout::Layout({{mutableText, MakeGlyphParams(mutableText)}}, params, + _layoutContext, !_ignoreGlyphRuns); precomputed = &localResult; } auto* lines = precomputed->getTextLines(mutableText); @@ -878,8 +932,8 @@ void PPTWriter::writeTextBoxGroup(XMLBuilder& out, const Group* textBox, mutableTexts.push_back(const_cast(run.text)); } auto params = MakeTextBoxParams(box); - auto layoutResult = - TextLayout::Layout(TextLayout::MakeElements(mutableTexts), params, _layoutContext); + auto layoutResult = TextLayout::Layout(TextLayout::MakeElements(mutableTexts), params, + _layoutContext, !_ignoreGlyphRuns); float boxWidth = EffectiveTextBoxWidth(box); float boxHeight = EffectiveTextBoxHeight(box); diff --git a/src/pagx/ppt/PPTWriter.h b/src/pagx/ppt/PPTWriter.h index 3a231f8094..d5b0e49114 100644 --- a/src/pagx/ppt/PPTWriter.h +++ b/src/pagx/ppt/PPTWriter.h @@ -23,6 +23,7 @@ #include #include #include +#include #include #include "base/utils/MathUtil.h" #include "pagx/LayoutContext.h" @@ -713,8 +714,13 @@ class PPTWriter { LayerBuildResult _buildResult = {}; bool _buildResultReady = false; ModifierResolver _resolver; + // Fixed-line-height modifier TextBoxes can split one laid-out block across sibling Text nodes. + // Cache the first line's embedded baseline offset from its authored line-box top so every + // sibling can recover its own line-box top from the pre-shaped baseline. + std::unordered_map _embeddedBaselineOffsets = {}; const LayerBuildResult& ensureBuildResult(); + static bool firstEmbeddedBaselineY(const Text& text, float* baselineY); // One geometry instance captured during the scope walk in writeElements. // The transform is baked at collection time so that later painters can emit diff --git a/test/src/PAGXTest.cpp b/test/src/PAGXTest.cpp index 39a5a57a12..e3be323ef4 100644 --- a/test/src/PAGXTest.cpp +++ b/test/src/PAGXTest.cpp @@ -2279,6 +2279,47 @@ PAGX_TEST(PAGXTest, LayoutConstraintScaleTextBothAxes) { EXPECT_FLOAT_EQ(text->renderFontSize(), expectedFontSize); } +PAGX_TEST(PAGXTest, TextLayoutCanReshapeReadableTextWhenGlyphRunsAreIgnored) { + auto doc = pagx::PAGXDocument::Make(400, 200); + auto text = doc->makeNode(); + text->text = "Hello\n"; + text->fontSize = 24; + + auto embeddedFont = doc->makeNode(); + auto embeddedGlyph = doc->makeNode(); + embeddedGlyph->advance = 500; + embeddedFont->glyphs.push_back(embeddedGlyph); + auto embeddedRun = doc->makeNode(); + embeddedRun->font = embeddedFont; + embeddedRun->fontSize = 24; + embeddedRun->glyphs = {1}; + embeddedRun->y = 27; + // Simulate a producer that stores the whole three-line block on the first Text. + embeddedRun->bounds = pagx::Rect::MakeXYWH(0, 0, 440, 108); + text->glyphRuns.push_back(embeddedRun); + + pagx::FontConfig fontConfig; + pagx::LayoutContext layoutContext(&fontConfig); + pagx::TextLayoutParams params = {}; + params.boxWidth = 440; + params.lineHeight = 36; + + auto embeddedResult = + pagx::TextLayout::Layout(pagx::TextLayout::MakeElements({text}), params, &layoutContext); + EXPECT_FLOAT_EQ(embeddedResult.bounds.height, 108); + EXPECT_EQ(embeddedResult.getTextLines(text), nullptr); + + auto reshapedResult = pagx::TextLayout::Layout(pagx::TextLayout::MakeElements({text}), params, + &layoutContext, false); + auto reshapedBounds = reshapedResult.getTextBounds(text); + auto* reshapedLines = reshapedResult.getTextLines(text); + EXPECT_FLOAT_EQ(reshapedBounds.height, 36); + ASSERT_NE(reshapedLines, nullptr); + ASSERT_EQ(reshapedLines->size(), 1u); + EXPECT_GT(reshapedLines->front().baselineY, reshapedBounds.y); + EXPECT_LT(reshapedLines->front().baselineY, reshapedBounds.y + reshapedBounds.height); +} + PAGX_TEST(PAGXTest, LayoutConstraintScaleTextSingleAxis) { auto doc = pagx::PAGXDocument::Make(400, 200); auto layer = doc->makeNode(); From d1fa87f548673998a10c2b44a188b599e2dccd52 Mon Sep 17 00:00:00 2001 From: OnionsYu Date: Thu, 30 Jul 2026 14:58:01 +0800 Subject: [PATCH 14/21] Preserve line-box vertical alignment for native PPT text exported from glyph runs with authored bounds. --- src/pagx/ppt/PPTTextWriter.cpp | 21 +++++++--- src/pagx/ppt/PPTWriter.h | 46 ++++++++++++---------- test/src/PAGXPPTTest.cpp | 70 ++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 25 deletions(-) diff --git a/src/pagx/ppt/PPTTextWriter.cpp b/src/pagx/ppt/PPTTextWriter.cpp index bd97cd830e..6da80e1e22 100644 --- a/src/pagx/ppt/PPTTextWriter.cpp +++ b/src/pagx/ppt/PPTTextWriter.cpp @@ -239,12 +239,12 @@ PPTWriter::NativeTextGeometry PPTWriter::computeNativeTextGeometry( if (baselineOffset != _embeddedBaselineOffsets.end()) { geom.posY = renderPos.y + embeddedBaseline - baselineOffset->second; } else { - geom.posY = - renderPos.y + textBounds.y + embeddedBaseline - lines->front().baselineY; + geom.posY = renderPos.y + textBounds.y + embeddedBaseline - lines->front().baselineY; } geom.posX = renderPos.x + (glyphBounds.width > 0 ? glyphBounds.x : textBounds.x); geom.estWidth = glyphBounds.width > 0 ? glyphBounds.width : textBounds.width; geom.estHeight = textBounds.height; + geom.frameUsesLineBox = true; } else if (glyphBounds.width > 0 && glyphBounds.height > 0) { // No layout-computed perTextBounds (embedded glyph runs skip the layout // pass, so getTextBounds is empty), or the TextBox uses automatic line height. Derive the @@ -256,6 +256,15 @@ PPTWriter::NativeTextGeometry PPTWriter::computeNativeTextGeometry( geom.posY = renderPos.y + glyphBounds.y; geom.estWidth = glyphBounds.width; geom.estHeight = glyphBounds.height; + // ComputeGlyphRunTextBounds falls back to glyph ink when no authored + // bounds exist. Only a real authored height is a line box in which + // paragraphAlign should be re-applied by PowerPoint. + for (const auto* run : text->glyphRuns) { + if (run != nullptr && run->bounds.height > 0) { + geom.frameUsesLineBox = true; + break; + } + } } else if (textBounds.width > 0 && textBounds.height > 0) { geom.posX = renderPos.x + firstRun->x + textBounds.x; geom.posY = renderPos.y + firstRun->y + textBounds.y; @@ -314,7 +323,8 @@ PPTWriter::NativeTextGeometry PPTWriter::computeNativeTextGeometry( } void PPTWriter::emitTextShapeEnvelope(XMLBuilder& out, const Xform& xf, const TextBox* textBox, - const char* wrap, bool suppressBoxLayout) { + const char* wrap, bool suppressBoxLayout, + bool includeParagraphAnchor) { int id = _ctx->nextShapeId(); out.openElement("p:sp").closeElementStart(); out.openElement("p:nvSpPr").closeElementStart(); @@ -363,7 +373,7 @@ void PPTWriter::emitTextShapeEnvelope(XMLBuilder& out, const Xform& xf, const Te .addRequiredAttribute("tIns", tIns) .addRequiredAttribute("rIns", rIns) .addRequiredAttribute("bIns", bIns); - AddBodyPrAttrsForTextBox(out, textBox, suppressBoxLayout); + AddBodyPrAttrsForTextBox(out, textBox, includeParagraphAnchor, !suppressBoxLayout); out.closeElementSelfClosing(); out.openElement("a:lstStyle").closeElementSelfClosing(); } @@ -380,7 +390,8 @@ void PPTWriter::emitNativeTextShapeFrame(XMLBuilder& out, const Matrix& m, bool justifyAlign = textBox && textBox->textAlign == TextAlign::Justify; const char* wrap = useLineLayout ? (justifyAlign ? "square" : "none") : (geom.hasTextBox ? "square" : "none"); - emitTextShapeEnvelope(out, xf, textBox, wrap, geom.originFromGlyphRun); + emitTextShapeEnvelope(out, xf, textBox, wrap, geom.originFromGlyphRun, + !geom.originFromGlyphRun || geom.frameUsesLineBox); } void PPTWriter::emitNativeTextBody(XMLBuilder& out, const Text* text, diff --git a/src/pagx/ppt/PPTWriter.h b/src/pagx/ppt/PPTWriter.h index d5b0e49114..e198126f73 100644 --- a/src/pagx/ppt/PPTWriter.h +++ b/src/pagx/ppt/PPTWriter.h @@ -492,12 +492,13 @@ inline void WriteParagraphProperties(XMLBuilder& out, const char* algn, int64_t // Adds the TextBox-derived attributes to a currently-open : vertical // writing mode, paragraph anchoring, and the vertical-mode anchorCtr override // for TextAlign::Center. Returns true when the box uses vertical writing mode. -// When `suppressAnchor` is true, only the writing-mode `vert` attribute is -// emitted (glyph orientation), while the paragraph anchor and anchorCtr — which -// place the text block within the frame — are skipped. Callers that anchor the -// frame to the exact glyph pen origin pass true so the block is not re-offset. +// The two alignment axes can be controlled independently because a native-text +// frame reconstructed from GlyphRuns may still represent an authored line box +// (and therefore need paragraphAlign within that box), while its inline-axis +// position is already baked into the glyph pen origin. inline bool AddBodyPrAttrsForTextBox(XMLBuilder& out, const TextBox* box, - bool suppressAnchor = false) { + bool includeParagraphAnchor = true, + bool includeInlineAnchor = true) { if (box == nullptr) { return false; } @@ -508,19 +509,18 @@ inline bool AddBodyPrAttrsForTextBox(XMLBuilder& out, const TextBox* box, if (isVertical) { out.addRequiredAttribute("vert", "eaVert"); } - if (suppressAnchor) { - return isVertical; - } // OOXML's "anchor" describes alignment along the block-flow axis, which // matches paragraphAlign in both writing modes: // - Horizontal: block axis is top->bottom (Near=top, Far=bottom). // - Vertical (eaVert): block axis is right->left (Near=right column, // Far=left column). PowerPoint maps t/ctr/b to start/center/end of // that axis, so the same enum->string mapping applies. - if (box->paragraphAlign == ParagraphAlign::Middle) { - out.addRequiredAttribute("anchor", "ctr"); - } else if (box->paragraphAlign == ParagraphAlign::Far) { - out.addRequiredAttribute("anchor", "b"); + if (includeParagraphAnchor) { + if (box->paragraphAlign == ParagraphAlign::Middle) { + out.addRequiredAttribute("anchor", "ctr"); + } else if (box->paragraphAlign == ParagraphAlign::Far) { + out.addRequiredAttribute("anchor", "b"); + } } // In vertical writing mode the bodyPr@anchor controls placement perpendicular // to the text-flow axis (i.e. horizontal placement of the column block), so @@ -529,7 +529,7 @@ inline bool AddBodyPrAttrsForTextBox(XMLBuilder& out, const TextBox* box, // column. anchorCtr="1" toggles the "center on the perpendicular axis" flag, // which in vertical mode produces the desired vertical centering of the text // within its column. - if (isVertical && box->textAlign == TextAlign::Center) { + if (includeInlineAnchor && isVertical && box->textAlign == TextAlign::Center) { out.addRequiredAttribute("anchorCtr", "1"); } return isVertical; @@ -788,6 +788,11 @@ class PPTWriter { // caller must NOT re-apply it as an OOXML algn or the text is centered // twice. bool originFromGlyphRun = false; + // True when posY/estHeight describe a real PAGX line box rather than an + // ink-bounds or font-size fallback. PowerPoint must still apply the + // TextBox's paragraphAlign inside such a frame (for example anchor="ctr" + // for a vertically centered 22px run in an authored 40px line box). + bool frameUsesLineBox = false; }; NativeTextGeometry computeNativeTextGeometry(const Text* text, Text* mutableText, @@ -921,14 +926,15 @@ class PPTWriter { // supplies the decomposed Xform, the in-scope TextBox (for bodyPr paragraph // attributes), and the pre-computed wrap value ("square" vs "none"). Leaves // open so the caller can stream children into it. - // When `suppressBoxLayout` is true the TextBox's padding insets and paragraph - // anchor are dropped (the frame is already positioned at the exact glyph pen - // origin, so re-applying them would offset the text a second time); the - // vertical writing mode is still honoured because it drives glyph orientation - // rather than placement. Used by the glyphRun-origin native fallback so it - // matches the authoritative writeTextAsPath path, which ignores the box. + // When `suppressBoxLayout` is true the TextBox's padding insets and inline + // anchor are dropped because they are already baked into the glyph pen + // origin. `includeParagraphAnchor` remains independently selectable: frames + // reconstructed from authored line-box bounds still need block-axis + // alignment within that frame. Vertical writing mode is always honoured + // because it drives glyph orientation rather than placement. void emitTextShapeEnvelope(XMLBuilder& out, const Xform& xf, const TextBox* textBox, - const char* wrap, bool suppressBoxLayout = false); + const char* wrap, bool suppressBoxLayout = false, + bool includeParagraphAnchor = true); // p:pic helpers (declared after Xform) void beginPicture(XMLBuilder& out, const char* name); diff --git a/test/src/PAGXPPTTest.cpp b/test/src/PAGXPPTTest.cpp index 87a5cb5a9c..7938da71d1 100644 --- a/test/src/PAGXPPTTest.cpp +++ b/test/src/PAGXPPTTest.cpp @@ -63,6 +63,9 @@ #include "pagx/nodes/TextModifier.h" #include "pagx/nodes/TextPath.h" #include "pagx/nodes/TrimPath.h" +#include "pagx/ppt/PPTWriter.h" +#include "pagx/ppt/PPTWriterContext.h" +#include "pagx/xml/XMLBuilder.h" #include "utils/ProjectPath.h" #include "utils/TestUtils.h" @@ -1490,6 +1493,73 @@ PAGX_TEST(PAGXPPTTest, TextIgnoreGlyphRuns) { ASSERT_TRUE(ExportAndVerify(*doc, "text_ignore_glyph_runs", options)); } +PAGX_TEST(PAGXPPTTest, TextIgnoreGlyphRunsPreservesLineBoxVerticalAlignment) { + auto doc = pagx::PAGXDocument::Make(400, 300); + auto* layer = doc->makeNode(); + auto* group = doc->makeNode(); + + auto* font = doc->makeNode(); + font->unitsPerEm = 1000; + auto* glyph = doc->makeNode(); + glyph->advance = 500; + glyph->path = doc->makeNode(); + glyph->path->moveTo(100, -700); + glyph->path->lineTo(400, -700); + glyph->path->lineTo(400, 0); + glyph->path->lineTo(100, 0); + glyph->path->close(); + font->glyphs.push_back(glyph); + + auto* text = doc->makeNode(); + text->text = "Q1 2025"; + text->fontFamily = "Arial"; + text->fontSize = 22.0f; + auto* run = doc->makeNode(); + run->font = font; + run->fontSize = 22.0f; + run->glyphs = {1, 1, 1, 1, 1, 1, 1}; + run->x = 21.5f; + run->y = 28.0f; + run->bounds = pagx::Rect::MakeXYWH(0, 0, 120, 40); + text->glyphRuns.push_back(run); + + group->elements.push_back(text); + auto* fill = doc->makeNode(); + auto* solid = doc->makeNode(); + solid->color = {0.1f, 0.3f, 0.9f, 1.0f}; + fill->color = solid; + group->elements.push_back(fill); + layer->contents.push_back(group); + + auto* textBox = doc->makeNode(); + textBox->width = 120; + textBox->height = 40; + textBox->textAlign = pagx::TextAlign::Center; + textBox->paragraphAlign = pagx::ParagraphAlign::Middle; + layer->contents.push_back(textBox); + doc->layers.push_back(layer); + doc->applyLayout(); + + pagx::PPTExportOptions options; + options.ignoreGlyphRuns = true; + ASSERT_TRUE(ExportAndVerify(*doc, "text_ignore_glyph_runs_linebox_alignment", options)); + + pagx::PPTWriterContext writerContext; + pagx::FontConfig fontConfig; + pagx::LayoutContext layoutContext(&fontConfig); + pagx::PPTWriter writer(&writerContext, doc.get(), options, &layoutContext); + pagx::XMLBuilder xml; + writer.writeDocument(xml); + auto body = xml.release(); + + EXPECT_NE(body.find(""), + std::string::npos); + // Horizontal textAlign is already encoded by run->x, so centering must not + // also be applied on a:pPr. + EXPECT_EQ(body.find("makeNode(); From dfa26513b067b6fba0de9c98b5fb0f2a0453026e Mon Sep 17 00:00:00 2001 From: OnionsYu Date: Thu, 30 Jul 2026 15:50:24 +0800 Subject: [PATCH 15/21] Fix misplaced bitmap emoji glyphs in native PPT text by keeping the pen offset and optical alignment with siblings. --- src/pagx/ppt/PPTExporter.cpp | 3 + src/pagx/ppt/PPTTextWriter.cpp | 122 ++++++++++++++++++++++++++++++++- src/pagx/ppt/PPTWriter.h | 9 +++ src/pagx/utils/TextUtils.cpp | 6 +- src/pagx/utils/TextUtils.h | 8 ++- test/src/PAGXPPTTest.cpp | 55 +++++++++++++++ 6 files changed, 196 insertions(+), 7 deletions(-) diff --git a/src/pagx/ppt/PPTExporter.cpp b/src/pagx/ppt/PPTExporter.cpp index 79c0fd89b0..d7ee38aceb 100644 --- a/src/pagx/ppt/PPTExporter.cpp +++ b/src/pagx/ppt/PPTExporter.cpp @@ -442,6 +442,9 @@ void PPTWriter::processVectorScope(XMLBuilder& out, const std::vector& if (localTextBox == nullptr) { localTextBox = parentTextBox; } + if (_ignoreGlyphRuns && localTextBox != nullptr) { + prepareEditableTextOpticalOffsets(elements, localTextBox); + } // A modifier-only TextBox may lay out several Text nodes as one fixed-line-height block, while // the embedded file stores the whole block bounds only on the first Text's first GlyphRun. // Capture that first line's baseline offset before painters emit any sibling. Later native-text diff --git a/src/pagx/ppt/PPTTextWriter.cpp b/src/pagx/ppt/PPTTextWriter.cpp index 6da80e1e22..08db5a8c03 100644 --- a/src/pagx/ppt/PPTTextWriter.cpp +++ b/src/pagx/ppt/PPTTextWriter.cpp @@ -24,6 +24,7 @@ #include #include "pagx/TextLayoutParams.h" #include "pagx/nodes/Composition.h" +#include "pagx/nodes/Font.h" #include "pagx/nodes/GlyphRun.h" #include "pagx/nodes/Image.h" #include "pagx/nodes/PathData.h" @@ -52,6 +53,52 @@ void WriteRunTypeface(XMLBuilder& out, const std::string& typeface) { out.openElement("a:cs").addRequiredAttribute("typeface", typeface).closeElementSelfClosing(); } +bool HasEmbeddedBitmapGlyphs(const Text* text) { + for (const auto* run : text->glyphRuns) { + if (run == nullptr || run->font == nullptr) { + continue; + } + for (auto glyphID : run->glyphs) { + if (glyphID == 0) { + continue; + } + size_t glyphIndex = static_cast(glyphID) - 1; + if (glyphIndex < run->font->glyphs.size()) { + auto* glyph = run->font->glyphs[glyphIndex]; + if (glyph != nullptr && glyph->image != nullptr) { + return true; + } + } + } + } + return false; +} + +bool ComputeLayoutInkBounds(const TextLayoutResult& layout, Text* text, tgfx::Rect* bounds) { + const auto* runs = layout.getGlyphRuns(text); + if (runs == nullptr) { + return false; + } + bool hasBounds = false; + for (const auto& run : *runs) { + size_t count = std::min(run.glyphs.size(), run.positions.size()); + for (size_t i = 0; i < count; ++i) { + auto glyphBounds = run.font.getBounds(run.glyphs[i]); + if (glyphBounds.isEmpty()) { + continue; + } + glyphBounds.offset(run.positions[i]); + if (hasBounds) { + bounds->join(glyphBounds); + } else { + *bounds = glyphBounds; + hasBounds = true; + } + } + } + return hasBounds; +} + } // namespace // Returns the baseline of the first rendered glyph in the embedded run coordinate system. @@ -73,6 +120,59 @@ bool PPTWriter::firstEmbeddedBaselineY(const Text& text, float* baselineY) { return false; } +void PPTWriter::prepareEditableTextOpticalOffsets(const std::vector& elements, + const TextBox* textBox) { + if (!_preparedEditableTextBoxes.insert(textBox).second || + textBox->writingMode != WritingMode::Horizontal) { + return; + } + + std::vector texts; + TextLayout::CollectTextElements(elements, texts); + if (texts.size() < 2) { + return; + } + auto layout = TextLayout::Layout(TextLayout::MakeElements(texts), MakeTextBoxParams(textBox), + _layoutContext, false); + + struct OpticalEntry { + Text* text = nullptr; + float baselineY = 0.0f; + tgfx::Rect inkBounds = {}; + bool bitmap = false; + }; + std::vector entries; + entries.reserve(texts.size()); + for (auto* text : texts) { + const auto* lines = layout.getTextLines(text); + if (lines == nullptr || lines->size() != 1) { + continue; + } + tgfx::Rect inkBounds = {}; + if (!ComputeLayoutInkBounds(layout, text, &inkBounds)) { + continue; + } + entries.push_back({text, lines->front().baselineY, inkBounds, HasEmbeddedBitmapGlyphs(text)}); + } + + constexpr float baselineEpsilon = 0.5f; + for (const auto& target : entries) { + if (!target.bitmap) { + continue; + } + for (const auto& reference : entries) { + if (reference.bitmap || + std::fabs(reference.baselineY - target.baselineY) >= baselineEpsilon) { + continue; + } + float offsetY = reference.inkBounds.centerY() - target.inkBounds.centerY(); + float maxOffset = target.text->renderFontSize() * 0.2f; + _editableOpticalOffsets[target.text] = std::clamp(offsetY, -maxOffset, maxOffset); + break; + } + } +} + void PPTWriter::writeTextAsPath(XMLBuilder& out, const Text* text, const FillStrokeInfo& fs, const Matrix& m, float alpha, const std::vector& /*filters*/, @@ -266,9 +366,21 @@ PPTWriter::NativeTextGeometry PPTWriter::computeNativeTextGeometry( } } } else if (textBounds.width > 0 && textBounds.height > 0) { - geom.posX = renderPos.x + firstRun->x + textBounds.x; - geom.posY = renderPos.y + firstRun->y + textBounds.y; - geom.estWidth = textBounds.width; + // Bitmap glyphs commonly have no authored run bounds and no vector + // outline from which ComputeGlyphRunTextBounds can derive a height. The + // horizontal advance span is still authoritative (for example an emoji + // fallback run may begin at positions[0].x = 160), while the freshly + // shaped editable text supplies a usable line box. Align that line box + // to the embedded baseline instead of treating GlyphRun::y as its top. + float embeddedBaseline = 0.0f; + bool hasEmbeddedBaseline = + lines != nullptr && !lines->empty() && firstEmbeddedBaselineY(*text, &embeddedBaseline); + geom.posX = + renderPos.x + (glyphBounds.width > 0 ? glyphBounds.x : firstRun->x + textBounds.x); + geom.posY = hasEmbeddedBaseline + ? renderPos.y + textBounds.y + embeddedBaseline - lines->front().baselineY + : renderPos.y + firstRun->y + textBounds.y; + geom.estWidth = glyphBounds.width > 0 ? glyphBounds.width : textBounds.width; geom.estHeight = textBounds.height; } else { // Last-resort estimate when the runs carry no usable glyph metrics @@ -280,6 +392,10 @@ PPTWriter::NativeTextGeometry PPTWriter::computeNativeTextGeometry( geom.posX = renderPos.x + firstRun->x; geom.posY = renderPos.y + firstRun->y - effectiveFontSize * 0.85f; } + auto opticalOffset = _editableOpticalOffsets.find(text); + if (opticalOffset != _editableOpticalOffsets.end()) { + geom.posY += opticalOffset->second; + } return geom; } diff --git a/src/pagx/ppt/PPTWriter.h b/src/pagx/ppt/PPTWriter.h index e198126f73..df60c6fd68 100644 --- a/src/pagx/ppt/PPTWriter.h +++ b/src/pagx/ppt/PPTWriter.h @@ -24,6 +24,7 @@ #include #include #include +#include #include #include "base/utils/MathUtil.h" #include "pagx/LayoutContext.h" @@ -718,9 +719,17 @@ class PPTWriter { // Cache the first line's embedded baseline offset from its authored line-box top so every // sibling can recover its own line-box top from the pre-shaped baseline. std::unordered_map _embeddedBaselineOffsets = {}; + // Editable native text uses the system font selected by PowerPoint, whose emoji ink box can sit + // noticeably higher than adjacent CJK ink even when both runs share a typographic baseline. + // Cache a small per-Text optical Y correction derived from the freshly shaped sibling runs so + // bitmap-glyph fallbacks remain visually centered with the surrounding text. + std::unordered_map _editableOpticalOffsets = {}; + std::unordered_set _preparedEditableTextBoxes = {}; const LayerBuildResult& ensureBuildResult(); static bool firstEmbeddedBaselineY(const Text& text, float* baselineY); + void prepareEditableTextOpticalOffsets(const std::vector& elements, + const TextBox* textBox); // One geometry instance captured during the scope walk in writeElements. // The transform is baked at collection time so that later painters can emit diff --git a/src/pagx/utils/TextUtils.cpp b/src/pagx/utils/TextUtils.cpp index bd6b5335bc..65d9d1f294 100644 --- a/src/pagx/utils/TextUtils.cpp +++ b/src/pagx/utils/TextUtils.cpp @@ -321,7 +321,11 @@ Rect ComputeGlyphRunTextBounds(const Text& text) { } } if (bottom <= top) { - return {}; + // Bitmap glyphs may carry neither authored GlyphRun bounds nor vector + // outlines. Keep their authoritative horizontal pen span even though the + // vertical extent is unknown so editable-text exporters can still recover + // an inline run's absolute X position from positions[]. + return Rect::MakeXYWH(minX, 0.0f, width, 0.0f); } return Rect::MakeXYWH(minX, top, width, bottom - top); } diff --git a/src/pagx/utils/TextUtils.h b/src/pagx/utils/TextUtils.h index 3cbf167277..b06aebf8ee 100644 --- a/src/pagx/utils/TextUtils.h +++ b/src/pagx/utils/TextUtils.h @@ -132,9 +132,11 @@ void ComputeGlyphPathsAndImages(const Text& text, float textPosX, float textPosY * prefers the first GlyphRun's authored `bounds.height` (the linebox height written by the layout * pass) and falls back to the union of glyph path ink heights when no authored bounds are present. * Returns an empty Rect when the text carries no positioned glyphs. This mirrors the advance-width + - * linebox-height - * semantics that TextLayout uses to populate perTextBounds, so native-text export (which cannot - * re-run layout on embedded glyph runs) reproduces the same box the interactive renderer lays out. + * linebox-height semantics that TextLayout uses to populate perTextBounds, so native-text export + * (which cannot re-run layout on embedded glyph runs) reproduces the same box the interactive + * renderer lays out. Bitmap-only glyphs without authored bounds retain their horizontal advance + * span and return a zero height, allowing callers to combine the authoritative X position with a + * separately shaped editable-text line box. */ Rect ComputeGlyphRunTextBounds(const Text& text); diff --git a/test/src/PAGXPPTTest.cpp b/test/src/PAGXPPTTest.cpp index 7938da71d1..6510d03644 100644 --- a/test/src/PAGXPPTTest.cpp +++ b/test/src/PAGXPPTTest.cpp @@ -1560,6 +1560,61 @@ PAGX_TEST(PAGXPPTTest, TextIgnoreGlyphRunsPreservesLineBoxVerticalAlignment) { EXPECT_EQ(body.find("makeNode(); + auto* group = doc->makeNode(); + + auto* font = doc->makeNode(); + font->unitsPerEm = 160; + auto* glyph = doc->makeNode(); + glyph->advance = 160; + // Match an embedded emoji glyph: it has bitmap content but no vector + // outline, and its owning GlyphRun carries no authored bounds. + glyph->image = doc->makeNode(); + font->glyphs.push_back(glyph); + + auto* text = doc->makeNode(); + text->text = "🤣"; + text->fontFamily = "Arial"; + text->fontSize = 32.0f; + auto* run = doc->makeNode(); + run->font = font; + run->fontSize = 32.0f; + run->glyphs = {1}; + run->y = 31.0f; + run->positions = {{160.0f, 0.0f}}; + text->glyphRuns.push_back(run); + + group->elements.push_back(text); + auto* fill = doc->makeNode(); + auto* solid = doc->makeNode(); + solid->color = {0.0f, 0.0f, 0.0f, 1.0f}; + fill->color = solid; + group->elements.push_back(fill); + layer->contents.push_back(group); + + auto* textBox = doc->makeNode(); + textBox->width = 440.0f; + layer->contents.push_back(textBox); + doc->layers.push_back(layer); + doc->applyLayout(); + + pagx::PPTExportOptions options; + options.ignoreGlyphRuns = true; + pagx::PPTWriterContext writerContext; + pagx::FontConfig fontConfig; + pagx::LayoutContext layoutContext(&fontConfig); + pagx::PPTWriter writer(&writerContext, doc.get(), options, &layoutContext); + pagx::XMLBuilder xml; + writer.writeDocument(xml); + auto body = xml.release(); + + // positions[0].x = 160px must become the shape's left edge. The old fallback + // discarded it and emitted x="0", placing the emoji at the start of the line. + EXPECT_NE(body.find("makeNode(); From c7bc1e447cff2d51763b3f88097cb428408d5021 Mon Sep 17 00:00:00 2001 From: OnionsYu Date: Thu, 30 Jul 2026 17:06:03 +0800 Subject: [PATCH 16/21] Combine modifier-only TextBox runs into one native PPT rich-text shape and align emoji runs via baseline offset. --- src/pagx/ppt/PPTExporter.cpp | 71 +++++++++++++- src/pagx/ppt/PPTTextWriter.cpp | 171 +++++++++++++++------------------ src/pagx/ppt/PPTWriter.h | 10 +- test/src/PAGXPPTTest.cpp | 147 ++++++++++++++++++++++++++++ 4 files changed, 295 insertions(+), 104 deletions(-) diff --git a/src/pagx/ppt/PPTExporter.cpp b/src/pagx/ppt/PPTExporter.cpp index d7ee38aceb..8c4f2b69aa 100644 --- a/src/pagx/ppt/PPTExporter.cpp +++ b/src/pagx/ppt/PPTExporter.cpp @@ -71,6 +71,63 @@ bool CanSkipShapeAfterPicture(bool imageWritten, const FillStrokeInfo& fs, return imageWritten && !fs.stroke && filters.empty() && styles.empty(); } +struct EditableTextScope { + size_t textCount = 0; + bool hasPainter = false; + LayerPlacement placement = LayerPlacement::Background; +}; + +// A modifier-only TextBox can be emitted as one native PowerPoint rich-text shape when the +// surrounding scope contains only untransformed Text/style groups. Keeping all runs in the same +// a:p lets PowerPoint apply one baseline and choose its own platform emoji fallback consistently. +// Mixed geometry, transformed groups, nested TextBoxes, and mixed painter placements keep using +// the ordinary per-geometry walk because collapsing those cases would change z-order or transforms. +bool AnalyzeEditableTextScope(const std::vector& elements, const TextBox* textBox, + EditableTextScope* scope) { + for (const auto* element : elements) { + switch (element->nodeType()) { + case NodeType::Text: { + const auto* text = static_cast(element); + if (!text->text.empty()) { + scope->textCount++; + } + break; + } + case NodeType::Fill: + case NodeType::Stroke: { + LayerPlacement placement = element->nodeType() == NodeType::Fill + ? static_cast(element)->placement + : static_cast(element)->placement; + if (scope->hasPainter && placement != scope->placement) { + return false; + } + scope->hasPainter = true; + scope->placement = placement; + break; + } + case NodeType::Group: { + const auto* group = static_cast(element); + if (group->alpha != 1.0f || !BuildGroupMatrix(group).isIdentity() || + FindModifierTextBox(group->elements) != nullptr || + !AnalyzeEditableTextScope(group->elements, textBox, scope)) { + return false; + } + break; + } + case NodeType::TextBox: { + const auto* current = static_cast(element); + if (current != textBox || !current->elements.empty()) { + return false; + } + break; + } + default: + return false; + } + } + return true; +} + } // namespace // ── Transform decomposition ──────────────────────────────────────────────── @@ -439,12 +496,20 @@ void PPTWriter::processVectorScope(XMLBuilder& out, const std::vector& // legacy CollectFillStroke().textBox behaviour, then fall back to the // parent's TextBox so Text inside a Group still inherits an outer one. const TextBox* localTextBox = FindModifierTextBox(elements); + if (_ignoreGlyphRuns && localTextBox != nullptr) { + EditableTextScope editableScope; + if (AnalyzeEditableTextScope(elements, localTextBox, &editableScope) && + editableScope.textCount >= 2 && editableScope.hasPainter) { + if (editableScope.placement == targetPlacement) { + auto textBoxMatrix = transform * BuildGroupMatrix(localTextBox); + writeTextBoxGroup(out, localTextBox, elements, textBoxMatrix, alpha, filters, styles); + } + return; + } + } if (localTextBox == nullptr) { localTextBox = parentTextBox; } - if (_ignoreGlyphRuns && localTextBox != nullptr) { - prepareEditableTextOpticalOffsets(elements, localTextBox); - } // A modifier-only TextBox may lay out several Text nodes as one fixed-line-height block, while // the embedded file stores the whole block bounds only on the first Text's first GlyphRun. // Capture that first line's baseline offset before painters emit any sibling. Later native-text diff --git a/src/pagx/ppt/PPTTextWriter.cpp b/src/pagx/ppt/PPTTextWriter.cpp index 08db5a8c03..7ceac6bae9 100644 --- a/src/pagx/ppt/PPTTextWriter.cpp +++ b/src/pagx/ppt/PPTTextWriter.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include "pagx/TextLayoutParams.h" #include "pagx/nodes/Composition.h" @@ -53,50 +54,39 @@ void WriteRunTypeface(XMLBuilder& out, const std::string& typeface) { out.openElement("a:cs").addRequiredAttribute("typeface", typeface).closeElementSelfClosing(); } -bool HasEmbeddedBitmapGlyphs(const Text* text) { +bool IsEmojiImagePath(const std::string& filePath) { + auto marker = filePath.find("emoji:"); + return marker != std::string::npos && + (marker == 0 || filePath[marker - 1] == '/' || filePath[marker - 1] == '\\'); +} + +// PAGX represents color emoji glyphs with an "emoji:" image source. Importing a PAGX +// file may resolve that pseudo-path against the document directory, so IsEmojiImagePath accepts +// the marker at the beginning of a path component. Requiring every authored glyph to carry that +// marker prevents ordinary bitmap fonts and icon glyphs from receiving emoji-only adjustments. +bool HasOnlyEmbeddedEmojiGlyphs(const Text* text) { + bool hasEmojiGlyph = false; for (const auto* run : text->glyphRuns) { if (run == nullptr || run->font == nullptr) { - continue; + return false; } for (auto glyphID : run->glyphs) { if (glyphID == 0) { - continue; + return false; } size_t glyphIndex = static_cast(glyphID) - 1; - if (glyphIndex < run->font->glyphs.size()) { - auto* glyph = run->font->glyphs[glyphIndex]; - if (glyph != nullptr && glyph->image != nullptr) { - return true; - } - } - } - } - return false; -} - -bool ComputeLayoutInkBounds(const TextLayoutResult& layout, Text* text, tgfx::Rect* bounds) { - const auto* runs = layout.getGlyphRuns(text); - if (runs == nullptr) { - return false; - } - bool hasBounds = false; - for (const auto& run : *runs) { - size_t count = std::min(run.glyphs.size(), run.positions.size()); - for (size_t i = 0; i < count; ++i) { - auto glyphBounds = run.font.getBounds(run.glyphs[i]); - if (glyphBounds.isEmpty()) { - continue; + if (glyphIndex >= run->font->glyphs.size()) { + return false; } - glyphBounds.offset(run.positions[i]); - if (hasBounds) { - bounds->join(glyphBounds); - } else { - *bounds = glyphBounds; - hasBounds = true; + const auto* glyph = run->font->glyphs[glyphIndex]; + if (glyph == nullptr || glyph->image == nullptr || + !IsEmojiImagePath(glyph->image->filePath)) { + return false; } + hasEmojiGlyph = true; } } - return hasBounds; + return hasEmojiGlyph; } } // namespace @@ -120,59 +110,6 @@ bool PPTWriter::firstEmbeddedBaselineY(const Text& text, float* baselineY) { return false; } -void PPTWriter::prepareEditableTextOpticalOffsets(const std::vector& elements, - const TextBox* textBox) { - if (!_preparedEditableTextBoxes.insert(textBox).second || - textBox->writingMode != WritingMode::Horizontal) { - return; - } - - std::vector texts; - TextLayout::CollectTextElements(elements, texts); - if (texts.size() < 2) { - return; - } - auto layout = TextLayout::Layout(TextLayout::MakeElements(texts), MakeTextBoxParams(textBox), - _layoutContext, false); - - struct OpticalEntry { - Text* text = nullptr; - float baselineY = 0.0f; - tgfx::Rect inkBounds = {}; - bool bitmap = false; - }; - std::vector entries; - entries.reserve(texts.size()); - for (auto* text : texts) { - const auto* lines = layout.getTextLines(text); - if (lines == nullptr || lines->size() != 1) { - continue; - } - tgfx::Rect inkBounds = {}; - if (!ComputeLayoutInkBounds(layout, text, &inkBounds)) { - continue; - } - entries.push_back({text, lines->front().baselineY, inkBounds, HasEmbeddedBitmapGlyphs(text)}); - } - - constexpr float baselineEpsilon = 0.5f; - for (const auto& target : entries) { - if (!target.bitmap) { - continue; - } - for (const auto& reference : entries) { - if (reference.bitmap || - std::fabs(reference.baselineY - target.baselineY) >= baselineEpsilon) { - continue; - } - float offsetY = reference.inkBounds.centerY() - target.inkBounds.centerY(); - float maxOffset = target.text->renderFontSize() * 0.2f; - _editableOpticalOffsets[target.text] = std::clamp(offsetY, -maxOffset, maxOffset); - break; - } - } -} - void PPTWriter::writeTextAsPath(XMLBuilder& out, const Text* text, const FillStrokeInfo& fs, const Matrix& m, float alpha, const std::vector& /*filters*/, @@ -392,10 +329,6 @@ PPTWriter::NativeTextGeometry PPTWriter::computeNativeTextGeometry( geom.posX = renderPos.x + firstRun->x; geom.posY = renderPos.y + firstRun->y - effectiveFontSize * 0.85f; } - auto opticalOffset = _editableOpticalOffsets.find(text); - if (opticalOffset != _editableOpticalOffsets.end()) { - geom.posY += opticalOffset->second; - } return geom; } @@ -718,6 +651,9 @@ void PPTWriter::writeParagraphRun(XMLBuilder& out, const std::string& runText, if (style.hasLetterSpacing) { out.addRequiredAttribute("spc", style.letterSpc); } + if (style.baseline != 0) { + out.addRequiredAttribute("baseline", style.baseline); + } out.closeElementStart(); // OOXML rPr child order is: a:ln, then EG_FillProperties, then a:effectLst, // then a:latin/a:ea. Emit the stroke first so the run carries both an @@ -1150,8 +1086,59 @@ void PPTWriter::writeTextBoxGroup(XMLBuilder& out, const Group* textBox, // Alignment lives on a:pPr (not a:rPr) so we leave style.algn at nullptr here. std::vector runStyles; runStyles.reserve(runs.size()); - for (const auto& run : runs) { - runStyles.push_back(BuildRunStyle(run.text, run.fill, run.stroke, alpha)); + std::vector emojiRuns(runs.size(), false); + std::vector adjustEmojiRuns(runs.size(), false); + for (size_t i = 0; i < runs.size(); ++i) { + emojiRuns[i] = HasOnlyEmbeddedEmojiGlyphs(runs[i].text); + } + // A run style cannot vary between lines, so only adjust an emoji run that contributes exactly + // one laid-out line and has ordinary text on that same baseline. This avoids moving standalone + // emoji lines merely because an unrelated line elsewhere in the TextBox contains normal text. + if (_ignoreGlyphRuns && !isVertical && useLineLayout) { + constexpr float baselineEpsilon = 0.5f; + for (size_t runIndex = 0; runIndex < runs.size(); ++runIndex) { + if (!emojiRuns[runIndex]) { + continue; + } + const LineEntry* emojiLine = nullptr; + bool hasMultipleLines = false; + for (const auto& line : lineEntries) { + if (line.runIndex != runIndex) { + continue; + } + if (emojiLine != nullptr) { + hasMultipleLines = true; + break; + } + emojiLine = &line; + } + if (emojiLine == nullptr || hasMultipleLines) { + continue; + } + for (const auto& siblingLine : lineEntries) { + if (!emojiRuns[siblingLine.runIndex] && + std::fabs(siblingLine.baselineY - emojiLine->baselineY) < baselineEpsilon) { + adjustEmojiRuns[runIndex] = true; + break; + } + } + } + } + // DrawingML exposes run-level vertical positioning only through baseline, which PowerPoint + // renders as subscript and therefore at 60% of the declared font size. Increase the declared + // size by the inverse factor so the visible emoji retains the authored size while a small + // negative baseline optically aligns it with adjacent text. + constexpr int emojiBaseline = -10000; + constexpr double powerPointSubscriptScale = 0.6; + for (size_t i = 0; i < runs.size(); ++i) { + const auto& run = runs[i]; + auto style = BuildRunStyle(run.text, run.fill, run.stroke, alpha); + if (adjustEmojiRuns[i]) { + style.baseline = emojiBaseline; + style.fontSize = static_cast( + std::lround(static_cast(style.fontSize) / powerPointSubscriptScale)); + } + runStyles.push_back(std::move(style)); } // Locate the first run whose text contains a `\t` and use its renderFontSize diff --git a/src/pagx/ppt/PPTWriter.h b/src/pagx/ppt/PPTWriter.h index df60c6fd68..40c53e6be9 100644 --- a/src/pagx/ppt/PPTWriter.h +++ b/src/pagx/ppt/PPTWriter.h @@ -24,7 +24,6 @@ #include #include #include -#include #include #include "base/utils/MathUtil.h" #include "pagx/LayoutContext.h" @@ -338,6 +337,7 @@ struct PPTRunStyle { const char* algn = nullptr; int fontSize = 0; int64_t letterSpc = 0; + int baseline = 0; bool hasBold = false; bool hasItalic = false; bool hasLetterSpacing = false; @@ -719,17 +719,9 @@ class PPTWriter { // Cache the first line's embedded baseline offset from its authored line-box top so every // sibling can recover its own line-box top from the pre-shaped baseline. std::unordered_map _embeddedBaselineOffsets = {}; - // Editable native text uses the system font selected by PowerPoint, whose emoji ink box can sit - // noticeably higher than adjacent CJK ink even when both runs share a typographic baseline. - // Cache a small per-Text optical Y correction derived from the freshly shaped sibling runs so - // bitmap-glyph fallbacks remain visually centered with the surrounding text. - std::unordered_map _editableOpticalOffsets = {}; - std::unordered_set _preparedEditableTextBoxes = {}; const LayerBuildResult& ensureBuildResult(); static bool firstEmbeddedBaselineY(const Text& text, float* baselineY); - void prepareEditableTextOpticalOffsets(const std::vector& elements, - const TextBox* textBox); // One geometry instance captured during the scope walk in writeElements. // The transform is baked at collection time so that later painters can emit diff --git a/test/src/PAGXPPTTest.cpp b/test/src/PAGXPPTTest.cpp index 6510d03644..75a4e7d238 100644 --- a/test/src/PAGXPPTTest.cpp +++ b/test/src/PAGXPPTTest.cpp @@ -1615,6 +1615,153 @@ PAGX_TEST(PAGXPPTTest, TextIgnoreGlyphRunsPreservesBitmapGlyphPosition) { EXPECT_NE(body.find("makeNode(); + + auto* vectorFont = doc->makeNode(); + vectorFont->unitsPerEm = 160; + auto* vectorGlyph = doc->makeNode(); + vectorGlyph->advance = 160; + vectorGlyph->path = doc->makeNode(); + vectorGlyph->path->moveTo(0, -140); + vectorGlyph->path->lineTo(140, -140); + vectorGlyph->path->lineTo(140, 0); + vectorGlyph->path->lineTo(0, 0); + vectorGlyph->path->close(); + vectorFont->glyphs.push_back(vectorGlyph); + + auto* title = doc->makeNode(); + title->text = "云原生架构"; + title->fontFamily = "Inter"; + title->fontStyle = "SemiBold"; + title->fontSize = 32.0f; + auto* titleRun = doc->makeNode(); + titleRun->font = vectorFont; + titleRun->fontSize = 32.0f; + titleRun->glyphs = {1, 1, 1, 1, 1}; + titleRun->y = 31.0f; + titleRun->positions = {{0.0f, 0.0f}, {32.0f, 0.0f}, {64.0f, 0.0f}, {96.0f, 0.0f}, {128.0f, 0.0f}}; + title->glyphRuns.push_back(titleRun); + + auto* bitmapFont = doc->makeNode(); + bitmapFont->unitsPerEm = 160; + auto* bitmapGlyph = doc->makeNode(); + bitmapGlyph->advance = 160; + bitmapGlyph->offset = {0.0f, -160.0f}; + bitmapGlyph->image = doc->makeNode(); + bitmapGlyph->image->filePath = "emoji:1F923"; + bitmapFont->glyphs.push_back(bitmapGlyph); + + auto* emoji = doc->makeNode(); + emoji->text = "🤣"; + emoji->fontFamily = "Inter"; + emoji->fontStyle = "SemiBold"; + emoji->fontSize = 32.0f; + auto* emojiRun = doc->makeNode(); + emojiRun->font = bitmapFont; + emojiRun->fontSize = 32.0f; + emojiRun->glyphs = {1}; + emojiRun->y = 31.0f; + emojiRun->positions = {{160.0f, 0.0f}}; + emoji->glyphRuns.push_back(emojiRun); + + auto addTextGroup = [&](pagx::Text* text) { + auto* group = doc->makeNode(); + group->elements.push_back(text); + auto* fill = doc->makeNode(); + auto* solid = doc->makeNode(); + solid->color = {0.0f, 0.0f, 0.0f, 1.0f}; + fill->color = solid; + group->elements.push_back(fill); + layer->contents.push_back(group); + return group; + }; + auto* titleGroup = addTextGroup(title); + addTextGroup(emoji); + + auto* textBox = doc->makeNode(); + textBox->width = 440.0f; + layer->contents.push_back(textBox); + doc->layers.push_back(layer); + doc->applyLayout(); + + pagx::PPTExportOptions options; + options.ignoreGlyphRuns = true; + pagx::PPTWriterContext writerContext; + pagx::FontConfig fontConfig; + pagx::LayoutContext layoutContext(&fontConfig); + pagx::PPTWriter writer(&writerContext, doc.get(), options, &layoutContext); + pagx::XMLBuilder xml; + writer.writeDocument(xml); + auto body = xml.release(); + + auto firstShape = body.find(""); + ASSERT_NE(firstShape, std::string::npos); + EXPECT_EQ(body.find("", firstShape + 1), std::string::npos); + auto titleText = body.find("云原生架构"); + auto emojiText = body.find("🤣"); + ASSERT_NE(titleText, std::string::npos); + ASSERT_NE(emojiText, std::string::npos); + EXPECT_LT(titleText, emojiText); + EXPECT_LT(emojiText, body.find("", emojiText)); + auto titleRunStart = body.rfind("", titleText); + auto emojiRunStart = body.rfind("", emojiText); + ASSERT_NE(titleRunStart, std::string::npos); + ASSERT_NE(emojiRunStart, std::string::npos); + auto titleProperties = body.substr(titleRunStart, titleText - titleRunStart); + auto emojiProperties = body.substr(emojiRunStart, emojiText - emojiRunStart); + EXPECT_EQ(titleProperties.find("baseline="), std::string::npos); + EXPECT_NE(titleProperties.find("sz=\"2400\""), std::string::npos); + EXPECT_NE(emojiProperties.find("baseline=\"-10000\""), std::string::npos); + EXPECT_NE(emojiProperties.find("sz=\"4000\""), std::string::npos); + + // An emoji on another line must not be shifted merely because the TextBox contains normal text. + emoji->text = "\n🤣"; + pagx::PPTWriterContext separateLineWriterContext; + pagx::PPTWriter separateLineWriter(&separateLineWriterContext, doc.get(), options, + &layoutContext); + pagx::XMLBuilder separateLineXML; + separateLineWriter.writeDocument(separateLineXML); + auto separateLineBody = separateLineXML.release(); + auto separateLineEmojiText = separateLineBody.find("🤣"); + ASSERT_NE(separateLineEmojiText, std::string::npos); + auto separateLineEmojiRun = separateLineBody.rfind("", separateLineEmojiText); + ASSERT_NE(separateLineEmojiRun, std::string::npos); + EXPECT_EQ( + separateLineBody.substr(separateLineEmojiRun, separateLineEmojiText - separateLineEmojiRun) + .find("baseline="), + std::string::npos); + emoji->text = "🤣"; + + // Bitmap glyphs that are not explicit emoji resources must retain their authored style. + bitmapGlyph->image->filePath = "icon.png"; + pagx::PPTWriterContext bitmapIconWriterContext; + pagx::PPTWriter bitmapIconWriter(&bitmapIconWriterContext, doc.get(), options, &layoutContext); + pagx::XMLBuilder bitmapIconXML; + bitmapIconWriter.writeDocument(bitmapIconXML); + auto bitmapIconBody = bitmapIconXML.release(); + auto bitmapIconText = bitmapIconBody.find("🤣"); + ASSERT_NE(bitmapIconText, std::string::npos); + auto bitmapIconRun = bitmapIconBody.rfind("", bitmapIconText); + ASSERT_NE(bitmapIconRun, std::string::npos); + EXPECT_EQ(bitmapIconBody.substr(bitmapIconRun, bitmapIconText - bitmapIconRun).find("baseline="), + std::string::npos); + bitmapGlyph->image->filePath = "emoji:1F923"; + + // A transformed child is not safe to flatten into the TextBox transform. Verify that the + // exporter keeps the original per-geometry path instead of applying the rich-text optimization. + titleGroup->rotation = 1.0f; + pagx::PPTWriterContext fallbackWriterContext; + pagx::PPTWriter fallbackWriter(&fallbackWriterContext, doc.get(), options, &layoutContext); + pagx::XMLBuilder fallbackXML; + fallbackWriter.writeDocument(fallbackXML); + auto fallbackBody = fallbackXML.release(); + auto fallbackFirstShape = fallbackBody.find(""); + ASSERT_NE(fallbackFirstShape, std::string::npos); + EXPECT_NE(fallbackBody.find("", fallbackFirstShape + 1), std::string::npos); +} + PAGX_TEST(PAGXPPTTest, MultipleElementsInLayer) { auto doc = pagx::PAGXDocument::Make(500, 400); auto* layer = doc->makeNode(); From 53c736b87e75d5a02ade695481679e8f9f7d219e Mon Sep 17 00:00:00 2001 From: OnionsYu Date: Fri, 31 Jul 2026 11:46:36 +0800 Subject: [PATCH 17/21] Keep native PowerPoint wrapping enabled for text with an authored inline extent even when PAGX line metadata is present so Web/WASM font differences can still rewrap lines. --- src/pagx/ppt/PPTTextWriter.cpp | 48 ++++++++++++++++++---------------- src/pagx/ppt/PPTWriter.h | 4 +-- test/src/PAGXPPTTest.cpp | 5 +++- 3 files changed, 32 insertions(+), 25 deletions(-) diff --git a/src/pagx/ppt/PPTTextWriter.cpp b/src/pagx/ppt/PPTTextWriter.cpp index 7ceac6bae9..d6c9306a18 100644 --- a/src/pagx/ppt/PPTTextWriter.cpp +++ b/src/pagx/ppt/PPTTextWriter.cpp @@ -428,17 +428,22 @@ void PPTWriter::emitTextShapeEnvelope(XMLBuilder& out, const Xform& xf, const Te } void PPTWriter::emitNativeTextShapeFrame(XMLBuilder& out, const Matrix& m, - const NativeTextGeometry& geom, const TextBox* textBox, - bool useLineLayout) { + const NativeTextGeometry& geom, const TextBox* textBox) { auto xf = DecomposeXform(geom.posX, geom.posY, geom.estWidth, geom.estHeight, m); - // Justify alignment requires PowerPoint to know a target line width; with - // wrap="none" the text is unbounded so PPT silently falls back to start - // alignment. Use wrap="square" in that case so PPT can justify within the - // shape's text area (our PAGX-determined visual lines should fit, so PPT - // shouldn't introduce additional wraps). - bool justifyAlign = textBox && textBox->textAlign == TextAlign::Justify; - const char* wrap = - useLineLayout ? (justifyAlign ? "square" : "none") : (geom.hasTextBox ? "square" : "none"); + // A fixed inline extent is an authored wrapping boundary. Keep native + // PowerPoint wrapping enabled even when PAGX supplied line metadata and we + // also emit soft breaks: Web/WASM may shape with a different or unavailable + // fallback font and report one oversized line, while PowerPoint can still + // wrap that line against the authored TextBox extent. Auto-sized and + // zero-extent anchor boxes remain unbounded. + bool hasInlineExtent = geom.hasTextBox; + if (textBox != nullptr) { + float inlineExtent = textBox->writingMode == WritingMode::Vertical + ? EffectiveTextBoxHeight(textBox) + : EffectiveTextBoxWidth(textBox); + hasInlineExtent = !std::isnan(inlineExtent) && inlineExtent > 0; + } + const char* wrap = hasInlineExtent ? "square" : "none"; emitTextShapeEnvelope(out, xf, textBox, wrap, geom.originFromGlyphRun, !geom.originFromGlyphRun || geom.frameUsesLineBox); } @@ -554,7 +559,7 @@ void PPTWriter::writeNativeText(XMLBuilder& out, const Text* text, const FillStr // has no line info; fall back to PowerPoint-driven wrapping in that case. bool useLineLayout = (lines != nullptr) && !lines->empty(); - emitNativeTextShapeFrame(out, m, geom, fs.textBox, useLineLayout); + emitNativeTextShapeFrame(out, m, geom, fs.textBox); // Paragraph base direction by UBA P2/P3. Emitted via pPr@rtl so PowerPoint // runs BiDi with the correct base direction and reproduces the same visual @@ -796,8 +801,7 @@ void PPTWriter::ParagraphEmitter::emitLineBreak(const PPTRunStyle& style) { } void PPTWriter::emitTextBoxShapeFrame(XMLBuilder& out, const TextBox* box, const Matrix& transform, - float estWidth, float estHeight, bool useLineLayout, - bool hasBoxWidth) { + float estWidth, float estHeight) { // `transform` already incorporates BuildGroupMatrix(box) (applied by the // caller in writeElements), so the local origin here is (0, 0). Adding // box->position again would double-offset the shape, pushing the text-box @@ -844,14 +848,14 @@ void PPTWriter::emitTextBoxShapeFrame(XMLBuilder& out, const TextBox* box, const } } auto xf = DecomposeXform(anchorOffsetX, anchorOffsetY, estWidth, estHeight, transform); - // Justify alignment requires PowerPoint to know a target line width; with - // wrap="none" the text is unbounded so PPT silently falls back to start - // alignment. Use wrap="square" in that case so PPT can justify within the - // shape's text area. Our PAGX-determined visual lines should already fit - // within the shape, so PPT shouldn't introduce additional wraps. - bool justifyAlign = box->textAlign == TextAlign::Justify; - const char* wrap = - useLineLayout ? (justifyAlign ? "square" : "none") : (hasBoxWidth ? "square" : "none"); + // Preserve the authored inline wrapping boundary as a native PowerPoint + // fallback even when PAGX line metadata is available. This keeps Web/WASM + // output usable when its font environment computes fewer line breaks than + // the environment that authored the PAGX file. + float inlineExtent = + isVertical ? EffectiveTextBoxHeight(box) : EffectiveTextBoxWidth(box); + bool hasInlineExtent = !std::isnan(inlineExtent) && inlineExtent > 0; + const char* wrap = hasInlineExtent ? "square" : "none"; emitTextShapeEnvelope(out, xf, box, wrap); } @@ -1041,7 +1045,7 @@ void PPTWriter::writeTextBoxGroup(XMLBuilder& out, const Group* textBox, useLineLayout = false; } - emitTextBoxShapeFrame(out, box, transform, estWidth, estHeight, useLineLayout, hasBoxWidth); + emitTextBoxShapeFrame(out, box, transform, estWidth, estHeight); // Paragraph base direction by UBA P2/P3. A TextBox carries a single pPr so // all runs share one base direction; concatenating the run text in source diff --git a/src/pagx/ppt/PPTWriter.h b/src/pagx/ppt/PPTWriter.h index 40c53e6be9..f6af1ffe63 100644 --- a/src/pagx/ppt/PPTWriter.h +++ b/src/pagx/ppt/PPTWriter.h @@ -800,7 +800,7 @@ class PPTWriter { const FillStrokeInfo& fs, const TextLayoutResult* precomputed); void emitNativeTextShapeFrame(XMLBuilder& out, const Matrix& m, const NativeTextGeometry& geom, - const TextBox* textBox, bool useLineLayout); + const TextBox* textBox); void emitNativeTextBody(XMLBuilder& out, const Text* text, const std::vector* lines, const PPTRunStyle& style, int64_t lnSpcPts, bool rtl, bool useLineLayout, int64_t defTabSzEMU, @@ -855,7 +855,7 @@ class PPTWriter { }; void emitTextBoxShapeFrame(XMLBuilder& out, const TextBox* box, const Matrix& transform, - float estWidth, float estHeight, bool useLineLayout, bool hasBoxWidth); + float estWidth, float estHeight); void emitTextBoxBody(const std::vector& runs, const std::vector& runStyles, std::vector& lineEntries, bool useLineLayout, diff --git a/test/src/PAGXPPTTest.cpp b/test/src/PAGXPPTTest.cpp index 75a4e7d238..f6b5cd338e 100644 --- a/test/src/PAGXPPTTest.cpp +++ b/test/src/PAGXPPTTest.cpp @@ -1552,7 +1552,9 @@ PAGX_TEST(PAGXPPTTest, TextIgnoreGlyphRunsPreservesLineBoxVerticalAlignment) { writer.writeDocument(xml); auto body = xml.release(); - EXPECT_NE(body.find(""), std::string::npos); // Horizontal textAlign is already encoded by run->x, so centering must not @@ -1699,6 +1701,7 @@ PAGX_TEST(PAGXPPTTest, TextIgnoreGlyphRunsCombinesModifierTextBoxRuns) { auto firstShape = body.find(""); ASSERT_NE(firstShape, std::string::npos); EXPECT_EQ(body.find("", firstShape + 1), std::string::npos); + EXPECT_NE(body.find("云原生架构"); auto emojiText = body.find("🤣"); ASSERT_NE(titleText, std::string::npos); From 39b9dc0fc672d3243cf9009d8ee780100ce3c6bc Mon Sep 17 00:00:00 2001 From: OnionsYu Date: Fri, 31 Jul 2026 12:42:27 +0800 Subject: [PATCH 18/21] Reformat the native PPT inline-extent assignment onto a single line to match the project code style. --- src/pagx/ppt/PPTTextWriter.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pagx/ppt/PPTTextWriter.cpp b/src/pagx/ppt/PPTTextWriter.cpp index d6c9306a18..a793f44bba 100644 --- a/src/pagx/ppt/PPTTextWriter.cpp +++ b/src/pagx/ppt/PPTTextWriter.cpp @@ -852,8 +852,7 @@ void PPTWriter::emitTextBoxShapeFrame(XMLBuilder& out, const TextBox* box, const // fallback even when PAGX line metadata is available. This keeps Web/WASM // output usable when its font environment computes fewer line breaks than // the environment that authored the PAGX file. - float inlineExtent = - isVertical ? EffectiveTextBoxHeight(box) : EffectiveTextBoxWidth(box); + float inlineExtent = isVertical ? EffectiveTextBoxHeight(box) : EffectiveTextBoxWidth(box); bool hasInlineExtent = !std::isnan(inlineExtent) && inlineExtent > 0; const char* wrap = hasInlineExtent ? "square" : "none"; emitTextShapeEnvelope(out, xf, box, wrap); From 2fab40d5aade4fe140d21e28d16701dae7b07680 Mon Sep 17 00:00:00 2001 From: OnionsYu Date: Fri, 31 Jul 2026 14:06:11 +0800 Subject: [PATCH 19/21] Restore native PPT wrapping for justified paragraphs in text boxes that are auto-sized along the inline axis, so PowerPoint keeps a target line width instead of silently falling back to start alignment. --- .codebuddy/skills/pagx/references/cli.md | 2 + include/pagx/PPTExporter.h | 4 +- src/cli/CommandExport.cpp | 3 +- src/pagx/ppt/PPTExporter.cpp | 7 +++ src/pagx/ppt/PPTTextWriter.cpp | 51 ++++++++++++++------ src/pagx/ppt/PPTWriter.h | 10 ++-- test/src/PAGXCliTest.cpp | 24 ++++++++++ test/src/PAGXPPTTest.cpp | 61 ++++++++++++++++++++++++ 8 files changed, 143 insertions(+), 19 deletions(-) diff --git a/.codebuddy/skills/pagx/references/cli.md b/.codebuddy/skills/pagx/references/cli.md index 99ae2ec8f4..ebd0b37d5f 100644 --- a/.codebuddy/skills/pagx/references/cli.md +++ b/.codebuddy/skills/pagx/references/cli.md @@ -490,6 +490,7 @@ pagx export --input a.pagx --input b.pagx --output deck.pptx # multi-slide deck 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 ``` @@ -499,6 +500,7 @@ pagx export --input icon.pagx --output out.html # PAGX to HTML | `--output ` | Output file (default: `.`) | | `--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 ` | Indentation spaces (default: 2, valid range: 0–16) | | `--svg-no-xml-declaration` | Omit the `` 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 | diff --git a/include/pagx/PPTExporter.h b/include/pagx/PPTExporter.h index 20e51906ad..02cdb68d03 100644 --- a/include/pagx/PPTExporter.h +++ b/include/pagx/PPTExporter.h @@ -47,7 +47,9 @@ struct PPTExportOptions { * 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. The default value is false. + * 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; diff --git a/src/cli/CommandExport.cpp b/src/cli/CommandExport.cpp index 33da1f3b1c..749ce0990d 100644 --- a/src/cli/CommandExport.cpp +++ b/src/cli/CommandExport.cpp @@ -82,7 +82,8 @@ static void PrintUsage() { << " 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.\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" diff --git a/src/pagx/ppt/PPTExporter.cpp b/src/pagx/ppt/PPTExporter.cpp index 8c4f2b69aa..0ae5f164ce 100644 --- a/src/pagx/ppt/PPTExporter.cpp +++ b/src/pagx/ppt/PPTExporter.cpp @@ -995,6 +995,13 @@ long ZCALLBACK MemZipSeek(voidpf, voidpf stream, uLong offset, int origin) { default: return -1; } + // minizip only ever seeks within the bytes it has already written (back to a + // local header to patch its CRC / sizes, then forward to the end again). + // Rejecting anything beyond that turns an unexpected seek into a reported + // failure instead of a silently zero-filled, corrupt archive. + if (offset > buffer->data.size() - base) { + return -1; + } buffer->position = base + offset; return 0; } diff --git a/src/pagx/ppt/PPTTextWriter.cpp b/src/pagx/ppt/PPTTextWriter.cpp index a793f44bba..4ff5ac6b62 100644 --- a/src/pagx/ppt/PPTTextWriter.cpp +++ b/src/pagx/ppt/PPTTextWriter.cpp @@ -54,10 +54,18 @@ void WriteRunTypeface(XMLBuilder& out, const std::string& typeface) { out.openElement("a:cs").addRequiredAttribute("typeface", typeface).closeElementSelfClosing(); } +// True when any path component of `filePath` is an "emoji:" pseudo-path. Every +// occurrence is inspected rather than only the first, so a directory that merely ends in "emoji:" +// (e.g. "assets/myemoji:cache/emoji:1F923") does not hide the real marker behind it. bool IsEmojiImagePath(const std::string& filePath) { - auto marker = filePath.find("emoji:"); - return marker != std::string::npos && - (marker == 0 || filePath[marker - 1] == '/' || filePath[marker - 1] == '\\'); + constexpr char marker[] = "emoji:"; + for (auto pos = filePath.find(marker); pos != std::string::npos; + pos = filePath.find(marker, pos + 1)) { + if (pos == 0 || filePath[pos - 1] == '/' || filePath[pos - 1] == '\\') { + return true; + } + } + return false; } // PAGX represents color emoji glyphs with an "emoji:" image source. Importing a PAGX @@ -243,8 +251,10 @@ PPTWriter::NativeTextGeometry PPTWriter::computeNativeTextGeometry( // run->y (e.g. 27 / 63 / 99 for a 36px line height), so routing them through // the TextBox branch below would collapse every line onto the box's single // origin. Mirror writeTextAsPath here so native fallback keeps the same - // vertical layout. Only the first run carries the block-level offset. - if (!text->glyphRuns.empty() && text->glyphRuns.front() != nullptr) { + // vertical layout. Only the first run carries the block-level offset. This only applies to the + // editable-text mode: outside it a Text with GlyphRuns is emitted as glyph paths and never + // reaches writeNativeText. + if (_ignoreGlyphRuns && !text->glyphRuns.empty() && text->glyphRuns.front() != nullptr) { const GlyphRun* firstRun = text->glyphRuns.front(); geom.originFromGlyphRun = true; auto renderPos = text->renderPosition(); @@ -334,9 +344,9 @@ PPTWriter::NativeTextGeometry PPTWriter::computeNativeTextGeometry( float boxWidth = fs.textBox ? EffectiveTextBoxWidth(fs.textBox) : NAN; float boxHeight = fs.textBox ? EffectiveTextBoxHeight(fs.textBox) : NAN; - geom.hasTextBox = fs.textBox && !std::isnan(boxWidth) && boxWidth > 0; + bool hasTextBox = fs.textBox && !std::isnan(boxWidth) && boxWidth > 0; - if (geom.hasTextBox) { + if (hasTextBox) { geom.posX = fs.textBox->renderPosition().x; geom.posY = fs.textBox->renderPosition().y; geom.estWidth = boxWidth; @@ -436,14 +446,22 @@ void PPTWriter::emitNativeTextShapeFrame(XMLBuilder& out, const Matrix& m, // fallback font and report one oversized line, while PowerPoint can still // wrap that line against the authored TextBox extent. Auto-sized and // zero-extent anchor boxes remain unbounded. - bool hasInlineExtent = geom.hasTextBox; + bool hasInlineExtent = false; if (textBox != nullptr) { float inlineExtent = textBox->writingMode == WritingMode::Vertical ? EffectiveTextBoxHeight(textBox) : EffectiveTextBoxWidth(textBox); hasInlineExtent = !std::isnan(inlineExtent) && inlineExtent > 0; } - const char* wrap = hasInlineExtent ? "square" : "none"; + // Justify alignment requires PowerPoint to know a target line width; with + // wrap="none" the text is unbounded so PPT silently falls back to start + // alignment. Force wrap="square" in that case so PPT can justify within the + // shape's text area even when the box itself is auto-sized. A glyph-origin + // frame never emits algn (the pen origin already encodes the alignment), so + // it needs no such fallback. + bool justifyAlign = + textBox != nullptr && textBox->textAlign == TextAlign::Justify && !geom.originFromGlyphRun; + const char* wrap = (hasInlineExtent || justifyAlign) ? "square" : "none"; emitTextShapeEnvelope(out, xf, textBox, wrap, geom.originFromGlyphRun, !geom.originFromGlyphRun || geom.frameUsesLineBox); } @@ -854,7 +872,12 @@ void PPTWriter::emitTextBoxShapeFrame(XMLBuilder& out, const TextBox* box, const // the environment that authored the PAGX file. float inlineExtent = isVertical ? EffectiveTextBoxHeight(box) : EffectiveTextBoxWidth(box); bool hasInlineExtent = !std::isnan(inlineExtent) && inlineExtent > 0; - const char* wrap = hasInlineExtent ? "square" : "none"; + // Justify alignment requires PowerPoint to know a target line width; with + // wrap="none" the text is unbounded so PPT silently falls back to start + // alignment. Force wrap="square" in that case so PPT can justify within the + // shape's text area even when the box is auto-sized in the inline axis. + bool justifyAlign = box->textAlign == TextAlign::Justify; + const char* wrap = (hasInlineExtent || justifyAlign) ? "square" : "none"; emitTextShapeEnvelope(out, xf, box, wrap); } @@ -1089,16 +1112,16 @@ void PPTWriter::writeTextBoxGroup(XMLBuilder& out, const Group* textBox, // Alignment lives on a:pPr (not a:rPr) so we leave style.algn at nullptr here. std::vector runStyles; runStyles.reserve(runs.size()); - std::vector emojiRuns(runs.size(), false); std::vector adjustEmojiRuns(runs.size(), false); - for (size_t i = 0; i < runs.size(); ++i) { - emojiRuns[i] = HasOnlyEmbeddedEmojiGlyphs(runs[i].text); - } // A run style cannot vary between lines, so only adjust an emoji run that contributes exactly // one laid-out line and has ordinary text on that same baseline. This avoids moving standalone // emoji lines merely because an unrelated line elsewhere in the TextBox contains normal text. if (_ignoreGlyphRuns && !isVertical && useLineLayout) { constexpr float baselineEpsilon = 0.5f; + std::vector emojiRuns(runs.size(), false); + for (size_t i = 0; i < runs.size(); ++i) { + emojiRuns[i] = HasOnlyEmbeddedEmojiGlyphs(runs[i].text); + } for (size_t runIndex = 0; runIndex < runs.size(); ++runIndex) { if (!emojiRuns[runIndex]) { continue; diff --git a/src/pagx/ppt/PPTWriter.h b/src/pagx/ppt/PPTWriter.h index f6af1ffe63..010bf965b1 100644 --- a/src/pagx/ppt/PPTWriter.h +++ b/src/pagx/ppt/PPTWriter.h @@ -665,11 +665,16 @@ class PPTWriter { uint32_t byteEnd = 0; }; + // convertTextToPath and ignoreGlyphRuns are direct opposites — one asks for glyph outlines, the + // other for editable runs — so resolve the conflict once here instead of letting whichever + // branch happens to be checked first decide. convertTextToPath wins because it is the stronger + // request: it guarantees the slide renders identically without depending on the reader's fonts. PPTWriter(PPTWriterContext* ctx, PAGXDocument* doc, const PPTExporter::Options& options, LayoutContext* layoutContext) : _ctx(ctx), _doc(doc), _convertTextToPath(options.convertTextToPath), - _ignoreGlyphRuns(options.ignoreGlyphRuns), _bridgeContours(options.bridgeContours), - _resolveModifiers(options.resolveModifiers), _bakeUnsupported(options.bakeUnsupported), + _ignoreGlyphRuns(options.ignoreGlyphRuns && !options.convertTextToPath), + _bridgeContours(options.bridgeContours), _resolveModifiers(options.resolveModifiers), + _bakeUnsupported(options.bakeUnsupported), _rasterScale(std::clamp(options.rasterScale, 0.01f, 4.0f)), _layoutContext(layoutContext), _resolver(doc) { } @@ -782,7 +787,6 @@ class PPTWriter { float posY = 0; float estWidth = 0; float estHeight = 0; - bool hasTextBox = false; // True when posX/posY were taken from the GlyphRun pen origin (glyphRun- // carrying Text rendered as native fallback). In that case the modifier // TextBox's horizontal alignment is already baked into the origin, so the diff --git a/test/src/PAGXCliTest.cpp b/test/src/PAGXCliTest.cpp index 3d64eb7be6..a226577dcb 100644 --- a/test/src/PAGXCliTest.cpp +++ b/test/src/PAGXCliTest.cpp @@ -1563,6 +1563,30 @@ CLI_TEST(PAGXCliTest, Export_PagxToPptx_SingleInputOneSlide) { EXPECT_EQ(bytes.find("ppt/slides/slide2.xml"), std::string::npos); } +// --ppt-ignore-glyphruns swaps the pre-shaped glyph paths for editable a:t runs, so the readable +// text ends up in the slide XML. --text-to-path is its opposite and wins when both are given. +CLI_TEST(PAGXCliTest, Export_PagxToPptx_IgnoreGlyphRuns) { + auto inputPath = TestResourcePath("render_text.pagx"); + auto editablePath = TempDir() + "/ExportPPTX_IgnoreGlyphRuns.pptx"; + auto ret = CallRun(pagx::cli::RunExport, {"export", "--input", inputPath, "--output", + editablePath, "--ppt-ignore-glyphruns"}); + EXPECT_EQ(ret, 0); + ASSERT_TRUE(std::filesystem::exists(editablePath)); + EXPECT_GT(std::filesystem::file_size(editablePath), 0u); + + auto pathsPath = TempDir() + "/ExportPPTX_IgnoreGlyphRunsWithTextToPath.pptx"; + ret = CallRun(pagx::cli::RunExport, {"export", "--input", inputPath, "--output", pathsPath, + "--ppt-ignore-glyphruns", "--text-to-path"}); + EXPECT_EQ(ret, 0); + ASSERT_TRUE(std::filesystem::exists(pathsPath)); + // Combining the two flags must behave exactly like --text-to-path alone. + auto textToPathOnlyPath = TempDir() + "/ExportPPTX_TextToPathOnly.pptx"; + ret = CallRun(pagx::cli::RunExport, + {"export", "--input", inputPath, "--output", textToPathOnlyPath, "--text-to-path"}); + EXPECT_EQ(ret, 0); + EXPECT_EQ(ReadFile(pathsPath), ReadFile(textToPathOnlyPath)); +} + // If one of several inputs fails to load, the whole export aborts with an error // rather than silently dropping the bad slide. CLI_TEST(PAGXCliTest, Export_PagxToPptx_MultipleInputsOneMissing) { diff --git a/test/src/PAGXPPTTest.cpp b/test/src/PAGXPPTTest.cpp index f6b5cd338e..fc4d0b5267 100644 --- a/test/src/PAGXPPTTest.cpp +++ b/test/src/PAGXPPTTest.cpp @@ -65,6 +65,7 @@ #include "pagx/nodes/TrimPath.h" #include "pagx/ppt/PPTWriter.h" #include "pagx/ppt/PPTWriterContext.h" +#include "pagx/utils/TextUtils.h" #include "pagx/xml/XMLBuilder.h" #include "utils/ProjectPath.h" #include "utils/TestUtils.h" @@ -1491,6 +1492,31 @@ PAGX_TEST(PAGXPPTTest, TextIgnoreGlyphRuns) { pagx::PPTExportOptions options; options.ignoreGlyphRuns = true; ASSERT_TRUE(ExportAndVerify(*doc, "text_ignore_glyph_runs", options)); + + auto writeBody = [&](const pagx::PPTExportOptions& opts) { + pagx::PPTWriterContext writerContext; + pagx::FontConfig fontConfig; + pagx::LayoutContext layoutContext(&fontConfig); + pagx::PPTWriter writer(&writerContext, doc.get(), opts, &layoutContext); + pagx::XMLBuilder xml; + writer.writeDocument(xml); + return xml.release(); + }; + // The flag replaces the glyph outlines with an editable run carrying the readable text. + auto editableBody = writeBody(options); + EXPECT_NE(editableBody.find("A"), std::string::npos); + EXPECT_EQ(editableBody.find("a:custGeom"), std::string::npos); + + // convertTextToPath asks for the opposite and is the stronger guarantee (the slide renders the + // same without the reader's fonts), so it wins when both are set instead of one flag silently + // cancelling the other depending on which branch is evaluated first. + pagx::PPTExportOptions conflictingOptions; + conflictingOptions.ignoreGlyphRuns = true; + conflictingOptions.convertTextToPath = true; + pagx::PPTExportOptions pathOnlyOptions; + pathOnlyOptions.convertTextToPath = true; + EXPECT_EQ(writeBody(conflictingOptions), writeBody(pathOnlyOptions)); + EXPECT_EQ(writeBody(conflictingOptions).find("A"), std::string::npos); } PAGX_TEST(PAGXPPTTest, TextIgnoreGlyphRunsPreservesLineBoxVerticalAlignment) { @@ -5969,6 +5995,41 @@ PAGX_TEST(PAGXPPTTest, TextBoxJustifyTextAlign) { ASSERT_TRUE(ExportAndVerify(*doc, "textbox_justify")); } +// Justify needs a bounded text area: with wrap="none" PowerPoint has no target line width to +// distribute the extra space across and silently falls back to start alignment. A modifier TextBox +// is a bare typography carrier with no resolved layout box, so it has no authored inline extent to +// wrap against — the exporter must still turn wrapping on for the justified paragraph. +PAGX_TEST(PAGXPPTTest, TextBoxJustifyAutoInlineExtentKeepsWrap) { + auto doc = pagx::PAGXDocument::Make(400, 250); + auto* layer = doc->makeNode(); + auto* text = doc->makeNode(); + text->text = "Justified text content here."; + text->fontFamily = "Arial"; + text->fontSize = 16; + text->position = {40, 100}; + layer->contents.push_back(text); + layer->contents.push_back(MakeSolidFill(doc.get(), {0.0f, 0.0f, 0.0f, 1.0f})); + auto* tb = doc->makeNode(); + tb->textAlign = pagx::TextAlign::Justify; + layer->contents.push_back(tb); + doc->layers.push_back(layer); + doc->applyLayout(); + + ASSERT_TRUE(std::isnan(pagx::EffectiveTextBoxWidth(tb))) + << "the box must stay auto-sized for this test to cover the justify fallback"; + + pagx::PPTWriterContext writerContext; + pagx::FontConfig fontConfig; + pagx::LayoutContext layoutContext(&fontConfig); + pagx::PPTWriter writer(&writerContext, doc.get(), {}, &layoutContext); + pagx::XMLBuilder xml; + writer.writeDocument(xml); + auto body = xml.release(); + + EXPECT_NE(body.find(" Date: Fri, 31 Jul 2026 15:07:07 +0800 Subject: [PATCH 20/21] Fix auto-sized native PPT text frames from shifting in Web/WASM builds by using the embedded authoring glyph-run bounds for the envelope instead of the platform-dependent runtime layout width. --- src/pagx/ppt/PPTTextWriter.cpp | 30 ++++++++++--- test/src/PAGXPPTTest.cpp | 77 ++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/src/pagx/ppt/PPTTextWriter.cpp b/src/pagx/ppt/PPTTextWriter.cpp index 4ff5ac6b62..f7cbd95e67 100644 --- a/src/pagx/ppt/PPTTextWriter.cpp +++ b/src/pagx/ppt/PPTTextWriter.cpp @@ -1021,19 +1021,37 @@ void PPTWriter::writeTextBoxGroup(XMLBuilder& out, const Group* textBox, mutableTexts.push_back(const_cast(run.text)); } auto params = MakeTextBoxParams(box); - auto layoutResult = TextLayout::Layout(TextLayout::MakeElements(mutableTexts), params, - _layoutContext, !_ignoreGlyphRuns); - + auto textElements = TextLayout::MakeElements(mutableTexts); + auto layoutResult = TextLayout::Layout(textElements, params, _layoutContext, !_ignoreGlyphRuns); + + // Editable export re-shapes Text::text to recover line and baseline metadata, but that fresh + // layout is environment-dependent. In Web/WASM a missing typeface may fall back to the + // character-count estimate (0.6 * fontSize per character), making an auto-sized centered frame + // wider than the authored text and shifting every visible line. GlyphRun::bounds stores the + // authoring layout's block bounds, so keep those dimensions for the PowerPoint envelope while + // still using the fresh result above for line entries and editable runs. + Rect embeddedBounds = {}; + bool allHaveEmbeddedGlyphRuns = _ignoreGlyphRuns; + for (const auto& run : runs) { + if (run.text->glyphRuns.empty()) { + allHaveEmbeddedGlyphRuns = false; + break; + } + } + if (allHaveEmbeddedGlyphRuns) { + embeddedBounds = TextLayout::Layout(textElements, params, _layoutContext, true).bounds; + } float boxWidth = EffectiveTextBoxWidth(box); float boxHeight = EffectiveTextBoxHeight(box); bool hasBoxWidth = !std::isnan(boxWidth) && boxWidth > 0; - float estWidth = hasBoxWidth ? boxWidth : layoutResult.bounds.width; + float autoWidth = embeddedBounds.width > 0 ? embeddedBounds.width : layoutResult.bounds.width; + float estWidth = hasBoxWidth ? boxWidth : autoWidth; if (estWidth <= 0) { estWidth = static_cast(CountUTF8Characters(runs.front().text->text)) * runs.front().text->renderFontSize() * 0.6f; } - float estHeight = - (!std::isnan(boxHeight) && boxHeight > 0) ? boxHeight : layoutResult.bounds.height; + float autoHeight = embeddedBounds.height > 0 ? embeddedBounds.height : layoutResult.bounds.height; + float estHeight = (!std::isnan(boxHeight) && boxHeight > 0) ? boxHeight : autoHeight; if (estHeight <= 0) { estHeight = runs.front().text->renderFontSize() * 1.4f; } diff --git a/test/src/PAGXPPTTest.cpp b/test/src/PAGXPPTTest.cpp index fc4d0b5267..86ee30d60c 100644 --- a/test/src/PAGXPPTTest.cpp +++ b/test/src/PAGXPPTTest.cpp @@ -1791,6 +1791,83 @@ PAGX_TEST(PAGXPPTTest, TextIgnoreGlyphRunsCombinesModifierTextBoxRuns) { EXPECT_NE(fallbackBody.find("", fallbackFirstShape + 1), std::string::npos); } +PAGX_TEST(PAGXPPTTest, TextIgnoreGlyphRunsAutoTextBoxUsesEmbeddedBounds) { + auto doc = pagx::PAGXDocument::Make(400, 200); + auto* layer = doc->makeNode(); + + auto* font = doc->makeNode(); + font->unitsPerEm = 1000; + auto* glyph = doc->makeNode(); + glyph->advance = 500; + glyph->path = doc->makeNode(); + glyph->path->moveTo(0, -800); + glyph->path->lineTo(500, -800); + glyph->path->lineTo(500, 0); + glyph->path->lineTo(0, 0); + glyph->path->close(); + font->glyphs.push_back(glyph); + + auto* firstLine = doc->makeNode(); + // Deliberately use readable content whose runtime fallback estimate is much wider than the + // authored block. This reproduces Web/WASM builds where the requested typeface is unavailable. + firstLine->text = "MMMMMMMM\n"; + firstLine->fontFamily = "Unavailable Test Font"; + firstLine->fontSize = 52.0f; + auto* firstRun = doc->makeNode(); + firstRun->font = font; + firstRun->fontSize = 52.0f; + firstRun->glyphs = {1, 1, 1, 1, 1, 1, 1, 1}; + firstRun->y = 48.0f; + firstRun->bounds = pagx::Rect::MakeXYWH(0, 0, 161, 116); + firstLine->glyphRuns.push_back(firstRun); + + auto* secondLine = doc->makeNode(); + secondLine->text = "亿"; + secondLine->fontFamily = "Unavailable Test Font"; + secondLine->fontSize = 52.0f; + auto* secondRun = doc->makeNode(); + secondRun->font = font; + secondRun->fontSize = 52.0f; + secondRun->glyphs = {1}; + secondRun->x = 54.0f; + secondRun->y = 106.0f; + secondLine->glyphRuns.push_back(secondRun); + + layer->contents.push_back(firstLine); + layer->contents.push_back(secondLine); + auto* fill = doc->makeNode(); + auto* color = doc->makeNode(); + color->color = {1.0f, 0.8f, 0.3f, 1.0f}; + fill->color = color; + layer->contents.push_back(fill); + auto* textBox = doc->makeNode(); + textBox->textAlign = pagx::TextAlign::Center; + textBox->paragraphAlign = pagx::ParagraphAlign::Middle; + textBox->lineHeight = 58.0f; + layer->contents.push_back(textBox); + doc->layers.push_back(layer); + doc->applyLayout(); + + ASSERT_TRUE(std::isnan(pagx::EffectiveTextBoxWidth(textBox))); + ASSERT_TRUE(std::isnan(pagx::EffectiveTextBoxHeight(textBox))); + + pagx::PPTExportOptions options; + options.ignoreGlyphRuns = true; + pagx::PPTWriterContext writerContext; + pagx::FontConfig fontConfig; + pagx::LayoutContext layoutContext(&fontConfig); + pagx::PPTWriter writer(&writerContext, doc.get(), options, &layoutContext); + pagx::XMLBuilder xml; + writer.writeDocument(xml); + auto body = xml.release(); + + // The native text remains editable, but its auto-sized frame must use the embedded 161x116 + // authoring bounds instead of the platform-dependent runtime fallback width. + EXPECT_NE(body.find(""), std::string::npos); + EXPECT_NE(body.find("MMMMMMMM"), std::string::npos); + EXPECT_NE(body.find("亿"), std::string::npos); +} + PAGX_TEST(PAGXPPTTest, MultipleElementsInLayer) { auto doc = pagx::PAGXDocument::Make(500, 400); auto* layer = doc->makeNode(); From 43c8cdb1c042617a1a67b3ba26c3b81180884456 Mon Sep 17 00:00:00 2001 From: OnionsYu Date: Fri, 31 Jul 2026 16:23:38 +0800 Subject: [PATCH 21/21] Fail the whole PPT export when a document has unresolved imports or reports a layout error instead of silently emitting a blank slide, clamp zero or non-finite slide sizes to the minimum legal OOXML extent, and skip null glyph runs when computing text bounds. --- include/pagx/PPTExporter.h | 15 +++++++------ src/pagx/ppt/PPTBoilerplate.cpp | 10 +++++++++ src/pagx/ppt/PPTExporter.cpp | 13 ++++++++++-- src/pagx/utils/TextUtils.cpp | 5 ++++- test/src/PAGXPPTTest.cpp | 37 +++++++++++++++++++++++++++++++++ test/src/PAGXUtilsTest.cpp | 26 +++++++++++++++++++++++ 6 files changed, 97 insertions(+), 9 deletions(-) diff --git a/include/pagx/PPTExporter.h b/include/pagx/PPTExporter.h index 02cdb68d03..ef635e8b33 100644 --- a/include/pagx/PPTExporter.h +++ b/include/pagx/PPTExporter.h @@ -115,7 +115,9 @@ struct PPTExportOptions { * 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. + * the whole deck. Later documents keep their native coordinates; content outside the first + * document's canvas is clipped rather than scaled. A structurally valid document with no visible + * content intentionally produces a blank slide. */ class PPTExporter { public: @@ -126,11 +128,12 @@ class PPTExporter { * 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. + * or contain nullptr entries or unresolved imports. * @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 document list was empty / - * contained a nullptr, 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 + * invalid, layout reported an error, the file could not be created, or a write error + * occurred. */ static bool ToFile(const std::vector& documents, const std::string& filePath, const Options& options = {}); @@ -142,10 +145,10 @@ class PPTExporter { * 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. + * or contain nullptr entries or unresolved imports. * @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 + * document list was invalid, layout reported an error, or the documents could not be * serialized. */ static std::shared_ptr ToData(const std::vector& documents, diff --git a/src/pagx/ppt/PPTBoilerplate.cpp b/src/pagx/ppt/PPTBoilerplate.cpp index 4bd88d3d82..7c3102139a 100644 --- a/src/pagx/ppt/PPTBoilerplate.cpp +++ b/src/pagx/ppt/PPTBoilerplate.cpp @@ -129,6 +129,16 @@ std::string GenerateRootRels() { std::string GeneratePresentation(float w, float h, size_t slideCount) { int64_t cx = PxToEMU(w); int64_t cy = PxToEMU(h); + // PxToEMU maps non-finite values to zero, while non-positive document dimensions + // convert to zero or a negative value. Give each invalid axis the minimum legal + // OOXML extent before the aspect-preserving clamp below; dividing by zero there + // would otherwise produce infinity and make the subsequent integer cast undefined. + if (cx <= 0) { + cx = MIN_SLIDE_SIZE_EMU; + } + if (cy <= 0) { + cy = MIN_SLIDE_SIZE_EMU; + } if (cx > MAX_SLIDE_SIZE_EMU || cy > MAX_SLIDE_SIZE_EMU) { double scale = std::min(static_cast(MAX_SLIDE_SIZE_EMU) / static_cast(cx), static_cast(MAX_SLIDE_SIZE_EMU) / static_cast(cy)); diff --git a/src/pagx/ppt/PPTExporter.cpp b/src/pagx/ppt/PPTExporter.cpp index 0ae5f164ce..c3db0bfb4c 100644 --- a/src/pagx/ppt/PPTExporter.cpp +++ b/src/pagx/ppt/PPTExporter.cpp @@ -1075,7 +1075,9 @@ struct SlideBuild { }; // Serializes every document into its own slide. Returns an empty vector when the -// input is invalid (empty list or a nullptr entry) so callers can bail out. Each +// input is invalid (empty list, nullptr entry, unresolved import, or a newly +// reported layout error) so callers can bail out. A structurally valid document +// with no visible content is intentionally retained as a blank slide. Each // slide's media numbering is offset by the running image total so all slides can // share the single ppt/media/ directory without file-name collisions. std::vector BuildSlides(const std::vector& documents, @@ -1087,12 +1089,19 @@ std::vector BuildSlides(const std::vector& documents, slides.reserve(documents.size()); int imageBase = 0; for (auto* doc : documents) { - if (doc == nullptr) { + if (doc == nullptr || doc->hasUnresolvedImports()) { return {}; } + auto errorCount = doc->errors.size(); if (!doc->isLayoutApplied()) { doc->applyLayout(); } + // applyLayout reports structural failures (for example, a cyclic external + // composition) through the document's error list. Do not silently turn the + // failed document into an empty slide while returning success for the deck. + if (!doc->isLayoutApplied() || doc->errors.size() != errorCount) { + return {}; + } SlideBuild slide; slide.context = std::make_unique(imageBase); // The LayoutContext only backs the writer while the slide XML is produced; diff --git a/src/pagx/utils/TextUtils.cpp b/src/pagx/utils/TextUtils.cpp index 65d9d1f294..7ad794db5d 100644 --- a/src/pagx/utils/TextUtils.cpp +++ b/src/pagx/utils/TextUtils.cpp @@ -251,7 +251,7 @@ Rect ComputeGlyphRunTextBounds(const Text& text) { float maxX = std::numeric_limits::lowest(); bool hasGlyph = false; for (const auto* run : text.glyphRuns) { - if (!run->font || run->font->unitsPerEm <= 0 || run->glyphs.empty()) { + if (run == nullptr || !run->font || run->font->unitsPerEm <= 0 || run->glyphs.empty()) { continue; } float scale = run->fontSize / static_cast(run->font->unitsPerEm); @@ -294,6 +294,9 @@ Rect ComputeGlyphRunTextBounds(const Text& text) { float top = std::numeric_limits::max(); float bottom = std::numeric_limits::lowest(); for (const auto* run : text.glyphRuns) { + if (run == nullptr) { + continue; + } if (run->bounds.height > 0) { top = run->bounds.y; bottom = run->bounds.y + run->bounds.height; diff --git a/test/src/PAGXPPTTest.cpp b/test/src/PAGXPPTTest.cpp index 86ee30d60c..30f9780d3c 100644 --- a/test/src/PAGXPPTTest.cpp +++ b/test/src/PAGXPPTTest.cpp @@ -63,6 +63,7 @@ #include "pagx/nodes/TextModifier.h" #include "pagx/nodes/TextPath.h" #include "pagx/nodes/TrimPath.h" +#include "pagx/ppt/PPTBoilerplate.h" #include "pagx/ppt/PPTWriter.h" #include "pagx/ppt/PPTWriterContext.h" #include "pagx/utils/TextUtils.h" @@ -6643,6 +6644,29 @@ PAGX_TEST(PAGXPPTTest, MultiPage_NullEntryFails) { EXPECT_EQ(pagx::PPTExporter::ToData(docs), nullptr); } +PAGX_TEST(PAGXPPTTest, MultiPage_UnresolvedImportFailsWholeExport) { + auto page1 = MakeSimplePPTDoc(); + auto page2 = MakeSimplePPTDoc(); + page2->layers.front()->importDirective.source = "missing.svg"; + auto page3 = MakeSimplePPTDoc(); + + EXPECT_EQ(pagx::PPTExporter::ToData({page1.get(), page2.get(), page3.get()}), nullptr); +} + +PAGX_TEST(PAGXPPTTest, MultiPage_LayoutErrorFailsWholeExport) { + auto page1 = MakeSimplePPTDoc(); + auto page2 = pagx::PAGXDocument::Make(400, 300); + auto* cyclicLayer = page2->makeNode(); + cyclicLayer->externalDoc = page2; + page2->layers.push_back(cyclicLayer); + auto page3 = MakeSimplePPTDoc(); + + EXPECT_EQ(pagx::PPTExporter::ToData({page1.get(), page2.get(), page3.get()}), nullptr); + EXPECT_FALSE(page2->errors.empty()); + // Break the shared_ptr cycle created specifically for this regression case. + cyclicLayer->externalDoc.reset(); +} + // Slides may declare different canvas sizes; the deck adopts the first document's // size and still produces a valid archive with a slide per document. PAGX_TEST(PAGXPPTTest, MultiPage_MixedDocumentSizes) { @@ -6656,6 +6680,19 @@ PAGX_TEST(PAGXPPTTest, MultiPage_MixedDocumentSizes) { std::string bytes(reinterpret_cast(data->bytes()), data->size()); EXPECT_NE(bytes.find("ppt/slides/slide2.xml"), std::string::npos); + + auto presentation = pagx::GeneratePresentation(page1->width, page1->height, docs.size()); + EXPECT_NE(presentation.find(""), std::string::npos); +} + +PAGX_TEST(PAGXPPTTest, ZeroSizedDocumentUsesMinimumSlideSize) { + auto doc = pagx::PAGXDocument::Make(0, 0); + auto data = pagx::PPTExporter::ToData({doc.get()}); + ASSERT_NE(data, nullptr); + EXPECT_TRUE(HasZipMagic(data.get())); + + auto presentation = pagx::GeneratePresentation(doc->width, doc->height, 1); + EXPECT_NE(presentation.find(""), std::string::npos); } } // namespace pag diff --git a/test/src/PAGXUtilsTest.cpp b/test/src/PAGXUtilsTest.cpp index d89714a59a..1c9e4471d3 100644 --- a/test/src/PAGXUtilsTest.cpp +++ b/test/src/PAGXUtilsTest.cpp @@ -1210,6 +1210,32 @@ PAGX_TEST(PAGXUtilsTest, ComputeGlyphRunTextBounds_UnionsMultipleRuns) { EXPECT_FLOAT_EQ(bounds.height, 12.0f); } +// Null GlyphRun entries can occur in hand-authored in-memory documents. They +// must be ignored by both the horizontal span and vertical extent passes. +PAGX_TEST(PAGXUtilsTest, ComputeGlyphRunTextBounds_SkipsNullRuns) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto font = doc->makeNode(); + font->unitsPerEm = 1000; + auto glyph = doc->makeNode(); + glyph->advance = 1000.0f; + font->glyphs.push_back(glyph); + + auto run = doc->makeNode(); + run->font = font; + run->fontSize = 10.0f; + run->glyphs = {1}; + run->x = 5.0f; + run->bounds = pagx::Rect::MakeXYWH(0.0f, 2.0f, 20.0f, 12.0f); + text->glyphRuns = {nullptr, run, nullptr}; + + auto bounds = pagx::ComputeGlyphRunTextBounds(*text); + EXPECT_FLOAT_EQ(bounds.x, 5.0f); + EXPECT_FLOAT_EQ(bounds.width, 10.0f); + EXPECT_FLOAT_EQ(bounds.y, 2.0f); + EXPECT_FLOAT_EQ(bounds.height, 12.0f); +} + // --------------------------------------------------------------------------- // GetPNGDimensionsFromPath (data URI variant) // ---------------------------------------------------------------------------