diff --git a/.codebuddy/commands/cr.md b/.codebuddy/commands/cr.md index 51d1421a19..885990b399 100644 --- a/.codebuddy/commands/cr.md +++ b/.codebuddy/commands/cr.md @@ -67,8 +67,8 @@ done # 拉取最新的 main 分支 git fetch origin main -# 当前分支相对 origin/main 的完整变更(已提交 + 暂存区 + 工作区的最终结果) -git diff origin/main +# 当前分支相对 origin/main 分叉点的完整变更(已提交 + 暂存区 + 工作区的最终结果) +git diff $(git merge-base origin/main HEAD) # 查看文件状态(用于识别未跟踪文件) git status @@ -105,8 +105,8 @@ cd /tmp/pr-review-{pr_number} # 拉取最新的 main 分支 git fetch origin main -# 当前分支相对 origin/main 的完整变更 -git diff origin/main +# 当前分支相对 origin/main 分叉点的完整变更 +git diff $(git merge-base origin/main HEAD) gh pr view {pr_number} --comments ``` @@ -233,3 +233,4 @@ echo "已清理临时审查环境" 9. **潜在风险**:是否引入回归风险或影响其他模块 10. **模块架构**:模块职责是否清晰,依赖方向是否合理(如核心模块不应反向依赖平台特定实现) 11. **整体设计**:结合关联代码评估修改后的整体合理性,必要时建议扩大修改范围 + diff --git a/.codebuddy/commands/pr.md b/.codebuddy/commands/pr.md index 0c5a8edb4c..b95efe0c0c 100644 --- a/.codebuddy/commands/pr.md +++ b/.codebuddy/commands/pr.md @@ -11,7 +11,6 @@ description: 提交 PR - 自动识别新建或追加提交 ## 前置检查 ```bash -git fetch origin main && \ CURRENT_BRANCH=$(git branch --show-current) && \ echo "CURRENT_BRANCH:$CURRENT_BRANCH" && \ gh pr list --head "$CURRENT_BRANCH" --state open --json number,url && \ diff --git a/.codebuddy/rules/Code.md b/.codebuddy/rules/Code.md index 40e92dcafe..4b20858f15 100644 --- a/.codebuddy/rules/Code.md +++ b/.codebuddy/rules/Code.md @@ -6,8 +6,8 @@ alwaysApply: true ## 编码规范 - 对话用中文,代码和注释用英语 -- 所有说明文件、方案设计文件统一放在 `.codebuddy/designs/` 目录(已加入 .gitignore,不参与提交) -- 新需求禁止直接编码,先输出详细方案设计文件,穷尽疑问向用户提问,用户明确确认后再按方案编码 +- 永远优先追求答案的正确性而不只是迎合用户的具体要求,特别是当用户的具体要求和正确性有冲突时要及时指出来 +- 新需求的编码任务禁止直接编码,先输出关键接口和伪代码,穷尽所有疑问向用户提问,方案确认后才可编码 - 复用项目已有功能,保持变更简洁,避免重复代码 - 重构时审查关联代码合理性,顺带清理冗余,不考虑向后兼容 - 版权声明里的年份对新增的文件要使用当前年份(如 `Copyright (C) 2026 Tencent`),已有文件保持原年份不变 diff --git a/.codebuddy/skills/pagx-optimize/SKILL.md b/.codebuddy/skills/pagx-optimize/SKILL.md new file mode 100644 index 0000000000..b2de4710f0 --- /dev/null +++ b/.codebuddy/skills/pagx-optimize/SKILL.md @@ -0,0 +1,122 @@ +--- +name: pagx-optimize +description: Optimize PAGX file structure by removing redundancy, merging shared painters, extracting reusable resources, and improving readability. Use this skill when asked to optimize, simplify, or clean up a PAGX file. +argument-hint: "[file-path]" +--- + +# PAGX File Structure Optimization + +This skill provides a systematic checklist for optimizing PAGX file structure. The goals are +to simplify file structure, reduce file size, and improve rendering performance. + +## Fundamental Constraint + +**All optimizations must preserve the original design appearance.** This is a hard requirement +that overrides any individual optimization direction below. + +- **Allowed**: Structural transformations that produce identical or near-identical rendering + (minor pixel-level differences from node reordering or painter merging are acceptable). +- **Allowed**: Removing provably invisible content — elements entirely outside the canvas + bounds, unused resources, zero-width strokes, fully transparent elements, and other content + that contributes nothing to the final rendered image. +- **Forbidden**: Modifying any parameter that affects design intent — blur radius, spacing, + density, colors, alpha, gradient stops, font sizes, shadow offsets, stroke widths, or any + other visual attribute — unless the user explicitly approves the change. +- **Forbidden**: Reducing Repeater density (increasing spacing), lowering blur values, changing + opacity, or simplifying geometry in ways that alter the visual result, even if the change + seems minor. These are design decisions, not optimization decisions. +- **When in doubt**: If an optimization might change the rendered appearance, do not apply it. + Instead, describe the potential optimization and its visual impact to the user, and ask for + explicit approval before proceeding. + +--- + +## Optimization Checklist + +### Structure Cleanup (Sections 1-7) + +| # | Optimization | When to Apply | +|---|--------------|---------------| +| 1 | Move Resources to End | `` appears before layer tree | +| 2 | Remove Empty Elements | Empty ``, empty ``, `width="0"` strokes | +| 3 | Omit Default Values | Attributes explicitly set to spec default | +| 4 | Simplify Transforms | Translation-only matrix, identity matrix, cascaded translations | +| 5 | Normalize Numerics | Scientific notation near zero, trailing decimals, short hex | +| 6 | Remove Unused Resources | Resource `id` has no `@id` reference | +| 7 | Remove Redundant Wrappers | Group/Layer with no attributes wrapping single element | + +> For detailed examples and default value tables, read `references/structure-cleanup.md`. + +### Painter Merging (Sections 8-9) + +| # | Optimization | When to Apply | +|---|--------------|---------------| +| 8 | Merge Geometry Sharing Identical Painters | Multiple geometry elements use same Fill/Stroke | +| 9 | Merge Painters on Identical Geometry | Same geometry appears twice with different painters | + +**Critical caveat (Section 8)**: Different geometry needing different painters must be isolated +with Groups. This is the most common source of errors. + +> For detailed examples and scope isolation patterns, read `references/painter-merging.md`. + +### Resource Reuse (Sections 10-14) + +| # | Optimization | When to Apply | +|---|--------------|---------------| +| 10 | Composition Reuse | 2+ identical layer subtrees differing only in position | +| 11 | PathData Reuse | Same path data string appears 2+ times | +| 12 | Color Source Sharing | Identical gradient definitions inline in multiple places | +| 13 | Replace Path with Primitive | Path describes a Rectangle or Ellipse | +| 14 | Remove Full-Canvas Clips | Clip mask covers entire canvas | + +> For detailed examples and coordinate conversion formulas, read `references/resource-reuse.md`. + +### Performance Optimization (Section 15) + +**Rendering Cost Model**: +- **Repeater**: N copies = N× full rendering cost (no GPU instancing) +- **Nested Repeaters**: Multiplicative (A×B elements) +- **BlurFilter / DropShadowStyle**: Cost proportional to blur radius +- **Dashed Stroke under Repeater**: Dash computation per copy +- **Layer vs Group**: Layer creates extra surface; Group is lighter + +**Two categories**: +1. **Equivalent (auto-apply)**: Downgrade Layer to Group, clip Repeater to canvas bounds +2. **Suggestions (never auto-apply)**: Reduce density, lower blur, simplify geometry + +> For detailed optimization techniques, read `references/performance.md`. + +--- + +## Appendix: Core Concepts + +### Painter Scope + +Painters (Fill / Stroke) render **all geometry accumulated in the current scope up to that +painter's position**. Subsequent painters continue to render the same geometry. + +### Group Scope Isolation + +Group creates an isolated scope. Internal geometry accumulates only within the Group, and +after the Group ends, its geometry propagates upward to the parent scope. + +### Layer vs Group + +| Feature | Layer | Group | +|---------|-------|-------| +| Geometry propagation | No (boundary) | Yes (to parent) | +| Styles / Filters / Mask | Supported | Not supported | +| Composition / BlendMode | Supported | Not supported | +| Transform | matrix | position/rotation/scale | + +**Selection rule**: Use Layer for styles/filters/mask/composition/blendMode. Otherwise prefer Group. + +> For DropShadowStyle scope and color source coordinate system, see `references/painter-merging.md` +> and `references/resource-reuse.md` respectively. + +--- + +## PAGX Specification Quick Reference + +For complete attribute defaults, required attributes, and enumeration values extracted from +the PAGX spec, see `references/pagx-quick-reference.md`. diff --git a/.codebuddy/skills/pagx-optimize/references/pagx-quick-reference.md b/.codebuddy/skills/pagx-optimize/references/pagx-quick-reference.md new file mode 100644 index 0000000000..14e430a7cf --- /dev/null +++ b/.codebuddy/skills/pagx-optimize/references/pagx-quick-reference.md @@ -0,0 +1,431 @@ +# PAGX Quick Reference + +Back to main: [SKILL.md](../SKILL.md) + +This file contains complete attribute defaults and enumeration values extracted from the PAGX +specification (Appendix C). Use this for quick lookup during optimization. + +--- + +## Required Attributes (No Default — Never Omit) + +These attributes are marked `(required)` in the spec. Even if their value is `0` or `0,0`, +they **must not** be omitted. + +| Element | Required Attributes | +|---------|---------------------| +| **pagx** | `version`, `width`, `height` | +| **Composition** | `width`, `height` | +| **Image** | `source` | +| **PathData** | `data` | +| **Glyph** | `advance` | +| **SolidColor** | `color` | +| **LinearGradient** | `startPoint`, `endPoint` | +| **RadialGradient** | `radius` | +| **DiamondGradient** | `radius` | +| **ColorStop** | `offset`, `color` | +| **ImagePattern** | `image` | +| **BlurFilter** | `blurX`, `blurY` | +| **BlendFilter** | `color` | +| **ColorMatrixFilter** | `matrix` | +| **Path** | `data` | +| **GlyphRun** | `font`, `glyphs` | +| **TextPath** | `path` | + +--- + +## Complete Default Values by Element + +### Layer + +| Attribute | Type | Default | +|-----------|------|---------| +| `name` | string | "" | +| `visible` | bool | true | +| `alpha` | float | 1 | +| `blendMode` | BlendMode | normal | +| `x` | float | 0 | +| `y` | float | 0 | +| `matrix` | Matrix | identity | +| `preserve3D` | bool | false | +| `antiAlias` | bool | true | +| `groupOpacity` | bool | false | +| `passThroughBackground` | bool | true | +| `excludeChildEffectsInLayerStyle` | bool | false | +| `maskType` | MaskType | alpha | + +### Group + +| Attribute | Type | Default | +|-----------|------|---------| +| `anchor` | Point | 0,0 | +| `position` | Point | 0,0 | +| `rotation` | float | 0 | +| `scale` | Point | 1,1 | +| `skew` | float | 0 | +| `skewAxis` | float | 0 | +| `alpha` | float | 1 | + +### Rectangle + +| Attribute | Type | Default | +|-----------|------|---------| +| `center` | Point | 0,0 | +| `size` | Size | 100,100 | +| `roundness` | float | 0 | +| `reversed` | bool | false | + +### Ellipse + +| Attribute | Type | Default | +|-----------|------|---------| +| `center` | Point | 0,0 | +| `size` | Size | 100,100 | +| `reversed` | bool | false | + +### Polystar + +| Attribute | Type | Default | +|-----------|------|---------| +| `center` | Point | 0,0 | +| `type` | PolystarType | star | +| `pointCount` | float | 5 | +| `outerRadius` | float | 100 | +| `innerRadius` | float | 50 | +| `rotation` | float | 0 | +| `outerRoundness` | float | 0 | +| `innerRoundness` | float | 0 | +| `reversed` | bool | false | + +### Path + +| Attribute | Type | Default | +|-----------|------|---------| +| `data` | string/idref | (required) | +| `reversed` | bool | false | + +### Fill + +| Attribute | Type | Default | +|-----------|------|---------| +| `color` | Color/idref | #000000 | +| `alpha` | float | 1 | +| `blendMode` | BlendMode | normal | +| `fillRule` | FillRule | winding | +| `placement` | LayerPlacement | background | + +### Stroke + +| Attribute | Type | Default | +|-----------|------|---------| +| `color` | Color/idref | #000000 | +| `width` | float | 1 | +| `alpha` | float | 1 | +| `blendMode` | BlendMode | normal | +| `cap` | LineCap | butt | +| `join` | LineJoin | miter | +| `miterLimit` | float | 4 | +| `dashOffset` | float | 0 | +| `dashAdaptive` | bool | false | +| `align` | StrokeAlign | center | +| `placement` | LayerPlacement | background | + +### Repeater + +| Attribute | Type | Default | +|-----------|------|---------| +| `copies` | float | 3 | +| `offset` | float | 0 | +| `order` | RepeaterOrder | belowOriginal | +| `anchor` | Point | 0,0 | +| `position` | Point | 100,100 | +| `rotation` | float | 0 | +| `scale` | Point | 1,1 | +| `startAlpha` | float | 1 | +| `endAlpha` | float | 1 | + +### TrimPath + +| Attribute | Type | Default | +|-----------|------|---------| +| `start` | float | 0 | +| `end` | float | 1 | +| `offset` | float | 0 | +| `type` | TrimType | separate | + +### RoundCorner + +| Attribute | Type | Default | +|-----------|------|---------| +| `radius` | float | 10 | + +### MergePath + +| Attribute | Type | Default | +|-----------|------|---------| +| `mode` | MergePathOp | append | + +### LinearGradient + +| Attribute | Type | Default | +|-----------|------|---------| +| `startPoint` | Point | (required) | +| `endPoint` | Point | (required) | +| `matrix` | Matrix | identity | + +### RadialGradient + +| Attribute | Type | Default | +|-----------|------|---------| +| `center` | Point | 0,0 | +| `radius` | float | (required) | +| `matrix` | Matrix | identity | + +### ConicGradient + +| Attribute | Type | Default | +|-----------|------|---------| +| `center` | Point | 0,0 | +| `startAngle` | float | 0 | +| `endAngle` | float | 360 | +| `matrix` | Matrix | identity | + +### DiamondGradient + +| Attribute | Type | Default | +|-----------|------|---------| +| `center` | Point | 0,0 | +| `radius` | float | (required) | +| `matrix` | Matrix | identity | + +### ImagePattern + +| Attribute | Type | Default | +|-----------|------|---------| +| `image` | idref | (required) | +| `tileModeX` | TileMode | clamp | +| `tileModeY` | TileMode | clamp | +| `filterMode` | FilterMode | linear | +| `mipmapMode` | MipmapMode | linear | +| `matrix` | Matrix | identity | + +### DropShadowStyle + +| Attribute | Type | Default | +|-----------|------|---------| +| `offsetX` | float | 0 | +| `offsetY` | float | 0 | +| `blurX` | float | 0 | +| `blurY` | float | 0 | +| `color` | Color | #000000 | +| `showBehindLayer` | bool | true | +| `blendMode` | BlendMode | normal | + +### InnerShadowStyle + +| Attribute | Type | Default | +|-----------|------|---------| +| `offsetX` | float | 0 | +| `offsetY` | float | 0 | +| `blurX` | float | 0 | +| `blurY` | float | 0 | +| `color` | Color | #000000 | +| `blendMode` | BlendMode | normal | + +### BackgroundBlurStyle + +| Attribute | Type | Default | +|-----------|------|---------| +| `blurX` | float | 0 | +| `blurY` | float | 0 | +| `tileMode` | TileMode | mirror | +| `blendMode` | BlendMode | normal | + +### BlurFilter + +| Attribute | Type | Default | +|-----------|------|---------| +| `blurX` | float | (required) | +| `blurY` | float | (required) | +| `tileMode` | TileMode | decal | + +### DropShadowFilter + +| Attribute | Type | Default | +|-----------|------|---------| +| `offsetX` | float | 0 | +| `offsetY` | float | 0 | +| `blurX` | float | 0 | +| `blurY` | float | 0 | +| `color` | Color | #000000 | +| `shadowOnly` | bool | false | + +### InnerShadowFilter + +| Attribute | Type | Default | +|-----------|------|---------| +| `offsetX` | float | 0 | +| `offsetY` | float | 0 | +| `blurX` | float | 0 | +| `blurY` | float | 0 | +| `color` | Color | #000000 | +| `shadowOnly` | bool | false | + +### BlendFilter + +| Attribute | Type | Default | +|-----------|------|---------| +| `color` | Color | (required) | +| `blendMode` | BlendMode | normal | + +### Font + +| Attribute | Type | Default | +|-----------|------|---------| +| `unitsPerEm` | int | 1000 | + +### Glyph + +| Attribute | Type | Default | +|-----------|------|---------| +| `advance` | float | (required) | +| `offset` | Point | 0,0 | + +### Text + +| Attribute | Type | Default | +|-----------|------|---------| +| `text` | string | "" | +| `position` | Point | 0,0 | +| `fontFamily` | string | system default | +| `fontStyle` | string | "Regular" | +| `fontSize` | float | 12 | +| `letterSpacing` | float | 0 | +| `baselineShift` | float | 0 | + +### GlyphRun + +| Attribute | Type | Default | +|-----------|------|---------| +| `font` | idref | (required) | +| `fontSize` | float | 12 | +| `glyphs` | string | (required) | +| `x` | float | 0 | +| `y` | float | 0 | + +### TextModifier + +| Attribute | Type | Default | +|-----------|------|---------| +| `anchor` | Point | 0,0 | +| `position` | Point | 0,0 | +| `rotation` | float | 0 | +| `scale` | Point | 1,1 | +| `skew` | float | 0 | +| `skewAxis` | float | 0 | +| `alpha` | float | 1 | + +### RangeSelector + +| Attribute | Type | Default | +|-----------|------|---------| +| `start` | float | 0 | +| `end` | float | 1 | +| `offset` | float | 0 | +| `unit` | SelectorUnit | percentage | +| `shape` | SelectorShape | square | +| `easeIn` | float | 0 | +| `easeOut` | float | 0 | +| `mode` | SelectorMode | add | +| `weight` | float | 1 | +| `randomOrder` | bool | false | +| `randomSeed` | int | 0 | + +### TextPath + +| Attribute | Type | Default | +|-----------|------|---------| +| `path` | string/idref | (required) | +| `baselineOrigin` | Point | 0,0 | +| `baselineAngle` | float | 0 | +| `firstMargin` | float | 0 | +| `lastMargin` | float | 0 | +| `perpendicular` | bool | true | +| `reversed` | bool | false | +| `forceAlignment` | bool | false | + +### TextLayout + +| Attribute | Type | Default | +|-----------|------|---------| +| `position` | Point | 0,0 | +| `width` | float | auto | +| `height` | float | auto | +| `textAlign` | TextAlign | start | +| `verticalAlign` | VerticalAlign | top | +| `writingMode` | WritingMode | horizontal | +| `lineHeight` | float | 1.2 | + +--- + +## Enumeration Types + +### Layer Related + +| Enum | Values | +|------|--------| +| **BlendMode** | `normal`, `multiply`, `screen`, `overlay`, `darken`, `lighten`, `colorDodge`, `colorBurn`, `hardLight`, `softLight`, `difference`, `exclusion`, `hue`, `saturation`, `color`, `luminosity`, `plusLighter`, `plusDarker` | +| **MaskType** | `alpha`, `luminance`, `contour` | +| **TileMode** | `clamp`, `repeat`, `mirror`, `decal` | +| **FilterMode** | `nearest`, `linear` | +| **MipmapMode** | `none`, `nearest`, `linear` | + +### Painter Related + +| Enum | Values | +|------|--------| +| **FillRule** | `winding`, `evenOdd` | +| **LineCap** | `butt`, `round`, `square` | +| **LineJoin** | `miter`, `round`, `bevel` | +| **StrokeAlign** | `center`, `inside`, `outside` | +| **LayerPlacement** | `background`, `foreground` | + +### Geometry Element Related + +| Enum | Values | +|------|--------| +| **PolystarType** | `polygon`, `star` | + +### Modifier Related + +| Enum | Values | +|------|--------| +| **TrimType** | `separate`, `continuous` | +| **MergePathOp** | `append`, `union`, `intersect`, `xor`, `difference` | +| **SelectorUnit** | `index`, `percentage` | +| **SelectorShape** | `square`, `rampUp`, `rampDown`, `triangle`, `round`, `smooth` | +| **SelectorMode** | `add`, `subtract`, `intersect`, `min`, `max`, `difference` | +| **TextAlign** | `start`, `center`, `end`, `justify` | +| **VerticalAlign** | `top`, `center`, `bottom` | +| **WritingMode** | `horizontal`, `vertical` | +| **RepeaterOrder** | `belowOriginal`, `aboveOriginal` | + +--- + +## Non-Obvious Defaults (Easy to Misremember) + +These defaults are counter-intuitive and commonly forgotten: + +| Element | Attribute | Default | Common Misconception | +|---------|-----------|---------|---------------------| +| **Repeater** | `position` | `100,100` | Often assumed `0,0` | +| **Repeater** | `copies` | `3` | Often assumed `1` | +| **Rectangle/Ellipse** | `size` | `100,100` | May forget there is a default | +| **Polystar** | `type` | `star` | May assume `polygon` | +| **Polystar** | `outerRadius` | `100` | May forget there is a default | +| **Polystar** | `innerRadius` | `50` | May forget there is a default | +| **TextLayout** | `lineHeight` | `1.2` | Often assumed `1.0` | +| **RoundCorner** | `radius` | `10` | Often assumed `0` | +| **Stroke** | `miterLimit` | `4` | Often assumed `10` (SVG default) | +| **BackgroundBlurStyle** | `tileMode` | `mirror` | May assume `clamp` | +| **BlurFilter** | `tileMode` | `decal` | May assume `clamp` | diff --git a/.codebuddy/skills/pagx-optimize/references/painter-merging.md b/.codebuddy/skills/pagx-optimize/references/painter-merging.md new file mode 100644 index 0000000000..5e8ad08e69 --- /dev/null +++ b/.codebuddy/skills/pagx-optimize/references/painter-merging.md @@ -0,0 +1,244 @@ +# Painter Merging Reference + +Back to main: [SKILL.md](../SKILL.md) + +This file contains detailed examples for Sections 8-9, the most common and highest-impact +optimizations. + +--- + +## 8. Merge Geometry Sharing Identical Painters + +This is the most common and highest-impact optimization. + +### Principle + +In PAGX, painters (Fill / Stroke) render **all geometry accumulated in the current scope**. +Therefore, multiple geometry elements using identical painters can share a single painter +declaration within the same scope. + +### 8.1 Path Merging — Multi-M Subpaths + +**When to apply**: Multiple Paths have identical Fill and/or Stroke. + +**How**: Concatenate multiple Path data strings using multiple `M` (moveto) commands into a +single Path. + +```xml + + + + + + + + + + + + + + + +``` + +**Typical scenarios**: Symmetrical character parts, corner decorations, repeated small icons. + +### 8.2 Shape Merging — Multiple Ellipses / Rectangles Sharing Painters + +**When to apply**: Multiple independent Ellipses or Rectangles have identical painters. + +**How**: Place them in the same Group (or directly in the same Layer), sharing a single +Fill / Stroke declaration. + +```xml + + + + + + + + + + + + + + + + +``` + +### 8.3 Cross-Layer Merging + +**When to apply**: Multiple adjacent Layers have identical painters and identical styles (or no +styles), with no individual filters / mask / blendMode / alpha / name. + +**How**: Merge multiple Layers into one, with geometry sharing painters and styles. + +```xml + + + + + + + + + + + + +``` + +### 8.4 Cross-Layer Style Merging + +**When to apply**: Multiple adjacent Layers have identical painters AND identical +DropShadowStyle / InnerShadowStyle parameters, and the elements do not overlap or are spaced +far enough apart that their individual shadows do not interact. + +DropShadowStyle computes the shadow from the entire Layer's opaque silhouette. When elements do +not overlap, the shadow of the combined silhouette equals the union of individual shadows — +merging is visually equivalent. + +**How**: Merge the Layers into one, with geometry sharing painters and one shared style. + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +**Equivalence condition**: The elements must not overlap and the gap between adjacent elements +must be larger than the blur radius. When this holds, individual shadows are independent and +the merged shadow is identical. When elements overlap or are very close, the merged shadow +produces a single connected silhouette instead of multiple separate shadows — this changes the +rendering and requires user confirmation. + +### Caveats — Painter Scope Isolation + +**This is the most common source of errors.** After merging, if different geometry elements +need different painters, they must be isolated with Groups: + +```xml + + + + + + + + + + + + + + + + + + + +``` + +**Rule**: Before merging, verify two conditions for each geometry element: + +1. **Identical painter sets**: Only geometry with the same painter configuration (Fill only, + Stroke only, or Fill + Stroke) can share a scope. +2. **No modifiers between geometry**: If the original Group contains modifiers (TrimPath, + RoundCorner, MergePath, TextModifier, etc.) between geometry and painter, do not merge + that Group with others. Merging would expand the modifier's scope to include the other + geometry, changing the rendered result. + +```xml + + + + + + + + + + + +``` + +--- + +## 9. Merge Multiple Painters on Identical Geometry + +### Principle + +When two Groups use **identical geometry** but apply different painters (e.g., one Fill, one +Stroke), merge them into a single Group with both painters. Painters do not clear the geometry +list — subsequent painters continue to render the same geometry. + +### When to Apply + +Two adjacent Groups contain identical geometry elements (same Path data / Rectangle / Ellipse +parameters) but different painters. + +### Example + +```xml + + + + + + + + + + + + + + + + +``` + +### Caveats + +- Only applicable when geometry is **exactly identical**. Any difference in path data + prevents merging. +- Painter rendering order follows document order — earlier declarations render below later + ones. Maintain the original visual stacking when merging. + +--- + +## Appendix: DropShadowStyle Scope + +DropShadowStyle is a **layer-level** style that computes shadow shape based on the entire +Layer's opaque content (including all child layers). This means: + +- When merging multiple Layers into one, if they originally had different DropShadowStyle + parameters, only one can be retained after merging. +- Extracting part of a Layer's content into a child Composition does not change shadow scope + (the Composition instance remains a child of that Layer). diff --git a/.codebuddy/skills/pagx-optimize/references/performance.md b/.codebuddy/skills/pagx-optimize/references/performance.md new file mode 100644 index 0000000000..0e36e34b28 --- /dev/null +++ b/.codebuddy/skills/pagx-optimize/references/performance.md @@ -0,0 +1,338 @@ +# Performance Optimization Reference + +Back to main: [SKILL.md](../SKILL.md) + +This file contains detailed examples for Section 15. + +--- + +## Background: Rendering Cost Model + +Understanding the PAGX renderer's cost model helps identify performance bottlenecks: + +- **Repeater**: Expands at render time by fully cloning every Geometry and Painter per copy. No + GPU instancing or batching. Each copy independently computes Stroke, dash patterns, and + transforms. Cost is strictly linear: N copies = N× full rendering cost. +- **Nested Repeaters**: Multiplicative. `copies="A"` containing `copies="B"` produces A×B + elements. This is the most common source of performance problems. +- **BlurFilter / DropShadowStyle**: Computational cost is proportional to blur radius. Large + blur values are expensive. +- **DropShadowStyle**: Applied once per Layer on the composited silhouette — not per Repeater + copy. This makes container-level shadows efficient. +- **Dashed Stroke**: Dash pattern is computed per Geometry independently. When combined with + Repeater, each copy incurs dash computation overhead on top of the basic stroke cost. +- **Layer vs Group**: Layer is heavier — it creates an independent rendering surface that must + be composited back. Group only creates a painter scope boundary with no extra surface. + +--- + +## Equivalent Optimizations (No Visual Change — Auto-apply) + +### 15.1 Downgrade Layer to Group + +**Problem**: Layer creates an independent rendering surface that must be composited back into +the parent. When a Layer does not use any Layer-exclusive features, replacing it with Group +eliminates this overhead. + +**When to apply**: A Layer has no styles (DropShadowStyle, InnerShadowStyle, +BackgroundBlurStyle), no filters (BlurFilter, etc.), no mask, no blendMode, no composition +reference, no scrollRect, and no name attribute that matters for debugging. + +**How**: Replace `` with ``. Convert `x`/`y` to `position`. + +```xml + + + + + + + + + + + +``` + +**Caveats**: +- Group geometry **propagates upward** to the parent scope. If the parent scope has painters + that should not affect this geometry, the Layer isolation is needed. Verify that converting to + Group does not cause unintended painter leakage. +- Layer with `scrollRect` cannot become Group because Group does not support scrollRect. +- Layer with child Layers cannot become Group because Group cannot contain Layers. +- Only apply when the Layer is a leaf (contains only geometry + painters, no child Layers). + +### 15.2 Clip Repeater Content to Canvas Bounds + +**Problem**: Repeaters positioned outside the canvas generate invisible elements that still +consume rendering resources. These elements are never visible in the final output. + +**Category**: This is an equivalent optimization — removing off-canvas content does not affect +the rendered image within the canvas bounds. Safe to auto-apply. + +**When to apply**: A Repeater (or nested Repeaters) generates content that extends significantly +beyond the canvas bounds. + +**How**: Adjust the starting position and `copies` count so generated content just covers the +canvas, with minimal overflow. **Keep the original spacing/density unchanged** — only reduce +the extent of the pattern. This reduces file size and element traversal cost. + +```xml + + + + + + + + + + + + + + + + + + + +``` + +**Caveats**: +- If the file is animated and elements scroll or move, ensure the clipped area still covers all + animation frames. +- For staggered patterns (hex grids), include one extra column/row for the offset. +- This optimization preserves the original density and spacing — only the extent changes. + Within the canvas area, the rendered result should be visually identical. + +--- + +## Suggestions Only (May Change Visual — Never Auto-apply) + +The following optimizations involve modifying design parameters (density, blur, alpha, etc.) +and **must never be applied automatically**. Instead, describe the potential optimization and +its visual trade-off to the user, and only apply after receiving explicit approval. + +### 15.3 Reduce Nested Repeater Element Count + +**Problem**: Nested Repeaters multiply element counts. A seemingly innocent `copies="70"` +nested inside `copies="40"` generates 2800 elements, each fully cloned at render time. + +**Performance guideline**: +- Single Repeater: up to ~200 copies is typically fine +- Nested Repeaters: product of copies should ideally stay under ~500 for smooth rendering +- Above 1000 elements: expect noticeable performance impact + +**Alternatives**: +1. **Reduce density**: Increase spacing to reduce copy count while maintaining visual effect. + For decorative grids and patterns, doubling the spacing halves element count while preserving + the visual impression. +2. **Limit to visible area**: See 15.2 above. +3. **Simplify geometry**: Use a simpler shape (e.g., a dot instead of a hexagon) to reduce + per-element cost. + +**Suggest to user**: Describe the performance cost and ask if reducing density, simplifying +geometry, or other alternatives are acceptable. + +### 15.4 Evaluate Low-Opacity Expensive Elements + +**Problem**: Elements with very low alpha (e.g., `alpha="0.15"`) are nearly invisible but still +fully rendered. When combined with Repeaters or blur effects, the cost-to-visibility ratio is +extremely poor. + +**When to apply**: An element or layer has `alpha` below ~0.2 AND generates significant +rendering cost (Repeaters with many copies, blur filters, complex geometry trees). + +**How to optimize**: +1. Reduce complexity under the low-alpha layer (fewer copies, simpler geometry) +2. Increase alpha to make the element more visible, justifying the render cost +3. Ask user if the element can be removed entirely + +```xml + + + + + + + + + +``` + +**Suggest to user**: Describe the cost-to-visibility ratio and ask if reducing complexity, +increasing alpha, or removing the element is acceptable. + +### 15.5 Reduce Large Blur Radius Values + +**Problem**: Blur effects have computational cost that grows with blur radius. Large values +(e.g., `blurX="220"`) are significantly more expensive than moderate values (e.g., +`blurX="40"`). + +**Two categories**: + +1. **DropShadowStyle / InnerShadowStyle**: These operate on the layer's composited silhouette. + For small elements with large blur, the shadow may be imperceptible. Reducing blur radius + or removing the style entirely can save significant cost. + +2. **BlurFilter**: Used for intentional artistic effects (glows, bokeh, frosted glass). These + are often more visually significant and harder to reduce. But for background glows + (`alpha="0.2"` with `blurX="220"`), consider whether a lower blur radius achieves a + similar visual at fraction of the cost. + +**When to apply**: `blurX` or `blurY` exceeds ~30 pixels. + +**Suggest to user**: Report the blur values found and their estimated cost. Ask if reducing +them is acceptable. + +### 15.6 Merge Redundant Overlapping Repeaters + +**Problem**: UI elements like gauges often have multiple overlapping scale rings (e.g., major +ticks every 6° and minor ticks every 3°). Half of the minor ticks overlap with major ticks, +rendering twice at the same position. + +**When to apply**: Two Repeaters generate marks at intervals where one is a multiple of the +other. + +**How**: Adjust the finer Repeater to skip positions covered by the coarser one using `offset`. + +```xml + + + + + + + + + + + + + + + + + + + + + + + +``` + +**Suggest to user**: Describe the overlap and ask if the minor visual change (removing +redundant marks at shared positions) is acceptable. + +### 15.7 Avoid Dashed Stroke under Repeater + +**Problem**: Stroke with `dashes` attribute has extra overhead (dash pattern computation) on +each geometry independently. When combined with Repeater, this cost multiplies: each of N +copies independently computes dash decomposition. + +**When to apply**: A `` appears in the same scope as a Repeater +with many copies. + +**How**: For decorative patterns where exact dash alignment per-copy is not critical, consider +replacing with a solid stroke at reduced alpha, or a simpler visual treatment. + +```xml + + + + + + + + +``` + +**Suggest to user**: Report the dashed stroke + Repeater combination and ask if replacing +with a solid stroke or other treatment is acceptable. + +### 15.8 Prefer Primitive Geometry over Path under Repeater + +**Problem**: Rectangle and Ellipse are primitive geometry types with dedicated fast paths in +the renderer. Path requires general-purpose tessellation for every shape instance. When a +Repeater multiplies a shape hundreds or thousands of times, the per-instance cost difference +becomes significant. + +**When to apply**: A `` appears in the same scope as a Repeater (especially nested +Repeaters) and the path describes a shape that can be expressed as Rectangle, Ellipse, or +RoundRect. + +**How**: Replace the Path with the equivalent primitive geometry element. This is the same +transformation as Section 13, but here the motivation is rendering performance rather than +readability. + +```xml + + + + + + + + + +``` + +**Note**: This only applies when the Path is geometrically equivalent to a primitive. Do not +approximate complex shapes (e.g., hexagons, stars) as rectangles — visual fidelity takes +priority. + +### 15.9 Replace PathData with Simple Geometry Combinations + +**Problem**: PathData is the most expensive geometry type — it requires general-purpose +tessellation per instance, and Repeaters multiply this cost. Many patterns built from repeated +PathData can be expressed equivalently using combinations of primitive geometry (Rectangle, +Ellipse) with Repeater and Group transforms, which are far cheaper to render. + +**Key insight**: Rather than looking at individual elements, examine the **overall visual +result** of the pattern. Ask: "Can this visual effect be constructed from simple rectangles +or ellipses?" Often the answer is yes, especially for decorative backgrounds. + +**When to apply**: PathData appears inside a Repeater (especially nested Repeaters) producing +a decorative pattern, and the overall visual can be replicated by primitive geometry +combinations. + +**How**: Decompose the visual effect into its simplest geometric components. Use Rectangle / +Ellipse with single-level Repeaters, and Group `rotation` / `position` for orientation. + +**Example — hexagonal grid to 3 sets of parallel lines**: + +A hexagonal grid built from repeated Path hexagons is visually equivalent to 3 sets of +parallel lines (0 degrees, 60 degrees, -60 degrees), each expressible as a Rectangle + Repeater: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + +``` + +**Suggest to user**: Replacing PathData with simple geometry combinations may change fine +details (e.g., hexagon outlines become intersecting lines). At low opacity the difference is +subtle, but it may be noticeable at higher opacity. Always ask for approval before applying. diff --git a/.codebuddy/skills/pagx-optimize/references/resource-reuse.md b/.codebuddy/skills/pagx-optimize/references/resource-reuse.md new file mode 100644 index 0000000000..bef25c1165 --- /dev/null +++ b/.codebuddy/skills/pagx-optimize/references/resource-reuse.md @@ -0,0 +1,285 @@ +# Resource Reuse Reference + +Back to main: [SKILL.md](../SKILL.md) + +This file contains detailed examples for Sections 10-14. + +--- + +## 10. Composition Resource Reuse + +### Principle + +When multiple layer subtrees have identical internal structure (differing only in position), +extract them into a `` resource and instantiate via +``. Compositions do not support parameterization, so only fully +identical portions can be extracted. + +### When to Apply + +2 or more layer subtrees where: +1. Internal structure (geometry, painters, child layer hierarchy) is identical +2. The only difference is the parent Layer's `x` / `y` position + +### How + +1. Define a Composition in Resources with appropriate `width` and `height` +2. Convert internal coordinates from canvas-absolute to Composition-local +3. Replace originals with `` + +### Coordinate Conversion + +Composition has its own coordinate system with origin at the top-left corner. The referencing +Layer's `x` / `y` positions the Composition's top-left corner in the parent coordinate system. + +**Conversion steps**: Given an original Layer at `(layerX, layerY)` with geometry using +`center="cx,cy"`: + +``` +Composition width = geometry width +Composition height = geometry height +Internal center = (width/2, height/2) +Reference Layer x = layerX - width/2 + cx (simplifies to layerX - width/2 when cx=0) +Reference Layer y = layerY - height/2 + cy (simplifies to layerY - height/2 when cy=0) +``` + +### Example + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### Gradient Coordinate Conversion + +When geometry inside a Composition uses gradients, convert absolute canvas coordinates to +coordinates relative to the geometry element's local coordinate system. The PAGX spec states +that all color source coordinates (except solid colors) are **relative to the geometry +element's local coordinate system origin**. + +```xml + + + + + ... + + + + + + + + + ... + + + + + +``` + +### Caveats + +- **DropShadowStyle scope**: DropShadowStyle computes shadow shape based on the entire + Layer's opaque content (including all child layers). If the parent Layer has a + DropShadowStyle and you extract some child content into a Composition, the shadow scope does + not change (the Composition instance is still a child of that Layer). But if the extracted + content's own Layer has a DropShadowStyle, ensure the Composition's internal Layer still + carries that style. +- **No parameterization**: If instances differ in anything beyond position (color, size, etc.), + they cannot share the same Composition. Only extract the fully identical subset. +- **Independent layer tree**: Composition internals are independent rendering units. Geometry + does not propagate outside the Composition boundary. + +--- + +## 11. PathData Resource Reuse + +### Principle + +When the same Path data string appears 2 or more times in a file, extract it into a +`` resource to eliminate duplication and improve maintainability. + +### When to Apply + +Search all `` elements and find identical data strings. + +### How + +1. Add `` to Resources +2. Replace all identical inline data with `` + +### Example + +```xml + + + + + + + + +``` + +### Caveats + +- **reversed attribute**: `` has a `reversed` attribute for reversing path direction. + If one reference needs reversal and another does not, you can still extract a shared + PathData and set `reversed` individually at each reference site. +- **Short paths with only 2 occurrences**: If the path is very short (e.g., `M 0 0 L 10 0`), + extraction may increase total line count due to the resource definition. The benefit is + maintainability (single point of change) rather than line reduction. + +--- + +## 12. Color Source Resource Sharing + +### Principle + +When identical gradient definitions appear inline in multiple places, define them once in +Resources and reference via `@id`. + +### When to Apply + +Multiple Fill / Stroke elements contain LinearGradient / RadialGradient / ConicGradient / +DiamondGradient definitions with identical parameters. + +### Example + +```xml + + + + + + + + + + + + + + + +``` + +### Caveats + +- **Coordinates are relative to geometry**: Gradient startPoint / endPoint / center + coordinates are relative to the geometry element's local coordinate system origin. Two + geometry elements at different positions or with different sizes referencing the same + gradient will render differently. Sharing a gradient only guarantees visual consistency when + the geometry shapes and sizes are identical. +- **Do not extract single-use gradients**: The PAGX spec supports both modes — shared + definitions for multiple references and inline definitions for single use. Not every + gradient needs to be in Resources. + +--- + +## 13. Replace Path with Primitive Geometry + +### Principle + +When a Path's data describes a shape that can be expressed as a Rectangle or Ellipse, prefer +the primitive geometry element. Primitives are more readable, more compact, and convey +semantic intent. + +### When to Apply + +A Path's data is a simple axis-aligned rectangle (4 lines forming a box) or a circle/ellipse +(can be detected by arc commands forming a full closed shape). + +### Example + +```xml + + + + + +``` + +### Caveats + +- Only apply when the path is clearly a standard shape. Do not convert rounded rectangles + described via Bezier curves unless you can accurately extract the roundness parameter. +- Paths with transforms applied may not map cleanly to primitive attributes. +- This optimization improves readability. It also benefits rendering performance — see + Section 15.8 in the performance reference for details on when shape choice affects render cost. + +--- + +## 14. Remove Full-Canvas Clip Masks + +### Principle + +A clip mask that covers the entire canvas (or exceeds the masked content's bounds) has no +clipping effect and can be removed along with the mask reference. + +### When to Apply + +A `visible="false"` Layer used as a mask contains a Rectangle whose bounds equal or exceed the +root element's dimensions, and the mask reference is on a Layer that does not need clipping. + +### Example + +```xml + + + + + + + + + + + + + +``` + +### Caveats + +- Verify that the masked content truly fits within the mask bounds. If content extends beyond + the canvas, the clip mask may still be needed. +- Some full-canvas clips exist because the SVG source had `overflow: hidden` on the root. + Removing them is safe when the PAGX root already clips to its own dimensions. + +--- + +## Appendix: Color Source Coordinate System + +All color sources except solid colors use coordinates **relative to the geometry element's +local coordinate system origin**: +- External transforms (Group transform, Layer matrix) apply to both geometry and color source + together — they scale, rotate, and translate as one unit +- Modifying geometry properties (e.g., Rectangle size) does not affect color source coordinates diff --git a/.codebuddy/skills/pagx-optimize/references/structure-cleanup.md b/.codebuddy/skills/pagx-optimize/references/structure-cleanup.md new file mode 100644 index 0000000000..726d5d560f --- /dev/null +++ b/.codebuddy/skills/pagx-optimize/references/structure-cleanup.md @@ -0,0 +1,383 @@ +# Structure Cleanup Reference + +Back to main: [SKILL.md](../SKILL.md) + +This file contains detailed examples for Sections 1-7. + +--- + +## 1. Move Resources to End of File + +### Principle + +Place the `` node as the last child of the root element so the layer tree appears +first. This makes the content structure immediately visible without scrolling past resource +definitions. + +### When to Apply + +`` appears before the layer tree. + +### How + +Move the entire `...` block to just before the root element's closing +tag. + +### Example + +```xml + + + + + + ... + + + + + ... + + + + +``` + +### Caveats + +None. Resource position does not affect rendering. This is purely a readability improvement. + +--- + +## 2. Remove Empty and Dead Elements + +### Principle + +Empty elements and invisible painters are dead code and should be removed. + +### When to Apply + +- Empty `` tags with no children and no meaningful attributes +- Empty `` blocks with no resource definitions +- `` — zero-width strokes are invisible and have no effect + +### Example + +```xml + + + + + + + + + + + + + + +``` + +### Caveats + +- An empty Layer may serve as a mask target (`id` referenced elsewhere via `mask="@id"`). + Verify it is truly unreferenced before removing. +- A Layer with `visible="false"` is not "empty" — it is likely a mask/clip definition. +- **Mask layers must not be moved to a different position in the layer tree.** A mask layer + (e.g. ``) is a regular layer in the rendering tree, not a + Resources-level asset. Its position relative to other layers affects rendering behavior + (e.g. DropShadowStyle clipping). Only `` can be freely relocated. + +--- + +## 3. Omit Default Attribute Values + +### Principle + +The PAGX spec defines default values for most optional attributes. Attributes explicitly set +to their default value are redundant and should be omitted to reduce noise. + +### Common Defaults to Omit + +**Layer**: `alpha="1"`, `visible="true"`, `blendMode="normal"`, `x="0"`, `y="0"`, +`antiAlias="true"`, `groupOpacity="false"`, `maskType="alpha"` + +**Rectangle / Ellipse**: `center="0,0"`, `size="100,100"`, `reversed="false"`. +Also `roundness="0"` for Rectangle. + +**Fill**: `color="#000000"`, `alpha="1"`, `blendMode="normal"`, `fillRule="winding"`, +`placement="background"` + +**Stroke**: `color="#000000"`, `width="1"`, `alpha="1"`, `blendMode="normal"`, `cap="butt"`, +`join="miter"`, `miterLimit="4"`, `dashOffset="0"`, `align="center"`, +`placement="background"` + +**Path**: `reversed="false"` + +**Group**: `alpha="1"`, `position="0,0"`, `rotation="0"`, `scale="1,1"`, `skew="0"`, +`skewAxis="0"` + +**DropShadowStyle**: `showBehindLayer="true"`, `blendMode="normal"`. +All offsets and blur values default to `0`, color defaults to `#000000`. + +**Gradient**: `matrix` defaults to identity. RadialGradient/ConicGradient/DiamondGradient +`center` defaults to `0,0`. ConicGradient `startAngle` defaults to `0`, `endAngle` to `360`. + +**TrimPath**: `start="0"`, `end="1"`, `offset="0"`, `type="separate"` + +**Repeater**: `copies="3"`, `offset="0"`, `order="belowOriginal"`, `anchor="0,0"`, +`position="100,100"`, `rotation="0"`, `scale="1,1"`, `startAlpha="1"`, `endAlpha="1"` + +### Example + +```xml + + + + + + + + + + + + + +``` + +### Caveats + +- Only omit when the value **exactly matches** the spec default. When in doubt, keep it. +- `ColorStop offset` is always required (no default) — do not omit even `offset="0"`. + +### Non-Obvious Defaults (Easy to Forget) + +The following defaults are counter-intuitive and easy to misremember: + +| Element | Attribute | Default | Common Misconception | +|---------|-----------|---------|---------------------| +| **Repeater** | `position` | `100,100` | Often assumed to be `0,0` | +| **Repeater** | `copies` | `3` | Often assumed to be `1` | +| **Rectangle/Ellipse** | `size` | `100,100` | May forget there is a default | +| **Polystar** | `type` | `star` | May assume `polygon` | +| **Polystar** | `outerRadius` | `100` | May forget there is a default | +| **Polystar** | `innerRadius` | `50` | May forget there is a default | +| **TextLayout** | `lineHeight` | `1.2` | Often assumed to be `1.0` | +| **RoundCorner** | `radius` | `10` | Often assumed to be `0` | +| **Stroke** | `miterLimit` | `4` | Often assumed to be `10` (SVG default) | + +### Required Attributes That Look Like They Have Defaults + +The following attributes are **(required)** in the PAGX spec — they have **no default value**. +Even when their value is `"0,0"` or `"0"`, they must **never** be omitted. + +| Element | Attribute | Typical Value | Why It Looks Optional | +|---------|-----------|--------------|----------------------| +| **LinearGradient** | `startPoint` | `0,0` | Looks like a `0,0` default, but the spec marks it as (required) | +| **LinearGradient** | `endPoint` | varies | Also (required) | +| **ColorStop** | `offset` | `0` | Looks like a zero default, but (required) per spec | +| **ColorStop** | `color` | varies | Also (required) | + +**Tip**: When optimizing files, always verify against the PAGX spec (Appendix C) if unsure. + +--- + +## 4. Simplify Transform Attributes + +### Principle + +Use the simplest representation for transforms. Prefer `x`/`y` over `matrix` when the matrix +only represents translation. + +### 4.1 Translation-Only Matrix to x/y + +When a Layer's matrix is `1,0,0,1,tx,ty` (identity + translation), replace with `x`/`y`. + +```xml + + + + + +``` + +### 4.2 Identity Matrix Removal + +A matrix of `1,0,0,1,0,0` is identity and should be removed entirely. + +```xml + + + + + +``` + +### 4.3 Cascaded Translation Merging + +When nested Layers each only apply translation (no rotation/scale), their matrices can be +merged and intermediate Layers removed. + +```xml + + + + + + + + + +``` + +### Caveats + +- Only merge translations when intermediate Layers have no other attributes (no styles, + filters, mask, alpha, blendMode, etc.). +- Matrices with rotation or scale (`a != 1` or `b != 0` or `c != 0` or `d != 1`) cannot be + simplified to x/y. + +--- + +## 5. Normalize Numeric Values + +### Principle + +Use the simplest numeric representation for readability and smaller file size. + +### 5.1 Near-Zero Scientific Notation + +Values like `-2.18557e-06` are effectively zero and should be written as `0`. + +```xml + + + + + +``` + +### 5.2 Integer Values + +Whole numbers should not have trailing `.0` or unnecessary decimal places. + +```xml + + + + + +``` + +### 5.3 Short Hex Colors + +The spec supports `#RGB` shorthand that expands to `#RRGGBB`. Use it when possible. + +```xml + + + + + + + +``` + +### Caveats + +- Only simplify scientific notation when the value is negligibly small (absolute value + < 0.001). Larger values must be preserved. +- Only use short hex when each pair of digits is identical (`FF` → `F`, `00` → `0`). + `#F43F5E` cannot be shortened. +- Standardize to uppercase hex for consistency. +- Remove spaces after commas in coordinate attribute values: `"30, -20"` → `"30,-20"`. + +--- + +## 6. Remove Unused Resources + +### Principle + +Resources defined in `` but never referenced are dead code and should be deleted. + +### When to Apply + +A resource defined with `id="xxx"` has no corresponding `@xxx` reference anywhere else in the +file. + +### How + +Delete the entire resource definition. + +### Example + +```xml + + + + + + + + + +``` + +### Caveats + +- In animated files, resources may be referenced by keyframe `value` attributes + (e.g., `value="@gradA"`). Search the entire file, not just static attributes. +- Resources may reference other resources internally (e.g., a Composition referencing a + PathData). Do not delete indirectly referenced resources. + +--- + +## 7. Remove Redundant Group / Layer Wrappers + +### Principle + +A Group or Layer that carries no attributes and wraps only a single content group is an +unnecessary intermediate layer that can be flattened. + +### When to Apply + +All of the following conditions are met: + +1. No `transform` / `position` / `rotation` / `scale` / `alpha` / `blendMode` / `name` / + `mask` or similar attributes +2. Contains only one content group (not multiple sibling elements requiring scope isolation) + +### How + +Promote the inner content to the parent scope and delete the wrapper element. + +### Example + +```xml + + + + + + + + + + + + + +``` + +### Caveats + +- **Layers cannot be removed freely**: Layer is the only container for styles + (DropShadowStyle, etc.), filters (BlurFilter, etc.), masks, composition references, and + blendMode. If a Layer carries any of these, it must stay. +- **Groups with transforms cannot be removed**: A Group's transform applies to all its + contents. Removing it changes the rendering. +- **Groups for scope isolation**: If a Group exists to isolate painter scope (see Section 8), + it must not be removed. diff --git a/.gitignore b/.gitignore index 850e904227..42746d9ae6 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,18 @@ mac/PAG linux/vendor/ /PAG .cache + +# PAGX Playground +playground/wasm-mt +playground/build-pagx-playground +playground/.pagx.wasm-mt.md5 +playground/node_modules +playground/package-lock.json + +# PAGX Site +public + +# PAGX Spec +spec/node_modules +spec/package-lock.json + diff --git a/CMakeLists.txt b/CMakeLists.txt index d8b05fe977..71a3939970 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -102,6 +102,7 @@ if (PAG_BUILD_TESTS) set(PAG_USE_HARFBUZZ ON) set(PAG_USE_SYSTEM_LZ4 OFF) set(PAG_BUILD_SHARED OFF) + set(TGFX_BUILD_LAYERS ON) endif () message("PAG_USE_LIBAVC: ${PAG_USE_LIBAVC}") @@ -159,8 +160,10 @@ list(APPEND PAG_FILES ${PAG_HEADERS}) file(GLOB_RECURSE SRC_FILES src/base/*.* src/codec/*.* - src/rendering/*.*) + src/rendering/*.* + src/renderer/*.*) list(APPEND PAG_FILES ${SRC_FILES}) +list(APPEND PAG_INCLUDES src/renderer src/pagx) file(GLOB COMMON_FILES src/platform/*.*) list(APPEND PAG_FILES ${COMMON_FILES}) @@ -470,6 +473,7 @@ if (NOT HAS_CUSTOM_TGFX_DIR AND EXISTS ${TGFX_CACHE_DIR}) list(APPEND TGFX_OPTIONS "-DTGFX_ENABLE_PROFILING=${PAG_ENABLE_PROFILING}") list(APPEND TGFX_OPTIONS "-DTGFX_USE_THREADS=${PAG_USE_THREADS}") list(APPEND TGFX_OPTIONS "-DTGFX_USE_TEXT_GAMMA_CORRECTION=ON") + list(APPEND TGFX_OPTIONS "-DTGFX_BUILD_LAYERS=${TGFX_BUILD_LAYERS}") if (PAG_USE_QT) list(APPEND TGFX_OPTIONS "-DCMAKE_PREFIX_PATH=\"${CMAKE_PREFIX_PATH}\"") endif () @@ -504,6 +508,7 @@ else () set(TGFX_ENABLE_PROFILING ${PAG_ENABLE_PROFILING}) set(TGFX_USE_THREADS ${PAG_USE_THREADS}) set(TGFX_USE_TEXT_GAMMA_CORRECTION ON) + set(TGFX_BUILD_LAYERS ${TGFX_BUILD_LAYERS}) set(CMAKE_POLICY_DEFAULT_CMP0077 NEW) add_subdirectory(${TGFX_DIR} tgfx EXCLUDE_FROM_ALL) list(APPEND PAG_STATIC_LIBS $) @@ -633,13 +638,31 @@ if (PAG_BUILD_TESTS) endif () endif () + # Build pagx static library (format module, no tgfx dependency) + file(GLOB PAGX_CORE_SOURCES src/pagx/*.cpp) + file(GLOB_RECURSE PAGX_UTILS_SOURCES src/pagx/utils/*.cpp) + file(GLOB_RECURSE PAGX_XML_SOURCES src/pagx/xml/*.cpp) + file(GLOB_RECURSE PAGX_SVG_SOURCES src/pagx/svg/*.cpp) + add_library(pagx STATIC ${PAGX_CORE_SOURCES} ${PAGX_UTILS_SOURCES} ${PAGX_XML_SOURCES} ${PAGX_SVG_SOURCES}) + target_include_directories(pagx PUBLIC include) + target_include_directories(pagx PRIVATE src/pagx + ${CMAKE_CURRENT_SOURCE_DIR}/third_party/expat/expat/lib) + if(WIN32) + target_compile_definitions(pagx PRIVATE XML_STATIC) + endif() + add_vendor_target(pagx-vendor STATIC_VENDORS expat) + find_vendor_libraries(pagx-vendor STATIC PAGX_VENDOR_STATIC_LIBRARIES) + add_dependencies(pagx pagx-vendor) + merge_libraries_into(pagx ${PAGX_VENDOR_STATIC_LIBRARIES}) + file(GLOB_RECURSE SRC_FILES test/src/*.*) list(APPEND PAG_TEST_FILES ${SRC_FILES}) - list(APPEND PAG_TEST_LIBS pag ${PAG_SHARED_LIBS}) + list(APPEND PAG_TEST_LIBS pagx pag ${PAG_SHARED_LIBS}) set(GOOGLE_TEST_DIR ${TGFX_DIR}/third_party/googletest/googletest) list(APPEND PAG_TEST_INCLUDES ${PAG_INCLUDES} test/src ${GOOGLE_TEST_DIR} ${GOOGLE_TEST_DIR}/include - ${TGFX_DIR}/third_party/json/include) + ${TGFX_DIR}/third_party/json/include + src/renderer src/pagx src/pagx/svg) add_vendor_target(test-vendor STATIC_VENDORS googletest CONFIG_DIR ${TGFX_DIR}) find_vendor_libraries(test-vendor STATIC TEST_VENDOR_STATIC_LIBRARIES) diff --git a/DEPS b/DEPS index 14c4af5c13..75fa4aac7f 100644 --- a/DEPS +++ b/DEPS @@ -12,7 +12,7 @@ }, { "url": "${PAG_GROUP}/tgfx.git", - "commit": "cbd5c1a6f9c353998136330ce87d879e5252493a", + "commit": "7069da198a399c4a313ffacef4aeb8e0e8ccd5e1", "dir": "third_party/tgfx" }, { @@ -34,6 +34,11 @@ "url": "https://github.com/lz4/lz4.git", "commit": "cacca37747572717ceb1f156eb9840644205ca4f", "dir": "third_party/lz4" + }, + { + "url": "https://github.com/libexpat/libexpat.git", + "commit": "88b3ed553d8ad335559254863a33360d55b9f1d6", + "dir": "third_party/expat" } ] }, diff --git a/README.md b/README.md index 99bf502227..a9a6011a77 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ required for features like video templates. - macOS 10.15+ - Windows 7.0+ - Chrome 69.0+ (Web) -- Safari 11.3+ (Web) +- Safari 15.0+ (Web) ## Getting Started diff --git a/README.zh_CN.md b/README.zh_CN.md index cfc0207bc2..531e763e0e 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -61,7 +61,7 @@ PAG 方案目前已经接入了腾讯系几乎所有主流应用以及外部几 - macOS 10.15 版本及以上 - Windows 7.0 版本及以上 - Chrome 69.0 版本及以上 -- Safari 11.3 版本及以上 +- Safari 15.0 版本及以上 ## 快速接入 diff --git a/include/pagx/PAGXDocument.h b/include/pagx/PAGXDocument.h new file mode 100644 index 0000000000..cbcf519eae --- /dev/null +++ b/include/pagx/PAGXDocument.h @@ -0,0 +1,125 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include +#include +#include "pagx/nodes/Layer.h" +#include "pagx/nodes/Node.h" +#include "pagx/types/Data.h" + +namespace pagx { + +/** + * PAGXDocument is the root container for a PAGX document. + * It contains resources and layers. This is a pure data structure class. + * Use PAGXImporter to load documents and PAGXExporter to save documents. + */ +class PAGXDocument { + public: + /** + * Creates an empty document with the specified size. + */ + static std::shared_ptr Make(float width, float height); + + /** + * Format version. + */ + std::string version = "1.0"; + + /** + * Canvas width. + */ + float width = 0; + + /** + * Canvas height. + */ + float height = 0; + + /** + * Top-level layers (raw pointers, owned by nodes). + */ + std::vector layers = {}; + + /** + * Creates a node of the specified type and adds it to the document management. + * If an ID is provided, the node will be indexed for lookup. + * If the ID already exists, an error will be logged and the new node will replace the old one in + * the index. + */ + template + T* makeNode(const std::string& id = "") { + auto node = std::unique_ptr(new T()); + auto* result = node.get(); + registerNode(result, id); + nodes.push_back(std::move(node)); + return result; + } + + /** + * Finds a node by ID. + * Returns nullptr if not found. + */ + Node* findNode(const std::string& id) const; + + /** + * Finds a node of the specified type by ID. + * The caller must ensure T matches the actual node type, otherwise behavior is undefined. + * @param id The unique identifier of the node. + * @return A pointer to the node cast to type T, or nullptr if not found. + */ + template + T* findNode(const std::string& id) const { + return static_cast(findNode(id)); + } + + /** + * All nodes in the document (owned by the document). + */ + std::vector> nodes = {}; + + /** + * Returns a list of external file paths referenced by Image nodes that have no embedded data. + * Data URIs (paths starting with "data:") are excluded. + */ + std::vector getExternalFilePaths() const; + + /** + * Loads external file data for an Image node matching the given file path. Once loaded, the + * Image's data field is populated and its filePath is cleared, so the renderer uses embedded data + * instead of file I/O. + */ + bool loadFileData(const std::string& filePath, std::shared_ptr data); + + private: + PAGXDocument() = default; + + void registerNode(Node* node, const std::string& id); + + std::unordered_map nodeMap = {}; + + friend class PAGXImporter; + friend class PAGXExporter; + friend class TypesetterContext; +}; + +} // namespace pagx diff --git a/include/pagx/PAGXExporter.h b/include/pagx/PAGXExporter.h new file mode 100644 index 0000000000..3e30a8c974 --- /dev/null +++ b/include/pagx/PAGXExporter.h @@ -0,0 +1,52 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include "pagx/PAGXDocument.h" + +namespace pagx { + +/** + * Export options for PAGXExporter. + */ +struct PAGXExportOptions { + /** + * Whether to skip GlyphRun and Font resource data in the export. + * - false (default): Export pre-shaped glyph data for cross-platform consistency. + * - true: Skip glyph data to reduce file size. Text will require runtime shaping. + */ + bool skipGlyphData = false; +}; + +/** + * PAGXExporter exports PAGXDocument to PAGX XML format. + */ +class PAGXExporter { + public: + using Options = PAGXExportOptions; + + /** + * Exports a PAGXDocument to XML string. + * The output faithfully reflects the structure of the input document. + */ + static std::string ToXML(const PAGXDocument& document, const Options& options = {}); +}; + +} // namespace pagx diff --git a/include/pagx/PAGXImporter.h b/include/pagx/PAGXImporter.h new file mode 100644 index 0000000000..c050c97810 --- /dev/null +++ b/include/pagx/PAGXImporter.h @@ -0,0 +1,51 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include "pagx/PAGXDocument.h" + +namespace pagx { + +/** + * PAGXImporter parses PAGX XML format into PAGXDocument. + */ +class PAGXImporter { + public: + /** + * Parses a PAGX file and returns a PAGXDocument. + * Returns nullptr if the file cannot be loaded or parsing fails. + */ + static std::shared_ptr FromFile(const std::string& filePath); + + /** + * Parses PAGX XML content and returns a PAGXDocument. + * Returns nullptr if parsing fails. + */ + static std::shared_ptr FromXML(const std::string& xmlContent); + + /** + * Parses PAGX XML data and returns a PAGXDocument. + * Returns nullptr if parsing fails. + */ + static std::shared_ptr FromXML(const uint8_t* data, size_t length); +}; + +} // namespace pagx diff --git a/include/pagx/SVGImporter.h b/include/pagx/SVGImporter.h new file mode 100644 index 0000000000..afd6149d82 --- /dev/null +++ b/include/pagx/SVGImporter.h @@ -0,0 +1,72 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include "pagx/PAGXDocument.h" + +namespace pagx { + +/** + * SVGImporter converts SVG documents to PAGX Document. + * This importer is independent of tgfx and preserves complete SVG information. + */ +class SVGImporter { + public: + struct Options { + /** + * If true, unsupported SVG elements are preserved as Unknown nodes. + */ + bool preserveUnknownElements = false; + + /** + * If true, references are expanded to actual content. + */ + bool expandUseReferences = true; + + /** + * If true, nested transforms are flattened into single matrices. + */ + bool flattenTransforms = false; + + Options() { + } + }; + + /** + * Parses an SVG file and creates a PAGX Document. + */ + static std::shared_ptr Parse(const std::string& filePath, + const Options& options = Options()); + + /** + * Parses SVG data and creates a PAGX Document. + */ + static std::shared_ptr Parse(const uint8_t* data, size_t length, + const Options& options = Options()); + + /** + * Parses an SVG string and creates a PAGX Document. + */ + static std::shared_ptr ParseString(const std::string& svgContent, + const Options& options = Options()); +}; + +} // namespace pagx diff --git a/include/pagx/nodes/BackgroundBlurStyle.h b/include/pagx/nodes/BackgroundBlurStyle.h new file mode 100644 index 0000000000..45556f4aca --- /dev/null +++ b/include/pagx/nodes/BackgroundBlurStyle.h @@ -0,0 +1,56 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/LayerStyle.h" +#include "pagx/types/TileMode.h" + +namespace pagx { + +/** + * A background blur layer style that blurs the content behind the layer. + */ +class BackgroundBlurStyle : public LayerStyle { + public: + /** + * The horizontal blur radius in pixels. The default value is 0. + */ + float blurX = 0.0f; + + /** + * The vertical blur radius in pixels. The default value is 0. + */ + float blurY = 0.0f; + + /** + * The tile mode for handling blur edges. The default value is Mirror. + */ + TileMode tileMode = TileMode::Mirror; + + NodeType nodeType() const override { + return NodeType::BackgroundBlurStyle; + } + + private: + BackgroundBlurStyle() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/BlendFilter.h b/include/pagx/nodes/BlendFilter.h new file mode 100644 index 0000000000..a9bed9b2e4 --- /dev/null +++ b/include/pagx/nodes/BlendFilter.h @@ -0,0 +1,52 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/LayerFilter.h" +#include "pagx/types/BlendMode.h" +#include "pagx/types/Color.h" + +namespace pagx { + +/** + * A blend filter that blends a color with the layer content using a specified blend mode. + */ +class BlendFilter : public LayerFilter { + public: + /** + * The color to blend with the layer content. + */ + Color color = {}; + + /** + * The blend mode used for combining the color with the layer. The default value is Normal. + */ + BlendMode blendMode = BlendMode::Normal; + + NodeType nodeType() const override { + return NodeType::BlendFilter; + } + + private: + BlendFilter() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/BlurFilter.h b/include/pagx/nodes/BlurFilter.h new file mode 100644 index 0000000000..62c7c1409f --- /dev/null +++ b/include/pagx/nodes/BlurFilter.h @@ -0,0 +1,56 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/LayerFilter.h" +#include "pagx/types/TileMode.h" + +namespace pagx { + +/** + * A blur filter that applies a Gaussian blur effect to the layer. + */ +class BlurFilter : public LayerFilter { + public: + /** + * The horizontal blur radius in pixels. The default value is 0. + */ + float blurX = 0.0f; + + /** + * The vertical blur radius in pixels. The default value is 0. + */ + float blurY = 0.0f; + + /** + * The tile mode for handling blur edges. The default value is Decal. + */ + TileMode tileMode = TileMode::Decal; + + NodeType nodeType() const override { + return NodeType::BlurFilter; + } + + private: + BlurFilter() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/ColorMatrixFilter.h b/include/pagx/nodes/ColorMatrixFilter.h new file mode 100644 index 0000000000..d5932a540e --- /dev/null +++ b/include/pagx/nodes/ColorMatrixFilter.h @@ -0,0 +1,54 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include "pagx/nodes/LayerFilter.h" + +namespace pagx { + +/** + * A color matrix filter that transforms the layer colors using a 4x5 matrix. + * The matrix is applied as: [R' G' B' A'] = [R G B A 1] * matrix + */ +class ColorMatrixFilter : public LayerFilter { + public: + /** + * The 4x5 color transformation matrix stored as a 20-element array in row-major order. + * Elements 0-4: red channel transformation + * Elements 5-9: green channel transformation + * Elements 10-14: blue channel transformation + * Elements 15-19: alpha channel transformation + * The last element of each row is an additive offset (bias). + * The default value is the identity matrix. + */ + std::array matrix = {1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, + 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f}; + + NodeType nodeType() const override { + return NodeType::ColorMatrixFilter; + } + + private: + ColorMatrixFilter() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/ColorSource.h b/include/pagx/nodes/ColorSource.h new file mode 100644 index 0000000000..e8c9054673 --- /dev/null +++ b/include/pagx/nodes/ColorSource.h @@ -0,0 +1,37 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/Node.h" + +namespace pagx { + +/** + * Base class for color sources (SolidColor, gradients, ImagePattern). + * ColorSource can be used both inline in painters and as standalone resources in defs. + * Inherits from Node so it can be stored in resources and referenced by ID. + */ +class ColorSource : public Node { + protected: + ColorSource() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/ColorStop.h b/include/pagx/nodes/ColorStop.h new file mode 100644 index 0000000000..db185adffb --- /dev/null +++ b/include/pagx/nodes/ColorStop.h @@ -0,0 +1,48 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/types/Color.h" +#include "pagx/nodes/Node.h" + +namespace pagx { + +/** + * A color stop defines a color at a specific position in a gradient. + * Unlike other Node subclasses, ColorStop is stored by value in gradient color stop arrays + * and does not need to be created through PAGXDocument::makeNode(). + */ +class ColorStop : public Node { + public: + /** + * The position of this color stop along the gradient, ranging from 0 to 1. + */ + float offset = 0.0f; + + /** + * The color value at this stop position. + */ + Color color = {}; + + NodeType nodeType() const override { + return NodeType::ColorStop; + } +}; + +} // namespace pagx diff --git a/include/pagx/nodes/Composition.h b/include/pagx/nodes/Composition.h new file mode 100644 index 0000000000..b8f3b3d066 --- /dev/null +++ b/include/pagx/nodes/Composition.h @@ -0,0 +1,59 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include "pagx/nodes/Node.h" + +namespace pagx { + +class Layer; + +/** + * Composition represents a reusable composition resource that contains a set of layers. It can be + * referenced by a Layer's composition property to create instances. + */ +class Composition : public Node { + public: + /** + * The width of the composition in pixels. + */ + float width = 0.0f; + + /** + * The height of the composition in pixels. + */ + float height = 0.0f; + + /** + * The layers contained in this composition. + */ + std::vector layers = {}; + + NodeType nodeType() const override { + return NodeType::Composition; + } + + private: + Composition() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/ConicGradient.h b/include/pagx/nodes/ConicGradient.h new file mode 100644 index 0000000000..24eac551a0 --- /dev/null +++ b/include/pagx/nodes/ConicGradient.h @@ -0,0 +1,69 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include "pagx/nodes/ColorSource.h" +#include "pagx/nodes/ColorStop.h" +#include "pagx/types/Matrix.h" +#include "pagx/types/Point.h" + +namespace pagx { + +/** + * A conic (sweep) gradient color source that produces a gradient sweeping around a center point. + */ +class ConicGradient : public ColorSource { + public: + /** + * The center point of the gradient. + */ + Point center = {}; + + /** + * The starting angle of the gradient sweep in degrees. The default value is 0. + */ + float startAngle = 0.0f; + + /** + * The ending angle of the gradient sweep in degrees. The default value is 360. + */ + float endAngle = 360.0f; + + /** + * The transformation matrix applied to the gradient. + */ + Matrix matrix = {}; + + /** + * The color stops defining the gradient colors and positions. + */ + std::vector colorStops = {}; + + NodeType nodeType() const override { + return NodeType::ConicGradient; + } + + private: + ConicGradient() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/DiamondGradient.h b/include/pagx/nodes/DiamondGradient.h new file mode 100644 index 0000000000..6aa6c8024d --- /dev/null +++ b/include/pagx/nodes/DiamondGradient.h @@ -0,0 +1,64 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include "pagx/nodes/ColorSource.h" +#include "pagx/nodes/ColorStop.h" +#include "pagx/types/Matrix.h" +#include "pagx/types/Point.h" + +namespace pagx { + +/** + * A diamond gradient color source that produces a gradient in a diamond shape from the center. + */ +class DiamondGradient : public ColorSource { + public: + /** + * The center point of the gradient. + */ + Point center = {}; + + /** + * Half the diagonal length of the diamond shape. + */ + float radius = 0.0f; + + /** + * The transformation matrix applied to the gradient. + */ + Matrix matrix = {}; + + /** + * The color stops defining the gradient colors and positions. + */ + std::vector colorStops = {}; + + NodeType nodeType() const override { + return NodeType::DiamondGradient; + } + + private: + DiamondGradient() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/DropShadowFilter.h b/include/pagx/nodes/DropShadowFilter.h new file mode 100644 index 0000000000..967fd67ada --- /dev/null +++ b/include/pagx/nodes/DropShadowFilter.h @@ -0,0 +1,71 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/LayerFilter.h" +#include "pagx/types/Color.h" + +namespace pagx { + +/** + * A drop shadow filter that renders a shadow behind the layer content. + */ +class DropShadowFilter : public LayerFilter { + public: + /** + * The horizontal offset of the shadow in pixels. The default value is 0. + */ + float offsetX = 0.0f; + + /** + * The vertical offset of the shadow in pixels. The default value is 0. + */ + float offsetY = 0.0f; + + /** + * The horizontal blur radius of the shadow in pixels. The default value is 0. + */ + float blurX = 0.0f; + + /** + * The vertical blur radius of the shadow in pixels. The default value is 0. + */ + float blurY = 0.0f; + + /** + * The color of the shadow. + */ + Color color = {}; + + /** + * Whether to render only the shadow without the original content. The default value is false. + */ + bool shadowOnly = false; + + NodeType nodeType() const override { + return NodeType::DropShadowFilter; + } + + private: + DropShadowFilter() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/DropShadowStyle.h b/include/pagx/nodes/DropShadowStyle.h new file mode 100644 index 0000000000..ba1edd1921 --- /dev/null +++ b/include/pagx/nodes/DropShadowStyle.h @@ -0,0 +1,71 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/LayerStyle.h" +#include "pagx/types/Color.h" + +namespace pagx { + +/** + * A drop shadow layer style that renders a shadow behind the layer content. + */ +class DropShadowStyle : public LayerStyle { + public: + /** + * The horizontal offset of the shadow in pixels. The default value is 0. + */ + float offsetX = 0.0f; + + /** + * The vertical offset of the shadow in pixels. The default value is 0. + */ + float offsetY = 0.0f; + + /** + * The horizontal blur radius of the shadow in pixels. The default value is 0. + */ + float blurX = 0.0f; + + /** + * The vertical blur radius of the shadow in pixels. The default value is 0. + */ + float blurY = 0.0f; + + /** + * The color of the shadow. + */ + Color color = {}; + + /** + * Whether the shadow is shown behind the layer. The default value is true. + */ + bool showBehindLayer = true; + + NodeType nodeType() const override { + return NodeType::DropShadowStyle; + } + + private: + DropShadowStyle() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/Element.h b/include/pagx/nodes/Element.h new file mode 100644 index 0000000000..7d02b331c5 --- /dev/null +++ b/include/pagx/nodes/Element.h @@ -0,0 +1,40 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/Node.h" + +namespace pagx { + +/** + * Element is the base class for all elements in a shape layer. It includes shapes (Rectangle, + * Ellipse, Polystar, Path, Text), painters (Fill, Stroke), modifiers (TrimPath, RoundCorner, + * MergePath), text elements (TextModifier, TextPath, TextLayout), and containers (Group, Repeater). + */ +class Element : public Node { + public: + ~Element() override = default; + + protected: + Element() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/Ellipse.h b/include/pagx/nodes/Ellipse.h new file mode 100644 index 0000000000..c6746cbec7 --- /dev/null +++ b/include/pagx/nodes/Ellipse.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/nodes/Element.h" +#include "pagx/types/Point.h" +#include "pagx/types/Size.h" + +namespace pagx { + +/** + * Ellipse represents an ellipse shape defined by a center point and size. + */ +class Ellipse : public Element { + public: + /** + * The center point of the ellipse. + */ + Point center = {}; + + /** + * The size of the ellipse. The default value is {100, 100}. + */ + Size size = {100.0f, 100.0f}; + + /** + * Whether the path direction is reversed. The default value is false. + */ + bool reversed = false; + + NodeType nodeType() const override { + return NodeType::Ellipse; + } + + private: + Ellipse() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/Fill.h b/include/pagx/nodes/Fill.h new file mode 100644 index 0000000000..510a620577 --- /dev/null +++ b/include/pagx/nodes/Fill.h @@ -0,0 +1,74 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/ColorSource.h" +#include "pagx/nodes/Element.h" +#include "pagx/types/BlendMode.h" +#include "pagx/types/FillRule.h" +#include "pagx/types/LayerPlacement.h" + +namespace pagx { + +/** + * Fill represents a fill painter that fills shapes with a solid color, gradient, or pattern. + * The color is specified through a ColorSource node (SolidColor, LinearGradient, etc.) or + * a reference to a defined color source (e.g., "@gradientId"). + */ +class Fill : public Element { + public: + /** + * The color source for this fill. Can be a SolidColor, LinearGradient, RadialGradient, + * ConicGradient, DiamondGradient, or ImagePattern. + */ + ColorSource* color = nullptr; + + /** + * The opacity of the fill, ranging from 0 (transparent) to 1 (opaque). The default value is 1. + */ + float alpha = 1.0f; + + /** + * The blend mode used when compositing the fill. The default value is Normal. + */ + BlendMode blendMode = BlendMode::Normal; + + /** + * The fill rule that determines the interior of self-intersecting paths. The default value is + * Winding. + */ + FillRule fillRule = FillRule::Winding; + + /** + * The placement of the fill relative to strokes (Background or Foreground). The default value is + * Background. + */ + LayerPlacement placement = LayerPlacement::Background; + + NodeType nodeType() const override { + return NodeType::Fill; + } + + private: + Fill() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/Font.h b/include/pagx/nodes/Font.h new file mode 100644 index 0000000000..d2f4434ea5 --- /dev/null +++ b/include/pagx/nodes/Font.h @@ -0,0 +1,95 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include "pagx/nodes/Node.h" +#include "pagx/types/Point.h" + +namespace pagx { + +class Image; +class PathData; + +/** + * Glyph defines a single glyph's rendering data. Either path or image must be specified, not both. + */ +class Glyph : public Node { + public: + /** + * Vector glyph outline data. Mutually exclusive with image. + */ + PathData* path = nullptr; + + /** + * Bitmap glyph image resource. Mutually exclusive with path. + */ + Image* image = nullptr; + + /** + * Glyph offset in design space. Typically used for bitmap glyphs. The default value is (0,0). + */ + Point offset = {}; + + /** + * Horizontal advance width in design space (unitsPerEm coordinates). This value determines the + * spacing to the next glyph when using Default positioning mode. + */ + float advance = 0.0f; + + NodeType nodeType() const override { + return NodeType::Glyph; + } + + private: + Glyph() = default; + + friend class PAGXDocument; +}; + +/** + * Font defines an embedded font resource containing subsetted glyph data (vector outlines or + * bitmaps). PAGX files embed glyph data for complete self-containment, ensuring cross-platform + * rendering consistency. + */ +class Font : public Node { + public: + /** + * Units per em of the font design space. Rendering scale = fontSize / unitsPerEm. + * The default value is 1000. + */ + int unitsPerEm = 1000; + + /** + * The list of glyphs in this font. GlyphID is the index + 1 (GlyphID 0 is reserved for missing + * glyph). + */ + std::vector glyphs = {}; + + NodeType nodeType() const override { + return NodeType::Font; + } + + private: + Font() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/GlyphRun.h b/include/pagx/nodes/GlyphRun.h new file mode 100644 index 0000000000..c121e30a64 --- /dev/null +++ b/include/pagx/nodes/GlyphRun.h @@ -0,0 +1,108 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include "pagx/nodes/Node.h" +#include "pagx/types/Point.h" + +namespace pagx { + +class Font; + +/** + * GlyphRun defines pre-shaped glyph data for a segment of text. Each GlyphRun independently + * references a font resource. + */ +class GlyphRun : public Node { + public: + /** + * Reference to the Font resource. + */ + Font* font = nullptr; + + /** + * Font size for rendering. Actual scale = fontSize / font.unitsPerEm. The default value is 12. + */ + float fontSize = 12.0f; + + /** + * GlyphID sequence. GlyphID 0 indicates missing glyph (not rendered). + */ + std::vector glyphs = {}; + + /** + * Overall X offset. The default value is 0. + */ + float x = 0.0f; + + /** + * Overall Y offset. The default value is 0. + */ + float y = 0.0f; + + /** + * Per-glyph X offsets. Can be combined with positions. + * Final X = x + xOffsets[i] + positions[i].x + */ + std::vector xOffsets = {}; + + /** + * Per-glyph (x, y) offsets. Can be combined with x, y, and xOffsets. + * Final X = x + xOffsets[i] + positions[i].x + * Final Y = y + positions[i].y + */ + std::vector positions = {}; + + /** + * Per-glyph anchor point offsets relative to the default anchor (advance * 0.5, 0). + * The anchor is the center point for scale, rotation, and skew transforms. + */ + std::vector anchors = {}; + + /** + * Per-glyph scale factors (scaleX, scaleY). Default is (1, 1). + * Scaling is applied around the anchor point. + */ + std::vector scales = {}; + + /** + * Per-glyph rotation angles in degrees. Default is 0. + * Rotation is applied around the anchor point. + */ + std::vector rotations = {}; + + /** + * Per-glyph skew angles in degrees (along vertical axis). Default is 0. + * Skewing is applied around the anchor point. + */ + std::vector skews = {}; + + NodeType nodeType() const override { + return NodeType::GlyphRun; + } + + private: + GlyphRun() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/Group.h b/include/pagx/nodes/Group.h new file mode 100644 index 0000000000..293c3e0ed0 --- /dev/null +++ b/include/pagx/nodes/Group.h @@ -0,0 +1,84 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include "pagx/nodes/Element.h" +#include "pagx/types/Point.h" + +namespace pagx { + +/** + * Group is a container that groups multiple vector elements with its own transform. It can contain + * shapes, painters, modifiers, and nested groups, allowing for hierarchical organization of + * content. + */ +class Group : public Element { + public: + /** + * The anchor point for transformations. + */ + Point anchor = {}; + + /** + * The position offset of the group. + */ + Point position = {}; + + /** + * The rotation angle in degrees. The default value is 0. + */ + float rotation = 0.0f; + + /** + * The scale factor as (scaleX, scaleY). The default value is {1, 1}. + */ + Point scale = {1.0f, 1.0f}; + + /** + * The skew angle in degrees. The default value is 0. + */ + float skew = 0.0f; + + /** + * The axis angle in degrees for the skew transformation. The default value is 0. + */ + float skewAxis = 0.0f; + + /** + * The opacity of the group, ranging from 0 to 1. The default value is 1. + */ + float alpha = 1.0f; + + /** + * The child elements contained in this group. + */ + std::vector elements = {}; + + NodeType nodeType() const override { + return NodeType::Group; + } + + private: + Group() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/Image.h b/include/pagx/nodes/Image.h new file mode 100644 index 0000000000..771e2f9081 --- /dev/null +++ b/include/pagx/nodes/Image.h @@ -0,0 +1,54 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include "pagx/types/Data.h" +#include "pagx/nodes/Node.h" + +namespace pagx { + +/** + * Image represents an image resource that can be referenced by other nodes. The image source can + * be a file path, a URL, or a base64-encoded data URI. + */ +class Image : public Node { + public: + /** + * Image binary data (decoded from base64). + */ + std::shared_ptr data = nullptr; + + /** + * External file path (mutually exclusive with data, data has priority). + */ + std::string filePath = {}; + + NodeType nodeType() const override { + return NodeType::Image; + } + + private: + Image() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/ImagePattern.h b/include/pagx/nodes/ImagePattern.h new file mode 100644 index 0000000000..650e5fb4b5 --- /dev/null +++ b/include/pagx/nodes/ImagePattern.h @@ -0,0 +1,76 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/ColorSource.h" +#include "pagx/types/FilterMode.h" +#include "pagx/types/Matrix.h" +#include "pagx/types/MipmapMode.h" +#include "pagx/types/TileMode.h" + +namespace pagx { + +class Image; + +/** + * An image pattern color source that tiles an image to fill shapes. + */ +class ImagePattern : public ColorSource { + public: + /** + * A reference to an image resource. + */ + Image* image = nullptr; + + /** + * The tile mode for the horizontal direction. The default value is Clamp. + */ + TileMode tileModeX = TileMode::Clamp; + + /** + * The tile mode for the vertical direction. The default value is Clamp. + */ + TileMode tileModeY = TileMode::Clamp; + + /** + * The filter mode for texture sampling. The default value is Linear. + */ + FilterMode filterMode = FilterMode::Linear; + + /** + * The mipmap mode for texture sampling. The default value is Linear. + */ + MipmapMode mipmapMode = MipmapMode::Linear; + + /** + * The transformation matrix applied to the pattern. + */ + Matrix matrix = {}; + + NodeType nodeType() const override { + return NodeType::ImagePattern; + } + + private: + ImagePattern() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/InnerShadowFilter.h b/include/pagx/nodes/InnerShadowFilter.h new file mode 100644 index 0000000000..a585669c8a --- /dev/null +++ b/include/pagx/nodes/InnerShadowFilter.h @@ -0,0 +1,71 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/LayerFilter.h" +#include "pagx/types/Color.h" + +namespace pagx { + +/** + * An inner shadow filter that renders a shadow inside the layer content. + */ +class InnerShadowFilter : public LayerFilter { + public: + /** + * The horizontal offset of the shadow in pixels. The default value is 0. + */ + float offsetX = 0.0f; + + /** + * The vertical offset of the shadow in pixels. The default value is 0. + */ + float offsetY = 0.0f; + + /** + * The horizontal blur radius of the shadow in pixels. The default value is 0. + */ + float blurX = 0.0f; + + /** + * The vertical blur radius of the shadow in pixels. The default value is 0. + */ + float blurY = 0.0f; + + /** + * The color of the shadow. + */ + Color color = {}; + + /** + * Whether to render only the shadow without the original content. The default value is false. + */ + bool shadowOnly = false; + + NodeType nodeType() const override { + return NodeType::InnerShadowFilter; + } + + private: + InnerShadowFilter() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/InnerShadowStyle.h b/include/pagx/nodes/InnerShadowStyle.h new file mode 100644 index 0000000000..999b859efb --- /dev/null +++ b/include/pagx/nodes/InnerShadowStyle.h @@ -0,0 +1,66 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/LayerStyle.h" +#include "pagx/types/Color.h" + +namespace pagx { + +/** + * An inner shadow layer style that renders a shadow inside the layer content. + */ +class InnerShadowStyle : public LayerStyle { + public: + /** + * The horizontal offset of the shadow in pixels. The default value is 0. + */ + float offsetX = 0.0f; + + /** + * The vertical offset of the shadow in pixels. The default value is 0. + */ + float offsetY = 0.0f; + + /** + * The horizontal blur radius of the shadow in pixels. The default value is 0. + */ + float blurX = 0.0f; + + /** + * The vertical blur radius of the shadow in pixels. The default value is 0. + */ + float blurY = 0.0f; + + /** + * The color of the shadow. + */ + Color color = {}; + + NodeType nodeType() const override { + return NodeType::InnerShadowStyle; + } + + private: + InnerShadowStyle() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/Layer.h b/include/pagx/nodes/Layer.h new file mode 100644 index 0000000000..be8b2e5796 --- /dev/null +++ b/include/pagx/nodes/Layer.h @@ -0,0 +1,163 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include "pagx/nodes/Element.h" +#include "pagx/nodes/LayerFilter.h" +#include "pagx/nodes/LayerStyle.h" +#include "pagx/nodes/Node.h" +#include "pagx/types/BlendMode.h" +#include "pagx/types/MaskType.h" +#include "pagx/types/Matrix.h" +#include "pagx/types/Rect.h" + +namespace pagx { + +class Composition; + +/** + * Layer represents a layer node that can contain vector elements, layer styles, filters, and child + * layers. It is the main building block for composing visual content in a PAGX document. + */ +class Layer : public Node { + public: + /** + * The display name of the layer. + */ + std::string name = {}; + + /** + * Whether the layer is visible. The default value is true. + */ + bool visible = true; + + /** + * The opacity of the layer, ranging from 0 to 1. The default value is 1. + */ + float alpha = 1.0f; + + /** + * The blend mode used when compositing the layer. The default value is Normal. + */ + BlendMode blendMode = BlendMode::Normal; + + /** + * The x-coordinate of the layer position. The default value is 0. + */ + float x = 0.0f; + + /** + * The y-coordinate of the layer position. The default value is 0. + */ + float y = 0.0f; + + /** + * The 2D transformation matrix of the layer. + */ + Matrix matrix = {}; + + /** + * The 3D transformation matrix as a 16-element array in column-major order. + */ + std::vector matrix3D = {}; + + /** + * Whether to preserve 3D transformations for child layers. The default value is false. + */ + bool preserve3D = false; + + /** + * Whether to apply antialiasing to the layer edges. The default value is true. + */ + bool antiAlias = true; + + /** + * Whether to use group opacity mode for the layer and its children. The default value is false. + */ + bool groupOpacity = false; + + /** + * Whether layer effects pass through to the background. The default value is true. + */ + bool passThroughBackground = true; + + /** + * Whether to exclude child effects when applying layer styles. The default value is false. + */ + bool excludeChildEffectsInLayerStyle = false; + + /** + * The scroll rectangle for clipping the layer content. + */ + Rect scrollRect = {}; + + /** + * Whether a scroll rectangle is applied to the layer. The default value is false. + */ + bool hasScrollRect = false; + + /** + * A reference to a mask layer. + */ + Layer* mask = nullptr; + + /** + * The type of masking to apply (Alpha, Luminance, or Contour). + * The default value is Alpha. + */ + MaskType maskType = MaskType::Alpha; + + /** + * A reference to a composition used as the layer content. + */ + Composition* composition = nullptr; + + /** + * The vector elements contained in this layer (shapes, painters, modifiers, etc.). + */ + std::vector contents = {}; + + /** + * The layer styles applied to this layer (drop shadow, inner shadow, etc.). + */ + std::vector styles = {}; + + /** + * The filters applied to this layer (blur, color matrix, etc.). + */ + std::vector filters = {}; + + /** + * The child layers contained in this layer. + */ + std::vector children = {}; + + NodeType nodeType() const override { + return NodeType::Layer; + } + + private: + Layer() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/LayerFilter.h b/include/pagx/nodes/LayerFilter.h new file mode 100644 index 0000000000..22dfe67a33 --- /dev/null +++ b/include/pagx/nodes/LayerFilter.h @@ -0,0 +1,38 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/Node.h" + +namespace pagx { + +/** + * Base class for layer filters (BlurFilter, DropShadowFilter, InnerShadowFilter, BlendFilter, ColorMatrixFilter). + */ +class LayerFilter : public Node { + public: + ~LayerFilter() override = default; + + protected: + LayerFilter() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/LayerStyle.h b/include/pagx/nodes/LayerStyle.h new file mode 100644 index 0000000000..9b80330c0e --- /dev/null +++ b/include/pagx/nodes/LayerStyle.h @@ -0,0 +1,44 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/types/BlendMode.h" +#include "pagx/nodes/Node.h" + +namespace pagx { + +/** + * Base class for layer styles (DropShadowStyle, InnerShadowStyle, BackgroundBlurStyle). + */ +class LayerStyle : public Node { + public: + /** + * The blend mode used when compositing the style. The default value is Normal. + */ + BlendMode blendMode = BlendMode::Normal; + + ~LayerStyle() override = default; + + protected: + LayerStyle() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/LinearGradient.h b/include/pagx/nodes/LinearGradient.h new file mode 100644 index 0000000000..265438cb46 --- /dev/null +++ b/include/pagx/nodes/LinearGradient.h @@ -0,0 +1,64 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include "pagx/nodes/ColorSource.h" +#include "pagx/nodes/ColorStop.h" +#include "pagx/types/Matrix.h" +#include "pagx/types/Point.h" + +namespace pagx { + +/** + * A linear gradient color source that produces a gradient along a line between two points. + */ +class LinearGradient : public ColorSource { + public: + /** + * The starting point of the gradient line. + */ + Point startPoint = {}; + + /** + * The ending point of the gradient line. + */ + Point endPoint = {}; + + /** + * The transformation matrix applied to the gradient. + */ + Matrix matrix = {}; + + /** + * The color stops defining the gradient colors and positions. + */ + std::vector colorStops = {}; + + NodeType nodeType() const override { + return NodeType::LinearGradient; + } + + private: + LinearGradient() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/MergePath.h b/include/pagx/nodes/MergePath.h new file mode 100644 index 0000000000..52b81969af --- /dev/null +++ b/include/pagx/nodes/MergePath.h @@ -0,0 +1,47 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/Element.h" +#include "pagx/types/MergePathMode.h" + +namespace pagx { + +/** + * MergePath is a path modifier that merges multiple paths using boolean operations. It can append, + * add, subtract, intersect, or exclude paths from each other. + */ +class MergePath : public Element { + public: + /** + * The merge mode that determines how paths are combined. The default value is Append. + */ + MergePathMode mode = MergePathMode::Append; + + NodeType nodeType() const override { + return NodeType::MergePath; + } + + private: + MergePath() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/Node.h b/include/pagx/nodes/Node.h new file mode 100644 index 0000000000..6d6f1cd1a1 --- /dev/null +++ b/include/pagx/nodes/Node.h @@ -0,0 +1,224 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include + +namespace pagx { + +/** + * NodeType enumerates all types of nodes that can be stored in a PAGX document. This includes + * resources (Image, Composition, ColorSources) and elements (shapes, painters, modifiers, etc.). + */ +enum class NodeType { + // Resources + /** + * A reusable path data resource. + */ + PathData, + /** + * An image resource. + */ + Image, + /** + * A composition resource containing layers. + */ + Composition, + /** + * A solid color source. + */ + SolidColor, + /** + * A linear gradient color source. + */ + LinearGradient, + /** + * A radial gradient color source. + */ + RadialGradient, + /** + * A conic (sweep) gradient color source. + */ + ConicGradient, + /** + * A diamond gradient color source. + */ + DiamondGradient, + /** + * An image pattern color source. + */ + ImagePattern, + /** + * A color stop in a gradient. + */ + ColorStop, + /** + * An embedded font resource containing glyph data. + */ + Font, + /** + * A single glyph definition in a Font. + */ + Glyph, + + // Layer + /** + * A layer node that contains vector elements and child layers. + */ + Layer, + + // Layer Styles + /** + * A drop shadow layer style. + */ + DropShadowStyle, + /** + * An inner shadow layer style. + */ + InnerShadowStyle, + /** + * A background blur layer style. + */ + BackgroundBlurStyle, + + // Layer Filters + /** + * A blur filter. + */ + BlurFilter, + /** + * A drop shadow filter. + */ + DropShadowFilter, + /** + * An inner shadow filter. + */ + InnerShadowFilter, + /** + * A blend filter. + */ + BlendFilter, + /** + * A color matrix filter. + */ + ColorMatrixFilter, + + // Elements (shapes, painters, modifiers, containers) + /** + * A rectangle shape with optional rounded corners. + */ + Rectangle, + /** + * An ellipse shape defined by center point and size. + */ + Ellipse, + /** + * A polygon or star shape with configurable points and roundness. + */ + Polystar, + /** + * A freeform path shape defined by a PathData. + */ + Path, + /** + * A text element that generates glyphs for rendering. + */ + Text, + /** + * A fill painter that fills shapes with a color or gradient. + */ + Fill, + /** + * A stroke painter that outlines shapes with a color or gradient. + */ + Stroke, + /** + * A path modifier that trims paths to a specified range. + */ + TrimPath, + /** + * A path modifier that rounds the corners of shapes. + */ + RoundCorner, + /** + * A path modifier that merges multiple paths using boolean operations. + */ + MergePath, + /** + * A text animator that modifies text appearance with range-based transformations. + */ + TextModifier, + /** + * A text animator that places text along a path. + */ + TextPath, + /** + * A text modifier that controls text layout and alignment. + */ + TextLayout, + /** + * A container that groups multiple elements with its own transform. + */ + Group, + /** + * A modifier that creates multiple copies of preceding elements. + */ + Repeater, + + // Text Selectors + /** + * A range selector for text modifiers. + */ + RangeSelector, + /** + * A precomposed glyph run for text rendering. + */ + GlyphRun +}; + +/** + * Node is the base class for all shareable nodes in a PAGX document. Nodes can be stored in the + * document's resources list and referenced by ID (e.g., "@resourceId"). Each node has a unique + * identifier and a type. + */ +class Node { + public: + /** + * The unique identifier of this node. Used for referencing the node by ID (e.g., "@id"). + */ + std::string id = {}; + + /** + * Custom data attributes. The keys are stored without the "data-" prefix. + */ + std::unordered_map customData = {}; + + virtual ~Node() = default; + + /** + * Returns the node type of this node. + */ + virtual NodeType nodeType() const = 0; + + protected: + Node() = default; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/Path.h b/include/pagx/nodes/Path.h new file mode 100644 index 0000000000..bea1595cad --- /dev/null +++ b/include/pagx/nodes/Path.h @@ -0,0 +1,52 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/PathData.h" +#include "pagx/nodes/Element.h" + +namespace pagx { + +/** + * Path represents a freeform shape defined by a PathData containing vertices, in-tangents, and + * out-tangents. + */ +class Path : public Element { + public: + /** + * The path data containing vertices and control points. + */ + PathData* data = nullptr; + + /** + * Whether the path direction is reversed. The default value is false. + */ + bool reversed = false; + + NodeType nodeType() const override { + return NodeType::Path; + } + + private: + Path() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/PathData.h b/include/pagx/nodes/PathData.h new file mode 100644 index 0000000000..b75bc1e65b --- /dev/null +++ b/include/pagx/nodes/PathData.h @@ -0,0 +1,142 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include "pagx/nodes/Node.h" +#include "pagx/types/PathVerb.h" +#include "pagx/types/Point.h" +#include "pagx/types/Rect.h" + +namespace pagx { + +/** + * PathData stores path commands in a format optimized for fast iteration + * and serialization. Unlike tgfx::Path, it exposes raw data arrays directly. + * PathData can be stored in document resources and referenced by ID. + */ +class PathData : public Node { + public: + /** + * Starts a new contour at the specified point. + */ + void moveTo(float x, float y); + + /** + * Adds a line from the current point to the specified point. + */ + void lineTo(float x, float y); + + /** + * Adds a quadratic Bezier curve from the current point. + * @param cx control point x + * @param cy control point y + * @param x end point x + * @param y end point y + */ + void quadTo(float cx, float cy, float x, float y); + + /** + * Adds a cubic Bezier curve from the current point. + * @param c1x first control point x + * @param c1y first control point y + * @param c2x second control point x + * @param c2y second control point y + * @param x end point x + * @param y end point y + */ + void cubicTo(float c1x, float c1y, float c2x, float c2y, float x, float y); + + /** + * Closes the current contour by connecting to the starting point. + */ + void close(); + + /** + * Returns the array of path commands. + */ + const std::vector& verbs() const { + return _verbs; + } + + /** + * Returns the array of points. + */ + const std::vector& points() const { + return _points; + } + + /** + * Returns the number of points. + */ + size_t countPoints() const { + return _points.size(); + } + + /** + * Iterates over all path commands with a visitor function. + * The visitor receives the verb and a pointer to the point data. + */ + template + void forEach(Visitor&& visitor) const { + size_t pointIndex = 0; + for (auto verb : _verbs) { + const Point* pts = _points.data() + pointIndex; + visitor(verb, pts); + pointIndex += PointsPerVerb(verb); + } + } + + /** + * Returns the control-point bounding box of the path, which encloses all on-curve and off-curve + * points but may be larger than the tight geometric bounds. + */ + Rect getBounds(); + + /** + * Returns true if the path contains no commands. + */ + bool isEmpty() const { + return _verbs.empty(); + } + + /** + * Returns the number of points used by the given verb. + */ + static int PointsPerVerb(PathVerb verb); + + NodeType nodeType() const override { + return NodeType::PathData; + } + + private: + PathData() = default; + + std::vector _verbs = {}; + std::vector _points = {}; + Rect _cachedBounds = {}; + bool _boundsDirty = true; + + friend class PAGXDocument; + friend class SVGParserContext; + friend PathData PathDataFromSVGString(const std::string& d); +}; + +} // namespace pagx diff --git a/include/pagx/nodes/Polystar.h b/include/pagx/nodes/Polystar.h new file mode 100644 index 0000000000..049e108395 --- /dev/null +++ b/include/pagx/nodes/Polystar.h @@ -0,0 +1,88 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/Element.h" +#include "pagx/types/Point.h" +#include "pagx/types/PolystarType.h" + +namespace pagx { + +/** + * Polystar represents a polygon or star shape with configurable points, radii, and roundness. + */ +class Polystar : public Element { + public: + /** + * The center point of the polystar. + */ + Point center = {}; + + /** + * The type of polystar shape, either Star or Polygon. The default value is Star. + */ + PolystarType type = PolystarType::Star; + + /** + * The number of points in the polystar. The default value is 5. + */ + float pointCount = 5.0f; + + /** + * The outer radius of the polystar. The default value is 100. + */ + float outerRadius = 100.0f; + + /** + * The inner radius of the polystar. Only applies when type is Star. The default value is 50. + */ + float innerRadius = 50.0f; + + /** + * The rotation angle in degrees. The default value is 0. + */ + float rotation = 0.0f; + + /** + * The roundness of the outer points, ranging from 0 to 100. The default value is 0. + */ + float outerRoundness = 0.0f; + + /** + * The roundness of the inner points, ranging from 0 to 100. Only applies when type is Star. The + * default value is 0. + */ + float innerRoundness = 0.0f; + + /** + * Whether the path direction is reversed. The default value is false. + */ + bool reversed = false; + + NodeType nodeType() const override { + return NodeType::Polystar; + } + + private: + Polystar() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/RadialGradient.h b/include/pagx/nodes/RadialGradient.h new file mode 100644 index 0000000000..80ca5cf955 --- /dev/null +++ b/include/pagx/nodes/RadialGradient.h @@ -0,0 +1,64 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include "pagx/nodes/ColorSource.h" +#include "pagx/nodes/ColorStop.h" +#include "pagx/types/Matrix.h" +#include "pagx/types/Point.h" + +namespace pagx { + +/** + * A radial gradient color source that produces a gradient radiating from a center point. + */ +class RadialGradient : public ColorSource { + public: + /** + * The center point of the gradient. + */ + Point center = {}; + + /** + * The radius of the gradient circle. + */ + float radius = 0.0f; + + /** + * The transformation matrix applied to the gradient. + */ + Matrix matrix = {}; + + /** + * The color stops defining the gradient colors and positions. + */ + std::vector colorStops = {}; + + NodeType nodeType() const override { + return NodeType::RadialGradient; + } + + private: + RadialGradient() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/RangeSelector.h b/include/pagx/nodes/RangeSelector.h new file mode 100644 index 0000000000..9c6712bfbb --- /dev/null +++ b/include/pagx/nodes/RangeSelector.h @@ -0,0 +1,100 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/TextSelector.h" +#include "pagx/types/SelectorTypes.h" + +namespace pagx { + +/** + * A range selector that defines which characters in a text are affected by a text modifier. It + * provides flexible control over character selection through start/end positions, shapes, and + * randomization. + */ +class RangeSelector : public TextSelector { + public: + /** + * The starting position of the selection range, in units defined by the unit property. + * The default value is 0. + */ + float start = 0.0f; + + /** + * The ending position of the selection range, in units defined by the unit property. The default + * value is 1. + */ + float end = 1.0f; + + /** + * The offset to shift the selection range. The default value is 0. + */ + float offset = 0.0f; + + /** + * The unit used for start, end, and offset values. The default value is Percentage. + */ + SelectorUnit unit = SelectorUnit::Percentage; + + /** + * The shape of the selection falloff curve. The default value is Square. + */ + SelectorShape shape = SelectorShape::Square; + + /** + * The ease-in amount for the selection shape, ranging from 0 to 1. The default value is 0. + */ + float easeIn = 0.0f; + + /** + * The ease-out amount for the selection shape, ranging from 0 to 1. The default value is 0. + */ + float easeOut = 0.0f; + + /** + * The mode for combining multiple selectors. The default value is Add. + */ + SelectorMode mode = SelectorMode::Add; + + /** + * The weight of this selector's influence, ranging from 0 to 1. The default value is 1. + */ + float weight = 1.0f; + + /** + * Whether to randomize the order of character selection. The default value is false. + */ + bool randomOrder = false; + + /** + * The seed for random order generation. The default value is 0. + */ + int randomSeed = 0; + + NodeType nodeType() const override { + return NodeType::RangeSelector; + } + + private: + RangeSelector() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/Rectangle.h b/include/pagx/nodes/Rectangle.h new file mode 100644 index 0000000000..334e888ff4 --- /dev/null +++ b/include/pagx/nodes/Rectangle.h @@ -0,0 +1,62 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/Element.h" +#include "pagx/types/Point.h" +#include "pagx/types/Size.h" + +namespace pagx { + +/** + * Rectangle represents a rectangle shape with optional rounded corners. + */ +class Rectangle : public Element { + public: + /** + * The center point of the rectangle. + */ + Point center = {}; + + /** + * The size of the rectangle. The default value is {100, 100}. + */ + Size size = {100.0f, 100.0f}; + + /** + * The corner roundness of the rectangle, ranging from 0 to 100. The default value is 0. + */ + float roundness = 0.0f; + + /** + * Whether the path direction is reversed. The default value is false. + */ + bool reversed = false; + + NodeType nodeType() const override { + return NodeType::Rectangle; + } + + private: + Rectangle() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/Repeater.h b/include/pagx/nodes/Repeater.h new file mode 100644 index 0000000000..6ebcffe357 --- /dev/null +++ b/include/pagx/nodes/Repeater.h @@ -0,0 +1,90 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/Element.h" +#include "pagx/types/Point.h" +#include "pagx/types/RepeaterOrder.h" + +namespace pagx { + +/** + * Repeater is a modifier that creates multiple copies of preceding elements with progressive + * transformations. Each copy can have an incremental offset in position, rotation, scale, and + * opacity. + */ +class Repeater : public Element { + public: + /** + * The number of copies to create. The default value is 3. + */ + float copies = 3.0f; + + /** + * The offset applied to the copy index, allowing fractional copies. The default value is 0. + */ + float offset = 0.0f; + + /** + * The stacking order of copies (BelowOriginal or AboveOriginal). The default value is + * BelowOriginal. + */ + RepeaterOrder order = RepeaterOrder::BelowOriginal; + + /** + * The anchor point for transformations. + */ + Point anchor = {}; + + /** + * The position offset applied between each copy. The default value is {100, 100}. + */ + Point position = {100.0f, 100.0f}; + + /** + * The rotation angle in degrees applied between each copy. The default value is 0. + */ + float rotation = 0.0f; + + /** + * The scale factor applied between each copy. The default value is {1, 1}. + */ + Point scale = {1.0f, 1.0f}; + + /** + * The starting opacity for the first copy, ranging from 0 to 1. The default value is 1. + */ + float startAlpha = 1.0f; + + /** + * The ending opacity for the last copy, ranging from 0 to 1. The default value is 1. + */ + float endAlpha = 1.0f; + + NodeType nodeType() const override { + return NodeType::Repeater; + } + + private: + Repeater() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/RoundCorner.h b/include/pagx/nodes/RoundCorner.h new file mode 100644 index 0000000000..5d40313db3 --- /dev/null +++ b/include/pagx/nodes/RoundCorner.h @@ -0,0 +1,46 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/Element.h" + +namespace pagx { + +/** + * RoundCorner is a path modifier that rounds the corners of shapes by adding smooth curves at + * sharp vertices. + */ +class RoundCorner : public Element { + public: + /** + * The radius of the rounded corners in pixels. The default value is 10. + */ + float radius = 10.0f; + + NodeType nodeType() const override { + return NodeType::RoundCorner; + } + + private: + RoundCorner() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/SolidColor.h b/include/pagx/nodes/SolidColor.h new file mode 100644 index 0000000000..09b8d89045 --- /dev/null +++ b/include/pagx/nodes/SolidColor.h @@ -0,0 +1,46 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/ColorSource.h" +#include "pagx/types/Color.h" + +namespace pagx { + +/** + * A solid color source for fills and strokes. + */ +class SolidColor : public ColorSource { + public: + /** + * The color value with RGBA components and color space. + */ + Color color = {}; + + NodeType nodeType() const override { + return NodeType::SolidColor; + } + + private: + SolidColor() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/Stroke.h b/include/pagx/nodes/Stroke.h new file mode 100644 index 0000000000..d0ced49fce --- /dev/null +++ b/include/pagx/nodes/Stroke.h @@ -0,0 +1,112 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include "pagx/nodes/ColorSource.h" +#include "pagx/nodes/Element.h" +#include "pagx/types/BlendMode.h" +#include "pagx/types/LayerPlacement.h" +#include "pagx/types/StrokeStyle.h" + +namespace pagx { + +/** + * Stroke represents a stroke painter that outlines shapes with a solid color, gradient, or + * pattern. It supports various line cap styles, join styles, dash patterns, and stroke alignment. + * The color is specified through a ColorSource node (SolidColor, LinearGradient, etc.) or + * a reference to a defined color source (e.g., "@gradientId"). + */ +class Stroke : public Element { + public: + /** + * The color source for this stroke. Can be a SolidColor, LinearGradient, RadialGradient, + * ConicGradient, DiamondGradient, or ImagePattern. + */ + ColorSource* color = nullptr; + + /** + * The stroke width in pixels. The default value is 1. + */ + float width = 1.0f; + + /** + * The opacity of the stroke, ranging from 0 (transparent) to 1 (opaque). The default value is 1. + */ + float alpha = 1.0f; + + /** + * The blend mode used when compositing the stroke. The default value is Normal. + */ + BlendMode blendMode = BlendMode::Normal; + + /** + * The line cap style at the endpoints of open paths. The default value is Butt. + */ + LineCap cap = LineCap::Butt; + + /** + * The line join style at path corners. The default value is Miter. + */ + LineJoin join = LineJoin::Miter; + + /** + * The limit for miter joins before they are beveled. The default value is 4. + */ + float miterLimit = 4.0f; + + /** + * The dash pattern as an array of dash and gap lengths. An empty array means a solid line. + */ + std::vector dashes = {}; + + /** + * The offset into the dash pattern. The default value is 0. + */ + float dashOffset = 0.0f; + + /** + * Whether to scale the dash intervals so that the dash segments have the same length. The default + * value is false. + */ + bool dashAdaptive = false; + + /** + * The alignment of the stroke relative to the path (Center, Inner, or Outer). The default value + * is Center. + */ + StrokeAlign align = StrokeAlign::Center; + + /** + * The placement of the stroke relative to fills (Background or Foreground). The default value is + * Background. + */ + LayerPlacement placement = LayerPlacement::Background; + + NodeType nodeType() const override { + return NodeType::Stroke; + } + + private: + Stroke() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/Text.h b/include/pagx/nodes/Text.h new file mode 100644 index 0000000000..d004568161 --- /dev/null +++ b/include/pagx/nodes/Text.h @@ -0,0 +1,95 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include "pagx/nodes/Element.h" +#include "pagx/nodes/GlyphRun.h" +#include "pagx/types/Point.h" + +namespace pagx { + +/** + * Text is a geometry element that produces a glyph list after text shaping, which accumulates into + * the rendering context for subsequent modifiers or painters. It supports two rendering modes: + * - Pre-shaped mode: Uses GlyphRun children with precomputed glyph IDs and positions, rendered + * with embedded fonts to ensure cross-platform consistency. + * - Runtime shaping mode: Performs text shaping at runtime using the text content and font + * properties. Results may vary slightly across platforms due to font and shaping differences. + */ +class Text : public Element { + public: + /** + * The text content to render. Supports newline characters (\n) for line breaks, which use a + * default line height of 1.2 times the font size. + */ + std::string text = {}; + + /** + * The position of the text origin (x, y where y is the baseline). This can be overridden by + * TextLayout or TextPath modifiers. The default value is (0, 0). + */ + Point position = {}; + + /** + * The font family name (e.g., "Arial", "Helvetica"). Used for runtime shaping. + */ + std::string fontFamily = {}; + + /** + * The font style/variant name (e.g., "Regular", "Bold", "Italic", "Bold Italic"). This + * corresponds to the specific font file variant. The default value is an empty string, which + * typically resolves to "Regular" during font matching. + */ + std::string fontStyle = {}; + + /** + * The font size in pixels. The default value is 12. + */ + float fontSize = 12.0f; + + /** + * The letter spacing (tracking) value that adjusts spacing between characters. The default value + * is 0. + */ + float letterSpacing = 0.0f; + + /** + * The baseline shift for superscript/subscript effects. Positive values shift up, negative values + * shift down. The default value is 0. + */ + float baselineShift = 0.0f; + + /** + * Pre-shaped glyph runs. When present, these are used for rendering instead of runtime shaping. + */ + std::vector glyphRuns = {}; + + NodeType nodeType() const override { + return NodeType::Text; + } + + private: + Text() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/TextLayout.h b/include/pagx/nodes/TextLayout.h new file mode 100644 index 0000000000..1537b8f089 --- /dev/null +++ b/include/pagx/nodes/TextLayout.h @@ -0,0 +1,88 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/Element.h" +#include "pagx/types/Point.h" +#include "pagx/types/TextAlign.h" +#include "pagx/types/TextLayoutTypes.h" + +namespace pagx { + +/** + * TextLayout is a text modifier that controls text layout and alignment for accumulated Text + * elements. It overrides the position of Text elements and provides layout capabilities including: + * - Point text mode (no width): position and alignment relative to anchor point + * - Paragraph text mode (with width): automatic line wrapping within bounds + * - Vertical alignment (when height is specified) + * - Horizontal/vertical writing mode + */ +class TextLayout : public Element { + public: + /** + * The position of the layout origin. The default value is (0, 0). + */ + Point position = {}; + + /** + * The width of the layout area in pixels. When specified (> 0), enables automatic line wrapping + * (paragraph text mode). A value of 0 or negative means point text mode (no wrapping). + * The default value is 0. + */ + float width = 0.0f; + + /** + * The height of the layout area in pixels. When specified (> 0), enables vertical alignment. + * A value of 0 or negative means auto-height. The default value is 0. + */ + float height = 0.0f; + + /** + * The horizontal text alignment. The default value is Start. + */ + TextAlign textAlign = TextAlign::Start; + + /** + * The vertical text alignment (only effective when height > 0). The default value is Top. + */ + VerticalAlign verticalAlign = VerticalAlign::Top; + + /** + * The writing mode (horizontal or vertical text). The default value is Horizontal. Vertical mode + * uses right-to-left column ordering (traditional CJK vertical text). + */ + WritingMode writingMode = WritingMode::Horizontal; + + /** + * The line height multiplier. Applied to font size to calculate line spacing. The default value + * is 1.2. + */ + float lineHeight = 1.2f; + + NodeType nodeType() const override { + return NodeType::TextLayout; + } + + private: + TextLayout() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/TextModifier.h b/include/pagx/nodes/TextModifier.h new file mode 100644 index 0000000000..5d3f4dc3d4 --- /dev/null +++ b/include/pagx/nodes/TextModifier.h @@ -0,0 +1,102 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include "pagx/nodes/Element.h" +#include "pagx/nodes/TextSelector.h" +#include "pagx/types/Point.h" +#include "pagx/types/Color.h" + +namespace pagx { + +/** + * TextModifier is a text animator that modifies text appearance with range-based transformations. + * It applies transformations like position, rotation, scale, and color changes to selected ranges + * of characters. + */ +class TextModifier : public Element { + public: + /** + * The anchor point for transformations. + */ + Point anchor = {}; + + /** + * The position offset applied to selected characters. + */ + Point position = {}; + + /** + * The rotation angle in degrees applied to selected characters. The default value is 0. + */ + float rotation = 0.0f; + + /** + * The scale factor applied to selected characters. The default value is {1, 1}. + */ + Point scale = {1.0f, 1.0f}; + + /** + * The skew angle in degrees applied to selected characters. The default value is 0. + */ + float skew = 0.0f; + + /** + * The axis angle in degrees for the skew transformation. The default value is 0. + */ + float skewAxis = 0.0f; + + /** + * The opacity applied to selected characters, ranging from 0 to 1. The default value is 1. + */ + float alpha = 1.0f; + + /** + * The fill color override for selected characters. + */ + std::optional fillColor = std::nullopt; + + /** + * The stroke color override for selected characters. + */ + std::optional strokeColor = std::nullopt; + + /** + * The stroke width override for selected characters. + */ + std::optional strokeWidth = std::nullopt; + + /** + * The selectors that determine which characters are affected by this modifier. + */ + std::vector selectors = {}; + + NodeType nodeType() const override { + return NodeType::TextModifier; + } + + private: + TextModifier() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/TextPath.h b/include/pagx/nodes/TextPath.h new file mode 100644 index 0000000000..52d3f98db0 --- /dev/null +++ b/include/pagx/nodes/TextPath.h @@ -0,0 +1,90 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/Element.h" +#include "pagx/nodes/PathData.h" +#include "pagx/types/Point.h" + +namespace pagx { + +/** + * TextPath is a text modifier that places text along a path. It allows text to follow the contour + * of a path shape. The path can be specified either inline or by referencing a PathData resource. + */ +class TextPath : public Element { + public: + /** + * The path data that the text follows. + */ + PathData* path = nullptr; + + /** + * The origin point of the baseline in the TextPath's local coordinate space. The baseline is a + * straight line starting from this origin at the angle specified by baselineAngle. Each glyph's + * distance along the baseline determines where it lands on the curve, and its perpendicular offset + * from the baseline is preserved as a perpendicular offset from the curve. Default is (0, 0). + */ + Point baselineOrigin = {}; + + /** + * The angle of the baseline in degrees. 0 means a horizontal baseline (text flows left to right + * along the X axis), 90 means a vertical baseline (text flows top to bottom along the Y axis). + * The default value is 0. + */ + float baselineAngle = 0.0f; + + /** + * The margin from the start of the path in pixels. The default value is 0. + */ + float firstMargin = 0.0f; + + /** + * The margin from the end of the path in pixels. The default value is 0. + */ + float lastMargin = 0.0f; + + /** + * Whether characters are rotated to be perpendicular to the path. The default value is true. + */ + bool perpendicular = true; + + /** + * Whether to reverse the direction of the path. The default value is false. + */ + bool reversed = false; + + /** + * Whether text is stretched to fit the available path length. When enabled, glyphs are laid out + * consecutively using their advance widths, then spacing is adjusted proportionally to fill the + * path region between firstMargin and lastMargin. The default value is false. + */ + bool forceAlignment = false; + + NodeType nodeType() const override { + return NodeType::TextPath; + } + + private: + TextPath() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/TextSelector.h b/include/pagx/nodes/TextSelector.h new file mode 100644 index 0000000000..4c8f96bab7 --- /dev/null +++ b/include/pagx/nodes/TextSelector.h @@ -0,0 +1,38 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/Node.h" + +namespace pagx { + +/** + * Base class for text selectors. + */ +class TextSelector : public Node { + public: + ~TextSelector() override = default; + + protected: + TextSelector() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/nodes/TrimPath.h b/include/pagx/nodes/TrimPath.h new file mode 100644 index 0000000000..a954cdefef --- /dev/null +++ b/include/pagx/nodes/TrimPath.h @@ -0,0 +1,68 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/nodes/Element.h" +#include "pagx/types/TrimType.h" + +namespace pagx { + +/** + * TrimPath is a path modifier that trims paths to a specified range. It can be used to animate + * path drawing or reveal effects by adjusting the start and end values. + */ +class TrimPath : public Element { + public: + /** + * The starting point of the trim as a percentage of the path length, ranging from 0 to 1. The + * default value is 0. + */ + float start = 0.0f; + + /** + * The ending point of the trim as a percentage of the path length, ranging from 0 to 1. The + * default value is 1. + */ + float end = 1.0f; + + /** + * The offset to shift the trim range along the path. The value is in degrees, where 360 degrees + * equals a full cycle of the path length. For example, 180 degrees shifts the trim range by half + * the path. The default value is 0. + */ + float offset = 0.0f; + + /** + * The trim type that determines how multiple paths are trimmed. Separate trims each path + * individually, while Continuous trims all paths as one continuous path. The default value is + * Separate. + */ + TrimType type = TrimType::Separate; + + NodeType nodeType() const override { + return NodeType::TrimPath; + } + + private: + TrimPath() = default; + + friend class PAGXDocument; +}; + +} // namespace pagx diff --git a/include/pagx/types/BlendMode.h b/include/pagx/types/BlendMode.h new file mode 100644 index 0000000000..019b2afcbb --- /dev/null +++ b/include/pagx/types/BlendMode.h @@ -0,0 +1,105 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * Blend modes for compositing layers and colors. + */ +enum class BlendMode { + /** + * Normal blending, the source color replaces the destination. + */ + Normal, + /** + * Multiplies the source and destination colors. + */ + Multiply, + /** + * Inverts, multiplies, and inverts again, resulting in a lighter image. + */ + Screen, + /** + * Combines Multiply and Screen modes depending on the destination color. + */ + Overlay, + /** + * Retains the darker of the source and destination colors. + */ + Darken, + /** + * Retains the lighter of the source and destination colors. + */ + Lighten, + /** + * Brightens the destination color to reflect the source color. + */ + ColorDodge, + /** + * Darkens the destination color to reflect the source color. + */ + ColorBurn, + /** + * Combines Multiply and Screen modes depending on the source color. + */ + HardLight, + /** + * A softer version of Hard Light, producing a more subtle effect. + */ + SoftLight, + /** + * Subtracts the darker color from the lighter color. + */ + Difference, + /** + * Similar to Difference but with lower contrast. + */ + Exclusion, + /** + * Creates a result color with the hue of the source and the saturation and luminosity of the + * destination. + */ + Hue, + /** + * Creates a result color with the saturation of the source and the hue and luminosity of + * the destination. + */ + Saturation, + /** + * Creates a result color with the hue and saturation of the source and the luminosity of the + * destination. + */ + Color, + /** + * Creates a result color with the luminosity of the source and the hue and saturation of the + * destination. + */ + Luminosity, + /** + * Adds the source and destination colors, clamping to white. + */ + PlusLighter, + /** + * Adds the source and destination colors, clamping to black. + */ + PlusDarker +}; + +} // namespace pagx diff --git a/include/pagx/types/Color.h b/include/pagx/types/Color.h new file mode 100644 index 0000000000..fba47e69c3 --- /dev/null +++ b/include/pagx/types/Color.h @@ -0,0 +1,64 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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/types/ColorSpace.h" + +namespace pagx { + +/** + * An RGBA color with floating-point components and color space. + */ +struct Color { + /** + * Red component in [0, 1] range. + */ + float red = 0; + + /** + * Green component in [0, 1] range. + */ + float green = 0; + + /** + * Blue component in [0, 1] range. + */ + float blue = 0; + + /** + * Alpha component, in [0, 1] range. Default is 1 (fully opaque). + */ + float alpha = 1; + + /** + * The color space of this color. Default is sRGB. + */ + ColorSpace colorSpace = ColorSpace::SRGB; + + bool operator==(const Color& other) const { + return red == other.red && green == other.green && blue == other.blue && alpha == other.alpha && + colorSpace == other.colorSpace; + } + + bool operator!=(const Color& other) const { + return !(*this == other); + } +}; + +} // namespace pagx diff --git a/include/pagx/types/ColorSpace.h b/include/pagx/types/ColorSpace.h new file mode 100644 index 0000000000..05359b2d4a --- /dev/null +++ b/include/pagx/types/ColorSpace.h @@ -0,0 +1,40 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * Color space enumeration for color values. + */ +enum class ColorSpace { + /** + * Standard RGB color space (sRGB). The most common color space for web and displays. + * Component values are typically in [0, 1] range. + */ + SRGB, + + /** + * Display P3 color space, a wide gamut color space used by Apple devices. + * Component values may exceed [0, 1] range to represent colors outside sRGB gamut. + */ + DisplayP3 +}; + +} // namespace pagx diff --git a/include/pagx/types/Data.h b/include/pagx/types/Data.h new file mode 100644 index 0000000000..3638b5d822 --- /dev/null +++ b/include/pagx/types/Data.h @@ -0,0 +1,87 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include + +namespace pagx { + +/** + * Data holds an immutable byte buffer. + */ +class Data { + public: + /** + * Creates a Data object by copying the specified data. + */ + static std::shared_ptr MakeWithCopy(const void* data, size_t length); + + /** + * Creates a Data object that takes ownership of the given buffer. + * @param data A buffer allocated with new[]. Data takes ownership and will delete[] it. + * @param length The byte size of the buffer. + */ + static std::shared_ptr MakeAdopt(uint8_t* data, size_t length); + + ~Data(); + + Data(const Data&) = delete; + Data& operator=(const Data&) = delete; + Data(Data&&) = delete; + Data& operator=(Data&&) = delete; + + /** + * Returns the memory address of the data. + */ + const void* data() const { + return _data; + } + + /** + * Returns the read-only memory address of the data cast to uint8_t*. + */ + const uint8_t* bytes() const { + return _data; + } + + /** + * Returns the byte size. + */ + size_t size() const { + return _size; + } + + /** + * Returns true if the Data is empty. + */ + bool empty() const { + return _size == 0; + } + + private: + Data(const void* data, size_t length); + Data(uint8_t* data, size_t length, bool adopt); + + const uint8_t* _data = nullptr; + size_t _size = 0; +}; + +} // namespace pagx diff --git a/include/pagx/types/FillRule.h b/include/pagx/types/FillRule.h new file mode 100644 index 0000000000..6b5bba1658 --- /dev/null +++ b/include/pagx/types/FillRule.h @@ -0,0 +1,37 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * Fill rules that determine the interior of self-intersecting paths. + */ +enum class FillRule { + /** + * Non-zero winding rule. A point is inside if the winding number is non-zero. + */ + Winding, + /** + * Even-odd rule. A point is inside if the number of crossings is odd. + */ + EvenOdd +}; + +} // namespace pagx diff --git a/include/pagx/types/FilterMode.h b/include/pagx/types/FilterMode.h new file mode 100644 index 0000000000..77511149e1 --- /dev/null +++ b/include/pagx/types/FilterMode.h @@ -0,0 +1,37 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * FilterMode defines how texture sampling is performed when a texture is minified or magnified. + */ +enum class FilterMode { + /** + * Single sample point (the nearest neighbor). + */ + Nearest, + /** + * Interpolate between 2x2 sample points (bi-linear interpolation). + */ + Linear +}; + +} // namespace pagx diff --git a/include/pagx/types/LayerPlacement.h b/include/pagx/types/LayerPlacement.h new file mode 100644 index 0000000000..ee47b0072f --- /dev/null +++ b/include/pagx/types/LayerPlacement.h @@ -0,0 +1,37 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * Placement of fill or stroke relative to other painters. + */ +enum class LayerPlacement { + /** + * Place behind other painters (rendered first). + */ + Background, + /** + * Place in front of other painters (rendered last). + */ + Foreground +}; + +} // namespace pagx diff --git a/include/pagx/types/MaskType.h b/include/pagx/types/MaskType.h new file mode 100644 index 0000000000..62b8f1b4ca --- /dev/null +++ b/include/pagx/types/MaskType.h @@ -0,0 +1,41 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * Mask types that define how a mask layer affects its target. + */ +enum class MaskType { + /** + * Use the alpha channel of the mask to determine visibility. + */ + Alpha, + /** + * Use the luminance (brightness) of the mask to determine visibility. + */ + Luminance, + /** + * Use the contour (outline) of the mask for masking. + */ + Contour +}; + +} // namespace pagx diff --git a/include/pagx/types/Matrix.h b/include/pagx/types/Matrix.h new file mode 100644 index 0000000000..e4cad06c40 --- /dev/null +++ b/include/pagx/types/Matrix.h @@ -0,0 +1,144 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include "pagx/types/Point.h" + +namespace pagx { + +/** + * A 2D affine transformation matrix. + * Matrix form: + * | a c tx | + * | b d ty | + * | 0 0 1 | + */ +struct Matrix { + /** + * The horizontal scale factor (scaleX). + */ + float a = 1; + + /** + * The vertical skew factor (skewY). + */ + float b = 0; + + /** + * The horizontal skew factor (skewX). + */ + float c = 0; + + /** + * The vertical scale factor (scaleY). + */ + float d = 1; + + /** + * The horizontal translation (transX). + */ + float tx = 0; + + /** + * The vertical translation (transY). + */ + float ty = 0; + + /** + * Returns the identity matrix. + */ + static Matrix Identity() { + return {}; + } + + /** + * Returns a translation matrix. + */ + static Matrix Translate(float x, float y) { + Matrix m = {}; + m.tx = x; + m.ty = y; + return m; + } + + /** + * Returns a scale matrix. + */ + static Matrix Scale(float sx, float sy) { + Matrix m = {}; + m.a = sx; + m.d = sy; + return m; + } + + /** + * Returns a rotation matrix (angle in degrees). + */ + static Matrix Rotate(float degrees) { + Matrix m = {}; + float radians = degrees * 3.14159265358979323846f / 180.0f; + float cosVal = std::cos(radians); + float sinVal = std::sin(radians); + m.a = cosVal; + m.b = sinVal; + m.c = -sinVal; + m.d = cosVal; + return m; + } + + /** + * Returns true if this is the identity matrix. + */ + bool isIdentity() const { + return a == 1 && b == 0 && c == 0 && d == 1 && tx == 0 && ty == 0; + } + + /** + * Concatenates this matrix with another. + */ + Matrix operator*(const Matrix& other) const { + Matrix result = {}; + result.a = a * other.a + c * other.b; + result.b = b * other.a + d * other.b; + result.c = a * other.c + c * other.d; + result.d = b * other.c + d * other.d; + result.tx = a * other.tx + c * other.ty + tx; + result.ty = b * other.tx + d * other.ty + ty; + return result; + } + + /** + * Transforms a point by this matrix. + */ + Point mapPoint(const Point& point) const { + return {a * point.x + c * point.y + tx, b * point.x + d * point.y + ty}; + } + + bool operator==(const Matrix& other) const { + return a == other.a && b == other.b && c == other.c && d == other.d && tx == other.tx && + ty == other.ty; + } + + bool operator!=(const Matrix& other) const { + return !(*this == other); + } +}; + +} // namespace pagx diff --git a/include/pagx/types/MergePathMode.h b/include/pagx/types/MergePathMode.h new file mode 100644 index 0000000000..cc04c417cb --- /dev/null +++ b/include/pagx/types/MergePathMode.h @@ -0,0 +1,49 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * Path merge modes for boolean operations. + */ +enum class MergePathMode { + /** + * Append all paths without any boolean operation. + */ + Append, + /** + * Union (add) all paths together. + */ + Union, + /** + * Intersect all paths, keeping only overlapping areas. + */ + Intersect, + /** + * Exclusive-or of all paths, keeping non-overlapping areas. + */ + Xor, + /** + * Subtract subsequent paths from the first path. + */ + Difference +}; + +} // namespace pagx diff --git a/include/pagx/types/MipmapMode.h b/include/pagx/types/MipmapMode.h new file mode 100644 index 0000000000..cb965d2c8f --- /dev/null +++ b/include/pagx/types/MipmapMode.h @@ -0,0 +1,41 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * MipmapMode defines how mipmap levels are selected during texture sampling. + */ +enum class MipmapMode { + /** + * Ignore mipmap levels, sample from the "base". + */ + None, + /** + * Sample from the nearest level. + */ + Nearest, + /** + * Interpolate between the two nearest levels. + */ + Linear +}; + +} // namespace pagx diff --git a/include/pagx/types/PathVerb.h b/include/pagx/types/PathVerb.h new file mode 100644 index 0000000000..e5877feeb3 --- /dev/null +++ b/include/pagx/types/PathVerb.h @@ -0,0 +1,34 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * Path command types. + */ +enum class PathVerb { + Move, // 1 point: destination + Line, // 1 point: end point + Quad, // 2 points: control point, end point + Cubic, // 3 points: control point 1, control point 2, end point + Close // 0 points +}; + +} // namespace pagx diff --git a/include/pagx/types/Point.h b/include/pagx/types/Point.h new file mode 100644 index 0000000000..24158a4c12 --- /dev/null +++ b/include/pagx/types/Point.h @@ -0,0 +1,46 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * A point with x and y coordinates. + */ +struct Point { + /** + * The x coordinate. + */ + float x = 0; + + /** + * The y coordinate. + */ + float y = 0; + + bool operator==(const Point& other) const { + return x == other.x && y == other.y; + } + + bool operator!=(const Point& other) const { + return !(*this == other); + } +}; + +} // namespace pagx diff --git a/include/pagx/types/PolystarType.h b/include/pagx/types/PolystarType.h new file mode 100644 index 0000000000..a0cdad005c --- /dev/null +++ b/include/pagx/types/PolystarType.h @@ -0,0 +1,37 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * Polystar shape types. + */ +enum class PolystarType { + /** + * A regular polygon with equal-length sides. + */ + Polygon, + /** + * A star shape with alternating inner and outer points. + */ + Star +}; + +} // namespace pagx diff --git a/include/pagx/types/Rect.h b/include/pagx/types/Rect.h new file mode 100644 index 0000000000..a2116fcf59 --- /dev/null +++ b/include/pagx/types/Rect.h @@ -0,0 +1,74 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * A rectangle defined by position and size. + */ +struct Rect { + /** + * The x coordinate of the rectangle origin. + */ + float x = 0; + + /** + * The y coordinate of the rectangle origin. + */ + float y = 0; + + /** + * The width of the rectangle. + */ + float width = 0; + + /** + * The height of the rectangle. + */ + float height = 0; + + /** + * Returns a Rect from left, top, right, bottom coordinates. + */ + static Rect MakeLTRB(float l, float t, float r, float b) { + return {l, t, r - l, b - t}; + } + + /** + * Returns a Rect from position and size. + */ + static Rect MakeXYWH(float x, float y, float w, float h) { + return {x, y, w, h}; + } + + bool isEmpty() const { + return width <= 0 || height <= 0; + } + + bool operator==(const Rect& other) const { + return x == other.x && y == other.y && width == other.width && height == other.height; + } + + bool operator!=(const Rect& other) const { + return !(*this == other); + } +}; + +} // namespace pagx diff --git a/include/pagx/types/RepeaterOrder.h b/include/pagx/types/RepeaterOrder.h new file mode 100644 index 0000000000..976e41956c --- /dev/null +++ b/include/pagx/types/RepeaterOrder.h @@ -0,0 +1,37 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * Repeater stacking order. + */ +enum class RepeaterOrder { + /** + * Place copies below (behind) the original elements. + */ + BelowOriginal, + /** + * Place copies above (in front of) the original elements. + */ + AboveOriginal +}; + +} // namespace pagx diff --git a/include/pagx/types/SelectorTypes.h b/include/pagx/types/SelectorTypes.h new file mode 100644 index 0000000000..4db849be76 --- /dev/null +++ b/include/pagx/types/SelectorTypes.h @@ -0,0 +1,97 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * The unit type for range selector values. + */ +enum class SelectorUnit { + /** + * Values are specified as character indices. + */ + Index, + /** + * Values are specified as percentages of the total text length. + */ + Percentage +}; + +/** + * The shape of the selection falloff curve. + */ +enum class SelectorShape { + /** + * A square falloff with no transition. + */ + Square, + /** + * A ramp that increases from start to end. + */ + RampUp, + /** + * A ramp that decreases from start to end. + */ + RampDown, + /** + * A triangle shape that peaks in the middle. + */ + Triangle, + /** + * A rounded falloff with smooth edges. + */ + Round, + /** + * A smooth S-curve falloff. + */ + Smooth +}; + +/** + * The mode for combining multiple selectors. + */ +enum class SelectorMode { + /** + * Add selector values together. + */ + Add, + /** + * Subtract selector values. + */ + Subtract, + /** + * Use the intersection of selector ranges. + */ + Intersect, + /** + * Use the minimum of selector values. + */ + Min, + /** + * Use the maximum of selector values. + */ + Max, + /** + * Use the absolute difference of selector values. + */ + Difference +}; + +} // namespace pagx diff --git a/include/pagx/types/Size.h b/include/pagx/types/Size.h new file mode 100644 index 0000000000..f798c1de9e --- /dev/null +++ b/include/pagx/types/Size.h @@ -0,0 +1,46 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * A size with width and height. + */ +struct Size { + /** + * The width dimension. + */ + float width = 0; + + /** + * The height dimension. + */ + float height = 0; + + bool operator==(const Size& other) const { + return width == other.width && height == other.height; + } + + bool operator!=(const Size& other) const { + return !(*this == other); + } +}; + +} // namespace pagx diff --git a/include/pagx/types/StrokeStyle.h b/include/pagx/types/StrokeStyle.h new file mode 100644 index 0000000000..d607eae7a0 --- /dev/null +++ b/include/pagx/types/StrokeStyle.h @@ -0,0 +1,77 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * Line cap styles that define the shape at the endpoints of open paths. + */ +enum class LineCap { + /** + * A flat cap that ends exactly at the path endpoint. + */ + Butt, + /** + * A rounded cap that extends beyond the endpoint by half the stroke width. + */ + Round, + /** + * A square cap that extends beyond the endpoint by half the stroke width. + */ + Square +}; + +/** + * Line join styles that define the shape at the corners of paths. + */ +enum class LineJoin { + /** + * A sharp join that extends to a point. + */ + Miter, + /** + * A rounded join with a circular arc. + */ + Round, + /** + * A beveled join that cuts off the corner. + */ + Bevel +}; + +/** + * Stroke alignment relative to the path. + */ +enum class StrokeAlign { + /** + * Stroke is centered on the path. + */ + Center, + /** + * Stroke is drawn inside the path boundary. + */ + Inside, + /** + * Stroke is drawn outside the path boundary. + */ + Outside +}; + +} // namespace pagx diff --git a/include/pagx/types/TextAlign.h b/include/pagx/types/TextAlign.h new file mode 100644 index 0000000000..7ac32d452d --- /dev/null +++ b/include/pagx/types/TextAlign.h @@ -0,0 +1,46 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * Text horizontal alignment. + */ +enum class TextAlign { + /** + * Align text to the start (left for LTR, right for RTL). + */ + Start, + /** + * Align text to the center. + */ + Center, + /** + * Align text to the end (right for LTR, left for RTL). + */ + End, + /** + * Justify text (stretch to fill the available width). + * The last line uses start alignment by default. + */ + Justify +}; + +} // namespace pagx diff --git a/include/pagx/types/TextLayoutTypes.h b/include/pagx/types/TextLayoutTypes.h new file mode 100644 index 0000000000..a0b8979183 --- /dev/null +++ b/include/pagx/types/TextLayoutTypes.h @@ -0,0 +1,58 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * Text vertical alignment within the layout area. + */ +enum class VerticalAlign { + /** + * Align text to the top of the layout area. + */ + Top, + /** + * Align text to the vertical center of the layout area. + */ + Center, + /** + * Align text to the bottom of the layout area. + */ + Bottom +}; + +/** + * Text writing mode (horizontal or vertical). + */ +enum class WritingMode { + /** + * Horizontal text layout. Lines flow from top to bottom. This is the default mode for most + * languages. + */ + Horizontal, + /** + * Vertical text layout. Characters are arranged vertically from top to bottom, and columns flow + * from right to left. This is the traditional writing mode for Chinese, Japanese, and Korean + * (CJK) text. + */ + Vertical +}; + +} // namespace pagx diff --git a/include/pagx/types/TileMode.h b/include/pagx/types/TileMode.h new file mode 100644 index 0000000000..a70e5e26f4 --- /dev/null +++ b/include/pagx/types/TileMode.h @@ -0,0 +1,45 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * Tile modes for patterns, gradients, and filters. + */ +enum class TileMode { + /** + * Clamp to the edge color, extending the edge pixels outward. + */ + Clamp, + /** + * Repeat the pattern by tiling it. + */ + Repeat, + /** + * Repeat the pattern by mirroring it at the edges. + */ + Mirror, + /** + * Render transparent pixels outside the bounds. + */ + Decal +}; + +} // namespace pagx diff --git a/include/pagx/types/TrimType.h b/include/pagx/types/TrimType.h new file mode 100644 index 0000000000..66621678bd --- /dev/null +++ b/include/pagx/types/TrimType.h @@ -0,0 +1,37 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 { + +/** + * Trim path types that control how multiple paths are trimmed. + */ +enum class TrimType { + /** + * Trim each path individually within the group. + */ + Separate, + /** + * Treat all paths as one continuous path and trim collectively. + */ + Continuous +}; + +} // namespace pagx diff --git a/playground/.gitignore b/playground/.gitignore new file mode 100644 index 0000000000..4bb3ca5e30 --- /dev/null +++ b/playground/.gitignore @@ -0,0 +1,7 @@ +# Build outputs +wasm-mt/ + +# Build cache +build-pagx-playground/ +.*.md5 +.build.lock diff --git a/playground/CMakeLists.txt b/playground/CMakeLists.txt new file mode 100644 index 0000000000..7523dbef9b --- /dev/null +++ b/playground/CMakeLists.txt @@ -0,0 +1,99 @@ +cmake_minimum_required(VERSION 3.13) +project(PAGXPlayground) + +# Include vendor tools from libpag root +include(${CMAKE_CURRENT_SOURCE_DIR}/../third_party/vendor_tools/vendor.cmake) + +set(CMAKE_VERBOSE_MAKEFILE ON) +set(CMAKE_CXX_STANDARD 17) + +if (NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE "Release") +endif () + +# Set TGFX_DIR for libpag structure (tgfx is in third_party/tgfx) +if (NOT TGFX_DIR) + set(TGFX_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../third_party/tgfx) +endif () +get_filename_component(TGFX_DIR "${TGFX_DIR}" REALPATH) + +# Add tgfx as dependency +if (NOT TARGET tgfx) + set(CMAKE_POLICY_DEFAULT_CMP0077 NEW) + set(TGFX_BUILD_LAYERS ON CACHE BOOL "" FORCE) + add_subdirectory(${TGFX_DIR} ${CMAKE_CURRENT_BINARY_DIR}/tgfx) +endif () + +# Build pagx static library +file(GLOB PAGX_CORE_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/../src/pagx/*.cpp) +file(GLOB_RECURSE PAGX_UTILS_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/../src/pagx/utils/*.cpp) +file(GLOB_RECURSE PAGX_XML_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/../src/pagx/xml/*.cpp) +file(GLOB_RECURSE PAGX_SVG_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/../src/pagx/svg/*.cpp) +if (NOT TARGET pagx) + add_library(pagx STATIC ${PAGX_CORE_SOURCES} ${PAGX_UTILS_SOURCES} ${PAGX_XML_SOURCES} ${PAGX_SVG_SOURCES}) + target_include_directories(pagx PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../include) + target_include_directories(pagx PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../src/pagx + ${CMAKE_CURRENT_SOURCE_DIR}/../third_party/expat/expat/lib) + if(WIN32) + target_compile_definitions(pagx PRIVATE XML_STATIC) + endif() + add_vendor_target(pagx-vendor STATIC_VENDORS expat CONFIG_DIR ${CMAKE_CURRENT_SOURCE_DIR}/..) + find_vendor_libraries(pagx-vendor STATIC PAGX_VENDOR_STATIC_LIBRARIES) + add_dependencies(pagx pagx-vendor) + merge_libraries_into(pagx ${PAGX_VENDOR_STATIC_LIBRARIES}) +endif () + +# Build renderer sources as a static library for playground use +file(GLOB_RECURSE RENDERER_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/../src/renderer/*.cpp) +if (NOT TARGET pagx-renderer) + add_library(pagx-renderer STATIC ${RENDERER_SOURCES}) + target_include_directories(pagx-renderer PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/../src/renderer) + target_include_directories(pagx-renderer PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../src/pagx) + target_link_libraries(pagx-renderer PUBLIC pagx tgfx) +endif () + +# For Emscripten builds, pagx needs the same compile options as tgfx +if (DEFINED EMSCRIPTEN) + target_compile_options(pagx PRIVATE -fno-rtti -DEMSCRIPTEN_HAS_UNBOUND_TYPE_NAMES=0) + target_compile_options(pagx-renderer PRIVATE -fno-rtti -DEMSCRIPTEN_HAS_UNBOUND_TYPE_NAMES=0) +endif () + +file(GLOB_RECURSE PLAYGROUND_FILES src/*.cpp) + +if (DEFINED EMSCRIPTEN) + add_executable(pagx-playground ${PLAYGROUND_FILES}) + list(APPEND PLAYGROUND_COMPILE_OPTIONS -fno-rtti -DEMSCRIPTEN_HAS_UNBOUND_TYPE_NAMES=0) + list(APPEND PLAYGROUND_LINK_OPTIONS --no-entry -lembind -fno-rtti -DEMSCRIPTEN_HAS_UNBOUND_TYPE_NAMES=0 -sEXPORT_NAME='PAGXWasm' -sWASM=1 + -sMAX_WEBGL_VERSION=2 -sEXPORTED_RUNTIME_METHODS=['GL','HEAPU8'] -sMODULARIZE=1 + -sENVIRONMENT=web,worker -sEXPORT_ES6=1) + set(unsupported_emsdk_versions "4.0.11") + foreach (unsupported_version IN LISTS unsupported_emsdk_versions) + if (${EMSCRIPTEN_VERSION} VERSION_EQUAL ${unsupported_version}) + message(FATAL_ERROR "Emscripten version ${EMSCRIPTEN_VERSION} is not supported.") + endif () + endforeach () + if (EMSCRIPTEN_PTHREADS) + list(APPEND PLAYGROUND_LINK_OPTIONS -sUSE_PTHREADS=1 -sINITIAL_MEMORY=32MB -sALLOW_MEMORY_GROWTH=1 + -sPTHREAD_POOL_SIZE=navigator.hardwareConcurrency + -sEXIT_RUNTIME=0 -sINVOKE_RUN=0 -sMALLOC=dlmalloc) + list(APPEND PLAYGROUND_COMPILE_OPTIONS -fPIC -pthread) + else () + list(APPEND PLAYGROUND_LINK_OPTIONS -sALLOW_MEMORY_GROWTH=1) + endif () + if (CMAKE_BUILD_TYPE STREQUAL "Debug") + list(APPEND PLAYGROUND_COMPILE_OPTIONS -O0 -g3) + list(APPEND PLAYGROUND_LINK_OPTIONS -O0 -g3 -sSAFE_HEAP=1 -Wno-limited-postlink-optimizations) + else () + list(APPEND PLAYGROUND_COMPILE_OPTIONS -Oz) + list(APPEND PLAYGROUND_LINK_OPTIONS -Oz) + endif () +else () + add_library(pagx-playground SHARED ${PLAYGROUND_FILES}) +endif () + +target_compile_options(pagx-playground PUBLIC ${PLAYGROUND_COMPILE_OPTIONS}) +target_link_options(pagx-playground PUBLIC ${PLAYGROUND_LINK_OPTIONS}) +target_link_libraries(pagx-playground pagx-renderer) diff --git a/playground/favicon.png b/playground/favicon.png new file mode 100644 index 0000000000..912a7f1ced --- /dev/null +++ b/playground/favicon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80e1adb9dc95987069da3083077dc81018c104a8ae25e5db9414910d098783d5 +size 1200 diff --git a/playground/index.css b/playground/index.css new file mode 100644 index 0000000000..34666f94c8 --- /dev/null +++ b/playground/index.css @@ -0,0 +1,482 @@ +html, body { + padding: 0; + border: 0; + margin: 0; + height: 100%; + overflow: hidden; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; +} + +.container { + position: relative; + overflow: hidden; + margin: auto; + width: 100%; + height: 100%; + background: #1a1a2e; +} + +.container.hidden { + display: none; +} + +.canvas { + cursor: grab; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 1; + touch-action: none; +} + +.canvas.hidden { + display: none; +} + +.canvas:active { + cursor: grabbing; +} + +.drop-zone { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 100; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 40px; + background: rgba(26, 26, 46, 0.95); + transition: background 0.3s ease; +} + +.drop-zone.drag-over { + background: rgba(26, 26, 46, 0.98); +} + +.drop-zone.drag-over .drop-zone-content { + border-color: #0052d9; + transform: scale(1.02); +} + +.drop-zone.hidden { + display: none; +} + +.drop-zone-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 420px; + height: 280px; + padding: 60px 80px; + border: 2px dashed #555; + border-radius: 16px; + background: rgba(255, 255, 255, 0.02); + cursor: pointer; + transition: all 0.3s ease; + box-sizing: border-box; +} + +.drop-zone-content:hover { + border-color: #777; + background: rgba(255, 255, 255, 0.04); +} + +.drop-zone-content.hidden { + display: none; +} + +.brand { + display: flex; + flex-direction: column; + align-items: center; +} + +.logo { + width: 120px; + height: 120px; +} + +.title { + color: #fff; + font-size: 36px; + font-weight: 600; + margin: 20px 0 0 0; + text-align: center; +} + +.drop-icon { + width: 64px; + height: 64px; + color: #888; + margin-bottom: 20px; +} + +.drop-text { + color: #ccc; + font-size: 20px; + margin: 0 0 8px 0; +} + +.drop-subtext { + color: #666; + font-size: 14px; + margin: 0; +} + +.nav-btns { + position: absolute; + top: 16px; + right: 16px; + z-index: 150; + display: flex; + align-items: center; + gap: 8px; +} + +.nav-btns.hidden { + display: none; +} + +.nav-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 80px; + padding: 8px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + text-decoration: none; + transition: all 0.2s ease; +} + +.nav-btn:hover { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); +} + +.nav-btn svg { + width: 16px; + height: 16px; + color: #aaa; +} + +.nav-btn:hover svg { + color: #ccc; +} + +.nav-btn span { + color: #aaa; + font-size: 13px; +} + +.nav-btn:hover span { + color: #ccc; +} + +.loading-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 420px; + height: 280px; + border: 2px solid transparent; + border-radius: 16px; + box-sizing: border-box; +} + +.loading-content.hidden { + display: none; +} + +.loading-text { + color: #ccc; + font-size: 20px; + margin: 0 0 24px 0; +} + +.progress-container { + width: 280px; + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + overflow: hidden; +} + +.progress-bar { + width: 0; + height: 100%; + background: linear-gradient(90deg, #0052d9, #0062e9); + border-radius: 3px; + transition: width 0.15s ease-out; +} + +.progress-text { + color: #888; + font-size: 14px; + margin: 12px 0 0 0; +} + +.error-content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 420px; + height: 280px; + border: 2px solid transparent; + border-radius: 16px; + cursor: pointer; + box-sizing: border-box; +} + +.error-content:hover .error-icon { + color: #ff6b5b; +} + +.error-content.hidden { + display: none; +} + +.error-icon { + width: 64px; + height: 64px; + color: #e74c3c; + margin-bottom: 20px; +} + +.error-title { + color: #ccc; + font-size: 20px; + margin: 0 0 12px 0; +} + +.error-message { + color: #888; + font-size: 14px; + margin: 0 0 24px 0; + max-width: 400px; + text-align: center; + word-break: break-word; + white-space: pre-line; +} + +.toolbar { + position: absolute; + top: 16px; + right: 16px; + z-index: 150; + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + background: rgba(0, 0, 0, 0.6); + border-radius: 8px; + backdrop-filter: blur(8px); +} + +.toolbar.hidden { + display: none; +} + +.toolbar-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 6px; + box-sizing: border-box; + background: transparent; + border: none; + border-radius: 6px; + cursor: pointer; + text-decoration: none; + transition: background 0.2s ease; +} + +.toolbar-btn svg { + width: 24px; + height: 24px; + color: #ccc; +} + +.toolbar-btn:hover { + background: rgba(255, 255, 255, 0.1); +} + +.toolbar-btn:hover svg { + color: #fff; +} + +.toolbar-btn:active { + background: rgba(255, 255, 255, 0.2); +} + +.toolbar-btn.hidden { + display: none; +} + +.toolbar-divider { + width: 1px; + height: 20px; + background: rgba(255, 255, 255, 0.2); +} + +.file-name { + color: #aaa; + font-size: 14px; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Mobile */ +@media (max-width: 600px) { + .drop-zone { + padding: 0 24px; + } + + .drop-zone-content { + max-width: 100%; + box-sizing: border-box; + padding: 60px 40px; + } + + .drop-text { + text-align: center; + } + + .error-content { + max-width: 100%; + box-sizing: border-box; + padding: 60px 40px; + } + + .file-name { + max-width: 150px; + } +} + +/* Samples page */ +.samples-page { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 200; + background: #1a1a2e; + overflow-y: auto; + padding: 0 24px 40px; +} + +.samples-page.hidden { + display: none; +} + +.samples-header { + display: flex; + align-items: center; + padding: 16px 0; + position: sticky; + top: 0; + background: #1a1a2e; + z-index: 10; +} + +.samples-header-btns { + position: fixed; + top: 16px; + right: 16px; + display: flex; + align-items: center; + gap: 8px; + z-index: 10; +} + +.samples-title { + color: #fff; + font-size: 20px; + font-weight: 600; + margin: 0; +} + +.samples-list { + padding: 0; + margin: 0 auto; + max-width: 1200px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; +} + +.samples-list > a { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 8px; + border-radius: 8px; + text-decoration: none; + background: rgba(255, 255, 255, 0.04); + transition: background 0.15s ease; +} + +.samples-list > a:hover { + background: rgba(255, 255, 255, 0.12); +} + +.samples-list .sample-image { + width: 100%; + aspect-ratio: 1 / 1; + background: + repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 0 0 / 16px 16px; + border-radius: 6px; + object-fit: contain; +} + +.samples-list .sample-name { + color: #999; + font-size: 12px; + font-weight: 400; + text-align: center; + word-break: break-all; + line-height: 1.3; +} + +/* Mobile - Samples */ +@media (max-width: 600px) { + .samples-page { + padding: 0 16px 24px; + } + + .samples-list { + grid-template-columns: repeat(2, 1fr); + gap: 10px; + } + + .samples-list > a { + padding: 6px; + gap: 6px; + } + + .samples-list .sample-image { + border-radius: 4px; + } + + .samples-list .sample-name { + font-size: 10px; + } +} diff --git a/playground/index.html b/playground/index.html new file mode 100644 index 0000000000..fd982aca82 --- /dev/null +++ b/playground/index.html @@ -0,0 +1,127 @@ + + + + + + + + PAGX Playground + + + + +
+ + + +
+
+ +

PAGX Playground

+
+
+ + + + + +

Drag & Drop PAGX file here

+

or click to browse

+ +
+ + +
+
+ + + + diff --git a/playground/index.ts b/playground/index.ts new file mode 100644 index 0000000000..ee0f3af0ce --- /dev/null +++ b/playground/index.ts @@ -0,0 +1,1267 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +import { PAGXModule, PAGXView } from './types'; +import PAGXWasm from './wasm-mt/pagx-playground'; +import { TGFXBind } from '@tgfx/binding'; + +interface I18nStrings { + dropText: string; + dropSubtext: string; + loading: string; + errorTitle: string; + errorFormat: string; + errorWasm: string; + errorFont: string; + errorNetwork: string; + errorBrowser: string; + openFile: string; + resetView: string; + invalidFile: string; + spec: string; + specTitle: string; + leave: string; + samples: string; + samplesTitle: string; + home: string; + back: string; +} + +const i18n: Record = { + en: { + dropText: 'Drag & Drop PAGX file here', + dropSubtext: 'or click to browse', + loading: 'Loading...', + errorTitle: 'Failed to load', + errorFormat: 'Invalid file format. Please use a valid .pagx file.', + errorWasm: 'Failed to load WebAssembly module.', + errorFont: 'Failed to load fonts.', + errorNetwork: 'Network error. Please check your connection.', + errorBrowser: 'Minimum browser versions required:', + openFile: 'Open PAGX File', + resetView: 'Reset View', + invalidFile: 'Please drop a .pagx file', + spec: 'Spec', + specTitle: 'PAGX Specification', + leave: 'Leave', + samples: 'Samples', + samplesTitle: 'PAGX Samples', + home: 'Home', + back: 'Back', + }, + zh: { + dropText: '拖放 PAGX 文件到此处', + dropSubtext: '或点击选择文件', + loading: '加载中...', + errorTitle: '加载失败', + errorFormat: '无效的文件格式,请使用有效的 .pagx 文件。', + errorWasm: 'WebAssembly 模块加载失败。', + errorFont: '字体加载失败。', + errorNetwork: '网络错误,请检查网络连接。', + errorBrowser: '浏览器最低版本要求:', + openFile: '打开 PAGX 文件', + resetView: '重置视图', + invalidFile: '请拖放 .pagx 文件', + spec: '格式', + specTitle: 'PAGX 格式规范', + leave: '离开', + samples: '示例', + samplesTitle: 'PAGX 示例', + home: '首页', + back: '返回', + }, +}; + +function getLocale(): string { + const params = new URLSearchParams(window.location.search); + const langParam = params.get('lang'); + if (langParam === 'zh' || langParam === 'en') { + return langParam; + } + const lang = navigator.language || ''; + return lang.toLowerCase().startsWith('zh') ? 'zh' : 'en'; +} + +function t(): I18nStrings { + return i18n[getLocale()]; +} + +const MIN_ZOOM = 0.001; +const MAX_ZOOM = 1000.0; + +// Font URLs for preloading +const FONT_URL = './fonts/NotoSansSC-Regular.otf'; +const EMOJI_FONT_URL = './fonts/NotoColorEmoji.ttf'; +const WASM_URL = './wasm-mt/pagx-playground.wasm'; + +// Estimated sizes for progress calculation (in bytes) +const ESTIMATED_WASM_SIZE = 2400000; +const ESTIMATED_FONT_SIZE = 8800000; +const ESTIMATED_EMOJI_FONT_SIZE = 10300000; + +class PlaygroundState { + module: PAGXModule | null = null; + pagxView: PAGXView | null = null; + animationFrameId: number | null = null; + isPageVisible: boolean = true; + resized: boolean = false; + zoom: number = 1.0; + offsetX: number = 0; + offsetY: number = 0; + fontData: Uint8Array | null = null; + emojiFontData: Uint8Array | null = null; +} + +class LoadingProgress { + wasmLoaded: number = 0; + wasmTotal: number = ESTIMATED_WASM_SIZE; + wasmDone: boolean = false; + fontLoaded: number = 0; + fontTotal: number = ESTIMATED_FONT_SIZE; + fontDone: boolean = false; + emojiFontLoaded: number = 0; + emojiFontTotal: number = ESTIMATED_EMOJI_FONT_SIZE; + emojiFontDone: boolean = false; + + isAllResourcesCached(): boolean { + return this.wasmDone && this.fontDone && this.emojiFontDone; + } + + getOverallProgress(): number { + // Only count resources that are not yet done + let totalSize = 0; + let loadedSize = 0; + if (!this.wasmDone) { + totalSize += this.wasmTotal; + loadedSize += this.wasmLoaded; + } + if (!this.fontDone) { + totalSize += this.fontTotal; + loadedSize += this.fontLoaded; + } + if (!this.emojiFontDone) { + totalSize += this.emojiFontTotal; + loadedSize += this.emojiFontLoaded; + } + if (totalSize === 0) { + // All resources cached, show 99% + return 99; + } + // Cap at 99%, reserve 100% for after PAGX file loaded + return Math.min(99, Math.round((loadedSize / totalSize) * 100)); + } +} + +enum ErrorType { + WASM = 'wasm', + FONT = 'font', + FORMAT = 'format', + NETWORK = 'network', +} + +class PlaygroundError extends Error { + type: ErrorType; + constructor(type: ErrorType, message?: string) { + super(message); + this.type = type; + } +} + +interface GestureEvent extends UIEvent { + scale: number; + rotation: number; + clientX: number; + clientY: number; +} + +enum ScaleGestureState { + SCALE_START = 0, + SCALE_CHANGE = 1, + SCALE_END = 2, +} + +enum ScrollGestureState { + SCROLL_START = 0, + SCROLL_CHANGE = 1, + SCROLL_END = 2, +} + +enum DeviceType { + TOUCH = 0, + MOUSE = 1, +} + +class GestureManager { + private scaleY = 1.0; + private pinchTimeout = 150; + private timer: number | undefined; + private scaleStartZoom = 1.0; + private lastEventTime = 0; + private lastDeltaY = 0; + private timeThreshold = 50; + private deltaYThreshold = 50; + private deltaYChangeThreshold = 10; + private mouseWheelRatio = 800; + private touchWheelRatio = 100; + + // Drag state + private isDragging = false; + private dragStartX = 0; + private dragStartY = 0; + private dragStartOffsetX = 0; + private dragStartOffsetY = 0; + + // Touch state + private startTouchDistance = 0; + private lastTouchCenterX = 0; + private lastTouchCenterY = 0; + private isTouchPanning = false; + private isTouchZooming = false; + public zoom = 1.0; + public offsetX = 0; + public offsetY = 0; + + private handleScrollEvent( + event: WheelEvent, + state: ScrollGestureState, + playgroundState: PlaygroundState, + ) { + if (state === ScrollGestureState.SCROLL_CHANGE) { + this.scaleStartZoom = this.zoom; + this.scaleY = 1.0; + if (event.shiftKey && event.deltaX === 0 && event.deltaY !== 0) { + this.offsetX -= event.deltaY * window.devicePixelRatio; + } else { + this.offsetX -= event.deltaX * window.devicePixelRatio; + this.offsetY -= event.deltaY * window.devicePixelRatio; + } + playgroundState.pagxView?.updateZoomScaleAndOffset(this.zoom, this.offsetX, this.offsetY); + } + } + + private handleScaleEvent( + event: WheelEvent, + state: ScaleGestureState, + canvas: HTMLElement, + playgroundState: PlaygroundState, + ) { + if (state === ScaleGestureState.SCALE_START) { + this.scaleY = 1.0; + this.scaleStartZoom = this.zoom; + } + if (state === ScaleGestureState.SCALE_CHANGE) { + const rect = canvas.getBoundingClientRect(); + const pixelX = (event.clientX - rect.left) * window.devicePixelRatio; + const pixelY = (event.clientY - rect.top) * window.devicePixelRatio; + const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.scaleStartZoom * this.scaleY)); + this.offsetX = (this.offsetX - pixelX) * (newZoom / this.zoom) + pixelX; + this.offsetY = (this.offsetY - pixelY) * (newZoom / this.zoom) + pixelY; + this.zoom = newZoom; + } + if (state === ScaleGestureState.SCALE_END) { + this.scaleY = 1.0; + this.scaleStartZoom = this.zoom; + } + playgroundState.pagxView?.updateZoomScaleAndOffset(this.zoom, this.offsetX, this.offsetY); + } + + public clearState() { + this.scaleY = 1.0; + this.timer = undefined; + } + + public resetTransform(playgroundState: PlaygroundState) { + this.zoom = 1.0; + this.offsetX = 0; + this.offsetY = 0; + this.clearState(); + playgroundState.pagxView?.updateZoomScaleAndOffset(this.zoom, this.offsetX, this.offsetY); + } + + private resetScrollTimeout( + event: WheelEvent, + playgroundState: PlaygroundState, + ) { + clearTimeout(this.timer); + this.timer = window.setTimeout(() => { + this.timer = undefined; + this.handleScrollEvent(event, ScrollGestureState.SCROLL_END, playgroundState); + this.clearState(); + }, this.pinchTimeout); + } + + private resetScaleTimeout( + event: WheelEvent, + canvas: HTMLElement, + playgroundState: PlaygroundState, + ) { + clearTimeout(this.timer); + this.timer = window.setTimeout(() => { + this.timer = undefined; + this.handleScaleEvent(event, ScaleGestureState.SCALE_END, canvas, playgroundState); + this.clearState(); + }, this.pinchTimeout); + } + + private getDeviceType(event: WheelEvent): DeviceType { + const now = Date.now(); + const timeDifference = now - this.lastEventTime; + const deltaYChange = Math.abs(event.deltaY - this.lastDeltaY); + let isTouchpad = false; + if (event.deltaMode === event.DOM_DELTA_PIXEL && timeDifference < this.timeThreshold) { + if (Math.abs(event.deltaY) < this.deltaYThreshold && deltaYChange < this.deltaYChangeThreshold) { + isTouchpad = true; + } + } + this.lastEventTime = now; + this.lastDeltaY = event.deltaY; + return isTouchpad ? DeviceType.TOUCH : DeviceType.MOUSE; + } + + public onWheel(event: WheelEvent, canvas: HTMLElement, playgroundState: PlaygroundState) { + const deviceType = this.getDeviceType(event); + let wheelRatio = (deviceType === DeviceType.MOUSE ? this.mouseWheelRatio : this.touchWheelRatio); + if (!event.deltaY || (!event.ctrlKey && !event.metaKey)) { + this.resetScrollTimeout(event, playgroundState); + this.handleScrollEvent(event, ScrollGestureState.SCROLL_CHANGE, playgroundState); + } else { + this.scaleY *= Math.exp(-(event.deltaY) / wheelRatio); + if (!this.timer) { + this.resetScaleTimeout(event, canvas, playgroundState); + this.handleScaleEvent(event, ScaleGestureState.SCALE_START, canvas, playgroundState); + } else { + this.resetScaleTimeout(event, canvas, playgroundState); + this.handleScaleEvent(event, ScaleGestureState.SCALE_CHANGE, canvas, playgroundState); + } + } + } + + // Mouse drag handlers + public onMouseDown(event: MouseEvent, canvas: HTMLElement) { + // Only handle left mouse button + if (event.button !== 0) { + return; + } + this.isDragging = true; + this.dragStartX = event.clientX; + this.dragStartY = event.clientY; + this.dragStartOffsetX = this.offsetX; + this.dragStartOffsetY = this.offsetY; + canvas.style.cursor = 'grabbing'; + } + + public onMouseMove(event: MouseEvent, playgroundState: PlaygroundState) { + if (!this.isDragging) { + return; + } + const deltaX = (event.clientX - this.dragStartX) * window.devicePixelRatio; + const deltaY = (event.clientY - this.dragStartY) * window.devicePixelRatio; + this.offsetX = this.dragStartOffsetX + deltaX; + this.offsetY = this.dragStartOffsetY + deltaY; + playgroundState.pagxView?.updateZoomScaleAndOffset(this.zoom, this.offsetX, this.offsetY); + } + + public onMouseUp(canvas: HTMLElement) { + this.isDragging = false; + canvas.style.cursor = 'grab'; + } + + // Touch handlers + private getTouchDistance(touches: TouchList): number { + if (touches.length < 2) { + return 0; + } + const dx = touches[0].clientX - touches[1].clientX; + const dy = touches[0].clientY - touches[1].clientY; + return Math.sqrt(dx * dx + dy * dy); + } + + private getTouchCenter(touches: TouchList): { x: number; y: number } { + if (touches.length < 2) { + return { x: touches[0].clientX, y: touches[0].clientY }; + } + return { + x: (touches[0].clientX + touches[1].clientX) / 2, + y: (touches[0].clientY + touches[1].clientY) / 2, + }; + } + + public onTouchStart(event: TouchEvent, canvas: HTMLElement) { + if (event.touches.length === 1) { + // Single finger pan + this.isTouchPanning = true; + this.isTouchZooming = false; + this.dragStartX = event.touches[0].clientX; + this.dragStartY = event.touches[0].clientY; + this.dragStartOffsetX = this.offsetX; + this.dragStartOffsetY = this.offsetY; + } else if (event.touches.length === 2) { + // Two finger zoom/pan + this.isTouchPanning = false; + this.isTouchZooming = true; + this.startTouchDistance = this.getTouchDistance(event.touches); + const center = this.getTouchCenter(event.touches); + this.lastTouchCenterX = center.x; + this.lastTouchCenterY = center.y; + this.scaleStartZoom = this.zoom; + this.dragStartOffsetX = this.offsetX; + this.dragStartOffsetY = this.offsetY; + } + } + + public onTouchMove(event: TouchEvent, canvas: HTMLElement, playgroundState: PlaygroundState) { + if (event.touches.length === 1 && this.isTouchPanning) { + // Single finger pan + const deltaX = (event.touches[0].clientX - this.dragStartX) * window.devicePixelRatio; + const deltaY = (event.touches[0].clientY - this.dragStartY) * window.devicePixelRatio; + this.offsetX = this.dragStartOffsetX + deltaX; + this.offsetY = this.dragStartOffsetY + deltaY; + playgroundState.pagxView?.updateZoomScaleAndOffset(this.zoom, this.offsetX, this.offsetY); + } else if (event.touches.length === 2 && this.isTouchZooming) { + // Two finger zoom and pan + const currentDistance = this.getTouchDistance(event.touches); + const center = this.getTouchCenter(event.touches); + const rect = canvas.getBoundingClientRect(); + const pixelX = (center.x - rect.left) * window.devicePixelRatio; + const pixelY = (center.y - rect.top) * window.devicePixelRatio; + + // Calculate zoom using absolute ratio relative to the start distance. + if (this.startTouchDistance > 0) { + const scale = currentDistance / this.startTouchDistance; + const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.scaleStartZoom * scale)); + + // Zoom around pinch center + this.offsetX = (this.offsetX - pixelX) * (newZoom / this.zoom) + pixelX; + this.offsetY = (this.offsetY - pixelY) * (newZoom / this.zoom) + pixelY; + this.zoom = newZoom; + } + + // Also handle pan during pinch + const centerDeltaX = (center.x - this.lastTouchCenterX) * window.devicePixelRatio; + const centerDeltaY = (center.y - this.lastTouchCenterY) * window.devicePixelRatio; + this.offsetX += centerDeltaX; + this.offsetY += centerDeltaY; + + this.lastTouchCenterX = center.x; + this.lastTouchCenterY = center.y; + + playgroundState.pagxView?.updateZoomScaleAndOffset(this.zoom, this.offsetX, this.offsetY); + } + } + + public onTouchEnd(event: TouchEvent, canvas: HTMLElement) { + if (event.touches.length === 0) { + this.isTouchPanning = false; + this.isTouchZooming = false; + } else if (event.touches.length === 1) { + // Switched from pinch to single finger + this.isTouchPanning = true; + this.isTouchZooming = false; + this.dragStartX = event.touches[0].clientX; + this.dragStartY = event.touches[0].clientY; + this.dragStartOffsetX = this.offsetX; + this.dragStartOffsetY = this.offsetY; + } + } + + // Safari gesture handlers + public onGestureStart(event: GestureEvent) { + this.scaleStartZoom = this.zoom; + this.dragStartOffsetX = this.offsetX; + this.dragStartOffsetY = this.offsetY; + } + + public onGestureChange(event: GestureEvent, canvas: HTMLElement, + playgroundState: PlaygroundState) { + // event.scale is already an absolute ratio relative to gesturestart. + const rect = canvas.getBoundingClientRect(); + const pixelX = (event.clientX - rect.left) * window.devicePixelRatio; + const pixelY = (event.clientY - rect.top) * window.devicePixelRatio; + const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.scaleStartZoom * event.scale)); + this.offsetX = (this.offsetX - pixelX) * (newZoom / this.zoom) + pixelX; + this.offsetY = (this.offsetY - pixelY) * (newZoom / this.zoom) + pixelY; + this.zoom = newZoom; + playgroundState.pagxView?.updateZoomScaleAndOffset(this.zoom, this.offsetX, this.offsetY); + } + + public onGestureEnd() { + } +} + +const playgroundState = new PlaygroundState(); +const gestureManager = new GestureManager(); +const loadingProgress = new LoadingProgress(); +let animationLoopRunning = false; +let wasmLoadPromise: Promise | null = null; +let fontLoadPromise: Promise | null = null; + +function updateProgressUI(): void { + const progressBar = document.getElementById('progress-bar'); + const progressText = document.getElementById('progress-text'); + if (progressBar && progressText) { + const progress = loadingProgress.getOverallProgress(); + progressBar.style.width = `${progress}%`; + progressText.textContent = `${progress}%`; + } +} + +function resetProgressUI(): void { + const progressBar = document.getElementById('progress-bar'); + const progressText = document.getElementById('progress-text'); + if (progressBar && progressText) { + // Remove transition temporarily to reset instantly + progressBar.style.transition = 'none'; + progressBar.style.width = '0%'; + progressText.textContent = '0%'; + // Force reflow to apply the reset + progressBar.offsetHeight; + // Restore transition + progressBar.style.transition = ''; + } +} + +async function fetchWithProgress( + url: string, + onProgress: (loaded: number, total: number) => void +): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status}`); + } + const contentLength = response.headers.get('content-length'); + const total = contentLength ? parseInt(contentLength, 10) : 0; + if (total > 0) { + onProgress(0, total); + } + const reader = response.body?.getReader(); + if (!reader) { + const buffer = await response.arrayBuffer(); + onProgress(buffer.byteLength, buffer.byteLength); + return buffer; + } + const chunks: Uint8Array[] = []; + let loaded = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + chunks.push(value); + loaded += value.length; + onProgress(loaded, total > 0 ? total : loaded); + } + const result = new Uint8Array(loaded); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result.buffer; +} + +async function loadFonts(): Promise { + const loadFont = async ( + url: string, + onProgress: (loaded: number, total: number) => void, + onDone: () => void + ): Promise => { + try { + const buffer = await fetchWithProgress(url, onProgress); + onDone(); + updateProgressUI(); + return new Uint8Array(buffer); + } catch (error) { + console.warn(`Error loading font ${url}:`, error); + onDone(); + updateProgressUI(); + return null; + } + }; + + const [fontData, emojiFontData] = await Promise.all([ + loadFont( + FONT_URL, + (loaded, total) => { + loadingProgress.fontLoaded = loaded; + if (total > 0) { + loadingProgress.fontTotal = total; + } + updateProgressUI(); + }, + () => { loadingProgress.fontDone = true; } + ), + loadFont( + EMOJI_FONT_URL, + (loaded, total) => { + loadingProgress.emojiFontLoaded = loaded; + if (total > 0) { + loadingProgress.emojiFontTotal = total; + } + updateProgressUI(); + }, + () => { loadingProgress.emojiFontDone = true; } + ), + ]); + playgroundState.fontData = fontData; + playgroundState.emojiFontData = emojiFontData; +} + +async function loadWasm(): Promise { + // First fetch the WASM file with progress tracking + const wasmBuffer = await fetchWithProgress(WASM_URL, (loaded, total) => { + loadingProgress.wasmLoaded = loaded; + if (total > 0) { + loadingProgress.wasmTotal = total; + } + updateProgressUI(); + }); + loadingProgress.wasmDone = true; + updateProgressUI(); + // Then instantiate the module with the pre-fetched WASM + const module = await PAGXWasm({ + locateFile: (file: string) => './wasm-mt/' + file, + mainScriptUrlOrBlob: './wasm-mt/pagx-playground.js', + wasmBinary: wasmBuffer, + }); + playgroundState.module = module as PAGXModule; + TGFXBind(playgroundState.module as any); + const pagxView = playgroundState.module.PAGXView.MakeFrom('#pagx-canvas'); + if (!pagxView) { + throw new Error('Failed to create PAGXView'); + } + playgroundState.pagxView = pagxView; + updateSize(); + playgroundState.pagxView.updateZoomScaleAndOffset(1.0, 0, 0); + const canvas = document.getElementById('pagx-canvas') as HTMLCanvasElement; + bindCanvasEvents(canvas); + animationLoop(); + setupVisibilityListeners(); +} + +function registerFontsToView(): void { + if (!playgroundState.pagxView) { + return; + } + const fontData = playgroundState.fontData || new Uint8Array(0); + const emojiFontData = playgroundState.emojiFontData || new Uint8Array(0); + playgroundState.pagxView.registerFonts(fontData, emojiFontData); +} + +function updateSize() { + if (!playgroundState.pagxView) { + return; + } + playgroundState.resized = false; + const canvas = document.getElementById('pagx-canvas') as HTMLCanvasElement; + const container = document.getElementById('container') as HTMLDivElement; + const screenRect = container.getBoundingClientRect(); + const scaleFactor = window.devicePixelRatio; + canvas.width = screenRect.width * scaleFactor; + canvas.height = screenRect.height * scaleFactor; + canvas.style.width = screenRect.width + "px"; + canvas.style.height = screenRect.height + "px"; + playgroundState.pagxView.updateSize(); +} + +function draw() { + playgroundState.pagxView?.draw(); +} + +function animationLoop() { + if (animationLoopRunning) { + return; + } + animationLoopRunning = true; + const frame = () => { + if (!playgroundState.pagxView || !playgroundState.isPageVisible) { + animationLoopRunning = false; + playgroundState.animationFrameId = null; + return; + } + draw(); + playgroundState.animationFrameId = requestAnimationFrame(frame); + }; + playgroundState.animationFrameId = requestAnimationFrame(frame); +} + +function handleVisibilityChange() { + playgroundState.isPageVisible = !document.hidden; + if (playgroundState.isPageVisible && playgroundState.animationFrameId === null) { + animationLoop(); + } +} + +function setupVisibilityListeners() { + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('beforeunload', () => { + if (playgroundState.animationFrameId !== null) { + cancelAnimationFrame(playgroundState.animationFrameId); + playgroundState.animationFrameId = null; + } + }); +} + +function bindCanvasEvents(canvas: HTMLElement) { + // Set initial cursor style + canvas.style.cursor = 'grab'; + + // Wheel events for scroll and zoom + canvas.addEventListener('wheel', (e: WheelEvent) => { + e.preventDefault(); + gestureManager.onWheel(e, canvas, playgroundState); + }, { passive: false }); + + // Mouse drag events + canvas.addEventListener('mousedown', (e: MouseEvent) => { + e.preventDefault(); + gestureManager.onMouseDown(e, canvas); + }); + canvas.addEventListener('mousemove', (e: MouseEvent) => { + gestureManager.onMouseMove(e, playgroundState); + }); + canvas.addEventListener('mouseup', () => { + gestureManager.onMouseUp(canvas); + }); + canvas.addEventListener('mouseleave', () => { + gestureManager.onMouseUp(canvas); + }); + + // Touch events for mobile + canvas.addEventListener('touchstart', (e: TouchEvent) => { + e.preventDefault(); + gestureManager.onTouchStart(e, canvas); + }, { passive: false }); + canvas.addEventListener('touchmove', (e: TouchEvent) => { + e.preventDefault(); + gestureManager.onTouchMove(e, canvas, playgroundState); + }, { passive: false }); + canvas.addEventListener('touchend', (e: TouchEvent) => { + gestureManager.onTouchEnd(e, canvas); + }); + canvas.addEventListener('touchcancel', (e: TouchEvent) => { + gestureManager.onTouchEnd(e, canvas); + }); + + // Safari gesture events for pinch-to-zoom + canvas.addEventListener('gesturestart', (e: Event) => { + e.preventDefault(); + gestureManager.onGestureStart(e as GestureEvent); + }); + canvas.addEventListener('gesturechange', (e: Event) => { + e.preventDefault(); + gestureManager.onGestureChange(e as GestureEvent, canvas, playgroundState); + }); + canvas.addEventListener('gestureend', (e: Event) => { + e.preventDefault(); + gestureManager.onGestureEnd(); + }); +} + +type DropZoneState = 'dropzone' | 'loading' | 'error'; + +function setDropZoneState(state: DropZoneState, errorMessage?: string): void { + const dropZoneContent = document.getElementById('drop-zone-content'); + const loadingContent = document.getElementById('loading-content'); + const errorContent = document.getElementById('error-content'); + const dropZone = document.getElementById('drop-zone'); + if (!dropZoneContent || !loadingContent || !errorContent || !dropZone) { + return; + } + dropZoneContent.classList.toggle('hidden', state !== 'dropzone'); + loadingContent.classList.toggle('hidden', state !== 'loading'); + errorContent.classList.toggle('hidden', state !== 'error'); + dropZone.classList.remove('hidden'); + if (state === 'error' && errorMessage !== undefined) { + const errorMessageEl = document.getElementById('error-message'); + if (errorMessageEl) { + errorMessageEl.textContent = errorMessage; + } + } +} + +function showLoadingUI(): void { + setDropZoneState('loading'); +} + +function showDropZoneUI(): void { + setDropZoneState('dropzone'); +} + +function showErrorUI(message: string): void { + setDropZoneState('error', message); +} + +function hideDropZone(): void { + const dropZone = document.getElementById('drop-zone'); + if (dropZone) { + dropZone.classList.add('hidden'); + } +} + +function hidePlaybackUI(): void { + const canvas = document.getElementById('pagx-canvas') as HTMLCanvasElement; + const toolbar = document.getElementById('toolbar') as HTMLDivElement; + canvas.classList.add('hidden'); + toolbar.classList.add('hidden'); +} + +const DEFAULT_TITLE = 'PAGX Playground'; + +function goHome(pushHistory: boolean = true): void { + if (playgroundState.pagxView) { + playgroundState.pagxView.loadPAGX(new Uint8Array(0)); + gestureManager.resetTransform(playgroundState); + } + const canvas = document.getElementById('pagx-canvas') as HTMLCanvasElement; + const toolbar = document.getElementById('toolbar') as HTMLDivElement; + const navBtns = document.getElementById('nav-btns') as HTMLDivElement; + canvas.classList.add('hidden'); + toolbar.classList.add('hidden'); + navBtns.classList.remove('hidden'); + document.title = DEFAULT_TITLE; + showDropZoneUI(); + currentPlayingFile = null; + + // Clear file parameter from URL + if (pushHistory) { + history.pushState(null, '', window.location.pathname); + } +} + +async function loadExternalFiles(baseURL: string): Promise { + if (!playgroundState.pagxView) { + return; + } + const paths = playgroundState.pagxView.getExternalFilePaths(); + const count = paths.size(); + if (count === 0) { + paths.delete(); + return; + } + const fetches: Promise[] = []; + for (let i = 0; i < count; i++) { + const filePath = paths.get(i); + const fileURL = baseURL + filePath; + fetches.push( + fetch(fileURL) + .then(response => { + if (!response.ok) { + throw new Error(`Failed to fetch ${fileURL}: ${response.status}`); + } + return response.arrayBuffer(); + }) + .then(buffer => { + playgroundState.pagxView?.loadFileData(filePath, new Uint8Array(buffer)); + }) + .catch(error => { + console.warn(`Failed to load external file ${filePath}:`, error); + }) + ); + } + paths.delete(); + await Promise.all(fetches); +} + +async function loadPAGXData(data: Uint8Array, name: string, baseURL: string) { + const navBtns = document.getElementById('nav-btns') as HTMLDivElement; + const toolbar = document.getElementById('toolbar') as HTMLDivElement; + const canvas = document.getElementById('pagx-canvas') as HTMLCanvasElement; + + if (!playgroundState.pagxView) { + throw new Error('PAGXView not initialized'); + } + + registerFontsToView(); + playgroundState.pagxView.parsePAGX(data); + await loadExternalFiles(baseURL); + playgroundState.pagxView.buildLayers(); + gestureManager.resetTransform(playgroundState); + updateSize(); + // Draw the first frame before showing canvas to avoid flashing old content + draw(); + hideDropZone(); + canvas.classList.remove('hidden'); + toolbar.classList.remove('hidden'); + navBtns.classList.add('hidden'); + document.title = 'PAGX Playground - ' + name; +} + +async function prepareForLoading(): Promise { + hidePlaybackUI(); + showLoadingUI(); + resetProgressUI(); + await new Promise(resolve => requestAnimationFrame(resolve)); + + if (!wasmLoadPromise) { + wasmLoadPromise = loadWasm(); + } + if (!fontLoadPromise) { + fontLoadPromise = loadFonts(); + } + + const loadingStartTime = Date.now(); + await Promise.all([wasmLoadPromise, fontLoadPromise]); + updateProgressUI(); + + const elapsed = Date.now() - loadingStartTime; + const minDisplayTime = 300; + if (elapsed < minDisplayTime) { + await new Promise(resolve => setTimeout(resolve, minDisplayTime - elapsed)); + } +} + +async function loadPAGXFile(file: File) { + try { + await prepareForLoading(); + + const fileBuffer = await file.arrayBuffer(); + await loadPAGXData(new Uint8Array(fileBuffer), file.name, ''); + currentPlayingFile = null; + + history.replaceState(null, '', window.location.pathname); + } catch (error) { + console.error('Failed to load PAGX file:', error); + showErrorUI(t().errorFormat); + } +} + +async function loadPAGXFromURL(url: string, pushHistory: boolean = true) { + try { + await prepareForLoading(); + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`); + } + const fileBuffer = await response.arrayBuffer(); + + const lastSlash = url.lastIndexOf('/'); + const baseURL = lastSlash >= 0 ? url.substring(0, lastSlash + 1) : ''; + const name = url.substring(lastSlash + 1) || 'remote.pagx'; + + await loadPAGXData(new Uint8Array(fileBuffer), name, baseURL); + currentPlayingFile = url; + + const cleanUrl = window.location.pathname + '?file=' + url; + if (pushHistory) { + history.pushState(null, '', cleanUrl); + } + } catch (error) { + console.error('Failed to load PAGX from URL:', error); + const message = error instanceof Error ? error.message : 'Unknown error'; + showErrorUI(message); + } +} + +function getPAGXUrlFromParams(): string | null { + const params = new URLSearchParams(window.location.search); + return params.get('file'); +} + +function setupDragAndDrop() { + const dropZone = document.getElementById('drop-zone') as HTMLDivElement; + const dropZoneContent = document.getElementById('drop-zone-content') as HTMLDivElement; + const errorContent = document.getElementById('error-content') as HTMLDivElement; + const container = document.getElementById('container') as HTMLDivElement; + const fileInput = document.getElementById('file-input') as HTMLInputElement; + const leaveBtn = document.getElementById('leave-btn') as HTMLButtonElement; + const openBtn = document.getElementById('open-btn') as HTMLButtonElement; + const resetBtn = document.getElementById('reset-btn') as HTMLButtonElement; + + const preventDefaults = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + }; + + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { + container.addEventListener(eventName, preventDefaults, false); + document.body.addEventListener(eventName, preventDefaults, false); + }); + + ['dragenter', 'dragover'].forEach(eventName => { + container.addEventListener(eventName, () => { + dropZone.classList.add('drag-over'); + dropZone.classList.remove('hidden'); + }, false); + }); + + ['dragleave', 'drop'].forEach(eventName => { + container.addEventListener(eventName, () => { + dropZone.classList.remove('drag-over'); + }, false); + }); + + container.addEventListener('drop', (e: DragEvent) => { + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + const file = files[0]; + if (file.name.endsWith('.pagx')) { + loadPAGXFile(file); + } else { + alert(t().invalidFile); + } + } + }, false); + + dropZoneContent.addEventListener('click', () => { + fileInput.click(); + }); + + errorContent.addEventListener('click', () => { + fileInput.click(); + }); + + leaveBtn.addEventListener('click', () => { + goHome(); + }); + + openBtn.addEventListener('click', () => { + fileInput.click(); + }); + + resetBtn.addEventListener('click', () => { + gestureManager.resetTransform(playgroundState); + }); + + fileInput.addEventListener('change', () => { + const files = fileInput.files; + if (files && files.length > 0) { + loadPAGXFile(files[0]); + } + fileInput.value = ''; + }); +} + +function checkWebGL2Support(): boolean { + const canvas = document.createElement('canvas'); + const gl = canvas.getContext('webgl2'); + return gl !== null; +} + +function checkWasmSupport(): boolean { + try { + if (typeof WebAssembly === 'object' && + typeof WebAssembly.instantiate === 'function') { + const module = new WebAssembly.Module(new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00])); + return module instanceof WebAssembly.Module; + } + } catch (e) { + // WebAssembly not supported + } + return false; +} + +function getBrowserRequirements(): string { + return `${t().errorBrowser} +• Chrome 69+ +• Firefox 79+ +• Safari 15+ +• Edge 79+`; +} + +function applyI18n(): void { + const strings = t(); + const locale = getLocale(); + document.documentElement.lang = locale === 'zh' ? 'zh-CN' : 'en'; + + const dropText = document.querySelector('.drop-text'); + const dropSubtext = document.querySelector('.drop-subtext'); + const loadingText = document.querySelector('.loading-text'); + const errorTitle = document.querySelector('.error-title'); + const openBtn = document.getElementById('open-btn'); + const resetBtn = document.getElementById('reset-btn'); + const leaveBtn = document.getElementById('leave-btn'); + + if (dropText) dropText.textContent = strings.dropText; + if (dropSubtext) dropSubtext.textContent = strings.dropSubtext; + if (loadingText) loadingText.textContent = strings.loading; + if (errorTitle) errorTitle.textContent = strings.errorTitle; + if (openBtn) openBtn.title = strings.openFile; + if (resetBtn) resetBtn.title = strings.resetView; + if (leaveBtn) leaveBtn.title = strings.leave; + + const specBtn = document.getElementById('spec-btn'); + const specBtnText = document.getElementById('spec-btn-text'); + if (specBtn) specBtn.title = strings.specTitle; + if (specBtnText) specBtnText.textContent = strings.spec; + + const samplesBtn = document.getElementById('samples-btn'); + const samplesBtnText = document.getElementById('samples-btn-text'); + if (samplesBtn) samplesBtn.title = strings.samplesTitle; + if (samplesBtnText) samplesBtnText.textContent = strings.samples; + + const toolbarSamplesBtn = document.getElementById('toolbar-samples-btn'); + if (toolbarSamplesBtn) toolbarSamplesBtn.title = strings.samplesTitle; + + const samplesTitle = document.querySelector('.samples-title'); + if (samplesTitle) samplesTitle.textContent = strings.samplesTitle; + + const samplesBackBtn = document.getElementById('samples-back-btn'); + if (samplesBackBtn) { + samplesBackBtn.title = strings.back; + const span = samplesBackBtn.querySelector('span'); + if (span) span.textContent = strings.back; + } + + const samplesSpecBtn = document.getElementById('samples-spec-btn'); + if (samplesSpecBtn) { + samplesSpecBtn.title = strings.specTitle; + const span = samplesSpecBtn.querySelector('span'); + if (span) span.textContent = strings.spec; + } +} + +let sampleFiles: string[] = []; +let currentPlayingFile: string | null = null; + +async function loadSampleList(): Promise { + if (sampleFiles.length > 0) { + return; + } + const response = await fetch('./samples/index.json'); + if (!response.ok) { + throw new Error('Failed to load samples index'); + } + sampleFiles = await response.json(); +} + +function renderSampleList(): void { + const list = document.getElementById('samples-list') as HTMLDivElement; + list.innerHTML = ''; + for (const file of sampleFiles) { + const a = document.createElement('a'); + a.href = '#'; + + const baseName = file.replace(/\.pagx$/, ''); + const imageUrl = `./samples/images/${baseName}.webp`; + + a.innerHTML = ` + ${baseName} + ${file} + `; + a.addEventListener('click', (e) => { + e.preventDefault(); + // Clear hash before loading so replaceState won't carry #samples + history.replaceState(null, '', window.location.pathname + window.location.search); + hideSamplesPage(); + loadPAGXFromURL('./samples/' + file); + }); + list.appendChild(a); + } +} + +function showSamplesPage(): void { + const container = document.getElementById('container') as HTMLDivElement; + const samplesPage = document.getElementById('samples-page') as HTMLDivElement; + container.classList.add('hidden'); + samplesPage.classList.remove('hidden'); + document.title = t().samplesTitle; + + loadSampleList().then(renderSampleList).catch((error) => { + console.error('Failed to load samples:', error); + }); +} + +function hideSamplesPage(): void { + const container = document.getElementById('container') as HTMLDivElement; + const samplesPage = document.getElementById('samples-page') as HTMLDivElement; + container.classList.remove('hidden'); + samplesPage.classList.add('hidden'); +} + +function handlePopState(): void { + const pagxUrl = getPAGXUrlFromParams(); + if (pagxUrl) { + if (pagxUrl !== currentPlayingFile) { + loadPAGXFromURL(pagxUrl, false); + } + } else { + goHome(false); + } + handleRoute(); +} + +function handleRoute(): void { + const hash = window.location.hash; + if (hash === '#samples') { + showSamplesPage(); + } else { + hideSamplesPage(); + } +} + +if (typeof window !== 'undefined') { + window.onload = async () => { + // Apply i18n texts + applyI18n(); + + // Setup routing + window.addEventListener('hashchange', handleRoute); + window.addEventListener('popstate', handlePopState); + handleRoute(); + + // Setup samples back button + const samplesBackBtn = document.getElementById('samples-back-btn'); + if (samplesBackBtn) { + samplesBackBtn.addEventListener('click', (e) => { + e.preventDefault(); + history.replaceState(null, '', window.location.pathname + window.location.search); + hideSamplesPage(); + }); + } + + // Setup drag and drop early so UI is responsive + setupDragAndDrop(); + + if (!checkWasmSupport() || !checkWebGL2Support()) { + showErrorUI(getBrowserRequirements()); + const errorContent = document.getElementById('error-content'); + if (errorContent) { + errorContent.style.cursor = 'default'; + errorContent.style.pointerEvents = 'none'; + } + return; + } + + // Start preloading resources in background (will be awaited when file is selected) + wasmLoadPromise = loadWasm().catch(error => { + console.error('WASM load failed:', error); + throw error; + }); + fontLoadPromise = loadFonts().catch(error => { + console.error('Font load failed:', error); + throw error; + }); + + // Check for URL parameter and auto-load if present + const pagxUrl = getPAGXUrlFromParams(); + if (pagxUrl) { + loadPAGXFromURL(pagxUrl, false); + } + }; + + window.onresize = () => { + if (!playgroundState.pagxView || playgroundState.resized) { + return; + } + playgroundState.resized = true; + window.setTimeout(() => { + updateSize(); + }, 300); + }; +} diff --git a/playground/logo.png b/playground/logo.png new file mode 100644 index 0000000000..e74bea1ac7 --- /dev/null +++ b/playground/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:91273a42aff655d620061ab7e31a0902d2b67e50e420087fe2bfb58953c447f1 +size 54847 diff --git a/playground/package.json b/playground/package.json new file mode 100644 index 0000000000..4d9f86c4a6 --- /dev/null +++ b/playground/package.json @@ -0,0 +1,33 @@ +{ + "name": "pagx-playground", + "version": "1.0.0", + "description": "PAGX Playground", + "type": "module", + "scripts": { + "clean": "rimraf wasm-mt build-pagx-playground .*.md5", + "build": "node script/cmake.js -a wasm-mt && rollup -c ./script/rollup.js", + "build:debug": "node script/cmake.js -a wasm-mt --debug && rollup -c ./script/rollup.js", + "build:release": "node script/cmake.js -a wasm-mt && BUILD_MODE=release rollup -c ./script/rollup.js", + "publish": "node script/publish.cjs", + "server": "node server.js" + }, + "devDependencies": { + "@rollup/plugin-alias": "~5.1.1", + "@rollup/plugin-commonjs": "~28.0.3", + "@rollup/plugin-json": "~6.1.0", + "@rollup/plugin-node-resolve": "~16.0.1", + "@types/emscripten": "~1.39.6", + "esbuild": "~0.15.14", + "rimraf": "~5.0.10", + "rollup": "~2.79.1", + "rollup-plugin-esbuild": "~4.10.3", + "rollup-plugin-terser": "~7.0.2", + "tslib": "~2.4.1", + "typescript": "~5.0.3" + }, + "dependencies": { + "express": "^4.21.1" + }, + "license": "Apache-2.0", + "author": "Tencent" +} diff --git a/playground/pagx-playground.hash b/playground/pagx-playground.hash new file mode 100644 index 0000000000..ee79705fa2 --- /dev/null +++ b/playground/pagx-playground.hash @@ -0,0 +1,6 @@ +src/ +CMakeLists.txt +../include/ +../src/ +../CMakeLists.txt +../../DEPS diff --git a/playground/script/cmake.js b/playground/script/cmake.js new file mode 100644 index 0000000000..b43b1879f5 --- /dev/null +++ b/playground/script/cmake.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; +import path from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const require = createRequire(import.meta.url); + +const playgroundDir = path.dirname(__dirname); +process.chdir(playgroundDir); + +process.argv.push("-s"); +process.argv.push("./"); +process.argv.push("-o"); +process.argv.push("./"); +process.argv.push("-p"); +process.argv.push("web"); +process.argv.push("pagx-playground"); + +// Use vendor_tools from libpag +require("../../third_party/vendor_tools/lib-build"); \ No newline at end of file diff --git a/playground/script/publish.cjs b/playground/script/publish.cjs new file mode 100644 index 0000000000..6272c7b189 --- /dev/null +++ b/playground/script/publish.cjs @@ -0,0 +1,219 @@ +#!/usr/bin/env node +/** + * PAGX Playground Publisher + * + * Builds and copies the PAGX Playground to the public directory. + * + * Source files: + * index.html + * index.css + * wasm-mt/ + * ../../resources/font/ (from libpag root) + * ../../spec/samples/ (from libpag root) + * + * Output structure: + * /index.html + * /index.css + * /fonts/ + * /wasm-mt/ + * /samples/ (.pagx files, images, and generated index.json) + * + * Usage: + * npm run publish [-- -o ] + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +// Default paths +const SCRIPT_DIR = __dirname; +const PLAYGROUND_DIR = path.dirname(SCRIPT_DIR); +const LIBPAG_DIR = path.dirname(PLAYGROUND_DIR); +const RESOURCES_FONT_DIR = path.join(LIBPAG_DIR, 'resources', 'font'); +const SAMPLES_DIR = path.join(LIBPAG_DIR, 'spec', 'samples'); +const DEFAULT_OUTPUT_DIR = path.join(LIBPAG_DIR, 'public'); + +/** + * Parse command line arguments. + */ +function parseArgs() { + const args = process.argv.slice(2); + let outputDir = DEFAULT_OUTPUT_DIR; + let skipBuild = false; + + for (let i = 0; i < args.length; i++) { + if ((args[i] === '-o' || args[i] === '--output') && args[i + 1]) { + outputDir = path.resolve(args[i + 1]); + i++; + } else if (args[i] === '--skip-build') { + skipBuild = true; + } else if (args[i] === '-h' || args[i] === '--help') { + console.log(` +PAGX Playground Publisher + +Usage: + npm run publish [-- -o ] [-- --skip-build] + +Options: + -o, --output Output directory (default: ../public) + --skip-build Skip build step (use pre-built wasm-mt directory) + -h, --help Show this help message +`); + process.exit(0); + } + } + + return { outputDir, skipBuild }; +} + +/** + * Copy a file from source to destination. + */ +function copyFile(src, dest) { + const destDir = path.dirname(dest); + fs.mkdirSync(destDir, { recursive: true }); + fs.copyFileSync(src, dest); + console.log(` Copied: ${dest}`); +} + +/** + * Run a command with timeout and improved error handling. + */ +function runCommand(command, cwd, timeout = 600000) { + console.log(` Running: ${command}`); + try { + execSync(command, { + cwd, + stdio: 'inherit', + timeout: timeout // 10 minutes default + }); + } catch (error) { + if (error.killed) { + console.error(` ERROR: Command timed out after ${timeout/1000} seconds`); + console.error(` If build is taking longer, run directly:`); + console.error(` cd ${cwd}`); + console.error(` npm run build:release`); + } else { + console.error(` ERROR: Command failed with exit code ${error.status}`); + } + process.exit(1); + } +} + +/** + * Main function. + */ +function main() { + const { outputDir, skipBuild } = parseArgs(); + + console.log('Publishing PAGX Playground...'); + console.log(`Output: ${outputDir}\n`); + + // Build release (uses cache if available) + if (!skipBuild) { + console.log('Step 1: Build release...'); + runCommand('npm run build:release', PLAYGROUND_DIR, 600000); // 10 minute timeout + } else { + console.log('Step 1: Skipping build (--skip-build flag set)'); + if (!fs.existsSync(path.join(PLAYGROUND_DIR, 'wasm-mt', 'pagx-playground.wasm'))) { + console.error('ERROR: wasm-mt/pagx-playground.wasm not found. Run build first or remove --skip-build flag'); + process.exit(1); + } + } + + // Copy index.html + console.log('\nStep 2: Copy files...'); + copyFile( + path.join(PLAYGROUND_DIR, 'index.html'), + path.join(outputDir, 'index.html') + ); + + // Copy index.css + copyFile( + path.join(PLAYGROUND_DIR, 'index.css'), + path.join(outputDir, 'index.css') + ); + + // Copy favicon and logo + copyFile( + path.join(PLAYGROUND_DIR, 'favicon.png'), + path.join(outputDir, 'favicon.png') + ); + copyFile( + path.join(PLAYGROUND_DIR, 'logo.png'), + path.join(outputDir, 'logo.png') + ); + + // Copy fonts from resources/font + console.log('\n Copying fonts...'); + copyFile( + path.join(RESOURCES_FONT_DIR, 'NotoSansSC-Regular.otf'), + path.join(outputDir, 'fonts', 'NotoSansSC-Regular.otf') + ); + copyFile( + path.join(RESOURCES_FONT_DIR, 'NotoColorEmoji.ttf'), + path.join(outputDir, 'fonts', 'NotoColorEmoji.ttf') + ); + + // Copy wasm-mt directory + console.log('\n Copying wasm-mt...'); + const wasmDir = path.join(PLAYGROUND_DIR, 'wasm-mt'); + const wasmOutputDir = path.join(outputDir, 'wasm-mt'); + copyFile( + path.join(wasmDir, 'index.js'), + path.join(wasmOutputDir, 'index.js') + ); + copyFile( + path.join(wasmDir, 'pagx-playground.js'), + path.join(wasmOutputDir, 'pagx-playground.js') + ); + copyFile( + path.join(wasmDir, 'pagx-playground.wasm'), + path.join(wasmOutputDir, 'pagx-playground.wasm') + ); + + // Copy samples directory and generate index.json + console.log('\n Copying samples...'); + const samplesOutputDir = path.join(outputDir, 'samples'); + if (fs.existsSync(samplesOutputDir)) { + fs.rmSync(samplesOutputDir, { recursive: true }); + } + const sampleFiles = fs.readdirSync(SAMPLES_DIR) + .filter(f => !f.startsWith('.')) + .sort(); + for (const file of sampleFiles) { + copyFile( + path.join(SAMPLES_DIR, file), + path.join(samplesOutputDir, file) + ); + } + const pagxFiles = sampleFiles.filter(f => f.endsWith('.pagx')); + const indexJsonPath = path.join(samplesOutputDir, 'index.json'); + fs.mkdirSync(path.dirname(indexJsonPath), { recursive: true }); + fs.writeFileSync(indexJsonPath, JSON.stringify(pagxFiles, null, 2) + '\n'); + console.log(` Generated: ${indexJsonPath}`); + + // Copy baseline images that match sample pagx files + console.log('\n Copying sample images...'); + const testOutputDir = path.join(LIBPAG_DIR, 'test', 'out', 'PAGXTest'); + if (fs.existsSync(testOutputDir)) { + const imagesOutputDir = path.join(samplesOutputDir, 'images'); + fs.mkdirSync(imagesOutputDir, { recursive: true }); + + for (const pagxFile of pagxFiles) { + const baseName = pagxFile.replace(/\.pagx$/, ''); + const baselineFile = baseName + '_base.webp'; + const src = path.join(testOutputDir, baselineFile); + if (fs.existsSync(src)) { + copyFile(src, path.join(imagesOutputDir, baseName + '.webp')); + } + } + } else { + console.warn(' Warning: test output directory not found at', testOutputDir); + } + + console.log('\nDone!'); +} + +main(); diff --git a/playground/script/rollup.js b/playground/script/rollup.js new file mode 100644 index 0000000000..4f600fd659 --- /dev/null +++ b/playground/script/rollup.js @@ -0,0 +1,81 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +import esbuild from 'rollup-plugin-esbuild'; +import resolve from '@rollup/plugin-node-resolve'; +import commonJs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import alias from '@rollup/plugin-alias'; +import path from "path"; +import {readFileSync, readdirSync, unlinkSync} from "node:fs"; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const fileHeaderPath = path.resolve(__dirname, '../../.idea/fileTemplates/includes/PAG File Header.h'); +const banner = readFileSync(fileHeaderPath, 'utf-8'); +const isRelease = process.env.BUILD_MODE === 'release'; + +// Clean up old source map files in release mode +if (isRelease) { + const outputDir = path.resolve(__dirname, '../wasm-mt'); + try { + const files = readdirSync(outputDir); + for (const file of files) { + if (file.endsWith('.map')) { + unlinkSync(path.join(outputDir, file)); + } + } + } catch (e) { + // Directory may not exist yet + } +} + +const plugins = [ + esbuild({tsconfig: path.resolve(__dirname, "../tsconfig.json"), minify: isRelease}), + json(), + resolve({ extensions: ['.ts', '.js'] }), + commonJs(), + alias({ + entries: [{ find: '@tgfx', replacement: path.resolve(__dirname, '../../third_party/tgfx/web/src') }], + }), + { + name: 'preserve-import-meta-url', + resolveImportMeta(property, options) { + // Preserve the original behavior of `import.meta.url`. + if (property === 'url') { + return 'import.meta.url'; + } + return null; + }, + }, +]; + +export default [ + { + input: path.resolve(__dirname, '../index.ts'), + output: { + banner, + file: path.resolve(__dirname, '../wasm-mt/index.js'), + format: 'esm', + sourcemap: !isRelease + }, + plugins: plugins, + } +]; diff --git a/playground/server.js b/playground/server.js new file mode 100644 index 0000000000..873374e8c3 --- /dev/null +++ b/playground/server.js @@ -0,0 +1,71 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +import express from 'express'; +import path from 'path'; +import fs from 'fs'; +import { exec } from 'child_process'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const libpagDir = path.resolve(__dirname, '..'); + +const app = express(); + +// Enable SharedArrayBuffer (required for multi-threaded builds) +app.use((req, res, next) => { + res.set('Cross-Origin-Opener-Policy', 'same-origin'); + res.set('Cross-Origin-Embedder-Policy', 'require-corp'); + next(); +}); + +// Map /fonts to resources/font directory +app.use('/fonts', express.static(path.join(libpagDir, 'resources', 'font'))); + +// Generate samples/index.json dynamically for development +app.get('/samples/index.json', (req, res) => { + const samplesDir = path.join(libpagDir, 'spec', 'samples'); + const files = fs.readdirSync(samplesDir) + .filter(f => f.endsWith('.pagx')) + .sort(); + res.json(files); +}); + +// Map /samples to spec/samples directory +app.use('/samples', express.static(path.join(libpagDir, 'spec', 'samples'))); + +app.use('', express.static(__dirname, { + setHeaders: (res, filePath) => { + if (filePath.endsWith('.wasm')) { + res.set('Content-Type', 'application/wasm'); + } + } +})); + +app.get('/', (req, res) => { + res.redirect('/index.html'); +}); + +const port = 8080; +app.listen(port, () => { + const url = `http://localhost:${port}/`; + const start = (process.platform === 'darwin' ? 'open' : 'start'); + exec(start + ' ' + url); + console.log(`PAGX Playground running at ${url}`); +}); diff --git a/playground/src/GridBackground.cpp b/playground/src/GridBackground.cpp new file mode 100644 index 0000000000..3220537cfe --- /dev/null +++ b/playground/src/GridBackground.cpp @@ -0,0 +1,60 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#include "GridBackground.h" +#include "tgfx/layers/LayerRecorder.h" + +namespace pagx { + +std::shared_ptr GridBackgroundLayer::Make(int width, int height, + float density) { + return std::shared_ptr(new GridBackgroundLayer(width, height, density)); +} + +GridBackgroundLayer::GridBackgroundLayer(int width, int height, float density) + : width(width), height(height), density(density) { + invalidateContent(); +} + +void GridBackgroundLayer::onUpdateContent(tgfx::LayerRecorder* recorder) { + tgfx::LayerPaint backgroundPaint(tgfx::Color::White()); + recorder->addRect(tgfx::Rect::MakeWH(static_cast(width), static_cast(height)), + backgroundPaint); + + tgfx::LayerPaint tilePaint(tgfx::Color{0.8f, 0.8f, 0.8f, 1.f}); + // Use fixed logical size (32px) so the grid looks the same on all screens. + int logicalTileSize = 32; + int tileSize = static_cast(static_cast(logicalTileSize) * density); + if (tileSize <= 0) { + tileSize = logicalTileSize; + } + for (int y = 0; y < height; y += tileSize) { + bool draw = (y / tileSize) % 2 == 1; + for (int x = 0; x < width; x += tileSize) { + if (draw) { + recorder->addRect( + tgfx::Rect::MakeXYWH(static_cast(x), static_cast(y), + static_cast(tileSize), static_cast(tileSize)), + tilePaint); + } + draw = !draw; + } + } +} + +} // namespace pagx diff --git a/playground/src/GridBackground.h b/playground/src/GridBackground.h new file mode 100644 index 0000000000..20a197ea56 --- /dev/null +++ b/playground/src/GridBackground.h @@ -0,0 +1,40 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 "tgfx/layers/Layer.h" + +namespace pagx { + +class GridBackgroundLayer : public tgfx::Layer { + public: + static std::shared_ptr Make(int width, int height, float density); + + protected: + void onUpdateContent(tgfx::LayerRecorder* recorder) override; + + private: + GridBackgroundLayer(int width, int height, float density); + + int width = 0; + int height = 0; + float density = 1.f; +}; + +} // namespace pagx diff --git a/playground/src/PAGXView.cpp b/playground/src/PAGXView.cpp new file mode 100644 index 0000000000..9bc7de0ed8 --- /dev/null +++ b/playground/src/PAGXView.cpp @@ -0,0 +1,370 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 "PAGXView.h" +#include +#include "pagx/PAGXImporter.h" +#include "pagx/types/Data.h" +#include "tgfx/core/Data.h" +#include "tgfx/core/Typeface.h" + +using namespace emscripten; + +namespace pagx { + +// The frame duration threshold in milliseconds above which a frame is considered slow. +static constexpr double SlowFrameThresholdMs = 32.0; +// The time window in milliseconds for averaging frame durations to detect performance recovery. +static constexpr double RecoveryWindowMs = 2000.0; +// The timeout in milliseconds to detect the end of a zoom-in gesture. +static constexpr double ZoomInEndTimeoutMs = 300.0; +// The timeout in milliseconds to detect the end of a zoom-out gesture. +static constexpr double ZoomOutEndTimeoutMs = 800.0; +// The delay in milliseconds before retrying a tile refinement upgrade after zoom ends. +static constexpr double UpgradeRetryDelayMs = 300.0; +// The initial delay in milliseconds before upgrading tile refinement after zoom ends. +static constexpr double InitialUpgradeDelayMs = 200.0; +// The minimum number of normal frames required to recover from slow state in static mode. +static constexpr size_t MinRecoveryFramesStatic = 20; +// The minimum number of normal frames required to recover from slow state after zoom ends. +static constexpr size_t MinRecoveryFramesZoomEnd = 10; + +static uint8_t* CopyFromEmscripten(const val& emscriptenData, unsigned int* outLength) { + if (emscriptenData.isUndefined()) { + return nullptr; + } + unsigned int length = emscriptenData["length"].as(); + if (length == 0) { + return nullptr; + } + auto buffer = new (std::nothrow) uint8_t[length]; + if (!buffer) { + return nullptr; + } + auto memory = val::module_property("HEAPU8")["buffer"]; + auto memoryView = emscriptenData["constructor"].new_( + memory, static_cast(reinterpret_cast(buffer)), length); + memoryView.call("set", emscriptenData); + *outLength = length; + return buffer; +} + +static std::shared_ptr GetTGFXDataFromEmscripten(const val& emscriptenData) { + unsigned int length = 0; + auto buffer = CopyFromEmscripten(emscriptenData, &length); + if (!buffer) { + return nullptr; + } + return tgfx::Data::MakeAdopted(buffer, length, tgfx::Data::DeleteProc); +} + +static std::shared_ptr GetPagxDataFromEmscripten(const val& emscriptenData) { + unsigned int length = 0; + auto buffer = CopyFromEmscripten(emscriptenData, &length); + if (!buffer) { + return nullptr; + } + return Data::MakeAdopt(buffer, length); +} + +PAGXView::PAGXView(const std::string& canvasID) : canvasID(canvasID) { + displayList.setRenderMode(tgfx::RenderMode::Tiled); + displayList.setAllowZoomBlur(true); + displayList.setMaxTileCount(512); + displayList.setMaxTilesRefinedPerFrame(currentMaxTilesRefinedPerFrame); +} + +void PAGXView::registerFonts(const val& fontVal, const val& emojiFontVal) { + std::vector> fallbackTypefaces; + auto fontData = GetTGFXDataFromEmscripten(fontVal); + if (fontData) { + auto typeface = tgfx::Typeface::MakeFromData(fontData, 0); + if (typeface) { + fallbackTypefaces.push_back(std::move(typeface)); + } + } + auto emojiFontData = GetTGFXDataFromEmscripten(emojiFontVal); + if (emojiFontData) { + auto typeface = tgfx::Typeface::MakeFromData(emojiFontData, 0); + if (typeface) { + fallbackTypefaces.push_back(std::move(typeface)); + } + } + typesetter.setFallbackTypefaces(std::move(fallbackTypefaces)); +} + +void PAGXView::loadPAGX(const val& pagxData) { + parsePAGX(pagxData); + buildLayers(); +} + +void PAGXView::parsePAGX(const val& pagxData) { + document = nullptr; + auto data = GetPagxDataFromEmscripten(pagxData); + if (!data) { + return; + } + document = PAGXImporter::FromXML(data->bytes(), data->size()); +} + +std::vector PAGXView::getExternalFilePaths() const { + if (!document) { + return {}; + } + return document->getExternalFilePaths(); +} + +bool PAGXView::loadFileData(const std::string& filePath, const val& fileData) { + if (!document) { + return false; + } + auto data = GetPagxDataFromEmscripten(fileData); + if (!data) { + return false; + } + return document->loadFileData(filePath, std::move(data)); +} + +void PAGXView::buildLayers() { + if (!document) { + return; + } + contentLayer = LayerBuilder::Build(document.get(), &typesetter); + if (!contentLayer) { + return; + } + pagxWidth = document->width; + pagxHeight = document->height; + displayList.root()->removeChildren(); + displayList.root()->addChild(contentLayer); + applyCenteringTransform(); +} + +void PAGXView::updateSize() { + if (window == nullptr) { + window = tgfx::WebGLWindow::MakeFrom(canvasID); + } + if (window == nullptr) { + return; + } + window->invalidSize(); + auto device = window->getDevice(); + auto context = device->lockContext(); + if (context == nullptr) { + return; + } + auto surface = window->getSurface(context); + if (surface == nullptr) { + device->unlock(); + return; + } + if (surface->width() != lastSurfaceWidth || surface->height() != lastSurfaceHeight) { + lastSurfaceWidth = surface->width(); + lastSurfaceHeight = surface->height(); + applyCenteringTransform(); + presentImmediately = true; + } + device->unlock(); +} + +void PAGXView::applyCenteringTransform() { + if (lastSurfaceWidth <= 0 || lastSurfaceHeight <= 0 || !contentLayer) { + return; + } + if (pagxWidth <= 0 || pagxHeight <= 0) { + return; + } + float scaleX = static_cast(lastSurfaceWidth) / pagxWidth; + float scaleY = static_cast(lastSurfaceHeight) / pagxHeight; + float scale = std::min(scaleX, scaleY); + float offsetX = (static_cast(lastSurfaceWidth) - pagxWidth * scale) * 0.5f; + float offsetY = (static_cast(lastSurfaceHeight) - pagxHeight * scale) * 0.5f; + auto matrix = tgfx::Matrix::MakeTrans(offsetX, offsetY); + matrix.preScale(scale, scale); + contentLayer->setMatrix(matrix); +} + +void PAGXView::updateZoomScaleAndOffset(float zoom, float offsetX, float offsetY) { + if (zoom <= 1.0f) { + displayList.setSubtreeCacheMaxSize(1024); + } else { + displayList.setSubtreeCacheMaxSize(0); + } + + bool zoomChanged = (std::abs(zoom - lastZoom) > 0.001f); + if (zoomChanged) { + if (!isZooming) { + isZooming = true; + accumulatedZoomChange = 0.0f; + updateAdaptiveTileRefinement(); + } + float currentChange = zoom - lastZoom; + accumulatedZoomChange += currentChange; + if (std::abs(accumulatedZoomChange) > 0.01f) { + isZoomingIn = (accumulatedZoomChange > 0.0f); + } + lastZoomUpdateTimestampMs = emscripten_get_now(); + } + + displayList.setZoomScale(zoom); + displayList.setContentOffset(offsetX, offsetY); + lastZoom = zoom; +} + +void PAGXView::draw() { + if (window == nullptr) { + window = tgfx::WebGLWindow::MakeFrom(canvasID); + } + if (window == nullptr) { + return; + } + double frameStartMs = emscripten_get_now(); + bool hasContentChanged = displayList.hasContentChanged(); + bool hasLastRecording = (lastRecording != nullptr); + if (!hasContentChanged && !hasLastRecording) { + return; + } + auto device = window->getDevice(); + auto context = device->lockContext(); + if (context == nullptr) { + return; + } + auto surface = window->getSurface(context); + if (surface == nullptr) { + device->unlock(); + return; + } + auto canvas = surface->getCanvas(); + canvas->clear(); + int width = 0; + int height = 0; + emscripten_get_canvas_element_size(canvasID.c_str(), &width, &height); + auto density = width > 0 ? static_cast(surface->width()) / static_cast(width) : 1.0f; + int bgWidth = surface->width(); + int bgHeight = surface->height(); + if (!backgroundLayer || bgWidth != lastBackgroundWidth || bgHeight != lastBackgroundHeight || + std::abs(density - lastBackgroundDensity) > 0.001f) { + backgroundLayer = GridBackgroundLayer::Make(bgWidth, bgHeight, density); + lastBackgroundWidth = bgWidth; + lastBackgroundHeight = bgHeight; + lastBackgroundDensity = density; + } + backgroundLayer->draw(canvas); + displayList.render(surface.get(), false); + auto recording = context->flush(); + if (presentImmediately) { + presentImmediately = false; + if (recording) { + context->submit(std::move(recording)); + window->present(context); + } + } else { + std::swap(lastRecording, recording); + if (recording) { + context->submit(std::move(recording)); + window->present(context); + } + } + device->unlock(); + + double frameEndMs = emscripten_get_now(); + double frameDurationMs = frameEndMs - frameStartMs; + updatePerformanceState(frameDurationMs); + + if (isZooming && lastZoomUpdateTimestampMs > 0.0) { + double currentTimeoutMs = isZoomingIn ? ZoomInEndTimeoutMs : ZoomOutEndTimeoutMs; + double timeSinceLastUpdate = frameStartMs - lastZoomUpdateTimestampMs; + if (timeSinceLastUpdate >= currentTimeoutMs) { + onZoomEnd(); + } + } + + if (!isZooming && tryUpgradeTimestampMs > 0.0) { + if (frameStartMs >= tryUpgradeTimestampMs) { + if (!lastFrameSlow) { + int targetCount = calculateTargetTileRefinement(lastZoom); + currentMaxTilesRefinedPerFrame = targetCount; + displayList.setMaxTilesRefinedPerFrame(targetCount); + tryUpgradeTimestampMs = 0.0; + } else { + tryUpgradeTimestampMs = frameStartMs + UpgradeRetryDelayMs; + } + } + } else if (!isZooming) { + updateAdaptiveTileRefinement(); + } +} + +void PAGXView::onZoomEnd() { + if (!isZooming) { + return; + } + isZooming = false; + currentMaxTilesRefinedPerFrame = 1; + displayList.setMaxTilesRefinedPerFrame(currentMaxTilesRefinedPerFrame); + tryUpgradeTimestampMs = emscripten_get_now() + InitialUpgradeDelayMs; +} + +void PAGXView::updatePerformanceState(double frameDurationMs) { + double now = emscripten_get_now(); + if (frameDurationMs > SlowFrameThresholdMs) { + if (!lastFrameSlow) { + frameHistory.clear(); + frameHistoryTotalTime = 0.0; + } + lastFrameSlow = true; + } + frameHistory.push_back({now, frameDurationMs}); + frameHistoryTotalTime += frameDurationMs; + double windowStart = now - RecoveryWindowMs; + while (!frameHistory.empty() && frameHistory.front().timestampMs < windowStart) { + frameHistoryTotalTime -= frameHistory.front().durationMs; + frameHistory.pop_front(); + } + if (lastFrameSlow && !frameHistory.empty()) { + double avgTime = frameHistoryTotalTime / static_cast(frameHistory.size()); + size_t minFrames = isZooming ? MinRecoveryFramesZoomEnd : MinRecoveryFramesStatic; + if (avgTime <= SlowFrameThresholdMs && frameHistory.size() >= minFrames) { + lastFrameSlow = false; + } + } +} + +int PAGXView::calculateTargetTileRefinement(float zoom) const { + if (isZooming) { + return 0; + } + if (lastFrameSlow) { + return 1; + } + if (zoom < 1.0f) { + int count = static_cast(zoom / 0.33f) + 1; + return std::clamp(count, 1, 3); + } + return 3; +} + +void PAGXView::updateAdaptiveTileRefinement() { + int targetCount = calculateTargetTileRefinement(lastZoom); + if (targetCount != currentMaxTilesRefinedPerFrame) { + currentMaxTilesRefinedPerFrame = targetCount; + displayList.setMaxTilesRefinedPerFrame(targetCount); + } +} + +} // namespace pagx diff --git a/playground/src/PAGXView.h b/playground/src/PAGXView.h new file mode 100644 index 0000000000..718da72422 --- /dev/null +++ b/playground/src/PAGXView.h @@ -0,0 +1,108 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#pragma once + +#include +#include +#include "LayerBuilder.h" +#include "Typesetter.h" +#include "pagx/PAGXDocument.h" +#include "tgfx/gpu/Recording.h" +#include "tgfx/gpu/opengl/webgl/WebGLWindow.h" +#include "GridBackground.h" +#include "tgfx/layers/DisplayList.h" + +namespace pagx { + +class PAGXView { + public: + explicit PAGXView(const std::string& canvasID); + + void registerFonts(const emscripten::val& fontVal, const emscripten::val& emojiFontVal); + + void loadPAGX(const emscripten::val& pagxData); + + void parsePAGX(const emscripten::val& pagxData); + + std::vector getExternalFilePaths() const; + + bool loadFileData(const std::string& filePath, const emscripten::val& fileData); + + void buildLayers(); + + void updateSize(); + + void updateZoomScaleAndOffset(float zoom, float offsetX, float offsetY); + + void draw(); + + float contentWidth() const { + return pagxWidth; + } + + float contentHeight() const { + return pagxHeight; + } + + private: + void applyCenteringTransform(); + void onZoomEnd(); + void updatePerformanceState(double frameDurationMs); + void updateAdaptiveTileRefinement(); + int calculateTargetTileRefinement(float zoom) const; + + std::string canvasID = {}; + std::shared_ptr window = nullptr; + tgfx::DisplayList displayList = {}; + std::shared_ptr contentLayer = nullptr; + std::unique_ptr lastRecording = nullptr; + int lastSurfaceWidth = 0; + int lastSurfaceHeight = 0; + bool presentImmediately = true; + float pagxWidth = 0.0f; + float pagxHeight = 0.0f; + Typesetter typesetter = {}; + std::shared_ptr document = nullptr; + + // Background layer cache + std::shared_ptr backgroundLayer = nullptr; + int lastBackgroundWidth = 0; + int lastBackgroundHeight = 0; + float lastBackgroundDensity = 0.0f; + + // Performance monitoring + struct FrameRecord { + double timestampMs = 0.0; + double durationMs = 0.0; + }; + std::deque frameHistory = {}; + double frameHistoryTotalTime = 0.0; + bool lastFrameSlow = false; + + // Zoom state tracking + float lastZoom = 1.0f; + float accumulatedZoomChange = 0.0f; + bool isZooming = false; + bool isZoomingIn = false; + int currentMaxTilesRefinedPerFrame = 1; + double tryUpgradeTimestampMs = 0.0; + double lastZoomUpdateTimestampMs = 0.0; +}; + +} // namespace pagx diff --git a/playground/src/binding.cpp b/playground/src/binding.cpp new file mode 100644 index 0000000000..eeb4cd82f3 --- /dev/null +++ b/playground/src/binding.cpp @@ -0,0 +1,44 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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 +#include "PAGXView.h" + +using namespace emscripten; + +EMSCRIPTEN_BINDINGS(PAGXPlayground) { + class_("PAGXView") + .smart_ptr>("PAGXView") + .class_function("MakeFrom", optional_override([](const std::string& canvasID) { + if (canvasID.empty()) { + return std::shared_ptr(nullptr); + } + return std::make_shared(canvasID); + })) + .function("registerFonts", &pagx::PAGXView::registerFonts) + .function("loadPAGX", &pagx::PAGXView::loadPAGX) + .function("parsePAGX", &pagx::PAGXView::parsePAGX) + .function("getExternalFilePaths", &pagx::PAGXView::getExternalFilePaths) + .function("loadFileData", &pagx::PAGXView::loadFileData) + .function("buildLayers", &pagx::PAGXView::buildLayers) + .function("updateSize", &pagx::PAGXView::updateSize) + .function("updateZoomScaleAndOffset", &pagx::PAGXView::updateZoomScaleAndOffset) + .function("draw", &pagx::PAGXView::draw) + .function("contentWidth", &pagx::PAGXView::contentWidth) + .function("contentHeight", &pagx::PAGXView::contentHeight); +} diff --git a/playground/tsconfig.json b/playground/tsconfig.json new file mode 100644 index 0000000000..9eb5e62ead --- /dev/null +++ b/playground/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": ["ES5", "ES6", "DOM", "DOM.Iterable"], + "target": "ES2020", + "module": "ES2020", + "allowJs": true, + "sourceMap": true, + "esModuleInterop": true, + "moduleResolution": "Node", + "experimentalDecorators": true, + "strict": true, + "outDir": "./wasm-mt", + "baseUrl": ".", + "paths": { + "@tgfx/*": ["../../third_party/tgfx/web/src/*"] + }, + "resolveJsonModule": true + }, + "include": ["*.ts", "src/**/*.ts"] +} diff --git a/playground/types.ts b/playground/types.ts new file mode 100644 index 0000000000..4e5df8c9d9 --- /dev/null +++ b/playground/types.ts @@ -0,0 +1,73 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// 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. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +export interface EmscriptenGLContext { + handle: number; + GLctx: WebGLRenderingContext; + attributes: EmscriptenGLContextAttributes; + initExtensionsDone: boolean; + version: number; +} + +export type EmscriptenGLContextAttributes = + { majorVersion: number; minorVersion: number } + & WebGLContextAttributes; + +export interface EmscriptenGL { + contexts: (EmscriptenGLContext | null)[]; + createContext: ( + canvas: HTMLCanvasElement | OffscreenCanvas, + webGLContextAttributes: EmscriptenGLContextAttributes, + ) => number; + currentContext?: EmscriptenGLContext; + deleteContext: (contextHandle: number) => void; + framebuffers: (WebGLFramebuffer | null)[]; + getContext: (contextHandle: number) => EmscriptenGLContext; + getNewId: (array: any[]) => number; + makeContextCurrent: (contextHandle: number) => boolean; + registerContext: (ctx: WebGLRenderingContext, webGLContextAttributes: EmscriptenGLContextAttributes) => number; + textures: (WebGLTexture | null)[]; +} + +export interface PAGXModule extends EmscriptenModule { + GL: EmscriptenGL; + PAGXView: { + MakeFrom: (canvasID: string) => PAGXView | null; + }; + [key: string]: any; +} + +export interface PAGXView { + registerFonts: (fontData: Uint8Array, emojiFontData: Uint8Array) => void; + loadPAGX: (pagxData: Uint8Array) => void; + parsePAGX: (pagxData: Uint8Array) => void; + getExternalFilePaths: () => StringVector; + loadFileData: (filePath: string, data: Uint8Array) => boolean; + buildLayers: () => void; + updateSize: () => void; + updateZoomScaleAndOffset: (zoom: number, offsetX: number, offsetY: number) => void; + draw: () => void; + contentWidth: () => number; + contentHeight: () => number; +} + +export interface StringVector { + size: () => number; + get: (index: number) => string; + delete: () => void; +} diff --git a/resources/apitest/SVG/Baseline.svg b/resources/apitest/SVG/Baseline.svg new file mode 100644 index 0000000000..195195bf39 --- /dev/null +++ b/resources/apitest/SVG/Baseline.svg @@ -0,0 +1,1174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/apitest/SVG/ColorPicker.svg b/resources/apitest/SVG/ColorPicker.svg new file mode 100644 index 0000000000..d6be723228 --- /dev/null +++ b/resources/apitest/SVG/ColorPicker.svg @@ -0,0 +1,563 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/resources/apitest/SVG/Guidelines.svg b/resources/apitest/SVG/Guidelines.svg new file mode 100644 index 0000000000..ec24007886 --- /dev/null +++ b/resources/apitest/SVG/Guidelines.svg @@ -0,0 +1,540 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/apitest/SVG/Overview.svg b/resources/apitest/SVG/Overview.svg new file mode 100644 index 0000000000..0ac6f28c76 --- /dev/null +++ b/resources/apitest/SVG/Overview.svg @@ -0,0 +1,1328 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/apitest/SVG/Switch.svg b/resources/apitest/SVG/Switch.svg new file mode 100644 index 0000000000..26ef508abc --- /dev/null +++ b/resources/apitest/SVG/Switch.svg @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/apitest/SVG/UIkit.svg b/resources/apitest/SVG/UIkit.svg new file mode 100644 index 0000000000..3792d0a4b8 --- /dev/null +++ b/resources/apitest/SVG/UIkit.svg @@ -0,0 +1,898 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/apitest/SVG/blur.svg b/resources/apitest/SVG/blur.svg new file mode 100644 index 0000000000..809f1aec23 --- /dev/null +++ b/resources/apitest/SVG/blur.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/resources/apitest/SVG/complex1.svg b/resources/apitest/SVG/complex1.svg new file mode 100644 index 0000000000..224471c844 --- /dev/null +++ b/resources/apitest/SVG/complex1.svg @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/resources/apitest/SVG/complex2.svg b/resources/apitest/SVG/complex2.svg new file mode 100644 index 0000000000..3b5ba1707e --- /dev/null +++ b/resources/apitest/SVG/complex2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/apitest/SVG/complex3.svg b/resources/apitest/SVG/complex3.svg new file mode 100644 index 0000000000..4d34e2ab07 --- /dev/null +++ b/resources/apitest/SVG/complex3.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/apitest/SVG/complex4.svg b/resources/apitest/SVG/complex4.svg new file mode 100644 index 0000000000..91b3264ebf --- /dev/null +++ b/resources/apitest/SVG/complex4.svg @@ -0,0 +1 @@ +5日6日7日8日9日10日11日00.20.40.6商品用券情况用券未使用券 \ No newline at end of file diff --git a/resources/apitest/SVG/complex5.svg b/resources/apitest/SVG/complex5.svg new file mode 100644 index 0000000000..55d46b9198 --- /dev/null +++ b/resources/apitest/SVG/complex5.svg @@ -0,0 +1,1502 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/apitest/SVG/complex6.svg b/resources/apitest/SVG/complex6.svg new file mode 100644 index 0000000000..e9a04ef324 --- /dev/null +++ b/resources/apitest/SVG/complex6.svg @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/apitest/SVG/complex7.svg b/resources/apitest/SVG/complex7.svg new file mode 100644 index 0000000000..9a0d53d3c6 --- /dev/null +++ b/resources/apitest/SVG/complex7.svg @@ -0,0 +1,264 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/apitest/SVG/customData.svg b/resources/apitest/SVG/customData.svg new file mode 100644 index 0000000000..5119e99c6c --- /dev/null +++ b/resources/apitest/SVG/customData.svg @@ -0,0 +1,15 @@ + + + + + + + + Custom Data Test + + + diff --git a/resources/apitest/SVG/displayp3.svg b/resources/apitest/SVG/displayp3.svg new file mode 100644 index 0000000000..3298ffb5a1 --- /dev/null +++ b/resources/apitest/SVG/displayp3.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/resources/apitest/SVG/drawImageRect.svg b/resources/apitest/SVG/drawImageRect.svg new file mode 100644 index 0000000000..f832f4b61a --- /dev/null +++ b/resources/apitest/SVG/drawImageRect.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/resources/apitest/SVG/duplicatePaths.svg b/resources/apitest/SVG/duplicatePaths.svg new file mode 100644 index 0000000000..19822f2532 --- /dev/null +++ b/resources/apitest/SVG/duplicatePaths.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/resources/apitest/SVG/emoji.svg b/resources/apitest/SVG/emoji.svg new file mode 100644 index 0000000000..7d1f52bbd8 --- /dev/null +++ b/resources/apitest/SVG/emoji.svg @@ -0,0 +1,8 @@ + + + + SVG 😀 + diff --git a/resources/apitest/SVG/jpg.svg b/resources/apitest/SVG/jpg.svg new file mode 100644 index 0000000000..0771a79eba --- /dev/null +++ b/resources/apitest/SVG/jpg.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/resources/apitest/SVG/mask.svg b/resources/apitest/SVG/mask.svg new file mode 100644 index 0000000000..1f98244997 --- /dev/null +++ b/resources/apitest/SVG/mask.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/resources/apitest/SVG/path.svg b/resources/apitest/SVG/path.svg new file mode 100644 index 0000000000..b516f00af9 --- /dev/null +++ b/resources/apitest/SVG/path.svg @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/apitest/SVG/png.svg b/resources/apitest/SVG/png.svg new file mode 100644 index 0000000000..921c2a13d7 --- /dev/null +++ b/resources/apitest/SVG/png.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/resources/apitest/SVG/radialGradient.svg b/resources/apitest/SVG/radialGradient.svg new file mode 100644 index 0000000000..4747a517bc --- /dev/null +++ b/resources/apitest/SVG/radialGradient.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/resources/apitest/SVG/refStyle.svg b/resources/apitest/SVG/refStyle.svg new file mode 100644 index 0000000000..75a6cee404 --- /dev/null +++ b/resources/apitest/SVG/refStyle.svg @@ -0,0 +1,16 @@ + + + + + + + + + \ No newline at end of file diff --git a/resources/apitest/SVG/text.svg b/resources/apitest/SVG/text.svg new file mode 100644 index 0000000000..d242aab002 --- /dev/null +++ b/resources/apitest/SVG/text.svg @@ -0,0 +1,8 @@ + + + + SVG GVS + diff --git a/resources/apitest/SVG/textFont.svg b/resources/apitest/SVG/textFont.svg new file mode 100644 index 0000000000..32c6f0432a --- /dev/null +++ b/resources/apitest/SVG/textFont.svg @@ -0,0 +1,9 @@ + + + + SVG GVS + SVG GVS + diff --git a/resources/apitest/SVG/widegamut.svg b/resources/apitest/SVG/widegamut.svg new file mode 100644 index 0000000000..08c65d4d23 --- /dev/null +++ b/resources/apitest/SVG/widegamut.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/resources/pagx/api_consistency.pagx b/resources/pagx/api_consistency.pagx new file mode 100644 index 0000000000..00a9c5c773 --- /dev/null +++ b/resources/pagx/api_consistency.pagx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/resources/pagx/layer_direct_content.pagx b/resources/pagx/layer_direct_content.pagx new file mode 100644 index 0000000000..99ad59174c --- /dev/null +++ b/resources/pagx/layer_direct_content.pagx @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/resources/pagx/linear_gradient.pagx b/resources/pagx/linear_gradient.pagx new file mode 100644 index 0000000000..bf5b7c4cb5 --- /dev/null +++ b/resources/pagx/linear_gradient.pagx @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/spec/favicon.png b/spec/favicon.png new file mode 100644 index 0000000000..912a7f1ced --- /dev/null +++ b/spec/favicon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80e1adb9dc95987069da3083077dc81018c104a8ae25e5db9414910d098783d5 +size 1200 diff --git a/spec/package.json b/spec/package.json new file mode 100644 index 0000000000..718bbfa6c1 --- /dev/null +++ b/spec/package.json @@ -0,0 +1,16 @@ +{ + "name": "pagx-spec", + "version": "1.0", + "stableVersion": "", + "description": "PAGX specification publisher", + "private": true, + "scripts": { + "publish": "node script/publish.js" + }, + "dependencies": { + "highlight.js": "^11.9.0", + "marked": "^15.0.0", + "marked-gfm-heading-id": "^4.1.0", + "marked-highlight": "^2.2.0" + } +} diff --git a/spec/pagx_spec.md b/spec/pagx_spec.md new file mode 100644 index 0000000000..594f3e8988 --- /dev/null +++ b/spec/pagx_spec.md @@ -0,0 +1,1892 @@ +# PAGX Specification + +## 1. Introduction + +**PAGX** (Portable Animated Graphics XML) is an XML-based markup language for describing animated vector graphics. It provides a unified and powerful representation of vector graphics and animations, designed to be the vector animation interchange standard across all major tools and runtimes. + +### 1.1 Design Goals + +- **Readable**: Uses a plain-text XML format that is easy to read and edit, with native support for version control and diffing, facilitating debugging as well as AI understanding and generation. + +- **Comprehensive**: Fully covers vector graphics, raster images, rich text, filter effects, blending modes, masking, and related capabilities, meeting the requirements for complex animated graphics. + +- **Expressive**: Defines a compact yet expressive unified structure that optimizes the description of both static vector content and animations, while reserving extensibility for future interaction and scripting. + +- **Interoperable**: Can serve as a common interchange format for design tools such as After Effects, Figma, and Tencent Design, enabling seamless asset exchange across platforms. + +- **Deployable**: Design assets can be exported and deployed to production environments with a single action, achieving high compression ratios and excellent runtime performance after conversion to the binary PAG format. + +### 1.2 File Structure + +PAGX is a plain XML file (`.pagx`) that can reference external resource files (images, videos, audio, fonts, etc.) or embed resources via data URIs. PAGX and binary PAG formats are bidirectionally convertible: convert to PAG for optimized loading performance during publishing; use PAGX format for reading and editing during development and review. + +### 1.3 Document Organization + +This specification is organized in the following order: + +1. **Basic Data Types**: Defines the fundamental data formats used throughout the document +2. **Document Structure**: Describes the overall organization of a PAGX document +3. **Layer System**: Defines layers and their related features (styles, filters, masks) +4. **VectorElement System**: Defines vector elements within layers and their processing model + +**Appendices** (for quick reference): + +- **Appendix A**: Node hierarchy and containment relationships +- **Appendix B**: Enumeration type reference +- **Appendix C**: Common usage examples + +--- + +## 2. Basic Data Types + +This section defines the basic data types and naming conventions used in PAGX documents. + +### 2.1 Naming Conventions + +| Category | Convention | Examples | +|----------|------------|----------| +| Element names | PascalCase, no abbreviations | `Group`, `Rectangle`, `Fill` | +| Attribute names | camelCase, kept short | `antiAlias`, `blendMode`, `fontSize` | +| Default unit | Pixels (no notation required) | `width="100"` | +| Angle unit | Degrees | `rotation="45"` | + +### 2.2 Attribute Table Conventions + +This specification uses the "Default" column in attribute tables to describe whether an attribute is required: + +| Default Format | Meaning | +|----------------|---------| +| `(required)` | Attribute must be specified; no default value | +| Specific value (e.g., `0`, `true`, `normal`) | Attribute is optional; uses this default when not specified | +| `-` | Attribute is optional; has no effect when not specified | + +### 2.3 Common Attributes + +The following attributes are available on any element and are not repeated in individual node attribute tables: + +| Attribute | Type | Description | +|-----------|------|-------------| +| `id` | string | Unique identifier used for reference by other elements (e.g., masks, color sources). Must be unique within the document and cannot be empty or contain whitespace | +| `data-*` | string | Custom data attributes for storing application-specific private data. `*` can be replaced with any name (e.g., `data-name`, `data-layer-id`); ignored at runtime | + +**Custom Attribute Guidelines**: + +- Attribute names must begin with `data-` followed by at least one character +- Attribute names may only contain lowercase letters, numbers, and hyphens (`-`), and cannot end with a hyphen +- Attribute values are arbitrary strings interpreted by the creating application +- These attributes are not processed at runtime; used only for passing metadata between tools or storing debug information + +**Example**: + +```xml + + + + +``` + +### 2.4 Basic Value Types + +| Type | Description | Examples | +|------|-------------|----------| +| `float` | Floating-point number | `1.5`, `-0.5`, `100` | +| `int` | Integer | `400`, `0`, `-1` | +| `bool` | Boolean | `true`, `false` | +| `string` | String | `"Arial"`, `"myLayer"` | +| `enum` | Enumeration value | `normal`, `multiply` | +| `idref` | ID reference | `@gradientId`, `@maskLayer` | + +### 2.5 Point + +A point is represented by two comma-separated floating-point numbers: + +``` +"x,y" +``` + +**Examples**: `"100,200"`, `"0.5,0.5"`, `"-50,100"` + +### 2.6 Rect + +A rectangle is represented by four comma-separated floating-point numbers: + +``` +"x,y,width,height" +``` + +**Examples**: `"0,0,100,100"`, `"10,20,200,150"` + +### 2.7 Transform Matrix + +#### 2D Transform Matrix + +2D transforms use 6 comma-separated floating-point numbers corresponding to a standard 2D affine transformation matrix: + +``` +"a,b,c,d,tx,ty" +``` + +Matrix form: +``` +| a c tx | +| b d ty | +| 0 0 1 | +``` + +**Identity matrix**: `"1,0,0,1,0,0"` + +#### 3D Transform Matrix + +3D transforms use 16 comma-separated floating-point numbers in column-major order: + +``` +"m00,m10,m20,m30,m01,m11,m21,m31,m02,m12,m22,m32,m03,m13,m23,m33" +``` + +### 2.8 Color + +PAGX supports two color formats: + +#### HEX Format (Hexadecimal) + +HEX format represents colors in the sRGB color space using a `#` prefix with hexadecimal values: + +| Format | Example | Description | +|--------|---------|-------------| +| `#RGB` | `#F00` | 3-digit shorthand; each digit expands to two (equivalent to `#FF0000`) | +| `#RRGGBB` | `#FF0000` | 6-digit standard format, opaque | +| `#RRGGBBAA` | `#FF000080` | 8-digit with alpha at the end (consistent with CSS) | + +#### Floating-Point Format + +Floating-point format uses `colorspace(r, g, b)` or `colorspace(r, g, b, a)` to represent colors, supporting both sRGB and Display P3 color spaces: + +| Color Space | Format | Example | Description | +|-------------|--------|---------|-------------| +| sRGB | `srgb(r, g, b)` | `srgb(1.0, 0.5, 0.2)` | sRGB color space, components 0.0~1.0 | +| sRGB | `srgb(r, g, b, a)` | `srgb(1.0, 0.5, 0.2, 0.8)` | With alpha | +| Display P3 | `p3(r, g, b)` | `p3(1.0, 0.5, 0.2)` | Display P3 wide color gamut | +| Display P3 | `p3(r, g, b, a)` | `p3(1.0, 0.5, 0.2, 0.8)` | With alpha | + +**Notes**: +- Color space identifiers (`srgb` or `p3`) and parentheses **cannot be omitted** +- Wide color gamut (Display P3) component values may exceed the [0, 1] range to represent colors outside the sRGB gamut +- sRGB floating-point format and HEX format represent the same color space; use whichever best suits your needs + +#### Color Source Reference + +Use `@resourceId` to reference color sources (gradients, patterns, etc.) defined in Resources. + +### 2.9 Blend Mode + +Blend modes define how source color (S) combines with destination color (D). + +| Value | Formula | Description | +|-------|---------|-------------| +| `normal` | S | Normal (overwrite) | +| `multiply` | S × D | Multiply | +| `screen` | 1 - (1-S)(1-D) | Screen | +| `overlay` | multiply/screen combination | Overlay | +| `darken` | min(S, D) | Darken | +| `lighten` | max(S, D) | Lighten | +| `colorDodge` | D / (1-S) | Color Dodge | +| `colorBurn` | 1 - (1-D)/S | Color Burn | +| `hardLight` | multiply/screen reversed combination | Hard Light | +| `softLight` | Soft version of overlay | Soft Light | +| `difference` | \|S - D\| | Difference | +| `exclusion` | S + D - 2SD | Exclusion | +| `hue` | D's saturation and luminosity + S's hue | Hue | +| `saturation` | D's hue and luminosity + S's saturation | Saturation | +| `color` | D's luminosity + S's hue and saturation | Color | +| `luminosity` | S's luminosity + D's hue and saturation | Luminosity | +| `plusLighter` | S + D | Plus Lighter (toward white) | +| `plusDarker` | S + D - 1 | Plus Darker (toward black) | + +### 2.10 Path Data Syntax + +Path data uses SVG path syntax, consisting of a series of commands and coordinates. + +**Path Commands**: + +| Command | Parameters | Description | +|---------|------------|-------------| +| M/m | x y | Move to (absolute/relative) | +| L/l | x y | Line to | +| H/h | x | Horizontal line to | +| V/v | y | Vertical line to | +| C/c | x1 y1 x2 y2 x y | Cubic Bézier curve | +| S/s | x2 y2 x y | Smooth cubic Bézier | +| Q/q | x1 y1 x y | Quadratic Bézier curve | +| T/t | x y | Smooth quadratic Bézier | +| A/a | rx ry rotation large-arc sweep x y | Elliptical arc | +| Z/z | - | Close path | + +### 2.11 External Resource Reference + +External resources are referenced via relative paths or data URIs, applicable to images, videos, audio, fonts, and other files. + +```xml + + + + + + +``` + +**Path Resolution Rules**: + +- **Relative paths**: Resolved relative to the directory containing the PAGX file; supports `../` to reference parent directories +- **Data URIs**: Begin with `data:`, format is `data:;base64,`; only base64 encoding is supported +- Path separators must use `/` (forward slash); `\` (backslash) is not supported + +--- + +## 3. Document Structure + +This section defines the overall structure of a PAGX document. + +### 3.1 Coordinate System + +PAGX uses a standard 2D Cartesian coordinate system: + +- **Origin**: Located at the top-left corner of the canvas +- **X-axis**: Positive direction points right +- **Y-axis**: Positive direction points down +- **Angles**: Clockwise direction is positive (0° points toward the positive X-axis) +- **Units**: All length values default to pixels; angle values default to degrees + +### 3.2 Root Element (pagx) + +`` is the root element of a PAGX document, defining the canvas dimensions and directly containing the layer list. + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `version` | string | (required) | Format version | +| `width` | float | (required) | Canvas width | +| `height` | float | (required) | Canvas height | + +**Layer Rendering Order**: Layers are rendered sequentially in document order; layers earlier in the document render first (below); later layers render last (above). + +> [Sample](samples/3.2_document_structure.pagx) + +### 3.3 Resources + +`` defines reusable resources including images, path data, color sources, and compositions. Resources are identified by the `id` attribute and referenced elsewhere in the document using the `@id` syntax. + +**Element Position**: The Resources element may be placed anywhere within the root element; there are no restrictions on its position. Parsers must support forward references—elements that reference resources or layers defined later in the document. + +> [Sample](samples/3.3_resources.pagx) + +#### 3.3.1 Image + +Image resources define bitmap data for use throughout the document. + +```xml + + +``` + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `source` | string | (required) | File path or data URI | + +**Supported Formats**: PNG, JPEG, WebP, GIF + +#### 3.3.2 PathData + +PathData defines reusable path data for reference by Path elements and TextPath modifiers. + +```xml + +``` + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `data` | string | (required) | SVG path data | + +#### 3.3.3 Color Sources + +Color sources define colors that can be used for fills and strokes, supporting two usage patterns: + +1. **Shared Definition**: Pre-defined in `` and referenced via `@id`. Suitable for color sources **referenced in multiple places**. +2. **Inline Definition**: Nested directly within `` or `` elements. Suitable for color sources **used only once**, providing a more concise syntax. + +##### SolidColor + +```xml + +``` + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `color` | Color | (required) | Color value | + +##### LinearGradient + +Linear gradients interpolate along the direction from start point to end point. + +> [Sample](samples/3.3.3_linear_gradient.pagx) + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `startPoint` | Point | (required) | Start point | +| `endPoint` | Point | (required) | End point | +| `matrix` | Matrix | identity matrix | Transform matrix | + +**Calculation**: For a point P, its color is determined by the projection position of P onto the line connecting start and end points. + +##### RadialGradient + +Radial gradients radiate outward from the center. + +> [Sample](samples/3.3.3_radial_gradient.pagx) + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `center` | Point | 0,0 | Center point | +| `radius` | float | (required) | Gradient radius | +| `matrix` | Matrix | identity matrix | Transform matrix | + +**Calculation**: For a point P, its color is determined by `distance(P, center) / radius`. + +##### ConicGradient + +Conic gradients (also known as sweep gradients) interpolate along the circumference. + +> [Sample](samples/3.3.3_conic_gradient.pagx) + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `center` | Point | 0,0 | Center point | +| `startAngle` | float | 0 | Start angle | +| `endAngle` | float | 360 | End angle | +| `matrix` | Matrix | identity matrix | Transform matrix | + +**Calculation**: For a point P, its color is determined by the ratio of `atan2(P.y - center.y, P.x - center.x)` within the `[startAngle, endAngle]` range. + +**Angle convention**: Follows the global coordinate system convention (see §3.1): 0° points to the **right** (positive X-axis), and angles increase **clockwise**. This differs from CSS `conic-gradient` where 0° points to the top. Common reference angles: + +| Angle | Direction | +|-------|-----------| +| 0° | Right (3 o'clock) | +| 90° | Down (6 o'clock) | +| 180° | Left (9 o'clock) | +| 270° | Up (12 o'clock) | + +##### DiamondGradient + +Diamond gradients radiate from the center toward the four corners. + +> [Sample](samples/3.3.3_diamond_gradient.pagx) + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `center` | Point | 0,0 | Center point | +| `radius` | float | (required) | Gradient radius | +| `matrix` | Matrix | identity matrix | Transform matrix | + +**Calculation**: For a point P, its color is determined by the Chebyshev distance `max(|P.x - center.x|, |P.y - center.y|) / radius`. + +##### ColorStop + +```xml + +``` + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `offset` | float | (required) | Position 0.0~1.0 | +| `color` | Color | (required) | Stop color | + +**Common Gradient Rules**: + +- **Stop Interpolation**: Linear interpolation between adjacent color stops +- **Stop Boundaries**: + - Stops with `offset < 0` are treated as `offset = 0` + - Stops with `offset > 1` are treated as `offset = 1` + - If there is no stop at `offset = 0`, the first stop's color is used to fill + - If there is no stop at `offset = 1`, the last stop's color is used to fill + +##### ImagePattern + +Image patterns use an image as a color source. + +```xml + +``` + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `image` | idref | (required) | Image reference "@id" | +| `tileModeX` | TileMode | clamp | X-direction tile mode | +| `tileModeY` | TileMode | clamp | Y-direction tile mode | +| `filterMode` | FilterMode | linear | Texture filter mode | +| `mipmapMode` | MipmapMode | linear | Mipmap mode | +| `matrix` | Matrix | identity matrix | Transform matrix | + +**TileMode**: `clamp`, `repeat`, `mirror`, `decal` + +**FilterMode**: `nearest`, `linear` + +**MipmapMode**: `none`, `nearest`, `linear` + +**Complete Example**: Demonstrates ImagePattern fill with different tile modes + +> [Sample](samples/3.3.3_image_pattern.pagx) + +##### Color Source Coordinate System + +Except for solid colors, all color sources (gradients, image patterns) operate within a coordinate system **relative to the origin of the geometry element's local coordinate system**. The `matrix` attribute can be used to apply transforms to the color source coordinate system. + +**Transform Behavior**: + +1. **External transforms affect both geometry and color source**: Group transforms, layer matrices, and other external transforms apply holistically to both the geometry element and its color source—they scale, rotate, and translate together. + +2. **Modifying geometry properties does not affect the color source**: Directly modifying geometry element properties (such as Rectangle's width/height or Path's path data) only changes the geometry content itself without affecting the color source coordinate system. + +**Example**: Drawing a diagonal linear gradient within a 300×300 region: + +> [Sample](samples/3.3.3_color_source_coordinates.pagx) + +- Applying `scale(2, 2)` transform to this layer: The rectangle becomes 600×600, and the gradient scales accordingly, maintaining consistent visual appearance +- Directly changing Rectangle's size to 600,600: The rectangle becomes 600×600, but the gradient coordinates remain unchanged, covering only the top-left quarter of the rectangle + +#### 3.3.4 Composition + +Compositions are used for content reuse (similar to After Effects pre-comps). + +> [Sample](samples/3.3.4_composition.pagx) + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `width` | float | (required) | Composition width | +| `height` | float | (required) | Composition height | + +#### 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. + +```xml + + + + + + + + + + + +``` + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `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. + +**GlyphID Rules**: +- **GlyphID starts from 1**: Glyph list index + 1 = GlyphID +- **GlyphID 0 is reserved**: Represents a missing glyph; not rendered + +##### Glyph + +Glyph defines rendering data for a single glyph. Either `path` or `image` must be specified (but not both). + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `advance` | float | (required) | Horizontal advance width in design space coordinates (unitsPerEm units) | +| `path` | string | - | SVG path data (vector outline) | +| `image` | string | - | Image data (base64 data URI) or external file path | +| `offset` | Point | 0,0 | Glyph offset in design space coordinates (typically used for bitmap glyphs) | + +**Glyph Types**: +- **Vector glyph**: Specifies the `path` attribute using SVG path syntax to describe the outline +- **Bitmap glyph**: Specifies the `image` attribute for colored glyphs like emoji; position can be adjusted with `offset` + +**Coordinate System**: Glyph paths and offsets use design space coordinates. During rendering, the scale factor is calculated from GlyphRun's `fontSize` and Font's `unitsPerEm`: `scale = fontSize / unitsPerEm`. + +### 3.4 Document Hierarchy + +PAGX documents organize content in a hierarchical structure: + +``` + ← Root element (defines canvas dimensions) +├── ← Layer (multiple allowed) +│ ├── Geometry elements ← Rectangle, Ellipse, Path, Text, etc. +│ ├── Modifiers ← TrimPath, RoundCorner, TextModifier, etc. +│ ├── Painters ← Fill, Stroke +│ ├── ← VectorElement container (nestable) +│ ├── LayerStyle ← DropShadowStyle, InnerShadowStyle, etc. +│ ├── LayerFilter ← BlurFilter, ColorMatrixFilter, etc. +│ └── ← Child layers (recursive structure) +│ └── ... +│ +└── ← Resources section (optional, defines reusable resources) + ├── ← Image resource + ├── ← Path data resource + ├── ← Solid color definition + ├── ← Gradient definition + ├── ← Image pattern definition + ├── ← Font resource (embedded font) + │ └── ← Glyph definition + └── ← Composition definition + └── ← Layers within composition +``` + +--- + +## 4. Layer System + +Layers are the fundamental organizational units for PAGX content, offering comprehensive control over visual effects. + +### 4.1 Core Concepts + +This section introduces the core concepts of the layer system. These concepts form the foundation for understanding layer styles, filters, and masks. + +#### Layer Rendering Pipeline + +Painters (Fill, Stroke, etc.) bound to a layer are divided into background content and foreground content via the `placement` attribute, defaulting to background content. A single layer renders in the following order: + +1. **Layer Styles (below)**: Render layer styles positioned below content (e.g., drop shadows) +2. **Background Content**: Render Fill and Stroke with `placement="background"` +3. **Child Layers**: Recursively render all child layers in document order +4. **Layer Styles (above)**: Render layer styles positioned above content (e.g., inner shadows) +5. **Foreground Content**: Render Fill and Stroke with `placement="foreground"` +6. **Layer Filters**: Use the combined output of previous steps as input to the filter chain, applying all filters sequentially + +#### Layer Content + +**Layer content** refers to the complete rendering result of the layer's background content, child layers, and foreground content. Layer styles compute their effects based on layer content. For example, when fill is background and stroke is foreground, the stroke renders above child layers, but drop shadows are still calculated based on the complete layer content including fill, child layers, and stroke. + +#### Layer Contour + +**Layer contour** is used for masking and certain layer styles. Compared to normal layer content, layer contour has these differences: + +1. **Includes geometry drawn with alpha=0**: Geometric shapes filled with completely transparent fills are included in the contour +2. **Solid fills and gradient fills**: Original fills are ignored and replaced with opaque white drawing +3. **Image fills**: Original pixels are preserved, but semi-transparent pixels are converted to fully opaque (fully transparent pixels are preserved) + +Note: Geometry elements must have painters to participate in the contour; standalone geometry elements (Rectangle, Ellipse, etc.) without corresponding Fill or Stroke do not participate in contour calculation. + +Layer contour is primarily used for: + +- **Layer Styles**: Some layer styles require contour as one of their input sources +- **Masking**: `maskType="contour"` uses the mask layer's contour for clipping + +#### Layer Background + +**Layer background** refers to the composited result of all rendered content below the current layer, including: +- Rendering results of all sibling layers below the current layer and their subtrees +- Layer styles already drawn below the current layer (excluding BackgroundBlurStyle itself) + +Layer background is primarily used for: + +- **Layer Styles**: Some layer styles require background as one of their input sources +- **Blend Modes**: Some blend modes require background information for correct rendering + +**Background Pass-through**: The `passThroughBackground` attribute controls whether layer background passes through to child layers. When set to `false`, child layers' background-dependent styles cannot access the correct layer background. The following conditions automatically disable background pass-through: +- Layer uses a non-`normal` blend mode +- Layer has filters applied +- Layer uses 3D transforms or projection transforms + +### 4.2 Layer + +`` is the basic container for content and child layers. + +> [Sample](samples/4.2_layer.pagx) + +#### Child Elements + +Layer child elements are automatically categorized into four collections by type: + +| Child Element Type | Category | Description | +|-------------------|----------|-------------| +| VectorElement | contents | Geometry elements, modifiers, painters (participate in accumulation processing) | +| LayerStyle | styles | DropShadowStyle, InnerShadowStyle, BackgroundBlurStyle | +| LayerFilter | filters | BlurFilter, DropShadowFilter, and other filters | +| Layer | children | Nested child layers | + +**Recommended Order**: Although child element order does not affect parsing results, it is recommended to write them in the order: VectorElement → LayerStyle → LayerFilter → child Layer, for improved readability. + +#### Layer Attributes + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `name` | string | "" | Display name | +| `visible` | bool | true | Whether visible | +| `alpha` | float | 1 | Opacity 0~1 | +| `blendMode` | BlendMode | normal | Blend mode | +| `x` | float | 0 | X position | +| `y` | float | 0 | Y position | +| `matrix` | Matrix | identity matrix | 2D transform "a,b,c,d,tx,ty" | +| `matrix3D` | Matrix | - | 3D transform (16 values, column-major) | +| `preserve3D` | bool | false | Preserve 3D transform | +| `antiAlias` | bool | true | Edge anti-aliasing | +| `groupOpacity` | bool | false | Group opacity | +| `passThroughBackground` | bool | true | Whether to pass background through to child layers | +| `excludeChildEffectsInLayerStyle` | bool | false | Whether layer styles exclude child layer effects | +| `scrollRect` | Rect | - | Scroll clipping region "x,y,w,h" | +| `mask` | idref | - | Mask layer reference "@id" | +| `maskType` | MaskType | alpha | Mask type | +| `composition` | idref | - | Composition reference "@id" | + +**groupOpacity**: When `false` (default), the layer's `alpha` is applied independently to each child element, which may cause overlapping semi-transparent children to appear darker at intersections. When `true`, all layer content is first composited into an offscreen buffer, then `alpha` is applied to the buffer as a whole, producing uniform transparency across the entire layer. + +**preserve3D**: When `false` (default), child layers with 3D transforms are flattened into the parent's 2D plane before compositing. When `true`, child layers retain their 3D positions and are rendered in a shared 3D space, enabling depth-based intersections and correct z-ordering among siblings. Similar to CSS `transform-style: preserve-3d`. + +**Transform Attribute Priority**: `x`/`y`, `matrix`, and `matrix3D` have an override relationship: +- Only `x`/`y` set: Uses `x`/`y` for translation +- `matrix` set: `matrix` overrides `x`/`y` values +- `matrix3D` set: `matrix3D` overrides both `matrix` and `x`/`y` values + +**MaskType**: + +| Value | Description | +|-------|-------------| +| `alpha` | Alpha mask: Uses mask's alpha channel | +| `luminance` | Luminance mask: Uses mask's luminance values | +| `contour` | Contour mask: Uses mask's contour for clipping | + +**BlendMode**: See Section 2.9 for the complete blend mode table. + +### 4.3 Layer Styles + +Layer styles add visual effects above or below layer content without modifying the original. + +**Input Sources for Layer Styles**: + +All layer styles compute effects based on **layer content**. In layer styles, layer content is automatically converted to **Opaque mode**: geometric shapes are rendered with normal fills, then all semi-transparent pixels are converted to fully opaque (fully transparent pixels are preserved). This means shadows produced by semi-transparent fills appear the same as those from fully opaque fills. + +Some layer styles additionally use **layer contour** or **layer background** as input (see individual style descriptions). Definitions of layer contour and layer background are in Section 4.1. + +> [Sample](samples/4.3_layer_styles.pagx) + +**Common LayerStyle Attributes**: + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `blendMode` | BlendMode | normal | Blend mode (see Section 2.9) | + +#### 4.3.1 DropShadowStyle + +Draws a drop shadow **below** the layer. Computes shadow shape based on opaque layer content. When `showBehindLayer="false"`, additionally uses **layer contour** as an erase mask to cut out the portion occluded by the layer. + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `offsetX` | float | 0 | X offset | +| `offsetY` | float | 0 | Y offset | +| `blurX` | float | 0 | X blur radius | +| `blurY` | float | 0 | Y blur radius | +| `color` | Color | #000000 | Shadow color | +| `showBehindLayer` | bool | true | Whether shadow shows behind layer | + +**Rendering Steps**: +1. Get opaque layer content and offset by `(offsetX, offsetY)` +2. Apply Gaussian blur `(blurX, blurY)` to the offset content +3. Fill the shadow region with `color` +4. If `showBehindLayer="false"`, use layer contour as erase mask to cut out occluded portion + +**showBehindLayer**: +- `true`: Shadow displays completely, including portion occluded by layer content +- `false`: Portion of shadow occluded by layer content is cut out (using layer contour as erase mask) + +#### 4.3.2 BackgroundBlurStyle + +Applies blur effect to layer background **below** the layer. Computes effect based on **layer background**, using opaque layer content as mask for clipping. + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `blurX` | float | 0 | X blur radius | +| `blurY` | float | 0 | Y blur radius | +| `tileMode` | TileMode | mirror | Tile mode | + +**Rendering Steps**: +1. Get layer background below layer bounds +2. Apply Gaussian blur `(blurX, blurY)` to layer background +3. Clip blurred result using opaque layer content as mask + +#### 4.3.3 InnerShadowStyle + +Draws an inner shadow **above** the layer, appearing inside the layer content. Computes shadow range based on opaque layer content. + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `offsetX` | float | 0 | X offset | +| `offsetY` | float | 0 | Y offset | +| `blurX` | float | 0 | X blur radius | +| `blurY` | float | 0 | Y blur radius | +| `color` | Color | #000000 | Shadow color | + +**Rendering Steps**: +1. Get opaque layer content and offset by `(offsetX, offsetY)` +2. Apply Gaussian blur `(blurX, blurY)` to the inverse of the offset content (exterior region) +3. Fill the shadow region with `color` +4. Intersect with opaque layer content, keeping only shadow inside content + +### 4.4 Layer Filters + +Layer filters are the final stage of layer rendering. All previously rendered results (including layer styles) accumulated in order serve as filter input. Filters are applied in chain fashion according to document order, with each filter's output becoming the next filter's input. + +Unlike layer styles (Section 4.3), which **independently render** visual effects above or below layer content, filters **modify** the layer's overall rendering output. Layer styles are applied before filters. + +> [Sample](samples/4.4_layer_filters.pagx) + +#### 4.4.1 BlurFilter + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `blurX` | float | (required) | X blur radius | +| `blurY` | float | (required) | Y blur radius | +| `tileMode` | TileMode | decal | Tile mode | + +#### 4.4.2 DropShadowFilter + +Generates shadow effect based on filter input. Unlike DropShadowStyle, the filter projects from original rendering content and preserves semi-transparency, whereas the style projects from opaque layer content. The two also support different attribute features. + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `offsetX` | float | 0 | X offset | +| `offsetY` | float | 0 | Y offset | +| `blurX` | float | 0 | X blur radius | +| `blurY` | float | 0 | Y blur radius | +| `color` | Color | #000000 | Shadow color | +| `shadowOnly` | bool | false | Show shadow only | + +**Rendering Steps**: +1. Offset filter input by `(offsetX, offsetY)` +2. Extract alpha channel and apply Gaussian blur `(blurX, blurY)` +3. Fill shadow region with `color` +4. Composite shadow with filter input (`shadowOnly=false`) or output shadow only (`shadowOnly=true`) + +#### 4.4.3 InnerShadowFilter + +Draws shadow inside the filter input. + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `offsetX` | float | 0 | X offset | +| `offsetY` | float | 0 | Y offset | +| `blurX` | float | 0 | X blur radius | +| `blurY` | float | 0 | Y blur radius | +| `color` | Color | #000000 | Shadow color | +| `shadowOnly` | bool | false | Show shadow only | + +**Rendering Steps**: +1. Create inverse mask of filter input's alpha channel +2. Offset and apply Gaussian blur +3. Intersect with filter input's alpha channel +4. Composite result with filter input (`shadowOnly=false`) or output shadow only (`shadowOnly=true`) + +#### 4.4.4 BlendFilter + +Overlays a specified color onto the layer using a specified blend mode. + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `color` | Color | (required) | Blend color | +| `blendMode` | BlendMode | normal | Blend mode (see Section 2.9) | + +#### 4.4.5 ColorMatrixFilter + +Transforms colors using a 4×5 color matrix. + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `matrix` | Matrix | (required) | 4x5 color matrix (20 comma-separated floats) | + +**Matrix Format** (20 values, row-major): +``` +| R' | | m[0] m[1] m[2] m[3] m[4] | | R | +| G' | | m[5] m[6] m[7] m[8] m[9] | | G | +| B' | = | m[10] m[11] m[12] m[13] m[14] | × | B | +| A' | | m[15] m[16] m[17] m[18] m[19] | | A | + | 1 | +``` + +### 4.5 Clipping and Masking + +#### 4.5.1 scrollRect + +The `scrollRect` attribute defines the layer's visible region; content outside this region is clipped. + +> [Sample](samples/4.5.1_scroll_rect.pagx) + +#### 4.5.2 Masking + +Reference another layer as a mask using the `mask` attribute. + +> [Sample](samples/4.5.2_masking.pagx) + +**Masking Rules**: +- The mask layer itself is not rendered (the `visible` attribute is ignored) +- The mask layer's transforms do not affect the masked layer + +--- + +## 5. VectorElement System + +The VectorElement system defines how vector content within Layers is processed and rendered. + +### 5.1 Processing Model + +The VectorElement system employs an **accumulate-render** processing model: geometry elements accumulate in a rendering context, modifiers transform the accumulated geometry, and painters trigger final rendering. + +#### 5.1.1 Terminology + +| Term | Elements | Description | +|------|----------|-------------| +| **Geometry Elements** | Rectangle, Ellipse, Polystar, Path, Text | Elements providing geometric shapes; accumulate as a geometry list in the context | +| **Modifiers** | TrimPath, RoundCorner, MergePath, TextModifier, TextPath, TextLayout, Repeater | Transform accumulated geometry | +| **Painters** | Fill, Stroke | Perform fill or stroke rendering on accumulated geometry | +| **Containers** | Group | Create isolated scopes and apply matrix transforms; merge upon completion | + +#### 5.1.2 Internal Structure of Geometry Elements + +Geometry elements have different internal structures when accumulating in the context: + +| Element Type | Internal Structure | Description | +|--------------|-------------------|-------------| +| Shape elements (Rectangle, Ellipse, Polystar, Path) | Single Path | Each shape element produces one path | +| Text element (Text) | Glyph list | A Text produces multiple glyphs after shaping | + +#### 5.1.3 Processing and Rendering Order + +VectorElements are processed sequentially in **document order**; elements appearing earlier are processed first. By default, painters processed earlier are rendered first (appearing below). + +Since Fill and Stroke can specify rendering to layer background or foreground via the `placement` attribute, the final rendering order may not exactly match document order. However, in the default case (all content as background), rendering order matches document order. + +#### 5.1.4 Unified Processing Flow + +``` +Geometry Elements Modifiers Painters +┌──────────┐ ┌──────────┐ ┌──────────┐ +│Rectangle │ │ TrimPath │ │ Fill │ +│ Ellipse │ │RoundCorn │ │ Stroke │ +│ Polystar │ │MergePath │ └────┬─────┘ +│ Path │ │TextModif │ │ +│ Text │ │ TextPath │ │ +└────┬─────┘ │TextLayout│ │ + │ │ Repeater │ │ + │ └────┬─────┘ │ + │ │ │ + │ Accumulate │ Transform │ Render + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────┐ +│ Geometry List [Path1, Path2, GlyphList1, GlyphList2...] │ +└─────────────────────────────────────────────────────────┘ +``` + +**Rendering context** accumulates a geometry list where: +- Each shape element contributes one Path +- Each Text contributes a glyph list (containing multiple glyphs) + +#### 5.1.5 Modifier Scope + +Different modifiers have different scopes over elements in the geometry list: + +| Modifier Type | Target | Description | +|---------------|--------|-------------| +| Shape modifiers (TrimPath, RoundCorner, MergePath) | Paths only | Trigger forced conversion for text | +| Text modifiers (TextModifier, TextPath, TextLayout) | Glyph lists only | No effect on Paths | +| Repeater | Paths + glyph lists | Affects all geometry simultaneously | + +### 5.2 Geometry Elements + +Geometry elements provide renderable shapes. + +#### 5.2.1 Rectangle + +Rectangles are defined from center point with uniform corner rounding support. + +```xml + +``` + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `center` | Point | 0,0 | Center point | +| `size` | Size | 100,100 | Dimensions "width,height" | +| `roundness` | float | 0 | Corner radius | +| `reversed` | bool | false | Reverse path direction | + +**Calculation Rules**: +``` +rect.left = center.x - size.width / 2 +rect.top = center.y - size.height / 2 +rect.right = center.x + size.width / 2 +rect.bottom = center.y + size.height / 2 +``` + +**Corner Rounding**: +- `roundness` value is automatically limited to `min(roundness, size.width/2, size.height/2)` +- When `roundness >= min(size.width, size.height) / 2`, the shorter dimension becomes semicircular + +**Path Start Point**: Rectangle path starts from the **top-right corner**, drawn clockwise (`reversed="false"`). + +**Example**: + +> [Sample](samples/5.2.1_rectangle.pagx) + +#### 5.2.2 Ellipse + +Ellipses are defined from center point. + +```xml + +``` + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `center` | Point | 0,0 | Center point | +| `size` | Size | 100,100 | Dimensions "width,height" | +| `reversed` | bool | false | Reverse path direction | + +**Calculation Rules**: +``` +boundingRect.left = center.x - size.width / 2 +boundingRect.top = center.y - size.height / 2 +boundingRect.right = center.x + size.width / 2 +boundingRect.bottom = center.y + size.height / 2 +``` + +**Path Start Point**: Ellipse path starts from the **right midpoint** (3 o'clock position). + +**Example**: + +> [Sample](samples/5.2.2_ellipse.pagx) + +#### 5.2.3 Polystar + +Supports both regular polygon and star modes. + +```xml + +``` + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `center` | Point | 0,0 | Center point | +| `type` | PolystarType | star | Type (see below) | +| `pointCount` | float | 5 | Number of points (supports decimals) | +| `outerRadius` | float | 100 | Outer radius | +| `innerRadius` | float | 50 | Inner radius (star only) | +| `rotation` | float | 0 | Rotation angle | +| `outerRoundness` | float | 0 | Outer corner roundness 0~1 | +| `innerRoundness` | float | 0 | Inner corner roundness 0~1 | +| `reversed` | bool | false | Reverse path direction | + +**PolystarType**: + +| Value | Description | +|-------|-------------| +| `polygon` | Regular polygon: Uses outer radius only | +| `star` | Star: Alternates between outer and inner radii | + +**Polygon Mode** (`type="polygon"`): +- Uses only `outerRadius` and `outerRoundness` +- `innerRadius` and `innerRoundness` are ignored + +**Star Mode** (`type="star"`): +- Outer vertices at `outerRadius` +- Inner vertices at `innerRadius` +- Vertices alternate to form star shape + +**Vertex Calculation** (i-th outer vertex): +``` +angle = rotation + (i / pointCount) * 360° +x = center.x + outerRadius * cos(angle) +y = center.y + outerRadius * sin(angle) +``` + +**Fractional Point Count**: +- `pointCount` supports decimal values (e.g., `5.5`) +- The fractional part determines how much of the final vertex is drawn, producing an incomplete corner +- `pointCount <= 0` generates no path + +**Roundness**: +- `outerRoundness` and `innerRoundness` range from 0~1 +- 0 means sharp corners; 1 means fully rounded +- Roundness is achieved by adding Bézier control points at vertices + +**Example**: + +> [Sample](samples/5.2.3_polystar.pagx) + +#### 5.2.4 Path + +Defines arbitrary shapes using SVG path syntax, supporting inline data or references to PathData defined in Resources. + +```xml + + + + + +``` + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `data` | string/idref | (required) | SVG path data or PathData resource reference "@id" | +| `reversed` | bool | false | Reverse path direction | + +**Example**: + +> [Sample](samples/5.2.4_path.pagx) + +#### 5.2.5 Text + +Text elements provide geometric shapes for text content. Unlike shape elements that produce a single Path, Text produces a **glyph list** (multiple glyphs) after shaping, which accumulates in the rendering context's geometry list for subsequent modifier transformation or painter rendering. + +```xml + +``` + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `text` | string | "" | Text content | +| `position` | Point | 0,0 | Text start position, y is baseline (may be overridden by TextLayout) | +| `fontFamily` | string | system default | Font family | +| `fontStyle` | string | "Regular" | Font variant (Regular, Bold, Italic, Bold Italic, etc.) | +| `fontSize` | float | 12 | Font size | +| `letterSpacing` | float | 0 | Letter spacing | +| `baselineShift` | float | 0 | Baseline shift (positive shifts up, negative shifts down) | + +Child elements: `CDATA` text, `GlyphRun`* + +**Text Content**: Typically use the `text` attribute to specify text content. When text contains XML special characters (`<`, `>`, `&`, etc.) or needs to preserve multi-line formatting, use a CDATA child node instead of the `text` attribute. Text does not allow direct plain text child nodes; CDATA wrapping is required. + +```xml + + + + + D]]> + + + + + +``` + +**Rendering Modes**: Text supports **pre-layout** and **runtime layout** modes. Pre-layout provides pre-computed glyphs and positions via GlyphRun child nodes, rendering with embedded fonts for cross-platform consistency. Runtime layout performs shaping and layout at runtime; due to platform differences in fonts and layout features, minor inconsistencies may occur. For pixel-perfect reproduction of design tool layouts, pre-layout is recommended. + +**Runtime Layout Rendering Flow**: +1. Find system font based on `fontFamily` and `fontStyle`; if unavailable, select fallback font according to runtime-configured fallback list +2. Shape using `text` attribute (or CDATA child node); newlines trigger line breaks (default 1.2× font size line height, customizable via TextLayout) +3. Apply typography parameters: `fontSize`, `letterSpacing`, `baselineShift` +4. Construct glyph list and accumulate to rendering context + +**Runtime Layout Example**: + +> [Sample](samples/5.2.5_text.pagx) + +##### GlyphRun (Pre-layout Data) + +GlyphRun defines pre-layout data for a group of glyphs, each GlyphRun independently referencing one font resource. + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `font` | idref | (required) | Font resource reference `@id` | +| `fontSize` | float | 12 | Rendering font size. Actual scale = `fontSize / font.unitsPerEm` | +| `glyphs` | string | (required) | GlyphID sequence, comma-separated (0 means missing glyph) | +| `x` | float | 0 | Overall X offset | +| `y` | float | 0 | Overall Y offset | +| `xOffsets` | string | - | Per-glyph X offset, comma-separated | +| `positions` | string | - | Per-glyph (x,y) offset, semicolon-separated | +| `anchors` | string | - | Per-glyph anchor offset (x,y), semicolon-separated. The anchor is the center point for scale, rotation, and skew transforms. Default anchor is (advance×0.5, 0) | +| `scales` | string | - | Per-glyph scale (sx,sy), semicolon-separated. Scaling is applied around the anchor point. Default 1,1 | +| `rotations` | string | - | Per-glyph rotation angle (degrees), comma-separated. Rotation is applied around the anchor point. Default 0 | +| `skews` | string | - | Per-glyph skew angle (degrees), comma-separated. Skewing is applied around the anchor point. Default 0 | + +All attributes are optional and can be combined. When an attribute array is shorter than the glyph count, missing values use defaults. + +**Position Calculation**: + +``` +finalX[i] = x + xOffsets[i] + positions[i].x +finalY[i] = y + positions[i].y +``` + +- When `xOffsets` is not specified, `xOffsets[i]` is treated as 0 +- When `positions` is not specified, `positions[i]` is treated as (0, 0) +- When neither `xOffsets` nor `positions` is specified: First glyph positioned at (x, y), subsequent glyphs positioned by accumulating each Glyph's `advance` attribute + +**Transform Application Order**: + +When a glyph has scale, rotation, or skew transforms, they are applied in the following order (consistent with TextModifier): + +1. Translate to anchor (`translate(-anchor)`) +2. Scale (`scale`) +3. Skew (`skew`, along vertical axis) +4. Rotate (`rotation`) +5. Translate back from anchor (`translate(anchor)`) +6. Translate to position (`translate(position)`) + +**Anchor**: + +- Each glyph's **default anchor** is at `(advance × 0.5, 0)`, the horizontal center at baseline +- The `anchors` attribute records offsets relative to the default anchor, final anchor = default anchor + anchors[i] + +**Pre-layout Example**: + +> [Sample](samples/5.2.5_glyph_run.pagx) + +### 5.3 Painters + +Painters (Fill, Stroke) render all geometry (Paths and glyph lists) accumulated at the **current moment**. + +#### 5.3.1 Fill + +Fill draws the interior region of geometry using a specified color source. + +> [Sample](samples/5.3.1_fill.pagx) + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `color` | Color/idref | #000000 | Color value or color source reference, default black | +| `alpha` | float | 1 | Opacity 0~1 | +| `blendMode` | BlendMode | normal | Blend mode (see Section 2.9) | +| `fillRule` | FillRule | winding | Fill rule (see below) | +| `placement` | LayerPlacement | background | Rendering position (see Section 5.3.3) | + +Child elements: May embed one color source (SolidColor, LinearGradient, RadialGradient, ConicGradient, DiamondGradient, ImagePattern) + +**FillRule**: + +| Value | Description | +|-------|-------------| +| `winding` | Non-zero winding rule: Counts based on path direction; fills if non-zero | +| `evenOdd` | Even-odd rule: Counts based on crossing count; fills if odd | + +**Text Fill**: +- Text is filled per glyph +- Supports color override for individual glyphs via TextModifier +- Color override uses alpha blending: `finalColor = lerp(originalColor, overrideColor, overrideAlpha)` + +#### 5.3.2 Stroke + +Stroke draws lines along geometry boundaries. + +> [Sample](samples/5.3.2_stroke.pagx) + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `color` | Color/idref | #000000 | Color value or color source reference, default black | +| `width` | float | 1 | Stroke width | +| `alpha` | float | 1 | Opacity 0~1 | +| `blendMode` | BlendMode | normal | Blend mode (see Section 2.9) | +| `cap` | LineCap | butt | Line cap style (see below) | +| `join` | LineJoin | miter | Line join style (see below) | +| `miterLimit` | float | 4 | Miter limit | +| `dashes` | string | - | Dash pattern "d1,d2,..." | +| `dashOffset` | float | 0 | Dash offset | +| `dashAdaptive` | bool | false | Scale dashes to equal length | +| `align` | StrokeAlign | center | Stroke alignment (see below) | +| `placement` | LayerPlacement | background | Rendering position (see Section 5.3.3) | + +**LineCap**: + +| Value | Description | +|-------|-------------| +| `butt` | Butt cap: Line does not extend beyond endpoints | +| `round` | Round cap: Semicircular extension at endpoints | +| `square` | Square cap: Rectangular extension at endpoints | + +**LineJoin**: + +| Value | Description | +|-------|-------------| +| `miter` | Miter join: Extends outer edges to form sharp corner | +| `round` | Round join: Connected with circular arc | +| `bevel` | Bevel join: Fills connection with triangle | + +**StrokeAlign**: + +| Value | Description | +|-------|-------------| +| `center` | Stroke centered on path (default) | +| `inside` | Stroke inside closed path | +| `outside` | Stroke outside closed path | + +Inside/outside stroke is achieved by: +1. Stroking at double width +2. Boolean operation with original shape (intersection for inside, difference for outside) + +**Dash Pattern**: +- `dashes`: Defines dash segment length sequence, e.g., `"5,3"` means 5px solid + 3px gap +- `dashOffset`: Dash start offset +- `dashAdaptive`: When true, scales the dash intervals so that the dash segments have the same length + +#### 5.3.3 LayerPlacement + +The `placement` attribute of Fill and Stroke controls rendering order relative to child layers: + +| Value | Description | +|-------|-------------| +| `background` | Render **below** child layers (default) | +| `foreground` | Render **above** child layers | + +### 5.4 Shape Modifiers + +Shape modifiers perform **in-place transformation** on accumulated Paths; for glyph lists, they trigger forced conversion to Paths. + +#### 5.4.1 TrimPath + +Trims paths to a specified start/end range. + +```xml + +``` + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `start` | float | 0 | Start position 0~1 | +| `end` | float | 1 | End position 0~1 | +| `offset` | float | 0 | Offset in degrees; 360° equals one full cycle of the path length. For example, 180° shifts the trim range by half the path length | +| `type` | TrimType | separate | Trim type (see below) | + +**TrimType**: + +| Value | Description | +|-------|-------------| +| `separate` | Separate mode: Each shape trimmed independently with same start/end parameters | +| `continuous` | Continuous mode: All shapes treated as one continuous path, trimmed by total length ratio | + +**Edge Cases**: +- `start > end`: The start and end values are mirrored (`start = 1 - start`, `end = 1 - end`) and all path directions are reversed, then normal trimming is applied. The resulting visual is the complementary segment of the path with reversed direction +- Supports wrapping: When trim range exceeds [0,1], automatically wraps to other end of path +- When total path length is 0, no operation is performed + +> [Sample](samples/5.4.1_trim_path.pagx) + +#### 5.4.2 RoundCorner + +Converts sharp corners of paths to rounded corners. + +```xml + +``` + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `radius` | float | 10 | Corner radius | + +**Processing Rules**: +- Only affects sharp corners (non-smooth connected vertices) +- Corner radius is automatically limited to not exceed half the length of adjacent edges +- `radius <= 0` performs no operation + +**Example**: + +> [Sample](samples/5.4.2_round_corner.pagx) + +#### 5.4.3 MergePath + +Merges all shapes into a single shape. + +```xml + +``` + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `mode` | MergePathOp | append | Merge operation (see below) | + +**MergePathOp**: + +| Value | Description | +|-------|-------------| +| `append` | Append: Simply merge all paths without boolean operations (default) | +| `union` | Union: Merge all shape coverage areas | +| `intersect` | Intersect: Keep only overlapping areas of all shapes | +| `xor` | XOR: Keep non-overlapping areas | +| `difference` | Difference: Subtract subsequent shapes from first shape | + +**Important Behavior**: +- MergePath **clears all previously accumulated Fill and Stroke effects** in the current scope; only the merged path remains in the geometry list +- Current transformation matrices of shapes are applied during merge +- Merged shape's transformation matrix resets to identity matrix + +**Example**: + +> [Sample](samples/5.4.3_merge_path.pagx) + +### 5.5 Text Modifiers + +Text modifiers transform individual glyphs within text. + +#### 5.5.1 Text Modifier Processing + +When a text modifier is encountered, **all glyph lists** accumulated in the context are combined into a unified glyph list for the operation: + +```xml + + + + + + + +``` + +#### 5.5.2 Text to Shape Conversion + +When text encounters a shape modifier, it is forcibly converted to shape paths: + +``` +Text Element Shape Modifier Subsequent Modifiers +┌──────────┐ ┌──────────┐ +│ Text │ │ TrimPath │ +└────┬─────┘ │RoundCorn │ + │ │MergePath │ + │ Accumulated └────┬─────┘ + │ Glyph List │ + ▼ │ Triggers Conversion +┌──────────────┐ │ +│ Glyph List │───────────┼──────────────────────┐ +│ [H,e,l,l,o] │ │ │ +└──────────────┘ ▼ ▼ + ┌──────────────┐ ┌──────────────────┐ + │ Merged into │ │ Emoji Discarded │ + │ Single Path │ │ (Cannot convert) │ + └──────────────┘ └──────────────────┘ + │ + │ Subsequent text modifiers no longer effective + ▼ + ┌──────────────┐ + │ TextModifier │ → Skipped (Already Path) + └──────────────┘ +``` + +**Conversion Rules**: + +1. **Trigger Condition**: Conversion triggered when text encounters TrimPath, RoundCorner, or MergePath +2. **Merge into Single Path**: All glyphs of one Text merge into **one** Path, not one independent Path per glyph +3. **Emoji Loss**: Emoji cannot be converted to path outlines; discarded during conversion +4. **Irreversible Conversion**: After conversion becomes pure Path; subsequent text modifiers have no effect on it + +**Example**: + +```xml + + + + + + +``` + +#### 5.5.3 TextModifier + +Applies transforms and style overrides to glyphs within selected ranges. TextModifier may contain multiple RangeSelector child elements. + +```xml + + + + +``` + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `anchor` | Point | 0,0 | Anchor point offset, relative to glyph's default anchor position. Each glyph's default anchor is at `(advance × 0.5, 0)`, the horizontal center at baseline | +| `position` | Point | 0,0 | Position offset | +| `rotation` | float | 0 | Rotation | +| `scale` | Point | 1,1 | Scale | +| `skew` | float | 0 | Skew amount in degrees along the skewAxis direction | +| `skewAxis` | float | 0 | Skew axis angle in degrees; defines the direction along which skewing is applied | +| `alpha` | float | 1 | Opacity | +| `fillColor` | Color | - | Fill color override | +| `strokeColor` | Color | - | Stroke color override | +| `strokeWidth` | float | - | Stroke width override | + +**Selector Calculation**: +1. Calculate selection range based on RangeSelector's `start`, `end`, `offset` (supports any decimal value; automatically wraps when exceeding [0,1] range) +2. Calculate influence factor (0~1) for each glyph based on `shape` +3. Combine multiple selectors according to `mode` + +**Transform Application**: + +The `factor` calculated by the selector ranges from [-1, 1] and controls the degree to which transform properties are applied: + +``` +factor = clamp(selectorFactor × weight, -1, 1) +``` + +Position and rotation are applied linearly with factor. Transforms are applied in the following order: + +1. Translate to negative anchor (`translate(-anchor × factor)`) +2. Scale from identity (`scale(1 + (scale - 1) × factor)`) +3. Skew (`skew(skew × factor, skewAxis)`) +4. Rotate (`rotate(rotation × factor)`) +5. Translate back to anchor (`translate(anchor × factor)`) +6. Translate to position (`translate(position × factor)`) + +Opacity uses the absolute value of factor: + +``` +alphaFactor = 1 + (alpha - 1) × |factor| +finalAlpha = originalAlpha × max(0, alphaFactor) +``` + +**Color Override**: + +Color override uses the absolute value of `factor` for alpha blending: + +``` +blendFactor = overrideColor.alpha × |factor| +finalColor = blend(originalColor, overrideColor, blendFactor) +``` + +**Example**: + +> [Sample](samples/5.5.3_text_modifier.pagx) + +#### 5.5.4 RangeSelector + +Range selectors define the glyph range and influence degree for TextModifier. + +```xml + +``` + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `start` | float | 0 | Selection start | +| `end` | float | 1 | Selection end | +| `offset` | float | 0 | Selection offset | +| `unit` | SelectorUnit | percentage | Unit | +| `shape` | SelectorShape | square | Shape | +| `easeIn` | float | 0 | Ease in amount | +| `easeOut` | float | 0 | Ease out amount | +| `mode` | SelectorMode | add | Combine mode | +| `weight` | float | 1 | Selector weight | +| `randomOrder` | bool | false | Random order | +| `randomSeed` | int | 0 | Random seed | + +**SelectorUnit**: + +| Value | Description | +|-------|-------------| +| `index` | Index: Calculate range by glyph index | +| `percentage` | Percentage: Calculate range by percentage of total glyphs | + +**SelectorShape**: + +| Value | Description | +|-------|-------------| +| `square` | Square: 1 within range, 0 outside | +| `rampUp` | Ramp Up: Linear increase from 0 to 1 | +| `rampDown` | Ramp Down: Linear decrease from 1 to 0 | +| `triangle` | Triangle: 1 at center, 0 at edges | +| `round` | Round: Sinusoidal transition | +| `smooth` | Smooth: Smoother transition curve | + +**SelectorMode**: + +| Value | Description | +|-------|-------------| +| `add` | Add: Accumulate selector weights | +| `subtract` | Subtract: Subtract selector weights | +| `intersect` | Intersect: Use the intersection of selector ranges | +| `min` | Min: Take the minimum of selector values | +| `max` | Max: Take maximum value | +| `difference` | Difference: Take absolute difference | + +#### 5.5.5 TextPath + +Arranges text along a specified path. The path can reference a PathData defined in Resources, or use +inline path data. TextPath uses a baseline (a straight line defined by baselineOrigin and +baselineAngle) as the text's reference line: glyphs are mapped from their positions along this +baseline onto corresponding positions on the path curve, preserving their relative spacing and +offsets. When forceAlignment is enabled, original glyph positions are ignored and glyphs are +redistributed evenly to fill the available path length. + +> [Sample](samples/5.5.5_text_path.pagx) + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `path` | string/idref | (required) | SVG path data or PathData resource reference "@id" | +| `baselineOrigin` | Point | 0,0 | Baseline origin, the starting point of the text reference line | +| `baselineAngle` | float | 0 | Baseline angle in degrees, 0 for horizontal, 90 for vertical | +| `firstMargin` | float | 0 | Start margin | +| `lastMargin` | float | 0 | End margin | +| `perpendicular` | bool | true | Perpendicular to path | +| `reversed` | bool | false | Reverse direction | +| `forceAlignment` | bool | false | Force stretch text to fill path | + +**Baseline**: +- `baselineOrigin`: The starting point of the baseline in the TextPath's local coordinate space +- `baselineAngle`: The angle of the baseline in degrees. 0 means a horizontal baseline (text flows left to right along X axis), 90 means a vertical baseline (text flows top to bottom along Y axis) +- Each glyph's distance along the baseline determines where it lands on the curve, and its perpendicular offset from the baseline is preserved as a perpendicular offset from the curve + +**Margins**: +- `firstMargin`: Start margin (offset inward from path start) +- `lastMargin`: End margin (offset inward from path end) + +**Force Alignment**: +- When `forceAlignment="true"`, glyphs are laid out consecutively using their advance widths, then spacing is adjusted proportionally to fill the path region between firstMargin and lastMargin + +**Glyph Positioning**: +1. Calculate glyph center position on path +2. Get path tangent direction at that position +3. If `perpendicular="true"`, rotate glyph perpendicular to path + +**Closed Paths**: For closed paths, glyphs exceeding the range wrap to the other end of the path. + +#### 5.5.6 TextLayout + +TextLayout is a text layout modifier that applies typography to accumulated Text elements. It overrides the original positions of Text elements (similar to how TextPath overrides positions). Two modes are supported: + +- **Point Text Mode** (no width): Text does not auto-wrap; textAlign controls text alignment relative to the (x, y) anchor point +- **Paragraph Text Mode** (with width): Text auto-wraps within the specified width + +During rendering, an attached text typesetting module performs pre-layout, recalculating each glyph's position. TextLayout is expanded during pre-layout, with glyph positions written directly into Text. + +> [Sample](samples/5.5.6_text_layout.pagx) + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `position` | Point | 0,0 | Layout origin | +| `width` | float | auto | Layout width (auto-wraps when specified) | +| `height` | float | auto | Layout height (enables vertical alignment when specified) | +| `textAlign` | TextAlign | start | Horizontal alignment | +| `verticalAlign` | VerticalAlign | top | Vertical alignment | +| `writingMode` | WritingMode | horizontal | Layout direction | +| `lineHeight` | float | 1.2 | Line height multiplier | + +**TextAlign (Horizontal Alignment)**: + +| Value | Description | +|-------|-------------| +| `start` | Start alignment (left-aligned; right-aligned for RTL text) | +| `center` | Center alignment | +| `end` | End alignment (right-aligned; left-aligned for RTL text) | +| `justify` | Justified (last line start-aligned) | + +**VerticalAlign (Vertical Alignment)**: + +| Value | Description | +|-------|-------------| +| `top` | Top alignment | +| `center` | Vertical center | +| `bottom` | Bottom alignment | + +**WritingMode (Layout Direction)**: + +| Value | Description | +|-------|-------------| +| `horizontal` | Horizontal text | +| `vertical` | Vertical text (columns arranged right-to-left, traditional CJK vertical layout) | + +#### 5.5.7 Rich Text + +Rich text is achieved through multiple Text elements within a Group, each Text having independent Fill/Stroke styles. TextLayout provides unified typography. + +> [Sample](samples/5.5.7_rich_text.pagx) + +**Note**: Each Group's Text + Fill/Stroke defines a text segment with independent styling. TextLayout treats all segments as a single unit for typography, enabling auto-wrapping and alignment. + +### 5.6 Repeater + +Repeater duplicates accumulated content and rendered styles, applying progressive transforms to each copy. Repeater affects both Paths and glyph lists simultaneously, and does not trigger text-to-shape conversion. + +```xml + +``` + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `copies` | float | 3 | Number of copies | +| `offset` | float | 0 | Start offset | +| `order` | RepeaterOrder | belowOriginal | Stacking order | +| `anchor` | Point | 0,0 | Anchor point | +| `position` | Point | 100,100 | Position offset per copy | +| `rotation` | float | 0 | Rotation per copy | +| `scale` | Point | 1,1 | Scale per copy | +| `startAlpha` | float | 1 | First copy opacity | +| `endAlpha` | float | 1 | Last copy opacity | + +**Transform Calculation** (i-th copy, i starts from 0): +``` +progress = i + offset +``` + +Transforms are applied in the following order: + +1. Translate to negative anchor (`translate(-anchor)`) +2. Scale exponentially (`scale(scale^progress)`) +3. Rotate linearly (`rotate(rotation × progress)`) +4. Translate linearly (`translate(position × progress)`) +5. Translate back to anchor (`translate(anchor)`) + +**Opacity Interpolation**: +``` +maxCount = ceil(copies) +t = progress / maxCount +alpha = lerp(startAlpha, endAlpha, t) +// For the last copy, alpha is further multiplied by the fractional part of copies (see below) +``` + +**RepeaterOrder**: + +| Value | Description | +|-------|-------------| +| `belowOriginal` | Copies below original. Index 0 on top | +| `aboveOriginal` | Copies above original. Index N-1 on top | + +**Fractional Copy Count**: + +When `copies` is a decimal (e.g., `3.5`), partial copies are achieved through **semi-transparent blending**: + +1. **Geometry copying**: Shapes and text are copied by `ceil(copies)` (i.e., 4), geometry itself is not scaled or clipped +2. **Opacity adjustment**: The last copy's opacity is multiplied by the fractional part (e.g., 0.5), producing semi-transparent effect +3. **Visual effect**: Simulates partial copies through opacity gradation + +**Example**: When `copies="2.3"`: +- Copy 3 complete geometry copies +- Copies 1, 2 render normally +- Copy 3's opacity × 0.3, rendering semi-transparent + +**Edge Cases**: +- `copies < 0`: No operation performed +- `copies = 0`: Clear all accumulated content and rendered styles + +**Repeater Characteristics**: +- **Simultaneous effect**: Copies all accumulated Paths and glyph lists +- **Preserve text attributes**: Glyph lists retain glyph information after copying; subsequent text modifiers can still affect them +- **Copy rendered styles**: Also copies already rendered fills and strokes + +> [Sample](samples/5.6_repeater.pagx) + +### 5.7 Group + +Group is a VectorElement container with transform properties. + +> [Sample](samples/5.7_group.pagx) + +| Attribute | Type | Default | Description | +|-----------|------|---------|-------------| +| `anchor` | Point | 0,0 | Anchor point "x,y" | +| `position` | Point | 0,0 | Position "x,y" | +| `rotation` | float | 0 | Rotation angle | +| `scale` | Point | 1,1 | Scale "sx,sy" | +| `skew` | float | 0 | Skew amount | +| `skewAxis` | float | 0 | Skew axis angle | +| `alpha` | float | 1 | Opacity 0~1 | + +#### Transform Order + +Transforms are applied in the following order: + +1. Translate to negative anchor point (`translate(-anchor)`) +2. Scale (`scale`) +3. Skew (`skew` along `skewAxis` direction) +4. Rotate (`rotation`) +5. Translate to position (`translate(position)`) + +**Skew Transform**: + +Skew is applied in the following order: + +1. Rotate to skew axis direction (`rotate(skewAxis)`) +2. Shear along X axis (`shearX(tan(skew))`) +3. Rotate back (`rotate(-skewAxis)`) + +#### Scope Isolation + +Groups create isolated scopes for geometry accumulation and rendering: + +- Geometry elements within the group accumulate only within the group +- Painters within the group only render geometry accumulated within the group +- Modifiers within the group only affect geometry accumulated within the group +- The group's transform matrix applies to all content within the group +- The group's `alpha` property applies to all rendered content within the group + +**Geometry Accumulation Rules**: + +- **Painters do not clear geometry**: After Fill and Stroke render, the geometry list remains unchanged; subsequent painters can still render the same geometry +- **Child Group geometry propagates upward**: After a child Group completes, its geometry accumulates to the parent scope; painters at the end of the parent can render all child Group geometry +- **Sibling Groups do not affect each other**: Each Group creates an independent accumulation starting point; it cannot see geometry from subsequent sibling Groups +- **Isolate rendering scope**: Painters within a Group can only render geometry accumulated up to the current position, including this group and completed child Groups +- **Layer is accumulation boundary**: Geometry propagates upward until reaching a Layer boundary; it does not pass across Layers + +**Example 1 - Basic Isolation**: +> [Sample](samples/5.7_group_isolation.pagx) + +**Example 2 - Child Group Geometry Propagates Upward**: +> [Sample](samples/5.7_group_propagation.pagx) + +**Example 3 - Multiple Painters Reuse Geometry**: +> [Sample](samples/5.7_multiple_painters.pagx) + +#### Multiple Fills and Strokes + +Since painters do not clear the geometry list, the same geometry can have multiple Fills and Strokes applied consecutively. + +**Example 4 - Multiple Fills**: +> [Sample](samples/5.7_multiple_fills.pagx) + +**Example 5 - Multiple Strokes**: +> [Sample](samples/5.7_multiple_strokes.pagx) + +**Example 6 - Mixed Overlay**: +> [Sample](samples/5.7_mixed_overlay.pagx) + +**Rendering Order**: Multiple painters render in document order; those appearing earlier are below. + +--- + +## Appendix A. Node Hierarchy + +This appendix describes node categorization and nesting rules. + +### A.1 Node Categories + +| Category | Nodes | +|----------|-------| +| **Containers** | `pagx`, `Resources`, `Layer`, `Group` | +| **Resources** | `Image`, `PathData`, `Composition`, `Font`, `Glyph` | +| **Color Sources** | `SolidColor`, `LinearGradient`, `RadialGradient`, `ConicGradient`, `DiamondGradient`, `ImagePattern`, `ColorStop` | +| **Layer Styles** | `DropShadowStyle`, `InnerShadowStyle`, `BackgroundBlurStyle` | +| **Layer Filters** | `BlurFilter`, `DropShadowFilter`, `InnerShadowFilter`, `BlendFilter`, `ColorMatrixFilter` | +| **Geometry Elements** | `Rectangle`, `Ellipse`, `Polystar`, `Path`, `Text`, `GlyphRun` | +| **Modifiers** | `TrimPath`, `RoundCorner`, `MergePath`, `TextModifier`, `RangeSelector`, `TextPath`, `TextLayout`, `Repeater` | +| **Painters** | `Fill`, `Stroke` | + +### A.2 Document Containment + +``` +pagx +├── Resources +│ ├── Image +│ ├── PathData +│ ├── SolidColor +│ ├── LinearGradient → ColorStop* +│ ├── RadialGradient → ColorStop* +│ ├── ConicGradient → ColorStop* +│ ├── DiamondGradient → ColorStop* +│ ├── ImagePattern +│ ├── Font → Glyph* +│ └── Composition → Layer* +│ +└── Layer* + ├── VectorElement* (see A.3) + ├── DropShadowStyle* + ├── InnerShadowStyle* + ├── BackgroundBlurStyle* + ├── BlurFilter* + ├── DropShadowFilter* + ├── InnerShadowFilter* + ├── BlendFilter* + ├── ColorMatrixFilter* + └── Layer* (child layers) +``` + +### A.3 VectorElement Containment + +`Layer` and `Group` may contain the following VectorElements: + +``` +Layer / Group +├── Rectangle +├── Ellipse +├── Polystar +├── Path +├── Text → GlyphRun* (pre-layout mode) +├── Fill (may embed color source) +│ └── SolidColor / LinearGradient / RadialGradient / ConicGradient / DiamondGradient / ImagePattern +├── Stroke (may embed color source) +│ └── SolidColor / LinearGradient / RadialGradient / ConicGradient / DiamondGradient / ImagePattern +├── TrimPath +├── RoundCorner +├── MergePath +├── TextModifier → RangeSelector* +├── TextPath +├── TextLayout +├── Repeater +└── Group* (recursive) +``` + +--- + +## Appendix B. Enumeration Types + +### Layer Related + +| Enum | Values | +|------|--------| +| **BlendMode** | `normal`, `multiply`, `screen`, `overlay`, `darken`, `lighten`, `colorDodge`, `colorBurn`, `hardLight`, `softLight`, `difference`, `exclusion`, `hue`, `saturation`, `color`, `luminosity`, `plusLighter`, `plusDarker` | +| **MaskType** | `alpha`, `luminance`, `contour` | +| **TileMode** | `clamp`, `repeat`, `mirror`, `decal` | +| **FilterMode** | `nearest`, `linear` | +| **MipmapMode** | `none`, `nearest`, `linear` | + +### Painter Related + +| Enum | Values | +|------|--------| +| **FillRule** | `winding`, `evenOdd` | +| **LineCap** | `butt`, `round`, `square` | +| **LineJoin** | `miter`, `round`, `bevel` | +| **StrokeAlign** | `center`, `inside`, `outside` | +| **LayerPlacement** | `background`, `foreground` | + +### Geometry Element Related + +| Enum | Values | +|------|--------| +| **PolystarType** | `polygon`, `star` | + +### Modifier Related + +| Enum | Values | +|------|--------| +| **TrimType** | `separate`, `continuous` | +| **MergePathOp** | `append`, `union`, `intersect`, `xor`, `difference` | +| **SelectorUnit** | `index`, `percentage` | +| **SelectorShape** | `square`, `rampUp`, `rampDown`, `triangle`, `round`, `smooth` | +| **SelectorMode** | `add`, `subtract`, `intersect`, `min`, `max`, `difference` | +| **TextAlign** | `start`, `center`, `end`, `justify` | +| **VerticalAlign** | `top`, `center`, `bottom` | +| **WritingMode** | `horizontal`, `vertical` | +| **RepeaterOrder** | `belowOriginal`, `aboveOriginal` | +--- + +## Appendix C. Common Usage Examples + +### C.1 Complete Example + +The following example covers all major node types in PAGX, demonstrating complete document structure. + +> [Sample](samples/C.1_complete_example.pagx) + +### C.2 RPG Character Panel + +A fantasy RPG-style character status panel demonstrating complex UI composition with nested layers, gradients, and decorative elements. + +> [Sample](samples/C.2_rpg_character_panel.pagx) + +### C.3 Nebula Cadet + +A space-themed cadet profile card showcasing nebula effects, star fields, and modern UI design patterns. + +> [Sample](samples/C.3_nebula_cadet.pagx) + +### C.4 Game HUD + +A game heads-up display (HUD) demonstrating health bars, score displays, and game interface elements. + +> [Sample](samples/C.4_game_hud.pagx) + +### C.5 PAGX Features Overview + +A comprehensive showcase of PAGX format capabilities including gradients, effects, text styling, and vector graphics. + +> [Sample](samples/C.5_pagx_features.pagx) + diff --git a/spec/pagx_spec.zh_CN.md b/spec/pagx_spec.zh_CN.md new file mode 100644 index 0000000000..1691c5f517 --- /dev/null +++ b/spec/pagx_spec.zh_CN.md @@ -0,0 +1,1887 @@ +# PAGX 格式规范 + +## 1. 介绍(Introduction) + +**PAGX**(Portable Animated Graphics XML)是一种基于 XML 的矢量动画标记语言。它提供了统一且强大的矢量图形与动画描述能力,旨在成为跨所有主要工具与运行时的矢量动画交换标准。 + +### 1.1 设计目标 + +- **开放可读**:纯文本 XML 格式,易于阅读和编辑,天然支持版本控制与差异对比,便于调试及 AI 理解与生成。 + +- **特性完备**:完整覆盖矢量图形、图片、富文本、滤镜效果、混合模式、遮罩等能力,满足复杂动效的描述需求。 + +- **精简高效**:提供简洁且强大的统一结构,兼顾静态矢量与动画的优化描述,同时预留未来交互和脚本的扩展能力。 + +- **生态兼容**:可作为 After Effects、Figma、腾讯设计等设计工具的通用交换格式,实现设计资产无缝流转。 + +- **高效部署**:设计资产可一键导出并部署到研发环境,转换为二进制 PAG 格式后获得极高压缩比和运行时性能。 + +### 1.2 文件结构 + +PAGX 是纯 XML 文件(`.pagx`),可引用外部资源文件(图片、视频、音频、字体等),也支持通过数据 URI 内嵌资源。PAGX 与二进制 PAG 格式可双向互转:发布时转换为 PAG 以优化加载性能;开发、审查或编辑时可使用 PAGX 格式以便阅读和修改。 + +### 1.3 文档组织 + +本规范按以下顺序组织: + +1. **基础数据类型**:定义文档中使用的基本数据格式 +2. **文档结构**:描述 PAGX 文档的整体组织方式 +3. **图层系统**:定义图层及其相关特性(样式、滤镜、遮罩) +4. **矢量元素系统**:定义图层内容的矢量元素及其处理模型 + +**附录**(方便速查): + +- **附录 A**:节点层级与包含关系 +- **附录 B**:枚举类型速查 +- **附录 C**:常见用法示例 + +--- + +## 2. 基础数据类型(Basic Data Types) + +本节定义 PAGX 文档中使用的基础数据类型和命名规范。 + +### 2.1 命名规范 + +| 类别 | 规范 | 示例 | +|------|------|------| +| 元素名 | PascalCase,不缩写 | `Group`、`Rectangle`、`Fill` | +| 属性名 | camelCase,尽量简短 | `antiAlias`、`blendMode`、`fontSize` | +| 默认单位 | 像素(无需标注) | `width="100"` | +| 角度单位 | 度 | `rotation="45"` | + +### 2.2 属性表格约定 + +本规范中的属性表格统一使用"默认值"列描述属性的必填性: + +| 默认值格式 | 含义 | +|------------|------| +| `(必填)` | 属性必须指定,没有默认值 | +| 具体值(如 `0`、`true`、`normal`) | 属性可选,未指定时使用该默认值 | +| `-` | 属性可选,未指定时不生效 | + +### 2.3 通用属性 + +以下属性可用于任意元素,不在各节点的属性表中重复列出: + +| 属性 | 类型 | 说明 | +|------|------|------| +| `id` | string | 唯一标识符,用于被其他元素引用(如遮罩、颜色源)。在文档中必须唯一,不能为空或包含空白字符 | +| `data-*` | string | 自定义数据属性,用于存储应用特定的私有数据。`*` 可替换为任意名称(如 `data-name`、`data-layer-id`),运行时忽略这些属性 | + +**自定义属性说明**: + +- 属性名必须以 `data-` 开头,后跟至少一个字符 +- 属性名只能包含小写字母、数字和连字符(`-`),不能以连字符结尾 +- 属性值为任意字符串,由创建该属性的应用自行解释 +- 运行时不处理这些属性,仅用于工具间传递元数据或存储调试信息 + +**示例**: + +```xml + + + + +``` + +### 2.4 基本数值类型 + +| 类型 | 说明 | 示例 | +|------|------|------| +| `float` | 浮点数 | `1.5`、`-0.5`、`100` | +| `int` | 整数 | `400`、`0`、`-1` | +| `bool` | 布尔值 | `true`、`false` | +| `string` | 字符串 | `"Arial"`、`"myLayer"` | +| `enum` | 枚举值 | `normal`、`multiply` | +| `idref` | ID 引用 | `@gradientId`、`@maskLayer` | + +### 2.5 点(Point) + +点使用逗号分隔的两个浮点数表示: + +``` +"x,y" +``` + +**示例**:`"100,200"`、`"0.5,0.5"`、`"-50,100"` + +### 2.6 矩形(Rect) + +矩形使用逗号分隔的四个浮点数表示: + +``` +"x,y,width,height" +``` + +**示例**:`"0,0,100,100"`、`"10,20,200,150"` + +### 2.7 变换矩阵(Matrix) + +#### 2D 变换矩阵 + +2D 变换使用 6 个逗号分隔的浮点数表示,对应标准 2D 仿射变换矩阵: + +``` +"a,b,c,d,tx,ty" +``` + +矩阵形式: +``` +| a c tx | +| b d ty | +| 0 0 1 | +``` + +**单位矩阵**:`"1,0,0,1,0,0"` + +#### 3D 变换矩阵 + +3D 变换使用 16 个逗号分隔的浮点数表示,列优先顺序: + +``` +"m00,m10,m20,m30,m01,m11,m21,m31,m02,m12,m22,m32,m03,m13,m23,m33" +``` + +### 2.8 颜色(Color) + +PAGX 支持两种颜色表示方式: + +#### HEX 格式(十六进制) + +HEX 格式用于表示 sRGB 色域的颜色,使用 `#` 前缀的十六进制值: + +| 格式 | 示例 | 说明 | +|------|------|------| +| `#RGB` | `#F00` | 3 位简写,每位扩展为两位(等价于 `#FF0000`) | +| `#RRGGBB` | `#FF0000` | 6 位标准格式,不透明 | +| `#RRGGBBAA` | `#FF000080` | 8 位带 Alpha,Alpha 在末尾(与 CSS 一致) | + +#### 浮点数格式 + +浮点数格式使用 `色域(r, g, b)` 或 `色域(r, g, b, a)` 的形式表示颜色,支持 sRGB 和 Display P3 两种色域: + +| 色域 | 格式 | 示例 | 说明 | +|------|------|------|------| +| sRGB | `srgb(r, g, b)` | `srgb(1.0, 0.5, 0.2)` | sRGB 色域,各分量 0.0~1.0 | +| sRGB | `srgb(r, g, b, a)` | `srgb(1.0, 0.5, 0.2, 0.8)` | 带透明度 | +| Display P3 | `p3(r, g, b)` | `p3(1.0, 0.5, 0.2)` | Display P3 广色域 | +| Display P3 | `p3(r, g, b, a)` | `p3(1.0, 0.5, 0.2, 0.8)` | 带透明度 | + +**注意**: +- 色域标识符(`srgb` 或 `p3`)和括号**不能省略** +- 广色域颜色(Display P3)的分量值可以超出 [0, 1] 范围,以表示超出 sRGB 色域的颜色 +- sRGB 浮点格式与 HEX 格式表示相同的色域,可根据需要选择 + +#### 颜色源引用 + +使用 `@resourceId` 引用 Resources 中定义的颜色源(渐变、图案等)。 + +### 2.9 混合模式(Blend Mode) + +混合模式定义源颜色(S)如何与目标颜色(D)组合。 + +| 值 | 公式 | 说明 | +|------|------|------| +| `normal` | S | 正常(覆盖) | +| `multiply` | S × D | 正片叠底 | +| `screen` | 1 - (1-S)(1-D) | 滤色 | +| `overlay` | multiply/screen 组合 | 叠加 | +| `darken` | min(S, D) | 变暗 | +| `lighten` | max(S, D) | 变亮 | +| `colorDodge` | D / (1-S) | 颜色减淡 | +| `colorBurn` | 1 - (1-D)/S | 颜色加深 | +| `hardLight` | multiply/screen 反向组合 | 强光 | +| `softLight` | 柔和版叠加 | 柔光 | +| `difference` | \|S - D\| | 差值 | +| `exclusion` | S + D - 2SD | 排除 | +| `hue` | D 的饱和度和亮度 + S 的色相 | 色相 | +| `saturation` | D 的色相和亮度 + S 的饱和度 | 饱和度 | +| `color` | D 的亮度 + S 的色相和饱和度 | 颜色 | +| `luminosity` | S 的亮度 + D 的色相和饱和度 | 亮度 | +| `plusLighter` | S + D | 相加(趋向白色) | +| `plusDarker` | S + D - 1 | 相加减一(趋向黑色) | + +### 2.10 路径数据语法(Path Data Syntax) + +路径数据使用 SVG 路径语法,由一系列命令和坐标组成。 + +**路径命令**: + +| 命令 | 参数 | 说明 | +|------|------|------| +| M/m | x y | 移动到(绝对/相对) | +| L/l | x y | 直线到 | +| H/h | x | 水平线到 | +| V/v | y | 垂直线到 | +| C/c | x1 y1 x2 y2 x y | 三次贝塞尔曲线 | +| S/s | x2 y2 x y | 平滑三次贝塞尔 | +| Q/q | x1 y1 x y | 二次贝塞尔曲线 | +| T/t | x y | 平滑二次贝塞尔 | +| A/a | rx ry rotation large-arc sweep x y | 椭圆弧 | +| Z/z | - | 闭合路径 | + +### 2.11 外部资源引用(External Resource Reference) + +外部资源通过相对路径或数据 URI 引用,适用于图片、视频、音频、字体等文件。 + +```xml + + + + + + +``` + +**路径解析规则**: + +- **相对路径**:相对于 PAGX 文件所在目录解析,支持 `../` 引用父目录 +- **数据 URI**:以 `data:` 开头,格式为 `data:;base64,`,仅支持 base64 编码 +- 路径分隔符统一使用 `/`(正斜杠),不支持 `\`(反斜杠) + +--- + +## 3. 文档结构(Document Structure) + +本节定义 PAGX 文档的整体结构。 + +### 3.1 坐标系统 + +PAGX 使用标准的 2D 笛卡尔坐标系: + +- **原点**:位于画布左上角 +- **X 轴**:向右为正方向 +- **Y 轴**:向下为正方向 +- **角度**:顺时针方向为正(0° 指向 X 轴正方向) +- **单位**:所有长度值默认为像素,角度值默认为度 + +### 3.2 根元素(pagx) + +`` 是 PAGX 文档的根元素,定义画布尺寸并直接包含图层列表。 + +> [Sample](samples/3.2_document_structure.pagx) + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `version` | string | (必填) | 格式版本 | +| `width` | float | (必填) | 画布宽度 | +| `height` | float | (必填) | 画布高度 | + +**图层渲染顺序**:图层按文档顺序依次渲染,文档中靠前的图层先渲染(位于下方),靠后的图层后渲染(位于上方)。 + +### 3.3 资源区(Resources) + +`` 定义可复用的资源,包括图片、路径数据、颜色源和合成。资源通过 `id` 属性标识,在文档其他位置通过 `@id` 形式引用。 + +**元素位置**:Resources 元素可放置在根元素内的任意位置,对位置没有限制。解析器必须支持元素引用在文档后面定义的资源或图层(即前向引用)。 + +> [Sample](samples/3.3_resources.pagx) + +#### 3.3.1 图片(Image) + +图片资源定义可在文档中引用的位图数据。 + +```xml + + +``` + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `source` | string | (必填) | 文件路径或数据 URI | + +**支持格式**:PNG、JPEG、WebP、GIF + +#### 3.3.2 路径数据(PathData) + +PathData 定义可复用的路径数据,供 Path 元素和 TextPath 修改器引用。 + +```xml + +``` + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `data` | string | (必填) | SVG 路径数据 | + +#### 3.3.3 颜色源(Color Source) + +颜色源定义可用于填充和描边的颜色,支持两种使用方式: + +1. **共享定义**:在 `` 中预定义,通过 `@id` 引用。适用于**被多处引用**的颜色源。 +2. **内联定义**:直接嵌套在 `` 或 `` 元素内部。适用于**仅使用一次**的颜色源,更简洁。 + +##### 纯色(SolidColor) + +```xml + +``` + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `color` | Color | (必填) | 颜色值 | + +##### 线性渐变(LinearGradient) + +线性渐变沿起点到终点的方向插值。 + +> [Sample](samples/3.3.3_linear_gradient.pagx) + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `startPoint` | Point | (必填) | 起点 | +| `endPoint` | Point | (必填) | 终点 | +| `matrix` | Matrix | 单位矩阵 | 变换矩阵 | + +**计算**:对于点 P,其颜色由 P 在起点-终点连线上的投影位置决定。 + +##### 径向渐变(RadialGradient) + +径向渐变从中心向外辐射。 + +> [Sample](samples/3.3.3_radial_gradient.pagx) + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `center` | Point | 0,0 | 中心点 | +| `radius` | float | (必填) | 渐变半径 | +| `matrix` | Matrix | 单位矩阵 | 变换矩阵 | + +**计算**:对于点 P,其颜色由 `distance(P, center) / radius` 决定。 + +##### 锥形渐变(ConicGradient) + +锥形渐变(也称扫描渐变)沿圆周方向插值。 + +> [Sample](samples/3.3.3_conic_gradient.pagx) + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `center` | Point | 0,0 | 中心点 | +| `startAngle` | float | 0 | 起始角度 | +| `endAngle` | float | 360 | 结束角度 | +| `matrix` | Matrix | 单位矩阵 | 变换矩阵 | + +**计算**:对于点 P,其颜色由 `atan2(P.y - center.y, P.x - center.x)` 在 `[startAngle, endAngle]` 范围内的比例决定。 + +**角度约定**:遵循全局坐标系约定(见 §3.1):0° 指向**右侧**(X 轴正方向),角度沿**顺时针**递增。这与 CSS `conic-gradient`(0° 指向顶部)不同。常用参考角度: + +| 角度 | 方向 | +|------|------| +| 0° | 右 (3 点钟) | +| 90° | 下 (6 点钟) | +| 180° | 左 (9 点钟) | +| 270° | 上 (12 点钟) | + +##### 菱形渐变(DiamondGradient) + +菱形渐变从中心向四角辐射。 + +> [Sample](samples/3.3.3_diamond_gradient.pagx) + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `center` | Point | 0,0 | 中心点 | +| `radius` | float | (必填) | 渐变半径 | +| `matrix` | Matrix | 单位矩阵 | 变换矩阵 | + +**计算**:对于点 P,其颜色由切比雪夫距离 `max(|P.x - center.x|, |P.y - center.y|) / radius` 决定。 + +##### 渐变色标(ColorStop) + +```xml + +``` + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `offset` | float | (必填) | 位置 0.0~1.0 | +| `color` | Color | (必填) | 色标颜色 | + +**渐变通用规则**: + +- **色标插值**:相邻色标之间使用线性插值 +- **色标边界**: + - `offset < 0` 的色标被视为 `offset = 0` + - `offset > 1` 的色标被视为 `offset = 1` + - 如果没有 `offset = 0` 的色标,使用第一个色标的颜色填充 + - 如果没有 `offset = 1` 的色标,使用最后一个色标的颜色填充 + +##### 图片填充(ImagePattern) + +图片图案使用图片作为颜色源。 + +```xml + +``` + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `image` | idref | (必填) | 图片引用 "@id" | +| `tileModeX` | TileMode | clamp | X 方向平铺模式 | +| `tileModeY` | TileMode | clamp | Y 方向平铺模式 | +| `filterMode` | FilterMode | linear | 纹理过滤模式 | +| `mipmapMode` | MipmapMode | linear | Mipmap 模式 | +| `matrix` | Matrix | 单位矩阵 | 变换矩阵 | + +**TileMode(平铺模式)**:`clamp`(钳制)、`repeat`(重复)、`mirror`(镜像)、`decal`(贴花) + +**FilterMode(纹理过滤模式)**:`nearest`(最近邻)、`linear`(双线性插值) + +**MipmapMode(Mipmap 模式)**:`none`(禁用)、`nearest`(最近级别)、`linear`(线性插值) + +**完整示例**:演示不同平铺模式的图片填充 + +> [Sample](samples/3.3.3_image_pattern.pagx) + +##### 颜色源坐标系统 + +除纯色外,所有颜色源(渐变、图片填充)都有坐标系的概念,其坐标系**相对于几何元素的局部坐标系原点**。可通过 `matrix` 属性对颜色源坐标系应用变换。 + +**变换行为**: + +1. **外部变换会同时作用于几何和颜色源**:Group 的变换、Layer 的矩阵等外部变换会整体作用于几何元素及其颜色源,两者一起缩放、旋转、平移。 + +2. **修改几何属性不影响颜色源**:直接修改几何元素的属性(如 Rectangle 的 width/height、Path 的路径数据)只改变几何内容本身,不会影响颜色源的坐标系。 + +**示例**:在 300×300 的区域内绘制一个对角线方向的线性渐变: + +> [Sample](samples/3.3.3_color_source_coordinates.pagx) + +- 对该图层应用 `scale(2, 2)` 变换:矩形变为 600×600,渐变也随之放大,视觉效果保持一致 +- 直接将 Rectangle 的 size 改为 600,600:矩形变为 600×600,但渐变坐标不变,只覆盖矩形的左上四分之一 + +#### 3.3.4 合成(Composition) + +合成用于内容复用(类似 After Effects 的 Pre-comp)。 + +> [Sample](samples/3.3.4_composition.pagx) + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `width` | float | (必填) | 合成宽度 | +| `height` | float | (必填) | 合成高度 | + +#### 3.3.5 字体(Font) + +Font 定义嵌入字体资源,包含子集化的字形数据(矢量轮廓或位图)。PAGX 文件通过嵌入字形数据实现完全自包含,确保跨平台渲染一致性。 + +```xml + + + + + + + + + + + +``` + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `unitsPerEm` | int | 1000 | 字体设计空间单位。渲染时按 `fontSize / unitsPerEm` 缩放 | + +**一致性约束**:同一 Font 内的所有 Glyph 必须使用相同类型(全部 `path` 或全部 `image`),不允许混用。 + +**GlyphID 规则**: +- **GlyphID 从 1 开始**:Glyph 列表的索引 + 1 = GlyphID +- **GlyphID 0 保留**:表示缺失字形,不渲染 + +##### 字形(Glyph) + +Glyph 定义单个字形的渲染数据。`path` 和 `image` 二选一必填,不能同时指定。 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `advance` | float | (必填) | 水平步进宽度,设计空间坐标(unitsPerEm 单位) | +| `path` | string | - | SVG 路径数据(矢量轮廓) | +| `image` | string | - | 图片数据(base64 数据 URI)或外部文件路径 | +| `offset` | Point | 0,0 | 字形偏移量,设计空间坐标(通常用于位图字形) | + +**字形类型**: +- **矢量字形**:指定 `path` 属性,使用 SVG 路径语法描述轮廓 +- **位图字形**:指定 `image` 属性,用于 Emoji 等彩色字形,可通过 `offset` 调整位置 + +**坐标系说明**:字形路径、偏移和步进均使用设计空间坐标。渲染时根据 GlyphRun 的 `fontSize` 和 Font 的 `unitsPerEm` 计算缩放比例:`scale = fontSize / unitsPerEm`。 + +### 3.4 文档层级结构 + +PAGX 文档采用层级结构组织内容: + +``` + ← 根元素(定义画布尺寸) +├── ← 图层(可多个) +│ ├── 几何元素 ← Rectangle、Ellipse、Path、Text 等 +│ ├── 修改器 ← TrimPath、RoundCorner、TextModifier 等 +│ ├── 绘制器 ← Fill、Stroke +│ ├── ← 矢量元素容器(可嵌套) +│ ├── LayerStyle ← DropShadowStyle、InnerShadowStyle 等 +│ ├── LayerFilter ← BlurFilter、ColorMatrixFilter 等 +│ └── ← 子图层(递归结构) +│ └── ... +│ +└── ← 资源区(可选,定义可复用资源) + ├── ← 图片资源 + ├── ← 路径数据资源 + ├── ← 纯色定义 + ├── ← 渐变定义 + ├── ← 图片图案定义 + ├── ← 字体资源(嵌入字体) + │ └── ← 字形定义 + └── ← 合成定义 + └── ← 合成内的图层 +``` + +--- + +## 4. 图层系统(Layer System) + +图层(Layer)是 PAGX 内容组织的基本单元,提供了丰富的视觉效果控制能力。 + +### 4.1 核心概念 + +本节介绍图层系统的核心概念,这些概念是理解图层样式、滤镜和遮罩等功能的基础。 + +#### 图层渲染流程 + +图层绑定的绘制器(Fill、Stroke 等)通过 `placement` 属性分为下层内容和上层内容,默认为下层内容。单个图层渲染时按以下顺序处理: + +1. **图层样式(下方)**:渲染位于内容下方的图层样式(如投影阴影) +2. **下层内容**:渲染 `placement="background"` 的 Fill 和 Stroke +3. **子图层**:按文档顺序递归渲染所有子图层 +4. **图层样式(上方)**:渲染位于内容上方的图层样式(如内阴影) +5. **上层内容**:渲染 `placement="foreground"` 的 Fill 和 Stroke +6. **图层滤镜**:将前面步骤的整体输出作为滤镜链的输入,依次应用所有滤镜 + +#### 图层内容(Layer Content) + +**图层内容**是指图层的下层内容、子图层和上层内容的完整渲染结果。图层样式基于图层内容计算效果。例如,当填充为下层、描边为上层时,描边会绘制在子图层之上,但投影阴影仍然基于包含填充、子图层和描边的完整图层内容计算。 + +#### 图层轮廓(Layer Contour) + +**图层轮廓**用于遮罩和部分图层样式。与正常图层内容相比,图层轮廓有以下区别: + +1. **包含 alpha=0 的几何绘制**:填充透明度完全为 0 的几何形状也会加入轮廓 +2. **纯色填充和渐变填充**:忽略原始填充,替换为不透明白色绘制 +3. **图片填充**:保留原始像素,但将半透明像素转换为完全不透明(完全透明的像素保留) + +注意:几何元素必须有绘制器才能参与轮廓,单独的几何元素(Rectangle、Ellipse 等)如果没有对应的 Fill 或 Stroke,则不会参与轮廓计算。 + +图层轮廓主要用于: + +- **图层样式**:部分图层样式需要轮廓作为其中一个输入源 +- **遮罩**:`maskType="contour"` 使用遮罩图层的轮廓进行裁剪 + +#### 图层背景(Layer Background) + +**图层背景**是指当前图层下方所有已渲染内容的合成结果,包括: +- 当前图层下方的所有兄弟图层及其子树的渲染结果 +- 当前图层已绘制的下方图层样式(不包括 BackgroundBlurStyle 本身) + +图层背景主要用于: + +- **图层样式**:部分图层样式需要背景作为其中一个输入源 +- **混合模式**:部分混合模式需要背景信息才能正确渲染 + +**背景透传**:通过 `passThroughBackground` 属性控制是否允许图层背景透传给子图层。当设置为 `false` 时,子图层的背景依赖样式将无法获取到正确的图层背景。以下情况会自动禁用背景透传: +- 图层使用了非 `normal` 的混合模式 +- 图层应用了滤镜 +- 图层使用了 3D 变换或投影变换 + +### 4.2 图层(Layer) + +`` 是内容和子图层的基本容器。 + +> [Sample](samples/4.2_layer.pagx) + +#### 子元素 + +Layer 的子元素按类型自动归类为四个集合: + +| 子元素类型 | 归类 | 说明 | +|-----------|------|------| +| VectorElement | contents | 几何元素、修改器、绘制器(参与累积处理) | +| LayerStyle | styles | DropShadowStyle、InnerShadowStyle、BackgroundBlurStyle | +| LayerFilter | filters | BlurFilter、DropShadowFilter 等滤镜 | +| Layer | children | 嵌套子图层 | + +**建议顺序**:虽然子元素顺序不影响解析结果,但建议按 VectorElement → LayerStyle → LayerFilter → 子Layer 的顺序书写,以提高可读性。 + +#### 图层属性 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `name` | string | "" | 显示名称 | +| `visible` | bool | true | 是否可见 | +| `alpha` | float | 1 | 透明度 0~1 | +| `blendMode` | BlendMode | normal | 混合模式 | +| `x` | float | 0 | X 位置 | +| `y` | float | 0 | Y 位置 | +| `matrix` | Matrix | 单位矩阵 | 2D 变换 "a,b,c,d,tx,ty" | +| `matrix3D` | Matrix | - | 3D 变换(16 个值,列优先) | +| `preserve3D` | bool | false | 保持 3D 变换 | +| `antiAlias` | bool | true | 边缘抗锯齿 | +| `groupOpacity` | bool | false | 组透明度 | +| `passThroughBackground` | bool | true | 是否允许背景透传给子图层 | +| `excludeChildEffectsInLayerStyle` | bool | false | 图层样式是否排除子图层效果 | +| `scrollRect` | Rect | - | 滚动裁剪区域 "x,y,w,h" | +| `mask` | idref | - | 遮罩图层引用 "@id" | +| `maskType` | MaskType | alpha | 遮罩类型 | +| `composition` | idref | - | 合成引用 "@id" | + +**groupOpacity**:当值为 `false`(默认)时,图层的 `alpha` 独立应用到每个子元素,重叠的半透明子元素在交叉处可能显得更深。当值为 `true` 时,所有图层内容先合成到离屏缓冲区,再将 `alpha` 整体应用到缓冲区,使整个图层呈现均匀的透明效果。 + +**preserve3D**:当值为 `false`(默认)时,具有 3D 变换的子图层在合成前会被压平到父级的 2D 平面。当值为 `true` 时,子图层保留其 3D 位置,在共享的 3D 空间中渲染,实现基于深度的交叉和正确的兄弟层 Z 排序。类似于 CSS 的 `transform-style: preserve-3d`。 + +**变换属性优先级**:`x`/`y`、`matrix`、`matrix3D` 三者存在覆盖关系: +- 仅设置 `x`/`y`:使用 `x`/`y` 作为平移 +- 设置 `matrix`:`matrix` 覆盖 `x`/`y` 的值 +- 设置 `matrix3D`:`matrix3D` 覆盖 `matrix` 和 `x`/`y` 的值 + +**MaskType(遮罩类型)**: + +| 值 | 说明 | +|------|------| +| `alpha` | Alpha 遮罩:使用遮罩的 alpha 通道 | +| `luminance` | 亮度遮罩:使用遮罩的亮度值 | +| `contour` | 轮廓遮罩:使用遮罩的轮廓进行裁剪 | + +**BlendMode**:见 2.9 节混合模式完整表格。 + +### 4.3 图层样式(Layer Styles) + +图层样式在图层内容的上方或下方添加视觉效果,不会替换原有内容。 + +**图层样式的输入源**: + +所有图层样式都基于**图层内容**计算效果。在图层样式中,图层内容会自动转换为 **不透明模式**:使用正常的填充方式渲染几何形状,然后将所有半透明像素转换为完全不透明(完全透明的像素保留)。这意味着半透明填充产生的阴影效果与完全不透明填充相同。 + +部分图层样式还会额外使用**图层轮廓**或**图层背景**作为输入(详见各样式说明)。图层轮廓和图层背景的定义参见 4.1 节。 + +> [Sample](samples/4.3_layer_styles.pagx) + +**所有 LayerStyle 共有属性**: + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `blendMode` | BlendMode | normal | 混合模式(见 2.9 节) | + +#### 4.3.1 投影阴影(DropShadowStyle) + +在图层**下方**绘制投影阴影。基于不透明图层内容计算阴影形状。当 `showBehindLayer="false"` 时,额外使用**图层轮廓**作为擦除遮罩挖空被图层遮挡的部分。 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `offsetX` | float | 0 | X 偏移 | +| `offsetY` | float | 0 | Y 偏移 | +| `blurX` | float | 0 | X 模糊半径 | +| `blurY` | float | 0 | Y 模糊半径 | +| `color` | Color | #000000 | 阴影颜色 | +| `showBehindLayer` | bool | true | 图层后面是否显示阴影 | + +**渲染步骤**: +1. 获取不透明图层内容并偏移 `(offsetX, offsetY)` +2. 对偏移后的内容应用高斯模糊 `(blurX, blurY)` +3. 使用 `color` 的颜色填充阴影区域 +4. 如果 `showBehindLayer="false"`,使用图层轮廓作为擦除遮罩挖空被遮挡部分 + +**showBehindLayer**: +- `true`:阴影完整显示,包括被图层内容遮挡的部分 +- `false`:阴影被图层内容遮挡的部分会被挖空(使用图层轮廓作为擦除遮罩) + +#### 4.3.2 背景模糊(BackgroundBlurStyle) + +在图层**下方**对图层背景应用模糊效果。基于**图层背景**计算效果,使用不透明图层内容作为遮罩裁剪。 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `blurX` | float | 0 | X 模糊半径 | +| `blurY` | float | 0 | Y 模糊半径 | +| `tileMode` | TileMode | mirror | 平铺模式 | + +**渲染步骤**: +1. 获取图层边界下方的图层背景 +2. 对图层背景应用高斯模糊 `(blurX, blurY)` +3. 使用不透明图层内容作为遮罩裁剪模糊结果 + +#### 4.3.3 内阴影(InnerShadowStyle) + +在图层**上方**绘制内阴影,效果呈现在图层内容之内。基于不透明图层内容计算阴影范围。 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `offsetX` | float | 0 | X 偏移 | +| `offsetY` | float | 0 | Y 偏移 | +| `blurX` | float | 0 | X 模糊半径 | +| `blurY` | float | 0 | Y 模糊半径 | +| `color` | Color | #000000 | 阴影颜色 | + +**渲染步骤**: +1. 获取不透明图层内容并偏移 `(offsetX, offsetY)` +2. 对偏移后内容的反向(内容外部区域)应用高斯模糊 `(blurX, blurY)` +3. 使用 `color` 的颜色填充阴影区域 +4. 与不透明图层内容求交集,仅保留内容内部的阴影 + +### 4.4 图层滤镜(Layer Filters) + +图层滤镜是图层渲染的最后一个环节,所有之前按顺序渲染的结果(包含图层样式)累积起来作为滤镜的输入。滤镜按文档顺序链式应用,每个滤镜的输出作为下一个滤镜的输入。 + +与图层样式(4.3 节)的关键区别:图层样式在图层内容的上方或下方**独立渲染**视觉效果,而滤镜**修改**图层的整体渲染输出。图层样式先于滤镜应用。 + +> [Sample](samples/4.4_layer_filters.pagx) + +#### 4.4.1 模糊滤镜(BlurFilter) + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `blurX` | float | (必填) | X 模糊半径 | +| `blurY` | float | (必填) | Y 模糊半径 | +| `tileMode` | TileMode | decal | 平铺模式 | + +#### 4.4.2 投影阴影滤镜(DropShadowFilter) + +基于滤镜输入生成阴影效果。与 DropShadowStyle 的核心区别:滤镜基于原始渲染内容投影,支持半透明度;而样式基于不透明图层内容投影。此外两者支持的属性功能也有所不同。 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `offsetX` | float | 0 | X 偏移 | +| `offsetY` | float | 0 | Y 偏移 | +| `blurX` | float | 0 | X 模糊半径 | +| `blurY` | float | 0 | Y 模糊半径 | +| `color` | Color | #000000 | 阴影颜色 | +| `shadowOnly` | bool | false | 仅显示阴影 | + +**渲染步骤**: +1. 将滤镜输入偏移 `(offsetX, offsetY)` +2. 提取 alpha 通道并应用高斯模糊 `(blurX, blurY)` +3. 使用 `color` 的颜色填充阴影区域 +4. 将阴影与滤镜输入合成(`shadowOnly=false`)或仅输出阴影(`shadowOnly=true`) + +#### 4.4.3 内阴影滤镜(InnerShadowFilter) + +在滤镜输入的内部绘制阴影。 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `offsetX` | float | 0 | X 偏移 | +| `offsetY` | float | 0 | Y 偏移 | +| `blurX` | float | 0 | X 模糊半径 | +| `blurY` | float | 0 | Y 模糊半径 | +| `color` | Color | #000000 | 阴影颜色 | +| `shadowOnly` | bool | false | 仅显示阴影 | + +**渲染步骤**: +1. 创建滤镜输入 alpha 通道的反向遮罩 +2. 偏移并应用高斯模糊 +3. 与滤镜输入的 alpha 通道求交集 +4. 将结果与滤镜输入合成(`shadowOnly=false`)或仅输出阴影(`shadowOnly=true`) + +#### 4.4.4 混合滤镜(BlendFilter) + +将指定颜色以指定混合模式叠加到图层上。 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `color` | Color | (必填) | 混合颜色 | +| `blendMode` | BlendMode | normal | 混合模式(见 2.9 节) | + +#### 4.4.5 颜色矩阵滤镜(ColorMatrixFilter) + +使用 4×5 颜色矩阵变换颜色。 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `matrix` | Matrix | (必填) | 4x5 颜色矩阵(20 个逗号分隔的浮点数) | + +**矩阵格式**(20 个值,行优先): +``` +| R' | | m[0] m[1] m[2] m[3] m[4] | | R | +| G' | | m[5] m[6] m[7] m[8] m[9] | | G | +| B' | = | m[10] m[11] m[12] m[13] m[14] | × | B | +| A' | | m[15] m[16] m[17] m[18] m[19] | | A | + | 1 | +``` + +### 4.5 裁剪与遮罩(Clipping and Masking) + +#### 4.5.1 scrollRect(滚动裁剪) + +`scrollRect` 属性定义图层的可视区域,超出该区域的内容会被裁剪。 + +> [Sample](samples/4.5.1_scroll_rect.pagx) + +#### 4.5.2 遮罩(Masking) + +通过 `mask` 属性引用另一个图层作为遮罩。 + +> [Sample](samples/4.5.2_masking.pagx) + +**遮罩规则**: +- 遮罩图层自身不渲染(`visible` 属性被忽略) +- 遮罩图层的变换不影响被遮罩图层 + +--- + +## 5. 矢量元素系统(VectorElement System) + +矢量元素系统定义了 Layer 内的矢量内容如何被处理和渲染。 + +### 5.1 处理模型(Processing Model) + +VectorElement 系统采用**累积-渲染**的处理模型:几何元素在渲染上下文中累积,修改器对累积的几何进行变换,绘制器触发最终渲染。 + +#### 5.1.1 术语定义 + +| 术语 | 包含元素 | 说明 | +|------|----------|------| +| **几何元素** | Rectangle、Ellipse、Polystar、Path、Text | 提供几何形状的元素,在上下文中累积为几何列表 | +| **修改器** | TrimPath、RoundCorner、MergePath、TextModifier、TextPath、TextLayout、Repeater | 对累积的几何进行变换 | +| **绘制器** | Fill、Stroke | 对累积的几何进行填充或描边渲染 | +| **容器** | Group | 创建独立作用域并应用矩阵变换,处理完成后合并 | + +#### 5.1.2 几何元素的内部结构 + +几何元素在上下文中累积时,内部结构有所不同: + +| 元素类型 | 内部结构 | 说明 | +|----------|----------|------| +| 形状元素(Rectangle、Ellipse、Polystar、Path) | 单个 Path | 每个形状元素产生一个路径 | +| 文本元素(Text) | 字形列表 | 一个 Text 经过塑形后产生多个字形 | + +#### 5.1.3 处理与渲染顺序 + +VectorElement 按**文档顺序**依次处理,文档中靠前的元素先处理。默认情况下,先处理的绘制器先渲染(位于下方)。 + +由于 Fill 和 Stroke 可通过 `placement` 属性指定渲染到图层的背景或前景,因此最终渲染顺序可能与文档顺序不完全一致。但在默认情况下(所有内容均为背景),渲染顺序与文档顺序一致。 + +#### 5.1.4 统一处理流程 + +``` +几何元素 修改器 绘制器 +┌──────────┐ ┌──────────┐ ┌──────────┐ +│Rectangle │ │ TrimPath │ │ Fill │ +│ Ellipse │ │RoundCorn │ │ Stroke │ +│ Polystar │ │MergePath │ └────┬─────┘ +│ Path │ │TextModif │ │ +│ Text │ │ TextPath │ │ +└────┬─────┘ │TextLayout│ │ + │ │ Repeater │ │ + │ └────┬─────┘ │ + │ │ │ + │ 累积几何 │ 变换几何 │ 渲染 + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────┐ +│ 几何列表 [Path1, Path2, 字形列表1, 字形列表2...] │ +└─────────────────────────────────────────────────────────┘ +``` + +**渲染上下文**累积的是一个几何列表,其中: +- 每个形状元素贡献一个 Path +- 每个 Text 贡献一个字形列表(包含多个字形) + +#### 5.1.5 修改器的作用范围 + +不同修改器对几何列表中的元素有不同的作用范围: + +| 修改器类型 | 作用对象 | 说明 | +|------------|----------|------| +| 形状修改器(TrimPath、RoundCorner、MergePath) | 仅 Path | 对文本触发强制转换 | +| 文本修改器(TextModifier、TextPath、TextLayout) | 仅字形列表 | 对 Path 无效 | +| 复制器(Repeater) | Path + 字形列表 | 同时作用于所有几何 | + +### 5.2 几何元素(Geometry Elements) + +几何元素提供可渲染的形状。 + +#### 5.2.1 矩形(Rectangle) + +矩形从中心点定义,支持统一圆角。 + +```xml + +``` + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `center` | Point | 0,0 | 中心点 | +| `size` | Size | 100,100 | 尺寸 "width,height" | +| `roundness` | float | 0 | 圆角半径 | +| `reversed` | bool | false | 反转路径方向 | + +**计算规则**: +``` +rect.left = center.x - size.width / 2 +rect.top = center.y - size.height / 2 +rect.right = center.x + size.width / 2 +rect.bottom = center.y + size.height / 2 +``` + +**圆角处理**: +- `roundness` 值自动限制为 `min(roundness, size.width/2, size.height/2)` +- 当 `roundness >= min(size.width, size.height) / 2` 时,短边方向呈半圆形 + +**示例**: + +> [Sample](samples/5.2.1_rectangle.pagx) + +**路径起点**:矩形路径从**右上角**开始,顺时针方向绘制(`reversed="false"` 时)。 + +#### 5.2.2 椭圆(Ellipse) + +椭圆从中心点定义。 + +```xml + +``` + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `center` | Point | 0,0 | 中心点 | +| `size` | Size | 100,100 | 尺寸 "width,height" | +| `reversed` | bool | false | 反转路径方向 | + +**计算规则**: +``` +boundingRect.left = center.x - size.width / 2 +boundingRect.top = center.y - size.height / 2 +boundingRect.right = center.x + size.width / 2 +boundingRect.bottom = center.y + size.height / 2 +``` + +**示例**: + +> [Sample](samples/5.2.2_ellipse.pagx) + +**路径起点**:椭圆路径从**右侧中点**(3 点钟方向)开始。 + +#### 5.2.3 多边形/星形(Polystar) + +支持正多边形和星形两种模式。 + +```xml + +``` + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `center` | Point | 0,0 | 中心点 | +| `type` | PolystarType | star | 类型(见下方) | +| `pointCount` | float | 5 | 顶点数(支持小数) | +| `outerRadius` | float | 100 | 外半径 | +| `innerRadius` | float | 50 | 内半径(仅星形) | +| `rotation` | float | 0 | 旋转角度 | +| `outerRoundness` | float | 0 | 外角圆度 0~1 | +| `innerRoundness` | float | 0 | 内角圆度 0~1 | +| `reversed` | bool | false | 反转路径方向 | + +**PolystarType(类型)**: + +| 值 | 说明 | +|------|------| +| `polygon` | 正多边形:只使用外半径 | +| `star` | 星形:使用外半径和内半径交替 | + +**多边形模式** (`type="polygon"`): +- 只使用 `outerRadius` 和 `outerRoundness` +- `innerRadius` 和 `innerRoundness` 被忽略 + +**星形模式** (`type="star"`): +- 外顶点位于 `outerRadius` 处 +- 内顶点位于 `innerRadius` 处 +- 顶点交替连接形成星形 + +**顶点计算**(第 i 个外顶点): +``` +angle = rotation + (i / pointCount) * 360° +x = center.x + outerRadius * cos(angle) +y = center.y + outerRadius * sin(angle) +``` + +**小数点数**: +- `pointCount` 支持小数值(如 `5.5`) +- 小数部分表示最后一个顶点的"完成度",产生不完整的最后一个角 +- `pointCount <= 0` 时不生成任何路径 + +**圆度处理**: +- `outerRoundness` 和 `innerRoundness` 取值范围 0~1 +- 0 表示尖角,1 表示完全圆滑 +- 圆度通过在顶点处添加贝塞尔控制点实现 + +**示例**: + +> [Sample](samples/5.2.3_polystar.pagx) + +#### 5.2.4 路径(Path) + +使用 SVG 路径语法定义任意形状,支持内联数据或引用 Resources 中定义的 PathData。 + +```xml + + + + + +``` + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `data` | string/idref | (必填) | SVG 路径数据或 PathData 资源引用 "@id" | +| `reversed` | bool | false | 反转路径方向 | + +**示例**: + +> [Sample](samples/5.2.4_path.pagx) + +#### 5.2.5 文本(Text) + +文本元素提供文本内容的几何形状。与形状元素产生单一 Path 不同,Text 经过塑形后会产生**字形列表**(多个字形)并累积到渲染上下文的几何列表中,供后续修改器变换或绘制器渲染。 + +```xml + +``` + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `text` | string | "" | 文本内容 | +| `position` | Point | 0,0 | 文本起点位置,y 为基线(可被 TextLayout 覆盖) | +| `fontFamily` | string | 系统默认 | 字体族 | +| `fontStyle` | string | "Regular" | 字体变体(Regular, Bold, Italic, Bold Italic 等) | +| `fontSize` | float | 12 | 字号 | +| `letterSpacing` | float | 0 | 字间距 | +| `baselineShift` | float | 0 | 基线偏移(正值上移,负值下移) | + +子元素:`CDATA` 文本、`GlyphRun`* + +**文本内容**:通常使用 `text` 属性指定文本内容。当文本包含 XML 特殊字符(`<`、`>`、`&` 等)或需要保留多行格式时,可使用 CDATA 子节点替代 `text` 属性。Text 不允许直接包含纯文本子节点,必须用 CDATA 包裹。 + +```xml + + + + + D]]> + + + + + +``` + +**渲染模式**:Text 支持**预排版**和**运行时排版**两种模式。预排版通过 GlyphRun 子节点提供预计算的字形和位置,使用嵌入字体渲染,确保跨平台完全一致。运行时排版在运行时进行塑形和排版,因各平台字体和排版特性差异,可能存在细微不一致。如需精确还原设计工具的排版效果,建议使用预排版。 + +**运行时排版渲染流程**: +1. 根据 `fontFamily` 和 `fontStyle` 查找系统字体,不可用时按运行时配置的回退列表选择替代字体 +2. 使用 `text` 属性(或 CDATA 子节点)进行塑形,换行符触发换行(默认 1.2 倍字号行高,可通过 TextLayout 自定义) +3. 应用 `fontSize`、`letterSpacing`、`baselineShift` 等排版参数 +4. 构造字形列表累积到渲染上下文 + +**运行时排版示例**: + +> [Sample](samples/5.2.5_text.pagx) + +##### 预排版数据(GlyphRun) + +GlyphRun 定义一组字形的预排版数据,每个 GlyphRun 独立引用一个字体资源。 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `font` | idref | (必填) | 引用 Font 资源 `@id` | +| `fontSize` | float | 12 | 渲染字号。实际缩放比例 = `fontSize / font.unitsPerEm` | +| `glyphs` | string | (必填) | GlyphID 序列,逗号分隔(0 表示缺失字形) | +| `x` | float | 0 | 总体 X 偏移 | +| `y` | float | 0 | 总体 Y 偏移 | +| `xOffsets` | string | - | 每字形 X 偏移,逗号分隔 | +| `positions` | string | - | 每字形 (x,y) 偏移,分号分隔 | +| `anchors` | string | - | 每字形锚点偏移 (x,y),分号分隔。锚点是缩放、旋转和斜切变换的中心点。默认锚点为 (advance×0.5, 0) | +| `scales` | string | - | 每字形缩放 (sx,sy),分号分隔。缩放围绕锚点进行。默认 1,1 | +| `rotations` | string | - | 每字形旋转角度(度),逗号分隔。旋转围绕锚点进行。默认 0 | +| `skews` | string | - | 每字形斜切角度(度),逗号分隔。斜切围绕锚点进行。默认 0 | + +所有属性均为可选,可任意组合使用。当属性数组长度小于字形数量时,缺失的值使用默认值。 + +**位置计算**: + +``` +finalX[i] = x + xOffsets[i] + positions[i].x +finalY[i] = y + positions[i].y +``` + +- 未指定 `xOffsets` 时,`xOffsets[i]` 视为 0 +- 未指定 `positions` 时,`positions[i]` 视为 (0, 0) +- 不指定 `xOffsets` 和 `positions` 时:首个字形位于 (x, y),后续字形依次累加 advance + +**变换应用顺序**: + +当字形有 scale、rotation 或 skew 变换时,按以下顺序应用(与 TextModifier 一致): + +1. 平移到锚点(`translate(-anchor)`) +2. 缩放(`scale`) +3. 斜切(`skew`,沿垂直轴方向) +4. 旋转(`rotation`) +5. 平移回锚点(`translate(anchor)`) +6. 平移到位置(`translate(position)`) + +**锚点**: + +- 每个字形的**默认锚点**位于 `(advance × 0.5, 0)`,即字形水平中心的基线位置 +- `anchors` 属性记录的是相对于默认锚点的偏移,最终锚点 = 默认锚点 + anchors[i] + +**预排版示例**: + +> [Sample](samples/5.2.5_glyph_run.pagx) + +### 5.3 绘制器(Painters) + +绘制器(Fill、Stroke)对**当前时刻**累积的所有几何(Path 和字形列表)进行渲染。 + +#### 5.3.1 填充(Fill) + +填充使用指定的颜色源绘制几何的内部区域。 + +> [Sample](samples/5.3.1_fill.pagx) + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `color` | Color/idref | #000000 | 颜色值或颜色源引用,默认黑色 | +| `alpha` | float | 1 | 透明度 0~1 | +| `blendMode` | BlendMode | normal | 混合模式(见 2.9 节) | +| `fillRule` | FillRule | winding | 填充规则(见下方) | +| `placement` | LayerPlacement | background | 绘制位置(见 5.3.3 节) | + +子元素:可内嵌一个颜色源(SolidColor、LinearGradient、RadialGradient、ConicGradient、DiamondGradient、ImagePattern) + +**FillRule(填充规则)**: + +| 值 | 说明 | +|------|------| +| `winding` | 非零环绕规则:根据路径方向计数,非零则填充 | +| `evenOdd` | 奇偶规则:根据交叉次数,奇数则填充 | + +**文本填充**: +- 文本以字形(glyph)为单位填充 +- 支持通过 TextModifier 对单个字形应用颜色覆盖 +- 颜色覆盖采用 alpha 混合:`finalColor = lerp(originalColor, overrideColor, overrideAlpha)` + +#### 5.3.2 描边(Stroke) + +描边沿几何边界绘制线条。 + +> [Sample](samples/5.3.2_stroke.pagx) + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `color` | Color/idref | #000000 | 颜色值或颜色源引用,默认黑色 | +| `width` | float | 1 | 描边宽度 | +| `alpha` | float | 1 | 透明度 0~1 | +| `blendMode` | BlendMode | normal | 混合模式(见 2.9 节) | +| `cap` | LineCap | butt | 线帽样式(见下方) | +| `join` | LineJoin | miter | 线连接样式(见下方) | +| `miterLimit` | float | 4 | 斜接限制 | +| `dashes` | string | - | 虚线模式 "d1,d2,..." | +| `dashOffset` | float | 0 | 虚线偏移 | +| `dashAdaptive` | bool | false | 等长虚线段缩放 | +| `align` | StrokeAlign | center | 描边对齐(见下方) | +| `placement` | LayerPlacement | background | 绘制位置(见 5.3.3 节) | + +**LineCap(线帽样式)**: + +| 值 | 说明 | +|------|------| +| `butt` | 平头:线条不超出端点 | +| `round` | 圆头:以半圆形扩展端点 | +| `square` | 方头:以方形扩展端点 | + +**LineJoin(线连接样式)**: + +| 值 | 说明 | +|------|------| +| `miter` | 斜接:延伸外边缘形成尖角 | +| `round` | 圆角:以圆弧连接 | +| `bevel` | 斜角:以三角形填充连接处 | + +**StrokeAlign(描边对齐)**: + +| 值 | 说明 | +|------|------| +| `center` | 描边居中于路径(默认) | +| `inside` | 描边在闭合路径内侧 | +| `outside` | 描边在闭合路径外侧 | + +内侧/外侧描边通过以下方式实现: +1. 以双倍宽度描边 +2. 与原始形状进行布尔运算(内侧用交集,外侧用差集) + +**虚线模式**: +- `dashes`:定义虚线段长度序列,如 `"5,3"` 表示 5px 实线 + 3px 空白 +- `dashOffset`:虚线起始偏移量 +- `dashAdaptive`:为 true 时,缩放虚线间隔使各虚线段保持等长 + +#### 5.3.3 绘制位置(LayerPlacement) + +Fill 和 Stroke 的 `placement` 属性控制相对于子图层的绘制顺序: + +| 值 | 说明 | +|------|------| +| `background` | 在子图层**下方**绘制(默认) | +| `foreground` | 在子图层**上方**绘制 | + +### 5.4 形状修改器(Shape Modifiers) + +形状修改器对累积的 Path 进行**原地变换**,对字形列表则触发强制转换为 Path。 + +#### 5.4.1 路径裁剪(TrimPath) + +裁剪路径到指定的起止范围。 + +```xml + +``` + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `start` | float | 0 | 起始位置 0~1 | +| `end` | float | 1 | 结束位置 0~1 | +| `offset` | float | 0 | 偏移量(度),360 度表示完整路径长度的一个周期。例如,180 度将裁剪范围偏移半个路径长度 | +| `type` | TrimType | separate | 裁剪类型(见下方) | + +**TrimType(裁剪类型)**: + +| 值 | 说明 | +|------|------| +| `separate` | 独立模式:每个形状独立裁剪,使用相同的 start/end 参数 | +| `continuous` | 连续模式:所有形状视为一条连续路径,按总长度比例裁剪 | + +**边界情况**: +- `start > end`:对 start 和 end 值取镜像(`start = 1 - start`,`end = 1 - end`)并反转所有路径方向,然后执行正常裁剪。视觉效果为路径的互补段且方向相反 +- 支持环绕:当裁剪范围超出 [0,1] 时,自动环绕到路径另一端 +- 路径总长度为 0 时,不执行任何操作 + +> [Sample](samples/5.4.1_trim_path.pagx) + +#### 5.4.2 圆角(RoundCorner) + +将路径的尖角转换为圆角。 + +```xml + +``` + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `radius` | float | 10 | 圆角半径 | + +**处理规则**: +- 只影响尖角(非平滑连接的顶点) +- 圆角半径自动限制为不超过相邻边长度的一半 +- `radius <= 0` 时不执行任何操作 + +**示例**: + +> [Sample](samples/5.4.2_round_corner.pagx) + +#### 5.4.3 路径合并(MergePath) + +将所有形状合并为单个形状。 + +```xml + +``` + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `mode` | MergePathOp | append | 合并操作(见下方) | + +**MergePathOp(路径合并操作)**: + +| 值 | 说明 | +|------|------| +| `append` | 追加:简单合并所有路径,不进行布尔运算(默认) | +| `union` | 并集:合并所有形状的覆盖区域 | +| `intersect` | 交集:只保留所有形状的重叠区域 | +| `xor` | 异或:保留非重叠区域 | +| `difference` | 差集:从第一个形状中减去后续形状 | + +**重要行为**: +- MergePath 会**清空当前作用域中之前累积的所有 Fill 和 Stroke 效果**,几何列表中仅保留合并后的路径 +- 合并时应用各形状的当前变换矩阵 +- 合并后的形状变换矩阵重置为单位矩阵 + +**示例**: + +> [Sample](samples/5.4.3_merge_path.pagx) + +### 5.5 文本修改器(Text Modifiers) + +文本修改器对文本中的独立字形进行变换。 + +#### 5.5.1 文本修改器处理 + +遇到文本修改器时,上下文中累积的**所有字形列表**会汇总为一个统一的字形列表进行操作: + +```xml + + + + + + + +``` + +#### 5.5.2 文本转形状 + +当文本遇到形状修改器时,会强制转换为形状路径: + +``` +文本元素 形状修改器 后续修改器 +┌──────────┐ ┌──────────┐ +│ Text │ │ TrimPath │ +└────┬─────┘ │RoundCorn │ + │ │MergePath │ + │ 累积字形列表 └────┬─────┘ + ▼ │ +┌──────────────┐ │ 触发转换 +│ 字形列表 │───────────┼──────────────────────┐ +│ [H,e,l,l,o] │ │ │ +└──────────────┘ ▼ ▼ + ┌──────────────┐ ┌──────────────────┐ + │ 合并为单个 │ │ Emoji 被丢弃 │ + │ Path │ │ (无法转为路径) │ + └──────────────┘ └──────────────────┘ + │ + │ 后续文本修改器不再生效 + ▼ + ┌──────────────┐ + │ TextModifier │ → 跳过(已是 Path) + └──────────────┘ +``` + +**转换规则**: + +1. **触发条件**:文本遇到 TrimPath、RoundCorner、MergePath 时触发转换 +2. **合并为单个 Path**:一个 Text 的所有字形合并为**一个** Path,而非每个字形产生一个独立 Path +3. **Emoji 丢失**:Emoji 无法转换为路径轮廓,转换时被丢弃 +4. **不可逆转换**:转换后成为纯 Path,后续的文本修改器对其无效 + +**示例**: +```xml + + + + + + +``` + +#### 5.5.3 文本变换器(TextModifier) + +对选定范围内的字形应用变换和样式覆盖。TextModifier 可包含多个 RangeSelector 子元素,用于定义不同的选择范围和影响因子。 + +```xml + + + + +``` + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `anchor` | Point | 0,0 | 锚点偏移,相对于字形默认锚点位置。每个字形的默认锚点位于 `(advance × 0.5, 0)`,即字形水平中心的基线位置 | +| `position` | Point | 0,0 | 位置偏移 | +| `rotation` | float | 0 | 旋转 | +| `scale` | Point | 1,1 | 缩放 | +| `skew` | float | 0 | 倾斜角度(度),沿 skewAxis 方向应用 | +| `skewAxis` | float | 0 | 倾斜轴角度(度),定义倾斜的作用方向 | +| `alpha` | float | 1 | 透明度 | +| `fillColor` | Color | - | 填充颜色覆盖 | +| `strokeColor` | Color | - | 描边颜色覆盖 | +| `strokeWidth` | float | - | 描边宽度覆盖 | + +**选择器计算**: +1. 根据 RangeSelector 的 `start`、`end`、`offset` 计算选择范围(支持任意小数值,超出 [0,1] 范围时自动环绕) +2. 根据 `shape` 计算每个字形的影响因子(0~1) +3. 多个选择器按 `mode` 组合 + +**变换应用**: + +选择器计算出的 `factor` 范围为 [-1, 1],控制变换属性的应用程度: + +``` +factor = clamp(selectorFactor × weight, -1, 1) +``` + +位置和旋转线性应用 factor。变换按以下顺序应用: + +1. 平移到锚点的负方向(`translate(-anchor × factor)`) +2. 从单位矩阵插值缩放(`scale(1 + (scale - 1) × factor)`) +3. 倾斜(`skew(skew × factor, skewAxis)`) +4. 旋转(`rotate(rotation × factor)`) +5. 平移回锚点(`translate(anchor × factor)`) +6. 平移到位置(`translate(position × factor)`) + +透明度使用 factor 的绝对值: + +``` +alphaFactor = 1 + (alpha - 1) × |factor| +finalAlpha = originalAlpha × max(0, alphaFactor) +``` + +**颜色覆盖**: + +颜色覆盖使用 `factor` 的绝对值进行 alpha 混合: + +``` +blendFactor = overrideColor.alpha × |factor| +finalColor = blend(originalColor, overrideColor, blendFactor) +``` + +**示例**: + +> [Sample](samples/5.5.3_text_modifier.pagx) + +#### 5.5.4 范围选择器(RangeSelector) + +范围选择器定义 TextModifier 影响的字形范围和影响程度。 + +```xml + +``` + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `start` | float | 0 | 选择起始 | +| `end` | float | 1 | 选择结束 | +| `offset` | float | 0 | 选择偏移 | +| `unit` | SelectorUnit | percentage | 单位(见下方) | +| `shape` | SelectorShape | square | 形状(见下方) | +| `easeIn` | float | 0 | 缓入量 | +| `easeOut` | float | 0 | 缓出量 | +| `mode` | SelectorMode | add | 组合模式(见下方) | +| `weight` | float | 1 | 选择器权重 | +| `randomOrder` | bool | false | 随机顺序 | +| `randomSeed` | int | 0 | 随机种子 | + +**SelectorUnit(单位)**: + +| 值 | 说明 | +|------|------| +| `index` | 索引:按字形索引计算范围 | +| `percentage` | 百分比:按字形总数的百分比计算范围 | + +**SelectorShape(形状)**: + +| 值 | 说明 | +|------|------| +| `square` | 矩形:范围内为 1,范围外为 0 | +| `rampUp` | 上升斜坡:从 0 线性增加到 1 | +| `rampDown` | 下降斜坡:从 1 线性减少到 0 | +| `triangle` | 三角形:中心为 1,两端为 0 | +| `round` | 圆形:正弦曲线过渡 | +| `smooth` | 平滑:更平滑的过渡曲线 | + +**SelectorMode(组合模式)**: + +| 值 | 说明 | +|------|------| +| `add` | 相加:累加选择器权重 | +| `subtract` | 相减:减去选择器权重 | +| `intersect` | 交集:使用选择器范围的交集 | +| `min` | 最小:取选择器值的最小值 | +| `max` | 最大:取最大值 | +| `difference` | 差值:取绝对差值 | + +#### 5.5.5 文本路径(TextPath) + +将文本沿指定路径排列。路径可以通过引用 Resources 中定义的 PathData,也可以内联路径数据。TextPath 使用 +基线(由 baselineOrigin 和 baselineAngle 定义的直线)作为文本的参考线:字形从基线上的位置映射到路径曲线上 +的对应位置,保持相对间距和偏移。当 forceAlignment 启用时,忽略原始字形位置,将字形均匀分布以填满可用路径长度。 + +> [Sample](samples/5.5.5_text_path.pagx) + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `path` | string/idref | (必填) | SVG 路径数据或 PathData 资源引用 "@id" | +| `baselineOrigin` | Point | 0,0 | 基线原点,文本参考线的起点坐标 | +| `baselineAngle` | float | 0 | 基线角度(度),0 为水平,90 为垂直 | +| `firstMargin` | float | 0 | 起始边距 | +| `lastMargin` | float | 0 | 结束边距 | +| `perpendicular` | bool | true | 垂直于路径 | +| `reversed` | bool | false | 反转方向 | +| `forceAlignment` | bool | false | 强制拉伸文本填满路径 | + +**基线**: +- `baselineOrigin`:基线的起点坐标,相对于 TextPath 的本地坐标空间 +- `baselineAngle`:基线的角度(度数),0 表示水平基线(文本从左到右沿 X 轴),90 表示垂直基线(文本从上到下沿 Y 轴) +- 字形沿基线的距离决定其在曲线上的位置,字形垂直于基线的偏移量保持为垂直于曲线的偏移量 + +**边距**: +- `firstMargin`:起点边距(从路径起点向内偏移) +- `lastMargin`:终点边距(从路径终点向内偏移) + +**强制对齐**: +- 当 `forceAlignment="true"` 时,字形按其推进宽度依次排列,然后按比例调整间距以填满 firstMargin 和 lastMargin 之间的路径区域 + +**字形定位**: +1. 计算字形中心在路径上的位置 +2. 获取该位置的路径切线方向 +3. 如果 `perpendicular="true"`,旋转字形使其垂直于路径 + +**闭合路径**:对于闭合路径,超出范围的字形会环绕到路径另一端。 + +#### 5.5.6 文本排版(TextLayout) + +TextLayout 是文本排版修改器,对累积的 Text 元素应用排版,会覆盖 Text 元素的原始位置(类似 TextPath 覆盖位置的行为)。支持两种模式: + +- **点文本模式**(无 width):文本不自动换行,textAlign 控制文本相对于 (x, y) 锚点的对齐 +- **段落文本模式**(有 width):文本在指定宽度内自动换行 + +渲染时会由附加的文字排版模块预先排版,重新计算每个字形的位置。TextLayout 会被预排版展开,字形位置直接写入 Text。 + +> [Sample](samples/5.5.6_text_layout.pagx) + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `position` | Point | 0,0 | 排版原点 | +| `width` | float | auto | 排版宽度(有值则自动换行) | +| `height` | float | auto | 排版高度(有值则启用垂直对齐) | +| `textAlign` | TextAlign | start | 水平对齐(见下方) | +| `verticalAlign` | VerticalAlign | top | 垂直对齐(见下方) | +| `writingMode` | WritingMode | horizontal | 排版方向(见下方) | +| `lineHeight` | float | 1.2 | 行高倍数 | + +**TextAlign(水平对齐)**: + +| 值 | 说明 | +|------|------| +| `start` | 起始对齐(左对齐,对于 RTL 文本为右对齐) | +| `center` | 居中对齐 | +| `end` | 结束对齐(右对齐,对于 RTL 文本为左对齐) | +| `justify` | 两端对齐(最后一行起始对齐) | + +**VerticalAlign(垂直对齐)**: + +| 值 | 说明 | +|------|------| +| `top` | 顶部对齐 | +| `center` | 垂直居中 | +| `bottom` | 底部对齐 | + +**WritingMode(排版方向)**: + +| 值 | 说明 | +|------|------| +| `horizontal` | 横排文本 | +| `vertical` | 竖排文本(列从右到左排列,传统中日文竖排) | + +#### 5.5.7 富文本 + +富文本通过 Group 内的多个 Text 元素组合,每个 Text 可以有独立的 Fill/Stroke 样式。使用 TextLayout 进行统一排版。 + +> [Sample](samples/5.5.7_rich_text.pagx) + +**说明**:每个 Group 内的 Text + Fill/Stroke 定义一段样式独立的文本片段,TextLayout 将所有片段作为整体进行排版,实现自动换行和对齐。 + +### 5.6 复制器(Repeater) + +复制累积的内容和已渲染的样式,对每个副本应用渐进变换。Repeater 对 Path 和字形列表同时生效,且不会触发文本转形状。 + +```xml + +``` + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `copies` | float | 3 | 副本数 | +| `offset` | float | 0 | 起始偏移 | +| `order` | RepeaterOrder | belowOriginal | 堆叠顺序(见下方) | +| `anchor` | Point | 0,0 | 锚点 | +| `position` | Point | 100,100 | 每个副本的位置偏移 | +| `rotation` | float | 0 | 每个副本的旋转 | +| `scale` | Point | 1,1 | 每个副本的缩放 | +| `startAlpha` | float | 1 | 首个副本透明度 | +| `endAlpha` | float | 1 | 末个副本透明度 | + +**变换计算**(第 i 个副本,i 从 0 开始): +``` +progress = i + offset +``` + +变换按以下顺序应用: + +1. 平移到锚点的负方向(`translate(-anchor)`) +2. 指数缩放(`scale(scale^progress)`) +3. 线性旋转(`rotate(rotation × progress)`) +4. 线性位移(`translate(position × progress)`) +5. 平移回锚点(`translate(anchor)`) + +**透明度插值**: +``` +maxCount = ceil(copies) +t = progress / maxCount +alpha = lerp(startAlpha, endAlpha, t) +// 最后一个副本的 alpha 还需乘以 copies 的小数部分(见下文) +``` + +**RepeaterOrder(堆叠顺序)**: + +| 值 | 说明 | +|------|------| +| `belowOriginal` | 副本在原件下方。索引 0 在最上 | +| `aboveOriginal` | 副本在原件上方。索引 N-1 在最上 | + +**小数副本数**: + +当 `copies` 为小数时(如 `3.5`),采用**叠加半透明**的方式实现部分副本效果: + +1. **几何复制**:形状和文本按 `ceil(copies)` 个复制(即 4 个),几何本身不做缩放或裁剪 +2. **透明度调整**:最后一个副本的透明度乘以小数部分(如 0.5),产生半透明效果 +3. **视觉效果**:通过透明度渐变模拟"部分存在"的副本 + +**示例**:`copies="2.3"` 时 +- 复制 3 个完整的几何副本 +- 第 1、2 个副本正常渲染 +- 第 3 个副本透明度 × 0.3,呈现半透明效果 + +**边界情况**: +- `copies < 0`:不执行任何操作 +- `copies = 0`:清空所有累积的内容和已渲染的样式 + +**Repeater 特性**: +- **同时作用**:复制所有累积的 Path 和字形列表 +- **保留文本属性**:字形列表复制后仍保留字形信息,后续文本修改器仍可作用 +- **复制已渲染样式**:同时复制已渲染的填充和描边 + +> [Sample](samples/5.6_repeater.pagx) + +### 5.7 容器(Group) + +Group 是带变换属性的矢量元素容器。 + +> [Sample](samples/5.7_group.pagx) + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `anchor` | Point | 0,0 | 锚点 "x,y" | +| `position` | Point | 0,0 | 位置 "x,y" | +| `rotation` | float | 0 | 旋转角度 | +| `scale` | Point | 1,1 | 缩放 "sx,sy" | +| `skew` | float | 0 | 倾斜量 | +| `skewAxis` | float | 0 | 倾斜轴角度 | +| `alpha` | float | 1 | 透明度 0~1 | + +#### 变换顺序 + +变换按以下顺序应用: + +1. 平移到锚点的负方向(`translate(-anchor)`) +2. 缩放(`scale`) +3. 倾斜(`skew` 沿 `skewAxis` 方向) +4. 旋转(`rotation`) +5. 平移到位置(`translate(position)`) + +**倾斜变换**: + +倾斜变换按以下顺序应用: + +1. 旋转到倾斜轴方向(`rotate(skewAxis)`) +2. 沿 X 轴剪切(`shearX(tan(skew))`) +3. 旋转回原方向(`rotate(-skewAxis)`) + +#### 作用域隔离 + +Group 创建独立的作用域,用于隔离几何累积和渲染: + +- 组内的几何元素只在组内累积 +- 组内的绘制器只渲染组内累积的几何 +- 组内的修改器只影响组内累积的几何 +- 组的变换矩阵应用到组内所有内容 +- 组的 `alpha` 属性应用到组内所有渲染内容 + +**几何累积规则**: + +- **绘制器不清空几何**:Fill 和 Stroke 渲染后,几何列表保持不变,后续绘制器仍可渲染相同的几何 +- **子 Group 几何向上累积**:子 Group 处理完成后,其几何会累积到父作用域,父级末尾的绘制器可以渲染所有子 Group 的几何 +- **同级 Group 互不影响**:每个 Group 创建独立的累积起点,不会看到后续兄弟 Group 的几何 +- **隔离渲染范围**:Group 内的绘制器只能渲染到当前位置已累积的几何,包括本组和已完成的子 Group +- **Layer 是累积终止点**:几何向上累积直到遇到 Layer 边界,不会跨 Layer 传递 + +**示例 1 - 基本隔离**: +> [Sample](samples/5.7_group_isolation.pagx) + +**示例 2 - 子 Group 几何向上累积**: +> [Sample](samples/5.7_group_propagation.pagx) + +**示例 3 - 多个绘制器复用几何**: +> [Sample](samples/5.7_multiple_painters.pagx) + +#### 多重填充与描边 + +由于绘制器不清空几何列表,同一几何可连续应用多个 Fill 和 Stroke。 + +**示例 4 - 多重填充**: +> [Sample](samples/5.7_multiple_fills.pagx) + +**示例 5 - 多重描边**: +> [Sample](samples/5.7_multiple_strokes.pagx) + +**示例 6 - 混合叠加**: +> [Sample](samples/5.7_mixed_overlay.pagx) + +**渲染顺序**:多个绘制器按文档顺序渲染,先出现的位于下方。 + +--- + +## 附录 A. 节点层级与包含关系(Node Hierarchy) + +本附录描述节点的分类和嵌套规则。 + +### A.1 节点分类 + +| 分类 | 节点 | +|------|------| +| **容器** | `pagx`, `Resources`, `Layer`, `Group` | +| **资源** | `Image`, `PathData`, `Composition`, `Font`, `Glyph` | +| **颜色源** | `SolidColor`, `LinearGradient`, `RadialGradient`, `ConicGradient`, `DiamondGradient`, `ImagePattern`, `ColorStop` | +| **图层样式** | `DropShadowStyle`, `InnerShadowStyle`, `BackgroundBlurStyle` | +| **图层滤镜** | `BlurFilter`, `DropShadowFilter`, `InnerShadowFilter`, `BlendFilter`, `ColorMatrixFilter` | +| **几何元素** | `Rectangle`, `Ellipse`, `Polystar`, `Path`, `Text`, `GlyphRun` | +| **修改器** | `TrimPath`, `RoundCorner`, `MergePath`, `TextModifier`, `RangeSelector`, `TextPath`, `TextLayout`, `Repeater` | +| **绘制器** | `Fill`, `Stroke` | + +### A.2 文档包含关系 + +``` +pagx +├── Resources +│ ├── Image +│ ├── PathData +│ ├── SolidColor +│ ├── LinearGradient → ColorStop* +│ ├── RadialGradient → ColorStop* +│ ├── ConicGradient → ColorStop* +│ ├── DiamondGradient → ColorStop* +│ ├── ImagePattern +│ ├── Font → Glyph* +│ └── Composition → Layer* +│ +└── Layer* + ├── VectorElement*(见 A.3) + ├── DropShadowStyle* + ├── InnerShadowStyle* + ├── BackgroundBlurStyle* + ├── BlurFilter* + ├── DropShadowFilter* + ├── InnerShadowFilter* + ├── BlendFilter* + ├── ColorMatrixFilter* + └── Layer*(子图层) +``` + +### A.3 VectorElement 包含关系 + +`Layer` 和 `Group` 可包含以下 VectorElement: + +``` +Layer / Group +├── Rectangle +├── Ellipse +├── Polystar +├── Path +├── Text → GlyphRun*(预排版模式) +├── Fill(可内嵌颜色源) +│ └── SolidColor / LinearGradient / RadialGradient / ConicGradient / DiamondGradient / ImagePattern +├── Stroke(可内嵌颜色源) +│ └── SolidColor / LinearGradient / RadialGradient / ConicGradient / DiamondGradient / ImagePattern +├── TrimPath +├── RoundCorner +├── MergePath +├── TextModifier → RangeSelector* +├── TextPath +├── TextLayout +├── Repeater +└── Group*(递归) +``` + +--- + +## 附录 B. 枚举类型(Enumeration Types) + +### 图层相关 + +| 枚举 | 值 | +|------|------| +| **BlendMode** | `normal`, `multiply`, `screen`, `overlay`, `darken`, `lighten`, `colorDodge`, `colorBurn`, `hardLight`, `softLight`, `difference`, `exclusion`, `hue`, `saturation`, `color`, `luminosity`, `plusLighter`, `plusDarker` | +| **MaskType** | `alpha`, `luminance`, `contour` | +| **TileMode** | `clamp`, `repeat`, `mirror`, `decal` | +| **FilterMode** | `nearest`, `linear` | +| **MipmapMode** | `none`, `nearest`, `linear` | + +### 绘制器相关 + +| 枚举 | 值 | +|------|------| +| **FillRule** | `winding`, `evenOdd` | +| **LineCap** | `butt`, `round`, `square` | +| **LineJoin** | `miter`, `round`, `bevel` | +| **StrokeAlign** | `center`, `inside`, `outside` | +| **LayerPlacement** | `background`, `foreground` | + +### 几何元素相关 + +| 枚举 | 值 | +|------|------| +| **PolystarType** | `polygon`, `star` | + +### 修改器相关 + +| 枚举 | 值 | +|------|------| +| **TrimType** | `separate`, `continuous` | +| **MergePathOp** | `append`, `union`, `intersect`, `xor`, `difference` | +| **SelectorUnit** | `index`, `percentage` | +| **SelectorShape** | `square`, `rampUp`, `rampDown`, `triangle`, `round`, `smooth` | +| **SelectorMode** | `add`, `subtract`, `intersect`, `min`, `max`, `difference` | +| **TextAlign** | `start`, `center`, `end`, `justify` | +| **VerticalAlign** | `top`, `center`, `bottom` | +| **WritingMode** | `horizontal`, `vertical` | +| **RepeaterOrder** | `belowOriginal`, `aboveOriginal` | +--- + +## 附录 C. 常见用法示例(Examples) + +### C.1 完整示例 + +以下示例涵盖 PAGX 的所有主要节点类型,展示完整的文档结构。 + +> [Sample](samples/C.1_complete_example.pagx) + +### C.2 RPG 角色面板 + +一个奇幻 RPG 风格的角色状态面板,展示了复杂的 UI 组合,包含嵌套图层、渐变和装饰元素。 + +> [Sample](samples/C.2_rpg_character_panel.pagx) + +### C.3 星云学员 + +一个太空主题的学员资料卡片,展示了星云效果、星空背景和现代 UI 设计模式。 + +> [Sample](samples/C.3_nebula_cadet.pagx) + +### C.4 游戏 HUD + +一个游戏平视显示器(HUD),展示了血条、分数显示和游戏界面元素。 + +> [Sample](samples/C.4_game_hud.pagx) + +### C.5 PAGX 特性概览 + +PAGX 格式能力的综合展示,包括渐变、效果、文本样式和矢量图形。 + +> [Sample](samples/C.5_pagx_features.pagx) + diff --git a/spec/samples/3.2_document_structure.pagx b/spec/samples/3.2_document_structure.pagx new file mode 100644 index 0000000000..16964aacaf --- /dev/null +++ b/spec/samples/3.2_document_structure.pagx @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/spec/samples/3.3.3_color_source_coordinates.pagx b/spec/samples/3.3.3_color_source_coordinates.pagx new file mode 100644 index 0000000000..14a4b44ce5 --- /dev/null +++ b/spec/samples/3.3.3_color_source_coordinates.pagx @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/spec/samples/3.3.3_conic_gradient.pagx b/spec/samples/3.3.3_conic_gradient.pagx new file mode 100644 index 0000000000..7f550c4d94 --- /dev/null +++ b/spec/samples/3.3.3_conic_gradient.pagx @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/3.3.3_diamond_gradient.pagx b/spec/samples/3.3.3_diamond_gradient.pagx new file mode 100644 index 0000000000..e0076b864f --- /dev/null +++ b/spec/samples/3.3.3_diamond_gradient.pagx @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/spec/samples/3.3.3_image_pattern.pagx b/spec/samples/3.3.3_image_pattern.pagx new file mode 100644 index 0000000000..55a3567d1d --- /dev/null +++ b/spec/samples/3.3.3_image_pattern.pagx @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/3.3.3_linear_gradient.pagx b/spec/samples/3.3.3_linear_gradient.pagx new file mode 100644 index 0000000000..0d96656228 --- /dev/null +++ b/spec/samples/3.3.3_linear_gradient.pagx @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/spec/samples/3.3.3_radial_gradient.pagx b/spec/samples/3.3.3_radial_gradient.pagx new file mode 100644 index 0000000000..4cf3830df6 --- /dev/null +++ b/spec/samples/3.3.3_radial_gradient.pagx @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/spec/samples/3.3.4_composition.pagx b/spec/samples/3.3.4_composition.pagx new file mode 100644 index 0000000000..bcd167b9a5 --- /dev/null +++ b/spec/samples/3.3.4_composition.pagx @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/3.3_resources.pagx b/spec/samples/3.3_resources.pagx new file mode 100644 index 0000000000..9645bbb8aa --- /dev/null +++ b/spec/samples/3.3_resources.pagx @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/4.2_layer.pagx b/spec/samples/4.2_layer.pagx new file mode 100644 index 0000000000..dcb55887b6 --- /dev/null +++ b/spec/samples/4.2_layer.pagx @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/4.3_layer_styles.pagx b/spec/samples/4.3_layer_styles.pagx new file mode 100644 index 0000000000..6de862acf0 --- /dev/null +++ b/spec/samples/4.3_layer_styles.pagx @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/spec/samples/4.4_layer_filters.pagx b/spec/samples/4.4_layer_filters.pagx new file mode 100644 index 0000000000..5ffce93802 --- /dev/null +++ b/spec/samples/4.4_layer_filters.pagx @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/spec/samples/4.5.1_scroll_rect.pagx b/spec/samples/4.5.1_scroll_rect.pagx new file mode 100644 index 0000000000..985a721f75 --- /dev/null +++ b/spec/samples/4.5.1_scroll_rect.pagx @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/4.5.2_masking.pagx b/spec/samples/4.5.2_masking.pagx new file mode 100644 index 0000000000..6718f88b12 --- /dev/null +++ b/spec/samples/4.5.2_masking.pagx @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/5.2.1_rectangle.pagx b/spec/samples/5.2.1_rectangle.pagx new file mode 100644 index 0000000000..1f8c6bddb4 --- /dev/null +++ b/spec/samples/5.2.1_rectangle.pagx @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/5.2.2_ellipse.pagx b/spec/samples/5.2.2_ellipse.pagx new file mode 100644 index 0000000000..149e9f0ac4 --- /dev/null +++ b/spec/samples/5.2.2_ellipse.pagx @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/5.2.3_polystar.pagx b/spec/samples/5.2.3_polystar.pagx new file mode 100644 index 0000000000..2ce8c87ac8 --- /dev/null +++ b/spec/samples/5.2.3_polystar.pagx @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/5.2.4_path.pagx b/spec/samples/5.2.4_path.pagx new file mode 100644 index 0000000000..c371438ca9 --- /dev/null +++ b/spec/samples/5.2.4_path.pagx @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/5.2.5_glyph_run.pagx b/spec/samples/5.2.5_glyph_run.pagx new file mode 100644 index 0000000000..98ad2c4135 --- /dev/null +++ b/spec/samples/5.2.5_glyph_run.pagx @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/5.2.5_text.pagx b/spec/samples/5.2.5_text.pagx new file mode 100644 index 0000000000..45e37d9ebd --- /dev/null +++ b/spec/samples/5.2.5_text.pagx @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/5.3.1_fill.pagx b/spec/samples/5.3.1_fill.pagx new file mode 100644 index 0000000000..6d2f057092 --- /dev/null +++ b/spec/samples/5.3.1_fill.pagx @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/5.3.2_stroke.pagx b/spec/samples/5.3.2_stroke.pagx new file mode 100644 index 0000000000..c6e56b6064 --- /dev/null +++ b/spec/samples/5.3.2_stroke.pagx @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/5.4.1_trim_path.pagx b/spec/samples/5.4.1_trim_path.pagx new file mode 100644 index 0000000000..f2b1c36c17 --- /dev/null +++ b/spec/samples/5.4.1_trim_path.pagx @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/5.4.2_round_corner.pagx b/spec/samples/5.4.2_round_corner.pagx new file mode 100644 index 0000000000..f3cd76f96d --- /dev/null +++ b/spec/samples/5.4.2_round_corner.pagx @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/5.4.3_merge_path.pagx b/spec/samples/5.4.3_merge_path.pagx new file mode 100644 index 0000000000..d62d0ce0b3 --- /dev/null +++ b/spec/samples/5.4.3_merge_path.pagx @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/spec/samples/5.5.3_text_modifier.pagx b/spec/samples/5.5.3_text_modifier.pagx new file mode 100644 index 0000000000..f0f289e3ac --- /dev/null +++ b/spec/samples/5.5.3_text_modifier.pagx @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/5.5.5_text_path.pagx b/spec/samples/5.5.5_text_path.pagx new file mode 100644 index 0000000000..7bd49e958a --- /dev/null +++ b/spec/samples/5.5.5_text_path.pagx @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/5.5.6_text_layout.pagx b/spec/samples/5.5.6_text_layout.pagx new file mode 100644 index 0000000000..df215e57c6 --- /dev/null +++ b/spec/samples/5.5.6_text_layout.pagx @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/5.5.7_rich_text.pagx b/spec/samples/5.5.7_rich_text.pagx new file mode 100644 index 0000000000..fc76e05af1 --- /dev/null +++ b/spec/samples/5.5.7_rich_text.pagx @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/5.6_repeater.pagx b/spec/samples/5.6_repeater.pagx new file mode 100644 index 0000000000..456e820bcb --- /dev/null +++ b/spec/samples/5.6_repeater.pagx @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/spec/samples/5.7_group.pagx b/spec/samples/5.7_group.pagx new file mode 100644 index 0000000000..86380d6f73 --- /dev/null +++ b/spec/samples/5.7_group.pagx @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/spec/samples/5.7_group_isolation.pagx b/spec/samples/5.7_group_isolation.pagx new file mode 100644 index 0000000000..9745aff3da --- /dev/null +++ b/spec/samples/5.7_group_isolation.pagx @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/spec/samples/5.7_group_propagation.pagx b/spec/samples/5.7_group_propagation.pagx new file mode 100644 index 0000000000..a57268d390 --- /dev/null +++ b/spec/samples/5.7_group_propagation.pagx @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/spec/samples/5.7_mixed_overlay.pagx b/spec/samples/5.7_mixed_overlay.pagx new file mode 100644 index 0000000000..16e85d30ed --- /dev/null +++ b/spec/samples/5.7_mixed_overlay.pagx @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/spec/samples/5.7_multiple_fills.pagx b/spec/samples/5.7_multiple_fills.pagx new file mode 100644 index 0000000000..02d4b56827 --- /dev/null +++ b/spec/samples/5.7_multiple_fills.pagx @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/spec/samples/5.7_multiple_painters.pagx b/spec/samples/5.7_multiple_painters.pagx new file mode 100644 index 0000000000..492542a3a8 --- /dev/null +++ b/spec/samples/5.7_multiple_painters.pagx @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/spec/samples/5.7_multiple_strokes.pagx b/spec/samples/5.7_multiple_strokes.pagx new file mode 100644 index 0000000000..77ff36c377 --- /dev/null +++ b/spec/samples/5.7_multiple_strokes.pagx @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/C.1_complete_example.pagx b/spec/samples/C.1_complete_example.pagx new file mode 100644 index 0000000000..e33460faa3 --- /dev/null +++ b/spec/samples/C.1_complete_example.pagx @@ -0,0 +1,247 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/C.2_rpg_character_panel.pagx b/spec/samples/C.2_rpg_character_panel.pagx new file mode 100644 index 0000000000..785cbcba2f --- /dev/null +++ b/spec/samples/C.2_rpg_character_panel.pagx @@ -0,0 +1,762 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/C.3_nebula_cadet.pagx b/spec/samples/C.3_nebula_cadet.pagx new file mode 100644 index 0000000000..8af2dbd92e --- /dev/null +++ b/spec/samples/C.3_nebula_cadet.pagx @@ -0,0 +1,468 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/C.4_game_hud.pagx b/spec/samples/C.4_game_hud.pagx new file mode 100644 index 0000000000..136806507f --- /dev/null +++ b/spec/samples/C.4_game_hud.pagx @@ -0,0 +1,349 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/C.5_pagx_features.pagx b/spec/samples/C.5_pagx_features.pagx new file mode 100644 index 0000000000..665b2c6304 --- /dev/null +++ b/spec/samples/C.5_pagx_features.pagx @@ -0,0 +1,474 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/samples/pag_logo.png b/spec/samples/pag_logo.png new file mode 100644 index 0000000000..e74bea1ac7 --- /dev/null +++ b/spec/samples/pag_logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:91273a42aff655d620061ab7e31a0902d2b67e50e420087fe2bfb58953c447f1 +size 54847 diff --git a/spec/script/publish.js b/spec/script/publish.js new file mode 100755 index 0000000000..1b3b9fe153 --- /dev/null +++ b/spec/script/publish.js @@ -0,0 +1,655 @@ +#!/usr/bin/env node +/** + * PAGX Specification Publisher + * + * Converts the PAGX specification Markdown documents to standalone HTML pages. + * + * Source files: + * pagx_spec.md - English version + * pagx_spec.zh_CN.md - Chinese version + * + * Usage: + * cd spec && npm run publish + */ + +const fs = require('fs'); +const path = require('path'); +const { Marked } = require('marked'); +const { markedHighlight } = require('marked-highlight'); +const { gfmHeadingId } = require('marked-gfm-heading-id'); +const hljs = require('highlight.js'); + +// Paths +const SCRIPT_DIR = __dirname; +const SPEC_DIR = path.dirname(SCRIPT_DIR); +const LIBPAG_DIR = path.dirname(SPEC_DIR); +const SPEC_FILE_EN = path.join(SPEC_DIR, 'pagx_spec.md'); +const SPEC_FILE_ZH = path.join(SPEC_DIR, 'pagx_spec.zh_CN.md'); +const PACKAGE_FILE = path.join(SPEC_DIR, 'package.json'); +const TEMPLATE_FILE = path.join(SCRIPT_DIR, 'template.html'); +const DEFAULT_SITE_DIR = path.join(LIBPAG_DIR, 'public'); + +// Base URL for the spec site +const BASE_URL = 'https://pag.io/pagx'; + +// Load template +const TEMPLATE = fs.readFileSync(TEMPLATE_FILE, 'utf-8'); + +/** + * Read version from package.json. + */ +function getVersion() { + const pkg = JSON.parse(fs.readFileSync(PACKAGE_FILE, 'utf-8')); + return pkg.version; +} + +/** + * Read stable version from package.json. + */ +function getStableVersion() { + const pkg = JSON.parse(fs.readFileSync(PACKAGE_FILE, 'utf-8')); + return pkg.stableVersion || ''; +} + +/** + * Format date based on language. + */ +function formatDate(date, lang) { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + + if (lang === 'en') { + const months = ['January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December']; + return `${day} ${months[month - 1]} ${year}`; + } + return `${year} 年 ${month} 月 ${day} 日`; +} + +/** + * Convert heading text to URL-friendly slug. + * Removes leading section numbers (e.g., "3.3.5 Font" -> "font"). + */ +function slugify(text) { + return text.toLowerCase() + .replace(/^[\d.]+\s+/, '') + .replace(/[^\w\s-]/g, '') + .replace(/[\s_]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || 'section'; +} + +/** + * Extract all heading slugs from English Markdown content. + * Appendix headings are prefixed with "ref-" to avoid conflicts. + */ +function extractEnglishSlugs(content) { + const slugs = []; + const slugCounts = {}; + let inAppendix = false; + + for (const line of content.split('\n')) { + const match = line.match(/^(#{1,6})\s+(.+)$/); + if (!match) continue; + + const text = match[2].trim(); + if (text.startsWith('Appendix')) inAppendix = true; + + let slug = slugify(text); + if (inAppendix && !text.startsWith('Appendix')) { + slug = 'ref-' + slug; + } + + if (slugCounts[slug] !== undefined) { + slugCounts[slug]++; + slug = `${slug}-${slugCounts[slug]}`; + } else { + slugCounts[slug] = 0; + } + + slugs.push(slug); + } + return slugs; +} + +/** + * Extract headings from Markdown for TOC generation. + */ +function parseMarkdownHeadings(content, englishSlugs = null) { + const headings = []; + const slugCounts = {}; + let slugIndex = 0; + + for (const line of content.split('\n')) { + const match = line.match(/^(#{1,6})\s+(.+)$/); + if (!match) continue; + + const level = match[1].length; + const text = match[2].trim(); + let slug; + + if (englishSlugs && slugIndex < englishSlugs.length) { + slug = englishSlugs[slugIndex++]; + } else { + slug = slugify(text); + if (slugCounts[slug] !== undefined) { + slugCounts[slug]++; + slug = `${slug}-${slugCounts[slug]}`; + } else { + slugCounts[slug] = 0; + } + } + + headings.push({ level, text, slug }); + } + return headings; +} + +/** + * Generate HTML for table of contents. + */ +function generateTocHtml(headings) { + if (!headings.length) return ''; + + const html = ['
    ']; + const stack = [1]; + + for (let i = 0; i < headings.length; i++) { + const { level, text, slug } = headings[i]; + if (level === 1) continue; + + const adjustedLevel = level - 1; + + while (stack.length > adjustedLevel) { + html.push('
'); + stack.pop(); + } + while (stack.length < adjustedLevel) { + html.push('
    • '); + stack.push(stack.length + 1); + } + + html.push(`
    • ${text}`); + + if (i + 1 < headings.length && headings[i + 1].level > level) { + html.push('
        '); + stack.push(stack.length + 1); + } else { + html.push(''); + } + } + + while (stack.length > 1) { + html.push('
    • '); + stack.pop(); + } + + html.push('
    '); + return html.join('\n'); +} + +/** + * Create configured Marked instance. + */ +function createMarkedInstance() { + const slugCounts = {}; + + return new Marked( + markedHighlight({ + langPrefix: 'hljs language-', + highlight(code, lang) { + if (lang && hljs.getLanguage(lang)) { + return hljs.highlight(code, { language: lang }).value; + } + return hljs.highlightAuto(code).value; + }, + }), + gfmHeadingId({ + prefix: '', + slug: (text) => { + let slug = slugify(text); + if (slugCounts[slug] !== undefined) { + slugCounts[slug]++; + slug = `${slug}-${slugCounts[slug]}`; + } else { + slugCounts[slug] = 0; + } + return slug; + }, + }) + ); +} + +/** + * Replace heading IDs in HTML content with provided slugs. + */ +function replaceHeadingIds(html, slugs) { + let i = 0; + return html.replace(/ { + return i < slugs.length ? ` vars[key] || ''); +} + +/** + * Embed sample file contents into Markdown content. Replaces lines matching + * `> [Sample](samples/xxx.pagx)` with the file content as an xml code block + * followed by an HTML comment marker for preview button post-processing. + */ +function embedSampleFiles(mdContent, specDir) { + const samplePattern = /^> \[Sample\]\((samples\/[^\s)]+\.pagx)\)$/gm; + return mdContent.replace(samplePattern, (match, samplePath) => { + const filePath = path.join(specDir, samplePath); + if (!fs.existsSync(filePath)) { + console.warn(` Warning: sample file not found: ${filePath}`); + return match; + } + const content = fs.readFileSync(filePath, 'utf-8').trimEnd(); + return '```xml\n' + content + '\n```\n\n'; + }); +} + +/** + * Post-process HTML to add preview headers to code blocks. Locates each + * `` marker, finds the immediately preceding + * `
    ` block, and wraps it with a header containing a preview button.
    + * Processes markers from back to front so insertions don't shift positions.
    + */
    +function addPreviewButtons(html, viewerUrl, lang) {
    +  const markerPattern = //g;
    +  var markers = [];
    +  var m;
    +  while ((m = markerPattern.exec(html)) !== null) {
    +    markers.push({ start: m.index, end: m.index + m[0].length, samplePath: m[1] });
    +  }
    +  if (markers.length === 0) return html;
    +  // Process from back to front to keep positions stable.
    +  for (var i = markers.length - 1; i >= 0; i--) {
    +    var marker = markers[i];
    +    // Find the closest 
    before this marker. + var preCloseTag = ''; + var preCloseEnd = html.lastIndexOf(preCloseTag, marker.start); + if (preCloseEnd === -1) { + console.warn(' Warning: no found before preview marker for ' + marker.samplePath); + continue; + } + preCloseEnd += preCloseTag.length; + // Find the matching
     for this 
    . + var preOpenStart = html.lastIndexOf('
    ', preCloseEnd);
    +    if (preOpenStart === -1) {
    +      console.warn('  Warning: no 
     found before preview marker for ' + marker.samplePath);
    +      continue;
    +    }
    +    var previewUrl = viewerUrl + '?file=./' + marker.samplePath;
    +    var label = lang === 'zh' ? '预览' : 'Preview';
    +    var header = '
    ' + + '' + + '' + + label + '' + + 'PAGX
    '; + var wrapperOpen = '
    ' + header; + var wrapperClose = '
    '; + // Replace marker with wrapper close, then insert wrapper open before
    .
    +    html = html.slice(0, preCloseEnd) + wrapperClose + html.slice(marker.end);
    +    html = html.slice(0, preOpenStart) + wrapperOpen + html.slice(preOpenStart);
    +  }
    +  return html;
    +}
    +
    +/**
    + * Publish a single spec file.
    + */
    +function publishSpec(specFile, outputDir, lang, langSwitchUrl, viewerUrl, faviconUrl, englishSlugs = null) {
    +  if (!fs.existsSync(specFile)) {
    +    console.log(`  Skipped (file not found): ${specFile}`);
    +    return;
    +  }
    +
    +  console.log(`  Reading: ${specFile}`);
    +  let mdContent = fs.readFileSync(specFile, 'utf-8');
    +  mdContent = embedSampleFiles(mdContent, SPEC_DIR);
    +
    +  const titleMatch = mdContent.match(/^#\s+(.+)$/m);
    +  const title = titleMatch ? titleMatch[1] : 'PAGX Format Specification';
    +
    +  const headings = parseMarkdownHeadings(mdContent, englishSlugs);
    +  const tocHtml = generateTocHtml(headings);
    +
    +  const marked = createMarkedInstance();
    +  let htmlContent = marked.parse(mdContent);
    +
    +  if (englishSlugs) {
    +    htmlContent = replaceHeadingIds(htmlContent, englishSlugs);
    +  }
    +
    +  htmlContent = addPreviewButtons(htmlContent, viewerUrl, lang);
    +
    +  const html = generateHtml(htmlContent, title, tocHtml, lang, langSwitchUrl, viewerUrl, faviconUrl);
    +
    +  fs.mkdirSync(outputDir, { recursive: true });
    +  const outputFile = path.join(outputDir, 'index.html');
    +  fs.writeFileSync(outputFile, html, 'utf-8');
    +  console.log(`  Published: ${outputFile}`);
    +}
    +
    +/**
    + * Generate redirect page in latest folder.
    + */
    +function generateRedirectPage(siteDir, version) {
    +  const html = `
    +
    +
    +    
    +    
    +    PAGX Specification
    +    
    +
    +
    +    

    Redirecting to the latest specification...

    + +`; + + const latestDir = path.join(siteDir, 'latest'); + fs.mkdirSync(latestDir, { recursive: true }); + const outputFile = path.join(latestDir, 'index.html'); + fs.writeFileSync(outputFile, html, 'utf-8'); + console.log(` Generated: ${outputFile}`); +} + +/** + * Find all published versions in the site directory. + */ +function findPublishedVersions(siteDir) { + if (!fs.existsSync(siteDir)) return []; + + const entries = fs.readdirSync(siteDir, { withFileTypes: true }); + const versions = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + // Skip non-version directories + if (['latest', 'fonts', 'wasm-mt', 'node_modules', 'viewer'].includes(entry.name)) continue; + // Version directories must match semver pattern (e.g., 1.0, 0.5, 2.1.3) + if (!/^\d+\.\d+(\.\d+)?$/.test(entry.name)) continue; + // Check if it looks like a version (contains index.html) + const indexPath = path.join(siteDir, entry.name, 'index.html'); + if (fs.existsSync(indexPath)) { + versions.push(entry.name); + } + } + + return versions; +} + +/** + * Generate version info block HTML (left border style). + * - Stable (latest): green border + * - Draft: yellow border with draft warning + * - Outdated: red border with outdated warning + */ +function generateVersionInfoHtml(thisVersion, draftVersion, stableVersion, isZh, lastUpdated) { + const langSuffix = isZh ? 'zh/' : ''; + const thisUrl = `${BASE_URL}/${thisVersion}/${langSuffix}`; + const latestUrl = `${BASE_URL}/latest/`; + + // Check version status + const isOutdated = stableVersion && thisVersion !== stableVersion && + thisVersion.localeCompare(stableVersion, undefined, { numeric: true }) < 0; + const isDraft = !isOutdated && thisVersion !== stableVersion; + + // Determine status class and text + let statusClass, statusText; + if (isOutdated) { + statusClass = 'outdated'; + statusText = isZh + ? `版本 ${thisVersion} (过时) — 当前版本不是最新版本,请查阅最新版本获取最新内容。` + : `Version ${thisVersion} (Outdated) — This is not the latest version. Please refer to the latest version for up-to-date content.`; + } else if (isDraft) { + statusClass = 'draft'; + statusText = isZh + ? `版本 ${thisVersion} (草稿) — 本规范为草稿版本,内容可能随时变更。` + : `Version ${thisVersion} (Draft) — This specification is a working draft and may change at any time.`; + } else { + statusClass = 'stable'; + statusText = isZh ? `版本 ${thisVersion} (正式)` : `Version ${thisVersion} (Stable)`; + } + + // Generate labels + const labels = isZh + ? { updated: '最后更新:', this: '当前版本:', latest: '最新版本:', draft: '草稿版本:' } + : { updated: 'Last updated:', this: 'This version:', latest: 'Latest version:', draft: "Editor's draft:" }; + + // Generate links + let linksHtml = `

    ${labels.updated} ${lastUpdated}

    +

    ${labels.this} ${thisUrl}

    +

    ${labels.latest} ${latestUrl}

    `; + + if (draftVersion !== stableVersion) { + const draftUrl = `${BASE_URL}/${draftVersion}/${langSuffix}`; + linksHtml += `\n

    ${labels.draft} ${draftUrl}

    `; + } + + return `
    +
    ${statusText}
    +${linksHtml} +
    `; +} + +/** + * Update version links in an HTML file. + * Removes old version links block and inserts new one after the h1 title. + * Preserves the original "last updated" time from the existing HTML. + */ +function updateVersionLinks(htmlFile, thisVersion, draftVersion, stableVersion, isZh) { + if (!fs.existsSync(htmlFile)) return false; + + let html = fs.readFileSync(htmlFile, 'utf-8'); + + const lastUpdated = formatDate(new Date(), isZh ? 'zh' : 'en'); + + // Generate version info block + const versionInfoHtml = generateVersionInfoHtml(thisVersion, draftVersion, stableVersion, isZh, lastUpdated); + + + // Update CSS: replace old version-info styles with new ones (with background colors) + const versionInfoCss = `/* Version info block */ + .version-info { + border-left: 4px solid; + padding: 12px 16px; + margin-bottom: 24px; + } + .version-info.stable { + border-color: #1e7e34; + background-color: #f0f9f1; + } + .version-info.draft { + border-color: #d4a000; + background-color: #fffbf0; + } + .version-info.outdated { + border-color: #c42c2c; + background-color: #fef6f6; + } + .version-info .version-status { + font-weight: 600; + margin-bottom: 8px; + } + .version-info.stable .version-status { color: #1e5631; } + .version-info.draft .version-status { color: #6a4c00; } + .version-info.outdated .version-status { color: #82071e; } + .version-info p { margin-bottom: 4px; font-size: 14px; color: #555; }`; + + // Replace existing version-info CSS block or insert new one + if (html.includes('/* Version info block */')) { + html = html.replace( + /\/\* Version info block \*\/[\s\S]*?\.version-info p \{[^}]+\}/, + versionInfoCss + ); + } else { + // Insert after img styles + html = html.replace( + /(img \{[^}]+\})/, + `$1\n\n ${versionInfoCss}` + ); + } + + // Restore h1 border-bottom if it was removed + html = html.replace( + /h1 \{ font-size: 2em; margin-bottom: 16px; \}/, + 'h1 { font-size: 2em; padding-bottom: 0.3em; border-bottom: 1px solid var(--border-color); margin-bottom: 16px; }' + ); + + + // Pattern to match everything from h1 closing tag to first h2 opening tag + const versionBlockRegex = /(]*>)[^<]*(<\/h1>)[\s\S]*?(]*>([^<]*)<\/h1>/); + let title = titleMatch ? titleMatch[1] : ''; + + // Remove version number from title if present + title = title.replace(/\s+\d+\.\d+(\.\d+)?$/, ''); + + // Also update the h1 id + const cleanH1Open = h1Open.replace(/id="[^"]*"/, 'id="pagx-format-specification"'); + + // Structure: title -> version info block -> content + const newContent = `${cleanH1Open}${title}${h1Close}\n${versionInfoHtml}\n${h2Open}`; + html = html.replace(versionBlockRegex, newContent); + fs.writeFileSync(htmlFile, html, 'utf-8'); + return true; + } + return false; +} + +/** + * Update version links in all published versions. + */ +function updateAllVersionLinks(siteDir, draftVersion, stableVersion) { + const versions = findPublishedVersions(siteDir); + if (versions.length === 0) return; + + console.log('\nUpdating version links in all versions...'); + + for (const ver of versions) { + const enFile = path.join(siteDir, ver, 'index.html'); + const zhFile = path.join(siteDir, ver, 'zh', 'index.html'); + + if (updateVersionLinks(enFile, ver, draftVersion, stableVersion, false)) { + console.log(` Updated: ${enFile}`); + } + if (updateVersionLinks(zhFile, ver, draftVersion, stableVersion, true)) { + console.log(` Updated: ${zhFile}`); + } + } +} + +/** + * Parse command line arguments. + */ +function parseArgs() { + const args = process.argv.slice(2); + const options = { siteDir: DEFAULT_SITE_DIR }; + + for (let i = 0; i < args.length; i++) { + if ((args[i] === '-o' || args[i] === '--output') && args[i + 1]) { + options.siteDir = path.resolve(args[++i]); + } else if (args[i] === '-h' || args[i] === '--help') { + console.log(` +PAGX Specification Publisher + +Usage: npm run publish [-- -o ] + +Options: + -o, --output Output directory (default: ../public) + -h, --help Show this help message +`); + process.exit(0); + } + } + return options; +} + +/** + * Main function. + */ +function main() { + const { siteDir } = parseArgs(); + const version = getVersion(); + const stableVersion = getStableVersion(); + + console.log(`Version: ${version}`); + console.log(`Stable: ${stableVersion || '(none)'}`); + console.log(`Output: ${siteDir}`); + + const baseOutputDir = path.join(siteDir, version); + const viewerUrlFromRoot = '../'; + const viewerUrlFromZh = '../../'; + const faviconUrlFromRoot = '../favicon.png'; + const faviconUrlFromZh = '../../favicon.png'; + + let englishSlugs = null; + if (fs.existsSync(SPEC_FILE_EN)) { + englishSlugs = extractEnglishSlugs(fs.readFileSync(SPEC_FILE_EN, 'utf-8')); + console.log(`\nExtracted ${englishSlugs.length} heading slugs from English version`); + } + + console.log('\nPublishing English version...'); + publishSpec(SPEC_FILE_EN, baseOutputDir, 'en', 'zh/', viewerUrlFromRoot, faviconUrlFromRoot, englishSlugs); + + console.log('\nPublishing Chinese version...'); + publishSpec(SPEC_FILE_ZH, path.join(baseOutputDir, 'zh'), 'zh', '../', viewerUrlFromZh, faviconUrlFromZh, englishSlugs); + + console.log('\nGenerating redirect page...'); + console.log(` Redirect to: ${stableVersion || version}`); + generateRedirectPage(siteDir, stableVersion || version); + + // Update version links in all published versions + updateAllVersionLinks(siteDir, version, stableVersion); + + console.log('\nCopying favicon...'); + fs.copyFileSync(path.join(SPEC_DIR, 'favicon.png'), path.join(siteDir, 'favicon.png')); + console.log(` Copied: ${path.join(siteDir, 'favicon.png')}`); + + console.log('\nDone!'); +} + +main(); diff --git a/spec/script/template.html b/spec/script/template.html new file mode 100644 index 0000000000..3514259b62 --- /dev/null +++ b/spec/script/template.html @@ -0,0 +1,414 @@ + + + + + + {{title}} + + + + + +
    + + + + {{viewerLabel}} + +
    + + +
    +
    + +
    + +
    +{{content}} +
    +
    + + + + diff --git a/src/pagx/Data.cpp b/src/pagx/Data.cpp new file mode 100644 index 0000000000..ab5a458f7f --- /dev/null +++ b/src/pagx/Data.cpp @@ -0,0 +1,58 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#include "pagx/types/Data.h" +#include +#include + +namespace pagx { + +std::shared_ptr Data::MakeWithCopy(const void* data, size_t length) { + if (data == nullptr || length == 0) { + return nullptr; + } + return std::shared_ptr(new Data(data, length)); +} + +std::shared_ptr Data::MakeAdopt(uint8_t* data, size_t length) { + if (data == nullptr || length == 0) { + return nullptr; + } + return std::shared_ptr(new Data(data, length, true)); +} + +Data::Data(const void* data, size_t length) : _size(length) { + if (data != nullptr && length > 0) { + auto* buffer = new (std::nothrow) uint8_t[length]; + if (buffer != nullptr) { + std::memcpy(buffer, data, length); + _data = buffer; + } else { + _size = 0; + } + } +} + +Data::Data(uint8_t* data, size_t length, bool) : _data(data), _size(length) { +} + +Data::~Data() { + delete[] _data; +} + +} // namespace pagx diff --git a/src/pagx/PAGXDocument.cpp b/src/pagx/PAGXDocument.cpp new file mode 100644 index 0000000000..491dd0d102 --- /dev/null +++ b/src/pagx/PAGXDocument.cpp @@ -0,0 +1,86 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#include "pagx/PAGXDocument.h" +#include +#include "pagx/nodes/Image.h" + +namespace pagx { + +std::shared_ptr PAGXDocument::Make(float docWidth, float docHeight) { + auto doc = std::shared_ptr(new PAGXDocument()); + doc->width = docWidth; + doc->height = docHeight; + return doc; +} + +Node* PAGXDocument::findNode(const std::string& id) const { + auto it = nodeMap.find(id); + return it != nodeMap.end() ? it->second : nullptr; +} + +void PAGXDocument::registerNode(Node* node, const std::string& id) { + if (id.empty()) { + return; + } + auto it = nodeMap.find(id); + if (it != nodeMap.end()) { + fprintf(stderr, "PAGXDocument::makeNode(): Duplicate node id '%s', overwriting.\n", id.c_str()); + } + node->id = id; + nodeMap[id] = node; +} + +std::vector PAGXDocument::getExternalFilePaths() const { + std::vector paths = {}; + for (auto& node : nodes) { + if (node->nodeType() != NodeType::Image) { + continue; + } + auto* image = static_cast(node.get()); + if (image->data != nullptr || image->filePath.empty()) { + continue; + } + if (image->filePath.find("data:") == 0) { + continue; + } + paths.push_back(image->filePath); + } + return paths; +} + +bool PAGXDocument::loadFileData(const std::string& filePath, std::shared_ptr data) { + if (filePath.empty() || data == nullptr) { + return false; + } + for (auto& node : nodes) { + if (node->nodeType() != NodeType::Image) { + continue; + } + auto* image = static_cast(node.get()); + if (image->filePath != filePath) { + continue; + } + image->data = std::move(data); + image->filePath = {}; + return true; + } + return false; +} + +} // namespace pagx diff --git a/src/pagx/PAGXExporter.cpp b/src/pagx/PAGXExporter.cpp new file mode 100644 index 0000000000..f4090a777d --- /dev/null +++ b/src/pagx/PAGXExporter.cpp @@ -0,0 +1,1120 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#include "pagx/PAGXExporter.h" +#include +#include "utils/Base64.h" +#include "utils/StringParser.h" +#include "svg/SVGPathParser.h" +#include "pagx/PAGXDocument.h" +#include "pagx/nodes/BackgroundBlurStyle.h" +#include "pagx/nodes/BlendFilter.h" +#include "pagx/nodes/BlurFilter.h" +#include "pagx/nodes/ColorMatrixFilter.h" +#include "pagx/nodes/Composition.h" +#include "pagx/nodes/ConicGradient.h" +#include "pagx/nodes/DiamondGradient.h" +#include "pagx/nodes/DropShadowFilter.h" +#include "pagx/nodes/DropShadowStyle.h" +#include "pagx/nodes/Ellipse.h" +#include "pagx/nodes/Fill.h" +#include "pagx/nodes/Font.h" +#include "pagx/nodes/GlyphRun.h" +#include "pagx/nodes/Group.h" +#include "pagx/nodes/Image.h" +#include "pagx/nodes/ImagePattern.h" +#include "pagx/nodes/InnerShadowFilter.h" +#include "pagx/nodes/InnerShadowStyle.h" +#include "pagx/nodes/LinearGradient.h" +#include "pagx/nodes/MergePath.h" +#include "pagx/nodes/Path.h" +#include "pagx/nodes/Polystar.h" +#include "pagx/nodes/RadialGradient.h" +#include "pagx/nodes/RangeSelector.h" +#include "pagx/nodes/Rectangle.h" +#include "pagx/nodes/Repeater.h" +#include "pagx/nodes/RoundCorner.h" +#include "pagx/nodes/SolidColor.h" +#include "pagx/nodes/Stroke.h" +#include "pagx/nodes/Text.h" +#include "pagx/nodes/TextLayout.h" +#include "pagx/nodes/TextModifier.h" +#include "pagx/nodes/TextPath.h" +#include "pagx/nodes/TrimPath.h" + +namespace pagx { + +//============================================================================== +// XMLBuilder - XML generation helper +//============================================================================== + +class XMLBuilder { + public: + XMLBuilder() { + tagStack.reserve(32); + } + + void appendDeclaration() { + buffer += "\n"; + } + + void openElement(const char* tag) { + writeIndent(); + buffer += "<"; + buffer += tag; + tagStack.push_back(tag); + } + + void addAttribute(const char* name, const std::string& value) { + if (!value.empty()) { + buffer += " "; + buffer += name; + buffer += "=\""; + buffer += escapeXML(value); + buffer += "\""; + } + } + + void addAttribute(const char* name, float value, float defaultValue = 0) { + if (value != defaultValue) { + buffer += " "; + buffer += name; + buffer += "=\""; + buffer += FloatToString(value); + buffer += "\""; + } + } + + void addRequiredAttribute(const char* name, float value) { + buffer += " "; + buffer += name; + buffer += "=\""; + buffer += FloatToString(value); + buffer += "\""; + } + + void addRequiredAttribute(const char* name, const std::string& value) { + buffer += " "; + buffer += name; + buffer += "=\""; + buffer += escapeXML(value); + buffer += "\""; + } + + void addAttribute(const char* name, int value, int defaultValue = 0) { + if (value != defaultValue) { + buffer += " "; + buffer += name; + buffer += "=\""; + buffer += std::to_string(value); + buffer += "\""; + } + } + + void addAttribute(const char* name, bool value, bool defaultValue = false) { + if (value != defaultValue) { + buffer += " "; + buffer += name; + buffer += "=\""; + buffer += (value ? "true" : "false"); + buffer += "\""; + } + } + + void closeElementStart() { + buffer += ">\n"; + indentLevel++; + } + + void closeElementSelfClosing() { + buffer += "/>\n"; + tagStack.pop_back(); + } + + void closeElement() { + indentLevel--; + writeIndent(); + buffer += "\n"; + tagStack.pop_back(); + } + + std::string release() { + return std::move(buffer); + } + + private: + std::string buffer = {}; + std::vector tagStack = {}; + int indentLevel = 0; + + void writeIndent() { + buffer.append(static_cast(indentLevel * 2), ' '); + } + + static std::string escapeXML(const std::string& input) { + size_t extraSize = 0; + for (char c : input) { + switch (c) { + case '&': extraSize += 4; break; + case '<': extraSize += 3; break; + case '"': extraSize += 5; break; + case '\'': extraSize += 5; break; + default: break; + } + } + if (extraSize == 0) { + return input; + } + std::string result; + result.reserve(input.size() + extraSize); + for (char c : input) { + switch (c) { + case '&': result += "&"; break; + case '<': result += "<"; break; + case '"': result += """; break; + case '\'': result += "'"; break; + default: result += c; break; + } + } + return result; + } +}; + +//============================================================================== +// Helper functions for converting types to strings +//============================================================================== + +static std::string pointToString(const Point& p) { + char buf[64] = {}; + snprintf(buf, sizeof(buf), "%g,%g", p.x, p.y); + return buf; +} + +static std::string sizeToString(const Size& s) { + char buf[64] = {}; + snprintf(buf, sizeof(buf), "%g,%g", s.width, s.height); + return buf; +} + +static std::string rectToString(const Rect& r) { + char buf[128] = {}; + snprintf(buf, sizeof(buf), "%g,%g,%g,%g", r.x, r.y, r.width, r.height); + return buf; +} + +static std::string floatListToString(const float* values, size_t count) { + std::string result; + result.reserve(count * 8); + for (size_t i = 0; i < count; i++) { + if (i > 0) { + result += ","; + } + result += FloatToString(values[i]); + } + return result; +} + +static std::string floatListToString(const std::vector& values) { + return floatListToString(values.data(), values.size()); +} + +static std::string pointListToString(const std::vector& points) { + std::string result = {}; + result.reserve(points.size() * 12); + for (size_t i = 0; i < points.size(); i++) { + if (i > 0) { + result += ";"; + } + result += FloatToString(points[i].x) + "," + FloatToString(points[i].y); + } + return result; +} + +//============================================================================== +// Forward declarations +//============================================================================== + +using Options = PAGXExporter::Options; + +static void writeColorSource(XMLBuilder& xml, const ColorSource* node); +static void writeVectorElement(XMLBuilder& xml, const Element* node, const Options& options); +static void writeLayerStyle(XMLBuilder& xml, const LayerStyle* node); +static void writeLayerFilter(XMLBuilder& xml, const LayerFilter* node); +static void writeResource(XMLBuilder& xml, const Node* node, const Options& options); +static void writeLayer(XMLBuilder& xml, const Layer* node, const Options& options); + +//============================================================================== +// ColorStop and ColorSource writing +//============================================================================== + +static bool writeColorAttribute(XMLBuilder& xml, const ColorSource* color) { + if (!color) { + return false; + } + if (!color->id.empty()) { + xml.addAttribute("color", "@" + color->id); + return false; + } + if (color->nodeType() == NodeType::SolidColor) { + auto solid = static_cast(color); + xml.addAttribute("color", ColorToHexString(solid->color, solid->color.alpha < 1.0f)); + return false; + } + return true; +} + +static void writeColorStops(XMLBuilder& xml, const std::vector& stops) { + for (const auto& stop : stops) { + xml.openElement("ColorStop"); + xml.addRequiredAttribute("offset", stop.offset); + xml.addRequiredAttribute("color", ColorToHexString(stop.color, stop.color.alpha < 1.0f)); + xml.closeElementSelfClosing(); + } +} + +static void writeGradientMatrixAndStops(XMLBuilder& xml, const Matrix& matrix, + const std::vector& colorStops) { + if (!matrix.isIdentity()) { + xml.addAttribute("matrix", MatrixToString(matrix)); + } + if (colorStops.empty()) { + xml.closeElementSelfClosing(); + } else { + xml.closeElementStart(); + writeColorStops(xml, colorStops); + xml.closeElement(); + } +} + +static void writeColorSource(XMLBuilder& xml, const ColorSource* node) { + switch (node->nodeType()) { + case NodeType::SolidColor: { + auto solid = static_cast(node); + xml.openElement("SolidColor"); + xml.addAttribute("id", solid->id); + xml.addAttribute("color", ColorToHexString(solid->color, solid->color.alpha < 1.0f)); + xml.closeElementSelfClosing(); + break; + } + case NodeType::LinearGradient: { + auto grad = static_cast(node); + xml.openElement("LinearGradient"); + xml.addAttribute("id", grad->id); + if (grad->startPoint.x != 0 || grad->startPoint.y != 0) { + xml.addAttribute("startPoint", pointToString(grad->startPoint)); + } + xml.addRequiredAttribute("endPoint", pointToString(grad->endPoint)); + writeGradientMatrixAndStops(xml, grad->matrix, grad->colorStops); + break; + } + case NodeType::RadialGradient: { + auto grad = static_cast(node); + xml.openElement("RadialGradient"); + xml.addAttribute("id", grad->id); + if (grad->center.x != 0 || grad->center.y != 0) { + xml.addAttribute("center", pointToString(grad->center)); + } + xml.addRequiredAttribute("radius", grad->radius); + writeGradientMatrixAndStops(xml, grad->matrix, grad->colorStops); + break; + } + case NodeType::ConicGradient: { + auto grad = static_cast(node); + xml.openElement("ConicGradient"); + xml.addAttribute("id", grad->id); + if (grad->center.x != 0 || grad->center.y != 0) { + xml.addAttribute("center", pointToString(grad->center)); + } + xml.addAttribute("startAngle", grad->startAngle); + xml.addAttribute("endAngle", grad->endAngle, 360.0f); + writeGradientMatrixAndStops(xml, grad->matrix, grad->colorStops); + break; + } + case NodeType::DiamondGradient: { + auto grad = static_cast(node); + xml.openElement("DiamondGradient"); + xml.addAttribute("id", grad->id); + if (grad->center.x != 0 || grad->center.y != 0) { + xml.addAttribute("center", pointToString(grad->center)); + } + xml.addRequiredAttribute("radius", grad->radius); + writeGradientMatrixAndStops(xml, grad->matrix, grad->colorStops); + break; + } + case NodeType::ImagePattern: { + auto pattern = static_cast(node); + xml.openElement("ImagePattern"); + xml.addAttribute("id", pattern->id); + if (pattern->image != nullptr && !pattern->image->id.empty()) { + xml.addAttribute("image", "@" + pattern->image->id); + } + if (pattern->tileModeX != TileMode::Clamp) { + xml.addAttribute("tileModeX", TileModeToString(pattern->tileModeX)); + } + if (pattern->tileModeY != TileMode::Clamp) { + xml.addAttribute("tileModeY", TileModeToString(pattern->tileModeY)); + } + if (pattern->filterMode != FilterMode::Linear) { + xml.addAttribute("filterMode", FilterModeToString(pattern->filterMode)); + } + if (pattern->mipmapMode != MipmapMode::Linear) { + xml.addAttribute("mipmapMode", MipmapModeToString(pattern->mipmapMode)); + } + if (!pattern->matrix.isIdentity()) { + xml.addAttribute("matrix", MatrixToString(pattern->matrix)); + } + xml.closeElementSelfClosing(); + break; + } + default: + break; + } +} + +//============================================================================== +// VectorElement writing +//============================================================================== + +static void writeVectorElement(XMLBuilder& xml, const Element* node, const Options& options) { + switch (node->nodeType()) { + case NodeType::Rectangle: { + auto rect = static_cast(node); + xml.openElement("Rectangle"); + if (rect->center.x != 0 || rect->center.y != 0) { + xml.addAttribute("center", pointToString(rect->center)); + } + if (rect->size.width != 100 || rect->size.height != 100) { + xml.addAttribute("size", sizeToString(rect->size)); + } + xml.addAttribute("roundness", rect->roundness); + xml.addAttribute("reversed", rect->reversed); + xml.closeElementSelfClosing(); + break; + } + case NodeType::Ellipse: { + auto ellipse = static_cast(node); + xml.openElement("Ellipse"); + if (ellipse->center.x != 0 || ellipse->center.y != 0) { + xml.addAttribute("center", pointToString(ellipse->center)); + } + if (ellipse->size.width != 100 || ellipse->size.height != 100) { + xml.addAttribute("size", sizeToString(ellipse->size)); + } + xml.addAttribute("reversed", ellipse->reversed); + xml.closeElementSelfClosing(); + break; + } + case NodeType::Polystar: { + auto polystar = static_cast(node); + xml.openElement("Polystar"); + if (polystar->center.x != 0 || polystar->center.y != 0) { + xml.addAttribute("center", pointToString(polystar->center)); + } + xml.addAttribute("type", PolystarTypeToString(polystar->type)); + xml.addAttribute("pointCount", polystar->pointCount, 5.0f); + xml.addAttribute("outerRadius", polystar->outerRadius, 100.0f); + xml.addAttribute("innerRadius", polystar->innerRadius, 50.0f); + xml.addAttribute("rotation", polystar->rotation); + xml.addAttribute("outerRoundness", polystar->outerRoundness); + xml.addAttribute("innerRoundness", polystar->innerRoundness); + xml.addAttribute("reversed", polystar->reversed); + xml.closeElementSelfClosing(); + break; + } + case NodeType::Path: { + auto path = static_cast(node); + xml.openElement("Path"); + if (path->data != nullptr && !path->data->id.empty()) { + // Use the reference to PathData resource. + xml.addAttribute("data", "@" + path->data->id); + } else if (path->data != nullptr && !path->data->isEmpty()) { + // Inline the path data. + xml.addAttribute("data", PathDataToSVGString(*path->data)); + } + xml.addAttribute("reversed", path->reversed); + xml.closeElementSelfClosing(); + break; + } + case NodeType::Text: { + auto text = static_cast(node); + xml.openElement("Text"); + if (!text->text.empty()) { + xml.addAttribute("text", text->text); + } + if (text->position.x != 0 || text->position.y != 0) { + xml.addAttribute("position", pointToString(text->position)); + } + xml.addAttribute("fontFamily", text->fontFamily); + if (!text->fontStyle.empty()) { + xml.addAttribute("fontStyle", text->fontStyle); + } + xml.addAttribute("fontSize", text->fontSize, 12.0f); + xml.addAttribute("letterSpacing", text->letterSpacing); + xml.addAttribute("baselineShift", text->baselineShift); + + // Skip GlyphRuns if requested or if none exist + if (options.skipGlyphData || text->glyphRuns.empty()) { + xml.closeElementSelfClosing(); + } else { + xml.closeElementStart(); + for (const auto& run : text->glyphRuns) { + xml.openElement("GlyphRun"); + if (run->font != nullptr && !run->font->id.empty()) { + xml.addAttribute("font", "@" + run->font->id); + } + xml.addAttribute("fontSize", run->fontSize, 12.0f); + + // Write glyphs as comma-separated integers + if (!run->glyphs.empty()) { + std::string glyphsStr = {}; + glyphsStr.reserve(run->glyphs.size() * 6); + for (size_t i = 0; i < run->glyphs.size(); i++) { + if (i > 0) { + glyphsStr += ","; + } + glyphsStr += std::to_string(run->glyphs[i]); + } + xml.addRequiredAttribute("glyphs", glyphsStr); + } + + // Write x/y overall offsets (only if non-zero) + xml.addAttribute("x", run->x, 0.0f); + xml.addAttribute("y", run->y, 0.0f); + + // Write xOffsets (comma-separated) + if (!run->xOffsets.empty()) { + xml.addRequiredAttribute("xOffsets", floatListToString(run->xOffsets)); + } + + // Write positions (semicolon-separated x,y pairs) + if (!run->positions.empty()) { + xml.addRequiredAttribute("positions", pointListToString(run->positions)); + } + + // Write anchors (semicolon-separated x,y pairs) + if (!run->anchors.empty()) { + xml.addRequiredAttribute("anchors", pointListToString(run->anchors)); + } + + // Write scales (semicolon-separated sx,sy pairs) + if (!run->scales.empty()) { + xml.addRequiredAttribute("scales", pointListToString(run->scales)); + } + + // Write rotations (comma-separated angles in degrees) + if (!run->rotations.empty()) { + xml.addRequiredAttribute("rotations", floatListToString(run->rotations)); + } + + // Write skews (comma-separated angles in degrees) + if (!run->skews.empty()) { + xml.addRequiredAttribute("skews", floatListToString(run->skews)); + } + + xml.closeElementSelfClosing(); + } + xml.closeElement(); + } + break; + } + case NodeType::Fill: { + auto fill = static_cast(node); + xml.openElement("Fill"); + bool needsInlineColorSource = writeColorAttribute(xml, fill->color); + xml.addAttribute("alpha", fill->alpha, 1.0f); + if (fill->blendMode != BlendMode::Normal) { + xml.addAttribute("blendMode", BlendModeToString(fill->blendMode)); + } + if (fill->fillRule != FillRule::Winding) { + xml.addAttribute("fillRule", FillRuleToString(fill->fillRule)); + } + if (fill->placement != LayerPlacement::Background) { + xml.addAttribute("placement", LayerPlacementToString(fill->placement)); + } + if (needsInlineColorSource) { + xml.closeElementStart(); + writeColorSource(xml, fill->color); + xml.closeElement(); + } else { + xml.closeElementSelfClosing(); + } + break; + } + case NodeType::Stroke: { + auto stroke = static_cast(node); + xml.openElement("Stroke"); + bool needsInlineColorSource = writeColorAttribute(xml, stroke->color); + xml.addAttribute("width", stroke->width, 1.0f); + xml.addAttribute("alpha", stroke->alpha, 1.0f); + if (stroke->blendMode != BlendMode::Normal) { + xml.addAttribute("blendMode", BlendModeToString(stroke->blendMode)); + } + if (stroke->cap != LineCap::Butt) { + xml.addAttribute("cap", LineCapToString(stroke->cap)); + } + if (stroke->join != LineJoin::Miter) { + xml.addAttribute("join", LineJoinToString(stroke->join)); + } + xml.addAttribute("miterLimit", stroke->miterLimit, 4.0f); + if (!stroke->dashes.empty()) { + xml.addAttribute("dashes", floatListToString(stroke->dashes)); + } + xml.addAttribute("dashOffset", stroke->dashOffset); + xml.addAttribute("dashAdaptive", stroke->dashAdaptive); + if (stroke->align != StrokeAlign::Center) { + xml.addAttribute("align", StrokeAlignToString(stroke->align)); + } + if (stroke->placement != LayerPlacement::Background) { + xml.addAttribute("placement", LayerPlacementToString(stroke->placement)); + } + if (needsInlineColorSource) { + xml.closeElementStart(); + writeColorSource(xml, stroke->color); + xml.closeElement(); + } else { + xml.closeElementSelfClosing(); + } + break; + } + case NodeType::TrimPath: { + auto trim = static_cast(node); + xml.openElement("TrimPath"); + xml.addAttribute("start", trim->start); + xml.addAttribute("end", trim->end, 1.0f); + xml.addAttribute("offset", trim->offset); + if (trim->type != TrimType::Separate) { + xml.addAttribute("type", TrimTypeToString(trim->type)); + } + xml.closeElementSelfClosing(); + break; + } + case NodeType::RoundCorner: { + auto round = static_cast(node); + xml.openElement("RoundCorner"); + xml.addAttribute("radius", round->radius, 10.0f); + xml.closeElementSelfClosing(); + break; + } + case NodeType::MergePath: { + auto merge = static_cast(node); + xml.openElement("MergePath"); + if (merge->mode != MergePathMode::Append) { + xml.addAttribute("mode", MergePathModeToString(merge->mode)); + } + xml.closeElementSelfClosing(); + break; + } + case NodeType::TextModifier: { + auto modifier = static_cast(node); + xml.openElement("TextModifier"); + if (modifier->anchor.x != 0 || modifier->anchor.y != 0) { + xml.addAttribute("anchor", pointToString(modifier->anchor)); + } + if (modifier->position.x != 0 || modifier->position.y != 0) { + xml.addAttribute("position", pointToString(modifier->position)); + } + xml.addAttribute("rotation", modifier->rotation); + if (modifier->scale.x != 1 || modifier->scale.y != 1) { + xml.addAttribute("scale", pointToString(modifier->scale)); + } + xml.addAttribute("skew", modifier->skew); + xml.addAttribute("skewAxis", modifier->skewAxis); + xml.addAttribute("alpha", modifier->alpha, 1.0f); + if (modifier->fillColor.has_value()) { + xml.addAttribute("fillColor", ColorToHexString(modifier->fillColor.value())); + } + if (modifier->strokeColor.has_value()) { + xml.addAttribute("strokeColor", ColorToHexString(modifier->strokeColor.value())); + } + if (modifier->strokeWidth.has_value()) { + xml.addAttribute("strokeWidth", modifier->strokeWidth.value()); + } + if (modifier->selectors.empty()) { + xml.closeElementSelfClosing(); + } else { + xml.closeElementStart(); + for (const auto& selector : modifier->selectors) { + if (selector->nodeType() != NodeType::RangeSelector) { + continue; + } + auto rangeSelector = static_cast(selector); + xml.openElement("RangeSelector"); + xml.addAttribute("start", rangeSelector->start); + xml.addAttribute("end", rangeSelector->end, 1.0f); + xml.addAttribute("offset", rangeSelector->offset); + if (rangeSelector->unit != SelectorUnit::Percentage) { + xml.addAttribute("unit", SelectorUnitToString(rangeSelector->unit)); + } + if (rangeSelector->shape != SelectorShape::Square) { + xml.addAttribute("shape", SelectorShapeToString(rangeSelector->shape)); + } + xml.addAttribute("easeIn", rangeSelector->easeIn); + xml.addAttribute("easeOut", rangeSelector->easeOut); + if (rangeSelector->mode != SelectorMode::Add) { + xml.addAttribute("mode", SelectorModeToString(rangeSelector->mode)); + } + xml.addAttribute("weight", rangeSelector->weight, 1.0f); + xml.addAttribute("randomOrder", rangeSelector->randomOrder); + xml.addAttribute("randomSeed", rangeSelector->randomSeed); + xml.closeElementSelfClosing(); + } + xml.closeElement(); + } + break; + } + case NodeType::TextPath: { + auto textPath = static_cast(node); + xml.openElement("TextPath"); + if (textPath->path != nullptr && !textPath->path->id.empty()) { + // Use the reference to PathData resource. + xml.addAttribute("path", "@" + textPath->path->id); + } else if (textPath->path != nullptr && !textPath->path->isEmpty()) { + // Inline the path data. + xml.addAttribute("path", PathDataToSVGString(*textPath->path)); + } + if (textPath->baselineOrigin.x != 0 || textPath->baselineOrigin.y != 0) { + xml.addAttribute("baselineOrigin", pointToString(textPath->baselineOrigin)); + } + xml.addAttribute("baselineAngle", textPath->baselineAngle); + xml.addAttribute("firstMargin", textPath->firstMargin); + xml.addAttribute("lastMargin", textPath->lastMargin); + xml.addAttribute("perpendicular", textPath->perpendicular, true); + xml.addAttribute("reversed", textPath->reversed); + xml.addAttribute("forceAlignment", textPath->forceAlignment); + xml.closeElementSelfClosing(); + break; + } + case NodeType::TextLayout: { + auto layout = static_cast(node); + xml.openElement("TextLayout"); + if (layout->position.x != 0 || layout->position.y != 0) { + xml.addAttribute("position", pointToString(layout->position)); + } + xml.addAttribute("width", layout->width); + xml.addAttribute("height", layout->height); + if (layout->textAlign != TextAlign::Start) { + xml.addAttribute("textAlign", TextAlignToString(layout->textAlign)); + } + if (layout->verticalAlign != VerticalAlign::Top) { + xml.addAttribute("verticalAlign", VerticalAlignToString(layout->verticalAlign)); + } + if (layout->writingMode != WritingMode::Horizontal) { + xml.addAttribute("writingMode", WritingModeToString(layout->writingMode)); + } + xml.addAttribute("lineHeight", layout->lineHeight, 1.2f); + xml.closeElementSelfClosing(); + break; + } + case NodeType::Repeater: { + auto repeater = static_cast(node); + xml.openElement("Repeater"); + xml.addAttribute("copies", repeater->copies, 3.0f); + xml.addAttribute("offset", repeater->offset); + if (repeater->order != RepeaterOrder::BelowOriginal) { + xml.addAttribute("order", RepeaterOrderToString(repeater->order)); + } + if (repeater->anchor.x != 0 || repeater->anchor.y != 0) { + xml.addAttribute("anchor", pointToString(repeater->anchor)); + } + if (repeater->position.x != 100 || repeater->position.y != 100) { + xml.addAttribute("position", pointToString(repeater->position)); + } + xml.addAttribute("rotation", repeater->rotation); + if (repeater->scale.x != 1 || repeater->scale.y != 1) { + xml.addAttribute("scale", pointToString(repeater->scale)); + } + xml.addAttribute("startAlpha", repeater->startAlpha, 1.0f); + xml.addAttribute("endAlpha", repeater->endAlpha, 1.0f); + xml.closeElementSelfClosing(); + break; + } + case NodeType::Group: { + auto group = static_cast(node); + xml.openElement("Group"); + if (group->anchor.x != 0 || group->anchor.y != 0) { + xml.addAttribute("anchor", pointToString(group->anchor)); + } + if (group->position.x != 0 || group->position.y != 0) { + xml.addAttribute("position", pointToString(group->position)); + } + xml.addAttribute("rotation", group->rotation); + if (group->scale.x != 1 || group->scale.y != 1) { + xml.addAttribute("scale", pointToString(group->scale)); + } + xml.addAttribute("skew", group->skew); + xml.addAttribute("skewAxis", group->skewAxis); + xml.addAttribute("alpha", group->alpha, 1.0f); + if (group->elements.empty()) { + xml.closeElementSelfClosing(); + } else { + xml.closeElementStart(); + for (const auto& element : group->elements) { + writeVectorElement(xml, element, options); + } + xml.closeElement(); + } + break; + } + default: + break; + } +} + +//============================================================================== +// LayerStyle writing +//============================================================================== + +static void writeShadowAttributes(XMLBuilder& xml, float offsetX, float offsetY, float blurX, + float blurY, const Color& color) { + xml.addAttribute("offsetX", offsetX); + xml.addAttribute("offsetY", offsetY); + xml.addAttribute("blurX", blurX); + xml.addAttribute("blurY", blurY); + xml.addAttribute("color", ColorToHexString(color, color.alpha < 1.0f)); +} + +static void writeLayerStyle(XMLBuilder& xml, const LayerStyle* node) { + switch (node->nodeType()) { + case NodeType::DropShadowStyle: { + auto style = static_cast(node); + xml.openElement("DropShadowStyle"); + if (style->blendMode != BlendMode::Normal) { + xml.addAttribute("blendMode", BlendModeToString(style->blendMode)); + } + writeShadowAttributes(xml, style->offsetX, style->offsetY, style->blurX, style->blurY, + style->color); + xml.addAttribute("showBehindLayer", style->showBehindLayer, true); + xml.closeElementSelfClosing(); + break; + } + case NodeType::InnerShadowStyle: { + auto style = static_cast(node); + xml.openElement("InnerShadowStyle"); + if (style->blendMode != BlendMode::Normal) { + xml.addAttribute("blendMode", BlendModeToString(style->blendMode)); + } + writeShadowAttributes(xml, style->offsetX, style->offsetY, style->blurX, style->blurY, + style->color); + xml.closeElementSelfClosing(); + break; + } + case NodeType::BackgroundBlurStyle: { + auto style = static_cast(node); + xml.openElement("BackgroundBlurStyle"); + if (style->blendMode != BlendMode::Normal) { + xml.addAttribute("blendMode", BlendModeToString(style->blendMode)); + } + xml.addAttribute("blurX", style->blurX); + xml.addAttribute("blurY", style->blurY); + if (style->tileMode != TileMode::Mirror) { + xml.addAttribute("tileMode", TileModeToString(style->tileMode)); + } + xml.closeElementSelfClosing(); + break; + } + default: + break; + } +} + +//============================================================================== +// LayerFilter writing +//============================================================================== + +static void writeLayerFilter(XMLBuilder& xml, const LayerFilter* node) { + switch (node->nodeType()) { + case NodeType::BlurFilter: { + auto filter = static_cast(node); + xml.openElement("BlurFilter"); + xml.addRequiredAttribute("blurX", filter->blurX); + xml.addRequiredAttribute("blurY", filter->blurY); + if (filter->tileMode != TileMode::Decal) { + xml.addAttribute("tileMode", TileModeToString(filter->tileMode)); + } + xml.closeElementSelfClosing(); + break; + } + case NodeType::DropShadowFilter: { + auto filter = static_cast(node); + xml.openElement("DropShadowFilter"); + writeShadowAttributes(xml, filter->offsetX, filter->offsetY, filter->blurX, filter->blurY, + filter->color); + xml.addAttribute("shadowOnly", filter->shadowOnly); + xml.closeElementSelfClosing(); + break; + } + case NodeType::InnerShadowFilter: { + auto filter = static_cast(node); + xml.openElement("InnerShadowFilter"); + writeShadowAttributes(xml, filter->offsetX, filter->offsetY, filter->blurX, filter->blurY, + filter->color); + xml.addAttribute("shadowOnly", filter->shadowOnly); + xml.closeElementSelfClosing(); + break; + } + case NodeType::BlendFilter: { + auto filter = static_cast(node); + xml.openElement("BlendFilter"); + xml.addAttribute("color", ColorToHexString(filter->color, filter->color.alpha < 1.0f)); + if (filter->blendMode != BlendMode::Normal) { + xml.addAttribute("blendMode", BlendModeToString(filter->blendMode)); + } + xml.closeElementSelfClosing(); + break; + } + case NodeType::ColorMatrixFilter: { + auto filter = static_cast(node); + xml.openElement("ColorMatrixFilter"); + xml.addAttribute("matrix", floatListToString(filter->matrix.data(), filter->matrix.size())); + xml.closeElementSelfClosing(); + break; + } + default: + break; + } +} + +//============================================================================== +// Resource writing +//============================================================================== + +static void writeResource(XMLBuilder& xml, const Node* node, const Options& options) { + switch (node->nodeType()) { + case NodeType::Image: { + auto image = static_cast(node); + xml.openElement("Image"); + xml.addAttribute("id", image->id); + if (image->data) { + xml.addAttribute("source", "data:image/png;base64," + + Base64Encode(image->data->bytes(), image->data->size())); + } else { + xml.addAttribute("source", image->filePath); + } + xml.closeElementSelfClosing(); + break; + } + case NodeType::PathData: { + auto pathData = static_cast(node); + xml.openElement("PathData"); + xml.addAttribute("id", pathData->id); + xml.addAttribute("data", PathDataToSVGString(*pathData)); + xml.closeElementSelfClosing(); + break; + } + case NodeType::Composition: { + auto comp = static_cast(node); + xml.openElement("Composition"); + xml.addAttribute("id", comp->id); + xml.addRequiredAttribute("width", comp->width); + xml.addRequiredAttribute("height", comp->height); + if (comp->layers.empty()) { + xml.closeElementSelfClosing(); + } else { + xml.closeElementStart(); + for (const auto& layer : comp->layers) { + writeLayer(xml, layer, options); + } + xml.closeElement(); + } + break; + } + case NodeType::Font: { + // Skip Font resources if skipGlyphData is true + if (options.skipGlyphData) { + break; + } + auto font = static_cast(node); + xml.openElement("Font"); + xml.addAttribute("id", font->id); + xml.addAttribute("unitsPerEm", font->unitsPerEm, 1000); + if (font->glyphs.empty()) { + xml.closeElementSelfClosing(); + } else { + xml.closeElementStart(); + for (const auto& glyph : font->glyphs) { + xml.openElement("Glyph"); + xml.addAttribute("id", glyph->id); + if (glyph->path != nullptr && !glyph->path->id.empty()) { + xml.addAttribute("path", "@" + glyph->path->id); + } else if (glyph->path != nullptr && !glyph->path->isEmpty()) { + xml.addAttribute("path", PathDataToSVGString(*glyph->path)); + } + if (glyph->image != nullptr) { + if (!glyph->image->id.empty()) { + xml.addAttribute("image", "@" + glyph->image->id); + } else if (glyph->image->data) { + xml.addAttribute("image", "data:image/png;base64," + + Base64Encode(glyph->image->data->bytes(), + glyph->image->data->size())); + } else if (!glyph->image->filePath.empty()) { + xml.addAttribute("image", glyph->image->filePath); + } + } + if (glyph->offset.x != 0 || glyph->offset.y != 0) { + xml.addAttribute("offset", pointToString(glyph->offset)); + } + xml.addRequiredAttribute("advance", glyph->advance); + xml.closeElementSelfClosing(); + } + xml.closeElement(); + } + break; + } + case NodeType::SolidColor: + case NodeType::LinearGradient: + case NodeType::RadialGradient: + case NodeType::ConicGradient: + case NodeType::DiamondGradient: + case NodeType::ImagePattern: { + writeColorSource(xml, static_cast(node)); + break; + } + default: + break; + } +} + +//============================================================================== +// Layer writing +//============================================================================== + +static void writeLayer(XMLBuilder& xml, const Layer* node, const Options& options) { + xml.openElement("Layer"); + if (!node->id.empty()) { + xml.addAttribute("id", node->id); + } + xml.addAttribute("name", node->name); + xml.addAttribute("visible", node->visible, true); + xml.addAttribute("alpha", node->alpha, 1.0f); + if (node->blendMode != BlendMode::Normal) { + xml.addAttribute("blendMode", BlendModeToString(node->blendMode)); + } + xml.addAttribute("x", node->x); + xml.addAttribute("y", node->y); + if (!node->matrix.isIdentity()) { + xml.addAttribute("matrix", MatrixToString(node->matrix)); + } + if (!node->matrix3D.empty()) { + xml.addAttribute("matrix3D", floatListToString(node->matrix3D)); + } + xml.addAttribute("preserve3D", node->preserve3D); + xml.addAttribute("antiAlias", node->antiAlias, true); + xml.addAttribute("groupOpacity", node->groupOpacity); + xml.addAttribute("passThroughBackground", node->passThroughBackground, true); + xml.addAttribute("excludeChildEffectsInLayerStyle", node->excludeChildEffectsInLayerStyle); + if (node->hasScrollRect) { + xml.addAttribute("scrollRect", rectToString(node->scrollRect)); + } + if (node->mask != nullptr && !node->mask->id.empty()) { + xml.addAttribute("mask", "@" + node->mask->id); + } + if (node->maskType != MaskType::Alpha) { + xml.addAttribute("maskType", MaskTypeToString(node->maskType)); + } + if (node->composition != nullptr && !node->composition->id.empty()) { + xml.addAttribute("composition", "@" + node->composition->id); + } + + // Write custom data as data-* attributes. + for (const auto& [key, value] : node->customData) { + xml.addAttribute(("data-" + key).c_str(), value); + } + + bool hasChildren = !node->contents.empty() || !node->styles.empty() || !node->filters.empty() || + !node->children.empty(); + if (!hasChildren) { + xml.closeElementSelfClosing(); + return; + } + + xml.closeElementStart(); + + // Write VectorElement (contents) directly without container node. + for (const auto& element : node->contents) { + writeVectorElement(xml, element, options); + } + + // Write LayerStyle (styles) directly without container node. + for (const auto& style : node->styles) { + writeLayerStyle(xml, style); + } + + // Write LayerFilter (filters) directly without container node. + for (const auto& filter : node->filters) { + writeLayerFilter(xml, filter); + } + + // Write child Layers. + for (const auto& child : node->children) { + writeLayer(xml, child, options); + } + + xml.closeElement(); +} + +//============================================================================== +// Main Export function +//============================================================================== + +std::string PAGXExporter::ToXML(const PAGXDocument& doc, const Options& options) { + XMLBuilder xml = {}; + xml.appendDeclaration(); + + xml.openElement("pagx"); + xml.addAttribute("version", doc.version); + xml.addAttribute("width", doc.width); + xml.addAttribute("height", doc.height); + xml.closeElementStart(); + + // Write Layers first (for better readability) + for (const auto& layer : doc.layers) { + writeLayer(xml, layer, options); + } + + // Write Resources section at the end (only if there are exportable resources) + bool hasResources = false; + for (const auto& resource : doc.nodes) { + if (!resource->id.empty()) { + if (options.skipGlyphData && resource->nodeType() == NodeType::Font) { + continue; + } + hasResources = true; + break; + } + } + if (hasResources) { + xml.openElement("Resources"); + xml.closeElementStart(); + + for (const auto& resource : doc.nodes) { + if (!resource->id.empty()) { + writeResource(xml, resource.get(), options); + } + } + + xml.closeElement(); + } + + xml.closeElement(); + + return xml.release(); +} + +} // namespace pagx diff --git a/src/pagx/PAGXImporter.cpp b/src/pagx/PAGXImporter.cpp new file mode 100644 index 0000000000..5e2913eb9a --- /dev/null +++ b/src/pagx/PAGXImporter.cpp @@ -0,0 +1,1549 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#include "pagx/PAGXImporter.h" +#include +#include +#include +#include +#include +#include "utils/Base64.h" +#include "utils/StringParser.h" +#include "svg/SVGPathParser.h" +#include "xml/XMLDOM.h" +#include "pagx/nodes/BackgroundBlurStyle.h" +#include "pagx/nodes/BlendFilter.h" +#include "pagx/nodes/BlurFilter.h" +#include "pagx/nodes/ColorMatrixFilter.h" +#include "pagx/nodes/Composition.h" +#include "pagx/nodes/ConicGradient.h" +#include "pagx/nodes/DiamondGradient.h" +#include "pagx/nodes/DropShadowFilter.h" +#include "pagx/nodes/DropShadowStyle.h" +#include "pagx/nodes/Ellipse.h" +#include "pagx/nodes/Fill.h" +#include "pagx/nodes/Font.h" +#include "pagx/nodes/GlyphRun.h" +#include "pagx/nodes/Group.h" +#include "pagx/nodes/Image.h" +#include "pagx/nodes/ImagePattern.h" +#include "pagx/nodes/InnerShadowFilter.h" +#include "pagx/nodes/InnerShadowStyle.h" +#include "pagx/nodes/LinearGradient.h" +#include "pagx/nodes/MergePath.h" +#include "pagx/nodes/Path.h" +#include "pagx/nodes/PathData.h" +#include "pagx/nodes/Polystar.h" +#include "pagx/nodes/RadialGradient.h" +#include "pagx/nodes/RangeSelector.h" +#include "pagx/nodes/Rectangle.h" +#include "pagx/nodes/Repeater.h" +#include "pagx/nodes/RoundCorner.h" +#include "pagx/nodes/SolidColor.h" +#include "pagx/nodes/Stroke.h" +#include "pagx/nodes/Text.h" +#include "pagx/nodes/TextLayout.h" +#include "pagx/nodes/TextModifier.h" +#include "pagx/nodes/TextPath.h" +#include "pagx/nodes/TrimPath.h" +#include "pagx/types/Color.h" + +namespace pagx { + +//============================================================================== +// Forward declarations and utility functions +//============================================================================== + +static const std::string EMPTY_STRING = {}; + +static const std::string& getAttribute(const DOMNode* node, const std::string& name, + const std::string& defaultValue = EMPTY_STRING); +static float getFloatAttribute(const DOMNode* node, const std::string& name, + float defaultValue = 0); +static int getIntAttribute(const DOMNode* node, const std::string& name, int defaultValue = 0); +static bool getBoolAttribute(const DOMNode* node, const std::string& name, + bool defaultValue = false); +static Point parsePoint(const std::string& str); +static Size parseSize(const std::string& str); +static Rect parseRect(const std::string& str); +static Color parseColor(const std::string& str); + +// Forward declarations for parse functions +static void parseDocument(const DOMNode* root, PAGXDocument* doc); +static void parseResources(const DOMNode* node, PAGXDocument* doc); +static Node* parseResource(const DOMNode* node, PAGXDocument* doc); +static Layer* parseLayer(const DOMNode* node, PAGXDocument* doc); +static void parseContents(const DOMNode* node, Layer* layer, PAGXDocument* doc); +static void parseStyles(const DOMNode* node, Layer* layer, PAGXDocument* doc); +static void parseFilters(const DOMNode* node, Layer* layer, PAGXDocument* doc); +static Element* parseElement(const DOMNode* node, PAGXDocument* doc); +static ColorSource* parseColorSource(const DOMNode* node, PAGXDocument* doc); +static LayerStyle* parseLayerStyle(const DOMNode* node, PAGXDocument* doc); +static LayerFilter* parseLayerFilter(const DOMNode* node, PAGXDocument* doc); +static Rectangle* parseRectangle(const DOMNode* node, PAGXDocument* doc); +static Ellipse* parseEllipse(const DOMNode* node, PAGXDocument* doc); +static Polystar* parsePolystar(const DOMNode* node, PAGXDocument* doc); +static Path* parsePath(const DOMNode* node, PAGXDocument* doc); +static Text* parseText(const DOMNode* node, PAGXDocument* doc); +static Fill* parseFill(const DOMNode* node, PAGXDocument* doc); +static Stroke* parseStroke(const DOMNode* node, PAGXDocument* doc); +static TrimPath* parseTrimPath(const DOMNode* node, PAGXDocument* doc); +static RoundCorner* parseRoundCorner(const DOMNode* node, PAGXDocument* doc); +static MergePath* parseMergePath(const DOMNode* node, PAGXDocument* doc); +static TextModifier* parseTextModifier(const DOMNode* node, PAGXDocument* doc); +static TextPath* parseTextPath(const DOMNode* node, PAGXDocument* doc); +static TextLayout* parseTextLayout(const DOMNode* node, PAGXDocument* doc); +static Repeater* parseRepeater(const DOMNode* node, PAGXDocument* doc); +static Group* parseGroup(const DOMNode* node, PAGXDocument* doc); +static RangeSelector* parseRangeSelector(const DOMNode* node, PAGXDocument* doc); +static SolidColor* parseSolidColor(const DOMNode* node, PAGXDocument* doc); +static LinearGradient* parseLinearGradient(const DOMNode* node, PAGXDocument* doc); +static RadialGradient* parseRadialGradient(const DOMNode* node, PAGXDocument* doc); +static ConicGradient* parseConicGradient(const DOMNode* node, PAGXDocument* doc); +static DiamondGradient* parseDiamondGradient(const DOMNode* node, PAGXDocument* doc); +static ImagePattern* parseImagePattern(const DOMNode* node, PAGXDocument* doc); +static ColorStop parseColorStop(const DOMNode* node); +static Image* parseImage(const DOMNode* node, PAGXDocument* doc); +static PathData* parsePathData(const DOMNode* node, PAGXDocument* doc); +static Composition* parseComposition(const DOMNode* node, PAGXDocument* doc); +static Font* parseFont(const DOMNode* node, PAGXDocument* doc); +static Glyph* parseGlyph(const DOMNode* node, PAGXDocument* doc); +static GlyphRun* parseGlyphRun(const DOMNode* node, PAGXDocument* doc); +static DropShadowStyle* parseDropShadowStyle(const DOMNode* node, PAGXDocument* doc); +static InnerShadowStyle* parseInnerShadowStyle(const DOMNode* node, PAGXDocument* doc); +static BackgroundBlurStyle* parseBackgroundBlurStyle(const DOMNode* node, PAGXDocument* doc); +static BlurFilter* parseBlurFilter(const DOMNode* node, PAGXDocument* doc); +static DropShadowFilter* parseDropShadowFilter(const DOMNode* node, PAGXDocument* doc); +static InnerShadowFilter* parseInnerShadowFilter(const DOMNode* node, PAGXDocument* doc); +static BlendFilter* parseBlendFilter(const DOMNode* node, PAGXDocument* doc); +static ColorMatrixFilter* parseColorMatrixFilter(const DOMNode* node, PAGXDocument* doc); + +//============================================================================== +// Internal parser implementation +//============================================================================== + +static void parseResources(const DOMNode* node, PAGXDocument* doc) { + auto child = node->firstChild; + while (child) { + auto current = child; + child = child->nextSibling; + if (current->type != DOMNodeType::Element) { + continue; + } + // Try to parse as a resource (Image, PathData, Composition, Font) + auto resource = parseResource(current.get(), doc); + if (resource) { + continue; + } + // Try to parse as a color source (SolidColor, Gradient, ImagePattern) + auto colorSource = parseColorSource(current.get(), doc); + if (colorSource) { + continue; + } + // Unknown resource type - report error. + fprintf(stderr, "PAGXImporter: Unknown element '%s' in Resources.\n", current->name.c_str()); + } +} + +static Node* parseResource(const DOMNode* node, PAGXDocument* doc) { + if (node->name == "Image") { + return parseImage(node, doc); + } + if (node->name == "PathData") { + return parsePathData(node, doc); + } + if (node->name == "Composition") { + return parseComposition(node, doc); + } + if (node->name == "Font") { + return parseFont(node, doc); + } + return nullptr; +} + +static Layer* parseLayer(const DOMNode* node, PAGXDocument* doc) { + auto id = getAttribute(node, "id"); + auto layer = doc->makeNode(id); + if (!layer) { + return nullptr; + } + layer->name = getAttribute(node, "name"); + layer->visible = getBoolAttribute(node, "visible", true); + layer->alpha = getFloatAttribute(node, "alpha", 1); + layer->blendMode = BlendModeFromString(getAttribute(node, "blendMode", "normal")); + layer->x = getFloatAttribute(node, "x", 0); + layer->y = getFloatAttribute(node, "y", 0); + auto matrixStr = getAttribute(node, "matrix"); + if (!matrixStr.empty()) { + layer->matrix = MatrixFromString(matrixStr); + } + auto matrix3DStr = getAttribute(node, "matrix3D"); + if (!matrix3DStr.empty()) { + layer->matrix3D = ParseFloatList(matrix3DStr); + } + layer->preserve3D = getBoolAttribute(node, "preserve3D", false); + layer->antiAlias = getBoolAttribute(node, "antiAlias", true); + layer->groupOpacity = getBoolAttribute(node, "groupOpacity", false); + layer->passThroughBackground = getBoolAttribute(node, "passThroughBackground", true); + layer->excludeChildEffectsInLayerStyle = + getBoolAttribute(node, "excludeChildEffectsInLayerStyle", false); + auto scrollRectStr = getAttribute(node, "scrollRect"); + if (!scrollRectStr.empty()) { + layer->scrollRect = parseRect(scrollRectStr); + layer->hasScrollRect = true; + } + + auto maskAttr = getAttribute(node, "mask"); + if (!maskAttr.empty() && maskAttr[0] == '@') { + layer->mask = doc->findNode(maskAttr.substr(1)); + } + layer->maskType = MaskTypeFromString(getAttribute(node, "maskType", "alpha")); + + auto compositionAttr = getAttribute(node, "composition"); + if (!compositionAttr.empty() && compositionAttr[0] == '@') { + layer->composition = doc->findNode(compositionAttr.substr(1)); + } + + // Parse data-* custom attributes. + for (const auto& attr : node->attributes) { + if (attr.name.length() > 5 && attr.name.compare(0, 5, "data-") == 0) { + layer->customData[attr.name.substr(5)] = attr.value; + } + } + + auto child = node->firstChild; + while (child) { + auto current = child; + child = child->nextSibling; + if (current->type != DOMNodeType::Element) { + continue; + } + // Legacy format: support container nodes for backward compatibility. + if (current->name == "contents") { + parseContents(current.get(), layer, doc); + continue; + } + if (current->name == "styles") { + parseStyles(current.get(), layer, doc); + continue; + } + if (current->name == "filters") { + parseFilters(current.get(), layer, doc); + continue; + } + // New format: direct child elements without container nodes. + if (current->name == "Layer") { + auto childLayer = parseLayer(current.get(), doc); + if (childLayer) { + layer->children.push_back(childLayer); + } + continue; + } + // Try to parse as VectorElement. + auto element = parseElement(current.get(), doc); + if (element) { + layer->contents.push_back(element); + continue; + } + // Try to parse as LayerStyle. + auto style = parseLayerStyle(current.get(), doc); + if (style) { + layer->styles.push_back(style); + continue; + } + // Try to parse as LayerFilter. + auto filter = parseLayerFilter(current.get(), doc); + if (filter) { + layer->filters.push_back(filter); + continue; + } + // Unknown node type - report error. + fprintf(stderr, "PAGXImporter: Unknown element '%s' in Layer.\n", current->name.c_str()); + } + + return layer; +} + +static void parseContents(const DOMNode* node, Layer* layer, PAGXDocument* doc) { + auto child = node->firstChild; + while (child) { + auto current = child; + child = child->nextSibling; + if (current->type != DOMNodeType::Element) { + continue; + } + auto element = parseElement(current.get(), doc); + if (element) { + layer->contents.push_back(element); + } else { + fprintf(stderr, "PAGXImporter: Unknown element '%s' in contents.\n", current->name.c_str()); + } + } +} + +static void parseStyles(const DOMNode* node, Layer* layer, PAGXDocument* doc) { + auto child = node->firstChild; + while (child) { + auto current = child; + child = child->nextSibling; + if (current->type != DOMNodeType::Element) { + continue; + } + auto style = parseLayerStyle(current.get(), doc); + if (style) { + layer->styles.push_back(style); + } else { + fprintf(stderr, "PAGXImporter: Unknown element '%s' in styles.\n", current->name.c_str()); + } + } +} + +static void parseFilters(const DOMNode* node, Layer* layer, PAGXDocument* doc) { + auto child = node->firstChild; + while (child) { + auto current = child; + child = child->nextSibling; + if (current->type != DOMNodeType::Element) { + continue; + } + auto filter = parseLayerFilter(current.get(), doc); + if (filter) { + layer->filters.push_back(filter); + } else { + fprintf(stderr, "PAGXImporter: Unknown element '%s' in filters.\n", current->name.c_str()); + } + } +} + +static Element* parseElement(const DOMNode* node, PAGXDocument* doc) { + if (node->name == "Rectangle") { + return parseRectangle(node, doc); + } + if (node->name == "Ellipse") { + return parseEllipse(node, doc); + } + if (node->name == "Polystar") { + return parsePolystar(node, doc); + } + if (node->name == "Path") { + return parsePath(node, doc); + } + if (node->name == "Text") { + return parseText(node, doc); + } + if (node->name == "Fill") { + return parseFill(node, doc); + } + if (node->name == "Stroke") { + return parseStroke(node, doc); + } + if (node->name == "TrimPath") { + return parseTrimPath(node, doc); + } + if (node->name == "RoundCorner") { + return parseRoundCorner(node, doc); + } + if (node->name == "MergePath") { + return parseMergePath(node, doc); + } + if (node->name == "TextModifier") { + return parseTextModifier(node, doc); + } + if (node->name == "TextPath") { + return parseTextPath(node, doc); + } + if (node->name == "TextLayout") { + return parseTextLayout(node, doc); + } + if (node->name == "Repeater") { + return parseRepeater(node, doc); + } + if (node->name == "Group") { + return parseGroup(node, doc); + } + return nullptr; +} + +static ColorSource* parseColorSource(const DOMNode* node, PAGXDocument* doc) { + if (node->name == "SolidColor") { + return parseSolidColor(node, doc); + } + if (node->name == "LinearGradient") { + return parseLinearGradient(node, doc); + } + if (node->name == "RadialGradient") { + return parseRadialGradient(node, doc); + } + if (node->name == "ConicGradient") { + return parseConicGradient(node, doc); + } + if (node->name == "DiamondGradient") { + return parseDiamondGradient(node, doc); + } + if (node->name == "ImagePattern") { + return parseImagePattern(node, doc); + } + return nullptr; +} + +static LayerStyle* parseLayerStyle(const DOMNode* node, PAGXDocument* doc) { + if (node->name == "DropShadowStyle") { + return parseDropShadowStyle(node, doc); + } + if (node->name == "InnerShadowStyle") { + return parseInnerShadowStyle(node, doc); + } + if (node->name == "BackgroundBlurStyle") { + return parseBackgroundBlurStyle(node, doc); + } + return nullptr; +} + +static LayerFilter* parseLayerFilter(const DOMNode* node, PAGXDocument* doc) { + if (node->name == "BlurFilter") { + return parseBlurFilter(node, doc); + } + if (node->name == "DropShadowFilter") { + return parseDropShadowFilter(node, doc); + } + if (node->name == "InnerShadowFilter") { + return parseInnerShadowFilter(node, doc); + } + if (node->name == "BlendFilter") { + return parseBlendFilter(node, doc); + } + if (node->name == "ColorMatrixFilter") { + return parseColorMatrixFilter(node, doc); + } + return nullptr; +} + +//============================================================================== +// Geometry element parsing +//============================================================================== + +static Rectangle* parseRectangle(const DOMNode* node, PAGXDocument* doc) { + auto rect = doc->makeNode(getAttribute(node, "id")); + if (!rect) { + return nullptr; + } + auto centerStr = getAttribute(node, "center", "0,0"); + rect->center = parsePoint(centerStr); + auto sizeStr = getAttribute(node, "size", "100,100"); + rect->size = parseSize(sizeStr); + rect->roundness = getFloatAttribute(node, "roundness", 0); + rect->reversed = getBoolAttribute(node, "reversed", false); + return rect; +} + +static Ellipse* parseEllipse(const DOMNode* node, PAGXDocument* doc) { + auto ellipse = doc->makeNode(getAttribute(node, "id")); + if (!ellipse) { + return nullptr; + } + auto centerStr = getAttribute(node, "center", "0,0"); + ellipse->center = parsePoint(centerStr); + auto sizeStr = getAttribute(node, "size", "100,100"); + ellipse->size = parseSize(sizeStr); + ellipse->reversed = getBoolAttribute(node, "reversed", false); + return ellipse; +} + +static Polystar* parsePolystar(const DOMNode* node, PAGXDocument* doc) { + auto polystar = doc->makeNode(getAttribute(node, "id")); + if (!polystar) { + return nullptr; + } + auto centerStr = getAttribute(node, "center", "0,0"); + polystar->center = parsePoint(centerStr); + polystar->type = PolystarTypeFromString(getAttribute(node, "type", "star")); + polystar->pointCount = getFloatAttribute(node, "pointCount", 5); + polystar->outerRadius = getFloatAttribute(node, "outerRadius", 100); + polystar->innerRadius = getFloatAttribute(node, "innerRadius", 50); + polystar->rotation = getFloatAttribute(node, "rotation", 0); + polystar->outerRoundness = getFloatAttribute(node, "outerRoundness", 0); + polystar->innerRoundness = getFloatAttribute(node, "innerRoundness", 0); + polystar->reversed = getBoolAttribute(node, "reversed", false); + return polystar; +} + +static Path* parsePath(const DOMNode* node, PAGXDocument* doc) { + auto id = getAttribute(node, "id"); + auto path = doc->makeNode(id); + if (!path) { + return nullptr; + } + auto dataAttr = getAttribute(node, "data"); + if (!dataAttr.empty()) { + if (dataAttr[0] == '@') { + // Reference to PathData resource + path->data = doc->findNode(dataAttr.substr(1)); + } else { + // Inline path data + path->data = doc->makeNode(); + *path->data = PathDataFromSVGString(dataAttr); + } + } + path->reversed = getBoolAttribute(node, "reversed", false); + return path; +} + +static Text* parseText(const DOMNode* node, PAGXDocument* doc) { + auto text = doc->makeNode(getAttribute(node, "id")); + if (!text) { + return nullptr; + } + // Parse text content from attribute first, then fallback to text child node + auto textAttr = getAttribute(node, "text"); + if (!textAttr.empty()) { + text->text = textAttr; + } else { + auto textChild = node->firstChild; + while (textChild) { + if (textChild->type == DOMNodeType::Text) { + text->text = textChild->name; + break; + } + textChild = textChild->nextSibling; + } + } + auto positionStr = getAttribute(node, "position", "0,0"); + auto pos = parsePoint(positionStr); + text->position = {pos.x, pos.y}; + text->fontFamily = getAttribute(node, "fontFamily"); + text->fontStyle = getAttribute(node, "fontStyle"); + text->fontSize = getFloatAttribute(node, "fontSize", 12); + text->letterSpacing = getFloatAttribute(node, "letterSpacing", 0); + text->baselineShift = getFloatAttribute(node, "baselineShift", 0); + + // Parse GlyphRun children for precomposition mode + auto child = node->firstChild; + while (child) { + if (child->type == DOMNodeType::Element && child->name == "GlyphRun") { + auto glyphRun = parseGlyphRun(child.get(), doc); + if (glyphRun) { + text->glyphRuns.push_back(glyphRun); + } + } + child = child->nextSibling; + } + + return text; +} + +//============================================================================== +// Painter parsing +//============================================================================== + +static ColorSource* parseColorAttr(const std::string& colorAttr, PAGXDocument* doc) { + if (colorAttr.empty()) { + return nullptr; + } + if (colorAttr[0] == '@') { + return doc->findNode(colorAttr.substr(1)); + } + auto solidColor = doc->makeNode(); + solidColor->color = parseColor(colorAttr); + return solidColor; +} + +static ColorSource* parseChildColorSource(const DOMNode* node, PAGXDocument* doc) { + auto child = node->firstChild; + while (child) { + if (child->type == DOMNodeType::Element) { + auto colorSource = parseColorSource(child.get(), doc); + if (colorSource) { + return colorSource; + } + } + child = child->nextSibling; + } + return nullptr; +} + +static Fill* parseFill(const DOMNode* node, PAGXDocument* doc) { + auto id = getAttribute(node, "id"); + auto fill = doc->makeNode(id); + if (!fill) { + return nullptr; + } + fill->color = parseColorAttr(getAttribute(node, "color"), doc); + fill->alpha = getFloatAttribute(node, "alpha", 1); + fill->blendMode = BlendModeFromString(getAttribute(node, "blendMode", "normal")); + fill->fillRule = FillRuleFromString(getAttribute(node, "fillRule", "winding")); + fill->placement = LayerPlacementFromString(getAttribute(node, "placement", "background")); + auto childColor = parseChildColorSource(node, doc); + if (childColor) { + fill->color = childColor; + } + return fill; +} + +static Stroke* parseStroke(const DOMNode* node, PAGXDocument* doc) { + auto id = getAttribute(node, "id"); + auto stroke = doc->makeNode(id); + if (!stroke) { + return nullptr; + } + stroke->color = parseColorAttr(getAttribute(node, "color"), doc); + stroke->width = getFloatAttribute(node, "width", 1); + stroke->alpha = getFloatAttribute(node, "alpha", 1); + stroke->blendMode = BlendModeFromString(getAttribute(node, "blendMode", "normal")); + stroke->cap = LineCapFromString(getAttribute(node, "cap", "butt")); + stroke->join = LineJoinFromString(getAttribute(node, "join", "miter")); + stroke->miterLimit = getFloatAttribute(node, "miterLimit", 4); + auto dashesStr = getAttribute(node, "dashes"); + if (!dashesStr.empty()) { + stroke->dashes = ParseFloatList(dashesStr); + } + stroke->dashOffset = getFloatAttribute(node, "dashOffset", 0); + stroke->dashAdaptive = getBoolAttribute(node, "dashAdaptive", false); + stroke->align = StrokeAlignFromString(getAttribute(node, "align", "center")); + stroke->placement = LayerPlacementFromString(getAttribute(node, "placement", "background")); + auto childColor = parseChildColorSource(node, doc); + if (childColor) { + stroke->color = childColor; + } + return stroke; +} + +//============================================================================== +// Modifier parsing +//============================================================================== + +static TrimPath* parseTrimPath(const DOMNode* node, PAGXDocument* doc) { + auto trim = doc->makeNode(getAttribute(node, "id")); + if (!trim) { + return nullptr; + } + trim->start = getFloatAttribute(node, "start", 0); + trim->end = getFloatAttribute(node, "end", 1); + trim->offset = getFloatAttribute(node, "offset", 0); + trim->type = TrimTypeFromString(getAttribute(node, "type", "separate")); + return trim; +} + +static RoundCorner* parseRoundCorner(const DOMNode* node, PAGXDocument* doc) { + auto round = doc->makeNode(getAttribute(node, "id")); + if (!round) { + return nullptr; + } + round->radius = getFloatAttribute(node, "radius", 10); + return round; +} + +static MergePath* parseMergePath(const DOMNode* node, PAGXDocument* doc) { + auto merge = doc->makeNode(getAttribute(node, "id")); + if (!merge) { + return nullptr; + } + merge->mode = MergePathModeFromString(getAttribute(node, "mode", "append")); + return merge; +} + +static TextModifier* parseTextModifier(const DOMNode* node, PAGXDocument* doc) { + auto id = getAttribute(node, "id"); + auto modifier = doc->makeNode(id); + if (!modifier) { + return nullptr; + } + auto anchorStr = getAttribute(node, "anchor", "0,0"); + modifier->anchor = parsePoint(anchorStr); + auto positionStr = getAttribute(node, "position", "0,0"); + modifier->position = parsePoint(positionStr); + modifier->rotation = getFloatAttribute(node, "rotation", 0); + auto scaleStr = getAttribute(node, "scale", "1,1"); + modifier->scale = parsePoint(scaleStr); + modifier->skew = getFloatAttribute(node, "skew", 0); + modifier->skewAxis = getFloatAttribute(node, "skewAxis", 0); + modifier->alpha = getFloatAttribute(node, "alpha", 1); + auto fillColorAttr = getAttribute(node, "fillColor"); + if (!fillColorAttr.empty()) { + modifier->fillColor = parseColor(fillColorAttr); + } + auto strokeColorAttr = getAttribute(node, "strokeColor"); + if (!strokeColorAttr.empty()) { + modifier->strokeColor = parseColor(strokeColorAttr); + } + auto strokeWidthAttr = getAttribute(node, "strokeWidth"); + if (!strokeWidthAttr.empty()) { + modifier->strokeWidth = getFloatAttribute(node, "strokeWidth", 0); + } + + auto child = node->firstChild; + while (child) { + if (child->type == DOMNodeType::Element && child->name == "RangeSelector") { + auto selector = parseRangeSelector(child.get(), doc); + if (selector) { + modifier->selectors.push_back(selector); + } + } + child = child->nextSibling; + } + + return modifier; +} + +static TextPath* parseTextPath(const DOMNode* node, PAGXDocument* doc) { + auto id = getAttribute(node, "id"); + auto textPath = doc->makeNode(id); + if (!textPath) { + return nullptr; + } + auto pathAttr = getAttribute(node, "path"); + if (!pathAttr.empty()) { + if (pathAttr[0] == '@') { + // Reference to PathData resource + textPath->path = doc->findNode(pathAttr.substr(1)); + } else { + // Inline path data + textPath->path = doc->makeNode(); + *textPath->path = PathDataFromSVGString(pathAttr); + } + } + textPath->baselineOrigin = parsePoint(getAttribute(node, "baselineOrigin", "0,0")); + textPath->baselineAngle = getFloatAttribute(node, "baselineAngle", 0); + textPath->firstMargin = getFloatAttribute(node, "firstMargin", 0); + textPath->lastMargin = getFloatAttribute(node, "lastMargin", 0); + textPath->perpendicular = getBoolAttribute(node, "perpendicular", true); + textPath->reversed = getBoolAttribute(node, "reversed", false); + textPath->forceAlignment = getBoolAttribute(node, "forceAlignment", false); + return textPath; +} + +static TextLayout* parseTextLayout(const DOMNode* node, PAGXDocument* doc) { + auto layout = doc->makeNode(getAttribute(node, "id")); + if (!layout) { + return nullptr; + } + auto positionStr = getAttribute(node, "position", "0,0"); + auto pos = parsePoint(positionStr); + layout->position = {pos.x, pos.y}; + layout->width = getFloatAttribute(node, "width", 0); + layout->height = getFloatAttribute(node, "height", 0); + layout->textAlign = TextAlignFromString(getAttribute(node, "textAlign", "start")); + layout->verticalAlign = VerticalAlignFromString(getAttribute(node, "verticalAlign", "top")); + layout->writingMode = WritingModeFromString(getAttribute(node, "writingMode", "horizontal")); + layout->lineHeight = getFloatAttribute(node, "lineHeight", 1.2f); + return layout; +} + +static Repeater* parseRepeater(const DOMNode* node, PAGXDocument* doc) { + auto repeater = doc->makeNode(getAttribute(node, "id")); + if (!repeater) { + return nullptr; + } + repeater->copies = getFloatAttribute(node, "copies", 3); + repeater->offset = getFloatAttribute(node, "offset", 0); + repeater->order = RepeaterOrderFromString(getAttribute(node, "order", "belowOriginal")); + auto anchorStr = getAttribute(node, "anchor", "0,0"); + repeater->anchor = parsePoint(anchorStr); + auto positionStr = getAttribute(node, "position", "100,100"); + repeater->position = parsePoint(positionStr); + repeater->rotation = getFloatAttribute(node, "rotation", 0); + auto scaleStr = getAttribute(node, "scale", "1,1"); + repeater->scale = parsePoint(scaleStr); + repeater->startAlpha = getFloatAttribute(node, "startAlpha", 1); + repeater->endAlpha = getFloatAttribute(node, "endAlpha", 1); + return repeater; +} + +static Group* parseGroup(const DOMNode* node, PAGXDocument* doc) { + auto group = doc->makeNode(getAttribute(node, "id")); + if (!group) { + return nullptr; + } + auto anchorStr = getAttribute(node, "anchor", "0,0"); + group->anchor = parsePoint(anchorStr); + auto positionStr = getAttribute(node, "position", "0,0"); + group->position = parsePoint(positionStr); + group->rotation = getFloatAttribute(node, "rotation", 0); + auto scaleStr = getAttribute(node, "scale", "1,1"); + group->scale = parsePoint(scaleStr); + group->skew = getFloatAttribute(node, "skew", 0); + group->skewAxis = getFloatAttribute(node, "skewAxis", 0); + group->alpha = getFloatAttribute(node, "alpha", 1); + + auto child = node->firstChild; + while (child) { + if (child->type == DOMNodeType::Element) { + auto element = parseElement(child.get(), doc); + if (element) { + group->elements.push_back(element); + } else { + fprintf(stderr, "PAGXImporter: Unknown element '%s' in Group.\n", child->name.c_str()); + } + } + child = child->nextSibling; + } + + return group; +} + +static RangeSelector* parseRangeSelector(const DOMNode* node, PAGXDocument* doc) { + auto selector = doc->makeNode(getAttribute(node, "id")); + if (!selector) { + return nullptr; + } + selector->start = getFloatAttribute(node, "start", 0); + selector->end = getFloatAttribute(node, "end", 1); + selector->offset = getFloatAttribute(node, "offset", 0); + selector->unit = SelectorUnitFromString(getAttribute(node, "unit", "percentage")); + selector->shape = SelectorShapeFromString(getAttribute(node, "shape", "square")); + selector->easeIn = getFloatAttribute(node, "easeIn", 0); + selector->easeOut = getFloatAttribute(node, "easeOut", 0); + selector->mode = SelectorModeFromString(getAttribute(node, "mode", "add")); + selector->weight = getFloatAttribute(node, "weight", 1); + selector->randomOrder = getBoolAttribute(node, "randomOrder", false); + selector->randomSeed = getIntAttribute(node, "randomSeed", 0); + return selector; +} + +//============================================================================== +// Color source parsing +//============================================================================== + +static SolidColor* parseSolidColor(const DOMNode* node, PAGXDocument* doc) { + auto solid = doc->makeNode(getAttribute(node, "id")); + if (!solid) { + return nullptr; + } + // Support "color" attribute with HEX (#RRGGBB) or p3() format + auto colorStr = getAttribute(node, "color"); + if (!colorStr.empty()) { + solid->color = parseColor(colorStr); + } else { + // Fallback to individual red/green/blue/alpha attributes + solid->color.red = getFloatAttribute(node, "red", 0); + solid->color.green = getFloatAttribute(node, "green", 0); + solid->color.blue = getFloatAttribute(node, "blue", 0); + solid->color.alpha = getFloatAttribute(node, "alpha", 1); + solid->color.colorSpace = ColorSpaceFromString(getAttribute(node, "colorSpace", "sRGB")); + } + return solid; +} + +static void parseGradientCommon(const DOMNode* node, Matrix& matrix, + std::vector& colorStops) { + auto matrixStr = getAttribute(node, "matrix"); + if (!matrixStr.empty()) { + matrix = MatrixFromString(matrixStr); + } + auto child = node->firstChild; + while (child) { + if (child->type == DOMNodeType::Element && child->name == "ColorStop") { + colorStops.push_back(parseColorStop(child.get())); + } + child = child->nextSibling; + } +} + +static LinearGradient* parseLinearGradient(const DOMNode* node, PAGXDocument* doc) { + auto gradient = doc->makeNode(getAttribute(node, "id")); + if (!gradient) { + return nullptr; + } + gradient->startPoint = parsePoint(getAttribute(node, "startPoint", "0,0")); + gradient->endPoint = parsePoint(getAttribute(node, "endPoint", "0,0")); + parseGradientCommon(node, gradient->matrix, gradient->colorStops); + return gradient; +} + +static RadialGradient* parseRadialGradient(const DOMNode* node, PAGXDocument* doc) { + auto gradient = doc->makeNode(getAttribute(node, "id")); + if (!gradient) { + return nullptr; + } + gradient->center = parsePoint(getAttribute(node, "center", "0,0")); + gradient->radius = getFloatAttribute(node, "radius", 0); + parseGradientCommon(node, gradient->matrix, gradient->colorStops); + return gradient; +} + +static ConicGradient* parseConicGradient(const DOMNode* node, PAGXDocument* doc) { + auto gradient = doc->makeNode(getAttribute(node, "id")); + if (!gradient) { + return nullptr; + } + gradient->center = parsePoint(getAttribute(node, "center", "0,0")); + gradient->startAngle = getFloatAttribute(node, "startAngle", 0); + gradient->endAngle = getFloatAttribute(node, "endAngle", 360); + parseGradientCommon(node, gradient->matrix, gradient->colorStops); + return gradient; +} + +static DiamondGradient* parseDiamondGradient(const DOMNode* node, PAGXDocument* doc) { + auto gradient = doc->makeNode(getAttribute(node, "id")); + if (!gradient) { + return nullptr; + } + gradient->center = parsePoint(getAttribute(node, "center", "0,0")); + gradient->radius = getFloatAttribute(node, "radius", 0); + parseGradientCommon(node, gradient->matrix, gradient->colorStops); + return gradient; +} + +static ImagePattern* parseImagePattern(const DOMNode* node, PAGXDocument* doc) { + auto id = getAttribute(node, "id"); + auto pattern = doc->makeNode(id); + if (!pattern) { + return nullptr; + } + auto imageAttr = getAttribute(node, "image"); + if (!imageAttr.empty() && imageAttr[0] == '@') { + pattern->image = doc->findNode(imageAttr.substr(1)); + } + pattern->tileModeX = TileModeFromString(getAttribute(node, "tileModeX", "clamp")); + pattern->tileModeY = TileModeFromString(getAttribute(node, "tileModeY", "clamp")); + pattern->filterMode = FilterModeFromString(getAttribute(node, "filterMode", "linear")); + pattern->mipmapMode = MipmapModeFromString(getAttribute(node, "mipmapMode", "linear")); + auto matrixStr = getAttribute(node, "matrix"); + if (!matrixStr.empty()) { + pattern->matrix = MatrixFromString(matrixStr); + } + return pattern; +} + +static ColorStop parseColorStop(const DOMNode* node) { + ColorStop stop = {}; + stop.offset = getFloatAttribute(node, "offset", 0); + auto colorStr = getAttribute(node, "color"); + if (!colorStr.empty()) { + stop.color = parseColor(colorStr); + } + return stop; +} + +//============================================================================== +// Resource parsing +//============================================================================== + +static Image* parseImage(const DOMNode* node, PAGXDocument* doc) { + auto id = getAttribute(node, "id"); + auto image = doc->makeNode(id); + if (!image) { + return nullptr; + } + auto source = getAttribute(node, "source"); + auto data = DecodeBase64DataURI(source); + if (data) { + image->data = data; + } else { + image->filePath = source; + } + return image; +} + +static PathData* parsePathData(const DOMNode* node, PAGXDocument* doc) { + auto id = getAttribute(node, "id"); + auto pathData = doc->makeNode(id); + if (!pathData) { + return nullptr; + } + auto data = getAttribute(node, "data"); + if (!data.empty()) { + *pathData = PathDataFromSVGString(data); + } + return pathData; +} + +static Composition* parseComposition(const DOMNode* node, PAGXDocument* doc) { + auto comp = doc->makeNode(getAttribute(node, "id")); + if (!comp) { + return nullptr; + } + comp->width = getFloatAttribute(node, "width", 0); + comp->height = getFloatAttribute(node, "height", 0); + auto child = node->firstChild; + while (child) { + if (child->type == DOMNodeType::Element && child->name == "Layer") { + auto layer = parseLayer(child.get(), doc); + if (layer) { + comp->layers.push_back(layer); + } + } + child = child->nextSibling; + } + return comp; +} + +static Font* parseFont(const DOMNode* node, PAGXDocument* doc) { + auto font = doc->makeNode(getAttribute(node, "id")); + if (!font) { + return nullptr; + } + font->unitsPerEm = getIntAttribute(node, "unitsPerEm", 1000); + auto child = node->firstChild; + while (child) { + if (child->type == DOMNodeType::Element && child->name == "Glyph") { + auto glyph = parseGlyph(child.get(), doc); + if (glyph) { + font->glyphs.push_back(glyph); + } + } + child = child->nextSibling; + } + return font; +} + +static Glyph* parseGlyph(const DOMNode* node, PAGXDocument* doc) { + auto glyph = doc->makeNode(getAttribute(node, "id")); + if (!glyph) { + return nullptr; + } + auto pathAttr = getAttribute(node, "path"); + if (!pathAttr.empty()) { + if (pathAttr[0] == '@') { + glyph->path = doc->findNode(pathAttr.substr(1)); + } else { + glyph->path = doc->makeNode(); + *glyph->path = PathDataFromSVGString(pathAttr); + } + } + auto imageAttr = getAttribute(node, "image"); + if (!imageAttr.empty()) { + if (imageAttr[0] == '@') { + // Reference to existing Image resource + glyph->image = doc->findNode(imageAttr.substr(1)); + } else { + // Inline image source (data URI or file path) + glyph->image = doc->makeNode(); + auto data = DecodeBase64DataURI(imageAttr); + if (data) { + glyph->image->data = data; + } else { + glyph->image->filePath = imageAttr; + } + } + } + auto offsetStr = getAttribute(node, "offset"); + if (!offsetStr.empty()) { + glyph->offset = parsePoint(offsetStr); + } + glyph->advance = getFloatAttribute(node, "advance", 0); + return glyph; +} + +static std::vector parseSemicolonSeparatedPoints(const std::string& str) { + std::vector result = {}; + const char* ptr = str.c_str(); + const char* end = ptr + str.size(); + while (ptr < end) { + while (ptr < end && (*ptr == ' ' || *ptr == '\t' || *ptr == ';')) { + ++ptr; + } + if (ptr >= end) { + break; + } + char* endPtr = nullptr; + float x = strtof(ptr, &endPtr); + if (endPtr == ptr) { + break; + } + ptr = endPtr; + while (ptr < end && (*ptr == ' ' || *ptr == '\t' || *ptr == ',')) { + ++ptr; + } + float y = strtof(ptr, &endPtr); + if (endPtr == ptr) { + break; + } + ptr = endPtr; + result.push_back({x, y}); + } + return result; +} + +static GlyphRun* parseGlyphRun(const DOMNode* node, PAGXDocument* doc) { + auto run = doc->makeNode(getAttribute(node, "id")); + if (!run) { + return nullptr; + } + auto fontAttr = getAttribute(node, "font"); + if (!fontAttr.empty() && fontAttr[0] == '@') { + auto fontId = fontAttr.substr(1); + run->font = doc->findNode(fontId); + if (!run->font) { + fprintf(stderr, "PAGXImporter: Font resource '%s' not found, GlyphRun will be empty.\n", + fontId.c_str()); + } + } + run->fontSize = getFloatAttribute(node, "fontSize", 12); + run->x = getFloatAttribute(node, "x", 0); + run->y = getFloatAttribute(node, "y", 0); + + // Parse glyphs regardless of whether font is valid, to maintain data consistency. + auto glyphsStr = getAttribute(node, "glyphs"); + if (!glyphsStr.empty()) { + const char* ptr = glyphsStr.c_str(); + const char* end = ptr + glyphsStr.size(); + while (ptr < end) { + while (ptr < end && (*ptr == ' ' || *ptr == '\t' || *ptr == ',')) { + ++ptr; + } + if (ptr >= end) { + break; + } + char* endPtr = nullptr; + long value = strtol(ptr, &endPtr, 10); + if (endPtr == ptr) { + break; + } + run->glyphs.push_back(static_cast(value)); + ptr = endPtr; + } + } + + // Parse xOffsets (comma-separated x offsets) + auto xOffsetsStr = getAttribute(node, "xOffsets"); + if (!xOffsetsStr.empty()) { + run->xOffsets = ParseFloatList(xOffsetsStr); + } + + // Parse positions + auto posStr = getAttribute(node, "positions"); + if (!posStr.empty()) { + run->positions = parseSemicolonSeparatedPoints(posStr); + } + + // Parse anchors + auto anchorsStr = getAttribute(node, "anchors"); + if (!anchorsStr.empty()) { + run->anchors = parseSemicolonSeparatedPoints(anchorsStr); + } + + // Parse scales + auto scalesStr = getAttribute(node, "scales"); + if (!scalesStr.empty()) { + run->scales = parseSemicolonSeparatedPoints(scalesStr); + } + + // Parse rotations (comma-separated angles in degrees) + auto rotationsStr = getAttribute(node, "rotations"); + if (!rotationsStr.empty()) { + run->rotations = ParseFloatList(rotationsStr); + } + + // Parse skews (comma-separated angles in degrees) + auto skewsStr = getAttribute(node, "skews"); + if (!skewsStr.empty()) { + run->skews = ParseFloatList(skewsStr); + } + + return run; +} + +//============================================================================== +// Layer style parsing +//============================================================================== + +static void parseShadowAttributes(const DOMNode* node, float& offsetX, float& offsetY, + float& blurX, float& blurY, Color& color) { + offsetX = getFloatAttribute(node, "offsetX", 0); + offsetY = getFloatAttribute(node, "offsetY", 0); + blurX = getFloatAttribute(node, "blurX", 0); + blurY = getFloatAttribute(node, "blurY", 0); + auto colorStr = getAttribute(node, "color"); + if (!colorStr.empty()) { + color = parseColor(colorStr); + } +} + +static DropShadowStyle* parseDropShadowStyle(const DOMNode* node, PAGXDocument* doc) { + auto style = doc->makeNode(getAttribute(node, "id")); + if (!style) { + return nullptr; + } + style->blendMode = BlendModeFromString(getAttribute(node, "blendMode", "normal")); + parseShadowAttributes(node, style->offsetX, style->offsetY, style->blurX, style->blurY, + style->color); + style->showBehindLayer = getBoolAttribute(node, "showBehindLayer", true); + return style; +} + +static InnerShadowStyle* parseInnerShadowStyle(const DOMNode* node, PAGXDocument* doc) { + auto style = doc->makeNode(getAttribute(node, "id")); + if (!style) { + return nullptr; + } + style->blendMode = BlendModeFromString(getAttribute(node, "blendMode", "normal")); + parseShadowAttributes(node, style->offsetX, style->offsetY, style->blurX, style->blurY, + style->color); + return style; +} + +static BackgroundBlurStyle* parseBackgroundBlurStyle( + const DOMNode* node, PAGXDocument* doc) { + auto style = doc->makeNode(getAttribute(node, "id")); + if (!style) { + return nullptr; + } + style->blendMode = BlendModeFromString(getAttribute(node, "blendMode", "normal")); + style->blurX = getFloatAttribute(node, "blurX", 0); + style->blurY = getFloatAttribute(node, "blurY", 0); + style->tileMode = TileModeFromString(getAttribute(node, "tileMode", "mirror")); + return style; +} + +//============================================================================== +// Layer filter parsing +//============================================================================== + +static BlurFilter* parseBlurFilter(const DOMNode* node, PAGXDocument* doc) { + auto filter = doc->makeNode(getAttribute(node, "id")); + if (!filter) { + return nullptr; + } + filter->blurX = getFloatAttribute(node, "blurX", 0); + filter->blurY = getFloatAttribute(node, "blurY", 0); + filter->tileMode = TileModeFromString(getAttribute(node, "tileMode", "decal")); + return filter; +} + +static DropShadowFilter* parseDropShadowFilter(const DOMNode* node, PAGXDocument* doc) { + auto filter = doc->makeNode(getAttribute(node, "id")); + if (!filter) { + return nullptr; + } + parseShadowAttributes(node, filter->offsetX, filter->offsetY, filter->blurX, filter->blurY, + filter->color); + filter->shadowOnly = getBoolAttribute(node, "shadowOnly", false); + return filter; +} + +static InnerShadowFilter* parseInnerShadowFilter(const DOMNode* node, PAGXDocument* doc) { + auto filter = doc->makeNode(getAttribute(node, "id")); + if (!filter) { + return nullptr; + } + parseShadowAttributes(node, filter->offsetX, filter->offsetY, filter->blurX, filter->blurY, + filter->color); + filter->shadowOnly = getBoolAttribute(node, "shadowOnly", false); + return filter; +} + +static BlendFilter* parseBlendFilter(const DOMNode* node, PAGXDocument* doc) { + auto filter = doc->makeNode(getAttribute(node, "id")); + if (!filter) { + return nullptr; + } + auto colorStr = getAttribute(node, "color"); + if (!colorStr.empty()) { + filter->color = parseColor(colorStr); + } + filter->blendMode = BlendModeFromString(getAttribute(node, "blendMode", "normal")); + return filter; +} + +static ColorMatrixFilter* parseColorMatrixFilter(const DOMNode* node, PAGXDocument* doc) { + auto filter = doc->makeNode(getAttribute(node, "id")); + if (!filter) { + return nullptr; + } + auto matrixStr = getAttribute(node, "matrix"); + if (!matrixStr.empty()) { + auto values = ParseFloatList(matrixStr); + for (size_t i = 0; i < std::min(values.size(), size_t(20)); i++) { + filter->matrix[i] = values[i]; + } + } + return filter; +} + +//============================================================================== +// Utility functions +//============================================================================== + +static const std::string& getAttribute(const DOMNode* node, const std::string& name, + const std::string& defaultValue) { + auto* value = node->findAttribute(name); + return value ? *value : defaultValue; +} + +static float getFloatAttribute(const DOMNode* node, const std::string& name, + float defaultValue) { + auto* str = node->findAttribute(name); + if (!str || str->empty()) { + return defaultValue; + } + char* endPtr = nullptr; + float value = strtof(str->c_str(), &endPtr); + if (endPtr == str->c_str()) { + return defaultValue; + } + return value; +} + +static int getIntAttribute(const DOMNode* node, const std::string& name, int defaultValue) { + auto* str = node->findAttribute(name); + if (!str || str->empty()) { + return defaultValue; + } + char* endPtr = nullptr; + long value = strtol(str->c_str(), &endPtr, 10); + if (endPtr == str->c_str()) { + return defaultValue; + } + return static_cast(value); +} + +static bool getBoolAttribute(const DOMNode* node, const std::string& name, + bool defaultValue) { + auto* str = node->findAttribute(name); + if (!str || str->empty()) { + return defaultValue; + } + return *str == "true" || *str == "1"; +} + +static const char* skipWhitespaceAndComma(const char* ptr, const char* end) { + while (ptr < end && (*ptr == ' ' || *ptr == '\t' || *ptr == ',')) { + ++ptr; + } + return ptr; +} + +static Point parsePoint(const std::string& str) { + Point point = {}; + const char* ptr = str.c_str(); + const char* end = ptr + str.size(); + char* endPtr = nullptr; + ptr = skipWhitespaceAndComma(ptr, end); + point.x = strtof(ptr, &endPtr); + if (endPtr > ptr) { + ptr = skipWhitespaceAndComma(endPtr, end); + point.y = strtof(ptr, &endPtr); + } + return point; +} + +static Size parseSize(const std::string& str) { + Size size = {}; + const char* ptr = str.c_str(); + const char* end = ptr + str.size(); + char* endPtr = nullptr; + ptr = skipWhitespaceAndComma(ptr, end); + size.width = strtof(ptr, &endPtr); + if (endPtr > ptr) { + ptr = skipWhitespaceAndComma(endPtr, end); + size.height = strtof(ptr, &endPtr); + } + return size; +} + +static Rect parseRect(const std::string& str) { + Rect rect = {}; + const char* ptr = str.c_str(); + const char* end = ptr + str.size(); + char* endPtr = nullptr; + ptr = skipWhitespaceAndComma(ptr, end); + rect.x = strtof(ptr, &endPtr); + if (endPtr > ptr) { + ptr = skipWhitespaceAndComma(endPtr, end); + rect.y = strtof(ptr, &endPtr); + } + if (endPtr > ptr) { + ptr = skipWhitespaceAndComma(endPtr, end); + rect.width = strtof(ptr, &endPtr); + } + if (endPtr > ptr) { + ptr = skipWhitespaceAndComma(endPtr, end); + rect.height = strtof(ptr, &endPtr); + } + return rect; +} + +namespace { +int parseHexDigit(char c) { + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + if (c >= 'A' && c <= 'F') { + return c - 'A' + 10; + } + return 0; +} +} // namespace + +static Color parseColor(const std::string& str) { + if (str.empty()) { + return {}; + } + // Hex format: #RGB, #RRGGBB, #RRGGBBAA (sRGB) + if (str[0] == '#') { + Color color = {}; + color.colorSpace = ColorSpace::SRGB; + if (str.size() == 4) { + int r = parseHexDigit(str[1]); + int g = parseHexDigit(str[2]); + int b = parseHexDigit(str[3]); + color.red = static_cast(r * 17) / 255.0f; + color.green = static_cast(g * 17) / 255.0f; + color.blue = static_cast(b * 17) / 255.0f; + color.alpha = 1.0f; + return color; + } + if (str.size() == 7 || str.size() == 9) { + int r = parseHexDigit(str[1]) * 16 + parseHexDigit(str[2]); + int g = parseHexDigit(str[3]) * 16 + parseHexDigit(str[4]); + int b = parseHexDigit(str[5]) * 16 + parseHexDigit(str[6]); + color.red = static_cast(r) / 255.0f; + color.green = static_cast(g) / 255.0f; + color.blue = static_cast(b) / 255.0f; + color.alpha = str.size() == 9 + ? static_cast(parseHexDigit(str[7]) * 16 + parseHexDigit(str[8])) / + 255.0f + : 1.0f; + return color; + } + } + // sRGB float format: srgb(r, g, b) or srgb(r, g, b, a) + // Display P3 format: p3(r, g, b) or p3(r, g, b, a) + struct FunctionalColorFormat { + const char* prefix; + size_t prefixLen; + ColorSpace colorSpace; + }; + static const FunctionalColorFormat formats[] = { + {"srgb(", 5, ColorSpace::SRGB}, + {"p3(", 3, ColorSpace::DisplayP3}, + }; + for (const auto& fmt : formats) { + if (str.compare(0, fmt.prefixLen, fmt.prefix) != 0) { + continue; + } + const char* ptr = str.c_str() + fmt.prefixLen; + const char* strEnd = str.c_str() + str.size(); + char* endPtr = nullptr; + float components[4] = {}; + int count = 0; + for (; count < 4 && ptr < strEnd && *ptr != ')'; ++count) { + ptr = skipWhitespaceAndComma(ptr, strEnd); + if (ptr >= strEnd || *ptr == ')') { + break; + } + components[count] = strtof(ptr, &endPtr); + if (endPtr == ptr) { + break; + } + ptr = endPtr; + } + if (count < 3) { + continue; + } + Color color = {}; + color.red = components[0]; + color.green = components[1]; + color.blue = components[2]; + color.alpha = count >= 4 ? components[3] : 1.0f; + color.colorSpace = fmt.colorSpace; + return color; + } + return {}; +} + + +//============================================================================== +// Public API implementation +//============================================================================== + +std::shared_ptr PAGXImporter::FromFile(const std::string& filePath) { + std::ifstream file(filePath, std::ios::binary | std::ios::ate); + if (!file) { + return nullptr; + } + auto fileSize = file.tellg(); + if (fileSize <= 0) { + return nullptr; + } + file.seekg(0, std::ios::beg); + std::string content; + content.resize(static_cast(fileSize)); + if (!file.read(&content[0], fileSize)) { + return nullptr; + } + + auto doc = FromXML(content); + if (doc) { + // Convert relative paths to absolute paths + std::string basePath = {}; + auto lastSlash = filePath.find_last_of("/\\"); + if (lastSlash != std::string::npos) { + basePath = filePath.substr(0, lastSlash + 1); + } + if (!basePath.empty()) { + 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; + } + } + } + } + } + return doc; +} + +std::shared_ptr PAGXImporter::FromXML(const std::string& xmlContent) { + return FromXML(reinterpret_cast(xmlContent.data()), xmlContent.size()); +} + +std::shared_ptr PAGXImporter::FromXML(const uint8_t* data, size_t length) { + auto dom = XMLDOM::Make(data, length); + if (!dom) { + return nullptr; + } + auto root = dom->getRootNode(); + if (!root || root->name != "pagx") { + return nullptr; + } + auto doc = std::shared_ptr(new PAGXDocument()); + parseDocument(root.get(), doc.get()); + return doc; +} + +static void parseDocument(const DOMNode* root, PAGXDocument* doc) { + doc->version = getAttribute(root, "version", "1.0"); + doc->width = getFloatAttribute(root, "width", 0); + doc->height = getFloatAttribute(root, "height", 0); + + // First pass: Parse Resources. + auto child = root->getFirstChild("Resources"); + if (child) { + parseResources(child.get(), doc); + } + + // Second pass: Parse Layers. + child = root->firstChild; + while (child) { + if (child->type == DOMNodeType::Element && child->name == "Layer") { + auto layer = parseLayer(child.get(), doc); + if (layer) { + doc->layers.push_back(layer); + } + } + child = child->nextSibling; + } +} + +} // namespace pagx diff --git a/src/pagx/PathData.cpp b/src/pagx/PathData.cpp new file mode 100644 index 0000000000..01fbbfc018 --- /dev/null +++ b/src/pagx/PathData.cpp @@ -0,0 +1,102 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#include "pagx/nodes/PathData.h" +#include + +namespace pagx { + +int PathData::PointsPerVerb(PathVerb verb) { + switch (verb) { + case PathVerb::Move: + return 1; + case PathVerb::Line: + return 1; + case PathVerb::Quad: + return 2; + case PathVerb::Cubic: + return 3; + case PathVerb::Close: + return 0; + default: + return 0; + } +} + +void PathData::moveTo(float x, float y) { + _verbs.push_back(PathVerb::Move); + _points.push_back({x, y}); + _boundsDirty = true; +} + +void PathData::lineTo(float x, float y) { + _verbs.push_back(PathVerb::Line); + _points.push_back({x, y}); + _boundsDirty = true; +} + +void PathData::quadTo(float cx, float cy, float x, float y) { + _verbs.push_back(PathVerb::Quad); + _points.push_back({cx, cy}); + _points.push_back({x, y}); + _boundsDirty = true; +} + +void PathData::cubicTo(float c1x, float c1y, float c2x, float c2y, float x, float y) { + _verbs.push_back(PathVerb::Cubic); + _points.push_back({c1x, c1y}); + _points.push_back({c2x, c2y}); + _points.push_back({x, y}); + _boundsDirty = true; +} + +void PathData::close() { + _verbs.push_back(PathVerb::Close); +} + +Rect PathData::getBounds() { + if (!_boundsDirty) { + return _cachedBounds; + } + + _cachedBounds = {}; + if (_points.empty()) { + _boundsDirty = false; + return _cachedBounds; + } + + float minX = _points[0].x; + float minY = _points[0].y; + float maxX = _points[0].x; + float maxY = _points[0].y; + + for (size_t i = 1; i < _points.size(); i++) { + float x = _points[i].x; + float y = _points[i].y; + minX = std::min(minX, x); + minY = std::min(minY, y); + maxX = std::max(maxX, x); + maxY = std::max(maxY, y); + } + + _cachedBounds = Rect::MakeLTRB(minX, minY, maxX, maxY); + _boundsDirty = false; + return _cachedBounds; +} + +} // namespace pagx diff --git a/src/pagx/svg/SVGImporter.cpp b/src/pagx/svg/SVGImporter.cpp new file mode 100644 index 0000000000..9d7f4d44c7 --- /dev/null +++ b/src/pagx/svg/SVGImporter.cpp @@ -0,0 +1,2665 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +#include "pagx/SVGImporter.h" +#include +#include +#include +#include "utils/StringParser.h" +#include "SVGPathParser.h" +#include "utils/MathUtil.h" +#include "pagx/PAGXDocument.h" +#include "pagx/nodes/Image.h" +#include "pagx/nodes/SolidColor.h" +#include "SVGParserContext.h" +#include "xml/XMLDOM.h" + +namespace pagx { + +static constexpr float DEFAULT_FONT_SIZE = 16.0f; +std::shared_ptr SVGImporter::Parse(const std::string& filePath, + const Options& options) { + SVGParserContext parser(options); + auto doc = parser.parseFile(filePath); + if (doc) { + // Convert relative paths to absolute paths + std::string basePath = {}; + auto lastSlash = filePath.find_last_of("/\\"); + if (lastSlash != std::string::npos) { + basePath = filePath.substr(0, lastSlash + 1); + } + if (!basePath.empty()) { + 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.find("data:") != 0) { + image->filePath = basePath + image->filePath; + } + } + } + } + } + return doc; +} + +std::shared_ptr SVGImporter::Parse(const uint8_t* data, size_t length, + const Options& options) { + SVGParserContext parser(options); + return parser.parse(data, length); +} + +std::shared_ptr SVGImporter::ParseString(const std::string& svgContent, + const Options& options) { + return Parse(reinterpret_cast(svgContent.data()), svgContent.size(), options); +} +// ============== SVGParserContext ============== + +SVGParserContext::SVGParserContext(const SVGImporter::Options& options) : _options(options) { +} + +std::shared_ptr SVGParserContext::parse(const uint8_t* data, size_t length) { + if (!data || length == 0) { + return nullptr; + } + + auto dom = XMLDOM::Make(data, length); + if (!dom) { + return nullptr; + } + + return parseDOM(dom); +} + +std::shared_ptr SVGParserContext::parseFile(const std::string& filePath) { + auto dom = XMLDOM::MakeFromFile(filePath); + if (!dom) { + return nullptr; + } + + return parseDOM(dom); +} +std::shared_ptr SVGParserContext::parseDOM(const std::shared_ptr& dom) { + auto root = dom->getRootNode(); + if (!root || root->name != "svg") { + return nullptr; + } + + // Parse viewBox and dimensions. + // When viewBox is present, use viewBox dimensions for the PAGX document size, + // because PAGX doesn't support viewBox and all SVG coordinates are in viewBox space. + // The explicit width/height with unit conversions (e.g., "1080pt" -> 1440px) are ignored + // to avoid coordinate mismatch. + auto viewBox = parseViewBox(getAttribute(root, "viewBox")); + float width = 0; + float height = 0; + + if (viewBox.size() >= 4) { + _viewBoxWidth = viewBox[2]; + _viewBoxHeight = viewBox[3]; + width = _viewBoxWidth; + height = _viewBoxHeight; + } else { + width = parseLength(getAttribute(root, "width"), 0); + height = parseLength(getAttribute(root, "height"), 0); + _viewBoxWidth = width; + _viewBoxHeight = height; + } + + if (width <= 0 || height <= 0) { + return nullptr; + } + + _document = PAGXDocument::Make(width, height); + + // Collect all IDs from the SVG to avoid conflicts when generating new IDs. + collectAllIds(root); + + // First pass: collect defs. + auto child = root->getFirstChild(); + while (child) { + if (child->name == "defs") { + parseDefs(child); + } + child = child->getNextSibling(); + } + + // Parse