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/svg/SVGExporter.cpp b/src/pagx/svg/SVGExporter.cpp index 690bdc871a..71f21124ac 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" @@ -277,6 +279,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 +374,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, @@ -1586,6 +1600,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 +2423,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 +2791,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 +2848,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