diff --git a/.codebuddy/skills/pagx/references/attributes.md b/.codebuddy/skills/pagx/references/attributes.md index f2396fbeae..1e28650ca4 100644 --- a/.codebuddy/skills/pagx/references/attributes.md +++ b/.codebuddy/skills/pagx/references/attributes.md @@ -406,6 +406,7 @@ Embedded font resource containing subsetted glyph data (vector outlines or bitma | Attribute | Type | Default | Description | |-----------|------|---------|-------------| +| `file` | string | - | External font file path (TTF/OTF). Font nodes with `file` serve as font source declarations. `pagx embed` auto-discovers them, loads the referenced font files, and registers fonts for text shaping. After embed, `file` is preserved for source traceability. | | `unitsPerEm` | int | 1000 | Font design space units; rendering scale = fontSize / unitsPerEm | ### Glyph diff --git a/.codebuddy/skills/pagx/references/cli.md b/.codebuddy/skills/pagx/references/cli.md index b0859eb9c8..523c503ff3 100644 --- a/.codebuddy/skills/pagx/references/cli.md +++ b/.codebuddy/skills/pagx/references/cli.md @@ -246,50 +246,62 @@ element, an error is reported. ## pagx font -Font operations with two subcommands: `info` (query metrics) and `embed` (embed into PAGX). - -### pagx font info - -Query font identity and metrics from a font file or system font. +Query font identity and metrics from a font file or system font, or enumerate installed +system font families. ```bash -pagx font info --file ./CustomFont.ttf -pagx font info --file ./CustomFont.ttf --size 24 -pagx font info --name "PingFang SC,Bold" -pagx font info --name "Arial" --size 24 --json +pagx font --file ./CustomFont.ttf +pagx font --file ./CustomFont.ttf --size 24 +pagx font --name "PingFang SC,Bold" +pagx font --name "Arial" --size 24 --json +pagx font --list +pagx font --list --json ``` | Option | Description | |--------|-------------| -| `--file ` | Font file path | -| `--name ` | System font by name (e.g., `"Arial"` or `"Arial,Bold"`) | +| `--file ` | Query a font file | +| `--name ` | Query a system font (e.g., `"Arial"` or `"Arial,Bold"`) | | `--size ` | Font size in points (default: 12, the PAGX spec default) | -| `--json` | JSON output | +| `--json` | Output in JSON format | +| `--list` | List every installed system font family | -Either `--file` or `--name` is required (mutually exclusive). +Exactly one of `--file`, `--name`, or `--list` is required. `--list` cannot be combined +with `--file` or `--name`; `--file` and `--name` are mutually exclusive. Returns typeface info (fontFamily, fontStyle, glyphsCount, unitsPerEm, hasColor, hasOutlines) and all FontMetrics fields at the specified size (top, ascent, descent, bottom, leading, xMin, xMax, xHeight, capHeight, underlineThickness, underlinePosition). -### pagx font embed +The retired `pagx font info` and `pagx font embed` subcommands now error out with a redirect +message; use `pagx font ...` for font queries and `pagx embed` for font embedding. -Embed fonts into a PAGX file by performing text layout and glyph extraction. +--- + +## pagx embed + +Embed font glyphs and images into a PAGX file for self-contained output. Font embedding extracts glyph data from laid-out text; image embedding inlines external image files as base64. Font nodes with a `file` attribute are automatically discovered and registered for text shaping — no `--font-file` flag needed. ```bash -pagx font embed input.pagx -pagx font embed -o out.pagx input.pagx -pagx font embed --file a.ttf --file b.ttf input.pagx -pagx font embed --file a.ttf --fallback "PingFang SC" --fallback b.otf input.pagx +pagx embed input.pagx # embed fonts + images (overwrite) +pagx embed -o out.pagx input.pagx # embed fonts + images to new file +pagx embed --skip-fonts input.pagx # embed images only +pagx embed --skip-images input.pagx # embed fonts only ``` | Option | Description | |--------|-------------| | `-o, --output ` | Output file path (default: overwrite input) | -| `--file ` | Register a font file (can be specified multiple times) | | `--fallback ` | Fallback font file or system font name (can be specified multiple times) | +| `--skip-fonts` | Skip font embedding | +| `--skip-images` | Skip image embedding | +| `-h, --help` | Show this help message | + +Fonts are resolved in the following order: +1. Font nodes with `file` attribute are loaded and registered by their internal family name +2. `--fallback` fonts are tried when a character is not found in the primary font -`--file` and `--fallback` work the same as in `pagx render`. +Image embedding inlines external file references (Image nodes with `filePath`) as base64 data. See the Font `file` attribute in `attributes.md` for details on external font references. --- diff --git a/.codebuddy/skills/pagx/references/guide.md b/.codebuddy/skills/pagx/references/guide.md index 9225aab40e..1fd03186da 100644 --- a/.codebuddy/skills/pagx/references/guide.md +++ b/.codebuddy/skills/pagx/references/guide.md @@ -425,7 +425,7 @@ over `position`. - **Text**: `text`, `fontFamily`, `fontStyle`, `fontSize`, `letterSpacing`. Wrap in TextBox for paragraph features. `fauxBold`/`fauxItalic` for algorithmic styles. ` ` for line breaks. Use `` for XML special characters (e.g., ``). -- **GlyphRun**: Pre-laid-out glyph data with embedded font. Generated by `pagx font embed`, +- **GlyphRun**: Pre-laid-out glyph data with embedded font. Generated by `pagx embed`, not written by hand. ## Painters diff --git a/cli/npm/README.md b/cli/npm/README.md index 00c4dbb764..fb4cf3ac41 100644 --- a/cli/npm/README.md +++ b/cli/npm/README.md @@ -22,8 +22,8 @@ npm install -g @libpag/pagx | `pagx optimize` | Validate, optimize, and format in one step | | `pagx format` | Format a PAGX file with consistent indentation and attribute ordering | | `pagx bounds` | Query the precise rendered bounds of layers | -| `pagx font info` | Query font identity and metrics from a file or system font | -| `pagx font embed` | Embed fonts into a PAGX file with glyph extraction | +| `pagx font` | Query a font file, a system font by name, or list system font families | +| `pagx embed` | Embed font glyphs and images into a PAGX file for self-contained output | ## Usage Examples @@ -65,13 +65,22 @@ pagx bounds --xpath "//Layer[@id='btn']" input.pagx pagx bounds --xpath "//Layer[@id='icon']" --relative "//Layer[@id='card']" --json input.pagx # Query system font metrics at 24pt -pagx font info --name "Arial" --size 24 +pagx font --name "Arial" --size 24 # Query font metrics from a file -pagx font info --file CustomFont.ttf --json +pagx font --file CustomFont.ttf --json -# Embed fonts with a custom fallback -pagx font embed --file BrandFont.ttf --fallback "Arial" input.pagx +# List all installed system font families +pagx font --list + +# Embed fonts and images into a PAGX file +pagx embed input.pagx + +# Embed with fallback fonts +pagx embed --fallback "Arial" input.pagx + +# Embed images only (skip font embedding) +pagx embed --skip-fonts input.pagx ``` ## Command Reference @@ -147,26 +156,29 @@ coordinates by default. `--id` and `--xpath` are mutually exclusive. Without either, outputs bounds for all layers. -### `pagx font info [options]` +### `pagx font [options]` -Query font identity and metrics. Requires either `--file` or `--name` (mutually exclusive). +Query a font file, a system font by name, or list system font families. Exactly one of `--file`, +`--name`, or `--list` must be specified. | Option | Description | |--------|-------------| -| `--file ` | Font file path | -| `--name ` | System font name (e.g., `"Arial"` or `"Arial,Bold"`) | +| `--file ` | Query a font file | +| `--name ` | Query a system font (e.g., `"Arial"` or `"Arial,Bold"`) | | `--size ` | Font size in points (default: `12`) | | `--json` | Output in JSON format | +| `--list` | List every installed system font family | -### `pagx font embed [options] ` +### `pagx embed [options] ` -Embed fonts into a PAGX file by performing text layout and glyph extraction. +Embed font glyphs and images into a PAGX file for self-contained output. | Option | Description | |--------|-------------| | `-o, --output ` | Output file path (default: overwrite input) | -| `--file ` | Register a font file (repeatable) | | `--fallback ` | Add a fallback font file or system font name (repeatable) | +| `--skip-fonts` | Skip font embedding | +| `--skip-images` | Skip image embedding | ## Supported Platforms diff --git a/include/pagx/PAGXDocument.h b/include/pagx/PAGXDocument.h index 968e795fb8..32f43fbdb0 100644 --- a/include/pagx/PAGXDocument.h +++ b/include/pagx/PAGXDocument.h @@ -20,6 +20,8 @@ #include #include +#include +#include #include #include "pagx/FontConfig.h" #include "pagx/nodes/Layer.h" @@ -113,7 +115,7 @@ class PAGXDocument : public Node { /** * Returns a list of external file paths referenced by Image nodes that have no embedded data. - * Data URIs (paths starting with "data:") are excluded. + * URL-form paths (http://, https://, and file://) are excluded. */ std::vector getExternalFilePaths() const; @@ -127,11 +129,20 @@ class PAGXDocument : public Node { */ bool loadFileData(const std::string& filePath, std::shared_ptr data); + /** + * Batch version of loadFileData. Loads file data for all Image nodes whose filePath matches + * a key in the map in a single pass over the nodes. More efficient than calling loadFileData + * individually for each file when embedding multiple images. + * @param fileDataMap a map from file path to the file content to embed + */ + void loadFileDataMap( + const std::unordered_map>& fileDataMap); + /** * Executes auto layout on the document, positioning layers according to their layout - * constraints. Must be called before rendering or font embedding. This method should only - * be called once per document — repeated calls may produce incorrect results because - * measurement data is cached and some layout operations permanently modify source geometry. + * constraints. Must be called before rendering or font embedding. Repeated calls are safe + * only after calling clearEmbed(), which clears embedded glyph data and resets the layout + * flag so layout re-runs with fresh shaping data. * @param fontConfig Optional font config for text measurement and rendering. When provided, * updates the internal config before layout. Pass nullptr to use the * previously set config (or no config). @@ -175,6 +186,11 @@ class PAGXDocument : public Node { bool layoutApplied = false; std::unordered_map nodeMap = {}; + void removeNodes(const std::unordered_set& nodesToRemove); + void setNodeId(Node* node, const std::string& id); + void resetLayoutState(); + + friend class FontEmbedder; friend class PAGXImporter; friend class PAGXExporter; friend class TextLayoutContext; diff --git a/include/pagx/nodes/Font.h b/include/pagx/nodes/Font.h index d2f4434ea5..12f07a166b 100644 --- a/include/pagx/nodes/Font.h +++ b/include/pagx/nodes/Font.h @@ -18,6 +18,7 @@ #pragma once +#include #include #include "pagx/nodes/Node.h" #include "pagx/types/Point.h" @@ -76,6 +77,26 @@ class Font : public Node { */ int unitsPerEm = 1000; + /** + * Path to an external font file. When set, this Font node serves as a font source declaration: + * `pagx embed` loads and registers the referenced font for text shaping. Extracted glyph data + * is stored in separate Font nodes (the source node's `glyphs` is preserved as empty across + * embed). The path is resolved relative to the PAGX file's directory. An empty string means + * no external reference. + * + * After PAGXImporter::FromFile() resolves relative paths to absolute paths for runtime use, + * the original verbatim string from the XML attribute is preserved here so that PAGXExporter + * can round-trip the authored path (relative or URL) unchanged. + */ + std::string file = {}; + + /** + * The verbatim `file` attribute value as it appeared in the source XML, before any path + * resolution. PAGXExporter writes this value when non-empty, ensuring that relative paths + * authored by the user are preserved after a round-trip through embed and re-export. + */ + std::string fileOriginal = {}; + /** * The list of glyphs in this font. GlyphID is the index + 1 (GlyphID 0 is reserved for missing * glyph). diff --git a/resources/cli/embed_font_file.pagx b/resources/cli/embed_font_file.pagx new file mode 100644 index 0000000000..429ce630b6 --- /dev/null +++ b/resources/cli/embed_font_file.pagx @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/resources/cli/embed_sample.pagx b/resources/cli/embed_sample.pagx new file mode 100644 index 0000000000..8b17a92120 --- /dev/null +++ b/resources/cli/embed_sample.pagx @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/spec/pagx.xsd b/spec/pagx.xsd index d2e5da817d..b8dcdb6c04 100644 --- a/spec/pagx.xsd +++ b/spec/pagx.xsd @@ -508,6 +508,7 @@ + diff --git a/spec/pagx_spec.md b/spec/pagx_spec.md index 264f7614b1..0ee736e1ce 100644 --- a/spec/pagx_spec.md +++ b/spec/pagx_spec.md @@ -488,7 +488,7 @@ Compositions are used for content reuse (similar to After Effects pre-comps). #### 3.3.5 Font -Font defines embedded font resources containing subsetted glyph data (vector outlines or bitmaps). Embedding glyph data makes PAGX files fully self-contained, ensuring consistent rendering across platforms. +Font defines embedded font resources containing subsetted glyph data (vector outlines or bitmaps). Embedding glyph data makes PAGX files fully self-contained, ensuring consistent rendering across platforms. Font nodes can also reference external font files via the optional `file` attribute, serving as font source declarations for `pagx embed` to discover and register before text layout. ```xml @@ -502,10 +502,14 @@ Font defines embedded font resources containing subsetted glyph data (vector out + + + ``` | Attribute | Type | Default | Description | |-----------|------|---------|-------------| +| `file` | string | - | External font file path. When set, the Font node references an external TTF/OTF file. Relative paths resolve against the PAGX file's directory. `pagx embed` discovers Font nodes with `file`, loads the fonts, and registers them for text shaping. After embed, `file` is preserved for source traceability while embedded glyph data lives in separate Font nodes. | | `unitsPerEm` | int | 1000 | Font design space units. Rendering scale = `fontSize / unitsPerEm` | **Consistency Constraint**: All Glyphs within the same Font must be of the same type—either all `path` or all `image`. Mixing is not allowed. diff --git a/spec/pagx_spec.zh_CN.md b/spec/pagx_spec.zh_CN.md index dbbdebac8c..faa5ac5b0a 100644 --- a/spec/pagx_spec.zh_CN.md +++ b/spec/pagx_spec.zh_CN.md @@ -488,7 +488,7 @@ PathData 定义可复用的路径数据,供 Path 元素和 TextPath 修改器 #### 3.3.5 字体(Font) -Font 定义嵌入字体资源,包含子集化的字形数据(矢量轮廓或位图)。PAGX 文件通过嵌入字形数据实现完全自包含,确保跨平台渲染一致性。 +Font 定义嵌入字体资源,包含子集化的字形数据(矢量轮廓或位图)。PAGX 文件通过嵌入字形数据实现完全自包含,确保跨平台渲染一致性。Font 节点还可以通过可选的 `file` 属性引用外部字体文件,作为字体来源声明,供 `pagx embed` 在执行文本排版前发现并注册。 ```xml @@ -502,10 +502,14 @@ Font 定义嵌入字体资源,包含子集化的字形数据(矢量轮廓或 + + + ``` | 属性 | 类型 | 默认值 | 说明 | |------|------|--------|------| +| `file` | string | - | 外部字体文件路径。设置后 Font 节点引用外部 TTF/OTF 文件。相对路径基于 PAGX 文件所在目录解析。`pagx embed` 会自动发现带 `file` 的 Font 节点,加载并注册字体用于文本排版。嵌入后 `file` 属性保留在输出中以保持源文件可追溯性,嵌入的字形数据位于独立的 Font 节点中。 | | `unitsPerEm` | int | 1000 | 字体设计空间单位。渲染时按 `fontSize / unitsPerEm` 缩放 | **一致性约束**:同一 Font 内的所有 Glyph 必须使用相同类型(全部 `path` 或全部 `image`),不允许混用。 diff --git a/spec/samples/app_icons.pagx b/spec/samples/app_icons.pagx index 649604d6b4..fdc4d15917 100644 --- a/spec/samples/app_icons.pagx +++ b/spec/samples/app_icons.pagx @@ -1,13 +1,9 @@ - - - - @@ -35,12 +31,7 @@ - - - - - @@ -51,11 +42,12 @@ - + + + - @@ -69,11 +61,12 @@ - + + + - @@ -91,11 +84,12 @@ - + + + - @@ -105,13 +99,13 @@ - + + + - - @@ -128,16 +122,17 @@ - + + + - - + @@ -151,11 +146,12 @@ - + + + - @@ -173,11 +169,12 @@ - + + + - @@ -192,38 +189,41 @@ - + + + - - - - - + + + - + - + + + - + + + - @@ -231,22 +231,23 @@ - + - + - + + + - @@ -261,11 +262,12 @@ - + + + - @@ -282,24 +284,52 @@ - + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - diff --git a/src/cli/CliUtils.cpp b/src/cli/CliUtils.cpp index 83840e746e..caa1bb04f6 100644 --- a/src/cli/CliUtils.cpp +++ b/src/cli/CliUtils.cpp @@ -17,6 +17,8 @@ ///////////////////////////////////////////////////////////////////////////////////////////////// #include "cli/CliUtils.h" +#include +#include #include #include #include "pagx/PAGXImporter.h" @@ -64,15 +66,26 @@ bool LoadFontConfig(FontConfig* fontConfig, const std::vector& font bool WriteStringToFile(const std::string& content, const std::string& filePath, const std::string& command) { - std::ofstream out(filePath); - if (!out.is_open()) { - std::cerr << command << ": failed to write '" << filePath << "'\n"; - return false; + auto tempPath = filePath + ".tmp"; + { + std::ofstream out(tempPath); + if (!out.is_open()) { + std::cerr << command << ": failed to write '" << tempPath << "'\n"; + return false; + } + out << content; + out.close(); + if (out.fail()) { + std::cerr << command << ": error writing to '" << tempPath << "'\n"; + std::remove(tempPath.c_str()); + return false; + } } - out << content; - out.close(); - if (out.fail()) { - std::cerr << command << ": error writing to '" << filePath << "'\n"; + std::error_code ec; + std::filesystem::rename(tempPath, filePath, ec); + if (ec) { + std::cerr << command << ": failed to replace '" << filePath << "'\n"; + std::remove(tempPath.c_str()); return false; } std::cout << command << ": wrote " << filePath << "\n"; diff --git a/src/cli/CliUtils.h b/src/cli/CliUtils.h index 321773153c..9679817235 100644 --- a/src/cli/CliUtils.h +++ b/src/cli/CliUtils.h @@ -27,6 +27,7 @@ #include #include "pagx/FontConfig.h" #include "pagx/PAGXDocument.h" +#include "pagx/SystemFonts.h" #include "pagx/nodes/Layer.h" #include "pagx/utils/VerifyUtils.h" #include "tgfx/core/Typeface.h" @@ -46,39 +47,79 @@ static inline bool FontFamilyMatch(const std::string& requested, const std::stri return true; } +static inline bool FontStyleMatch(const std::string& requested, const std::string& actual) { + if (requested.empty()) { + return true; + } + if (requested.size() != actual.size()) { + return false; + } + for (size_t i = 0; i < requested.size(); i++) { + if (std::tolower(static_cast(requested[i])) != + std::tolower(static_cast(actual[i]))) { + return false; + } + } + return true; +} + /** - * Resolves a system font by family and style with fallback. First attempts an exact match with the - * given style. If the result's fontFamily does not match the requested family (case-insensitive), - * or the style is not found, falls back to the family's default style. + * Resolves a system font by family and style with fallback. First attempts MakeFromName for an + * exact match. If MakeFromName is unavailable (e.g. FreeType backend on macOS), falls back to + * SystemFonts::FindFont to locate the font file path and loads via MakeFromPath. */ static inline std::shared_ptr ResolveSystemTypeface(const std::string& family, const std::string& style) { auto typeface = tgfx::Typeface::MakeFromName(family, style); - if (typeface != nullptr && FontFamilyMatch(family, typeface->fontFamily())) { + if (typeface != nullptr && FontFamilyMatch(family, typeface->fontFamily()) && + FontStyleMatch(style, typeface->fontStyle())) { return typeface; } if (!style.empty()) { typeface = tgfx::Typeface::MakeFromName(family, ""); - if (typeface != nullptr && FontFamilyMatch(family, typeface->fontFamily())) { + if (typeface != nullptr && FontFamilyMatch(family, typeface->fontFamily()) && + FontStyleMatch(style, typeface->fontStyle())) { return typeface; } } + // Fallback: locate the font file via platform APIs and load by path. + auto location = pagx::SystemFonts::FindFont(family, style); + if (!location.path.empty()) { + return tgfx::Typeface::MakeFromPath(location.path, location.ttcIndex); + } + if (!style.empty()) { + location = pagx::SystemFonts::FindFont(family, ""); + if (!location.path.empty()) { + return tgfx::Typeface::MakeFromPath(location.path, location.ttcIndex); + } + } return nullptr; } +inline size_t FindLastPathSeparator(const std::string& path) { + auto slash = path.rfind('/'); + auto backslash = path.rfind('\\'); + if (slash == std::string::npos) return backslash; + if (backslash == std::string::npos) return slash; + return std::max(slash, backslash); +} + /** * Resolves a fallback font specifier to a Typeface. Accepts either a font file path (containing - * '/' or ending with a known font extension) or a font name in "family[,style]" format. + * a path separator or ending with a known font extension) or a font name in "family[,style]" format. */ inline std::shared_ptr ResolveFallbackTypeface(const std::string& specifier) { - // Treat as file path if it contains '/' or ends with a known font extension. - bool isFilePath = specifier.find('/') != std::string::npos; + bool isFilePath = + specifier.find('/') != std::string::npos || specifier.find('\\') != std::string::npos; if (!isFilePath) { auto dot = specifier.rfind('.'); if (dot != std::string::npos) { auto ext = specifier.substr(dot); - isFilePath = ext == ".ttf" || ext == ".otf" || ext == ".ttc" || ext == ".woff" || - ext == ".woff2" || ext == ".TTF" || ext == ".OTF" || ext == ".TTC"; + for (auto& ch : ext) { + ch = static_cast(std::tolower(static_cast(ch))); + } + isFilePath = + ext == ".ttf" || ext == ".otf" || ext == ".ttc" || ext == ".woff" || ext == ".woff2"; } } if (isFilePath) { @@ -122,7 +163,7 @@ inline std::string ReplaceExtension(const std::string& path, const std::string& * Extracts the directory part of a path (including trailing slash), or returns "./" if none. */ inline std::string GetDirectory(const std::string& path) { - auto slash = path.rfind('/'); + auto slash = FindLastPathSeparator(path); if (slash != std::string::npos) { return path.substr(0, slash + 1); } @@ -133,7 +174,7 @@ inline std::string GetDirectory(const std::string& path) { * Extracts the base name from a path (filename without directory and extension). */ inline std::string GetBaseName(const std::string& path) { - auto slash = path.rfind('/'); + auto slash = FindLastPathSeparator(path); auto base = (slash != std::string::npos) ? path.substr(slash + 1) : path; auto dot = base.rfind('.'); if (dot != std::string::npos) { diff --git a/src/cli/CommandEmbed.cpp b/src/cli/CommandEmbed.cpp new file mode 100644 index 0000000000..87bfe5411f --- /dev/null +++ b/src/cli/CommandEmbed.cpp @@ -0,0 +1,158 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 "cli/CommandEmbed.h" +#include +#include +#include +#include "cli/CliUtils.h" +#include "pagx/FontConfig.h" +#include "pagx/PAGXExporter.h" +#include "pagx/nodes/Font.h" +#include "renderer/FontEmbedder.h" +#include "renderer/ImageEmbedder.h" + +namespace pagx::cli { + +struct EmbedOptions { + std::string inputFile = {}; + std::string outputFile = {}; + std::vector fallbacks = {}; + bool skipFonts = false; + bool skipImages = false; +}; + +static void PrintEmbedUsage() { + std::cout + << "Usage: pagx embed [options] input.pagx\n" + << "\n" + << "Embed font glyphs and images into a PAGX file for self-contained output.\n" + << "\n" + << "Options:\n" + << " -o, --output Output file path (default: overwrite input)\n" + << " --fallback Add a fallback font file or system font name (can\n" + << " be specified multiple times)\n" + << " --skip-fonts Skip font embedding\n" + << " --skip-images Skip image embedding\n" + << " -h, --help Show this help message\n"; +} + +static int ParseEmbedOptions(int argc, char* argv[], EmbedOptions* options) { + int i = 1; + while (i < argc) { + std::string arg = argv[i]; + if ((arg == "-o" || arg == "--output") && i + 1 < argc) { + options->outputFile = argv[++i]; + } else if (arg == "--fallback" && i + 1 < argc) { + options->fallbacks.push_back(argv[++i]); + } else if (arg == "--skip-fonts") { + options->skipFonts = true; + } else if (arg == "--skip-images") { + options->skipImages = true; + } else if (arg == "--help" || arg == "-h") { + PrintEmbedUsage(); + return -1; + } else if (arg[0] == '-') { + std::cerr << "pagx embed: unknown option '" << arg << "'\n"; + return 1; + } else if (options->inputFile.empty()) { + options->inputFile = arg; + } else { + std::cerr << "pagx embed: unexpected argument '" << arg << "'\n"; + return 1; + } + i++; + } + if (options->inputFile.empty()) { + std::cerr << "pagx embed: missing input file\n"; + return 1; + } + if (options->outputFile.empty()) { + options->outputFile = options->inputFile; + } + return 0; +} + +int RunEmbed(int argc, char* argv[]) { + EmbedOptions options = {}; + auto parseResult = ParseEmbedOptions(argc, argv, &options); + if (parseResult != 0) { + return parseResult == -1 ? 0 : parseResult; + } + + if (options.skipFonts && options.skipImages) { + std::cerr << "pagx embed: --skip-fonts and --skip-images cannot both be set\n"; + return 1; + } + if (options.skipFonts && !options.fallbacks.empty()) { + std::cerr << "pagx embed: --skip-fonts and --fallback cannot both be set\n"; + return 1; + } + + auto document = LoadDocument(options.inputFile, "pagx embed"); + if (document == nullptr) { + return 1; + } + if (document->hasUnresolvedImports()) { + std::cerr << "pagx embed: error: unresolved import directive, run 'pagx resolve' first\n"; + return 1; + } + + if (!options.skipFonts) { + FontConfig fontConfig = {}; + if (!LoadFontConfig(&fontConfig, {}, options.fallbacks, "pagx embed")) { + return 1; + } + for (auto& node : document->nodes) { + if (node->nodeType() == NodeType::Font) { + auto* font = static_cast(node.get()); + if (!font->file.empty()) { + auto typeface = tgfx::Typeface::MakeFromPath(font->file); + if (typeface == nullptr) { + std::cerr << "pagx embed: failed to load font '" << font->file << "'\n"; + return 1; + } + fontConfig.registerTypeface(typeface); + } + } + } + FontEmbedder::ClearEmbeddedGlyphRuns(document.get()); + document->applyLayout(&fontConfig); + FontEmbedder embedder = {}; + if (!embedder.embed(document.get())) { + std::cerr << "pagx embed: font embedding failed\n"; + return 1; + } + } + + if (!options.skipImages) { + ImageEmbedder imageEmbedder = {}; + if (!imageEmbedder.embed(document.get())) { + std::cerr << "pagx embed: failed to load image '" << imageEmbedder.lastErrorPath() << "'\n"; + return 1; + } + } + + auto xml = PAGXExporter::ToXML(*document); + if (!WriteStringToFile(xml, options.outputFile, "pagx embed")) { + return 1; + } + return 0; +} + +} // namespace pagx::cli diff --git a/src/cli/CommandEmbed.h b/src/cli/CommandEmbed.h new file mode 100644 index 0000000000..04917dd312 --- /dev/null +++ b/src/cli/CommandEmbed.h @@ -0,0 +1,30 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 + +namespace pagx::cli { + +/** + * Embeds font glyphs and images into a PAGX file, producing a self-contained output. + * Font embedding extracts glyph paths/images from laid-out text; image embedding inlines + * external files as base64. Run `pagx embed --help` for the full flag list. + */ +int RunEmbed(int argc, char* argv[]); + +} // namespace pagx::cli diff --git a/src/cli/CommandFont.cpp b/src/cli/CommandFont.cpp index ae65a30826..7ab02ac8b3 100644 --- a/src/cli/CommandFont.cpp +++ b/src/cli/CommandFont.cpp @@ -17,93 +17,163 @@ ///////////////////////////////////////////////////////////////////////////////////////////////// #include "cli/CommandFont.h" +#include #include -#include #include #include #include #include "cli/CliUtils.h" -#include "pagx/PAGXExporter.h" -#include "renderer/FontEmbedder.h" +#include "pagx/SystemFonts.h" #include "tgfx/core/Font.h" #include "tgfx/core/Typeface.h" namespace pagx::cli { -// ---- font info ---- +// ---- font query ---- -struct FontInfoOptions { +struct FontOptions { std::string fontFile = {}; std::string fontName = {}; float fontSize = 12.0f; bool jsonOutput = false; + bool listMode = false; }; -static void PrintFontInfoUsage() { - std::cout << "Usage: pagx font info [options]\n" +static void PrintFontUsage() { + std::cout << "Usage: pagx font [options]\n" << "\n" - << "Query font identity and metrics.\n" + << "Query a font file, a system font by name, or enumerate system font families.\n" << "\n" << "Options:\n" - << " --file Font file path\n" - << " --name System font name (e.g., \"Arial\" or \"Arial,Bold\")\n" + << " --file Query a font file\n" + << " --name Query a system font (e.g., \"Arial\" or \"Arial,Bold\")\n" << " --size Font size in points (default: 12)\n" << " --json Output in JSON format\n" + << " --list List every installed system font family\n" + << " -h, --help Show this help\n" << "\n" - << "Either --file or --name is required (mutually exclusive).\n"; + << "Exactly one of --file, --name, or --list must be specified.\n"; +} + +static void FormatFontListText(const std::vector& entries) { + for (const auto& entry : entries) { + if (entry.styles.empty()) { + std::cout << entry.family << "\n"; + continue; + } + std::cout << entry.family << " ("; + for (size_t i = 0; i < entry.styles.size(); i++) { + if (i > 0) { + std::cout << ", "; + } + std::cout << entry.styles[i]; + } + std::cout << ")\n"; + } +} + +static void FormatFontListJson(const std::vector& entries) { + std::cout << "["; + for (size_t i = 0; i < entries.size(); i++) { + if (i > 0) { + std::cout << ","; + } + std::cout << "{\"family\":\"" << EscapeJson(entries[i].family) << "\",\"styles\":["; + for (size_t j = 0; j < entries[i].styles.size(); j++) { + if (j > 0) { + std::cout << ","; + } + std::cout << "\"" << EscapeJson(entries[i].styles[j]) << "\""; + } + std::cout << "]}"; + } + std::cout << "]\n"; } -// Returns 0 on success, -1 if help was printed, 1 on error. -static int ParseFontInfoOptions(int argc, char* argv[], FontInfoOptions* options) { +int RunFont(int argc, char* argv[]) { + if (argc < 2) { + PrintFontUsage(); + return 1; + } + + std::string first = argv[1]; + if (first == "--help" || first == "-h") { + PrintFontUsage(); + return 0; + } + + if (first == "info") { + std::cerr << "pagx font: 'info' subcommand has been removed, use 'pagx font' instead\n"; + return 1; + } + + if (first == "embed") { + std::cerr << "pagx font: 'embed' subcommand has been removed, use 'pagx embed' instead\n"; + return 1; + } + + FontOptions options = {}; int i = 1; while (i < argc) { std::string arg = argv[i]; if (arg == "--file" && i + 1 < argc) { - options->fontFile = argv[++i]; + options.fontFile = argv[++i]; } else if (arg == "--name" && i + 1 < argc) { - options->fontName = argv[++i]; + options.fontName = argv[++i]; } else if (arg == "--size" && i + 1 < argc) { char* endPtr = nullptr; - options->fontSize = strtof(argv[++i], &endPtr); - if (endPtr == argv[i] || *endPtr != '\0' || !std::isfinite(options->fontSize) || - options->fontSize <= 0.0f) { - std::cerr << "pagx font info: invalid font size '" << argv[i] << "'\n"; + options.fontSize = strtof(argv[++i], &endPtr); + if (endPtr == argv[i] || *endPtr != '\0' || !std::isfinite(options.fontSize) || + options.fontSize <= 0.0f) { + std::cerr << "pagx font: invalid font size '" << argv[i] << "'\n"; return 1; } } else if (arg == "--json") { - options->jsonOutput = true; + options.jsonOutput = true; + } else if (arg == "--list") { + options.listMode = true; } else if (arg == "--help" || arg == "-h") { - PrintFontInfoUsage(); - return -1; + PrintFontUsage(); + return 0; } else if (arg[0] == '-') { - std::cerr << "pagx font info: unknown option '" << arg << "'\n"; + std::cerr << "pagx font: unknown option '" << arg << "'\n"; + return 1; + } else { + std::cerr << "pagx font: unknown subcommand '" << arg << "'\n"; return 1; } i++; } - if (options->fontFile.empty() && options->fontName.empty()) { - std::cerr << "pagx font info: either --file or --name is required\n"; + + if (options.listMode && (!options.fontFile.empty() || !options.fontName.empty())) { + std::cerr << "pagx font: --list cannot be combined with --file or --name\n"; return 1; } - if (!options->fontFile.empty() && !options->fontName.empty()) { - std::cerr << "pagx font info: --file and --name are mutually exclusive\n"; + if (!options.fontFile.empty() && !options.fontName.empty()) { + std::cerr << "pagx font: --file and --name are mutually exclusive\n"; + return 1; + } + if (!options.listMode && options.fontFile.empty() && options.fontName.empty()) { + std::cerr << "pagx font: either --file, --name, or --list is required\n"; return 1; } - return 0; -} -static int RunFontInfo(int argc, char* argv[]) { - FontInfoOptions options = {}; - auto parseResult = ParseFontInfoOptions(argc, argv, &options); - if (parseResult != 0) { - return parseResult == -1 ? 0 : parseResult; + if (options.listMode) { + auto entries = pagx::SystemFonts::AllFontFamilies(); + std::sort(entries.begin(), entries.end()); + if (options.jsonOutput) { + FormatFontListJson(entries); + } else { + FormatFontListText(entries); + } + return 0; } std::shared_ptr typeface = nullptr; if (!options.fontFile.empty()) { typeface = tgfx::Typeface::MakeFromPath(options.fontFile); if (typeface == nullptr) { - std::cerr << "pagx font info: failed to load font file '" << options.fontFile << "'\n"; + std::cerr << "pagx font: failed to load font file '" << options.fontFile << "'\n"; return 1; } } else { @@ -114,7 +184,7 @@ static int RunFontInfo(int argc, char* argv[]) { commaPos != std::string::npos ? options.fontName.substr(commaPos + 1) : std::string(); typeface = ResolveSystemTypeface(family, style); if (typeface == nullptr) { - std::cerr << "pagx font info: font '" << options.fontName << "' not found\n"; + std::cerr << "pagx font: font '" << options.fontName << "' not found\n"; return 1; } } @@ -162,138 +232,4 @@ static int RunFontInfo(int argc, char* argv[]) { return 0; } -// ---- font embed ---- - -struct FontEmbedOptions { - std::string inputFile = {}; - std::string outputFile = {}; - std::vector fontFiles = {}; - std::vector fallbacks = {}; -}; - -static void PrintFontEmbedUsage() { - std::cout - << "Usage: pagx font embed [options] \n" - << "\n" - << "Embed fonts into a PAGX file by performing text layout and glyph extraction.\n" - << "\n" - << "Options:\n" - << " -o, --output Output file path (default: overwrite input)\n" - << " --file Register a font file (can be specified multiple\n" - << " times)\n" - << " --fallback Add a fallback font file or system font name (can\n" - << " be specified multiple times)\n" - << " -h, --help Show this help message\n"; -} - -// Returns 0 on success, -1 if help was printed, 1 on error. -static int ParseFontEmbedOptions(int argc, char* argv[], FontEmbedOptions* options) { - int i = 1; - while (i < argc) { - std::string arg = argv[i]; - if ((arg == "-o" || arg == "--output") && i + 1 < argc) { - options->outputFile = argv[++i]; - } else if (arg == "--file" && i + 1 < argc) { - options->fontFiles.push_back(argv[++i]); - } else if (arg == "--fallback" && i + 1 < argc) { - options->fallbacks.push_back(argv[++i]); - } else if (arg == "--help" || arg == "-h") { - PrintFontEmbedUsage(); - return -1; - } else if (arg[0] == '-') { - std::cerr << "pagx font embed: unknown option '" << arg << "'\n"; - return 1; - } else if (options->inputFile.empty()) { - options->inputFile = arg; - } else { - std::cerr << "pagx font embed: unexpected argument '" << arg << "'\n"; - return 1; - } - i++; - } - if (options->inputFile.empty()) { - std::cerr << "pagx font embed: missing input file\n"; - return 1; - } - if (options->outputFile.empty()) { - options->outputFile = options->inputFile; - } - return 0; -} - -static int RunFontEmbed(int argc, char* argv[]) { - FontEmbedOptions options = {}; - auto parseResult = ParseFontEmbedOptions(argc, argv, &options); - if (parseResult != 0) { - return parseResult == -1 ? 0 : parseResult; - } - - auto document = LoadDocument(options.inputFile, "pagx font embed"); - if (document == nullptr) { - return 1; - } - if (document->hasUnresolvedImports()) { - std::cerr << "pagx font embed: error: unresolved import directive, run 'pagx resolve' first\n"; - return 1; - } - - FontConfig fontConfig = {}; - if (!LoadFontConfig(&fontConfig, options.fontFiles, options.fallbacks, "pagx font embed")) { - return 1; - } - - FontEmbedder::ClearEmbeddedGlyphRuns(document.get()); - document->applyLayout(&fontConfig); - - FontEmbedder embedder = {}; - if (!embedder.embed(document.get())) { - std::cerr << "pagx font embed: font embedding failed\n"; - return 1; - } - - auto xml = PAGXExporter::ToXML(*document); - if (!WriteStringToFile(xml, options.outputFile, "pagx font embed")) { - return 1; - } - - return 0; -} - -// ---- main entry ---- - -static void PrintFontUsage() { - std::cout << "Usage: pagx font [options]\n" - << "\n" - << "Subcommands:\n" - << " info Query font identity and metrics\n" - << " embed Embed fonts into a PAGX file\n" - << "\n" - << "Run 'pagx font --help' for details.\n"; -} - -int RunFont(int argc, char* argv[]) { - if (argc < 2) { - PrintFontUsage(); - return 1; - } - - std::string subcommand = argv[1]; - - if (subcommand == "--help" || subcommand == "-h") { - PrintFontUsage(); - return 0; - } - - if (subcommand == "info") { - return RunFontInfo(argc - 1, argv + 1); - } - if (subcommand == "embed") { - return RunFontEmbed(argc - 1, argv + 1); - } - - std::cerr << "pagx font: unknown subcommand '" << subcommand << "'\n"; - std::cerr << "Run 'pagx font --help' for usage.\n"; - return 1; -} - } // namespace pagx::cli diff --git a/src/cli/CommandFont.h b/src/cli/CommandFont.h index 8eb819fa11..f15d2e2e9b 100644 --- a/src/cli/CommandFont.h +++ b/src/cli/CommandFont.h @@ -21,11 +21,7 @@ namespace pagx::cli { /** - * Manages font operations: querying font metrics and embedding fonts into PAGX files. - * - * Subcommands: - * info - Query font identity and metrics from a file or system font - * embed - Embed fonts into a PAGX file with optional fallback list + * Queries font metrics from a file or system font. */ int RunFont(int argc, char* argv[]); diff --git a/src/cli/main.cpp b/src/cli/main.cpp index e90c32e092..1f9265c39d 100644 --- a/src/cli/main.cpp +++ b/src/cli/main.cpp @@ -20,6 +20,7 @@ #include #include #include "cli/CommandBounds.h" +#include "cli/CommandEmbed.h" #include "cli/CommandExport.h" #include "cli/CommandFont.h" #include "cli/CommandFormat.h" @@ -44,7 +45,8 @@ static void PrintUsage() { << " layout Display layout tree with bounds\n" << " render Render PAGX to an image file (supports crop and scale)\n" << " bounds Query rendered pixel bounds of layers (for crop regions)\n" - << " font Query font metrics or embed fonts into a PAGX file\n" + << " font Query font metrics\n" + << " embed Embed fonts and images into a PAGX file\n" << " format Format a PAGX file (indentation and attribute ordering)\n" << " import Import from another format (e.g. SVG) to PAGX\n" << " export Export a PAGX file to another format (e.g. SVG)\n" @@ -89,6 +91,9 @@ int main(int argc, char* argv[]) { if (command == "font") { return pagx::cli::RunFont(argc - 1, argv + 1); } + if (command == "embed") { + return pagx::cli::RunEmbed(argc - 1, argv + 1); + } if (command == "format") { return pagx::cli::RunFormat(argc - 1, argv + 1); } diff --git a/src/pagx/PAGXDocument.cpp b/src/pagx/PAGXDocument.cpp index 19cc0bb112..3a0ffbb61e 100644 --- a/src/pagx/PAGXDocument.cpp +++ b/src/pagx/PAGXDocument.cpp @@ -75,6 +75,40 @@ void PAGXDocument::registerNode(Node* node, const std::string& id) { nodeMap[id] = node; } +void PAGXDocument::removeNodes(const std::unordered_set& toRemove) { + for (auto it = nodeMap.begin(); it != nodeMap.end();) { + if (toRemove.count(it->second) > 0) { + it = nodeMap.erase(it); + } else { + ++it; + } + } + size_t writeIdx = 0; + for (size_t readIdx = 0; readIdx < nodes.size(); readIdx++) { + if (toRemove.count(nodes[readIdx].get()) == 0) { + nodes[writeIdx++] = std::move(nodes[readIdx]); + } + } + nodes.resize(writeIdx); +} + +void PAGXDocument::setNodeId(Node* node, const std::string& id) { + if (node == nullptr) { + return; + } + if (!node->id.empty()) { + auto it = nodeMap.find(node->id); + if (it != nodeMap.end() && it->second == node) { + nodeMap.erase(it); + } + } + registerNode(node, id); +} + +void PAGXDocument::resetLayoutState() { + layoutApplied = false; +} + static bool LayersHaveImports(const std::vector& layers) { for (auto* layer : layers) { if (!layer->importDirective.source.empty() || !layer->importDirective.content.empty()) { @@ -102,6 +136,39 @@ bool PAGXDocument::hasUnresolvedImports() const { return false; } +static bool IsUrlPath(const std::string& path) { + // Match known URL schemes (case-insensitive per RFC 3986 Section 3.1) rather than generic :// + // to avoid false positives on Windows paths like C://Users/file.png. + // data: URIs have no "://" so they are checked separately. + if (path.size() >= 5 && (path[0] == 'd' || path[0] == 'D') && + (path[1] == 'a' || path[1] == 'A') && (path[2] == 't' || path[2] == 'T') && + (path[3] == 'a' || path[3] == 'A') && path[4] == ':') { + return true; + } + auto schemeEnd = path.find("://"); + if (schemeEnd == std::string::npos) { + return false; + } + auto scheme = path.substr(0, schemeEnd); + if (scheme.size() == 4) { + if ((scheme[0] == 'h' || scheme[0] == 'H') && (scheme[1] == 't' || scheme[1] == 'T') && + (scheme[2] == 't' || scheme[2] == 'T') && (scheme[3] == 'p' || scheme[3] == 'P')) { + return true; + } + if ((scheme[0] == 'f' || scheme[0] == 'F') && (scheme[1] == 'i' || scheme[1] == 'I') && + (scheme[2] == 'l' || scheme[2] == 'L') && (scheme[3] == 'e' || scheme[3] == 'E')) { + return true; + } + } else if (scheme.size() == 5) { + if ((scheme[0] == 'h' || scheme[0] == 'H') && (scheme[1] == 't' || scheme[1] == 'T') && + (scheme[2] == 't' || scheme[2] == 'T') && (scheme[3] == 'p' || scheme[3] == 'P') && + (scheme[4] == 's' || scheme[4] == 'S')) { + return true; + } + } + return false; +} + std::vector PAGXDocument::getExternalFilePaths() const { std::vector paths = {}; for (auto& node : nodes) { @@ -112,7 +179,7 @@ std::vector PAGXDocument::getExternalFilePaths() const { if (image->data != nullptr || image->filePath.empty()) { continue; } - if (image->filePath.find("data:") == 0) { + if (IsUrlPath(image->filePath)) { continue; } paths.push_back(image->filePath); @@ -153,4 +220,23 @@ void PAGXDocument::clearEmbed() { FontEmbedder::ClearEmbeddedGlyphRuns(this); } +void PAGXDocument::loadFileDataMap( + const std::unordered_map>& fileDataMap) { + for (auto& node : nodes) { + if (node->nodeType() != NodeType::Image) { + continue; + } + auto* image = static_cast(node.get()); + if (image->data != nullptr || image->filePath.empty()) { + continue; + } + auto it = fileDataMap.find(image->filePath); + if (it == fileDataMap.end()) { + continue; + } + image->data = it->second; + image->filePath = {}; + } +} + } // namespace pagx diff --git a/src/pagx/PAGXExporter.cpp b/src/pagx/PAGXExporter.cpp index 4d76465a9d..3b31a19541 100644 --- a/src/pagx/PAGXExporter.cpp +++ b/src/pagx/PAGXExporter.cpp @@ -1004,6 +1004,10 @@ static void WriteResource(XMLBuilder& xml, const Node* node, const Options& opti xml.openElement("Font"); xml.addAttribute("id", font->id); xml.addAttribute("unitsPerEm", font->unitsPerEm, Default().unitsPerEm); + if (!font->file.empty()) { + const std::string& fileAttr = !font->fileOriginal.empty() ? font->fileOriginal : font->file; + xml.addAttribute("file", fileAttr); + } WriteCustomData(xml, node); if (font->glyphs.empty()) { xml.closeElementSelfClosing(); diff --git a/src/pagx/PAGXImporter.cpp b/src/pagx/PAGXImporter.cpp index 32aaf9244f..f2487f3086 100644 --- a/src/pagx/PAGXImporter.cpp +++ b/src/pagx/PAGXImporter.cpp @@ -1451,6 +1451,7 @@ static Font* ParseFont(const DOMNode* node, PAGXDocument* doc) { return nullptr; } font->unitsPerEm = GetIntAttribute(node, "unitsPerEm", Default().unitsPerEm, doc); + font->file = GetAttribute(node, "file"); auto child = node->firstChild; while (child) { if (child->type == DOMNodeType::Element) { @@ -2112,6 +2113,13 @@ static Color GetColorAttribute(const DOMNode* node, const char* name, PAGXDocume // Public API implementation //============================================================================== +static void ResolveRelativePath(const std::string& basePath, std::string& path) { + if (path.empty() || path[0] == '/' || path.find("://") != std::string::npos) { + return; + } + path = basePath + path; +} + std::shared_ptr PAGXImporter::FromFile(const std::string& filePath) { std::ifstream file(filePath, std::ios::binary | std::ios::ate); if (!file) { @@ -2140,10 +2148,14 @@ std::shared_ptr PAGXImporter::FromFile(const std::string& filePath for (auto& node : doc->nodes) { if (node->nodeType() == NodeType::Image) { auto* image = static_cast(node.get()); - if (!image->filePath.empty() && image->filePath[0] != '/' && - image->filePath.find("://") == std::string::npos) { - image->filePath = basePath + image->filePath; + ResolveRelativePath(basePath, image->filePath); + } + if (node->nodeType() == NodeType::Font) { + auto* font = static_cast(node.get()); + if (!font->file.empty()) { + font->fileOriginal = font->file; } + ResolveRelativePath(basePath, font->file); } } } diff --git a/src/pagx/SystemFonts.cpp b/src/pagx/SystemFonts.cpp index bbadcb38f8..afa95ef8a8 100644 --- a/src/pagx/SystemFonts.cpp +++ b/src/pagx/SystemFonts.cpp @@ -160,11 +160,153 @@ std::vector SystemFonts::FallbackTypefaces() { return fallbacks; } +std::vector SystemFonts::AllFontFamilies() { + CFArrayRef familyNames = CTFontManagerCopyAvailableFontFamilyNames(); + if (familyNames == nullptr) { + return {}; + } + + std::vector entries = {}; + CFIndex familyCount = CFArrayGetCount(familyNames); + entries.reserve(static_cast(familyCount)); + + // Build a reusable mandatory-attributes set containing only kCTFontFamilyNameAttribute so that + // CTFontDescriptorCreateMatchingFontDescriptors returns every descriptor whose family name + // matches exactly — i.e. every member of the family. + // Uses CTFontDescriptorCreateMatchingFontDescriptors instead of + // CTFontManagerCopyAvailableMembersOfFontFamily for consistency with the descriptor-based + // workflow; both produce equivalent family-member sets. + const void* mandatoryKeys[] = {kCTFontFamilyNameAttribute}; + CFSetRef mandatoryAttributes = + CFSetCreate(kCFAllocatorDefault, mandatoryKeys, 1, &kCFTypeSetCallBacks); + if (mandatoryAttributes == nullptr) { + CFRelease(familyNames); + return {}; + } + + for (CFIndex i = 0; i < familyCount; i++) { + auto cfFamilyName = static_cast(CFArrayGetValueAtIndex(familyNames, i)); + if (cfFamilyName == nullptr) { + continue; + } + auto familyStr = StringFromCFString(cfFamilyName); + if (familyStr.empty()) { + continue; + } + + FontFamilyEntry entry = {}; + entry.family = std::move(familyStr); + + const void* attrKeys[] = {kCTFontFamilyNameAttribute}; + const void* attrValues[] = {cfFamilyName}; + CFDictionaryRef attributes = + CFDictionaryCreate(kCFAllocatorDefault, attrKeys, attrValues, 1, + &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); + if (attributes != nullptr) { + CTFontDescriptorRef familyDescriptor = CTFontDescriptorCreateWithAttributes(attributes); + CFRelease(attributes); + if (familyDescriptor != nullptr) { + CFArrayRef members = + CTFontDescriptorCreateMatchingFontDescriptors(familyDescriptor, mandatoryAttributes); + CFRelease(familyDescriptor); + if (members != nullptr) { + CFIndex memberCount = CFArrayGetCount(members); + std::set seenStyles = {}; + entry.styles.reserve(static_cast(memberCount)); + + for (CFIndex j = 0; j < memberCount; j++) { + auto descriptor = static_cast(CFArrayGetValueAtIndex(members, j)); + if (descriptor == nullptr) { + continue; + } + auto cfStyle = static_cast( + CTFontDescriptorCopyAttribute(descriptor, kCTFontStyleNameAttribute)); + if (cfStyle == nullptr) { + continue; + } + auto styleStr = StringFromCFString(cfStyle); + CFRelease(cfStyle); + if (styleStr.empty()) { + continue; + } + if (seenStyles.insert(styleStr).second) { + entry.styles.push_back(std::move(styleStr)); + } + } + CFRelease(members); + } + } + } + + entries.push_back(std::move(entry)); + } + + if (mandatoryAttributes != nullptr) { + CFRelease(mandatoryAttributes); + } + CFRelease(familyNames); + return entries; +} + +FontLocation SystemFonts::FindFont(const std::string& family, const std::string& style) { + if (family.empty()) { + return {}; + } + auto cfFamily = + CFStringCreateWithCString(kCFAllocatorDefault, family.c_str(), kCFStringEncodingUTF8); + if (cfFamily == nullptr) { + return {}; + } + CFMutableDictionaryRef attributes = CFDictionaryCreateMutable( + kCFAllocatorDefault, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); + CFDictionaryAddValue(attributes, kCTFontFamilyNameAttribute, cfFamily); + CFRelease(cfFamily); + if (!style.empty()) { + auto cfStyle = + CFStringCreateWithCString(kCFAllocatorDefault, style.c_str(), kCFStringEncodingUTF8); + if (cfStyle != nullptr) { + CFDictionaryAddValue(attributes, kCTFontStyleNameAttribute, cfStyle); + CFRelease(cfStyle); + } + } + const void* mandatoryKeys[] = {kCTFontFamilyNameAttribute, kCTFontStyleNameAttribute}; + int mandatoryCount = style.empty() ? 1 : 2; + CFSetRef mandatoryAttributes = + CFSetCreate(kCFAllocatorDefault, mandatoryKeys, mandatoryCount, &kCFTypeSetCallBacks); + if (mandatoryAttributes == nullptr) { + CFRelease(attributes); + return {}; + } + CTFontDescriptorRef descriptor = CTFontDescriptorCreateWithAttributes(attributes); + CFRelease(attributes); + if (descriptor == nullptr) { + CFRelease(mandatoryAttributes); + return {}; + } + CFArrayRef matches = + CTFontDescriptorCreateMatchingFontDescriptors(descriptor, mandatoryAttributes); + CFRelease(descriptor); + if (mandatoryAttributes != nullptr) { + CFRelease(mandatoryAttributes); + } + if (matches == nullptr || CFArrayGetCount(matches) == 0) { + if (matches != nullptr) { + CFRelease(matches); + } + return {}; + } + auto matched = static_cast(CFArrayGetValueAtIndex(matches, 0)); + auto location = GetFontLocationFromDescriptor(matched); + CFRelease(matches); + return location; +} + } // namespace pagx #elif defined(_WIN32) #include +#include #include #pragma comment(lib, "dwrite.lib") @@ -189,10 +331,8 @@ static std::string WideToUTF8(const wchar_t* wide, int length) { return result; } -static std::string GetFamilyName(IDWriteFontFamily* fontFamily) { - IDWriteLocalizedStrings* names = nullptr; - HRESULT hr = fontFamily->GetFamilyNames(&names); - if (FAILED(hr) || names == nullptr) { +static std::string GetLocalizedName(IDWriteLocalizedStrings* names) { + if (names == nullptr) { return {}; } @@ -204,19 +344,82 @@ static std::string GetFamilyName(IDWriteFontFamily* fontFamily) { } UINT32 length = 0; - hr = names->GetStringLength(index, &length); + HRESULT hr = names->GetStringLength(index, &length); if (FAILED(hr) || length == 0) { - SafeRelease(&names); return {}; } std::wstring wide(static_cast(length) + 1, L'\0'); hr = names->GetString(index, wide.data(), length + 1); + if (FAILED(hr)) { + return {}; + } + + return WideToUTF8(wide.c_str(), static_cast(length)); +} + +static std::string GetFamilyName(IDWriteFontFamily* fontFamily) { + IDWriteLocalizedStrings* names = nullptr; + HRESULT hr = fontFamily->GetFamilyNames(&names); + if (FAILED(hr)) { + return {}; + } + auto result = GetLocalizedName(names); SafeRelease(&names); + return result; +} + +static std::string GetFaceName(IDWriteFont* font) { + IDWriteLocalizedStrings* names = nullptr; + HRESULT hr = font->GetFaceNames(&names); if (FAILED(hr)) { return {}; } + auto result = GetLocalizedName(names); + SafeRelease(&names); + return result; +} + +static std::string GetFilePath(IDWriteFontFile* fontFile) { + if (fontFile == nullptr) { + return {}; + } + + IDWriteFontFileLoader* loader = nullptr; + HRESULT hr = fontFile->GetLoader(&loader); + if (FAILED(hr) || loader == nullptr) { + return {}; + } + + IDWriteLocalFontFileLoader* localLoader = nullptr; + hr = loader->QueryInterface(__uuidof(IDWriteLocalFontFileLoader), + reinterpret_cast(&localLoader)); + SafeRelease(&loader); + if (FAILED(hr) || localLoader == nullptr) { + return {}; + } + + const void* refKey = nullptr; + UINT32 refKeySize = 0; + hr = fontFile->GetReferenceKey(&refKey, &refKeySize); + if (FAILED(hr) || refKey == nullptr) { + SafeRelease(&localLoader); + return {}; + } + + UINT32 length = 0; + hr = localLoader->GetFilePathLengthFromKey(refKey, refKeySize, &length); + if (FAILED(hr) || length == 0) { + SafeRelease(&localLoader); + return {}; + } + std::wstring wide(static_cast(length) + 1, L'\0'); + hr = localLoader->GetFilePathFromKey(refKey, refKeySize, wide.data(), length + 1); + SafeRelease(&localLoader); + if (FAILED(hr)) { + return {}; + } return WideToUTF8(wide.c_str(), static_cast(length)); } @@ -262,12 +465,217 @@ std::vector SystemFonts::FallbackTypefaces() { return fallbacks; } +std::vector SystemFonts::AllFontFamilies() { + IDWriteFactory* factory = nullptr; + HRESULT hr = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED, __uuidof(IDWriteFactory), + reinterpret_cast(&factory)); + if (FAILED(hr) || factory == nullptr) { + return {}; + } + + IDWriteFontCollection* fontCollection = nullptr; + hr = factory->GetSystemFontCollection(&fontCollection); + if (FAILED(hr) || fontCollection == nullptr) { + SafeRelease(&factory); + return {}; + } + + UINT32 familyCount = fontCollection->GetFontFamilyCount(); + std::vector entries = {}; + entries.reserve(familyCount); + + for (UINT32 i = 0; i < familyCount; i++) { + IDWriteFontFamily* fontFamily = nullptr; + hr = fontCollection->GetFontFamily(i, &fontFamily); + if (FAILED(hr) || fontFamily == nullptr) { + continue; + } + + auto familyName = GetFamilyName(fontFamily); + if (familyName.empty()) { + SafeRelease(&fontFamily); + continue; + } + + FontFamilyEntry entry = {}; + entry.family = std::move(familyName); + + UINT32 fontCount = fontFamily->GetFontCount(); + std::set seenStyles = {}; + entry.styles.reserve(fontCount); + + for (UINT32 j = 0; j < fontCount; j++) { + IDWriteFont* font = nullptr; + hr = fontFamily->GetFont(j, &font); + if (FAILED(hr) || font == nullptr) { + continue; + } + if (font->GetSimulations() != DWRITE_FONT_SIMULATIONS_NONE) { + SafeRelease(&font); + continue; + } + auto faceName = GetFaceName(font); + SafeRelease(&font); + if (faceName.empty()) { + continue; + } + if (seenStyles.insert(faceName).second) { + entry.styles.push_back(std::move(faceName)); + } + } + + SafeRelease(&fontFamily); + entries.push_back(std::move(entry)); + } + + SafeRelease(&fontCollection); + SafeRelease(&factory); + return entries; +} + +FontLocation SystemFonts::FindFont(const std::string& family, const std::string& style) { + if (family.empty()) { + return {}; + } + + IDWriteFactory* factory = nullptr; + HRESULT hr = DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED, __uuidof(IDWriteFactory), + reinterpret_cast(&factory)); + if (FAILED(hr) || factory == nullptr) { + return {}; + } + + IDWriteFontCollection* fontCollection = nullptr; + hr = factory->GetSystemFontCollection(&fontCollection); + if (FAILED(hr) || fontCollection == nullptr) { + SafeRelease(&factory); + return {}; + } + + int familyLen = MultiByteToWideChar(CP_UTF8, 0, family.c_str(), -1, nullptr, 0); + if (familyLen <= 0) { + SafeRelease(&fontCollection); + SafeRelease(&factory); + return {}; + } + std::wstring wideFamily; + wideFamily.resize(static_cast(familyLen)); + MultiByteToWideChar(CP_UTF8, 0, family.c_str(), -1, wideFamily.data(), familyLen); + wideFamily.resize(static_cast(familyLen) - 1); + + UINT32 familyIndex = 0; + BOOL exists = FALSE; + hr = fontCollection->FindFamilyName(wideFamily.c_str(), &familyIndex, &exists); + if (FAILED(hr) || !exists) { + SafeRelease(&fontCollection); + SafeRelease(&factory); + return {}; + } + + IDWriteFontFamily* fontFamily = nullptr; + hr = fontCollection->GetFontFamily(familyIndex, &fontFamily); + if (FAILED(hr) || fontFamily == nullptr) { + SafeRelease(&fontCollection); + SafeRelease(&factory); + return {}; + } + + UINT32 fontCount = fontFamily->GetFontCount(); + IDWriteFont* matchedFont = nullptr; + + if (style.empty()) { + for (UINT32 j = 0; j < fontCount; j++) { + hr = fontFamily->GetFont(j, &matchedFont); + if (FAILED(hr) || matchedFont == nullptr) { + continue; + } + if (matchedFont->GetSimulations() == DWRITE_FONT_SIMULATIONS_NONE) { + break; + } + SafeRelease(&matchedFont); + } + if (matchedFont == nullptr) { + fontFamily->GetFont(0, &matchedFont); + } + } else { + for (UINT32 j = 0; j < fontCount; j++) { + IDWriteFont* font = nullptr; + hr = fontFamily->GetFont(j, &font); + if (FAILED(hr) || font == nullptr) { + continue; + } + if (font->GetSimulations() != DWRITE_FONT_SIMULATIONS_NONE) { + SafeRelease(&font); + continue; + } + auto faceName = GetFaceName(font); + if (_stricmp(faceName.c_str(), style.c_str()) == 0) { + matchedFont = font; + break; + } + SafeRelease(&font); + } + } + + if (matchedFont == nullptr) { + SafeRelease(&fontFamily); + SafeRelease(&fontCollection); + SafeRelease(&factory); + return {}; + } + + IDWriteFontFace* fontFace = nullptr; + hr = matchedFont->CreateFontFace(&fontFace); + SafeRelease(&matchedFont); + if (FAILED(hr) || fontFace == nullptr) { + SafeRelease(&fontFamily); + SafeRelease(&fontCollection); + SafeRelease(&factory); + return {}; + } + + UINT32 fileCount = 1; + IDWriteFontFile* fontFile = nullptr; + hr = fontFace->GetFiles(&fileCount, &fontFile); + if (FAILED(hr) || fontFile == nullptr) { + SafeRelease(&fontFace); + SafeRelease(&fontFamily); + SafeRelease(&fontCollection); + SafeRelease(&factory); + return {}; + } + + FontLocation location = {}; + location.path = GetFilePath(fontFile); + SafeRelease(&fontFile); + if (location.path.empty()) { + SafeRelease(&fontFace); + SafeRelease(&fontFamily); + SafeRelease(&fontCollection); + SafeRelease(&factory); + return {}; + } + + location.ttcIndex = static_cast(fontFace->GetIndex()); + location.fontFamily = GetFamilyName(fontFamily); + if (!style.empty()) { + location.fontStyle = style; + } + + SafeRelease(&fontFace); + SafeRelease(&fontFamily); + SafeRelease(&fontCollection); + SafeRelease(&factory); + return location; +} + } // namespace pagx #elif defined(__linux__) #include #include +#include #include #include @@ -336,6 +744,115 @@ std::vector SystemFonts::FallbackTypefaces() { return fallbacks; } +std::vector SystemFonts::AllFontFamilies() { + FcPattern* pattern = FcPatternCreate(); + if (pattern == nullptr) { + return {}; + } + FcObjectSet* objectSet = FcObjectSetBuild(FC_FAMILY, FC_STYLE, (char*)0); + if (objectSet == nullptr) { + FcPatternDestroy(pattern); + return {}; + } + FcFontSet* fontSet = FcFontList(nullptr, pattern, objectSet); + FcObjectSetDestroy(objectSet); + FcPatternDestroy(pattern); + if (fontSet == nullptr) { + return {}; + } + + std::vector entries = {}; + std::map familyIndex = {}; + std::vector > seenStylesPerEntry = {}; + + for (int i = 0; i < fontSet->nfont; i++) { + FcPattern* font = fontSet->fonts[i]; + FcChar8* familyRaw = nullptr; + if (FcPatternGetString(font, FC_FAMILY, 0, &familyRaw) != FcResultMatch || + familyRaw == nullptr) { + continue; + } + std::string familyStr(reinterpret_cast(familyRaw)); + if (familyStr.empty()) { + continue; + } + + auto [it, inserted] = familyIndex.try_emplace(familyStr, entries.size()); + if (inserted) { + FontFamilyEntry entry = {}; + entry.family = familyStr; + entries.push_back(std::move(entry)); + seenStylesPerEntry.emplace_back(); + it->second = entries.size() - 1; + } + + FcChar8* styleRaw = nullptr; + if (FcPatternGetString(font, FC_STYLE, 0, &styleRaw) != FcResultMatch || styleRaw == nullptr) { + continue; + } + std::string styleStr(reinterpret_cast(styleRaw)); + if (styleStr.empty()) { + continue; + } + + size_t idx = it->second; + if (seenStylesPerEntry[idx].insert(styleStr).second) { + entries[idx].styles.push_back(std::move(styleStr)); + } + } + + FcFontSetDestroy(fontSet); + return entries; +} + +FontLocation SystemFonts::FindFont(const std::string& family, const std::string& style) { + if (family.empty()) { + return {}; + } + FcPattern* pattern = FcPatternCreate(); + if (pattern == nullptr) { + return {}; + } + FcPatternAddString(pattern, FC_FAMILY, reinterpret_cast(family.c_str())); + if (!style.empty()) { + FcPatternAddString(pattern, FC_STYLE, reinterpret_cast(style.c_str())); + } + FcConfigSubstitute(nullptr, pattern, FcMatchPattern); + FcDefaultSubstitute(pattern); + + FcResult result = FcResultMatch; + FcPattern* matched = FcFontMatch(nullptr, pattern, &result); + FcPatternDestroy(pattern); + if (matched == nullptr) { + return {}; + } + FcChar8* filePath = nullptr; + if (FcPatternGetString(matched, FC_FILE, 0, &filePath) != FcResultMatch || filePath == nullptr) { + FcPatternDestroy(matched); + return {}; + } + FcChar8* matchedFamily = nullptr; + FcPatternGetString(matched, FC_FAMILY, 0, &matchedFamily); + if (matchedFamily == nullptr || + strcasecmp(reinterpret_cast(matchedFamily), family.c_str()) != 0) { + FcPatternDestroy(matched); + return {}; + } + FontLocation location = {}; + location.path = std::string(reinterpret_cast(filePath)); + location.fontFamily = std::string(reinterpret_cast(matchedFamily)); + int ttcIndex = 0; + FcPatternGetInteger(matched, FC_INDEX, 0, &ttcIndex); + location.ttcIndex = ttcIndex; + FcChar8* matchedStyle = nullptr; + if (FcPatternGetString(matched, FC_STYLE, 0, &matchedStyle) == FcResultMatch && + matchedStyle != nullptr) { + location.fontStyle = std::string(reinterpret_cast(matchedStyle)); + } + FcPatternDestroy(matched); + return location; +} + } // namespace pagx #else @@ -346,6 +863,14 @@ std::vector SystemFonts::FallbackTypefaces() { return {}; } +std::vector SystemFonts::AllFontFamilies() { + return {}; +} + +FontLocation SystemFonts::FindFont(const std::string&, const std::string&) { + return {}; +} + } // namespace pagx #endif diff --git a/src/pagx/SystemFonts.h b/src/pagx/SystemFonts.h index 220935681d..84abb8d2d1 100644 --- a/src/pagx/SystemFonts.h +++ b/src/pagx/SystemFonts.h @@ -35,10 +35,24 @@ struct FontLocation { }; /** - * Provides access to system fallback fonts by querying native platform APIs. On macOS, this uses - * CTFontCopyDefaultCascadeListForLanguages with the user's language preferences. On Linux, this - * uses fontconfig's FcFontSort to enumerate system fonts in priority order. On Windows, this - * enumerates the system font collection via DirectWrite. + * Describes a single system font family and its available styles. Returned by + * SystemFonts::AllFontFamilies for font enumeration purposes. + */ +struct FontFamilyEntry { + std::string family = {}; + std::vector styles = {}; + + bool operator<(const FontFamilyEntry& other) const { + return family < other.family; + } +}; + +/** + * Provides access to system font metadata — fallback typefaces used during text rendering and + * the full list of installed font families. On macOS, this uses CoreText + * (CTFontCopyDefaultCascadeListForLanguages / CTFontManagerCopyAvailableFontFamilyNames). On + * Linux, this uses fontconfig (FcFontSort / FcFontList). On Windows, this uses DirectWrite + * (IDWriteFontCollection). */ class SystemFonts { public: @@ -47,6 +61,22 @@ class SystemFonts { * language preferences. No Typeface objects are created; callers should load fonts on demand. */ static std::vector FallbackTypefaces(); + + /** + * Returns every installed system font family with its available styles. On failure + * (platform API unavailable, enumeration fails), returns an empty vector silently. + * Styles within a family are deduplicated (first-occurrence-wins). Order within + * styles is platform-native order. Order of families across the vector is NOT + * guaranteed (callers should sort as needed). + */ + static std::vector AllFontFamilies(); + + /** + * Finds a single system font by family and style name. Returns a FontLocation with the file path + * and TTC index. If style is empty, returns the family's default style. Returns an empty + * FontLocation (empty path) if no match is found. + */ + static FontLocation FindFont(const std::string& family, const std::string& style); }; } // namespace pagx diff --git a/src/renderer/FontEmbedder.cpp b/src/renderer/FontEmbedder.cpp index 36da461fd5..1343676653 100644 --- a/src/renderer/FontEmbedder.cpp +++ b/src/renderer/FontEmbedder.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include "base/utils/Log.h" #include "base/utils/MathUtil.h" #include "pagx/TextLayout.h" @@ -38,6 +39,7 @@ namespace pagx { static constexpr int VectorFontUnitsPerEm = 1000; +static constexpr float MinFontSize = 0.001f; static void PathToPathData(const tgfx::Path& path, PathData* pathData) { for (const auto& segment : path) { @@ -117,7 +119,7 @@ static void CollectVectorGlyph(PAGXDocument* document, const tgfx::Font& font, if (!font.getPath(glyphID, &glyphPath) || glyphPath.isEmpty()) { return; } - if (fontSize < 0.001f) { + if (fontSize < MinFontSize) { return; } float scale = static_cast(VectorFontUnitsPerEm) / fontSize; @@ -167,7 +169,7 @@ static void CollectBitmapGlyph( if (builder.backingSize == 0) { float scaleX = std::abs(imageMatrix.getScaleX()); - if (scaleX < 0.001f) { + if (scaleX < MinFontSize) { return; } builder.backingSize = static_cast(std::round(font.getSize() / scaleX)); @@ -357,7 +359,7 @@ static void CollectSpacingGlyph( auto* typeface = font.getTypeface().get(); GlyphKey key = {typeface, glyphID}; float runFontSize = font.getSize(); - if (runFontSize < 0.001f) { + if (runFontSize < MinFontSize) { return; } auto bitmapIt = bitmapBuilders.find(typeface); @@ -391,6 +393,32 @@ void FontEmbedder::ClearEmbeddedGlyphRuns(PAGXDocument* document) { for (auto* text : textOrder) { text->glyphRuns.clear(); } + + std::unordered_set toRemove = {}; + for (auto& node : document->nodes) { + auto type = node->nodeType(); + if (type == NodeType::Font) { + auto* font = static_cast(node.get()); + for (auto* glyph : font->glyphs) { + toRemove.insert(glyph); + if (glyph->path != nullptr) { + toRemove.insert(glyph->path); + } + if (glyph->image != nullptr) { + toRemove.insert(glyph->image); + } + } + if (!font->file.empty()) { + font->glyphs.clear(); + } else { + toRemove.insert(node.get()); + } + } else if (type == NodeType::GlyphRun) { + toRemove.insert(node.get()); + } + } + document->removeNodes(toRemove); + document->resetLayoutState(); } bool FontEmbedder::embed(PAGXDocument* document) { @@ -444,10 +472,18 @@ bool FontEmbedder::embed(PAGXDocument* document) { } } - // Assign sequential IDs to all fonts + // Assign sequential IDs to all embedded fonts. Skip any index already taken by a user node to + // avoid silently displacing it from the nodeMap. int fontIndex = 1; + auto nextEmbedFontId = [&]() -> std::string { + std::string id; + do { + id = "__embed_font_" + std::to_string(fontIndex++); + } while (document->findNode(id) != nullptr); + return id; + }; if (vectorBuilder.font != nullptr) { - vectorBuilder.font->id = "font" + std::to_string(fontIndex++); + document->setNodeId(vectorBuilder.font, nextEmbedFontId()); } for (auto* typeface : bitmapTypefaces) { if (typeface == nullptr) { @@ -455,7 +491,7 @@ bool FontEmbedder::embed(PAGXDocument* document) { } auto builderIt = bitmapBuilders.find(typeface); if (builderIt != bitmapBuilders.end() && builderIt->second.font != nullptr) { - builderIt->second.font->id = "font" + std::to_string(fontIndex++); + document->setNodeId(builderIt->second.font, nextEmbedFontId()); } } diff --git a/src/renderer/FontEmbedder.h b/src/renderer/FontEmbedder.h index 43f56394f7..7176a62302 100644 --- a/src/renderer/FontEmbedder.h +++ b/src/renderer/FontEmbedder.h @@ -36,9 +36,17 @@ class FontEmbedder { FontEmbedder() = default; /** - * Clears existing embedded GlyphRuns from all Text nodes in the document. Call this before - * applyLayout() when re-embedding a file that already has embedded fonts, so that layout - * performs runtime shaping instead of using stale embedded data. + * Resets previously-embedded font data in the document so it can be re-embedded from scratch. + * Clears the embedded GlyphRuns vector on every Text node and removes previously-installed + * Font nodes (along with their Glyph, PathData, and Image children) plus any orphan GlyphRun + * nodes from document->nodes. Font nodes with a non-empty `file` attribute are preserved + * (only their Glyph children are cleared); Font nodes without `file` are removed entirely. + * Call this before applyLayout() when re-embedding a file that + * already has embedded fonts, so that layout performs runtime shaping instead of using stale + * embedded data. + * + * Warning: any external pointers or IDs referencing the Image or PathData nodes previously + * installed by a font-embed pass will become dangling after this call. */ static void ClearEmbeddedGlyphRuns(PAGXDocument* document); diff --git a/src/renderer/ImageEmbedder.cpp b/src/renderer/ImageEmbedder.cpp new file mode 100644 index 0000000000..cb83ae0b20 --- /dev/null +++ b/src/renderer/ImageEmbedder.cpp @@ -0,0 +1,70 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 "renderer/ImageEmbedder.h" +#include +#include +#include "base/utils/Log.h" +#include "pagx/types/Data.h" + +namespace pagx { + +static constexpr size_t MaxFileSize = 256 * 1024 * 1024; + +static std::shared_ptr ReadFileToData(const std::string& path) { + std::ifstream in(path, std::ios::binary | std::ios::ate); + if (!in.is_open()) return nullptr; + std::streampos end = in.tellg(); + if (end <= 0) return nullptr; // covers tellg failure and empty file + auto size = static_cast(end); + if (size > MaxFileSize) { + LOGE("ReadFileToData: file '%s' exceeds the maximum size limit (%zu bytes).", path.c_str(), + MaxFileSize); + return nullptr; + } + in.seekg(0, std::ios::beg); + auto* buffer = new (std::nothrow) uint8_t[size]; + if (buffer == nullptr) return nullptr; + in.read(reinterpret_cast(buffer), static_cast(size)); + if (!in || static_cast(in.gcount()) != size) { + delete[] buffer; + return nullptr; + } + return pagx::Data::MakeAdopt(buffer, size); +} + +bool ImageEmbedder::embed(PAGXDocument* document) { + if (document == nullptr) return false; + auto paths = document->getExternalFilePaths(); + std::unordered_map> fileDataMap; + for (const auto& path : paths) { + if (fileDataMap.count(path) > 0) { + continue; + } + auto data = ReadFileToData(path); + if (data == nullptr) { + lastErrorPath_ = path; + return false; + } + fileDataMap[path] = data; + } + document->loadFileDataMap(fileDataMap); + return true; +} + +} // namespace pagx diff --git a/src/renderer/ImageEmbedder.h b/src/renderer/ImageEmbedder.h new file mode 100644 index 0000000000..c0b80159b5 --- /dev/null +++ b/src/renderer/ImageEmbedder.h @@ -0,0 +1,57 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 "pagx/PAGXDocument.h" + +namespace pagx { + +/** + * ImageEmbedder reads every external-file Image node in a PAGXDocument and inlines the raw + * bytes via PAGXDocument::loadFileData. Unlike FontEmbedder, it does not require applyLayout() + * and has no ClearEmbeddedGlyphRuns-style helper — Image embedding has no layout dependency. + * + * URL-form paths (containing "://") are silently skipped; production PAGX does not use them. + * + * embed() is all-or-nothing with respect to document state — the document is only + * mutated when all files are successfully read. On failure, no mutations are applied. + */ +class ImageEmbedder { + public: + ImageEmbedder() = default; + + /** + * Embeds every external Image file referenced by the document. Returns true on full + * success. On failure, returns false and sets lastErrorPath() to the first unreadable + * file. + */ + bool embed(PAGXDocument* document); + + /** + * When embed() returns false, identifies which file could not be read. + */ + const std::string& lastErrorPath() const { + return lastErrorPath_; + } + + private: + std::string lastErrorPath_; +}; + +} // namespace pagx diff --git a/test/baseline/version.json b/test/baseline/version.json index e284297125..02f419cfb6 100644 --- a/test/baseline/version.json +++ b/test/baseline/version.json @@ -8724,6 +8724,7 @@ "layout_container_hug": "42abc278f", "layout_container_vertical": "b4fef60e9", "layout_flex": "b4fef60e9", + "layout_flex_weights": "d31df930a", "layout_padding_unified": "720d735b", "layout_textbox_constraint_wrap": "69357df37", "layout_textbox_explicit_height": "3bab0356d", @@ -8752,7 +8753,7 @@ "skills_star_badge": "912722813", "skills_tab_bar": "eb9361216", "skills_text_decoration": "e593ba6e8", - "spec_app_icons": "451bc13e", + "spec_app_icons": "e738016b4", "spec_clip_to_bounds": "b4fef60e9", "spec_color_source_coordinates": "625e411a", "spec_complete_example": "451bc13e", @@ -8768,8 +8769,8 @@ "spec_document_structure": "f1d07bfc7", "spec_ellipse": "f1d07bfc7", "spec_fill": "f1d07bfc7", - "spec_game_hud": "eb9361216", - "spec_glyph_run": "912722813", + "spec_game_hud": "e738016b4", + "spec_glyph_run": "e738016b4", "spec_group": "f1d07bfc7", "spec_group_isolation": "625e411a", "spec_group_propagation": "625e411a", @@ -8796,7 +8797,7 @@ "spec_space_explorer": "eb9361216", "spec_stroke": "9c8fb076", "spec_text": "4d8a9803", - "spec_text_box": "73083086", + "spec_text_box": "e738016b4", "spec_text_modifier": "004b53bf", "spec_text_path": "4d8a9803", "spec_trim_path": "eb9361216", diff --git a/test/src/PAGXCliTest.cpp b/test/src/PAGXCliTest.cpp index 869c255417..3f719b6303 100644 --- a/test/src/PAGXCliTest.cpp +++ b/test/src/PAGXCliTest.cpp @@ -24,6 +24,7 @@ #include #include "base/PAGTest.h" #include "cli/CommandBounds.h" +#include "cli/CommandEmbed.h" #include "cli/CommandExport.h" #include "cli/CommandFont.h" #include "cli/CommandFormat.h" @@ -35,6 +36,8 @@ #include "pagx/PAGXDocument.h" #include "pagx/PAGXExporter.h" #include "pagx/PAGXImporter.h" +#include "pagx/nodes/Font.h" +#include "pagx/nodes/Image.h" #include "tgfx/core/Bitmap.h" #include "tgfx/core/ImageCodec.h" #include "tgfx/core/Pixmap.h" @@ -67,6 +70,14 @@ static std::string CopyToTemp(const std::string& resourceName, const std::string return dst; } +static std::string CopyResourceToTemp(const std::string& resourceRelPath, + const std::string& tempName) { + auto src = ProjectPath::Absolute(resourceRelPath); + auto dst = TempDir() + "/" + tempName; + std::filesystem::copy_file(src, dst, std::filesystem::copy_options::overwrite_existing); + return dst; +} + static bool CompareRenderedImage(const std::string& imagePath, const std::string& key) { auto codec = ImageCodec::MakeFrom(imagePath); if (codec == nullptr) { @@ -102,6 +113,30 @@ static std::string ReadFile(const std::string& path) { return {std::istreambuf_iterator(in), std::istreambuf_iterator()}; } +// RAII helper that redirects an ostream (e.g. std::cout, std::cerr) into an internal +// stringstream for the lifetime of the instance and restores the original buffer on +// destruction. Use str() to read what was captured. +class StreamCapture { + public: + explicit StreamCapture(std::ostream& stream) : stream_(stream), oldBuf_(stream.rdbuf()) { + stream_.rdbuf(captured_.rdbuf()); + } + ~StreamCapture() { + stream_.rdbuf(oldBuf_); + } + StreamCapture(const StreamCapture&) = delete; + StreamCapture& operator=(const StreamCapture&) = delete; + + std::string str() const { + return captured_.str(); + } + + private: + std::ostream& stream_; + std::streambuf* oldBuf_; + std::stringstream captured_; +}; + static std::string ExportToSVG(const std::string& pagxResourceName, const std::string& svgTempName, std::vector extraExportArgs = {}) { auto pagxPath = TestResourcePath(pagxResourceName); @@ -514,36 +549,190 @@ CLI_TEST(PAGXCliTest, Render_XPathNoMatch) { // Font tests //============================================================================== -CLI_TEST(PAGXCliTest, FontInfo_FromFile) { +CLI_TEST(PAGXCliTest, Font_FromFile) { auto fontPath = ProjectPath::Absolute("resources/font/NotoSansSC-Regular.otf"); - auto ret = CallRun(pagx::cli::RunFont, {"font", "info", "--file", fontPath}); + auto ret = CallRun(pagx::cli::RunFont, {"font", "--file", fontPath}); EXPECT_EQ(ret, 0); } -CLI_TEST(PAGXCliTest, FontInfo_JsonOutput) { +CLI_TEST(PAGXCliTest, Font_JsonOutput) { auto fontPath = ProjectPath::Absolute("resources/font/NotoSansSC-Regular.otf"); - auto ret = CallRun(pagx::cli::RunFont, {"font", "info", "--file", fontPath, "--json"}); + auto ret = CallRun(pagx::cli::RunFont, {"font", "--file", fontPath, "--json"}); EXPECT_EQ(ret, 0); } -CLI_TEST(PAGXCliTest, FontInfo_FileNotFound) { - auto ret = CallRun(pagx::cli::RunFont, {"font", "info", "--file", "nonexistent.ttf"}); +CLI_TEST(PAGXCliTest, Font_FileNotFound) { + auto ret = CallRun(pagx::cli::RunFont, {"font", "--file", "nonexistent.ttf"}); EXPECT_NE(ret, 0); } -CLI_TEST(PAGXCliTest, FontInfo_MutualExclusive) { - auto ret = CallRun(pagx::cli::RunFont, {"font", "info", "--file", "x.ttf", "--name", "Arial"}); +CLI_TEST(PAGXCliTest, Font_MutualExclusive) { + auto ret = CallRun(pagx::cli::RunFont, {"font", "--file", "x.ttf", "--name", "Arial"}); EXPECT_NE(ret, 0); } -CLI_TEST(PAGXCliTest, FontInfo_NoSource) { - auto ret = CallRun(pagx::cli::RunFont, {"font", "info"}); +CLI_TEST(PAGXCliTest, Font_NoSource) { + auto ret = CallRun(pagx::cli::RunFont, {"font"}); EXPECT_NE(ret, 0); } +CLI_TEST(PAGXCliTest, FontInfo_Retired_PrintsRedirectError) { + const std::string expected = + "pagx font: 'info' subcommand has been removed, use 'pagx font' instead"; + + // Variant 1: with a positional argument + { + std::streambuf* oldCerr = std::cerr.rdbuf(); + std::ostringstream capturedErr; + std::cerr.rdbuf(capturedErr.rdbuf()); + auto ret = CallRun(pagx::cli::RunFont, {"font", "info", "--file", "x.otf"}); + std::cerr.rdbuf(oldCerr); + EXPECT_EQ(ret, 1); + EXPECT_NE(capturedErr.str().find(expected), std::string::npos); + } + + // Variant 2: no extra arguments + { + std::streambuf* oldCerr = std::cerr.rdbuf(); + std::ostringstream capturedErr; + std::cerr.rdbuf(capturedErr.rdbuf()); + auto ret = CallRun(pagx::cli::RunFont, {"font", "info"}); + std::cerr.rdbuf(oldCerr); + EXPECT_EQ(ret, 1); + EXPECT_NE(capturedErr.str().find(expected), std::string::npos); + } +} + +CLI_TEST(PAGXCliTest, Font_HelpShowsCurrentFlags) { + std::streambuf* oldCout = std::cout.rdbuf(); + std::ostringstream capturedOut; + std::cout.rdbuf(capturedOut.rdbuf()); + auto ret = CallRun(pagx::cli::RunFont, {"font", "--help"}); + std::cout.rdbuf(oldCout); + + EXPECT_EQ(ret, 0); + auto help = capturedOut.str(); + EXPECT_NE(help.find("--list"), std::string::npos); + EXPECT_NE(help.find("--file"), std::string::npos); + EXPECT_NE(help.find("--name"), std::string::npos); + EXPECT_NE(help.find("--size"), std::string::npos); + EXPECT_NE(help.find("--json"), std::string::npos); + EXPECT_EQ(help.find("embed"), std::string::npos); + EXPECT_EQ(help.find(" info "), std::string::npos); + EXPECT_EQ(help.find(" info\n"), std::string::npos); + EXPECT_EQ(help.find("\n info "), std::string::npos); +} + +CLI_TEST(PAGXCliTest, FontList_TextOutput) { + std::streambuf* oldCout = std::cout.rdbuf(); + std::ostringstream capturedOut; + std::cout.rdbuf(capturedOut.rdbuf()); + auto ret = CallRun(pagx::cli::RunFont, {"font", "--list"}); + std::cout.rdbuf(oldCout); + + EXPECT_EQ(ret, 0); + auto out = capturedOut.str(); + EXPECT_FALSE(out.empty()); + EXPECT_NE(out.find('\n'), std::string::npos); + int nonEmptyLines = 0; + size_t start = 0; + while (start < out.size()) { + size_t end = out.find('\n', start); + if (end == std::string::npos) { + end = out.size(); + } + if (end > start) { + std::string line = out.substr(start, end - start); + if (line.find_first_not_of(" \t\r") != std::string::npos) { + ++nonEmptyLines; + } + } + start = end + 1; + } + EXPECT_GE(nonEmptyLines, 2); +} + +CLI_TEST(PAGXCliTest, FontList_JsonOutput) { + std::streambuf* oldCout = std::cout.rdbuf(); + std::ostringstream capturedOut; + std::cout.rdbuf(capturedOut.rdbuf()); + auto ret = CallRun(pagx::cli::RunFont, {"font", "--list", "--json"}); + std::cout.rdbuf(oldCout); + + EXPECT_EQ(ret, 0); + auto out = capturedOut.str(); + EXPECT_FALSE(out.empty()); + auto trimEnd = out.find_last_not_of(" \t\r\n"); + ASSERT_NE(trimEnd, std::string::npos); + auto trimStart = out.find_first_not_of(" \t\r\n"); + ASSERT_NE(trimStart, std::string::npos); + EXPECT_EQ(out[trimStart], '['); + EXPECT_EQ(out[trimEnd], ']'); + EXPECT_NE(out.find("\"family\""), std::string::npos); + EXPECT_NE(out.find("\"styles\""), std::string::npos); +} + +CLI_TEST(PAGXCliTest, FontList_MutualExclusive) { + const std::string expected = "pagx font: --list cannot be combined with --file or --name"; + + // Variant 1: --list + --file + { + std::streambuf* oldCerr = std::cerr.rdbuf(); + std::ostringstream capturedErr; + std::cerr.rdbuf(capturedErr.rdbuf()); + auto ret = CallRun(pagx::cli::RunFont, {"font", "--list", "--file", "x.otf"}); + std::cerr.rdbuf(oldCerr); + EXPECT_EQ(ret, 1); + EXPECT_NE(capturedErr.str().find(expected), std::string::npos); + } + + // Variant 2: --list + --name + { + std::streambuf* oldCerr = std::cerr.rdbuf(); + std::ostringstream capturedErr; + std::cerr.rdbuf(capturedErr.rdbuf()); + auto ret = CallRun(pagx::cli::RunFont, {"font", "--list", "--name", "Arial"}); + std::cerr.rdbuf(oldCerr); + EXPECT_EQ(ret, 1); + EXPECT_NE(capturedErr.str().find(expected), std::string::npos); + } +} + CLI_TEST(PAGXCliTest, Font_UnknownSubcommand) { + std::streambuf* oldCerr = std::cerr.rdbuf(); + std::ostringstream capturedErr; + std::cerr.rdbuf(capturedErr.rdbuf()); auto ret = CallRun(pagx::cli::RunFont, {"font", "xyz"}); + std::cerr.rdbuf(oldCerr); EXPECT_NE(ret, 0); + EXPECT_NE(capturedErr.str().find("pagx font: unknown subcommand 'xyz'"), std::string::npos); +} + +CLI_TEST(PAGXCliTest, FontEmbed_Retired_PrintsRedirectError) { + const std::string expected = + "pagx font: 'embed' subcommand has been removed, use 'pagx embed' instead"; + + // Variant 1: with a positional argument + { + std::streambuf* oldCerr = std::cerr.rdbuf(); + std::ostringstream capturedErr; + std::cerr.rdbuf(capturedErr.rdbuf()); + auto ret = CallRun(pagx::cli::RunFont, {"font", "embed", "some.pagx"}); + std::cerr.rdbuf(oldCerr); + EXPECT_EQ(ret, 1); + EXPECT_NE(capturedErr.str().find(expected), std::string::npos); + } + + // Variant 2: no extra arguments + { + std::streambuf* oldCerr = std::cerr.rdbuf(); + std::ostringstream capturedErr; + std::cerr.rdbuf(capturedErr.rdbuf()); + auto ret = CallRun(pagx::cli::RunFont, {"font", "embed"}); + std::cerr.rdbuf(oldCerr); + EXPECT_EQ(ret, 1); + EXPECT_NE(capturedErr.str().find(expected), std::string::npos); + } } //============================================================================== @@ -2752,4 +2941,283 @@ CLI_TEST(PAGXCliTest, Verify_PainterLeakClean) { EXPECT_EQ(output.find("painter leaks geometry"), std::string::npos); } +//============================================================================== +// Embed tests +//============================================================================== + +CLI_TEST(PAGXCliTest, Embed_BothDefault_EmbedsFontsAndImages) { + auto tempPagx = CopyToTemp("embed_sample.pagx", "embed_sample.pagx"); + auto tempPng = CopyResourceToTemp("resources/apitest/image_as_mask.png", "image_as_mask.png"); + auto outPagx = TempDir() + "/embed_both_out.pagx"; + // EMBED-09 implicitly covered: embed_sample.pagx references image_as_mask.png by relative path; + // resolution happens at PAGXImporter::FromFile load time per D1.2. + std::streambuf* oldCout = std::cout.rdbuf(); + std::ostringstream capturedOut; + std::cout.rdbuf(capturedOut.rdbuf()); + auto ret = CallRun(pagx::cli::RunEmbed, {"embed", tempPagx, "-o", outPagx}); + std::cout.rdbuf(oldCout); + EXPECT_EQ(ret, 0); + EXPECT_NE(capturedOut.str().find("pagx embed: wrote"), std::string::npos); + auto document = pagx::PAGXImporter::FromFile(outPagx); + ASSERT_NE(document, nullptr); + EXPECT_TRUE(document->getExternalFilePaths().empty()); + bool hasImageData = false; + bool hasFontNode = false; + for (auto& node : document->nodes) { + if (node->nodeType() == pagx::NodeType::Image) { + auto* image = static_cast(node.get()); + if (image->data != nullptr) { + hasImageData = true; + } + } + if (node->nodeType() == pagx::NodeType::Font) { + hasFontNode = true; + } + } + EXPECT_TRUE(hasImageData); + EXPECT_TRUE(hasFontNode); +} + +CLI_TEST(PAGXCliTest, Embed_SkipFonts_ImagesOnly) { + auto tempPagx = CopyToTemp("embed_sample.pagx", "embed_sample.pagx"); + auto tempPng = CopyResourceToTemp("resources/apitest/image_as_mask.png", "image_as_mask.png"); + auto outPagx = TempDir() + "/embed_skipfonts_out.pagx"; + auto ret = CallRun(pagx::cli::RunEmbed, {"embed", "--skip-fonts", tempPagx, "-o", outPagx}); + EXPECT_EQ(ret, 0); + auto document = pagx::PAGXImporter::FromFile(outPagx); + ASSERT_NE(document, nullptr); + bool hasImageData = false; + bool hasFontNode = false; + for (auto& node : document->nodes) { + if (node->nodeType() == pagx::NodeType::Image) { + auto* image = static_cast(node.get()); + if (image->data != nullptr) { + hasImageData = true; + } + } + if (node->nodeType() == pagx::NodeType::Font) { + hasFontNode = true; + } + } + EXPECT_TRUE(hasImageData); + EXPECT_FALSE(hasFontNode); +} + +CLI_TEST(PAGXCliTest, Embed_SkipImages_FontsOnly) { + auto tempPagx = CopyToTemp("embed_sample.pagx", "embed_sample.pagx"); + auto tempPng = CopyResourceToTemp("resources/apitest/image_as_mask.png", "image_as_mask.png"); + auto outPagx = TempDir() + "/embed_skipimgs_out.pagx"; + auto ret = CallRun(pagx::cli::RunEmbed, {"embed", "--skip-images", tempPagx, "-o", outPagx}); + EXPECT_EQ(ret, 0); + auto document = pagx::PAGXImporter::FromFile(outPagx); + ASSERT_NE(document, nullptr); + bool hasFilePath = false; + bool hasNoImageData = true; + bool hasFontNode = false; + for (auto& node : document->nodes) { + if (node->nodeType() == pagx::NodeType::Image) { + auto* image = static_cast(node.get()); + if (!image->filePath.empty()) { + hasFilePath = true; + } + if (image->data != nullptr) { + hasNoImageData = false; + } + } + if (node->nodeType() == pagx::NodeType::Font) { + hasFontNode = true; + } + } + EXPECT_TRUE(hasFilePath); + EXPECT_TRUE(hasNoImageData); + EXPECT_TRUE(hasFontNode); +} + +CLI_TEST(PAGXCliTest, Embed_BothSkipFlags_ExitsWithError) { + auto tempPagx = CopyToTemp("embed_sample.pagx", "embed_sample.pagx"); + auto tempPng = CopyResourceToTemp("resources/apitest/image_as_mask.png", "image_as_mask.png"); + auto contentBefore = ReadFile(tempPagx); + std::streambuf* oldCerr = std::cerr.rdbuf(); + std::ostringstream capturedErr; + std::cerr.rdbuf(capturedErr.rdbuf()); + auto ret = CallRun(pagx::cli::RunEmbed, {"embed", "--skip-fonts", "--skip-images", tempPagx}); + std::cerr.rdbuf(oldCerr); + EXPECT_EQ(ret, 1); + EXPECT_NE(capturedErr.str().find("pagx embed: --skip-fonts and --skip-images cannot both be set"), + std::string::npos); + auto contentAfter = ReadFile(tempPagx); + EXPECT_EQ(contentBefore, contentAfter); +} + +CLI_TEST(PAGXCliTest, Embed_FontFile_AutoRegistersAndEmbeds) { + auto tempPagx = CopyToTemp("embed_font_file.pagx", "embed_font_file.pagx"); + CopyResourceToTemp("resources/font/NotoSansSC-Regular.otf", "NotoSansSC-Regular.otf"); + auto outPagx = TempDir() + "/embed_fontfile_out.pagx"; + StreamCapture outCapture(std::cout); + auto ret = CallRun(pagx::cli::RunEmbed, {"embed", tempPagx, "-o", outPagx}); + EXPECT_EQ(ret, 0); + EXPECT_NE(outCapture.str().find("pagx embed: wrote"), std::string::npos); + auto document = pagx::PAGXImporter::FromFile(outPagx); + ASSERT_NE(document, nullptr); + bool hasFileFont = false; + bool hasEmbedFont = false; + for (auto& node : document->nodes) { + if (node->nodeType() == pagx::NodeType::Font) { + auto* font = static_cast(node.get()); + if (!font->file.empty()) { + hasFileFont = true; + EXPECT_TRUE(font->id == "noto"); + // fileOriginal should be preserved; the resolved absolute path must not be written out. + EXPECT_EQ(font->fileOriginal, "NotoSansSC-Regular.otf") + << "fileOriginal should retain the verbatim relative path from the source XML"; + } + if (font->id.find("__embed_font_") == 0) { + hasEmbedFont = true; + } + } + } + EXPECT_TRUE(hasFileFont); + EXPECT_TRUE(hasEmbedFont); +} + +CLI_TEST(PAGXCliTest, Embed_FileFlag_RejectedAsUnknown) { + auto tempPagx = CopyToTemp("embed_sample.pagx", "embed_sample.pagx"); + StreamCapture errCapture(std::cerr); + auto ret = CallRun(pagx::cli::RunEmbed, {"embed", "--file", "font.ttf", tempPagx}); + EXPECT_EQ(ret, 1); + EXPECT_NE(errCapture.str().find("pagx embed: unknown option"), std::string::npos); +} + +CLI_TEST(PAGXCliTest, Embed_FontFileImport_RoundTrips) { + // Import a PAGX with Font(file) — verify file path survives import without existence check + // (D-05: no file existence check during import). + auto path = TestResourcePath("embed_font_file.pagx"); + auto document = pagx::PAGXImporter::FromFile(path); + ASSERT_NE(document, nullptr); + bool foundFileFont = false; + for (auto& node : document->nodes) { + if (node->nodeType() == pagx::NodeType::Font) { + auto* font = static_cast(node.get()); + if (!font->file.empty()) { + foundFileFont = true; + EXPECT_NE(font->file.find("NotoSansSC-Regular"), std::string::npos) + << "Font::file should contain the resolved font filename"; + } + } + } + EXPECT_TRUE(foundFileFont); +} + +CLI_TEST(PAGXCliTest, Embed_MissingFontFile_FailsLoud) { + auto tempPagx = CopyToTemp("embed_font_file.pagx", "embed_font_file.pagx"); + auto content = ReadFile(tempPagx); + auto pos = content.find("NotoSansSC-Regular.otf"); + ASSERT_NE(pos, std::string::npos); + content.replace(pos, std::strlen("NotoSansSC-Regular.otf"), "nonexistent_font.otf"); + std::ofstream out(tempPagx); + out << content; + out.close(); + auto outPagx = TempDir() + "/embed_missing_font_out.pagx"; + StreamCapture errCapture(std::cerr); + auto ret = CallRun(pagx::cli::RunEmbed, {"embed", tempPagx, "-o", outPagx}); + EXPECT_EQ(ret, 1); + EXPECT_NE(errCapture.str().find("pagx embed: failed to load font '"), std::string::npos); + EXPECT_FALSE(std::filesystem::exists(outPagx)); +} + +CLI_TEST(PAGXCliTest, Embed_FontFile_ReembedPreservesNode) { + auto tempPagx = CopyToTemp("embed_font_file.pagx", "embed_font_file.pagx"); + CopyResourceToTemp("resources/font/NotoSansSC-Regular.otf", "NotoSansSC-Regular.otf"); + auto pass1 = TempDir() + "/embed_reembed_pass1.pagx"; + auto pass2 = TempDir() + "/embed_reembed_pass2.pagx"; + auto ret1 = CallRun(pagx::cli::RunEmbed, {"embed", tempPagx, "-o", pass1}); + EXPECT_EQ(ret1, 0); + auto ret2 = CallRun(pagx::cli::RunEmbed, {"embed", pass1, "-o", pass2}); + EXPECT_EQ(ret2, 0); + auto doc2 = pagx::PAGXImporter::FromFile(pass2); + ASSERT_NE(doc2, nullptr); + bool hasFileFont = false; + for (auto& node : doc2->nodes) { + if (node->nodeType() == pagx::NodeType::Font) { + auto* font = static_cast(node.get()); + if (!font->file.empty()) { + hasFileFont = true; + EXPECT_TRUE(font->id == "noto"); + // fileOriginal must survive the second embed round-trip unchanged. + EXPECT_EQ(font->fileOriginal, "NotoSansSC-Regular.otf") + << "fileOriginal must not become absolute after a second embed round-trip"; + } + } + } + EXPECT_TRUE(hasFileFont); +} + +CLI_TEST(PAGXCliTest, Embed_AlreadyEmbeddedImage_IsNoOp) { + auto tempPagx = CopyToTemp("embed_sample.pagx", "embed_sample.pagx"); + auto tempPng = CopyResourceToTemp("resources/apitest/image_as_mask.png", "image_as_mask.png"); + auto out1 = TempDir() + "/embed_idempot_pass1.pagx"; + auto out2 = TempDir() + "/embed_idempot_pass2.pagx"; + auto ret1 = CallRun(pagx::cli::RunEmbed, {"embed", tempPagx, "-o", out1}); + EXPECT_EQ(ret1, 0); + auto ret2 = CallRun(pagx::cli::RunEmbed, {"embed", out1, "-o", out2}); + EXPECT_EQ(ret2, 0); + EXPECT_EQ(ReadFile(out1), ReadFile(out2)); +} + +CLI_TEST(PAGXCliTest, Embed_MissingImage_FailsLoud) { + auto tempPagx = CopyToTemp("embed_sample.pagx", "embed_missing.pagx"); + auto content = ReadFile(tempPagx); + auto pos = content.find("image_as_mask.png"); + ASSERT_NE(pos, std::string::npos); + content.replace(pos, strlen("image_as_mask.png"), "missing.png"); + std::ofstream out(tempPagx); + out << content; + out.close(); + auto outPagx = TempDir() + "/embed_missing_out.pagx"; + std::streambuf* oldCerr = std::cerr.rdbuf(); + std::ostringstream capturedErr; + std::cerr.rdbuf(capturedErr.rdbuf()); + auto ret = CallRun(pagx::cli::RunEmbed, {"embed", tempPagx, "-o", outPagx}); + std::cerr.rdbuf(oldCerr); + EXPECT_EQ(ret, 1); + EXPECT_NE(capturedErr.str().find("pagx embed: failed to load image '"), std::string::npos); + EXPECT_FALSE(std::filesystem::exists(outPagx)); +} + +CLI_TEST(PAGXCliTest, Embed_Success_PrintsWroteAndExitsZero) { + auto tempPagx = CopyToTemp("embed_sample.pagx", "embed_sample.pagx"); + auto tempPng = CopyResourceToTemp("resources/apitest/image_as_mask.png", "image_as_mask.png"); + auto outPagx = TempDir() + "/embed_success_out.pagx"; + std::streambuf* oldCout = std::cout.rdbuf(); + std::ostringstream capturedOut; + std::cout.rdbuf(capturedOut.rdbuf()); + auto ret = CallRun(pagx::cli::RunEmbed, {"embed", tempPagx, "-o", outPagx}); + std::cout.rdbuf(oldCout); + EXPECT_EQ(ret, 0); + auto output = capturedOut.str(); + EXPECT_NE(output.find("pagx embed: wrote"), std::string::npos); + EXPECT_NE(output.find(outPagx), std::string::npos); +} + +CLI_TEST(PAGXCliTest, Embed_Usage_NoInputErrors_HelpPrints) { + std::streambuf* oldCerr = std::cerr.rdbuf(); + std::ostringstream capturedErr; + std::cerr.rdbuf(capturedErr.rdbuf()); + auto ret = CallRun(pagx::cli::RunEmbed, {"embed"}); + std::cerr.rdbuf(oldCerr); + EXPECT_EQ(ret, 1); + EXPECT_NE(capturedErr.str().find("pagx embed: missing input file"), std::string::npos); + + std::streambuf* oldCout = std::cout.rdbuf(); + std::ostringstream capturedOut; + std::cout.rdbuf(capturedOut.rdbuf()); + auto helpRet = CallRun(pagx::cli::RunEmbed, {"embed", "--help"}); + std::cout.rdbuf(oldCout); + EXPECT_EQ(helpRet, 0); + auto helpOutput = capturedOut.str(); + EXPECT_NE(helpOutput.find("Usage: pagx embed"), std::string::npos); + EXPECT_NE(helpOutput.find("--skip-fonts"), std::string::npos); + EXPECT_NE(helpOutput.find("--skip-images"), std::string::npos); +} + } // namespace pag