diff --git a/CMakeLists.txt b/CMakeLists.txt index bc27e860ad..913f3335cc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -222,7 +222,7 @@ if (PAG_BUILD_PAGX) file(GLOB_RECURSE HTML_EXPORTER_SOURCES src/pagx/html/*.*) list(APPEND PAG_FILES ${HTML_EXPORTER_SOURCES}) - # woff2 encoder + brotli (for embedded font → WOFF2 conversion in HTML exporter) + # woff2 encoder + brotli (for embedded font → WOFF2 conversion in HTML/SVG exporters) set(WOFF2_DIR third_party/woff2) set(BROTLI_DIR ${WOFF2_DIR}/brotli) set(WOFF2_SOURCES diff --git a/include/pagx/SVGExporter.h b/include/pagx/SVGExporter.h index df0693c954..977fc3f00b 100644 --- a/include/pagx/SVGExporter.h +++ b/include/pagx/SVGExporter.h @@ -93,6 +93,19 @@ struct SVGExportOptions { * default of HTMLExportOptions::rasterScale. */ float rasterScale = 2.0f; + + /** + * Whether to embed vector Font resources as WOFF2 @font-face rules with base64 data URIs and + * render their Text elements via <text> with PUA Unicode characters. When enabled, Text + * nodes whose GlyphRun references an embeddable vector Font become real <text> elements — + * selectable, searchable, and animatable per character — instead of opaque outline <path> + * elements. Bitmap (CBDT) fonts and GlyphRuns that carry per-glyph scales / skews remain on the + * outline path because plain SVG <text> cannot express them. When disabled, every Text + * with GlyphRun data is emitted as <path> (the legacy behaviour). Has no effect when + * `convertTextToPath` is true (the user has explicitly requested outline geometry). The default + * value is true. + */ + bool embedFontsAsWoff2 = true; }; /** diff --git a/src/pagx/html/HTMLExporter.cpp b/src/pagx/html/HTMLExporter.cpp index e17cf2979f..ad3e8e6a6e 100644 --- a/src/pagx/html/HTMLExporter.cpp +++ b/src/pagx/html/HTMLExporter.cpp @@ -25,9 +25,9 @@ #include "pagx/html/HTMLBuilder.h" #include "pagx/html/HTMLStyleExtractor.h" #include "pagx/html/HTMLWriter.h" -#include "pagx/html/Woff2FontGenerator.h" #include "pagx/nodes/Font.h" #include "pagx/utils/StringParser.h" +#include "pagx/utils/Woff2FontGenerator.h" namespace pagx { diff --git a/src/pagx/html/HTMLWriter.h b/src/pagx/html/HTMLWriter.h index e6935ca973..24da0eda06 100644 --- a/src/pagx/html/HTMLWriter.h +++ b/src/pagx/html/HTMLWriter.h @@ -26,7 +26,6 @@ #include "pagx/html/FontSignature.h" #include "pagx/html/HTMLBuilder.h" #include "pagx/html/HTMLPlusDarkerRenderer.h" -#include "pagx/html/Woff2FontGenerator.h" #include "pagx/nodes/ColorSource.h" #include "pagx/nodes/ColorStop.h" #include "pagx/nodes/Composition.h" @@ -47,6 +46,7 @@ #include "pagx/types/Padding.h" #include "pagx/types/Rect.h" #include "pagx/types/SelectorTypes.h" +#include "pagx/utils/Woff2FontGenerator.h" namespace pagx { @@ -85,17 +85,6 @@ Color LerpColor(const Color& a, const Color& b, float t); std::string LayerTransformCSS(const Layer* layer); -/** - * HTML-local wrapper around pagx::BuildGroupMatrix that negates the `group->skew` angle so the - * resulting shear matches tgfx native rendering (VectorGroup::ApplySkew uses - * `DegreesToRadians(-skew)`). The shared pagx::BuildGroupMatrix follows the SVG matrix sign - * convention asserted by main's PAGXSVGTest.SVGExport_GroupSkew, so we cannot fix the sign at - * that layer without breaking the SVG / PPT exporters and their pinned test expectations. Use - * this wrapper everywhere the HTML exporter would have called BuildGroupMatrix on a Group node - * (path bake in flattenGroup, transform emission in writeGroup, etc.). - */ -Matrix BuildGroupMatrixForHTML(const Group* group); - const char* AlignmentToCSS(Alignment alignment); const char* ArrangementToCSS(Arrangement arrangement); std::string PaddingToCSS(const Padding& padding); diff --git a/src/pagx/html/HTMLWriterGroup.cpp b/src/pagx/html/HTMLWriterGroup.cpp index be9858119a..bf07f7ec88 100644 --- a/src/pagx/html/HTMLWriterGroup.cpp +++ b/src/pagx/html/HTMLWriterGroup.cpp @@ -26,6 +26,7 @@ #include "pagx/nodes/Group.h" #include "pagx/nodes/Repeater.h" #include "pagx/nodes/Stroke.h" +#include "pagx/utils/ExporterUtils.h" #include "pagx/utils/StringParser.h" namespace pagx { @@ -38,7 +39,7 @@ void HTMLWriter::writeGroup(HTMLBuilder& out, const Group* group, float alpha, b if (guard.overflowed()) { return; } - Matrix gm = BuildGroupMatrixForHTML(group); + Matrix gm = BuildGroupMatrix(group); if (!parentMatrix.isIdentity()) { gm = parentMatrix * gm; } diff --git a/src/pagx/html/HTMLWriterLayer.cpp b/src/pagx/html/HTMLWriterLayer.cpp index 4d1c8ab9b4..a89cc9f04f 100644 --- a/src/pagx/html/HTMLWriterLayer.cpp +++ b/src/pagx/html/HTMLWriterLayer.cpp @@ -43,6 +43,7 @@ #include "pagx/nodes/TrimPath.h" #include "pagx/svg/SVGPathParser.h" #include "pagx/types/MergePathMode.h" +#include "pagx/utils/ExporterUtils.h" #include "pagx/utils/StringParser.h" namespace pagx { @@ -521,7 +522,7 @@ void HTMLWriter::writeElements(HTMLBuilder& out, const std::vector& el // reads Text::renderPosition relative to the Group — not the enclosing Layer — so a // flattened Group would drop its constraint offset from the span's top/left and the // text would collapse onto the Layer's origin (app_icons Calendar "17" symptom). - bool groupHasTransform = !BuildGroupMatrixForHTML(group).isIdentity(); + bool groupHasTransform = !BuildGroupMatrix(group).isIdentity(); // Only use the DOM wrapper (writeGroup) when the Group has a transform AND contains // Text — the wrapper is needed so Text can resolve its renderPosition in Group space. // Groups with alpha but no transform/text must use the flatten path so their geometry @@ -650,7 +651,7 @@ void HTMLWriter::flattenGroup(HTMLBuilder& out, const Group* group, float alpha, const TextBox* curTextBox, ElementDispatchState& state) { ElementDispatchStateGuard stateGuard(state); std::vector groupGeos; - Matrix gm = BuildGroupMatrixForHTML(group); + Matrix gm = BuildGroupMatrix(group); // When a Group has alpha < 1, its Painters render with that alpha applied. In the // flatten path, carry the group's alpha into every paintGeos/writeTextPath/ // writeTextModifier call so the fill-opacity matches the tgfx compositing result. diff --git a/src/pagx/html/HTMLWriterUtils.cpp b/src/pagx/html/HTMLWriterUtils.cpp index 76c39b5e8b..34dc37ada5 100644 --- a/src/pagx/html/HTMLWriterUtils.cpp +++ b/src/pagx/html/HTMLWriterUtils.cpp @@ -476,52 +476,6 @@ std::string LayerTransformCSS(const Layer* layer) { return MatrixTransformToCSS(m); } -// HTML-local skew sign fix. Mirrors pagx::BuildGroupMatrix line-for-line except for one shear: -// the shear coefficient uses `tan(-group->skew)` so the result agrees with tgfx native rendering -// (VectorGroup::ApplySkew passes `DegreesToRadians(-skew)` into the shear). The shared -// pagx::BuildGroupMatrix uses the +skew sign because main's PAGXSVGTest.SVGExport_GroupSkew -// pinned that convention for SVG output, and the SVG/PPT exporters depend on it. Rather than -// flipping the shared helper (which would break those exporters and the pinned test) we keep -// the HTML exporter's path bake aligned with native by routing every BuildGroupMatrix call -// through this wrapper. Any change to the rest of BuildGroupMatrix must be mirrored here. -Matrix BuildGroupMatrixForHTML(const Group* group) { - auto renderPos = group->renderPosition(); - bool hasAnchor = !FloatNearlyZero(group->anchor.x) || !FloatNearlyZero(group->anchor.y); - bool hasPosition = !FloatNearlyZero(renderPos.x) || !FloatNearlyZero(renderPos.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 = {}; - // Sign deliberately negated relative to pagx::BuildGroupMatrix; see function comment. - 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(renderPos.x, renderPos.y) * m; - } - - return m; -} - //============================================================================== // Text & Font //============================================================================== diff --git a/src/pagx/ppt/PPTExporter.cpp b/src/pagx/ppt/PPTExporter.cpp index fdd9fdf772..4e204691c4 100644 --- a/src/pagx/ppt/PPTExporter.cpp +++ b/src/pagx/ppt/PPTExporter.cpp @@ -366,9 +366,11 @@ void PPTWriter::writePath(XMLBuilder& out, const Path* path, const FillStrokeInf // Dispatch a single accumulated geometry through the appropriate per-shape // writer with the given Painter applied. Each painter that renders a geometry // goes through this function so that multi-fill / multi-stroke produces one -// PowerPoint shape per painter (overlapping in document order). +// PowerPoint shape per painter (overlapping in document order). The supplied +// `alpha` is the alpha of the Painter's enclosing scope, NOT the alpha that +// was in effect when the entry was collected — see processVectorScope. void PPTWriter::emitGeometryWithFs(XMLBuilder& out, const AccumulatedGeometry& entry, - const FillStrokeInfo& fs, + const FillStrokeInfo& fs, float alpha, const std::vector& filters, const std::vector& styles) { FillStrokeInfo localFs = fs; @@ -378,14 +380,14 @@ void PPTWriter::emitGeometryWithFs(XMLBuilder& out, const AccumulatedGeometry& e switch (entry.element->nodeType()) { case NodeType::Rectangle: writeRectangle(out, static_cast(entry.element), localFs, entry.transform, - entry.alpha, filters, styles); + alpha, filters, styles); break; case NodeType::Ellipse: - writeEllipse(out, static_cast(entry.element), localFs, entry.transform, - entry.alpha, filters, styles); + writeEllipse(out, static_cast(entry.element), localFs, entry.transform, alpha, + filters, styles); break; case NodeType::Path: - writePath(out, static_cast(entry.element), localFs, entry.transform, entry.alpha, + writePath(out, static_cast(entry.element), localFs, entry.transform, alpha, filters, styles); break; case NodeType::Text: { @@ -402,9 +404,9 @@ void PPTWriter::emitGeometryWithFs(XMLBuilder& out, const AccumulatedGeometry& e // data is available to walk — without glyphRuns there is no geometry to // emit and we must fall back to native text anyway. if (!text->glyphRuns.empty()) { - writeTextAsPath(out, text, localFs, entry.transform, entry.alpha, filters, styles); + writeTextAsPath(out, text, localFs, entry.transform, alpha, filters, styles); } else { - writeNativeText(out, text, localFs, entry.transform, entry.alpha, filters, styles); + writeNativeText(out, text, localFs, entry.transform, alpha, filters, styles); } break; } @@ -447,8 +449,14 @@ void PPTWriter::processVectorScope(XMLBuilder& out, const std::vector& } else { painterFs.stroke = static_cast(element); } + // The painter's effective alpha is the alpha of THIS scope, not the + // alpha that was in effect when each geometry was collected. A Group + // that contains geometry but whose Painter lives outside the Group + // must NOT multiply its own alpha into the outer painter's output — + // Group is an isolation boundary for painters even though geometry + // propagates upward. for (size_t i = scopeStart; i < accumulator.size(); ++i) { - emitGeometryWithFs(out, accumulator[i], painterFs, filters, styles); + emitGeometryWithFs(out, accumulator[i], painterFs, alpha, filters, styles); } break; } @@ -459,7 +467,6 @@ void PPTWriter::processVectorScope(XMLBuilder& out, const std::vector& AccumulatedGeometry entry; entry.element = element; entry.transform = transform; - entry.alpha = alpha; entry.textBox = localTextBox; accumulator.push_back(entry); break; diff --git a/src/pagx/ppt/PPTWriter.h b/src/pagx/ppt/PPTWriter.h index 7b44ff3b8c..3a2cc1324e 100644 --- a/src/pagx/ppt/PPTWriter.h +++ b/src/pagx/ppt/PPTWriter.h @@ -708,15 +708,18 @@ class PPTWriter { const LayerBuildResult& ensureBuildResult(); // One geometry instance captured during the scope walk in writeElements. - // Transform/alpha are baked at collection time so that later painters can - // emit the geometry without knowing about the surrounding Group/TextBox - // stack. `textBox` carries the in-scope modifier so Text - // geometry still picks up box-level layout when rendered by a downstream - // Fill or Stroke (matches the legacy CollectFillStroke().textBox rule). + // The transform is baked at collection time so that later painters can emit + // the geometry without knowing about the surrounding Group/TextBox stack. + // Alpha is intentionally NOT baked here: a Painter outside the Group must + // render this geometry with the Painter's own scope alpha, not the alpha + // that was in effect when this entry was collected — Group is an isolation + // boundary for painters even though geometry propagates upward. + // `textBox` carries the in-scope modifier so Text geometry still + // picks up box-level layout when rendered by a downstream Fill or Stroke + // (matches the legacy CollectFillStroke().textBox rule). struct AccumulatedGeometry { const Element* element = nullptr; Matrix transform = {}; - float alpha = 1.0f; const TextBox* textBox = nullptr; }; @@ -732,7 +735,8 @@ class PPTWriter { std::vector& accumulator, size_t scopeStart); void emitGeometryWithFs(XMLBuilder& out, const AccumulatedGeometry& entry, - const FillStrokeInfo& fs, const std::vector& filters, + const FillStrokeInfo& fs, float alpha, + const std::vector& filters, const std::vector& styles); void writeRectangle(XMLBuilder& out, const Rectangle* rect, const FillStrokeInfo& fs, diff --git a/src/pagx/svg/SVGExporter.cpp b/src/pagx/svg/SVGExporter.cpp index 690bdc871a..95508ea9a7 100644 --- a/src/pagx/svg/SVGExporter.cpp +++ b/src/pagx/svg/SVGExporter.cpp @@ -42,6 +42,7 @@ #include "pagx/nodes/DropShadowStyle.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/ImagePattern.h" @@ -69,6 +70,7 @@ #include "pagx/utils/StringParser.h" #include "pagx/utils/StrokeGeometryUtils.h" #include "pagx/utils/TextUtils.h" +#include "pagx/utils/Woff2FontGenerator.h" #include "pagx/xml/XMLBuilder.h" #include "renderer/LayerBuilder.h" @@ -83,6 +85,34 @@ using SVGBuilder = XMLBuilder; // Utility types and static helpers //============================================================================== +// Appends a CSS inline-style entry "name:value;" to the given style string. +// Centralizes the colon/semicolon punctuation so call sites stay free of literal +// separators and the property/value pair is the only thing the reader has to look at. +static void AppendStyleEntry(std::string& style, const char* name, const std::string& value) { + style += name; + style += ':'; + style += value; + style += ';'; +} + +// Joins floats formatted via FloatToString with a single-character separator. Used +// for matrix(a,b,c,d,e,f), feColorMatrix values, and shadow color rows where the +// repeated `result += FloatToString(...); result += sep;` pattern obscured intent. +static std::string JoinFloats(const float* values, size_t count, char separator) { + std::string result; + if (count == 0) { + return result; + } + result.reserve(count * 12); + for (size_t i = 0; i < count; i++) { + if (i > 0) { + result += separator; + } + result += FloatToString(values[i]); + } + return result; +} + // 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) { @@ -97,8 +127,8 @@ static std::string ColorToSVGString(const Color& color) { // Emits a CSS color(display-p3 ...) value. Only used when the color source has // Display P3 color space values; sRGB colors use the standard #RRGGBB format. static std::string ColorToDisplayP3String(const Color& color) { - return "color(display-p3 " + FloatToString(color.red) + " " + FloatToString(color.green) + " " + - FloatToString(color.blue) + ")"; + float channels[3] = {color.red, color.green, color.blue}; + return "color(display-p3 " + JoinFloats(channels, 3, ' ') + ")"; } // feGaussianBlur stdDeviation string: one value when blurX == blurY, otherwise two. @@ -126,9 +156,7 @@ static void AppendBlendModeStyle(std::string& styleStr, BlendMode mode) { if (!blendStr) { return; } - styleStr += "mix-blend-mode:"; - styleStr += blendStr; - styleStr += ';'; + AppendStyleEntry(styleStr, "mix-blend-mode", blendStr); } // Appends Display P3 color via CSS color() function when the color source has @@ -146,18 +174,11 @@ static void AppendP3ColorStyle(std::string& styleStr, const char* property, if (solid->color.colorSpace != ColorSpace::DisplayP3) { return; } - styleStr += property; - styleStr += ':'; - styleStr += srgbHex; - styleStr += ';'; - styleStr += property; - styleStr += ':'; - styleStr += ColorToDisplayP3String(solid->color); - styleStr += ';'; - styleStr += property; - styleStr += "-opacity:"; - styleStr += FloatToString(effectiveAlpha); - styleStr += ';'; + AppendStyleEntry(styleStr, property, srgbHex); + AppendStyleEntry(styleStr, property, ColorToDisplayP3String(solid->color)); + std::string opacityProp = property; + opacityProp += "-opacity"; + AppendStyleEntry(styleStr, opacityProp.c_str(), FloatToString(effectiveAlpha)); } static std::string MatrixToSVGTransform(const Matrix& matrix) { @@ -168,22 +189,8 @@ static std::string MatrixToSVGTransform(const Matrix& matrix) { // [a c e] [scaleX skewX transX] // [b d f] = [skewY scaleY transY] // [0 0 1] [0 0 1 ] - std::string result; - result.reserve(80); - result += "matrix("; - result += FloatToString(matrix.a); - result += ','; - result += FloatToString(matrix.b); - result += ','; - result += FloatToString(matrix.c); - result += ','; - result += FloatToString(matrix.d); - result += ','; - result += FloatToString(matrix.tx); - result += ','; - result += FloatToString(matrix.ty); - result += ')'; - return result; + float values[6] = {matrix.a, matrix.b, matrix.c, matrix.d, matrix.tx, matrix.ty}; + return "matrix(" + JoinFloats(values, 6, ',') + ")"; } // Returns true when the painter's color source is a gradient (of any kind). @@ -277,6 +284,12 @@ class SVGWriterContext { return prefix + std::to_string(nextDefId++); } + // Font* → Woff2FontResult mapping. Populated by SVGExporter::ToSVG's pre-processing pass for + // every embedded vector Font resource. emitGeometryWithFs checks this mapping to decide whether + // to render Text via the WOFF2 path or fall back to the SVG outline path. + // The relativeUrl field stores a `data:font/woff2;base64,...` URI so each SVG is self-contained. + std::unordered_map woff2Fonts = {}; + private: int nextDefId = 0; }; @@ -366,6 +379,12 @@ class SVGWriter { float alpha); void writeTextAsPath(SVGBuilder& out, const Text* text, const FillStrokeInfo& fs, const Matrix& m, float alpha); + // WOFF2 path: renders pre-shaped GlyphRun glyphs as a real element referencing the + // generated @font-face font, using PUA Unicode characters mapped 1:1 to glyph IDs. Returns + // false when the run cannot be losslessly expressed as (per-glyph scales / skews, + // bitmap font, or every glyph is GID 0); the caller falls back to writeTextAsPath in that case. + bool writeTextAsFont(SVGBuilder& out, const Text* text, const FillStrokeInfo& fs, const Matrix& m, + float alpha, const Woff2FontResult& fontResult); void writeTextWithLayout(SVGBuilder& out, const Text* text, const FillStrokeInfo& fs, const TextLayoutResult& layoutResult, const Matrix& m, float alpha); void writeTextBoxGroup(SVGBuilder& out, const TextBox* textBox, @@ -448,6 +467,12 @@ class SVGWriter { std::string* p3Style = nullptr, float alphaMultiplier = 1.0f); void applyStrokeAttributes(SVGBuilder& out, const Stroke* stroke, const Rect& shapeBounds = {}, std::string* p3Style = nullptr, float alphaMultiplier = 1.0f); + // Shared paint-color emission for both fill and stroke. Writes the paint attribute + // (e.g. "fill") plus its "-opacity" sibling and accumulates the Display P3 / blend-mode + // CSS fragments into p3Style. Centralizes the alpha math and the default-#000000 rule. + void applyPaintColor(SVGBuilder& out, const char* paintAttr, const ColorSource* color, + float painterAlpha, BlendMode blendMode, const Rect& shapeBounds, + float alphaMultiplier, std::string* p3Style); // Writes fill, stroke, and the optional collected P3 style fragment as SVG attributes. // Every geometry writer ends with this three-call sequence so keeping it together // avoids forgetting a step and makes the painter apply order explicit. @@ -854,6 +879,9 @@ void SVGWriter::writeShadowColorMatrix(const Color& c, const std::string& inResu _defs->openElement("feColorMatrix"); _defs->addAttribute("in", inResult); _defs->addAttribute("type", "matrix"); + // feColorMatrix row form: r g b a t — each row outputs *alpha + t. + // Here we drive only the constant column with the flood color so the source + // alpha is preserved in the output alpha channel. std::string values; values.reserve(80); values += "0 0 0 0 "; @@ -913,15 +941,7 @@ void SVGWriter::writeColorMatrixFilter(const ColorMatrixFilter* cm, int& colorMa _defs->openElement("feColorMatrix"); _defs->addAttribute("in", currentSource); _defs->addAttribute("type", "matrix"); - std::string values; - values.reserve(200); - for (size_t i = 0; i < cm->matrix.size(); i++) { - if (i > 0) { - values += " "; - } - values += FloatToString(cm->matrix[i]); - } - _defs->addAttribute("values", values); + _defs->addAttribute("values", JoinFloats(cm->matrix.data(), cm->matrix.size(), ' ')); _defs->addAttribute("result", resultName); _defs->closeElementSelfClosing(); currentSource = resultName; @@ -1255,28 +1275,36 @@ std::string SVGWriter::writeClipPathDef(const Layer* maskLayer) { // SVGWriter – fill / stroke attribute helpers //============================================================================== +void SVGWriter::applyPaintColor(SVGBuilder& out, const char* paintAttr, const ColorSource* color, + float painterAlpha, BlendMode blendMode, const Rect& shapeBounds, + float alphaMultiplier, std::string* p3Style) { + float alpha = 1.0f; + // Per PAGX spec, Fill/Stroke defaults to opaque black (#000000) when no color is specified. + std::string paintStr = color ? getColorSourceRef(color, &alpha, shapeBounds) : "#000000"; + out.addAttribute(paintAttr, paintStr); + float effectiveAlpha = alpha * painterAlpha * alphaMultiplier; + std::string opacityAttr = paintAttr; + opacityAttr += "-opacity"; + if (effectiveAlpha < 1.0f) { + out.addAttribute(opacityAttr.c_str(), FloatToString(effectiveAlpha)); + } + if (p3Style) { + AppendP3ColorStyle(*p3Style, paintAttr, color, paintStr, effectiveAlpha); + AppendBlendModeStyle(*p3Style, blendMode); + } +} + void SVGWriter::applyFillAttributes(SVGBuilder& out, const Fill* fill, const Rect& shapeBounds, std::string* p3Style, float alphaMultiplier) { if (!fill) { out.addAttribute("fill", "none"); return; } - float alpha = 1.0f; - // Per PAGX spec, Fill defaults to opaque black (#000000) when no color is specified. - std::string fillStr = - fill->color ? getColorSourceRef(fill->color, &alpha, shapeBounds) : "#000000"; - out.addAttribute("fill", fillStr); - float effectiveAlpha = alpha * fill->alpha * alphaMultiplier; - if (effectiveAlpha < 1.0f) { - out.addAttribute("fill-opacity", FloatToString(effectiveAlpha)); - } + applyPaintColor(out, "fill", fill->color, fill->alpha, fill->blendMode, shapeBounds, + alphaMultiplier, p3Style); if (fill->fillRule == FillRule::EvenOdd) { out.addAttribute("fill-rule", "evenodd"); } - if (p3Style) { - AppendP3ColorStyle(*p3Style, "fill", fill->color, fillStr, effectiveAlpha); - AppendBlendModeStyle(*p3Style, fill->blendMode); - } } void SVGWriter::applyStrokeAttributes(SVGBuilder& out, const Stroke* stroke, @@ -1285,15 +1313,8 @@ void SVGWriter::applyStrokeAttributes(SVGBuilder& out, const Stroke* stroke, if (!stroke) { return; } - float alpha = 1.0f; - // Per PAGX spec, Stroke defaults to opaque black (#000000) when no color is specified. - std::string strokeStr = - stroke->color ? getColorSourceRef(stroke->color, &alpha, shapeBounds) : "#000000"; - out.addAttribute("stroke", strokeStr); - float effectiveAlpha = alpha * stroke->alpha * alphaMultiplier; - if (effectiveAlpha < 1.0f) { - out.addAttribute("stroke-opacity", FloatToString(effectiveAlpha)); - } + applyPaintColor(out, "stroke", stroke->color, stroke->alpha, stroke->blendMode, shapeBounds, + alphaMultiplier, p3Style); if (stroke->width != 1.0f) { out.addAttribute("stroke-width", FloatToString(stroke->width)); } @@ -1311,22 +1332,12 @@ void SVGWriter::applyStrokeAttributes(SVGBuilder& out, const Stroke* stroke, out.addAttribute("stroke-miterlimit", FloatToString(stroke->miterLimit)); } if (!stroke->dashes.empty()) { - std::string dashStr; - for (size_t i = 0; i < stroke->dashes.size(); i++) { - if (i > 0) { - dashStr += ","; - } - dashStr += FloatToString(stroke->dashes[i]); - } - out.addAttribute("stroke-dasharray", dashStr); + out.addAttribute("stroke-dasharray", + JoinFloats(stroke->dashes.data(), stroke->dashes.size(), ',')); } if (stroke->dashOffset != 0.0f) { out.addAttribute("stroke-dashoffset", FloatToString(stroke->dashOffset)); } - if (p3Style) { - AppendP3ColorStyle(*p3Style, "stroke", stroke->color, strokeStr, effectiveAlpha); - AppendBlendModeStyle(*p3Style, stroke->blendMode); - } } void SVGWriter::applyP3Style(SVGBuilder& out, const std::string& p3Style) { @@ -1586,6 +1597,123 @@ void SVGWriter::writeTextAsPath(SVGBuilder& out, const Text* text, const FillStr out.closeElement(); } +// ── writeTextAsFont ───────────────────────────────────────────────────────── +// +// Encodes a Unicode codepoint as UTF-8 and appends to the string. WOFF2 cmap maps +// 0xE000 + (glyphID - 1) → glyphID, so the character we emit is the PUA codepoint that the +// embedded font resolves back to the original glyph index. +static void AppendUTF8Codepoint(std::string& out, uint32_t cp) { + if (cp < 0x80) { + out += static_cast(cp); + } else if (cp < 0x800) { + out += static_cast(0xC0 | (cp >> 6)); + out += static_cast(0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + out += static_cast(0xE0 | (cp >> 12)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } else { + out += static_cast(0xF0 | (cp >> 18)); + out += static_cast(0x80 | ((cp >> 12) & 0x3F)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } +} + +bool SVGWriter::writeTextAsFont(SVGBuilder& out, const Text* text, const FillStrokeInfo& fs, + const Matrix& m, float alpha, const Woff2FontResult& fontResult) { + // Plain SVG can express per-character (x, y, rotate) positioning lists, but it cannot + // express per-glyph scale or skew — those would require a separate element per glyph + // (defeating the purpose of using for selectability). When the run carries any such + // transform, signal the caller to fall back to writeTextAsPath. + for (auto* run : text->glyphRuns) { + if (!run->scales.empty() || !run->skews.empty()) { + return false; + } + } + // Gradient fills cannot be confined to glyph silhouettes in the way they are in + // background-clip:text in HTML; SVG renders the gradient over the bounding rect and we'd lose + // visual fidelity. Fall back to writeTextAsPath where each glyph carries its own clipping path. + if (IsGradientSource(fs.fill ? fs.fill->color : nullptr) || + IsGradientSource(fs.stroke ? fs.stroke->color : nullptr)) { + return false; + } + + auto renderPos = text->renderPosition(); + if (fs.textBox) { + renderPos.x += fs.textBox->padding.left; + renderPos.y += fs.textBox->padding.top; + } + + // Walk every visible glyph and collect its absolute x/y/rotation. Skipping GID 0 (the .notdef + // sentinel) matches writeTextAsPath / HTMLWriter::writeEmbeddedShapeGlyphsAsFont so a missing + // glyph never paints a blank box. + std::string puaText; + std::string xList; + std::string yList; + std::string rotateList; + bool hasRotation = false; + float fontSize = text->glyphRuns.empty() ? 12.0f : text->glyphRuns[0]->fontSize; + for (auto* run : text->glyphRuns) { + if (run->fontSize > 0.0f) { + fontSize = run->fontSize; + } + for (size_t i = 0; i < run->glyphs.size(); ++i) { + uint16_t gid = run->glyphs[i]; + if (gid == 0) { + continue; + } + float gx = run->x; + float gy = run->y; + if (i < run->xOffsets.size()) { + gx += run->xOffsets[i]; + } + if (i < run->positions.size()) { + gx += run->positions[i].x; + gy += run->positions[i].y; + } + gx += renderPos.x; + gy += renderPos.y; + if (!puaText.empty()) { + xList += ' '; + yList += ' '; + rotateList += ' '; + } + xList += FloatToString(gx); + yList += FloatToString(gy); + float rotation = (i < run->rotations.size()) ? run->rotations[i] : 0.0f; + rotateList += FloatToString(rotation); + if (!FloatNearlyZero(rotation)) { + hasRotation = true; + } + AppendUTF8Codepoint(puaText, 0xE000u + (gid - 1u)); + } + } + if (puaText.empty()) { + return false; + } + + std::string transform = MatrixToSVGTransform(m); + out.openElement("text"); + if (!transform.empty()) { + out.addAttribute("transform", transform); + } + out.addAttribute("font-family", "'" + fontResult.familyName + "'"); + out.addAttribute("font-size", FloatToString(fontSize)); + out.addAttribute("x", xList); + out.addAttribute("y", yList); + if (hasRotation) { + // SVG rotate="..." applies one degree value per character around that character's (x, y); + // omit when no glyph rotates so simple runs stay terse. + out.addAttribute("rotate", rotateList); + } + // Fill / stroke painting via the shared helper. Solid colors only — the gradient fallback above + // sent the run to writeTextAsPath. shapeBounds is empty because gradients are excluded. + applyPainters(out, fs, {}, alpha); + out.closeElementWithText(puaText); + return true; +} + // ── writeText ─────────────────────────────────────────────────────────────── static void WriteSharedTextAttrs(SVGBuilder& out, const Text* text, TextAnchor anchor) { @@ -2292,6 +2420,27 @@ void SVGWriter::emitGeometryWithFs(SVGBuilder& out, const AccumulatedGeometry& e // data is available to walk — without glyphRuns there is no geometry to // emit and we must fall back to native text anyway. if (!text->glyphRuns.empty()) { + // Prefer the WOFF2 path when the GlyphRun's font is registered as an embedded + // vector font: the result is a real element with PUA Unicode characters that + // browsers / Inkscape / Preview render via the embedded @font-face, preserving text + // selection, search, and per-character animation. Per-glyph scales / skews and bitmap + // fonts cannot be expressed in , so writeTextAsFont returns false in those cases + // and the caller falls back to the SVG outline path below. + const Font* font = nullptr; + for (auto* run : text->glyphRuns) { + if (run->font) { + font = run->font; + break; + } + } + if (font != nullptr) { + auto it = _context->woff2Fonts.find(font); + if (it != _context->woff2Fonts.end()) { + if (writeTextAsFont(out, text, localFs, entry.transform, alpha, it->second)) { + break; + } + } + } writeTextAsPath(out, text, localFs, entry.transform, alpha); } else { writeText(out, text, localFs, entry.transform, alpha); @@ -2639,6 +2788,41 @@ std::string SVGExporter::ToSVG(PAGXDocument& doc, const Options& options, SVGBuilder defs(true, options.indent, 2); SVGWriterContext context; auto layoutContext = std::make_unique(options.fontConfig); + + // Pre-pass: build WOFF2 fonts for embedded vector fonts. Only Font resources whose first glyph + // carries vector outline data (Glyph.path, no Glyph.image) participate — bitmap CBDT fonts + // remain on the per-glyph rendering path because per-glyph scales/skews cannot be + // expressed in a plain SVG . Each generated WOFF2 byte stream is base64-embedded as a + // `data:font/woff2;base64,...` URI so the SVG remains self-contained, avoiding the external + // resource directory the HTML exporter writes alongside the document. The id-keyed mapping + // is consumed by writeText / writeTextAsFont below to decide whether to emit with + // PUA Unicode characters (WOFF2 path) or the SVG outline path. + if (options.embedFontsAsWoff2 && options.convertTextToPath == false) { + for (auto& nodePtr : doc.nodes) { + if (nodePtr->nodeType() != NodeType::Font) { + continue; + } + auto* font = static_cast(nodePtr.get()); + if (font->glyphs.empty() || !font->glyphs[0]) { + continue; + } + // Skip bitmap fonts: SVG + CBDT works in browsers but not in Inkscape / Preview, and + // per-glyph scales/skews still need the path. Sticking to vector fonts keeps the + // WOFF2 path coverage portable and predictable. + if (font->glyphs[0]->image || !font->glyphs[0]->path) { + continue; + } + std::string fontId = "f" + std::to_string(context.woff2Fonts.size()); + auto result = BuildWoff2FromFont(font, fontId); + if (result.woff2Data.empty()) { + continue; + } + result.relativeUrl = "data:font/woff2;base64,"; + result.relativeUrl += Base64Encode(result.woff2Data.data(), result.woff2Data.size()); + context.woff2Fonts[font] = std::move(result); + } + } + SVGWriter writer(&defs, &context, options.indent, options.convertTextToPath, layoutContext.get(), &doc, options.bakeUnsupported, safeRasterScale, options.resolveModifiers, warnings); @@ -2661,10 +2845,28 @@ std::string SVGExporter::ToSVG(PAGXDocument& doc, const Options& options, } std::string defsStr = defs.release(); - if (!defsStr.empty()) { + // Prepend @font-face rules to so embedded WOFF2 fonts are declared before any element + // references them via font-family. Browsers tolerate declaration in . Text nodes whose GlyphRun references a +// participating Font are emitted as a real element with PUA Unicode characters mapped +// 1:1 to glyph IDs (0xE000 + (gid-1)). The path output is reserved for cases the element +// cannot losslessly express: convertTextToPath=true, embedFontsAsWoff2=false, bitmap fonts, runs +// with per-glyph scales/skews, and gradient fills. + +// Builds a minimal vector Font with two square-ish glyph paths plus a Text containing a single +// GlyphRun that references it. Returns the doc so each test can tweak options independently. +static std::shared_ptr MakeVectorGlyphRunDocSVG() { + auto doc = pagx::PAGXDocument::Make(200, 100); + auto* font = doc->makeNode(); + font->id = "vectorFont"; + font->unitsPerEm = 1000; + + auto* glyph1 = doc->makeNode(); + glyph1->path = doc->makeNode(); + *glyph1->path = pagx::PathDataFromSVGString("M 0,0 L 700,0 L 700,-800 L 0,-800 Z"); + glyph1->advance = 700; + font->glyphs.push_back(glyph1); + + auto* glyph2 = doc->makeNode(); + glyph2->path = doc->makeNode(); + *glyph2->path = pagx::PathDataFromSVGString("M 350,0 L 700,-800 L 0,-800 Z"); + glyph2->advance = 700; + font->glyphs.push_back(glyph2); + + auto* layer = doc->makeNode(); + auto* group = doc->makeNode(); + auto* text = doc->makeNode(); + text->fontSize = 50; + + auto* run = doc->makeNode(); + run->font = font; + run->fontSize = 50; + run->glyphs = {1, 2}; + run->y = 60; + run->xOffsets = {10, 60}; + text->glyphRuns.push_back(run); + + group->elements.push_back(text); + group->elements.push_back(MakeSolidFillSVG(doc.get(), 0.1f, 0.1f, 0.1f)); + layer->contents.push_back(group); + doc->layers.push_back(layer); + return doc; +} + +/** + * Default SVGExportOptions enables embedFontsAsWoff2. A vector Font referenced by a GlyphRun + * should become a base64-encoded WOFF2 @font-face declaration, and the Text should emit a + * element pointing at the generated `pagx-font-*` family. No should be emitted + * for the run — the @font-face renders the glyph silhouettes via the embedded font. + */ +PAGX_TEST(PAGXSVGTest, SVGExport_EmbedFontsAsWoff2_Default) { + auto doc = MakeVectorGlyphRunDocSVG(); + + auto svg = pagx::SVGExporter::ToSVG(*doc); + EXPECT_NE(svg.find("@font-face"), std::string::npos); + EXPECT_NE(svg.find("data:font/woff2;base64,"), std::string::npos); + EXPECT_NE(svg.find("pagx-font-"), std::string::npos); + EXPECT_NE(svg.find(" with the glyph outline geometry. + */ +PAGX_TEST(PAGXSVGTest, SVGExport_EmbedFontsAsWoff2_Disabled) { + auto doc = MakeVectorGlyphRunDocSVG(); + + pagx::SVGExporter::Options opts; + opts.embedFontsAsWoff2 = false; + auto svg = pagx::SVGExporter::ToSVG(*doc, opts); + EXPECT_EQ(svg.find("@font-face"), std::string::npos); + EXPECT_EQ(svg.find("data:font/woff2"), std::string::npos); + EXPECT_EQ(svg.find("pagx-font-"), std::string::npos); + EXPECT_NE(svg.find(". + */ +PAGX_TEST(PAGXSVGTest, SVGExport_EmbedFontsAsWoff2_SuppressedByConvertTextToPath) { + auto doc = MakeVectorGlyphRunDocSVG(); + + pagx::SVGExporter::Options opts; + opts.embedFontsAsWoff2 = true; + opts.convertTextToPath = true; + auto svg = pagx::SVGExporter::ToSVG(*doc, opts); + EXPECT_EQ(svg.find("@font-face"), std::string::npos); + EXPECT_EQ(svg.find("data:font/woff2"), std::string::npos); + EXPECT_NE(svg.find(" cannot + * express the per-glyph image transforms a bitmap glyph carries. The Text should fall back to + * the writeTextAsPath / rendering path and no @font-face declaration should appear. + * + * The test avoids relying on a real PNG decoder — Image.data is left empty, which is enough for + * SVGExporter's pre-pass to inspect Glyph.image vs Glyph.path and choose its branch. + */ +PAGX_TEST(PAGXSVGTest, SVGExport_EmbedFontsAsWoff2_BitmapFontFallback) { + auto doc = pagx::PAGXDocument::Make(200, 100); + auto* font = doc->makeNode(); + font->id = "bitmapFont"; + font->unitsPerEm = 100; + + auto* bitmapGlyph = doc->makeNode(); + bitmapGlyph->image = doc->makeNode(); + bitmapGlyph->advance = 60; + font->glyphs.push_back(bitmapGlyph); + + auto* layer = doc->makeNode(); + auto* group = doc->makeNode(); + auto* text = doc->makeNode(); + auto* run = doc->makeNode(); + run->font = font; + run->fontSize = 60; + run->glyphs = {1}; + run->y = 60; + text->glyphRuns.push_back(run); + group->elements.push_back(text); + group->elements.push_back(MakeSolidFillSVG(doc.get(), 0.0f, 0.0f, 0.0f)); + layer->contents.push_back(group); + doc->layers.push_back(layer); + + auto svg = pagx::SVGExporter::ToSVG(*doc); + EXPECT_EQ(svg.find("@font-face"), std::string::npos); + EXPECT_EQ(svg.find("data:font/woff2"), std::string::npos); + SaveFile(svg, "PAGXSVGTest/svg_export_embed_fonts_woff2_bitmap_fallback.svg"); +} + +/** + * GlyphRuns carrying per-glyph scales (or skews) cannot be rendered through plain SVG + * because has no per-character scale/skew attribute. writeTextAsFont returns false and + * the writer falls back to writeTextAsPath. The @font-face declaration is still emitted (a + * different Text in the same document might still use it), but this run renders as . + */ +PAGX_TEST(PAGXSVGTest, SVGExport_EmbedFontsAsWoff2_PerGlyphScaleFallback) { + auto doc = MakeVectorGlyphRunDocSVG(); + // The doc has one GlyphRun; attach scales to force the fallback branch. + for (auto& nodePtr : doc->nodes) { + if (nodePtr->nodeType() == pagx::NodeType::GlyphRun) { + auto* run = static_cast(nodePtr.get()); + run->scales = {{1.2f, 1.2f}, {0.8f, 0.8f}}; + break; + } + } + auto svg = pagx::SVGExporter::ToSVG(*doc); + // @font-face is still produced — the WOFF2 pre-pass runs per Font, not per GlyphRun. + EXPECT_NE(svg.find("@font-face"), std::string::npos); + EXPECT_NE(svg.find(" cannot losslessly clip a + * gradient to glyph silhouettes — only individual glyph elements can carry their own + * userSpaceOnUse gradient transform. The @font-face is still emitted, but the GlyphRun renders + * as . + */ +PAGX_TEST(PAGXSVGTest, SVGExport_EmbedFontsAsWoff2_GradientFillFallback) { + auto doc = MakeVectorGlyphRunDocSVG(); + // Replace the Group's solid Fill with a linear gradient Fill. + for (auto& nodePtr : doc->nodes) { + if (nodePtr->nodeType() != pagx::NodeType::Group) { + continue; + } + auto* group = static_cast(nodePtr.get()); + for (auto& element : group->elements) { + if (element->nodeType() != pagx::NodeType::Fill) { + continue; + } + auto* fill = static_cast(element); + auto* grad = doc->makeNode(); + grad->startPoint = {0, 0}; + grad->endPoint = {200, 0}; + auto* stop1 = doc->makeNode(); + stop1->offset = 0.0f; + stop1->color = {1.0f, 0.0f, 0.0f, 1.0f}; + auto* stop2 = doc->makeNode(); + stop2->offset = 1.0f; + stop2->color = {0.0f, 0.0f, 1.0f, 1.0f}; + grad->colorStops.push_back(stop1); + grad->colorStops.push_back(stop2); + fill->color = grad; + break; + } + break; + } + + auto svg = pagx::SVGExporter::ToSVG(*doc); + EXPECT_NE(svg.find("@font-face"), std::string::npos); + EXPECT_NE(svg.find("makeNode(); + auto* path = doc->makeNode(); + auto* data = doc->makeNode(); + *data = pagx::PathDataFromSVGString("M40 40 L160 40 L160 160 L40 160 Z"); + path->data = data; + path->reversed = true; + + auto* trim = doc->makeNode(); + trim->type = pagx::TrimType::Separate; + trim->start = 0.0f; + trim->end = 0.5f; + + layer->contents.push_back(path); + layer->contents.push_back(trim); + layer->contents.push_back(MakeSolidFillSVG(doc.get(), 0.3f, 0.4f, 0.5f)); + doc->layers.push_back(layer); + auto svg = pagx::SVGExporter::ToSVG(*doc); + EXPECT_NE(svg.find("makeNode(); + auto* path = doc->makeNode(); + path->data = nullptr; + + auto* trim = doc->makeNode(); + trim->type = pagx::TrimType::Separate; + trim->start = 0.1f; + trim->end = 0.6f; + + layer->contents.push_back(path); + layer->contents.push_back(trim); + layer->contents.push_back(MakeSolidFillSVG(doc.get(), 0.4f, 0.7f, 0.2f)); + doc->layers.push_back(layer); + auto svg = pagx::SVGExporter::ToSVG(*doc); + EXPECT_NE(svg.find(" end triggers the reversed branch in applyContinuousTrim. + auto doc = pagx::PAGXDocument::Make(400, 200); + auto* layer = doc->makeNode(); + auto* rect1 = doc->makeNode(); + rect1->position = {80, 100}; + rect1->size = {60, 60}; + auto* rect2 = doc->makeNode(); + rect2->position = {220, 100}; + rect2->size = {60, 60}; + + auto* trim = doc->makeNode(); + trim->type = pagx::TrimType::Continuous; + trim->start = 0.8f; + trim->end = 0.2f; + + auto* stroke = doc->makeNode(); + auto* color = doc->makeNode(); + color->color = {0.5f, 0.5f, 0.0f, 1}; + stroke->color = color; + stroke->width = 3; + + layer->contents.push_back(rect1); + layer->contents.push_back(rect2); + layer->contents.push_back(trim); + layer->contents.push_back(stroke); + doc->layers.push_back(layer); + + auto svg = pagx::SVGExporter::ToSVG(*doc); + EXPECT_NE(svg.find("makeNode(); + auto* rect = doc->makeNode(); + rect->position = {200, 100}; + rect->size = {80, 80}; + + auto* trim = doc->makeNode(); + trim->type = pagx::TrimType::Continuous; + trim->start = 0.0f; + trim->end = 1.0f; + + auto* stroke = doc->makeNode(); + auto* color = doc->makeNode(); + color->color = {0.0f, 0.0f, 0.0f, 1}; + stroke->color = color; + stroke->width = 2; + + layer->contents.push_back(rect); + layer->contents.push_back(trim); + layer->contents.push_back(stroke); + doc->layers.push_back(layer); + auto svg = pagx::SVGExporter::ToSVG(*doc); + EXPECT_NE(svg.find(" 1 forces the wrap-around branch (trimEnd > totalLength). + auto doc = pagx::PAGXDocument::Make(400, 200); + auto* layer = doc->makeNode(); + auto* r1 = doc->makeNode(); + r1->position = {80, 100}; + r1->size = {60, 60}; + auto* r2 = doc->makeNode(); + r2->position = {220, 100}; + r2->size = {60, 60}; + + auto* trim = doc->makeNode(); + trim->type = pagx::TrimType::Continuous; + trim->start = 0.7f; + trim->end = 1.2f; + + auto* stroke = doc->makeNode(); + auto* color = doc->makeNode(); + color->color = {0.0f, 0.5f, 0.5f, 1}; + stroke->color = color; + stroke->width = 2; + + layer->contents.push_back(r1); + layer->contents.push_back(r2); + layer->contents.push_back(trim); + layer->contents.push_back(stroke); + doc->layers.push_back(layer); + auto svg = pagx::SVGExporter::ToSVG(*doc); + EXPECT_NE(svg.find("makeNode(); + auto* rect = doc->makeNode(); + rect->position = {100, 100}; + rect->size = {2, 2}; + auto* rep = doc->makeNode(); + rep->copies = 1000000.0f; + rep->position = {0, 0}; + rep->scale = {1, 1}; + layer->contents.push_back(rect); + layer->contents.push_back(MakeSolidFillSVG(doc.get(), 0.1f, 0.1f, 0.1f)); + layer->contents.push_back(rep); + doc->layers.push_back(layer); + + auto svg = pagx::SVGExporter::ToSVG(*doc); + EXPECT_NE(svg.find("makeNode(); + auto* rect = doc->makeNode(); + rect->position = {80, 100}; + rect->size = {30, 30}; + auto* rep = doc->makeNode(); + rep->copies = 3; + rep->position = {60, 0}; + rep->scale = {-1.0f, 1.0f}; + rep->offset = 0.5f; + + layer->contents.push_back(rect); + layer->contents.push_back(MakeSolidFillSVG(doc.get(), 0.5f, 0.2f, 0.5f)); + layer->contents.push_back(rep); + doc->layers.push_back(layer); + auto svg = pagx::SVGExporter::ToSVG(*doc); + EXPECT_NE(svg.find("makeNode(); + auto* outer = doc->makeNode(); + outer->position = {150, 150}; + outer->size = {200, 200}; + auto* inner = doc->makeNode(); + inner->position = {150, 150}; + inner->size = {80, 80}; + auto* mp = doc->makeNode(); + mp->mode = pagx::MergePathMode::Difference; + layer->contents.push_back(outer); + layer->contents.push_back(inner); + layer->contents.push_back(mp); + layer->contents.push_back(MakeSolidFillSVG(doc.get(), 0.2f, 0.4f, 0.8f)); + doc->layers.push_back(layer); + auto svg = pagx::SVGExporter::ToSVG(*doc); + EXPECT_NE(svg.find("evenodd"), std::string::npos); +} + +PAGX_TEST(PAGXSVGTest, SVGExport_MergePathAppendDefaultMode) { + // Default-constructed MergePath uses Append mode, mapping to PathOp::Union. + auto doc = pagx::PAGXDocument::Make(300, 200); + auto* layer = doc->makeNode(); + auto* r1 = doc->makeNode(); + r1->position = {120, 100}; + r1->size = {80, 80}; + auto* r2 = doc->makeNode(); + r2->position = {210, 100}; + r2->size = {80, 80}; + auto* mp = doc->makeNode(); + mp->mode = pagx::MergePathMode::Append; + layer->contents.push_back(r1); + layer->contents.push_back(r2); + layer->contents.push_back(mp); + layer->contents.push_back(MakeSolidFillSVG(doc.get(), 0.3f, 0.6f, 0.4f)); + doc->layers.push_back(layer); + auto svg = pagx::SVGExporter::ToSVG(*doc); + EXPECT_NE(svg.find("makeNode(); + auto* poly = doc->makeNode(); + poly->type = pagx::PolystarType::Polygon; + poly->position = {150, 150}; + poly->pointCount = 5; + poly->outerRadius = 70; + poly->reversed = true; + layer->contents.push_back(poly); + layer->contents.push_back(MakeSolidFillSVG(doc.get(), 0.6f, 0.2f, 0.4f)); + doc->layers.push_back(layer); + auto svg = pagx::SVGExporter::ToSVG(*doc); + EXPECT_NE(svg.find("makeNode(); + auto* star = doc->makeNode(); + star->type = pagx::PolystarType::Star; + star->position = {150, 150}; + star->pointCount = 4.5f; + star->outerRadius = 80; + star->innerRadius = 40; + star->reversed = true; + layer->contents.push_back(star); + layer->contents.push_back(MakeSolidFillSVG(doc.get(), 0.3f, 0.7f, 0.6f)); + doc->layers.push_back(layer); + auto svg = pagx::SVGExporter::ToSVG(*doc); + EXPECT_NE(svg.find("makeNode(); + auto* path = doc->makeNode(); + auto* data = doc->makeNode(); + *data = pagx::PathDataFromSVGString("M40 40 L160 40 L160 160 L40 160 Z"); + path->data = data; + auto* rc = doc->makeNode(); + rc->radius = 12.0f; + layer->contents.push_back(path); + layer->contents.push_back(rc); + layer->contents.push_back(MakeSolidFillSVG(doc.get(), 0.8f, 0.4f, 0.2f)); + doc->layers.push_back(layer); + auto svg = pagx::SVGExporter::ToSVG(*doc); + EXPECT_NE(svg.find("makeNode(); + auto* ellipse = doc->makeNode(); + ellipse->position = {100, 100}; + ellipse->size = {120, 80}; + auto* trim = doc->makeNode(); + trim->type = pagx::TrimType::Separate; + trim->start = 0.0f; + trim->end = 0.5f; + + auto* stroke = doc->makeNode(); + auto* color = doc->makeNode(); + color->color = {0.6f, 0.0f, 0.6f, 1}; + stroke->color = color; + stroke->width = 3; + + layer->contents.push_back(ellipse); + layer->contents.push_back(trim); + layer->contents.push_back(stroke); + doc->layers.push_back(layer); + auto svg = pagx::SVGExporter::ToSVG(*doc); + EXPECT_NE(svg.find("makeNode(); + auto solid = doc->makeNode(); + stroke->color = solid; + std::vector elements = {stroke}; + auto flags = pagx::ProbeElementsFeaturesForSVG(elements); + EXPECT_FALSE(flags.needsRasterization()); +} + +PAGX_TEST(PAGXUtilsTest, SVGFeatureProbe_GroupConicGradient) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto group = doc->makeNode(); + auto fill = doc->makeNode(); + auto grad = doc->makeNode(); + fill->color = grad; + group->elements.push_back(fill); + std::vector elements = {group}; + auto flags = pagx::ProbeElementsFeaturesForSVG(elements); + EXPECT_TRUE(flags.hasConicGradient); +} + +PAGX_TEST(PAGXUtilsTest, SVGFeatureProbe_TextBoxWithDiamondGradient) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto box = doc->makeNode(); + auto stroke = doc->makeNode(); + auto grad = doc->makeNode(); + stroke->color = grad; + box->elements.push_back(stroke); + std::vector elements = {box}; + auto flags = pagx::ProbeElementsFeaturesForSVG(elements); + EXPECT_TRUE(flags.hasDiamondGradient); +} + +PAGX_TEST(PAGXUtilsTest, SVGFeatureProbe_VisibleLayerWithoutFeatures) { + auto doc = pagx::PAGXDocument::Make(100, 100); + auto layer = doc->makeNode(); + auto fill = doc->makeNode(); + auto solid = doc->makeNode(); + fill->color = solid; + layer->contents.push_back(fill); + auto flags = pagx::ProbeLayerFeaturesForSVG(layer); + EXPECT_FALSE(flags.needsRasterization()); +} + +// --------------------------------------------------------------------------- +// SVGPathParser — PathDataFromSVGString +// --------------------------------------------------------------------------- + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_EmptyString) { + auto path = pagx::PathDataFromSVGString(""); + EXPECT_TRUE(path.isEmpty()); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_MoveLineClose) { + auto path = pagx::PathDataFromSVGString("M10 20 L30 40 Z"); + ASSERT_EQ(path.verbs().size(), 3u); + EXPECT_EQ(path.verbs()[0], pagx::PathVerb::Move); + EXPECT_EQ(path.verbs()[1], pagx::PathVerb::Line); + EXPECT_EQ(path.verbs()[2], pagx::PathVerb::Close); + EXPECT_FLOAT_EQ(path.points()[0].x, 10.0f); + EXPECT_FLOAT_EQ(path.points()[0].y, 20.0f); + EXPECT_FLOAT_EQ(path.points()[1].x, 30.0f); + EXPECT_FLOAT_EQ(path.points()[1].y, 40.0f); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_RelativeMoveAndLine) { + // Lower-case commands are relative to the current point. + auto path = pagx::PathDataFromSVGString("m5 5 l10 10"); + ASSERT_EQ(path.verbs().size(), 2u); + EXPECT_EQ(path.verbs()[0], pagx::PathVerb::Move); + EXPECT_EQ(path.verbs()[1], pagx::PathVerb::Line); + EXPECT_FLOAT_EQ(path.points()[0].x, 5.0f); + EXPECT_FLOAT_EQ(path.points()[0].y, 5.0f); + EXPECT_FLOAT_EQ(path.points()[1].x, 15.0f); + EXPECT_FLOAT_EQ(path.points()[1].y, 15.0f); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_HorizontalAndVerticalLines) { + // H and V commands and their relative variants. + auto path = pagx::PathDataFromSVGString("M0 0 H50 V20 h-10 v-5"); + ASSERT_EQ(path.verbs().size(), 5u); + EXPECT_EQ(path.verbs()[1], pagx::PathVerb::Line); + EXPECT_FLOAT_EQ(path.points()[1].x, 50.0f); + EXPECT_FLOAT_EQ(path.points()[1].y, 0.0f); + EXPECT_FLOAT_EQ(path.points()[2].x, 50.0f); + EXPECT_FLOAT_EQ(path.points()[2].y, 20.0f); + EXPECT_FLOAT_EQ(path.points()[3].x, 40.0f); + EXPECT_FLOAT_EQ(path.points()[3].y, 20.0f); + EXPECT_FLOAT_EQ(path.points()[4].x, 40.0f); + EXPECT_FLOAT_EQ(path.points()[4].y, 15.0f); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_QuadAndCubicCurves) { + auto path = pagx::PathDataFromSVGString("M0 0 Q10 20 30 40 C50 60 70 80 90 100"); + ASSERT_EQ(path.verbs().size(), 3u); + EXPECT_EQ(path.verbs()[1], pagx::PathVerb::Quad); + EXPECT_EQ(path.verbs()[2], pagx::PathVerb::Cubic); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_SmoothCubic) { + // S after C uses the reflected control point. The reflected x/y of the + // previous C ends at (40,40) reflected from control (10,30) -> (70,50). + auto path = pagx::PathDataFromSVGString("M0 0 C10 10 20 30 40 40 S60 60 80 80"); + ASSERT_EQ(path.verbs().size(), 3u); + EXPECT_EQ(path.verbs()[2], pagx::PathVerb::Cubic); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_SmoothQuad) { + auto path = pagx::PathDataFromSVGString("M0 0 Q10 20 30 40 T60 80"); + ASSERT_EQ(path.verbs().size(), 3u); + EXPECT_EQ(path.verbs()[2], pagx::PathVerb::Quad); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_SmoothCubicWithoutPriorCubic) { + // S / T without a prior C / Q reuse the current point as control1. + auto path = pagx::PathDataFromSVGString("M0 0 S10 10 20 20"); + ASSERT_EQ(path.verbs().size(), 2u); + EXPECT_EQ(path.verbs()[1], pagx::PathVerb::Cubic); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_SmoothQuadWithoutPriorQuad) { + auto path = pagx::PathDataFromSVGString("M0 0 T10 10"); + ASSERT_EQ(path.verbs().size(), 2u); + EXPECT_EQ(path.verbs()[1], pagx::PathVerb::Quad); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_ImplicitContinuationAfterMove) { + // Pairs after M continue as implicit L (uppercase) / l (lowercase). + auto path = pagx::PathDataFromSVGString("M0 0 10 10 20 20"); + ASSERT_EQ(path.verbs().size(), 3u); + EXPECT_EQ(path.verbs()[1], pagx::PathVerb::Line); + EXPECT_EQ(path.verbs()[2], pagx::PathVerb::Line); + EXPECT_FLOAT_EQ(path.points()[1].x, 10.0f); + EXPECT_FLOAT_EQ(path.points()[1].y, 10.0f); + EXPECT_FLOAT_EQ(path.points()[2].x, 20.0f); + EXPECT_FLOAT_EQ(path.points()[2].y, 20.0f); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_ArcQuarterCircle) { + // Arc command should generate cubic segments. + auto path = pagx::PathDataFromSVGString("M0 0 A10 10 0 0 1 10 10"); + EXPECT_GT(path.verbs().size(), 1u); + EXPECT_EQ(path.verbs()[0], pagx::PathVerb::Move); + bool sawCubic = false; + for (auto v : path.verbs()) { + if (v == pagx::PathVerb::Cubic) { + sawCubic = true; + break; + } + } + EXPECT_TRUE(sawCubic); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_ArcZeroRadiusFallsBackToLine) { + // rx == 0 falls back to a straight line. + auto path = pagx::PathDataFromSVGString("M0 0 A0 0 0 0 0 50 0"); + ASSERT_EQ(path.verbs().size(), 2u); + EXPECT_EQ(path.verbs()[1], pagx::PathVerb::Line); + EXPECT_FLOAT_EQ(path.points()[1].x, 50.0f); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_ArcLargeArcFullCircle) { + // Endpoints coincide → near-full sweep; large-arc forces a full sweep. + auto path = pagx::PathDataFromSVGString("M50 0 A50 50 0 1 1 49.99 0"); + EXPECT_GT(path.verbs().size(), 1u); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_NumberWithExponentAndSign) { + auto path = pagx::PathDataFromSVGString("M+1.5e1 -2.5e1"); + ASSERT_EQ(path.verbs().size(), 1u); + EXPECT_FLOAT_EQ(path.points()[0].x, 15.0f); + EXPECT_FLOAT_EQ(path.points()[0].y, -25.0f); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_LeadingDot) { + // ".5" should parse as 0.5. + auto path = pagx::PathDataFromSVGString("M.5 .25"); + ASSERT_EQ(path.verbs().size(), 1u); + EXPECT_FLOAT_EQ(path.points()[0].x, 0.5f); + EXPECT_FLOAT_EQ(path.points()[0].y, 0.25f); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_CommasAsSeparators) { + auto path = pagx::PathDataFromSVGString("M10,20,30,40"); + ASSERT_EQ(path.verbs().size(), 2u); + EXPECT_EQ(path.verbs()[1], pagx::PathVerb::Line); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_InvalidNumberRecovers) { + // Garbage between commands triggers the recovery path that skips to the + // next letter without erroring. + auto path = pagx::PathDataFromSVGString("M0 0 garbage L10 10"); + ASSERT_GE(path.verbs().size(), 2u); + EXPECT_EQ(path.verbs()[0], pagx::PathVerb::Move); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_UnknownCommandSkipped) { + // Unknown letter triggers the default branch and the parser advances past it. + auto path = pagx::PathDataFromSVGString("X M5 5"); + ASSERT_EQ(path.verbs().size(), 1u); + EXPECT_FLOAT_EQ(path.points()[0].x, 5.0f); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_ZAfterLineKeepsCurrent) { + // After Z, the current point should reset to the start of the contour. + auto path = pagx::PathDataFromSVGString("M10 10 L20 20 Z L30 30"); + ASSERT_EQ(path.verbs().size(), 4u); + EXPECT_EQ(path.verbs()[2], pagx::PathVerb::Close); + EXPECT_EQ(path.verbs()[3], pagx::PathVerb::Line); +} + +// --------------------------------------------------------------------------- +// SVGPathParser — PathDataToSVGString +// --------------------------------------------------------------------------- + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_ToSVGStringEmpty) { + pagx::PathData empty; + EXPECT_EQ(pagx::PathDataToSVGString(empty), ""); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_ToSVGStringAllVerbs) { + // Round-trip through the parser to construct a PathData with every verb, + // then ensure each command letter appears in the serialized output. + auto path = pagx::PathDataFromSVGString("M0 0 L10 0 Q15 5 20 0 C25 -5 30 -5 35 0 Z"); + auto out = pagx::PathDataToSVGString(path); + EXPECT_NE(out.find("M"), std::string::npos); + EXPECT_NE(out.find("L"), std::string::npos); + EXPECT_NE(out.find("Q"), std::string::npos); + EXPECT_NE(out.find("C"), std::string::npos); + EXPECT_NE(out.find("Z"), std::string::npos); +} + +PAGX_TEST(PAGXUtilsTest, SVGPathParser_ToSVGStringRoundTripPreservesGeometry) { + auto original = pagx::PathDataFromSVGString("M5 10 L20 30 L40 60 Z"); + auto serialized = pagx::PathDataToSVGString(original); + auto restored = pagx::PathDataFromSVGString(serialized); + ASSERT_EQ(original.verbs().size(), restored.verbs().size()); + ASSERT_EQ(original.points().size(), restored.points().size()); + for (size_t i = 0; i < original.points().size(); ++i) { + EXPECT_FLOAT_EQ(original.points()[i].x, restored.points()[i].x); + EXPECT_FLOAT_EQ(original.points()[i].y, restored.points()[i].y); + } +} + } // namespace pag