diff --git a/CMakeLists.txt b/CMakeLists.txt index 2127acd8cf..0697c3b42d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -34,6 +34,7 @@ option(PAG_USE_C "Enable c API" OFF) option(PAG_BUILD_PAGX "Enable PAGX format support" OFF) option(PAG_BUILD_CLI "Enable building the command-line tool" OFF) option(PAG_BUILD_SVG "Enable SVG import/export support" OFF) +option(PAG_BUILD_PDF "Enable PDF export support" OFF) # EMSCRIPTEN_PTHREADS can be set by vendor tools for building the wasm-mt architecture. if (NOT WEB OR EMSCRIPTEN_PTHREADS) @@ -99,6 +100,9 @@ endif () if (PAG_BUILD_SVG) set(PAG_BUILD_PAGX ON) endif () +if (PAG_BUILD_PDF) + set(PAG_BUILD_PAGX ON) +endif () # PAG_BUILD_PAGX enables HarfBuzz if (PAG_BUILD_PAGX) @@ -118,6 +122,7 @@ if (PAG_BUILD_TESTS) set(PAG_BUILD_PAGX ON) set(PAG_BUILD_CLI ON) set(PAG_BUILD_SVG ON) + set(PAG_BUILD_PDF ON) set(PAG_USE_HARFBUZZ ON) set(PAG_USE_SYSTEM_LZ4 OFF) set(PAG_BUILD_SHARED OFF) @@ -131,6 +136,7 @@ message("PAG_USE_HARFBUZZ: ${PAG_USE_HARFBUZZ}") message("PAG_BUILD_PAGX: ${PAG_BUILD_PAGX}") message("PAG_BUILD_CLI: ${PAG_BUILD_CLI}") message("PAG_BUILD_SVG: ${PAG_BUILD_SVG}") +message("PAG_BUILD_PDF: ${PAG_BUILD_PDF}") message("PAG_USE_SYSTEM_LZ4: ${PAG_USE_SYSTEM_LZ4}") message("PAG_USE_C: ${PAG_USE_C}") message("PAG_BUILD_SHARED: ${PAG_BUILD_SHARED}") @@ -199,6 +205,10 @@ if (PAG_BUILD_PAGX) list(APPEND PAG_FILES src/pagx/svg/SVGImporter.cpp src/pagx/svg/SVGParserContext.h) list(APPEND PAG_FILES src/pagx/svg/SVGExporter.cpp src/pagx/svg/SVGTextLayout.cpp) endif () + + if (PAG_BUILD_PDF) + list(APPEND PAG_FILES src/pagx/pdf/PDFExporter.cpp) + endif () endif () file(GLOB COMMON_FILES src/platform/*.*) list(APPEND PAG_FILES ${COMMON_FILES}) diff --git a/include/pagx/PDFExporter.h b/include/pagx/PDFExporter.h new file mode 100644 index 0000000000..71dd859bbf --- /dev/null +++ b/include/pagx/PDFExporter.h @@ -0,0 +1,60 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include "pagx/PAGXDocument.h" + +namespace pagx { + +/** + * Export options for PDFExporter. + */ +struct PDFExportOptions { + /** + * Whether to convert text elements to path elements using pre-shaped glyph outlines. When + * enabled, text with GlyphRun data is rendered as PDF path operators instead of text operators, + * ensuring identical rendering across platforms without font dependency. Falls back to Helvetica + * text operators when glyph outline data is unavailable. The default value is true. + */ + bool convertTextToPath = true; +}; + +/** + * PDFExporter converts a PAGXDocument into PDF 1.4 format. + * Supports vector shapes, solid/gradient fills, strokes, transforms, opacity, clipping, and text. + */ +class PDFExporter { + public: + using Options = PDFExportOptions; + + /** + * Exports a PAGXDocument to a PDF string (binary content). + */ + static std::string ToPDF(const PAGXDocument& document, const Options& options = {}); + + /** + * Exports a PAGXDocument to a PDF file. + * Returns true on success. + */ + static bool ToFile(const PAGXDocument& document, const std::string& filePath, + const Options& options = {}); +}; + +} // namespace pagx diff --git a/src/cli/CommandConvert.cpp b/src/cli/CommandConvert.cpp index 1c95978de8..6049d40c1f 100644 --- a/src/cli/CommandConvert.cpp +++ b/src/cli/CommandConvert.cpp @@ -23,6 +23,7 @@ #include #include "pagx/PAGXExporter.h" #include "pagx/PAGXImporter.h" +#include "pagx/PDFExporter.h" #include "pagx/SVGExporter.h" #include "pagx/SVGImporter.h" @@ -40,12 +41,17 @@ struct SVGImportOptions { bool preserveUnknown = false; }; +struct PDFExportOpts { + bool noConvertTextToPath = false; +}; + struct ConvertOptions { std::string inputFile = {}; std::string outputFile = {}; std::string outputFormat = {}; SVGExportOptions svgExport = {}; SVGImportOptions svgImport = {}; + PDFExportOpts pdfExport = {}; }; static void PrintUsage() { @@ -55,12 +61,15 @@ static void PrintUsage() { << "is inferred from file extensions (e.g. .pagx -> .svg or .svg -> .pagx).\n" << "\n" << "Options:\n" - << " --format Override output format (svg, pagx)\n" + << " --format Override output format (svg, pdf, pagx)\n" << "\n" << "SVG output options:\n" << " --indent Indentation spaces (default: 2, valid range: 0-16)\n" << " --no-xml-declaration Omit the declaration\n" - << " --no-convert-text-to-path Keep text as elements instead of \n" + << " --no-convert-text-to-path Keep text as / instead of paths\n" + << "\n" + << "PDF output options:\n" + << " --no-convert-text-to-path Keep text as PDF text operators instead of paths\n" << "\n" << "SVG input options:\n" << " --no-expand-use Do not expand references\n" @@ -69,9 +78,10 @@ static void PrintUsage() { << "\n" << "Examples:\n" << " pagx convert input.pagx output.svg # PAGX to SVG\n" + << " pagx convert input.pagx output.pdf # PAGX to PDF\n" << " pagx convert input.svg output.pagx # SVG to PAGX\n" << " pagx convert --indent 4 input.pagx out.svg # PAGX to SVG with 4-space indent\n" - << " pagx convert --format svg input.pagx output # specify SVG output format\n"; + << " pagx convert --format pdf input.pagx output # specify PDF output format\n"; } static std::string InferFormat(const std::string& path) { @@ -81,6 +91,9 @@ static std::string InferFormat(const std::string& path) { if (ext == "svg") { return "svg"; } + if (ext == "pdf") { + return "pdf"; + } if (ext == "pagx") { return "pagx"; } @@ -107,6 +120,7 @@ static int ParseOptions(int argc, char* argv[], ConvertOptions* options) { options->svgExport.noXmlDeclaration = true; } else if (arg == "--no-convert-text-to-path") { options->svgExport.noConvertTextToPath = true; + options->pdfExport.noConvertTextToPath = true; } else if (arg == "--no-expand-use") { options->svgImport.expandUse = false; } else if (arg == "--flatten-transforms") { @@ -178,6 +192,46 @@ static int ConvertToSVG(const ConvertOptions& options) { return 0; } +static int ConvertToPDF(const ConvertOptions& options) { + auto inputFormat = InferFormat(options.inputFile); + std::shared_ptr document; + + if (inputFormat == "pagx") { + document = PAGXImporter::FromFile(options.inputFile); + } else if (inputFormat == "svg") { + SVGImporter::Options svgOptions = {}; + svgOptions.expandUseReferences = options.svgImport.expandUse; + svgOptions.flattenTransforms = options.svgImport.flattenTransforms; + svgOptions.preserveUnknownElements = options.svgImport.preserveUnknown; + document = SVGImporter::Parse(options.inputFile, svgOptions); + } else { + std::cerr << "pagx convert: error: unsupported input format for '" << options.inputFile + << "'\n"; + return 1; + } + + if (document == nullptr) { + std::cerr << "pagx convert: error: failed to load '" << options.inputFile << "'\n"; + return 1; + } + if (!document->errors.empty()) { + for (auto& error : document->errors) { + std::cerr << "pagx convert: warning: " << error << "\n"; + } + } + + PDFExporter::Options pdfOptions = {}; + pdfOptions.convertTextToPath = !options.pdfExport.noConvertTextToPath; + + if (!PDFExporter::ToFile(*document, options.outputFile, pdfOptions)) { + std::cerr << "pagx convert: error: failed to write '" << options.outputFile << "'\n"; + return 1; + } + + std::cout << "pagx convert: wrote " << options.outputFile << "\n"; + return 0; +} + static int ConvertToPAGX(const ConvertOptions& options) { auto inputFormat = InferFormat(options.inputFile); if (inputFormat != "svg") { @@ -224,6 +278,9 @@ int RunConvert(int argc, char* argv[]) { if (options.outputFormat == "svg") { return ConvertToSVG(options); } + if (options.outputFormat == "pdf") { + return ConvertToPDF(options); + } if (options.outputFormat == "pagx") { return ConvertToPAGX(options); } diff --git a/src/pagx/pdf/PDFExporter.cpp b/src/pagx/pdf/PDFExporter.cpp new file mode 100644 index 0000000000..675b19b583 --- /dev/null +++ b/src/pagx/pdf/PDFExporter.cpp @@ -0,0 +1,1671 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#include "pagx/PDFExporter.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "pagx/PAGXDocument.h" +#include "pagx/nodes/ColorStop.h" +#include "pagx/nodes/Ellipse.h" +#include "pagx/nodes/Fill.h" +#include "pagx/nodes/Font.h" +#include "pagx/nodes/GlyphRun.h" +#include "pagx/nodes/Group.h" +#include "pagx/nodes/Image.h" +#include "pagx/nodes/ImagePattern.h" +#include "pagx/nodes/LinearGradient.h" +#include "pagx/nodes/Path.h" +#include "pagx/nodes/PathData.h" +#include "pagx/nodes/RadialGradient.h" +#include "pagx/nodes/Rectangle.h" +#include "pagx/nodes/SolidColor.h" +#include "pagx/nodes/Stroke.h" +#include "pagx/nodes/Text.h" +#include "pagx/nodes/TextBox.h" +#include "pagx/types/Rect.h" +#include "pagx/utils/ExporterUtils.h" +#include "pagx/utils/StringParser.h" +#include "tgfx/core/Data.h" +#include "tgfx/core/ImageCodec.h" +#include "tgfx/core/ImageInfo.h" +#include "tgfx/core/Pixmap.h" + +namespace pagx { + +// 4*(sqrt(2)-1)/3, optimal cubic Bezier approximation for quarter-circle arcs. +static constexpr float kKappa = 0.5522847498f; + +static bool HasNonOpaqueStops(const std::vector& stops) { + for (const auto* stop : stops) { + if (stop->color.alpha < 1.0f) { + return true; + } + } + return false; +} + +// PDF does not support scientific notation (e.g., "1e-06") in numeric values. +// The spec requires decimal digits with optional sign and decimal point only. +static void AppendPDFFloat(std::string& out, float value) { + if (std::abs(value) < 0.00001f) { + out += '0'; + return; + } + char buf[64]; + auto len = static_cast(snprintf(buf, sizeof(buf), "%.6f", value)); + if (memchr(buf, '.', len)) { + while (len > 0 && buf[len - 1] == '0') { + --len; + } + if (len > 0 && buf[len - 1] == '.') { + --len; + } + } + out.append(buf, len); +} + +static std::string PDFFloat(float value) { + std::string result; + AppendPDFFloat(result, value); + return result; +} + +static const char* BlendModeToPDFName(BlendMode mode) { + switch (mode) { + case BlendMode::Normal: + return "Normal"; + case BlendMode::Multiply: + return "Multiply"; + case BlendMode::Screen: + return "Screen"; + case BlendMode::Overlay: + return "Overlay"; + case BlendMode::Darken: + return "Darken"; + case BlendMode::Lighten: + return "Lighten"; + case BlendMode::ColorDodge: + return "ColorDodge"; + case BlendMode::ColorBurn: + return "ColorBurn"; + case BlendMode::HardLight: + return "HardLight"; + case BlendMode::SoftLight: + return "SoftLight"; + case BlendMode::Difference: + return "Difference"; + case BlendMode::Exclusion: + return "Exclusion"; + case BlendMode::Hue: + return "Hue"; + case BlendMode::Saturation: + return "Saturation"; + case BlendMode::Color: + return "Color"; + case BlendMode::Luminosity: + return "Luminosity"; + default: + return nullptr; + } +} + +//============================================================================== +// PDFStream – builds PDF content stream operators +//============================================================================== + +class PDFStream { + public: + PDFStream() { + _buf.reserve(4096); + } + + void append(const std::string& s) { + _buf += s; + } + void append(const char* s) { + _buf += s; + } + void append(float v) { + AppendPDFFloat(_buf, v); + } + void space() { + _buf += ' '; + } + void newline() { + _buf += '\n'; + } + + // Graphics state + void save() { + _buf += "q\n"; + } + void restore() { + _buf += "Q\n"; + } + + void concatMatrix(const Matrix& m) { + append(m.a); + space(); + append(m.b); + space(); + append(m.c); + space(); + append(m.d); + space(); + append(m.tx); + space(); + append(m.ty); + _buf += " cm\n"; + } + + // Path construction + void moveTo(float x, float y) { + append(x); + space(); + append(y); + _buf += " m\n"; + } + void lineTo(float x, float y) { + append(x); + space(); + append(y); + _buf += " l\n"; + } + void curveTo(float c1x, float c1y, float c2x, float c2y, float x, float y) { + append(c1x); + space(); + append(c1y); + space(); + append(c2x); + space(); + append(c2y); + space(); + append(x); + space(); + append(y); + _buf += " c\n"; + } + void closePath() { + _buf += "h\n"; + } + void rect(float x, float y, float w, float h) { + append(x); + space(); + append(y); + space(); + append(w); + space(); + append(h); + _buf += " re\n"; + } + + // Path painting + void fill() { + _buf += "f\n"; + } + void fillEvenOdd() { + _buf += "f*\n"; + } + void stroke() { + _buf += "S\n"; + } + void fillAndStroke() { + _buf += "B\n"; + } + void fillEvenOddAndStroke() { + _buf += "B*\n"; + } + void endPath() { + _buf += "n\n"; + } + + // Clipping + void clip() { + _buf += "W\n"; + } + void clipEvenOdd() { + _buf += "W*\n"; + } + + // Color + void setFillRGB(float r, float g, float b) { + append(r); + space(); + append(g); + space(); + append(b); + _buf += " rg\n"; + } + void setStrokeRGB(float r, float g, float b) { + append(r); + space(); + append(g); + space(); + append(b); + _buf += " RG\n"; + } + + // Pattern color + void setFillPattern(const std::string& name) { + _buf += "/Pattern cs /" + name + " scn\n"; + } + void setStrokePattern(const std::string& name) { + _buf += "/Pattern CS /" + name + " SCN\n"; + } + + // Shading (direct paint) + void paintShading(const std::string& name) { + _buf += "/" + name + " sh\n"; + } + + // ExtGState + void setExtGState(const std::string& name) { + _buf += "/" + name + " gs\n"; + } + + // Stroke attributes + void setLineWidth(float w) { + append(w); + _buf += " w\n"; + } + void setLineCap(int cap) { + _buf += static_cast('0' + cap); + _buf += " J\n"; + } + void setLineJoin(int join) { + _buf += static_cast('0' + join); + _buf += " j\n"; + } + void setMiterLimit(float limit) { + append(limit); + _buf += " M\n"; + } + void setDash(const std::vector& dashes, float offset) { + _buf += '['; + for (size_t i = 0; i < dashes.size(); i++) { + if (i > 0) { + space(); + } + append(dashes[i]); + } + _buf += "] "; + append(offset); + _buf += " d\n"; + } + + // Text + void beginText() { + _buf += "BT\n"; + } + void endText() { + _buf += "ET\n"; + } + void setFont(const std::string& name, float size) { + _buf += "/" + name + " "; + append(size); + _buf += " Tf\n"; + } + void setTextMatrix(float a, float b, float c, float d, float e, float f) { + append(a); + space(); + append(b); + space(); + append(c); + space(); + append(d); + space(); + append(e); + space(); + append(f); + _buf += " Tm\n"; + } + void showText(const std::string& escaped) { + _buf += "(" + escaped + ") Tj\n"; + } + void showTextHex(const std::string& hexStr) { + _buf += "<" + hexStr + "> Tj\n"; + } + + const std::string& str() const { + return _buf; + } + std::string release() { + return std::move(_buf); + } + bool empty() const { + return _buf.empty(); + } + + private: + std::string _buf = {}; +}; + +//============================================================================== +// ScopedTransform – RAII guard for save/concatMatrix/restore pattern +//============================================================================== + +class ScopedTransform { + public: + ScopedTransform(PDFStream* stream, Matrix& currentMatrix, const Matrix& transform) + : _stream(stream), _currentMatrix(currentMatrix), _savedMatrix(currentMatrix) { + _stream->save(); + if (!transform.isIdentity()) { + _stream->concatMatrix(transform); + _currentMatrix = _currentMatrix * transform; + } + } + ~ScopedTransform() { + _currentMatrix = _savedMatrix; + _stream->restore(); + } + ScopedTransform(const ScopedTransform&) = delete; + ScopedTransform& operator=(const ScopedTransform&) = delete; + + private: + PDFStream* _stream; + Matrix& _currentMatrix; + Matrix _savedMatrix; +}; + +//============================================================================== +// PDFObjectStore – manages numbered indirect objects +//============================================================================== + +class PDFObjectStore { + public: + int reserve() { + return _nextId++; + } + + void set(int id, const std::string& content) { + if (id >= static_cast(_objects.size())) { + _objects.resize(static_cast(id) + 1); + } + _objects[static_cast(id)] = content; + } + + int add(const std::string& content) { + int id = reserve(); + set(id, content); + return id; + } + + std::string serialize(int rootId) const { + std::string result; + result.reserve(64 * 1024); + + result += "%PDF-1.4\n"; + // Binary comment to signal this is a binary file + result += "%\xE2\xE3\xCF\xD3\n"; + + std::vector offsets(static_cast(_nextId), 0); + for (int i = 1; i < _nextId; i++) { + auto idx = static_cast(i); + offsets[idx] = result.size(); + result += std::to_string(i) + " 0 obj\n"; + if (idx < _objects.size()) { + result += _objects[idx]; + } + result += "\nendobj\n\n"; + } + + size_t xrefOffset = result.size(); + result += "xref\n"; + result += "0 " + std::to_string(_nextId) + "\n"; + // Object 0: head of free list + result += "0000000000 65535 f \n"; + for (int i = 1; i < _nextId; i++) { + char buf[22]; + snprintf(buf, sizeof(buf), "%010lu 00000 n \n", + static_cast(offsets[static_cast(i)])); + result += buf; + } + + result += "trailer\n<< /Size " + std::to_string(_nextId) + " /Root " + std::to_string(rootId) + + " 0 R >>\nstartxref\n" + std::to_string(xrefOffset) + "\n%%EOF\n"; + + return result; + } + + private: + int _nextId = 1; + std::vector _objects = {}; +}; + +//============================================================================== +// Image data helpers +//============================================================================== + +struct PDFImageData { + std::string rgbBytes; + std::string alphaBytes; + int width = 0; + int height = 0; +}; + +static PDFImageData GetPDFImageData(const std::shared_ptr& imageData) { + PDFImageData result; + auto codec = tgfx::ImageCodec::MakeFrom(imageData); + if (codec == nullptr) { + return {}; + } + result.width = codec->width(); + result.height = codec->height(); + if (IsJPEG(imageData->bytes(), imageData->size())) { + result.rgbBytes = {reinterpret_cast(imageData->bytes()), imageData->size()}; + return result; + } + auto info = tgfx::ImageInfo::Make(result.width, result.height, tgfx::ColorType::RGBA_8888, + tgfx::AlphaType::Unpremultiplied); + std::vector pixels(info.byteSize()); + if (!codec->readPixels(info, pixels.data())) { + return {}; + } + + auto pixelCount = static_cast(result.width) * static_cast(result.height); + bool hasTransparency = false; + for (size_t i = 0; i < pixelCount; i++) { + if (pixels[i * 4 + 3] != 255) { + hasTransparency = true; + break; + } + } + + tgfx::Pixmap pixmap(info, pixels.data()); + auto jpegData = tgfx::ImageCodec::Encode(pixmap, tgfx::EncodedFormat::JPEG, 95); + if (jpegData == nullptr) { + return {}; + } + result.rgbBytes = {reinterpret_cast(jpegData->bytes()), jpegData->size()}; + + if (hasTransparency) { + result.alphaBytes.resize(pixelCount); + for (size_t i = 0; i < pixelCount; i++) { + result.alphaBytes[i] = static_cast(pixels[i * 4 + 3]); + } + } + + return result; +} + +//============================================================================== +// PDFResourceManager – tracks ExtGState, Shading, Pattern, Font resources +//============================================================================== + +class PDFResourceManager { + public: + explicit PDFResourceManager(PDFObjectStore* store) : _store(store) { + } + + std::string getExtGState(float fillAlpha, float strokeAlpha) { + // Quantize to avoid creating duplicate objects for near-identical alphas. + auto fa = static_cast(std::round(fillAlpha * 10000.0f)); + auto sa = static_cast(std::round(strokeAlpha * 10000.0f)); + uint32_t key = (static_cast(fa) << 16) | sa; + auto it = _gsCache.find(key); + if (it != _gsCache.end()) { + return it->second; + } + + std::string name = "GS" + std::to_string(_gsCount++); + int objId = _store->add("<< /Type /ExtGState /ca " + PDFFloat(fillAlpha) + " /CA " + + PDFFloat(strokeAlpha) + " >>"); + _extGStates[name] = objId; + _gsCache[key] = name; + return name; + } + + std::string getBlendModeExtGState(BlendMode mode) { + auto it = _bmCache.find(mode); + if (it != _bmCache.end()) { + return it->second; + } + auto pdfName = BlendModeToPDFName(mode); + if (!pdfName) { + return {}; + } + std::string name = "GS" + std::to_string(_gsCount++); + int objId = _store->add("<< /Type /ExtGState /BM /" + std::string(pdfName) + " >>"); + _extGStates[name] = objId; + _bmCache[mode] = name; + return name; + } + + int buildStitchingFunction(const std::vector& segFuncs, + const std::vector& stops) { + std::string funcsArr = "["; + for (size_t i = 0; i < segFuncs.size(); i++) { + if (i > 0) { + funcsArr += " "; + } + funcsArr += std::to_string(segFuncs[i]) + " 0 R"; + } + funcsArr += "]"; + + std::string bounds = "["; + for (size_t i = 1; i + 1 < stops.size(); i++) { + if (i > 1) { + bounds += " "; + } + bounds += PDFFloat(stops[i]->offset); + } + bounds += "]"; + + std::string encode = "["; + for (size_t i = 0; i < segFuncs.size(); i++) { + if (i > 0) { + encode += " "; + } + encode += "0 1"; + } + encode += "]"; + + return _store->add("<< /FunctionType 3 /Domain [0 1] /Functions " + funcsArr + " /Bounds " + + bounds + " /Encode " + encode + " >>"); + } + + template + int createFunctionFromStops(const std::vector& stops, const ColorFormatter& format) { + if (stops.size() < 2) { + return -1; + } + if (stops.size() == 2) { + return _store->add("<< /FunctionType 2 /Domain [0 1] /C0 [" + format(stops[0]) + "] /C1 [" + + format(stops[1]) + "] /N 1 >>"); + } + std::vector segFuncs; + segFuncs.reserve(stops.size() - 1); + for (size_t i = 0; i + 1 < stops.size(); i++) { + segFuncs.push_back(_store->add("<< /FunctionType 2 /Domain [0 1] /C0 [" + format(stops[i]) + + "] /C1 [" + format(stops[i + 1]) + "] /N 1 >>")); + } + return buildStitchingFunction(segFuncs, stops); + } + + int createGradientFunction(const std::vector& stops) { + return createFunctionFromStops(stops, [](const ColorStop* stop) { + return PDFFloat(stop->color.red) + " " + PDFFloat(stop->color.green) + " " + + PDFFloat(stop->color.blue); + }); + } + + int createAlphaGradientFunction(const std::vector& stops) { + return createFunctionFromStops( + stops, [](const ColorStop* stop) { return PDFFloat(stop->color.alpha); }); + } + + static std::string matrixString(const Matrix& m) { + return "[" + PDFFloat(m.a) + " " + PDFFloat(m.b) + " " + PDFFloat(m.c) + " " + PDFFloat(m.d) + + " " + PDFFloat(m.tx) + " " + PDFFloat(m.ty) + "]"; + } + + std::string wrapShadingInPattern(int shadingId, const Matrix& patternMatrix) { + std::string matStr = matrixString(patternMatrix); + int patId = _store->add("<< /Type /Pattern /PatternType 2 /Shading " + + std::to_string(shadingId) + " 0 R /Matrix " + matStr + " >>"); + std::string patName = "P" + std::to_string(_patCount++); + _patterns[patName] = patId; + return patName; + } + + // Creates a Type 2 (axial) shading wrapped in a Pattern, returns the pattern resource name. + // The ctm parameter is the accumulated CTM from page-level transforms (Y-flip, layer matrices, + // etc.) that must be baked into the pattern matrix, because PDF shading patterns are positioned + // relative to the initial user space and are not affected by the current graphics state CTM. + std::string addLinearGradient(const LinearGradient* grad, const Matrix& ctm) { + int funcId = createGradientFunction(grad->colorStops); + if (funcId < 0) { + return {}; + } + int shadingId = + _store->add("<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [" + + PDFFloat(grad->startPoint.x) + " " + PDFFloat(grad->startPoint.y) + " " + + PDFFloat(grad->endPoint.x) + " " + PDFFloat(grad->endPoint.y) + "] /Function " + + std::to_string(funcId) + " 0 R /Extend [true true] >>"); + return wrapShadingInPattern(shadingId, ctm * grad->matrix); + } + + // Creates a Type 3 (radial) shading wrapped in a Pattern, returns the pattern resource name. + std::string addRadialGradient(const RadialGradient* grad, const Matrix& ctm) { + int funcId = createGradientFunction(grad->colorStops); + if (funcId < 0) { + return {}; + } + int shadingId = _store->add("<< /ShadingType 3 /ColorSpace /DeviceRGB /Coords [" + + PDFFloat(grad->center.x) + " " + PDFFloat(grad->center.y) + " 0 " + + PDFFloat(grad->center.x) + " " + PDFFloat(grad->center.y) + " " + + PDFFloat(grad->radius) + "] /Function " + std::to_string(funcId) + + " 0 R /Extend [true true] >>"); + return wrapShadingInPattern(shadingId, ctm * grad->matrix); + } + + std::string addGradientAlphaMask(int alphaShadingId, const Matrix& gradMatrix, float bboxWidth, + float bboxHeight) { + std::string shadingResName = "S0"; + std::string formContent; + if (gradMatrix.isIdentity()) { + formContent = "/" + shadingResName + " sh\n"; + } else { + formContent = PDFFloat(gradMatrix.a) + " " + PDFFloat(gradMatrix.b) + " " + + PDFFloat(gradMatrix.c) + " " + PDFFloat(gradMatrix.d) + " " + + PDFFloat(gradMatrix.tx) + " " + PDFFloat(gradMatrix.ty) + " cm\n/" + + shadingResName + " sh\n"; + } + + std::string bbox = "[0 0 " + PDFFloat(bboxWidth) + " " + PDFFloat(bboxHeight) + "]"; + int formId = _store->add("<< /Type /XObject /Subtype /Form /BBox " + bbox + + " /Group << /Type /Group /S /Transparency /CS /DeviceGray >>" + " /Resources << /Shading << /" + + shadingResName + " " + std::to_string(alphaShadingId) + + " 0 R >> >>" + " /Length " + + std::to_string(formContent.size()) + " >>\nstream\n" + formContent + + "\nendstream"); + + std::string gsName = "GS" + std::to_string(_gsCount++); + int gsId = _store->add("<< /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G " + + std::to_string(formId) + " 0 R >> >>"); + _extGStates[gsName] = gsId; + return gsName; + } + + std::string addLinearGradientAlphaMask(const std::vector& stops, + const Point& startPoint, const Point& endPoint, + const Matrix& gradMatrix, float bboxWidth, + float bboxHeight) { + int alphaFuncId = createAlphaGradientFunction(stops); + if (alphaFuncId < 0) { + return {}; + } + int alphaShadingId = _store->add( + "<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [" + PDFFloat(startPoint.x) + " " + + PDFFloat(startPoint.y) + " " + PDFFloat(endPoint.x) + " " + PDFFloat(endPoint.y) + + "] /Function " + std::to_string(alphaFuncId) + " 0 R /Extend [true true] >>"); + return addGradientAlphaMask(alphaShadingId, gradMatrix, bboxWidth, bboxHeight); + } + + std::string addRadialGradientAlphaMask(const std::vector& stops, const Point& center, + float radius, const Matrix& gradMatrix, float bboxWidth, + float bboxHeight) { + int alphaFuncId = createAlphaGradientFunction(stops); + if (alphaFuncId < 0) { + return {}; + } + int alphaShadingId = + _store->add("<< /ShadingType 3 /ColorSpace /DeviceGray /Coords [" + PDFFloat(center.x) + + " " + PDFFloat(center.y) + " 0 " + PDFFloat(center.x) + " " + + PDFFloat(center.y) + " " + PDFFloat(radius) + "] /Function " + + std::to_string(alphaFuncId) + " 0 R /Extend [true true] >>"); + return addGradientAlphaMask(alphaShadingId, gradMatrix, bboxWidth, bboxHeight); + } + + // Creates a Tiling Pattern (PatternType 1) that draws an image, returns the pattern resource name. + std::string addImagePattern(const ImagePattern* pattern, const Matrix& ctm) { + if (!pattern->image) { + return {}; + } + + auto cacheIt = _imageCache.find(pattern->image); + std::string xobjName = {}; + int xobjId = 0; + int imageWidth = 0; + int imageHeight = 0; + + if (cacheIt != _imageCache.end()) { + xobjName = cacheIt->second.xObjectName; + xobjId = cacheIt->second.objectId; + imageWidth = cacheIt->second.width; + imageHeight = cacheIt->second.height; + } else { + auto imageData = GetImageData(pattern->image); + if (!imageData) { + return {}; + } + + auto pdfImage = GetPDFImageData(imageData); + if (pdfImage.rgbBytes.empty()) { + return {}; + } + imageWidth = pdfImage.width; + imageHeight = pdfImage.height; + + std::string smaskRef; + if (!pdfImage.alphaBytes.empty()) { + int smaskId = _store->add( + "<< /Type /XObject /Subtype /Image /Width " + std::to_string(imageWidth) + " /Height " + + std::to_string(imageHeight) + " /ColorSpace /DeviceGray /BitsPerComponent 8 /Length " + + std::to_string(pdfImage.alphaBytes.size()) + " >>\nstream\n" + pdfImage.alphaBytes + + "\nendstream"); + smaskRef = " /SMask " + std::to_string(smaskId) + " 0 R"; + } + + xobjName = "Im" + std::to_string(_imgCount++); + std::string xobjDict = "<< /Type /XObject /Subtype /Image /Width " + + std::to_string(imageWidth) + " /Height " + + std::to_string(imageHeight) + + " /ColorSpace /DeviceRGB /BitsPerComponent 8" + " /Filter /DCTDecode" + + smaskRef + " /Length " + std::to_string(pdfImage.rgbBytes.size()) + + " >>\nstream\n" + pdfImage.rgbBytes + "\nendstream"; + xobjId = _store->add(xobjDict); + _imageCache[pattern->image] = {xobjName, xobjId, imageWidth, imageHeight}; + } + + auto width = static_cast(imageWidth); + auto height = static_cast(imageHeight); + + bool mirrorX = (pattern->tileModeX == TileMode::Mirror); + bool mirrorY = (pattern->tileModeY == TileMode::Mirror); + bool clampX = (pattern->tileModeX == TileMode::Clamp || pattern->tileModeX == TileMode::Decal); + bool clampY = (pattern->tileModeY == TileMode::Clamp || pattern->tileModeY == TileMode::Decal); + float tileWidth = mirrorX ? width * 2 : width; + float tileHeight = mirrorY ? height * 2 : height; + float xStep = clampX ? 16384.0f : tileWidth; + float yStep = clampY ? 16384.0f : tileHeight; + + // Content stream: draw the image inside the tile cell with Y-flip for PDF image coordinates. + // For Mirror tile modes, draw mirrored copies to form a 2x tile that repeats seamlessly. + std::string w = PDFFloat(width); + std::string h = PDFFloat(height); + std::string negH = PDFFloat(-height); + std::string tileStream = "q " + w + " 0 0 " + negH + " 0 " + h + " cm /" + xobjName + " Do Q"; + if (mirrorX) { + // Horizontally flipped copy at [width, 0]. + tileStream += " q " + PDFFloat(-width) + " 0 0 " + negH + " " + PDFFloat(width * 2) + " " + + h + " cm /" + xobjName + " Do Q"; + } + if (mirrorY) { + // Vertically flipped copy at [0, height]. + tileStream += + " q " + w + " 0 0 " + PDFFloat(height) + " 0 " + h + " cm /" + xobjName + " Do Q"; + } + if (mirrorX && mirrorY) { + // Both axes flipped copy at [width, height]. + tileStream += " q " + PDFFloat(-width) + " 0 0 " + PDFFloat(height) + " " + + PDFFloat(width * 2) + " " + h + " cm /" + xobjName + " Do Q"; + } + + std::string matStr = matrixString(ctm * pattern->matrix); + std::string patDict = + "<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1" + " /BBox [0 0 " + + PDFFloat(tileWidth) + " " + PDFFloat(tileHeight) + "] /XStep " + PDFFloat(xStep) + + " /YStep " + PDFFloat(yStep) + " /Matrix " + matStr + " /Resources << /XObject << /" + + xobjName + " " + std::to_string(xobjId) + " 0 R >> >> /Length " + + std::to_string(tileStream.size()) + " >>\nstream\n" + tileStream + "\nendstream"; + int patId = _store->add(patDict); + std::string patName = "P" + std::to_string(_patCount++); + _patterns[patName] = patId; + return patName; + } + + PDFObjectStore* store() const { + return _store; + } + + std::string addSoftMaskExtGState(MaskType maskType, const std::string& formContent, + const std::string& formResourceDict, float bboxWidth, + float bboxHeight) { + std::string bbox = "[0 0 " + PDFFloat(bboxWidth) + " " + PDFFloat(bboxHeight) + "]"; + int formId = _store->add("<< /Type /XObject /Subtype /Form /BBox " + bbox + + " /Group << /Type /Group /S /Transparency /CS /DeviceRGB >>" + " /Resources " + + formResourceDict + " /Length " + std::to_string(formContent.size()) + + " >>\nstream\n" + formContent + "\nendstream"); + + std::string smaskSubtype = (maskType == MaskType::Luminance) ? "/Luminosity" : "/Alpha"; + std::string gsName = "GS" + std::to_string(_gsCount++); + int gsId = _store->add("<< /Type /ExtGState /SMask << /Type /Mask /S " + smaskSubtype + " /G " + + std::to_string(formId) + " 0 R >> >>"); + _extGStates[gsName] = gsId; + return gsName; + } + + void ensureDefaultFont() { + if (_fontRegistered) { + return; + } + _fontRegistered = true; + int fontId = _store->add( + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"); + _fonts["F0"] = fontId; + } + + std::string ensureCIDFont() { + if (_cidFontRegistered) { + return _cidFontName; + } + _cidFontRegistered = true; + + int cidFontId = _store->add( + "<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light" + " /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> >>"); + + _cidFontName = "F1"; + int fontId = _store->add( + "<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light" + " /Encoding /UniGB-UCS2-H /DescendantFonts [" + + std::to_string(cidFontId) + " 0 R] >>"); + + _fonts[_cidFontName] = fontId; + return _cidFontName; + } + + static void appendResourceCategory(std::string& dict, const char* category, + const std::map& resources) { + if (resources.empty()) { + return; + } + dict += "/"; + dict += category; + dict += " << "; + for (const auto& [name, objId] : resources) { + dict += "/" + name + " " + std::to_string(objId) + " 0 R "; + } + dict += ">> "; + } + + std::string buildResourceDict() const { + std::string dict = "<< "; + appendResourceCategory(dict, "ExtGState", _extGStates); + appendResourceCategory(dict, "Pattern", _patterns); + appendResourceCategory(dict, "Font", _fonts); + dict += ">>"; + return dict; + } + + private: + PDFObjectStore* _store; + std::map _extGStates = {}; + std::map _patterns = {}; + std::map _fonts = {}; + std::unordered_map _gsCache = {}; + std::map _bmCache = {}; + + struct ImageCacheEntry { + std::string xObjectName; + int objectId = 0; + int width = 0; + int height = 0; + }; + std::map _imageCache = {}; + int _gsCount = 0; + int _patCount = 0; + int _imgCount = 0; + bool _fontRegistered = false; + bool _cidFontRegistered = false; + std::string _cidFontName; +}; + +//============================================================================== +// Utility types and static helpers +//============================================================================== + +static std::string EscapePDFString(const std::string& str) { + bool needsEscape = false; + for (unsigned char c : str) { + if (c == '(' || c == ')' || c == '\\' || c < 32 || c > 126) { + needsEscape = true; + break; + } + } + if (!needsEscape) { + return str; + } + + std::string result; + result.reserve(str.size() + 10); + for (unsigned char c : str) { + switch (c) { + case '(': + result += "\\("; + break; + case ')': + result += "\\)"; + break; + case '\\': + result += "\\\\"; + break; + default: + if (c < 32 || c > 126) { + char buf[5]; + snprintf(buf, sizeof(buf), "\\%03o", c); + result += buf; + } else { + result += static_cast(c); + } + break; + } + } + return result; +} + +//============================================================================== +// PDFWriter – converts PAGX nodes to PDF content stream operators +//============================================================================== + +class PDFWriter { + public: + PDFWriter(PDFStream* stream, PDFResourceManager* resources, bool convertTextToPath, + const Matrix& pageMatrix, float docWidth, float docHeight) + : _stream(stream), _resources(resources), _convertTextToPath(convertTextToPath), + _currentMatrix(pageMatrix), _docWidth(docWidth), _docHeight(docHeight) { + } + + void writeLayer(const Layer* layer); + + private: + PDFStream* _stream; + PDFResourceManager* _resources; + bool _convertTextToPath; + Matrix _currentMatrix; + float _docWidth; + float _docHeight; + + void writeElements(const std::vector& elements, const Matrix& transform = {}); + + void writeShape(const Element* element, const FillStrokeInfo& fs, const Matrix& transform); + void writeText(const Text* text, const FillStrokeInfo& fs, const Matrix& transform); + void writeTextAsPath(const Text* text, const FillStrokeInfo& fs, const Matrix& transform); + void writeTextAsPDFText(const Text* text, const FillStrokeInfo& fs, const Matrix& transform); + + void emitShapePath(const Element* element, const Matrix& transform = {}); + void emitPathData(const PathData& data, const Matrix& transform = {}); + void emitEllipsePath(float cx, float cy, float rx, float ry, const Matrix& transform = {}); + void emitRoundedRectPath(float x, float y, float w, float h, float r, + const Matrix& transform = {}); + void emitRectPath(float x, float y, float w, float h, const Matrix& transform = {}); + + enum class ColorRefType { Solid, Pattern }; + + struct ColorRef { + ColorRefType type = ColorRefType::Solid; + float r = 0, g = 0, b = 0; + float alpha = 1.0f; + std::string patternName; + std::string softMaskGSName; + }; + + ColorRef resolveColorSource(const ColorSource* source); + + struct PaintState { + bool hasFill = false; + bool hasStroke = false; + bool isEvenOdd = false; + }; + + PaintState applyFillStrokeColors(const FillStrokeInfo& fs); + void emitPaintOp(const PaintState& state); + void paintShape(const FillStrokeInfo& fs); + void applyStrokeAttrs(const Stroke* stroke); + + void writeClipPath(const Layer* maskLayer, const Matrix& parentMatrix = {}); + void writeSoftMask(const Layer* maskLayer, MaskType maskType); + void renderMaskLayerContent(const Layer* maskLayer); +}; + +//============================================================================== +// PDFWriter – path emission helpers +//============================================================================== + +void PDFWriter::emitPathData(const PathData& data, const Matrix& transform) { + bool hasTransform = !transform.isIdentity(); + Point cur = {}; + Point subpathStart = {}; + data.forEach( + [this, &cur, &subpathStart, hasTransform, &transform](PathVerb verb, const Point* pts) { + switch (verb) { + case PathVerb::Move: { + Point p = hasTransform ? transform.mapPoint(pts[0]) : pts[0]; + _stream->moveTo(p.x, p.y); + cur = p; + subpathStart = p; + break; + } + case PathVerb::Line: { + Point p = hasTransform ? transform.mapPoint(pts[0]) : pts[0]; + _stream->lineTo(p.x, p.y); + cur = p; + break; + } + case PathVerb::Quad: { + Point cp = hasTransform ? transform.mapPoint(pts[0]) : pts[0]; + Point end = hasTransform ? transform.mapPoint(pts[1]) : pts[1]; + float c1x = cur.x + 2.0f / 3.0f * (cp.x - cur.x); + float c1y = cur.y + 2.0f / 3.0f * (cp.y - cur.y); + float c2x = end.x + 2.0f / 3.0f * (cp.x - end.x); + float c2y = end.y + 2.0f / 3.0f * (cp.y - end.y); + _stream->curveTo(c1x, c1y, c2x, c2y, end.x, end.y); + cur = end; + break; + } + case PathVerb::Cubic: { + Point cp1 = hasTransform ? transform.mapPoint(pts[0]) : pts[0]; + Point cp2 = hasTransform ? transform.mapPoint(pts[1]) : pts[1]; + Point end = hasTransform ? transform.mapPoint(pts[2]) : pts[2]; + _stream->curveTo(cp1.x, cp1.y, cp2.x, cp2.y, end.x, end.y); + cur = end; + break; + } + case PathVerb::Close: + _stream->closePath(); + cur = subpathStart; + break; + } + }); +} + +void PDFWriter::emitEllipsePath(float cx, float cy, float rx, float ry, const Matrix& transform) { + bool hasTransform = !transform.isIdentity(); + float kx = rx * kKappa; + float ky = ry * kKappa; + Point points[] = { + {cx + rx, cy}, {cx + rx, cy + ky}, {cx + kx, cy + ry}, {cx, cy + ry}, + {cx - kx, cy + ry}, {cx - rx, cy + ky}, {cx - rx, cy}, {cx - rx, cy - ky}, + {cx - kx, cy - ry}, {cx, cy - ry}, {cx + kx, cy - ry}, {cx + rx, cy - ky}, + {cx + rx, cy}, + }; + if (hasTransform) { + for (auto& p : points) { + p = transform.mapPoint(p); + } + } + _stream->moveTo(points[0].x, points[0].y); + _stream->curveTo(points[1].x, points[1].y, points[2].x, points[2].y, points[3].x, points[3].y); + _stream->curveTo(points[4].x, points[4].y, points[5].x, points[5].y, points[6].x, points[6].y); + _stream->curveTo(points[7].x, points[7].y, points[8].x, points[8].y, points[9].x, points[9].y); + _stream->curveTo(points[10].x, points[10].y, points[11].x, points[11].y, points[12].x, + points[12].y); + _stream->closePath(); +} + +void PDFWriter::emitRoundedRectPath(float x, float y, float w, float h, float r, + const Matrix& transform) { + bool hasTransform = !transform.isIdentity(); + float maxR = std::min(w, h) / 2.0f; + r = std::min(r, maxR); + float k = r * kKappa; + Point points[] = { + {x + r, y}, {x + w - r, y}, {x + w - r + k, y}, {x + w, y + r - k}, + {x + w, y + r}, {x + w, y + h - r}, {x + w, y + h - r + k}, {x + w - r + k, y + h}, + {x + w - r, y + h}, {x + r, y + h}, {x + r - k, y + h}, {x, y + h - r + k}, + {x, y + h - r}, {x, y + r}, {x, y + r - k}, {x + r - k, y}, + {x + r, y}, + }; + if (hasTransform) { + for (auto& p : points) { + p = transform.mapPoint(p); + } + } + _stream->moveTo(points[0].x, points[0].y); + _stream->lineTo(points[1].x, points[1].y); + _stream->curveTo(points[2].x, points[2].y, points[3].x, points[3].y, points[4].x, points[4].y); + _stream->lineTo(points[5].x, points[5].y); + _stream->curveTo(points[6].x, points[6].y, points[7].x, points[7].y, points[8].x, points[8].y); + _stream->lineTo(points[9].x, points[9].y); + _stream->curveTo(points[10].x, points[10].y, points[11].x, points[11].y, points[12].x, + points[12].y); + _stream->lineTo(points[13].x, points[13].y); + _stream->curveTo(points[14].x, points[14].y, points[15].x, points[15].y, points[16].x, + points[16].y); + _stream->closePath(); +} + +void PDFWriter::emitRectPath(float x, float y, float w, float h, const Matrix& transform) { + if (transform.isIdentity()) { + _stream->rect(x, y, w, h); + return; + } + Point p0 = transform.mapPoint({x, y}); + Point p1 = transform.mapPoint({x + w, y}); + Point p2 = transform.mapPoint({x + w, y + h}); + Point p3 = transform.mapPoint({x, y + h}); + _stream->moveTo(p0.x, p0.y); + _stream->lineTo(p1.x, p1.y); + _stream->lineTo(p2.x, p2.y); + _stream->lineTo(p3.x, p3.y); + _stream->closePath(); +} + +//============================================================================== +// PDFWriter – color source resolution +//============================================================================== + +PDFWriter::ColorRef PDFWriter::resolveColorSource(const ColorSource* source) { + if (!source) { + return {}; + } + + switch (source->nodeType()) { + case NodeType::SolidColor: { + auto solid = static_cast(source); + return {ColorRefType::Solid, + solid->color.red, + solid->color.green, + solid->color.blue, + solid->color.alpha, + {}, + {}}; + } + case NodeType::LinearGradient: { + auto grad = static_cast(source); + std::string patName = _resources->addLinearGradient(grad, _currentMatrix); + if (patName.empty()) { + return {}; + } + ColorRef ref = {ColorRefType::Pattern, 0, 0, 0, 1.0f, std::move(patName), {}}; + if (HasNonOpaqueStops(grad->colorStops)) { + ref.softMaskGSName = _resources->addLinearGradientAlphaMask( + grad->colorStops, grad->startPoint, grad->endPoint, grad->matrix, _docWidth, + _docHeight); + } + return ref; + } + case NodeType::RadialGradient: { + auto grad = static_cast(source); + std::string patName = _resources->addRadialGradient(grad, _currentMatrix); + if (patName.empty()) { + return {}; + } + ColorRef ref = {ColorRefType::Pattern, 0, 0, 0, 1.0f, std::move(patName), {}}; + if (HasNonOpaqueStops(grad->colorStops)) { + ref.softMaskGSName = _resources->addRadialGradientAlphaMask( + grad->colorStops, grad->center, grad->radius, grad->matrix, _docWidth, _docHeight); + } + return ref; + } + case NodeType::ImagePattern: { + auto pattern = static_cast(source); + std::string patName = _resources->addImagePattern(pattern, _currentMatrix); + if (patName.empty()) { + return {}; + } + return {ColorRefType::Pattern, 0, 0, 0, 1.0f, std::move(patName), {}}; + } + default: + return {}; + } +} + +//============================================================================== +// PDFWriter – stroke attribute helpers +//============================================================================== + +void PDFWriter::applyStrokeAttrs(const Stroke* stroke) { + if (stroke->width != 1.0f) { + _stream->setLineWidth(stroke->width); + } + // PDF line cap: 0=butt, 1=round, 2=square + if (stroke->cap == LineCap::Round) { + _stream->setLineCap(1); + } else if (stroke->cap == LineCap::Square) { + _stream->setLineCap(2); + } + // PDF line join: 0=miter, 1=round, 2=bevel + if (stroke->join == LineJoin::Round) { + _stream->setLineJoin(1); + } else if (stroke->join == LineJoin::Bevel) { + _stream->setLineJoin(2); + } + if (stroke->join == LineJoin::Miter && stroke->miterLimit != 4.0f) { + _stream->setMiterLimit(stroke->miterLimit); + } + if (!stroke->dashes.empty()) { + _stream->setDash(stroke->dashes, stroke->dashOffset); + } +} + +//============================================================================== +// PDFWriter – shape painting (fill + stroke) +//============================================================================== + +PDFWriter::PaintState PDFWriter::applyFillStrokeColors(const FillStrokeInfo& fs) { + PaintState state = {}; + state.hasFill = fs.fill && fs.fill->color; + state.hasStroke = fs.stroke && fs.stroke->color; + state.isEvenOdd = fs.fill && (fs.fill->fillRule == FillRule::EvenOdd); + + ColorRef fillRef = {}; + ColorRef strokeRef = {}; + if (state.hasFill) { + fillRef = resolveColorSource(fs.fill->color); + } + if (state.hasStroke) { + strokeRef = resolveColorSource(fs.stroke->color); + } + + if (!fillRef.softMaskGSName.empty()) { + _stream->setExtGState(fillRef.softMaskGSName); + } else if (!strokeRef.softMaskGSName.empty()) { + _stream->setExtGState(strokeRef.softMaskGSName); + } + + float fillAlpha = state.hasFill ? (fillRef.alpha * fs.fill->alpha) : 1.0f; + float strokeAlpha = state.hasStroke ? (strokeRef.alpha * fs.stroke->alpha) : 1.0f; + if (fillAlpha < 1.0f || strokeAlpha < 1.0f) { + _stream->setExtGState(_resources->getExtGState(fillAlpha, strokeAlpha)); + } + + if (state.hasStroke) { + applyStrokeAttrs(fs.stroke); + if (strokeRef.type == ColorRefType::Solid) { + _stream->setStrokeRGB(strokeRef.r, strokeRef.g, strokeRef.b); + } else { + _stream->setStrokePattern(strokeRef.patternName); + } + } + + if (state.hasFill) { + if (fillRef.type == ColorRefType::Solid) { + _stream->setFillRGB(fillRef.r, fillRef.g, fillRef.b); + } else { + _stream->setFillPattern(fillRef.patternName); + } + } + + return state; +} + +void PDFWriter::emitPaintOp(const PaintState& state) { + if (state.hasFill && state.hasStroke) { + state.isEvenOdd ? _stream->fillEvenOddAndStroke() : _stream->fillAndStroke(); + } else if (state.hasFill) { + state.isEvenOdd ? _stream->fillEvenOdd() : _stream->fill(); + } else if (state.hasStroke) { + _stream->stroke(); + } +} + +void PDFWriter::paintShape(const FillStrokeInfo& fs) { + if ((!fs.fill || !fs.fill->color) && (!fs.stroke || !fs.stroke->color)) { + _stream->endPath(); + return; + } + + auto state = applyFillStrokeColors(fs); + emitPaintOp(state); +} + +//============================================================================== +// PDFWriter – unified shape path emission +//============================================================================== + +void PDFWriter::emitShapePath(const Element* element, const Matrix& transform) { + switch (element->nodeType()) { + case NodeType::Rectangle: { + auto rect = static_cast(element); + float x = rect->position.x - rect->size.width / 2; + float y = rect->position.y - rect->size.height / 2; + if (rect->roundness > 0) { + emitRoundedRectPath(x, y, rect->size.width, rect->size.height, rect->roundness, transform); + } else { + emitRectPath(x, y, rect->size.width, rect->size.height, transform); + } + break; + } + case NodeType::Ellipse: { + auto ellipse = static_cast(element); + emitEllipsePath(ellipse->position.x, ellipse->position.y, ellipse->size.width / 2, + ellipse->size.height / 2, transform); + break; + } + case NodeType::Path: { + auto path = static_cast(element); + if (path->data && !path->data->isEmpty()) { + emitPathData(*path->data, transform); + } + break; + } + default: + break; + } +} + +//============================================================================== +// PDFWriter – shape elements +//============================================================================== + +void PDFWriter::writeShape(const Element* element, const FillStrokeInfo& fs, + const Matrix& transform) { + if (element->nodeType() == NodeType::Path) { + auto path = static_cast(element); + if (!path->data || path->data->isEmpty()) { + return; + } + } + + ScopedTransform guard(_stream, _currentMatrix, transform); + emitShapePath(element); + paintShape(fs); +} + +//============================================================================== +// PDFWriter – text elements +//============================================================================== + +void PDFWriter::writeTextAsPath(const Text* text, const FillStrokeInfo& fs, + const Matrix& transform) { + auto glyphPaths = ComputeGlyphPaths(*text, text->position.x, text->position.y); + if (glyphPaths.empty()) { + return; + } + + ScopedTransform guard(_stream, _currentMatrix, transform); + auto state = applyFillStrokeColors(fs); + + for (const auto& gp : glyphPaths) { + _stream->save(); + _stream->concatMatrix(gp.transform); + emitPathData(*gp.pathData); + emitPaintOp(state); + _stream->restore(); + } +} + +void PDFWriter::writeTextAsPDFText(const Text* text, const FillStrokeInfo& fs, + const Matrix& transform) { + if (text->text.empty()) { + return; + } + + bool nonASCII = HasNonASCII(text->text); + std::string fontName; + if (nonASCII) { + fontName = _resources->ensureCIDFont(); + } else { + _resources->ensureDefaultFont(); + fontName = "F0"; + } + + ScopedTransform guard(_stream, _currentMatrix, transform); + applyFillStrokeColors(fs); + + _stream->beginText(); + _stream->setFont(fontName, text->fontSize); + // Under the Y-flip page transform, text needs an inverted Y scale to render right-side up. + _stream->setTextMatrix(1, 0, 0, -1, text->position.x, text->position.y); + if (nonASCII) { + _stream->showTextHex(UTF8ToUTF16BEHex(text->text)); + } else { + _stream->showText(EscapePDFString(text->text)); + } + _stream->endText(); +} + +void PDFWriter::writeText(const Text* text, const FillStrokeInfo& fs, const Matrix& transform) { + if (text->text.empty()) { + return; + } + + if (_convertTextToPath && !text->glyphRuns.empty()) { + writeTextAsPath(text, fs, transform); + return; + } + + writeTextAsPDFText(text, fs, transform); +} + +//============================================================================== +// PDFWriter – clip path +//============================================================================== + +void PDFWriter::writeClipPath(const Layer* maskLayer, const Matrix& parentMatrix) { + Matrix layerMatrix = BuildLayerMatrix(maskLayer); + Matrix combined = parentMatrix * layerMatrix; + + for (const auto* element : maskLayer->contents) { + emitShapePath(element, combined); + } + + for (const auto* child : maskLayer->children) { + writeClipPath(child, combined); + } +} + +//============================================================================== +// PDFWriter – soft mask (Alpha / Luminance) +//============================================================================== + +void PDFWriter::renderMaskLayerContent(const Layer* maskLayer) { + Matrix layerMatrix = BuildLayerMatrix(maskLayer); + bool needsGroup = !layerMatrix.isIdentity() || maskLayer->alpha < 1.0f; + Matrix savedMatrix = _currentMatrix; + + if (needsGroup) { + _stream->save(); + if (!layerMatrix.isIdentity()) { + _stream->concatMatrix(layerMatrix); + _currentMatrix = _currentMatrix * layerMatrix; + } + if (maskLayer->alpha < 1.0f) { + _stream->setExtGState(_resources->getExtGState(maskLayer->alpha, maskLayer->alpha)); + } + } + + writeElements(maskLayer->contents, {}); + for (const auto* child : maskLayer->children) { + renderMaskLayerContent(child); + } + + if (needsGroup) { + _currentMatrix = savedMatrix; + _stream->restore(); + } +} + +void PDFWriter::writeSoftMask(const Layer* maskLayer, MaskType maskType) { + PDFStream maskStream; + PDFResourceManager maskResources(_resources->store()); + PDFWriter maskWriter(&maskStream, &maskResources, _convertTextToPath, _currentMatrix, _docWidth, + _docHeight); + maskWriter.renderMaskLayerContent(maskLayer); + + std::string formContent = maskStream.release(); + std::string formResourceDict = maskResources.buildResourceDict(); + + std::string gsName = _resources->addSoftMaskExtGState(maskType, formContent, formResourceDict, + _docWidth, _docHeight); + _stream->setExtGState(gsName); +} + +//============================================================================== +// PDFWriter – element list and layer writing +//============================================================================== + +void PDFWriter::writeElements(const std::vector& elements, const Matrix& transform) { + auto fs = CollectFillStroke(elements); + + for (const auto* element : elements) { + auto type = element->nodeType(); + if (type == NodeType::Fill || type == NodeType::Stroke || type == NodeType::TextBox) { + continue; + } + switch (type) { + case NodeType::Rectangle: + case NodeType::Ellipse: + case NodeType::Path: + writeShape(element, fs, transform); + break; + case NodeType::Text: + writeText(static_cast(element), fs, transform); + break; + case NodeType::Group: { + auto* group = static_cast(element); + Matrix groupMatrix = BuildGroupMatrix(group); + Matrix combined = transform * groupMatrix; + + bool hasAlpha = group->alpha < 1.0f; + if (hasAlpha || !combined.isIdentity()) { + _stream->save(); + Matrix savedMatrix = _currentMatrix; + if (!combined.isIdentity()) { + _stream->concatMatrix(combined); + _currentMatrix = _currentMatrix * combined; + } + if (hasAlpha) { + _stream->setExtGState(_resources->getExtGState(group->alpha, group->alpha)); + } + writeElements(group->elements, {}); + _currentMatrix = savedMatrix; + _stream->restore(); + } else { + writeElements(group->elements, combined); + } + break; + } + default: + break; + } + } +} + +void PDFWriter::writeLayer(const Layer* layer) { + if (!layer->visible && layer->mask == nullptr) { + return; + } + + bool needsGroup = !layer->matrix.isIdentity() || layer->alpha < 1.0f || layer->mask != nullptr || + layer->x != 0.0f || layer->y != 0.0f || layer->blendMode != BlendMode::Normal; + + if (!needsGroup) { + writeElements(layer->contents, {}); + for (const auto* child : layer->children) { + writeLayer(child); + } + return; + } + + _stream->save(); + Matrix savedMatrix = _currentMatrix; + + Matrix layerMatrix = BuildLayerMatrix(layer); + if (!layerMatrix.isIdentity()) { + _stream->concatMatrix(layerMatrix); + _currentMatrix = _currentMatrix * layerMatrix; + } + + if (layer->alpha < 1.0f) { + _stream->setExtGState(_resources->getExtGState(layer->alpha, layer->alpha)); + } + + if (layer->blendMode != BlendMode::Normal) { + auto gsName = _resources->getBlendModeExtGState(layer->blendMode); + if (!gsName.empty()) { + _stream->setExtGState(gsName); + } + } + + if (layer->mask != nullptr) { + if (layer->maskType == MaskType::Contour) { + FillRule clipRule = DetectMaskFillRule(layer->mask); + writeClipPath(layer->mask); + if (clipRule == FillRule::EvenOdd) { + _stream->clipEvenOdd(); + } else { + _stream->clip(); + } + _stream->endPath(); + } else { + writeSoftMask(layer->mask, layer->maskType); + } + } + + writeElements(layer->contents, {}); + + for (const auto* child : layer->children) { + writeLayer(child); + } + + _currentMatrix = savedMatrix; + _stream->restore(); +} + +//============================================================================== +// Main Export functions +//============================================================================== + +std::string PDFExporter::ToPDF(const PAGXDocument& doc, const Options& options) { + PDFObjectStore store; + PDFResourceManager resources(&store); + PDFStream stream; + + // Apply page-level Y-flip so PAGX top-left origin maps to PDF bottom-left origin. + Matrix yFlip = {1, 0, 0, -1, 0, doc.height}; + + PDFWriter writer(&stream, &resources, options.convertTextToPath, yFlip, doc.width, doc.height); + + stream.save(); + stream.concatMatrix(yFlip); + + for (const auto* layer : doc.layers) { + writer.writeLayer(layer); + } + + stream.restore(); + + std::string contentData = stream.release(); + + // Reserve object IDs for the fixed structure: Catalog(1), Pages(2), Page(3), ContentStream(4) + int catalogId = store.reserve(); + int pagesId = store.reserve(); + int pageId = store.reserve(); + int contentId = store.reserve(); + + // Content stream object + store.set(contentId, "<< /Length " + std::to_string(contentData.size()) + " >>\nstream\n" + + contentData + "\nendstream"); + + // Page object + std::string resourceDict = resources.buildResourceDict(); + store.set(pageId, "<< /Type /Page /Parent " + std::to_string(pagesId) + " 0 R /MediaBox [0 0 " + + PDFFloat(doc.width) + " " + PDFFloat(doc.height) + + "] /Group << /Type /Group /S /Transparency /CS /DeviceRGB >>" + " /Contents " + + std::to_string(contentId) + " 0 R /Resources " + resourceDict + " >>"); + + // Pages tree + store.set(pagesId, "<< /Type /Pages /Kids [" + std::to_string(pageId) + " 0 R] /Count 1 >>"); + + // Catalog + store.set(catalogId, "<< /Type /Catalog /Pages " + std::to_string(pagesId) + " 0 R >>"); + + return store.serialize(catalogId); +} + +bool PDFExporter::ToFile(const PAGXDocument& document, const std::string& filePath, + const Options& options) { + auto pdfContent = ToPDF(document, options); + if (pdfContent.empty()) { + return false; + } + std::ofstream file(filePath, std::ios::binary); + if (!file) { + return false; + } + file.write(pdfContent.data(), static_cast(pdfContent.size())); + return file.good(); +} + +} // namespace pagx diff --git a/src/pagx/svg/SVGExporter.cpp b/src/pagx/svg/SVGExporter.cpp index fad1eb02cf..556e8637be 100644 --- a/src/pagx/svg/SVGExporter.cpp +++ b/src/pagx/svg/SVGExporter.cpp @@ -49,11 +49,11 @@ #include "pagx/svg/SVGTextLayout.h" #include "pagx/types/Rect.h" #include "pagx/utils/Base64.h" +#include "pagx/utils/ExporterUtils.h" #include "pagx/utils/StringParser.h" namespace pagx { -using pag::DegreesToRadians; using pag::FloatNearlyZero; //============================================================================== @@ -221,12 +221,6 @@ class SVGBuilder { // Utility types and static helpers //============================================================================== -struct FillStrokeInfo { - const Fill* fill = nullptr; - const Stroke* stroke = nullptr; - const TextBox* textBox = nullptr; -}; - // Returns only the RGB hex string (#RRGGBB). Alpha is handled separately via // fill-opacity/stroke-opacity attributes, following standard SVG practice. static std::string ColorToSVGString(const Color& color) { @@ -307,48 +301,6 @@ static std::string MatrixToSVGTransform(const Matrix& matrix) { return result; } -static bool GetPNGDimensions(const uint8_t* data, size_t size, int* width, int* height) { - if (size < 24) { - return false; - } - static const uint8_t kPNGSignature[8] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; - if (memcmp(data, kPNGSignature, 8) != 0) { - return false; - } - *width = static_cast((data[16] << 24) | (data[17] << 16) | (data[18] << 8) | data[19]); - *height = static_cast((data[20] << 24) | (data[21] << 16) | (data[22] << 8) | data[23]); - return *width > 0 && *height > 0; -} - -static bool GetPNGDimensionsFromPath(const std::string& path, int* width, int* height) { - if (path.rfind("data:", 0) == 0) { - auto decoded = DecodeBase64DataURI(path); - if (!decoded) { - return false; - } - return GetPNGDimensions(decoded->bytes(), decoded->size(), width, height); - } - std::ifstream file(path, std::ios::binary); - if (!file) { - return false; - } - uint8_t header[24]; - if (!file.read(reinterpret_cast(header), 24)) { - return false; - } - return GetPNGDimensions(header, 24, width, height); -} - -static bool GetImagePNGDimensions(const Image* image, int* width, int* height) { - if (image->data) { - return GetPNGDimensions(image->data->bytes(), image->data->size(), width, height); - } - if (!image->filePath.empty()) { - return GetPNGDimensionsFromPath(image->filePath, width, height); - } - return false; -} - static std::string GetImageHref(const Image* image) { if (image->data) { return "data:image/png;base64," + Base64Encode(image->data->bytes(), image->data->size()); @@ -359,78 +311,11 @@ static std::string GetImageHref(const Image* image) { return {}; } -static FillStrokeInfo CollectFillStroke(const std::vector& contents) { - FillStrokeInfo info = {}; - for (const auto* element : contents) { - if (element->nodeType() == NodeType::Fill && !info.fill) { - info.fill = static_cast(element); - } else if (element->nodeType() == NodeType::Stroke && !info.stroke) { - info.stroke = static_cast(element); - } else if (element->nodeType() == NodeType::TextBox && !info.textBox) { - info.textBox = static_cast(element); - } - if (info.fill && info.stroke && info.textBox) { - break; - } - } - return info; -} - -static Matrix BuildLayerMatrix(const Layer* layer) { - Matrix m = layer->matrix; - if (layer->x != 0.0f || layer->y != 0.0f) { - m = Matrix::Translate(layer->x, layer->y) * m; - } - return m; -} - static std::string BuildLayerTransform(const Layer* layer) { Matrix m = BuildLayerMatrix(layer); return MatrixToSVGTransform(m); } -static Matrix BuildGroupMatrix(const Group* group) { - bool hasAnchor = !FloatNearlyZero(group->anchor.x) || !FloatNearlyZero(group->anchor.y); - bool hasPosition = !FloatNearlyZero(group->position.x) || !FloatNearlyZero(group->position.y); - bool hasRotation = !FloatNearlyZero(group->rotation); - bool hasScale = - !FloatNearlyZero(group->scale.x - 1.0f) || !FloatNearlyZero(group->scale.y - 1.0f); - bool hasSkew = !FloatNearlyZero(group->skew); - - if (!hasAnchor && !hasPosition && !hasRotation && !hasScale && !hasSkew) { - return {}; - } - - // Transform order per PAGX spec: - // 1. translate(-anchor) → 2. scale → 3. skew → 4. rotate → 5. translate(position) - // Column-vector composition: M = T(pos) * R(rot) * Skew * S(scale) * T(-anchor) - Matrix m = {}; - - if (hasAnchor) { - m = Matrix::Translate(-group->anchor.x, -group->anchor.y); - } - if (hasScale) { - m = Matrix::Scale(group->scale.x, group->scale.y) * m; - } - if (hasSkew) { - // Skew per spec: R(skewAxis) → ShearX(tan(skew)) → R(-skewAxis) - // Column-vector: R(-skewAxis) * ShearX * R(skewAxis) - m = Matrix::Rotate(group->skewAxis) * m; - Matrix shear = {}; - shear.c = std::tan(DegreesToRadians(group->skew)); - m = shear * m; - m = Matrix::Rotate(-group->skewAxis) * m; - } - if (hasRotation) { - m = Matrix::Rotate(group->rotation) * m; - } - if (hasPosition) { - m = Matrix::Translate(group->position.x, group->position.y) * m; - } - - return m; -} - //============================================================================== // SVGWriterContext - shared state across SVGWriter instances //============================================================================== diff --git a/src/pagx/svg/SVGTextLayout.cpp b/src/pagx/svg/SVGTextLayout.cpp index fc09e2ff39..5a77e3f7b0 100644 --- a/src/pagx/svg/SVGTextLayout.cpp +++ b/src/pagx/svg/SVGTextLayout.cpp @@ -17,10 +17,6 @@ ///////////////////////////////////////////////////////////////////////////////////////////////// #include "SVGTextLayout.h" -#include "base/utils/MathUtil.h" -#include "pagx/nodes/Font.h" -#include "pagx/nodes/GlyphRun.h" -#include "pagx/nodes/PathData.h" #include "pagx/nodes/Text.h" #include "pagx/nodes/TextBox.h" #include "renderer/LineBreaker.h" @@ -270,80 +266,4 @@ SVGTextLayoutResult ComputeTextLayout(const SVGTextLayoutParams& params) { return result; } -std::vector ComputeGlyphPaths(const Text& text, float textPosX, float textPosY) { - std::vector result; - for (const auto* run : text.glyphRuns) { - if (!run->font || run->glyphs.empty()) { - continue; - } - float scale = run->fontSize / static_cast(run->font->unitsPerEm); - float currentX = textPosX + run->x; - 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 || !glyph->path || glyph->path->isEmpty()) { - continue; - } - - float posX = 0; - float posY = 0; - if (i < run->positions.size()) { - posX = textPosX + run->x + run->positions[i].x; - posY = textPosY + run->y + run->positions[i].y; - if (i < run->xOffsets.size()) { - posX += run->xOffsets[i]; - } - } else if (i < run->xOffsets.size()) { - posX = textPosX + run->x + run->xOffsets[i]; - posY = textPosY + run->y; - } else { - posX = currentX; - posY = textPosY + run->y; - } - currentX += glyph->advance * scale; - - Matrix glyphMatrix = Matrix::Translate(posX, posY) * Matrix::Scale(scale, scale); - - bool hasRotation = i < run->rotations.size() && run->rotations[i] != 0; - bool hasGlyphScale = - i < run->scales.size() && (run->scales[i].x != 1 || run->scales[i].y != 1); - bool hasSkew = i < run->skews.size() && run->skews[i] != 0; - - if (hasRotation || hasGlyphScale || hasSkew) { - float anchorX = glyph->advance * 0.5f; - float anchorY = 0; - if (i < run->anchors.size()) { - anchorX += run->anchors[i].x; - anchorY += run->anchors[i].y; - } - - Matrix perGlyph = Matrix::Translate(-anchorX, -anchorY); - if (hasGlyphScale) { - perGlyph = Matrix::Scale(run->scales[i].x, run->scales[i].y) * perGlyph; - } - if (hasSkew) { - Matrix shear = {}; - shear.c = std::tan(pag::DegreesToRadians(run->skews[i])); - perGlyph = shear * perGlyph; - } - if (hasRotation) { - perGlyph = Matrix::Rotate(run->rotations[i]) * perGlyph; - } - perGlyph = Matrix::Translate(anchorX, anchorY) * perGlyph; - glyphMatrix = glyphMatrix * perGlyph; - } - - result.push_back({glyphMatrix, glyph->path}); - } - } - return result; -} - } // namespace pagx diff --git a/src/pagx/svg/SVGTextLayout.h b/src/pagx/svg/SVGTextLayout.h index 43a08b7553..f87ff10fa4 100644 --- a/src/pagx/svg/SVGTextLayout.h +++ b/src/pagx/svg/SVGTextLayout.h @@ -25,10 +25,10 @@ #include "pagx/types/Matrix.h" #include "pagx/types/Point.h" #include "pagx/types/TextAnchor.h" +#include "pagx/utils/ExporterUtils.h" namespace pagx { -class PathData; class Text; class TextBox; @@ -102,18 +102,6 @@ struct SVGTextLayoutResult { */ SVGTextLayoutResult ComputeTextLayout(const SVGTextLayoutParams& params); -/** - * A single glyph's path data with its computed transform matrix. - */ -struct SVGGlyphPath { - Matrix transform; // glyph's full transform matrix - const PathData* pathData; // pointer to glyph's path (not owned) -}; - -/** - * Converts text glyph runs into a list of glyph paths with transform matrices. - * textPosX/textPosY are the TextBox-aligned base position (from ComputeTextLayout). - */ -std::vector ComputeGlyphPaths(const Text& text, float textPosX, float textPosY); +using SVGGlyphPath = GlyphPath; } // namespace pagx diff --git a/src/pagx/utils/ExporterUtils.cpp b/src/pagx/utils/ExporterUtils.cpp new file mode 100644 index 0000000000..245f2cff74 --- /dev/null +++ b/src/pagx/utils/ExporterUtils.cpp @@ -0,0 +1,293 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#include "pagx/utils/ExporterUtils.h" +#include +#include +#include +#include +#include "base/utils/MathUtil.h" +#include "pagx/nodes/Font.h" +#include "pagx/nodes/GlyphRun.h" +#include "pagx/nodes/Image.h" +#include "pagx/nodes/PathData.h" +#include "pagx/nodes/Text.h" +#include "pagx/utils/Base64.h" +#include "tgfx/core/Data.h" + +namespace pagx { + +using pag::DegreesToRadians; +using pag::FloatNearlyZero; + +FillStrokeInfo CollectFillStroke(const std::vector& contents) { + FillStrokeInfo info = {}; + for (const auto* element : contents) { + if (element->nodeType() == NodeType::Fill && !info.fill) { + info.fill = static_cast(element); + } else if (element->nodeType() == NodeType::Stroke && !info.stroke) { + info.stroke = static_cast(element); + } else if (element->nodeType() == NodeType::TextBox && !info.textBox) { + info.textBox = static_cast(element); + } + if (info.fill && info.stroke && info.textBox) { + break; + } + } + return info; +} + +Matrix BuildLayerMatrix(const Layer* layer) { + Matrix m = layer->matrix; + if (layer->x != 0.0f || layer->y != 0.0f) { + m = Matrix::Translate(layer->x, layer->y) * m; + } + return m; +} + +Matrix BuildGroupMatrix(const Group* group) { + bool hasAnchor = !FloatNearlyZero(group->anchor.x) || !FloatNearlyZero(group->anchor.y); + bool hasPosition = !FloatNearlyZero(group->position.x) || !FloatNearlyZero(group->position.y); + bool hasRotation = !FloatNearlyZero(group->rotation); + bool hasScale = + !FloatNearlyZero(group->scale.x - 1.0f) || !FloatNearlyZero(group->scale.y - 1.0f); + bool hasSkew = !FloatNearlyZero(group->skew); + + if (!hasAnchor && !hasPosition && !hasRotation && !hasScale && !hasSkew) { + return {}; + } + + Matrix m = {}; + if (hasAnchor) { + m = Matrix::Translate(-group->anchor.x, -group->anchor.y); + } + if (hasScale) { + m = Matrix::Scale(group->scale.x, group->scale.y) * m; + } + if (hasSkew) { + m = Matrix::Rotate(group->skewAxis) * m; + Matrix shear = {}; + shear.c = std::tan(DegreesToRadians(group->skew)); + m = shear * m; + m = Matrix::Rotate(-group->skewAxis) * m; + } + if (hasRotation) { + m = Matrix::Rotate(group->rotation) * m; + } + if (hasPosition) { + m = Matrix::Translate(group->position.x, group->position.y) * m; + } + + return m; +} + +std::vector ComputeGlyphPaths(const Text& text, float textPosX, float textPosY) { + std::vector result; + for (const auto* run : text.glyphRuns) { + if (!run->font || run->glyphs.empty()) { + continue; + } + float scale = run->fontSize / static_cast(run->font->unitsPerEm); + float currentX = textPosX + run->x; + 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 || !glyph->path || glyph->path->isEmpty()) { + continue; + } + + float posX = 0; + float posY = 0; + if (i < run->positions.size()) { + posX = textPosX + run->x + run->positions[i].x; + posY = textPosY + run->y + run->positions[i].y; + if (i < run->xOffsets.size()) { + posX += run->xOffsets[i]; + } + } else if (i < run->xOffsets.size()) { + posX = textPosX + run->x + run->xOffsets[i]; + posY = textPosY + run->y; + } else { + posX = currentX; + posY = textPosY + run->y; + } + currentX += glyph->advance * scale; + + Matrix glyphMatrix = Matrix::Translate(posX, posY) * Matrix::Scale(scale, scale); + + bool hasRotation = i < run->rotations.size() && run->rotations[i] != 0; + bool hasGlyphScale = + i < run->scales.size() && (run->scales[i].x != 1 || run->scales[i].y != 1); + bool hasSkew = i < run->skews.size() && run->skews[i] != 0; + + if (hasRotation || hasGlyphScale || hasSkew) { + float anchorX = glyph->advance * 0.5f; + float anchorY = 0; + if (i < run->anchors.size()) { + anchorX += run->anchors[i].x; + anchorY += run->anchors[i].y; + } + + Matrix perGlyph = Matrix::Translate(-anchorX, -anchorY); + if (hasGlyphScale) { + perGlyph = Matrix::Scale(run->scales[i].x, run->scales[i].y) * perGlyph; + } + if (hasSkew) { + Matrix shear = {}; + shear.c = std::tan(pag::DegreesToRadians(run->skews[i])); + perGlyph = shear * perGlyph; + } + if (hasRotation) { + perGlyph = Matrix::Rotate(run->rotations[i]) * perGlyph; + } + perGlyph = Matrix::Translate(anchorX, anchorY) * perGlyph; + glyphMatrix = glyphMatrix * perGlyph; + } + + result.push_back({glyphMatrix, glyph->path}); + } + } + return result; +} + +FillRule DetectMaskFillRule(const Layer* maskLayer) { + for (const auto* element : maskLayer->contents) { + if (element->nodeType() == NodeType::Fill) { + auto rule = static_cast(element)->fillRule; + if (rule == FillRule::EvenOdd) { + return FillRule::EvenOdd; + } + } + } + for (const auto* child : maskLayer->children) { + if (DetectMaskFillRule(child) == FillRule::EvenOdd) { + return FillRule::EvenOdd; + } + } + return FillRule::Winding; +} + +bool GetPNGDimensions(const uint8_t* data, size_t size, int* width, int* height) { + if (size < 24) { + return false; + } + static const uint8_t kPNGSignature[8] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; + if (memcmp(data, kPNGSignature, 8) != 0) { + return false; + } + *width = static_cast((data[16] << 24) | (data[17] << 16) | (data[18] << 8) | data[19]); + *height = static_cast((data[20] << 24) | (data[21] << 16) | (data[22] << 8) | data[23]); + return *width > 0 && *height > 0; +} + +bool GetPNGDimensionsFromPath(const std::string& path, int* width, int* height) { + if (path.rfind("data:", 0) == 0) { + auto decoded = DecodeBase64DataURI(path); + if (!decoded) { + return false; + } + return GetPNGDimensions(decoded->bytes(), decoded->size(), width, height); + } + std::ifstream file(path, std::ios::binary); + if (!file) { + return false; + } + uint8_t header[24]; + if (!file.read(reinterpret_cast(header), 24)) { + return false; + } + return GetPNGDimensions(header, 24, width, height); +} + +bool GetImagePNGDimensions(const Image* image, int* width, int* height) { + if (image->data) { + return GetPNGDimensions(image->data->bytes(), image->data->size(), width, height); + } + if (!image->filePath.empty()) { + return GetPNGDimensionsFromPath(image->filePath, width, height); + } + return false; +} + +bool IsJPEG(const uint8_t* data, size_t size) { + return size >= 2 && data[0] == 0xFF && data[1] == 0xD8; +} + +std::shared_ptr GetImageData(const Image* image) { + if (image->data) { + return tgfx::Data::MakeWithoutCopy(image->data->bytes(), image->data->size()); + } + if (!image->filePath.empty()) { + return tgfx::Data::MakeFromFile(image->filePath); + } + return nullptr; +} + +bool HasNonASCII(const std::string& str) { + for (unsigned char c : str) { + if (c > 127) { + return true; + } + } + return false; +} + +std::string UTF8ToUTF16BEHex(const std::string& utf8) { + std::string hex; + hex.reserve(utf8.size() * 6); + size_t i = 0; + while (i < utf8.size()) { + uint32_t cp = 0; + auto c = static_cast(utf8[i]); + size_t bytes = 1; + if (c < 0x80) { + cp = c; + } else if (c < 0xE0) { + cp = c & 0x1Fu; + bytes = 2; + } else if (c < 0xF0) { + cp = c & 0x0Fu; + bytes = 3; + } else { + cp = c & 0x07u; + bytes = 4; + } + for (size_t j = 1; j < bytes && (i + j) < utf8.size(); j++) { + cp = (cp << 6) | (static_cast(utf8[i + j]) & 0x3Fu); + } + i += bytes; + char buf[9]; + if (cp <= 0xFFFF) { + snprintf(buf, sizeof(buf), "%04X", cp); + } else { + cp -= 0x10000; + snprintf(buf, sizeof(buf), "%04X%04X", 0xD800 + (cp >> 10), 0xDC00 + (cp & 0x3FF)); + } + hex += buf; + } + return hex; +} + +} // namespace pagx diff --git a/src/pagx/utils/ExporterUtils.h b/src/pagx/utils/ExporterUtils.h new file mode 100644 index 0000000000..a8a569e176 --- /dev/null +++ b/src/pagx/utils/ExporterUtils.h @@ -0,0 +1,82 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include +#include +#include "pagx/nodes/Element.h" +#include "pagx/nodes/Fill.h" +#include "pagx/nodes/Group.h" +#include "pagx/nodes/Layer.h" +#include "pagx/nodes/Stroke.h" +#include "pagx/nodes/TextBox.h" +#include "pagx/types/Matrix.h" + +namespace tgfx { +class Data; +} + +namespace pagx { + +class Image; +class PathData; +class Text; + +struct FillStrokeInfo { + const Fill* fill = nullptr; + const Stroke* stroke = nullptr; + const TextBox* textBox = nullptr; +}; + +struct GlyphPath { + Matrix transform; + const PathData* pathData; +}; + +FillStrokeInfo CollectFillStroke(const std::vector& contents); + +Matrix BuildLayerMatrix(const Layer* layer); + +Matrix BuildGroupMatrix(const Group* group); + +/** + * Converts text glyph runs into a list of glyph paths with transform matrices. + * textPosX/textPosY are the base position for glyph placement. + */ +std::vector ComputeGlyphPaths(const Text& text, float textPosX, float textPosY); + +FillRule DetectMaskFillRule(const Layer* maskLayer); + +bool GetPNGDimensions(const uint8_t* data, size_t size, int* width, int* height); + +bool GetPNGDimensionsFromPath(const std::string& path, int* width, int* height); + +bool GetImagePNGDimensions(const Image* image, int* width, int* height); + +bool IsJPEG(const uint8_t* data, size_t size); + +std::shared_ptr GetImageData(const Image* image); + +bool HasNonASCII(const std::string& str); + +std::string UTF8ToUTF16BEHex(const std::string& utf8); + +} // namespace pagx diff --git a/test/src/PAGXCliTest.cpp b/test/src/PAGXCliTest.cpp index 73fd1b07e7..6160563b38 100644 --- a/test/src/PAGXCliTest.cpp +++ b/test/src/PAGXCliTest.cpp @@ -1235,4 +1235,293 @@ CLI_TEST(PAGXCliTest, Convert_PagxToSvg_ValidateSimple) { EXPECT_NE(output.find(""), std::string::npos); } +//============================================================================== +// Convert tests — PAGX to PDF +//============================================================================== + +CLI_TEST(PAGXCliTest, Convert_PagxToPdf_Basic) { + auto inputPath = TestResourcePath("render_basic.pagx"); + auto outputPath = TempDir() + "/ConvertPDF_Basic.pdf"; + auto ret = CallRun(pagx::cli::RunConvert, {"convert", inputPath, outputPath}); + EXPECT_EQ(ret, 0); + EXPECT_TRUE(std::filesystem::exists(outputPath)); + EXPECT_GT(std::filesystem::file_size(outputPath), 0u); + auto output = ReadFile(outputPath); + EXPECT_NE(output.find("%PDF-"), std::string::npos); +} + +CLI_TEST(PAGXCliTest, Convert_PagxToPdf_Gradient) { + auto inputPath = TestResourcePath("render_gradient.pagx"); + auto outputPath = TempDir() + "/ConvertPDF_Gradient.pdf"; + auto ret = CallRun(pagx::cli::RunConvert, {"convert", inputPath, outputPath}); + EXPECT_EQ(ret, 0); + EXPECT_TRUE(std::filesystem::exists(outputPath)); + auto output = ReadFile(outputPath); + EXPECT_NE(output.find("%PDF-"), std::string::npos); +} + +CLI_TEST(PAGXCliTest, Convert_PagxToPdf_Text) { + auto inputPath = TestResourcePath("render_text.pagx"); + auto outputPath = TempDir() + "/ConvertPDF_Text.pdf"; + auto ret = CallRun(pagx::cli::RunConvert, {"convert", inputPath, outputPath}); + EXPECT_EQ(ret, 0); + EXPECT_TRUE(std::filesystem::exists(outputPath)); + auto output = ReadFile(outputPath); + EXPECT_NE(output.find("%PDF-"), std::string::npos); +} + +CLI_TEST(PAGXCliTest, Convert_PagxToPdf_NoConvertTextToPath) { + auto inputPath = TestResourcePath("render_text.pagx"); + auto outputPath = TempDir() + "/ConvertPDF_NoTextPath.pdf"; + auto ret = CallRun(pagx::cli::RunConvert, + {"convert", "--no-convert-text-to-path", inputPath, outputPath}); + EXPECT_EQ(ret, 0); + EXPECT_TRUE(std::filesystem::exists(outputPath)); + auto output = ReadFile(outputPath); + EXPECT_NE(output.find("%PDF-"), std::string::npos); +} + +CLI_TEST(PAGXCliTest, Convert_PagxToPdf_ForceFormat) { + auto inputPath = TestResourcePath("render_basic.pagx"); + auto outputPath = TempDir() + "/ConvertPDF_ForceFormat.out"; + auto ret = CallRun(pagx::cli::RunConvert, {"convert", "--format", "pdf", inputPath, outputPath}); + EXPECT_EQ(ret, 0); + auto output = ReadFile(outputPath); + EXPECT_NE(output.find("%PDF-"), std::string::npos); +} + +CLI_TEST(PAGXCliTest, Convert_PagxToPdf_MissingFile) { + auto outputPath = TempDir() + "/ConvertPDF_Missing.pdf"; + auto ret = CallRun(pagx::cli::RunConvert, {"convert", "nonexistent.pagx", outputPath}); + EXPECT_NE(ret, 0); +} + +CLI_TEST(PAGXCliTest, Convert_PagxToPdf_InvalidFile) { + auto inputPath = TestResourcePath("validate_not_xml.pagx"); + auto outputPath = TempDir() + "/ConvertPDF_Invalid.pdf"; + auto ret = CallRun(pagx::cli::RunConvert, {"convert", inputPath, outputPath}); + EXPECT_NE(ret, 0); +} + +CLI_TEST(PAGXCliTest, Convert_PagxToPdf_ValidateSimple) { + auto inputPath = TestResourcePath("validate_simple.pagx"); + auto outputPath = TempDir() + "/ConvertPDF_ValidateSimple.pdf"; + auto ret = CallRun(pagx::cli::RunConvert, {"convert", inputPath, outputPath}); + EXPECT_EQ(ret, 0); + EXPECT_TRUE(std::filesystem::exists(outputPath)); + auto output = ReadFile(outputPath); + EXPECT_NE(output.find("%PDF-"), std::string::npos); +} + +CLI_TEST(PAGXCliTest, Convert_PagxToPdf_Scale) { + auto inputPath = TestResourcePath("render_scale.pagx"); + auto outputPath = TempDir() + "/ConvertPDF_Scale.pdf"; + auto ret = CallRun(pagx::cli::RunConvert, {"convert", inputPath, outputPath}); + EXPECT_EQ(ret, 0); + EXPECT_TRUE(std::filesystem::exists(outputPath)); + auto output = ReadFile(outputPath); + EXPECT_NE(output.find("%PDF-"), std::string::npos); +} + +CLI_TEST(PAGXCliTest, Convert_PagxToPdf_Background) { + auto inputPath = TestResourcePath("render_background.pagx"); + auto outputPath = TempDir() + "/ConvertPDF_Background.pdf"; + auto ret = CallRun(pagx::cli::RunConvert, {"convert", inputPath, outputPath}); + EXPECT_EQ(ret, 0); + EXPECT_TRUE(std::filesystem::exists(outputPath)); + auto output = ReadFile(outputPath); + EXPECT_NE(output.find("%PDF-"), std::string::npos); +} + +CLI_TEST(PAGXCliTest, Convert_PagxToPdf_LayerTarget) { + auto inputPath = TestResourcePath("render_layer_target.pagx"); + auto outputPath = TempDir() + "/ConvertPDF_LayerTarget.pdf"; + auto ret = CallRun(pagx::cli::RunConvert, {"convert", inputPath, outputPath}); + EXPECT_EQ(ret, 0); + EXPECT_TRUE(std::filesystem::exists(outputPath)); + auto output = ReadFile(outputPath); + EXPECT_NE(output.find("%PDF-"), std::string::npos); +} + +CLI_TEST(PAGXCliTest, Convert_PagxToPdf_Crop) { + auto inputPath = TestResourcePath("render_crop.pagx"); + auto outputPath = TempDir() + "/ConvertPDF_Crop.pdf"; + auto ret = CallRun(pagx::cli::RunConvert, {"convert", inputPath, outputPath}); + EXPECT_EQ(ret, 0); + EXPECT_TRUE(std::filesystem::exists(outputPath)); + auto output = ReadFile(outputPath); + EXPECT_NE(output.find("%PDF-"), std::string::npos); +} + +//============================================================================== +// Convert tests — SVG to PDF +//============================================================================== + +CLI_TEST(PAGXCliTest, Convert_SvgToPdf) { + auto inputPagx = TestResourcePath("render_basic.pagx"); + auto svgPath = TempDir() + "/ConvertSvgToPdf_intermediate.svg"; + auto ret = CallRun(pagx::cli::RunConvert, {"convert", inputPagx, svgPath}); + ASSERT_EQ(ret, 0); + auto outputPath = TempDir() + "/ConvertSvgToPdf.pdf"; + ret = CallRun(pagx::cli::RunConvert, {"convert", svgPath, outputPath}); + EXPECT_EQ(ret, 0); + EXPECT_TRUE(std::filesystem::exists(outputPath)); + auto output = ReadFile(outputPath); + EXPECT_NE(output.find("%PDF-"), std::string::npos); +} + +CLI_TEST(PAGXCliTest, Convert_SvgToPdf_WithImportOptions) { + auto inputPagx = TestResourcePath("render_basic.pagx"); + auto svgPath = TempDir() + "/ConvertSvgToPdf_import_opts.svg"; + auto ret = CallRun(pagx::cli::RunConvert, {"convert", inputPagx, svgPath}); + ASSERT_EQ(ret, 0); + auto outputPath = TempDir() + "/ConvertSvgToPdf_import_opts.pdf"; + ret = CallRun(pagx::cli::RunConvert, {"convert", "--no-expand-use", "--flatten-transforms", + "--preserve-unknown", svgPath, outputPath}); + EXPECT_EQ(ret, 0); + EXPECT_TRUE(std::filesystem::exists(outputPath)); + auto output = ReadFile(outputPath); + EXPECT_NE(output.find("%PDF-"), std::string::npos); +} + +CLI_TEST(PAGXCliTest, Convert_SvgToPdf_MissingFile) { + auto outputPath = TempDir() + "/ConvertSvgToPdf_Missing.pdf"; + auto ret = CallRun(pagx::cli::RunConvert, {"convert", "nonexistent.svg", outputPath}); + EXPECT_NE(ret, 0); +} + +CLI_TEST(PAGXCliTest, Convert_UnsupportedInputToPdf) { + auto inputPath = CopyToTemp("render_basic.pagx", "unsupported_input.dat"); + auto outputPath = TempDir() + "/ConvertPDF_UnsupportedInput.pdf"; + auto ret = CallRun(pagx::cli::RunConvert, {"convert", inputPath, outputPath}); + EXPECT_NE(ret, 0); +} + +//============================================================================== +// Convert tests — additional coverage +//============================================================================== + +CLI_TEST(PAGXCliTest, Convert_Help) { + auto ret = CallRun(pagx::cli::RunConvert, {"convert", "--help"}); + EXPECT_EQ(ret, 0); +} + +CLI_TEST(PAGXCliTest, Convert_HelpShort) { + auto ret = CallRun(pagx::cli::RunConvert, {"convert", "-h"}); + EXPECT_EQ(ret, 0); +} + +CLI_TEST(PAGXCliTest, Convert_TooManyArgs) { + auto ret = CallRun(pagx::cli::RunConvert, {"convert", "a.pagx", "b.svg", "c.svg"}); + EXPECT_NE(ret, 0); +} + +CLI_TEST(PAGXCliTest, Convert_UnsupportedFormatFlag) { + auto inputPath = TestResourcePath("render_basic.pagx"); + auto outputPath = TempDir() + "/ConvertUnsupported.out"; + auto ret = CallRun(pagx::cli::RunConvert, {"convert", "--format", "bmp", inputPath, outputPath}); + EXPECT_NE(ret, 0); +} + +CLI_TEST(PAGXCliTest, Convert_InvalidIndent) { + auto inputPath = TestResourcePath("render_basic.pagx"); + auto outputPath = TempDir() + "/ConvertInvalidIndent.svg"; + auto ret = CallRun(pagx::cli::RunConvert, {"convert", "--indent", "99", inputPath, outputPath}); + EXPECT_NE(ret, 0); +} + +CLI_TEST(PAGXCliTest, Convert_InvalidIndentNonNumeric) { + auto inputPath = TestResourcePath("render_basic.pagx"); + auto outputPath = TempDir() + "/ConvertInvalidIndentNaN.svg"; + auto ret = CallRun(pagx::cli::RunConvert, {"convert", "--indent", "abc", inputPath, outputPath}); + EXPECT_NE(ret, 0); +} + +//============================================================================== +// Convert tests — SVG to PAGX +//============================================================================== + +CLI_TEST(PAGXCliTest, Convert_SvgToPagx_Basic) { + auto inputPagx = TestResourcePath("render_basic.pagx"); + auto svgPath = TempDir() + "/ConvertSvgToPagx_src.svg"; + auto ret = CallRun(pagx::cli::RunConvert, {"convert", inputPagx, svgPath}); + ASSERT_EQ(ret, 0); + auto outputPath = TempDir() + "/ConvertSvgToPagx.pagx"; + ret = CallRun(pagx::cli::RunConvert, {"convert", svgPath, outputPath}); + EXPECT_EQ(ret, 0); + EXPECT_TRUE(std::filesystem::exists(outputPath)); + auto output = ReadFile(outputPath); + EXPECT_NE(output.find(" +#include +#include +#include "pagx/PAGXDocument.h" +#include "pagx/PAGXExporter.h" +#include "pagx/PAGXImporter.h" +#include "pagx/PDFExporter.h" +#include "pagx/SVGImporter.h" +#include "pagx/nodes/ColorStop.h" +#include "pagx/nodes/Ellipse.h" +#include "pagx/nodes/Fill.h" +#include "pagx/nodes/Group.h" +#include "pagx/nodes/Layer.h" +#include "pagx/nodes/LinearGradient.h" +#include "pagx/nodes/Path.h" +#include "pagx/nodes/PathData.h" +#include "pagx/nodes/RadialGradient.h" +#include "pagx/nodes/Rectangle.h" +#include "pagx/nodes/SolidColor.h" +#include "pagx/nodes/Stroke.h" +#include "pagx/nodes/Text.h" +#include "utils/ProjectPath.h" +#include "utils/TestUtils.h" + +namespace pag { + +static std::string SavePDFFile(const std::string& content, const std::string& key) { + auto outPath = ProjectPath::Absolute("test/out/" + key); + auto dirPath = std::filesystem::path(outPath).parent_path(); + if (!std::filesystem::exists(dirPath)) { + std::filesystem::create_directories(dirPath); + } + std::ofstream file(outPath, std::ios::binary); + if (file) { + file.write(content.data(), static_cast(content.size())); + } + return outPath; +} + +static void AssertValidPDF(const std::string& pdf, const std::string& label = "") { + ASSERT_FALSE(pdf.empty()) << label << " PDF export failed"; + EXPECT_GE(pdf.size(), 50u) << label << " PDF too small"; + EXPECT_EQ(pdf.substr(0, 5), "%PDF-") << label << " missing PDF header"; + EXPECT_NE(pdf.find("%%EOF"), std::string::npos) << label << " missing PDF trailer"; +} + +/** + * Read all SVG files from resources/svg, convert each to PAGX, then export to PDF. + */ +PAGX_TEST(PAGXPDFTest, SVGToPAGXToPDF) { + std::string svgDir = ProjectPath::Absolute("resources/svg"); + std::vector svgFiles = {}; + + for (const auto& entry : std::filesystem::directory_iterator(svgDir)) { + if (entry.path().extension() == ".svg") { + svgFiles.push_back(entry.path().string()); + } + } + std::sort(svgFiles.begin(), svgFiles.end()); + ASSERT_FALSE(svgFiles.empty()) << "No SVG files found in resources/svg"; + + for (const auto& svgPath : svgFiles) { + std::string baseName = std::filesystem::path(svgPath).stem().string(); + + auto doc = pagx::SVGImporter::Parse(svgPath); + if (!doc) { + ADD_FAILURE() << "Failed to parse SVG: " << svgPath; + continue; + } + if (doc->width <= 0 || doc->height <= 0) { + continue; + } + + auto pagxXml = pagx::PAGXExporter::ToXML(*doc); + ASSERT_FALSE(pagxXml.empty()) << baseName << " PAGX export failed"; + + auto reloadedDoc = pagx::PAGXImporter::FromXML(pagxXml); + ASSERT_NE(reloadedDoc, nullptr) << baseName << " PAGX re-import failed"; + + auto pdfContent = pagx::PDFExporter::ToPDF(*reloadedDoc); + AssertValidPDF(pdfContent, baseName); + + SavePDFFile(pdfContent, "PAGXPDFTest/" + baseName + ".pdf"); + } +} + +// Covers: emitEllipsePath, emitRoundedRectPath, emitRectPath (without transform) +PAGX_TEST(PAGXPDFTest, EllipseAndRoundedRect) { + auto doc = pagx::PAGXDocument::Make(300, 300); + auto layer = doc->makeNode(); + + auto ellipse = doc->makeNode(); + ellipse->position = {100, 100}; + ellipse->size = {120, 80}; + + auto rect = doc->makeNode(); + rect->position = {200, 200}; + rect->size = {100, 60}; + rect->roundness = 15; + + auto plainRect = doc->makeNode(); + plainRect->position = {50, 250}; + plainRect->size = {80, 40}; + plainRect->roundness = 0; + + auto fill = doc->makeNode(); + auto solid = doc->makeNode(); + solid->color = {0.2f, 0.5f, 0.8f, 1.0f}; + fill->color = solid; + + layer->contents = {ellipse, rect, plainRect, fill}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "EllipseAndRoundedRect"); + EXPECT_NE(pdf.find(" c\n"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/EllipseAndRoundedRect.pdf"); +} + +// Covers: applyStrokeAttrs – round/square cap, round/bevel join, miterLimit, dashes +PAGX_TEST(PAGXPDFTest, StrokeAttributes) { + auto doc = pagx::PAGXDocument::Make(400, 200); + auto layer = doc->makeNode(); + + auto path1 = doc->makeNode(); + auto pd1 = doc->makeNode(); + pd1->moveTo(10, 50); + pd1->lineTo(190, 50); + path1->data = pd1; + + auto stroke1 = doc->makeNode(); + auto sc1 = doc->makeNode(); + sc1->color = {1.0f, 0.0f, 0.0f, 1.0f}; + stroke1->color = sc1; + stroke1->width = 4.0f; + stroke1->cap = pagx::LineCap::Round; + stroke1->join = pagx::LineJoin::Round; + + layer->contents = {path1, stroke1}; + + auto layer2 = doc->makeNode(); + auto path2 = doc->makeNode(); + auto pd2 = doc->makeNode(); + pd2->moveTo(10, 100); + pd2->lineTo(100, 60); + pd2->lineTo(190, 100); + path2->data = pd2; + + auto stroke2 = doc->makeNode(); + auto sc2 = doc->makeNode(); + sc2->color = {0.0f, 0.0f, 1.0f, 1.0f}; + stroke2->color = sc2; + stroke2->width = 3.0f; + stroke2->cap = pagx::LineCap::Square; + stroke2->join = pagx::LineJoin::Bevel; + stroke2->dashes = {10.0f, 5.0f, 3.0f, 5.0f}; + stroke2->dashOffset = 2.0f; + + layer2->contents = {path2, stroke2}; + + auto layer3 = doc->makeNode(); + auto path3 = doc->makeNode(); + auto pd3 = doc->makeNode(); + pd3->moveTo(210, 150); + pd3->lineTo(300, 20); + pd3->lineTo(390, 150); + path3->data = pd3; + + auto stroke3 = doc->makeNode(); + auto sc3 = doc->makeNode(); + sc3->color = {0.0f, 0.5f, 0.0f, 1.0f}; + stroke3->color = sc3; + stroke3->width = 5.0f; + stroke3->join = pagx::LineJoin::Miter; + stroke3->miterLimit = 10.0f; + + layer3->contents = {path3, stroke3}; + + doc->layers = {layer, layer2, layer3}; + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "StrokeAttributes"); + EXPECT_NE(pdf.find(" J\n"), std::string::npos); + EXPECT_NE(pdf.find(" j\n"), std::string::npos); + EXPECT_NE(pdf.find(" d\n"), std::string::npos); + EXPECT_NE(pdf.find(" M\n"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/StrokeAttributes.pdf"); +} + +// Covers: fillAndStroke, fillEvenOddAndStroke, fill, fillEvenOdd, stroke, endPath +PAGX_TEST(PAGXPDFTest, FillAndStrokeCombinations) { + auto doc = pagx::PAGXDocument::Make(300, 300); + + { + auto layer = doc->makeNode(); + auto rect = doc->makeNode(); + rect->position = {75, 75}; + rect->size = {100, 100}; + + auto fill = doc->makeNode(); + auto fc = doc->makeNode(); + fc->color = {1.0f, 0.8f, 0.0f, 1.0f}; + fill->color = fc; + fill->fillRule = pagx::FillRule::EvenOdd; + + auto stroke = doc->makeNode(); + auto stc = doc->makeNode(); + stc->color = {0.0f, 0.0f, 0.0f, 1.0f}; + stroke->color = stc; + stroke->width = 2.0f; + + layer->contents = {rect, fill, stroke}; + doc->layers.push_back(layer); + } + + { + auto layer = doc->makeNode(); + auto rect = doc->makeNode(); + rect->position = {225, 75}; + rect->size = {100, 100}; + + auto stroke = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0.8f, 0.0f, 0.0f, 1.0f}; + stroke->color = sc; + stroke->width = 3.0f; + + layer->contents = {rect, stroke}; + doc->layers.push_back(layer); + } + + { + auto layer = doc->makeNode(); + auto rect = doc->makeNode(); + rect->position = {75, 225}; + rect->size = {100, 100}; + + auto fill = doc->makeNode(); + auto fc = doc->makeNode(); + fc->color = {0.0f, 0.6f, 0.0f, 1.0f}; + fill->color = fc; + fill->fillRule = pagx::FillRule::Winding; + + auto stroke = doc->makeNode(); + auto stc = doc->makeNode(); + stc->color = {0.0f, 0.0f, 0.5f, 1.0f}; + stroke->color = stc; + stroke->width = 2.0f; + + layer->contents = {rect, fill, stroke}; + doc->layers.push_back(layer); + } + + { + auto layer = doc->makeNode(); + auto rect = doc->makeNode(); + rect->position = {225, 225}; + rect->size = {100, 100}; + layer->contents = {rect}; + doc->layers.push_back(layer); + } + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "FillAndStrokeCombinations"); + EXPECT_NE(pdf.find("B*\n"), std::string::npos); + EXPECT_NE(pdf.find("S\n"), std::string::npos); + EXPECT_NE(pdf.find("n\n"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/FillAndStrokeCombinations.pdf"); +} + +// Covers: LinearGradient with alpha stops → addLinearGradientAlphaMask, HasNonOpaqueStops +PAGX_TEST(PAGXPDFTest, LinearGradientWithAlpha) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode(); + + auto rect = doc->makeNode(); + rect->position = {100, 100}; + rect->size = {180, 180}; + + auto grad = doc->makeNode(); + grad->startPoint = {0, 0}; + grad->endPoint = {180, 180}; + + auto stop0 = doc->makeNode(); + stop0->offset = 0.0f; + stop0->color = {1.0f, 0.0f, 0.0f, 1.0f}; + auto stop1 = doc->makeNode(); + stop1->offset = 1.0f; + stop1->color = {0.0f, 0.0f, 1.0f, 0.3f}; + grad->colorStops = {stop0, stop1}; + + auto fill = doc->makeNode(); + fill->color = grad; + + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "LinearGradientWithAlpha"); + EXPECT_NE(pdf.find("/SMask"), std::string::npos); + EXPECT_NE(pdf.find("ShadingType 2"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/LinearGradientWithAlpha.pdf"); +} + +// Covers: RadialGradient with alpha stops → addRadialGradientAlphaMask +PAGX_TEST(PAGXPDFTest, RadialGradientWithAlpha) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode(); + + auto ellipse = doc->makeNode(); + ellipse->position = {100, 100}; + ellipse->size = {180, 180}; + + auto grad = doc->makeNode(); + grad->center = {90, 90}; + grad->radius = 90; + + auto stop0 = doc->makeNode(); + stop0->offset = 0.0f; + stop0->color = {1.0f, 1.0f, 0.0f, 0.8f}; + auto stop1 = doc->makeNode(); + stop1->offset = 1.0f; + stop1->color = {0.5f, 0.0f, 0.5f, 0.2f}; + grad->colorStops = {stop0, stop1}; + + auto fill = doc->makeNode(); + fill->color = grad; + + layer->contents = {ellipse, fill}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "RadialGradientWithAlpha"); + EXPECT_NE(pdf.find("ShadingType 3"), std::string::npos); + EXPECT_NE(pdf.find("/SMask"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/RadialGradientWithAlpha.pdf"); +} + +// Covers: multi-stop gradient → buildStitchingFunction (FunctionType 3) +PAGX_TEST(PAGXPDFTest, MultiStopGradient) { + auto doc = pagx::PAGXDocument::Make(300, 100); + auto layer = doc->makeNode(); + + auto rect = doc->makeNode(); + rect->position = {150, 50}; + rect->size = {280, 80}; + + auto grad = doc->makeNode(); + grad->startPoint = {0, 0}; + grad->endPoint = {280, 0}; + + auto s0 = doc->makeNode(); + s0->offset = 0.0f; + s0->color = {1, 0, 0, 1}; + auto s1 = doc->makeNode(); + s1->offset = 0.25f; + s1->color = {1, 1, 0, 1}; + auto s2 = doc->makeNode(); + s2->offset = 0.5f; + s2->color = {0, 1, 0, 1}; + auto s3 = doc->makeNode(); + s3->offset = 0.75f; + s3->color = {0, 1, 1, 1}; + auto s4 = doc->makeNode(); + s4->offset = 1.0f; + s4->color = {0, 0, 1, 1}; + grad->colorStops = {s0, s1, s2, s3, s4}; + + auto fill = doc->makeNode(); + fill->color = grad; + + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "MultiStopGradient"); + EXPECT_NE(pdf.find("FunctionType 3"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/MultiStopGradient.pdf"); +} + +// Covers: writeTextAsPDFText (ASCII path), ensureDefaultFont, EscapePDFString +PAGX_TEST(PAGXPDFTest, TextAsPDFText_ASCII) { + auto doc = pagx::PAGXDocument::Make(300, 100); + auto layer = doc->makeNode(); + + auto text = doc->makeNode(); + text->text = "Hello (world) \\ test"; + text->position = {20, 50}; + text->fontSize = 24.0f; + + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0, 0, 0, 1}; + fill->color = sc; + + layer->contents = {text, fill}; + doc->layers.push_back(layer); + + pagx::PDFExporter::Options opts; + opts.convertTextToPath = false; + auto pdf = pagx::PDFExporter::ToPDF(*doc, opts); + AssertValidPDF(pdf, "TextAsPDFText_ASCII"); + EXPECT_NE(pdf.find("BT\n"), std::string::npos); + EXPECT_NE(pdf.find("Tf\n"), std::string::npos); + EXPECT_NE(pdf.find("Helvetica"), std::string::npos); + EXPECT_NE(pdf.find("\\("), std::string::npos); + EXPECT_NE(pdf.find("\\)"), std::string::npos); + EXPECT_NE(pdf.find("\\\\"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/TextAsPDFText_ASCII.pdf"); +} + +// Covers: writeTextAsPDFText (non-ASCII CID font path), ensureCIDFont, showTextHex +PAGX_TEST(PAGXPDFTest, TextAsPDFText_NonASCII) { + auto doc = pagx::PAGXDocument::Make(300, 100); + auto layer = doc->makeNode(); + + auto text = doc->makeNode(); + text->text = "\xe4\xb8\xad\xe6\x96\x87\xe6\xb5\x8b\xe8\xaf\x95"; + text->position = {20, 60}; + text->fontSize = 20.0f; + + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0, 0, 0, 1}; + fill->color = sc; + + layer->contents = {text, fill}; + doc->layers.push_back(layer); + + pagx::PDFExporter::Options opts; + opts.convertTextToPath = false; + auto pdf = pagx::PDFExporter::ToPDF(*doc, opts); + AssertValidPDF(pdf, "TextAsPDFText_NonASCII"); + EXPECT_NE(pdf.find("UniGB-UCS2-H"), std::string::npos); + EXPECT_NE(pdf.find("> Tj\n"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/TextAsPDFText_NonASCII.pdf"); +} + +// Covers: writeText with convertTextToPath=true but no glyphRuns → falls back to PDF text +PAGX_TEST(PAGXPDFTest, TextFallbackToNativeText) { + auto doc = pagx::PAGXDocument::Make(200, 80); + auto layer = doc->makeNode(); + + auto text = doc->makeNode(); + text->text = "Fallback"; + text->position = {10, 40}; + text->fontSize = 16.0f; + + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0, 0, 0, 1}; + fill->color = sc; + + layer->contents = {text, fill}; + doc->layers.push_back(layer); + + pagx::PDFExporter::Options opts; + opts.convertTextToPath = true; + auto pdf = pagx::PDFExporter::ToPDF(*doc, opts); + AssertValidPDF(pdf, "TextFallbackToNativeText"); + EXPECT_NE(pdf.find("BT\n"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/TextFallbackToNativeText.pdf"); +} + +// Covers: empty text skips (writeText, writeTextAsPDFText early returns) +PAGX_TEST(PAGXPDFTest, EmptyTextAndEmptyPath) { + auto doc = pagx::PAGXDocument::Make(100, 100); + + { + auto layer = doc->makeNode(); + auto text = doc->makeNode(); + text->text = ""; + text->position = {10, 50}; + + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0, 0, 0, 1}; + fill->color = sc; + + layer->contents = {text, fill}; + doc->layers.push_back(layer); + } + + { + auto layer = doc->makeNode(); + auto path = doc->makeNode(); + path->data = nullptr; + + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0, 0, 0, 1}; + fill->color = sc; + + layer->contents = {path, fill}; + doc->layers.push_back(layer); + } + + { + auto layer = doc->makeNode(); + auto path = doc->makeNode(); + auto pd = doc->makeNode(); + path->data = pd; + + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0, 0, 0, 1}; + fill->color = sc; + + layer->contents = {path, fill}; + doc->layers.push_back(layer); + } + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "EmptyTextAndEmptyPath"); +} + +// Covers: layer visible=false (writeLayer early return), layer x/y offset, layer alpha +PAGX_TEST(PAGXPDFTest, LayerVisibilityAlphaOffset) { + auto doc = pagx::PAGXDocument::Make(300, 200); + + { + auto layer = doc->makeNode(); + layer->visible = false; + auto rect = doc->makeNode(); + rect->position = {50, 50}; + rect->size = {80, 80}; + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {1, 0, 0, 1}; + fill->color = sc; + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + } + + { + auto layer = doc->makeNode(); + layer->alpha = 0.5f; + layer->x = 20.0f; + layer->y = 30.0f; + auto rect = doc->makeNode(); + rect->position = {100, 60}; + rect->size = {80, 60}; + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0, 0.5f, 1, 1}; + fill->color = sc; + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + } + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "LayerVisibilityAlphaOffset"); + EXPECT_NE(pdf.find("/ca"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/LayerVisibilityAlphaOffset.pdf"); +} + +// Covers: layer blendMode → getBlendModeExtGState, BlendModeToPDFName +PAGX_TEST(PAGXPDFTest, LayerBlendModes) { + auto doc = pagx::PAGXDocument::Make(200, 200); + + pagx::BlendMode modes[] = { + pagx::BlendMode::Multiply, pagx::BlendMode::Screen, pagx::BlendMode::Overlay, + pagx::BlendMode::Darken, pagx::BlendMode::Lighten, pagx::BlendMode::ColorDodge, + pagx::BlendMode::ColorBurn, pagx::BlendMode::HardLight, pagx::BlendMode::SoftLight, + pagx::BlendMode::Difference, pagx::BlendMode::Exclusion, pagx::BlendMode::Hue, + pagx::BlendMode::Saturation, pagx::BlendMode::Color, pagx::BlendMode::Luminosity, + }; + + for (auto mode : modes) { + auto layer = doc->makeNode(); + layer->blendMode = mode; + auto rect = doc->makeNode(); + rect->position = {100, 100}; + rect->size = {50, 50}; + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0.5f, 0.5f, 0.5f, 1}; + fill->color = sc; + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + } + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "LayerBlendModes"); + EXPECT_NE(pdf.find("/BM /Multiply"), std::string::npos); + EXPECT_NE(pdf.find("/BM /Screen"), std::string::npos); + EXPECT_NE(pdf.find("/BM /Luminosity"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/LayerBlendModes.pdf"); +} + +// Covers: layer with unsupported blend mode (PlusLighter → returns nullptr from BlendModeToPDFName) +PAGX_TEST(PAGXPDFTest, LayerUnsupportedBlendMode) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode(); + layer->blendMode = pagx::BlendMode::PlusLighter; + auto rect = doc->makeNode(); + rect->position = {50, 50}; + rect->size = {80, 80}; + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0.5f, 0.5f, 0.5f, 1}; + fill->color = sc; + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "LayerUnsupportedBlendMode"); +} + +// Covers: contour mask → writeClipPath, clip, clipEvenOdd, endPath +PAGX_TEST(PAGXPDFTest, ContourMask) { + auto doc = pagx::PAGXDocument::Make(200, 200); + + auto maskLayer = doc->makeNode(); + auto maskEllipse = doc->makeNode(); + maskEllipse->position = {100, 100}; + maskEllipse->size = {160, 160}; + maskLayer->contents = {maskEllipse}; + + auto layer = doc->makeNode(); + layer->mask = maskLayer; + layer->maskType = pagx::MaskType::Contour; + + auto rect = doc->makeNode(); + rect->position = {100, 100}; + rect->size = {200, 200}; + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0, 0.7f, 0, 1}; + fill->color = sc; + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "ContourMask"); + EXPECT_NE(pdf.find("W\n"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/ContourMask.pdf"); +} + +// Covers: contour mask with EvenOdd fill rule → clipEvenOdd +PAGX_TEST(PAGXPDFTest, ContourMaskEvenOdd) { + auto doc = pagx::PAGXDocument::Make(200, 200); + + auto maskLayer = doc->makeNode(); + auto maskRect = doc->makeNode(); + maskRect->position = {100, 100}; + maskRect->size = {180, 180}; + auto maskFill = doc->makeNode(); + maskFill->fillRule = pagx::FillRule::EvenOdd; + maskLayer->contents = {maskRect, maskFill}; + + auto layer = doc->makeNode(); + layer->mask = maskLayer; + layer->maskType = pagx::MaskType::Contour; + + auto rect = doc->makeNode(); + rect->position = {100, 100}; + rect->size = {200, 200}; + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0.8f, 0.2f, 0, 1}; + fill->color = sc; + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "ContourMaskEvenOdd"); + EXPECT_NE(pdf.find("W*\n"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/ContourMaskEvenOdd.pdf"); +} + +// Covers: soft mask (Alpha + Luminance) → writeSoftMask, renderMaskLayerContent, addSoftMaskExtGState +PAGX_TEST(PAGXPDFTest, SoftMask) { + auto doc = pagx::PAGXDocument::Make(200, 200); + + { + auto maskLayer = doc->makeNode(); + auto maskRect = doc->makeNode(); + maskRect->position = {100, 100}; + maskRect->size = {120, 120}; + auto maskFill = doc->makeNode(); + auto mc = doc->makeNode(); + mc->color = {1, 1, 1, 0.5f}; + maskFill->color = mc; + maskLayer->contents = {maskRect, maskFill}; + + auto layer = doc->makeNode(); + layer->mask = maskLayer; + layer->maskType = pagx::MaskType::Alpha; + + auto rect = doc->makeNode(); + rect->position = {100, 100}; + rect->size = {160, 160}; + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0, 0, 1, 1}; + fill->color = sc; + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + } + + { + auto maskLayer = doc->makeNode(); + maskLayer->alpha = 0.7f; + maskLayer->matrix = pagx::Matrix::Translate(10, 10); + auto maskEllipse = doc->makeNode(); + maskEllipse->position = {100, 100}; + maskEllipse->size = {100, 100}; + auto maskFill = doc->makeNode(); + auto mc = doc->makeNode(); + mc->color = {1, 1, 1, 1}; + maskFill->color = mc; + maskLayer->contents = {maskEllipse, maskFill}; + + auto layer = doc->makeNode(); + layer->mask = maskLayer; + layer->maskType = pagx::MaskType::Luminance; + + auto rect = doc->makeNode(); + rect->position = {100, 100}; + rect->size = {180, 180}; + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {1, 0, 0, 1}; + fill->color = sc; + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + } + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "SoftMask"); + EXPECT_NE(pdf.find("/Alpha"), std::string::npos); + EXPECT_NE(pdf.find("/Luminosity"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/SoftMask.pdf"); +} + +// Covers: soft mask with nested children → writeClipPath recursion with children +PAGX_TEST(PAGXPDFTest, ContourMaskWithChildren) { + auto doc = pagx::PAGXDocument::Make(200, 200); + + auto maskLayer = doc->makeNode(); + auto maskRect = doc->makeNode(); + maskRect->position = {100, 100}; + maskRect->size = {180, 180}; + maskLayer->contents = {maskRect}; + + auto childMask = doc->makeNode(); + auto childEllipse = doc->makeNode(); + childEllipse->position = {100, 100}; + childEllipse->size = {100, 100}; + childMask->contents = {childEllipse}; + maskLayer->children.push_back(childMask); + + auto layer = doc->makeNode(); + layer->mask = maskLayer; + layer->maskType = pagx::MaskType::Contour; + + auto rect = doc->makeNode(); + rect->position = {100, 100}; + rect->size = {200, 200}; + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0.3f, 0.3f, 0.8f, 1}; + fill->color = sc; + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "ContourMaskWithChildren"); + SavePDFFile(pdf, "PAGXPDFTest/ContourMaskWithChildren.pdf"); +} + +// Covers: soft mask with nested children → renderMaskLayerContent recursion +PAGX_TEST(PAGXPDFTest, SoftMaskWithChildren) { + auto doc = pagx::PAGXDocument::Make(200, 200); + + auto maskLayer = doc->makeNode(); + auto maskRect = doc->makeNode(); + maskRect->position = {100, 100}; + maskRect->size = {180, 180}; + auto maskFill = doc->makeNode(); + auto mc = doc->makeNode(); + mc->color = {1, 1, 1, 1}; + maskFill->color = mc; + maskLayer->contents = {maskRect, maskFill}; + + auto childMask = doc->makeNode(); + auto childEllipse = doc->makeNode(); + childEllipse->position = {100, 100}; + childEllipse->size = {80, 80}; + auto childFill = doc->makeNode(); + auto cfc = doc->makeNode(); + cfc->color = {0.5f, 0.5f, 0.5f, 1}; + childFill->color = cfc; + childMask->contents = {childEllipse, childFill}; + maskLayer->children.push_back(childMask); + + auto layer = doc->makeNode(); + layer->mask = maskLayer; + layer->maskType = pagx::MaskType::Alpha; + + auto rect = doc->makeNode(); + rect->position = {100, 100}; + rect->size = {200, 200}; + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0, 0.8f, 0.2f, 1}; + fill->color = sc; + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "SoftMaskWithChildren"); + SavePDFFile(pdf, "PAGXPDFTest/SoftMaskWithChildren.pdf"); +} + +// Covers: Group with alpha and non-identity transform → writeElements Group branch +PAGX_TEST(PAGXPDFTest, GroupWithAlphaAndTransform) { + auto doc = pagx::PAGXDocument::Make(300, 200); + auto layer = doc->makeNode(); + + auto group = doc->makeNode(); + group->position = {50, 30}; + group->alpha = 0.6f; + + auto rect = doc->makeNode(); + rect->position = {60, 40}; + rect->size = {100, 60}; + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0.9f, 0.1f, 0.3f, 1}; + fill->color = sc; + group->elements = {rect, fill}; + + auto fill2 = doc->makeNode(); + auto sc2 = doc->makeNode(); + sc2->color = {0, 0, 0, 1}; + fill2->color = sc2; + layer->contents = {group, fill2}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "GroupWithAlphaAndTransform"); + SavePDFFile(pdf, "PAGXPDFTest/GroupWithAlphaAndTransform.pdf"); +} + +// Covers: Group without alpha and with identity transform (else branch in writeElements) +PAGX_TEST(PAGXPDFTest, GroupNoAlphaIdentityTransform) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode(); + + auto group = doc->makeNode(); + group->alpha = 1.0f; + + auto rect = doc->makeNode(); + rect->position = {100, 100}; + rect->size = {80, 80}; + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0.1f, 0.1f, 0.9f, 1}; + fill->color = sc; + group->elements = {rect, fill}; + + auto fill2 = doc->makeNode(); + auto sc2 = doc->makeNode(); + sc2->color = {0, 0, 0, 1}; + fill2->color = sc2; + layer->contents = {group, fill2}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "GroupNoAlphaIdentityTransform"); +} + +// Covers: path with quad bezier (PathVerb::Quad → cubic conversion) +PAGX_TEST(PAGXPDFTest, PathWithQuadBezier) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode(); + + auto path = doc->makeNode(); + auto pd = doc->makeNode(); + pd->moveTo(20, 180); + pd->quadTo(100, 10, 180, 180); + pd->close(); + path->data = pd; + + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0.6f, 0, 0.6f, 1}; + fill->color = sc; + + layer->contents = {path, fill}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "PathWithQuadBezier"); + EXPECT_NE(pdf.find(" c\n"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/PathWithQuadBezier.pdf"); +} + +// Covers: path with cubic bezier and close +PAGX_TEST(PAGXPDFTest, PathWithCubicBezier) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode(); + + auto path = doc->makeNode(); + auto pd = doc->makeNode(); + pd->moveTo(20, 100); + pd->cubicTo(60, 10, 140, 190, 180, 100); + pd->close(); + path->data = pd; + + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0, 0.4f, 0.8f, 1}; + fill->color = sc; + + layer->contents = {path, fill}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "PathWithCubicBezier"); + EXPECT_NE(pdf.find(" c\n"), std::string::npos); + EXPECT_NE(pdf.find("h\n"), std::string::npos); +} + +// Covers: PDFExporter::ToFile +PAGX_TEST(PAGXPDFTest, ToFile) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode(); + auto rect = doc->makeNode(); + rect->position = {50, 50}; + rect->size = {80, 80}; + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0.2f, 0.8f, 0.2f, 1}; + fill->color = sc; + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + + auto outDir = ProjectPath::Absolute("test/out/PAGXPDFTest"); + std::filesystem::create_directories(outDir); + auto outPath = outDir + "/ToFile.pdf"; + ASSERT_TRUE(pagx::PDFExporter::ToFile(*doc, outPath)); + ASSERT_TRUE(std::filesystem::exists(outPath)); + auto fileSize = std::filesystem::file_size(outPath); + EXPECT_GT(fileSize, 50u); + + EXPECT_FALSE(pagx::PDFExporter::ToFile(*doc, "/nonexistent_dir/impossible.pdf")); +} + +// Covers: fill alpha < 1, stroke alpha < 1 → getExtGState +PAGX_TEST(PAGXPDFTest, FillStrokeAlpha) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode(); + + auto rect = doc->makeNode(); + rect->position = {100, 100}; + rect->size = {160, 160}; + + auto fill = doc->makeNode(); + auto fc = doc->makeNode(); + fc->color = {1, 0, 0, 1}; + fill->color = fc; + fill->alpha = 0.5f; + + auto stroke = doc->makeNode(); + auto stc = doc->makeNode(); + stc->color = {0, 0, 1, 1}; + stroke->color = stc; + stroke->width = 3.0f; + stroke->alpha = 0.7f; + + layer->contents = {rect, fill, stroke}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "FillStrokeAlpha"); + EXPECT_NE(pdf.find("/ca"), std::string::npos); + EXPECT_NE(pdf.find("/CA"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/FillStrokeAlpha.pdf"); +} + +// Covers: gradient on stroke → setStrokePattern +PAGX_TEST(PAGXPDFTest, GradientStroke) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode(); + + auto rect = doc->makeNode(); + rect->position = {100, 100}; + rect->size = {160, 160}; + + auto grad = doc->makeNode(); + grad->startPoint = {0, 0}; + grad->endPoint = {160, 0}; + auto s0 = doc->makeNode(); + s0->offset = 0; + s0->color = {1, 0, 0, 1}; + auto s1 = doc->makeNode(); + s1->offset = 1; + s1->color = {0, 0, 1, 1}; + grad->colorStops = {s0, s1}; + + auto stroke = doc->makeNode(); + stroke->color = grad; + stroke->width = 4.0f; + + layer->contents = {rect, stroke}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "GradientStroke"); + EXPECT_NE(pdf.find("/Pattern CS"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/GradientStroke.pdf"); +} + +// Covers: gradient fill → setFillPattern +PAGX_TEST(PAGXPDFTest, GradientFillPattern) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode(); + + auto rect = doc->makeNode(); + rect->position = {100, 100}; + rect->size = {180, 180}; + + auto grad = doc->makeNode(); + grad->startPoint = {0, 0}; + grad->endPoint = {180, 0}; + auto s0 = doc->makeNode(); + s0->offset = 0; + s0->color = {0, 1, 0, 1}; + auto s1 = doc->makeNode(); + s1->offset = 1; + s1->color = {1, 0, 1, 1}; + grad->colorStops = {s0, s1}; + + auto fill = doc->makeNode(); + fill->color = grad; + + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "GradientFillPattern"); + EXPECT_NE(pdf.find("/Pattern cs"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/GradientFillPattern.pdf"); +} + +// Covers: layer with non-identity matrix → concatMatrix in writeLayer +PAGX_TEST(PAGXPDFTest, LayerWithMatrix) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode(); + layer->matrix = pagx::Matrix::Rotate(15); + + auto rect = doc->makeNode(); + rect->position = {100, 100}; + rect->size = {80, 80}; + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0.8f, 0.4f, 0, 1}; + fill->color = sc; + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "LayerWithMatrix"); + EXPECT_NE(pdf.find(" cm\n"), std::string::npos); +} + +// Covers: layer with child layers (recursion in writeLayer) +PAGX_TEST(PAGXPDFTest, LayerWithChildren) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto parent = doc->makeNode(); + + auto child = doc->makeNode(); + child->x = 10; + child->y = 10; + auto rect = doc->makeNode(); + rect->position = {50, 50}; + rect->size = {60, 60}; + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0, 0, 1, 1}; + fill->color = sc; + child->contents = {rect, fill}; + parent->children.push_back(child); + + auto parentRect = doc->makeNode(); + parentRect->position = {100, 100}; + parentRect->size = {180, 180}; + auto parentFill = doc->makeNode(); + auto psc = doc->makeNode(); + psc->color = {1, 1, 0, 0.3f}; + parentFill->color = psc; + parent->contents = {parentRect, parentFill}; + + doc->layers.push_back(parent); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "LayerWithChildren"); + SavePDFFile(pdf, "PAGXPDFTest/LayerWithChildren.pdf"); +} + +// Covers: gradient with non-identity matrix → ctm * grad->matrix in addLinearGradient +PAGX_TEST(PAGXPDFTest, GradientWithMatrix) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode(); + + auto rect = doc->makeNode(); + rect->position = {100, 100}; + rect->size = {180, 180}; + + auto grad = doc->makeNode(); + grad->startPoint = {0, 0}; + grad->endPoint = {180, 180}; + grad->matrix = pagx::Matrix::Rotate(45); + + auto s0 = doc->makeNode(); + s0->offset = 0; + s0->color = {1, 0.5f, 0, 1}; + auto s1 = doc->makeNode(); + s1->offset = 1; + s1->color = {0, 0.5f, 1, 1}; + grad->colorStops = {s0, s1}; + + auto fill = doc->makeNode(); + fill->color = grad; + + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "GradientWithMatrix"); + EXPECT_NE(pdf.find("/Matrix"), std::string::npos); +} + +// Covers: radial gradient (without alpha) → addRadialGradient, ShadingType 3 +PAGX_TEST(PAGXPDFTest, RadialGradientOpaque) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode(); + + auto ellipse = doc->makeNode(); + ellipse->position = {100, 100}; + ellipse->size = {160, 160}; + + auto grad = doc->makeNode(); + grad->center = {80, 80}; + grad->radius = 80; + auto s0 = doc->makeNode(); + s0->offset = 0; + s0->color = {1, 1, 1, 1}; + auto s1 = doc->makeNode(); + s1->offset = 1; + s1->color = {0, 0, 0, 1}; + grad->colorStops = {s0, s1}; + + auto fill = doc->makeNode(); + fill->color = grad; + + layer->contents = {ellipse, fill}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "RadialGradientOpaque"); + EXPECT_NE(pdf.find("ShadingType 3"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/RadialGradientOpaque.pdf"); +} + +// Covers: multi-stop gradient with alpha → stitching function + alpha mask for >2 stops +PAGX_TEST(PAGXPDFTest, MultiStopGradientWithAlpha) { + auto doc = pagx::PAGXDocument::Make(300, 100); + auto layer = doc->makeNode(); + + auto rect = doc->makeNode(); + rect->position = {150, 50}; + rect->size = {280, 80}; + + auto grad = doc->makeNode(); + grad->startPoint = {0, 0}; + grad->endPoint = {280, 0}; + + auto s0 = doc->makeNode(); + s0->offset = 0; + s0->color = {1, 0, 0, 0.9f}; + auto s1 = doc->makeNode(); + s1->offset = 0.5f; + s1->color = {0, 1, 0, 0.4f}; + auto s2 = doc->makeNode(); + s2->offset = 1; + s2->color = {0, 0, 1, 0.1f}; + grad->colorStops = {s0, s1, s2}; + + auto fill = doc->makeNode(); + fill->color = grad; + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "MultiStopGradientWithAlpha"); + EXPECT_NE(pdf.find("FunctionType 3"), std::string::npos); + EXPECT_NE(pdf.find("/SMask"), std::string::npos); + SavePDFFile(pdf, "PAGXPDFTest/MultiStopGradientWithAlpha.pdf"); +} + +// Covers: multi-stop radial gradient with alpha → radial stitching + radial alpha mask +PAGX_TEST(PAGXPDFTest, MultiStopRadialGradientWithAlpha) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode(); + + auto ellipse = doc->makeNode(); + ellipse->position = {100, 100}; + ellipse->size = {180, 180}; + + auto grad = doc->makeNode(); + grad->center = {90, 90}; + grad->radius = 90; + auto s0 = doc->makeNode(); + s0->offset = 0; + s0->color = {1, 1, 0, 1.0f}; + auto s1 = doc->makeNode(); + s1->offset = 0.5f; + s1->color = {1, 0, 1, 0.5f}; + auto s2 = doc->makeNode(); + s2->offset = 1; + s2->color = {0, 1, 1, 0.0f}; + grad->colorStops = {s0, s1, s2}; + + auto fill = doc->makeNode(); + fill->color = grad; + layer->contents = {ellipse, fill}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "MultiStopRadialGradientWithAlpha"); + EXPECT_NE(pdf.find("ShadingType 3"), std::string::npos); + EXPECT_NE(pdf.find("/SMask"), std::string::npos); +} + +// Covers: gradient softMaskGSName on stroke → strokeRef.softMaskGSName branch +PAGX_TEST(PAGXPDFTest, GradientAlphaOnStroke) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode(); + + auto rect = doc->makeNode(); + rect->position = {100, 100}; + rect->size = {160, 160}; + + auto grad = doc->makeNode(); + grad->startPoint = {0, 0}; + grad->endPoint = {160, 0}; + auto s0 = doc->makeNode(); + s0->offset = 0; + s0->color = {1, 0, 0, 1.0f}; + auto s1 = doc->makeNode(); + s1->offset = 1; + s1->color = {0, 1, 0, 0.2f}; + grad->colorStops = {s0, s1}; + + auto stroke = doc->makeNode(); + stroke->color = grad; + stroke->width = 6.0f; + + layer->contents = {rect, stroke}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "GradientAlphaOnStroke"); + EXPECT_NE(pdf.find("/SMask"), std::string::npos); + EXPECT_NE(pdf.find("/Pattern CS"), std::string::npos); +} + +// Covers: SolidColor with alpha < 1 in color itself → resolveColorSource alpha path +PAGX_TEST(PAGXPDFTest, SolidColorWithAlpha) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode(); + + auto rect = doc->makeNode(); + rect->position = {50, 50}; + rect->size = {80, 80}; + + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {1, 0, 0, 0.4f}; + fill->color = sc; + + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "SolidColorWithAlpha"); + EXPECT_NE(pdf.find("/ca"), std::string::npos); +} + +// Covers: gradient alpha mask with non-identity gradient matrix +PAGX_TEST(PAGXPDFTest, GradientAlphaMaskWithGradientMatrix) { + auto doc = pagx::PAGXDocument::Make(200, 200); + auto layer = doc->makeNode(); + + auto rect = doc->makeNode(); + rect->position = {100, 100}; + rect->size = {180, 180}; + + auto grad = doc->makeNode(); + grad->startPoint = {0, 0}; + grad->endPoint = {180, 180}; + grad->matrix = pagx::Matrix::Scale(0.5f, 0.5f); + + auto s0 = doc->makeNode(); + s0->offset = 0; + s0->color = {0, 0, 1, 1.0f}; + auto s1 = doc->makeNode(); + s1->offset = 1; + s1->color = {1, 0, 0, 0.1f}; + grad->colorStops = {s0, s1}; + + auto fill = doc->makeNode(); + fill->color = grad; + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "GradientAlphaMaskWithGradientMatrix"); + EXPECT_NE(pdf.find("/SMask"), std::string::npos); + EXPECT_NE(pdf.find(" cm\n"), std::string::npos); +} + +// Covers: ExtGState cache hit path (same alpha values reused) +PAGX_TEST(PAGXPDFTest, ExtGStateCacheHit) { + auto doc = pagx::PAGXDocument::Make(200, 200); + + for (int i = 0; i < 3; i++) { + auto layer = doc->makeNode(); + layer->alpha = 0.5f; + auto rect = doc->makeNode(); + rect->position = {50.0f + static_cast(i) * 50, 100}; + rect->size = {40, 40}; + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0.5f, 0.5f, 0.5f, 1}; + fill->color = sc; + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + } + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "ExtGStateCacheHit"); +} + +// Covers: BlendMode cache hit (same mode reused) +PAGX_TEST(PAGXPDFTest, BlendModeCacheHit) { + auto doc = pagx::PAGXDocument::Make(200, 200); + + for (int i = 0; i < 3; i++) { + auto layer = doc->makeNode(); + layer->blendMode = pagx::BlendMode::Multiply; + auto rect = doc->makeNode(); + rect->position = {50.0f + static_cast(i) * 50, 100}; + rect->size = {40, 40}; + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0.5f, 0.5f, 0.5f, 1}; + fill->color = sc; + layer->contents = {rect, fill}; + doc->layers.push_back(layer); + } + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "BlendModeCacheHit"); +} + +// Covers: layer needsGroup=false (no matrix, no alpha, no mask, no x/y, Normal blend) +PAGX_TEST(PAGXPDFTest, LayerNoGroup) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode(); + auto rect = doc->makeNode(); + rect->position = {50, 50}; + rect->size = {80, 80}; + auto fill = doc->makeNode(); + auto sc = doc->makeNode(); + sc->color = {0.3f, 0.6f, 0.9f, 1}; + fill->color = sc; + layer->contents = {rect, fill}; + + auto child = doc->makeNode(); + auto childRect = doc->makeNode(); + childRect->position = {50, 50}; + childRect->size = {40, 40}; + auto childFill = doc->makeNode(); + auto csc = doc->makeNode(); + csc->color = {0.9f, 0.3f, 0.1f, 1}; + childFill->color = csc; + child->contents = {childRect, childFill}; + layer->children.push_back(child); + + doc->layers.push_back(layer); + + auto pdf = pagx::PDFExporter::ToPDF(*doc); + AssertValidPDF(pdf, "LayerNoGroup"); +} + +} // namespace pag diff --git a/test/src/PAGXSVGTest.cpp b/test/src/PAGXSVGTest.cpp index dc307a3c8e..f3cda70678 100644 --- a/test/src/PAGXSVGTest.cpp +++ b/test/src/PAGXSVGTest.cpp @@ -30,7 +30,9 @@ #include "pagx/nodes/DropShadowFilter.h" #include "pagx/nodes/Ellipse.h" #include "pagx/nodes/Fill.h" +#include "pagx/nodes/Font.h" #include "pagx/nodes/Group.h" +#include "pagx/nodes/Image.h" #include "pagx/nodes/InnerShadowFilter.h" #include "pagx/nodes/LinearGradient.h" #include "pagx/nodes/Path.h" @@ -41,6 +43,8 @@ #include "pagx/nodes/Stroke.h" #include "pagx/nodes/Text.h" #include "pagx/svg/SVGPathParser.h" +#include "pagx/types/Data.h" +#include "pagx/utils/ExporterUtils.h" #include "renderer/FontEmbedder.h" #include "renderer/LayerBuilder.h" #include "renderer/TextLayout.h" @@ -733,4 +737,738 @@ PAGX_TEST(PAGXSVGTest, SVGExport_LayerPosition) { SaveFile(svg, "PAGXSVGTest/svg_export_layer_position.svg"); } +// =========================================================================== +// ExporterUtils tests +// =========================================================================== + +PAGX_TEST(PAGXSVGTest, CollectFillStroke_Empty) { + std::vector contents; + auto info = pagx::CollectFillStroke(contents); + EXPECT_EQ(info.fill, nullptr); + EXPECT_EQ(info.stroke, nullptr); + EXPECT_EQ(info.textBox, nullptr); +} + +PAGX_TEST(PAGXSVGTest, CollectFillStroke_FillOnly) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto fill = doc->makeNode(); + std::vector contents = {fill}; + auto info = pagx::CollectFillStroke(contents); + EXPECT_EQ(info.fill, fill); + EXPECT_EQ(info.stroke, nullptr); + EXPECT_EQ(info.textBox, nullptr); +} + +PAGX_TEST(PAGXSVGTest, CollectFillStroke_StrokeOnly) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto stroke = doc->makeNode(); + std::vector contents = {stroke}; + auto info = pagx::CollectFillStroke(contents); + EXPECT_EQ(info.fill, nullptr); + EXPECT_EQ(info.stroke, stroke); + EXPECT_EQ(info.textBox, nullptr); +} + +PAGX_TEST(PAGXSVGTest, CollectFillStroke_AllThree) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto fill = doc->makeNode(); + auto stroke = doc->makeNode(); + auto textBox = doc->makeNode(); + std::vector contents = {fill, stroke, textBox}; + auto info = pagx::CollectFillStroke(contents); + EXPECT_EQ(info.fill, fill); + EXPECT_EQ(info.stroke, stroke); + EXPECT_EQ(info.textBox, textBox); +} + +PAGX_TEST(PAGXSVGTest, CollectFillStroke_DuplicatesKeepsFirst) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto fill1 = doc->makeNode(); + auto fill2 = doc->makeNode(); + auto stroke1 = doc->makeNode(); + auto stroke2 = doc->makeNode(); + std::vector contents = {fill1, stroke1, fill2, stroke2}; + auto info = pagx::CollectFillStroke(contents); + EXPECT_EQ(info.fill, fill1); + EXPECT_EQ(info.stroke, stroke1); +} + +PAGX_TEST(PAGXSVGTest, BuildLayerMatrix_Identity) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode(); + auto m = pagx::BuildLayerMatrix(layer); + EXPECT_TRUE(m.isIdentity()); +} + +PAGX_TEST(PAGXSVGTest, BuildLayerMatrix_WithPosition) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode(); + layer->x = 30.0f; + layer->y = 50.0f; + auto m = pagx::BuildLayerMatrix(layer); + EXPECT_FLOAT_EQ(m.tx, 30.0f); + EXPECT_FLOAT_EQ(m.ty, 50.0f); + EXPECT_FLOAT_EQ(m.a, 1.0f); + EXPECT_FLOAT_EQ(m.d, 1.0f); +} + +PAGX_TEST(PAGXSVGTest, BuildLayerMatrix_WithMatrix) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode(); + layer->matrix = pagx::Matrix::Scale(2.0f, 3.0f); + auto m = pagx::BuildLayerMatrix(layer); + EXPECT_FLOAT_EQ(m.a, 2.0f); + EXPECT_FLOAT_EQ(m.d, 3.0f); + EXPECT_FLOAT_EQ(m.tx, 0.0f); + EXPECT_FLOAT_EQ(m.ty, 0.0f); +} + +PAGX_TEST(PAGXSVGTest, BuildLayerMatrix_PositionAndMatrix) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode(); + layer->x = 10.0f; + layer->y = 20.0f; + layer->matrix = pagx::Matrix::Scale(2.0f, 2.0f); + auto m = pagx::BuildLayerMatrix(layer); + // Translate(10,20) * Scale(2,2) => a=2, d=2, tx=10, ty=20 + EXPECT_FLOAT_EQ(m.a, 2.0f); + EXPECT_FLOAT_EQ(m.d, 2.0f); + EXPECT_FLOAT_EQ(m.tx, 10.0f); + EXPECT_FLOAT_EQ(m.ty, 20.0f); +} + +PAGX_TEST(PAGXSVGTest, BuildGroupMatrix_Identity) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto group = doc->makeNode(); + auto m = pagx::BuildGroupMatrix(group); + EXPECT_TRUE(m.isIdentity()); +} + +PAGX_TEST(PAGXSVGTest, BuildGroupMatrix_PositionOnly) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto group = doc->makeNode(); + group->position = {50.0f, 100.0f}; + auto m = pagx::BuildGroupMatrix(group); + EXPECT_FLOAT_EQ(m.tx, 50.0f); + EXPECT_FLOAT_EQ(m.ty, 100.0f); +} + +PAGX_TEST(PAGXSVGTest, BuildGroupMatrix_AnchorOnly) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto group = doc->makeNode(); + group->anchor = {25.0f, 30.0f}; + auto m = pagx::BuildGroupMatrix(group); + EXPECT_FLOAT_EQ(m.tx, -25.0f); + EXPECT_FLOAT_EQ(m.ty, -30.0f); +} + +PAGX_TEST(PAGXSVGTest, BuildGroupMatrix_ScaleOnly) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto group = doc->makeNode(); + group->scale = {2.0f, 3.0f}; + auto m = pagx::BuildGroupMatrix(group); + EXPECT_FLOAT_EQ(m.a, 2.0f); + EXPECT_FLOAT_EQ(m.d, 3.0f); +} + +PAGX_TEST(PAGXSVGTest, BuildGroupMatrix_RotationOnly) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto group = doc->makeNode(); + group->rotation = 90.0f; + auto m = pagx::BuildGroupMatrix(group); + EXPECT_NEAR(m.a, 0.0f, 1e-5f); + EXPECT_NEAR(m.b, 1.0f, 1e-5f); + EXPECT_NEAR(m.c, -1.0f, 1e-5f); + EXPECT_NEAR(m.d, 0.0f, 1e-5f); +} + +PAGX_TEST(PAGXSVGTest, BuildGroupMatrix_SkewOnly) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto group = doc->makeNode(); + group->skew = 45.0f; + auto m = pagx::BuildGroupMatrix(group); + EXPECT_NEAR(m.c, std::tan(45.0f * 3.14159265358979323846f / 180.0f), 1e-5f); +} + +PAGX_TEST(PAGXSVGTest, BuildGroupMatrix_AnchorAndPosition) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto group = doc->makeNode(); + group->anchor = {10.0f, 20.0f}; + group->position = {50.0f, 60.0f}; + auto m = pagx::BuildGroupMatrix(group); + // Translate(50,60) * Translate(-10,-20) => tx=40, ty=40 + EXPECT_FLOAT_EQ(m.tx, 40.0f); + EXPECT_FLOAT_EQ(m.ty, 40.0f); +} + +PAGX_TEST(PAGXSVGTest, BuildGroupMatrix_ScaleAndAnchor) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto group = doc->makeNode(); + group->anchor = {50.0f, 50.0f}; + group->scale = {2.0f, 2.0f}; + auto m = pagx::BuildGroupMatrix(group); + // Scale(2,2) * Translate(-50,-50) => a=2, d=2, tx=-100, ty=-100 + EXPECT_FLOAT_EQ(m.a, 2.0f); + EXPECT_FLOAT_EQ(m.d, 2.0f); + EXPECT_FLOAT_EQ(m.tx, -100.0f); + EXPECT_FLOAT_EQ(m.ty, -100.0f); +} + +PAGX_TEST(PAGXSVGTest, DetectMaskFillRule_WindingDefault) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode(); + auto fill = doc->makeNode(); + fill->fillRule = pagx::FillRule::Winding; + layer->contents.push_back(fill); + EXPECT_EQ(pagx::DetectMaskFillRule(layer), pagx::FillRule::Winding); +} + +PAGX_TEST(PAGXSVGTest, DetectMaskFillRule_EvenOdd) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode(); + auto fill = doc->makeNode(); + fill->fillRule = pagx::FillRule::EvenOdd; + layer->contents.push_back(fill); + EXPECT_EQ(pagx::DetectMaskFillRule(layer), pagx::FillRule::EvenOdd); +} + +PAGX_TEST(PAGXSVGTest, DetectMaskFillRule_Empty) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode(); + EXPECT_EQ(pagx::DetectMaskFillRule(layer), pagx::FillRule::Winding); +} + +PAGX_TEST(PAGXSVGTest, DetectMaskFillRule_NestedEvenOdd) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto parent = doc->makeNode(); + auto child = doc->makeNode(); + auto fill = doc->makeNode(); + fill->fillRule = pagx::FillRule::EvenOdd; + child->contents.push_back(fill); + parent->children.push_back(child); + EXPECT_EQ(pagx::DetectMaskFillRule(parent), pagx::FillRule::EvenOdd); +} + +PAGX_TEST(PAGXSVGTest, GetPNGDimensions_Valid) { + // Construct a minimal valid PNG header: 8-byte signature + IHDR chunk (4 len + 4 type + 8 data) + uint8_t header[24] = { + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, // IHDR length (13) + 0x49, 0x48, 0x44, 0x52, // "IHDR" + 0x00, 0x00, 0x01, 0x00, // width = 256 + 0x00, 0x00, 0x00, 0x80 // height = 128 + }; + int w = 0, h = 0; + EXPECT_TRUE(pagx::GetPNGDimensions(header, 24, &w, &h)); + EXPECT_EQ(w, 256); + EXPECT_EQ(h, 128); +} + +PAGX_TEST(PAGXSVGTest, GetPNGDimensions_TooSmall) { + uint8_t data[10] = {}; + int w = 0, h = 0; + EXPECT_FALSE(pagx::GetPNGDimensions(data, 10, &w, &h)); +} + +PAGX_TEST(PAGXSVGTest, GetPNGDimensions_BadSignature) { + uint8_t data[24] = {}; + data[0] = 0x00; + int w = 0, h = 0; + EXPECT_FALSE(pagx::GetPNGDimensions(data, 24, &w, &h)); +} + +PAGX_TEST(PAGXSVGTest, GetPNGDimensions_ZeroDimension) { + uint8_t header[24] = { + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, + 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x00, // width = 0 + 0x00, 0x00, 0x00, 0x80 // height = 128 + }; + int w = 0, h = 0; + EXPECT_FALSE(pagx::GetPNGDimensions(header, 24, &w, &h)); +} + +PAGX_TEST(PAGXSVGTest, GetPNGDimensionsFromPath_ValidFile) { + auto pngPath = ProjectPath::Absolute("resources/apitest/imageReplacement.png"); + int w = 0, h = 0; + if (std::filesystem::exists(pngPath)) { + EXPECT_TRUE(pagx::GetPNGDimensionsFromPath(pngPath, &w, &h)); + EXPECT_GT(w, 0); + EXPECT_GT(h, 0); + } +} + +PAGX_TEST(PAGXSVGTest, GetPNGDimensionsFromPath_InvalidFile) { + int w = 0, h = 0; + EXPECT_FALSE(pagx::GetPNGDimensionsFromPath("/nonexistent/path.png", &w, &h)); +} + +PAGX_TEST(PAGXSVGTest, GetImagePNGDimensions_WithData) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto image = doc->makeNode(); + uint8_t header[24] = { + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, + 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x64, // width = 100 + 0x00, 0x00, 0x00, 0xC8 // height = 200 + }; + image->data = pagx::Data::MakeWithCopy(header, 24); + int w = 0, h = 0; + EXPECT_TRUE(pagx::GetImagePNGDimensions(image, &w, &h)); + EXPECT_EQ(w, 100); + EXPECT_EQ(h, 200); +} + +PAGX_TEST(PAGXSVGTest, GetImagePNGDimensions_NoDataNoPath) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto image = doc->makeNode(); + int w = 0, h = 0; + EXPECT_FALSE(pagx::GetImagePNGDimensions(image, &w, &h)); +} + +PAGX_TEST(PAGXSVGTest, GetImagePNGDimensions_WithFilePath) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto image = doc->makeNode(); + auto pngPath = ProjectPath::Absolute("resources/apitest/imageReplacement.png"); + if (std::filesystem::exists(pngPath)) { + image->filePath = pngPath; + int w = 0, h = 0; + EXPECT_TRUE(pagx::GetImagePNGDimensions(image, &w, &h)); + EXPECT_GT(w, 0); + EXPECT_GT(h, 0); + } +} + +PAGX_TEST(PAGXSVGTest, IsJPEG_Valid) { + uint8_t data[4] = {0xFF, 0xD8, 0xFF, 0xE0}; + EXPECT_TRUE(pagx::IsJPEG(data, 4)); +} + +PAGX_TEST(PAGXSVGTest, IsJPEG_TooSmall) { + uint8_t data[1] = {0xFF}; + EXPECT_FALSE(pagx::IsJPEG(data, 1)); +} + +PAGX_TEST(PAGXSVGTest, IsJPEG_WrongSignature) { + uint8_t data[2] = {0x89, 0x50}; + EXPECT_FALSE(pagx::IsJPEG(data, 2)); +} + +PAGX_TEST(PAGXSVGTest, GetImageData_WithInlineData) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto image = doc->makeNode(); + uint8_t bytes[4] = {1, 2, 3, 4}; + image->data = pagx::Data::MakeWithCopy(bytes, 4); + auto result = pagx::GetImageData(image); + ASSERT_NE(result, nullptr); + EXPECT_EQ(result->size(), 4u); +} + +PAGX_TEST(PAGXSVGTest, GetImageData_WithFilePath) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto image = doc->makeNode(); + auto pngPath = ProjectPath::Absolute("resources/apitest/imageReplacement.png"); + if (std::filesystem::exists(pngPath)) { + image->filePath = pngPath; + auto result = pagx::GetImageData(image); + EXPECT_NE(result, nullptr); + } +} + +PAGX_TEST(PAGXSVGTest, GetImageData_Empty) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto image = doc->makeNode(); + auto result = pagx::GetImageData(image); + EXPECT_EQ(result, nullptr); +} + +PAGX_TEST(PAGXSVGTest, HasNonASCII_PureASCII) { + EXPECT_FALSE(pagx::HasNonASCII("Hello, World!")); +} + +PAGX_TEST(PAGXSVGTest, HasNonASCII_Empty) { + EXPECT_FALSE(pagx::HasNonASCII("")); +} + +PAGX_TEST(PAGXSVGTest, HasNonASCII_WithNonASCII) { + EXPECT_TRUE(pagx::HasNonASCII("Hello, 世界")); +} + +PAGX_TEST(PAGXSVGTest, HasNonASCII_Latin1) { + EXPECT_TRUE(pagx::HasNonASCII("caf\xC3\xA9")); +} + +PAGX_TEST(PAGXSVGTest, UTF8ToUTF16BEHex_ASCII) { + auto hex = pagx::UTF8ToUTF16BEHex("A"); + EXPECT_EQ(hex, "0041"); +} + +PAGX_TEST(PAGXSVGTest, UTF8ToUTF16BEHex_Empty) { + auto hex = pagx::UTF8ToUTF16BEHex(""); + EXPECT_EQ(hex, ""); +} + +PAGX_TEST(PAGXSVGTest, UTF8ToUTF16BEHex_MultipleASCII) { + auto hex = pagx::UTF8ToUTF16BEHex("Hi"); + EXPECT_EQ(hex, "00480069"); +} + +PAGX_TEST(PAGXSVGTest, UTF8ToUTF16BEHex_TwoByte) { + // "é" = U+00E9, UTF-8: C3 A9 + auto hex = pagx::UTF8ToUTF16BEHex("\xC3\xA9"); + EXPECT_EQ(hex, "00E9"); +} + +PAGX_TEST(PAGXSVGTest, UTF8ToUTF16BEHex_ThreeByte) { + // "中" = U+4E2D, UTF-8: E4 B8 AD + auto hex = pagx::UTF8ToUTF16BEHex("\xE4\xB8\xAD"); + EXPECT_EQ(hex, "4E2D"); +} + +PAGX_TEST(PAGXSVGTest, UTF8ToUTF16BEHex_FourByte) { + // U+1F600 (😀), UTF-8: F0 9F 98 80 → surrogate pair D83D DE00 + auto hex = pagx::UTF8ToUTF16BEHex("\xF0\x9F\x98\x80"); + EXPECT_EQ(hex, "D83DDE00"); +} + +PAGX_TEST(PAGXSVGTest, ComputeGlyphPaths_EmptyRuns) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto result = pagx::ComputeGlyphPaths(*text, 0, 0); + EXPECT_TRUE(result.empty()); +} + +PAGX_TEST(PAGXSVGTest, ComputeGlyphPaths_NullFont) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto run = doc->makeNode(); + run->font = nullptr; + run->glyphs = {1}; + text->glyphRuns.push_back(run); + auto result = pagx::ComputeGlyphPaths(*text, 0, 0); + EXPECT_TRUE(result.empty()); +} + +PAGX_TEST(PAGXSVGTest, ComputeGlyphPaths_EmptyGlyphs) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto font = doc->makeNode(); + auto run = doc->makeNode(); + run->font = font; + text->glyphRuns.push_back(run); + auto result = pagx::ComputeGlyphPaths(*text, 0, 0); + EXPECT_TRUE(result.empty()); +} + +PAGX_TEST(PAGXSVGTest, ComputeGlyphPaths_GlyphIDZeroSkipped) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto font = doc->makeNode(); + auto glyph = doc->makeNode(); + auto pathData = doc->makeNode(); + pathData->moveTo(0, 0); + pathData->lineTo(100, 100); + glyph->path = pathData; + glyph->advance = 500; + font->glyphs.push_back(glyph); + auto run = doc->makeNode(); + run->font = font; + run->fontSize = 20; + run->glyphs = {0}; + text->glyphRuns.push_back(run); + auto result = pagx::ComputeGlyphPaths(*text, 0, 0); + EXPECT_TRUE(result.empty()); +} + +PAGX_TEST(PAGXSVGTest, ComputeGlyphPaths_OutOfBoundsGlyphID) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto font = doc->makeNode(); + font->unitsPerEm = 1000; + auto run = doc->makeNode(); + run->font = font; + run->fontSize = 20; + run->glyphs = {10}; + text->glyphRuns.push_back(run); + auto result = pagx::ComputeGlyphPaths(*text, 0, 0); + EXPECT_TRUE(result.empty()); +} + +PAGX_TEST(PAGXSVGTest, ComputeGlyphPaths_ValidGlyph) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto font = doc->makeNode(); + font->unitsPerEm = 1000; + auto glyph = doc->makeNode(); + auto pathData = doc->makeNode(); + pathData->moveTo(0, 0); + pathData->lineTo(500, 0); + pathData->lineTo(500, 700); + pathData->close(); + glyph->path = pathData; + glyph->advance = 600; + font->glyphs.push_back(glyph); + auto run = doc->makeNode(); + run->font = font; + run->fontSize = 20; + run->glyphs = {1}; + text->glyphRuns.push_back(run); + auto result = pagx::ComputeGlyphPaths(*text, 10, 20); + ASSERT_EQ(result.size(), 1u); + EXPECT_EQ(result[0].pathData, pathData); + float scale = 20.0f / 1000.0f; + EXPECT_FLOAT_EQ(result[0].transform.a, scale); + EXPECT_FLOAT_EQ(result[0].transform.d, scale); + EXPECT_FLOAT_EQ(result[0].transform.tx, 10.0f); + EXPECT_FLOAT_EQ(result[0].transform.ty, 20.0f); +} + +PAGX_TEST(PAGXSVGTest, ComputeGlyphPaths_WithPositions) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto font = doc->makeNode(); + font->unitsPerEm = 1000; + auto glyph = doc->makeNode(); + auto pathData = doc->makeNode(); + pathData->moveTo(0, 0); + pathData->lineTo(100, 100); + glyph->path = pathData; + glyph->advance = 500; + font->glyphs.push_back(glyph); + auto run = doc->makeNode(); + run->font = font; + run->fontSize = 10; + run->x = 5.0f; + run->y = 3.0f; + run->glyphs = {1}; + run->positions = {{7.0f, 9.0f}}; + text->glyphRuns.push_back(run); + auto result = pagx::ComputeGlyphPaths(*text, 100, 200); + ASSERT_EQ(result.size(), 1u); + // posX = textPosX + run->x + positions[0].x = 100 + 5 + 7 = 112 + // posY = textPosY + run->y + positions[0].y = 200 + 3 + 9 = 212 + float scale = 10.0f / 1000.0f; + EXPECT_FLOAT_EQ(result[0].transform.tx, 112.0f); + EXPECT_FLOAT_EQ(result[0].transform.ty, 212.0f); + EXPECT_FLOAT_EQ(result[0].transform.a, scale); +} + +PAGX_TEST(PAGXSVGTest, ComputeGlyphPaths_WithXOffsetsOnly) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto font = doc->makeNode(); + font->unitsPerEm = 1000; + auto glyph = doc->makeNode(); + auto pathData = doc->makeNode(); + pathData->moveTo(0, 0); + pathData->lineTo(100, 100); + glyph->path = pathData; + glyph->advance = 500; + font->glyphs.push_back(glyph); + auto run = doc->makeNode(); + run->font = font; + run->fontSize = 10; + run->x = 5.0f; + run->y = 3.0f; + run->glyphs = {1}; + run->xOffsets = {15.0f}; + text->glyphRuns.push_back(run); + auto result = pagx::ComputeGlyphPaths(*text, 10, 20); + ASSERT_EQ(result.size(), 1u); + // no positions, has xOffsets: posX = textPosX + run->x + xOffsets[0] = 10 + 5 + 15 = 30 + // posY = textPosY + run->y = 20 + 3 = 23 + EXPECT_FLOAT_EQ(result[0].transform.tx, 30.0f); + EXPECT_FLOAT_EQ(result[0].transform.ty, 23.0f); +} + +PAGX_TEST(PAGXSVGTest, ComputeGlyphPaths_WithPositionsAndXOffsets) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto font = doc->makeNode(); + font->unitsPerEm = 1000; + auto glyph = doc->makeNode(); + auto pathData = doc->makeNode(); + pathData->moveTo(0, 0); + pathData->lineTo(100, 100); + glyph->path = pathData; + glyph->advance = 500; + font->glyphs.push_back(glyph); + auto run = doc->makeNode(); + run->font = font; + run->fontSize = 10; + run->x = 2.0f; + run->y = 4.0f; + run->glyphs = {1}; + run->positions = {{10.0f, 20.0f}}; + run->xOffsets = {3.0f}; + text->glyphRuns.push_back(run); + auto result = pagx::ComputeGlyphPaths(*text, 0, 0); + ASSERT_EQ(result.size(), 1u); + // posX = textPosX + run->x + positions[0].x + xOffsets[0] = 0 + 2 + 10 + 3 = 15 + // posY = textPosY + run->y + positions[0].y = 0 + 4 + 20 = 24 + EXPECT_FLOAT_EQ(result[0].transform.tx, 15.0f); + EXPECT_FLOAT_EQ(result[0].transform.ty, 24.0f); +} + +PAGX_TEST(PAGXSVGTest, ComputeGlyphPaths_PerGlyphRotation) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto font = doc->makeNode(); + font->unitsPerEm = 1000; + auto glyph = doc->makeNode(); + auto pathData = doc->makeNode(); + pathData->moveTo(0, 0); + pathData->lineTo(100, 100); + glyph->path = pathData; + glyph->advance = 500; + font->glyphs.push_back(glyph); + auto run = doc->makeNode(); + run->font = font; + run->fontSize = 10; + run->glyphs = {1}; + run->rotations = {45.0f}; + text->glyphRuns.push_back(run); + auto result = pagx::ComputeGlyphPaths(*text, 0, 0); + ASSERT_EQ(result.size(), 1u); + EXPECT_FALSE(result[0].transform.isIdentity()); +} + +PAGX_TEST(PAGXSVGTest, ComputeGlyphPaths_PerGlyphScale) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto font = doc->makeNode(); + font->unitsPerEm = 1000; + auto glyph = doc->makeNode(); + auto pathData = doc->makeNode(); + pathData->moveTo(0, 0); + pathData->lineTo(100, 100); + glyph->path = pathData; + glyph->advance = 500; + font->glyphs.push_back(glyph); + auto run = doc->makeNode(); + run->font = font; + run->fontSize = 10; + run->glyphs = {1}; + run->scales = {{2.0f, 1.5f}}; + text->glyphRuns.push_back(run); + auto result = pagx::ComputeGlyphPaths(*text, 0, 0); + ASSERT_EQ(result.size(), 1u); + EXPECT_NE(result[0].pathData, nullptr); +} + +PAGX_TEST(PAGXSVGTest, ComputeGlyphPaths_PerGlyphSkew) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto font = doc->makeNode(); + font->unitsPerEm = 1000; + auto glyph = doc->makeNode(); + auto pathData = doc->makeNode(); + pathData->moveTo(0, 0); + pathData->lineTo(100, 100); + glyph->path = pathData; + glyph->advance = 500; + font->glyphs.push_back(glyph); + auto run = doc->makeNode(); + run->font = font; + run->fontSize = 10; + run->glyphs = {1}; + run->skews = {30.0f}; + text->glyphRuns.push_back(run); + auto result = pagx::ComputeGlyphPaths(*text, 0, 0); + ASSERT_EQ(result.size(), 1u); +} + +PAGX_TEST(PAGXSVGTest, ComputeGlyphPaths_PerGlyphAllTransforms) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto font = doc->makeNode(); + font->unitsPerEm = 1000; + auto glyph = doc->makeNode(); + auto pathData = doc->makeNode(); + pathData->moveTo(0, 0); + pathData->lineTo(100, 100); + glyph->path = pathData; + glyph->advance = 500; + font->glyphs.push_back(glyph); + auto run = doc->makeNode(); + run->font = font; + run->fontSize = 20; + run->glyphs = {1}; + run->rotations = {15.0f}; + run->scales = {{1.5f, 1.2f}}; + run->skews = {10.0f}; + run->anchors = {{5.0f, 3.0f}}; + text->glyphRuns.push_back(run); + auto result = pagx::ComputeGlyphPaths(*text, 0, 0); + ASSERT_EQ(result.size(), 1u); + EXPECT_EQ(result[0].pathData, pathData); +} + +PAGX_TEST(PAGXSVGTest, ComputeGlyphPaths_MultipleGlyphs) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto font = doc->makeNode(); + font->unitsPerEm = 1000; + auto glyph1 = doc->makeNode(); + auto path1 = doc->makeNode(); + path1->moveTo(0, 0); + path1->lineTo(100, 0); + glyph1->path = path1; + glyph1->advance = 500; + auto glyph2 = doc->makeNode(); + auto path2 = doc->makeNode(); + path2->moveTo(0, 0); + path2->lineTo(200, 0); + glyph2->path = path2; + glyph2->advance = 600; + font->glyphs = {glyph1, glyph2}; + auto run = doc->makeNode(); + run->font = font; + run->fontSize = 10; + run->glyphs = {1, 2}; + text->glyphRuns.push_back(run); + auto result = pagx::ComputeGlyphPaths(*text, 0, 0); + ASSERT_EQ(result.size(), 2u); + EXPECT_EQ(result[0].pathData, path1); + EXPECT_EQ(result[1].pathData, path2); + float scale = 10.0f / 1000.0f; + float secondX = glyph1->advance * scale; + EXPECT_FLOAT_EQ(result[1].transform.tx, secondX); +} + +PAGX_TEST(PAGXSVGTest, ComputeGlyphPaths_NullPath) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto font = doc->makeNode(); + font->unitsPerEm = 1000; + auto glyph = doc->makeNode(); + glyph->path = nullptr; + glyph->advance = 500; + font->glyphs.push_back(glyph); + auto run = doc->makeNode(); + run->font = font; + run->fontSize = 10; + run->glyphs = {1}; + text->glyphRuns.push_back(run); + auto result = pagx::ComputeGlyphPaths(*text, 0, 0); + EXPECT_TRUE(result.empty()); +} + +PAGX_TEST(PAGXSVGTest, ComputeGlyphPaths_EmptyPath) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto text = doc->makeNode(); + auto font = doc->makeNode(); + font->unitsPerEm = 1000; + auto glyph = doc->makeNode(); + auto pathData = doc->makeNode(); + glyph->path = pathData; + glyph->advance = 500; + font->glyphs.push_back(glyph); + auto run = doc->makeNode(); + run->font = font; + run->fontSize = 10; + run->glyphs = {1}; + text->glyphRuns.push_back(run); + auto result = pagx::ComputeGlyphPaths(*text, 0, 0); + EXPECT_TRUE(result.empty()); +} + } // namespace pag