@sliiu/present-renderer 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/COPYRIGHT +5 -0
  2. package/LICENSE +661 -0
  3. package/README.md +8 -2
  4. package/THIRD-PARTY-NOTICES.md +221 -0
  5. package/dist/cjs/arrow-geom.js +38 -0
  6. package/dist/cjs/canvas-backend.js +1368 -0
  7. package/dist/cjs/cell-borders.js +25 -0
  8. package/dist/cjs/chart-canvas.js +1059 -0
  9. package/dist/cjs/chart-ex-render.js +275 -0
  10. package/dist/cjs/chart-geom.js +313 -0
  11. package/dist/cjs/chart-svg.js +1110 -0
  12. package/dist/cjs/compose.js +74 -0
  13. package/dist/cjs/effect-clip.js +652 -0
  14. package/dist/cjs/entrance-hide.js +63 -0
  15. package/dist/cjs/font-stack.js +103 -0
  16. package/dist/cjs/hit-geom.js +50 -0
  17. package/dist/cjs/hybrid-backend.js +93 -0
  18. package/dist/cjs/hybrid-segments.js +54 -0
  19. package/dist/cjs/index.js +70 -0
  20. package/dist/cjs/linked-src.js +116 -0
  21. package/dist/cjs/metafile-preview.js +142 -0
  22. package/dist/cjs/model3d-frame.js +199 -0
  23. package/dist/cjs/model3d-render.js +78 -0
  24. package/dist/cjs/morph-transition.js +57 -0
  25. package/dist/cjs/node-affine.js +79 -0
  26. package/dist/cjs/ole-render.js +291 -0
  27. package/dist/cjs/package.json +1 -0
  28. package/dist/cjs/paint-canvas.js +701 -0
  29. package/dist/cjs/paint-svg.js +674 -0
  30. package/dist/cjs/scene-node.js +762 -0
  31. package/dist/cjs/selection.js +130 -0
  32. package/dist/cjs/shape-effects.js +66 -0
  33. package/dist/cjs/shape-front.js +38 -0
  34. package/dist/cjs/stage-chrome.js +15 -0
  35. package/dist/cjs/svg-backend.js +2172 -0
  36. package/dist/cjs/text-autofit.js +48 -0
  37. package/dist/cjs/text-hit.js +284 -0
  38. package/dist/cjs/text-layout.js +408 -0
  39. package/dist/cjs/text-shared.js +238 -0
  40. package/dist/cjs/text-stacked.js +82 -0
  41. package/dist/cjs/text-warp.js +377 -0
  42. package/dist/cjs/text-wordart.js +33 -0
  43. package/dist/cjs/video-frame.js +113 -0
  44. package/dist/cjs/webgl-model3d-backend.js +285 -0
  45. package/dist/cjs/webgl-shape-backend.js +360 -0
  46. package/dist/esm/arrow-geom.js +33 -0
  47. package/dist/esm/canvas-backend.js +1363 -0
  48. package/dist/esm/cell-borders.js +21 -0
  49. package/dist/esm/chart-canvas.js +1056 -0
  50. package/dist/esm/chart-ex-render.js +261 -0
  51. package/dist/esm/chart-geom.js +296 -0
  52. package/dist/esm/chart-svg.js +1107 -0
  53. package/dist/esm/compose.js +71 -0
  54. package/dist/esm/effect-clip.js +648 -0
  55. package/dist/esm/entrance-hide.js +59 -0
  56. package/dist/esm/font-stack.js +100 -0
  57. package/dist/esm/hit-geom.js +45 -0
  58. package/dist/esm/hybrid-backend.js +89 -0
  59. package/dist/esm/hybrid-segments.js +51 -0
  60. package/dist/esm/index.js +24 -0
  61. package/dist/esm/linked-src.js +109 -0
  62. package/dist/esm/metafile-preview.js +135 -0
  63. package/dist/esm/model3d-frame.js +158 -0
  64. package/dist/esm/model3d-render.js +73 -0
  65. package/dist/esm/morph-transition.js +54 -0
  66. package/dist/esm/node-affine.js +71 -0
  67. package/dist/esm/ole-render.js +286 -0
  68. package/dist/esm/paint-canvas.js +693 -0
  69. package/dist/esm/paint-svg.js +670 -0
  70. package/dist/esm/scene-node.js +750 -0
  71. package/dist/esm/selection.js +122 -0
  72. package/dist/esm/shape-effects.js +60 -0
  73. package/dist/esm/shape-front.js +35 -0
  74. package/dist/esm/stage-chrome.js +12 -0
  75. package/dist/esm/svg-backend.js +2168 -0
  76. package/dist/esm/text-autofit.js +44 -0
  77. package/dist/esm/text-hit.js +280 -0
  78. package/dist/esm/text-layout.js +400 -0
  79. package/dist/esm/text-shared.js +221 -0
  80. package/dist/esm/text-stacked.js +78 -0
  81. package/dist/esm/text-warp.js +371 -0
  82. package/dist/esm/text-wordart.js +30 -0
  83. package/dist/esm/video-frame.js +109 -0
  84. package/dist/esm/webgl-model3d-backend.js +248 -0
  85. package/dist/esm/webgl-shape-backend.js +323 -0
  86. package/dist/types/arrow-geom.d.ts +10 -0
  87. package/dist/types/canvas-backend.d.ts +55 -0
  88. package/dist/types/cell-borders.d.ts +9 -0
  89. package/dist/types/chart-canvas.d.ts +2 -0
  90. package/dist/types/chart-ex-render.d.ts +79 -0
  91. package/dist/types/chart-geom.d.ts +112 -0
  92. package/dist/types/chart-svg.d.ts +2 -0
  93. package/dist/types/compose.d.ts +2 -0
  94. package/dist/types/effect-clip.d.ts +10 -0
  95. package/dist/types/entrance-hide.d.ts +16 -0
  96. package/dist/types/font-stack.d.ts +9 -0
  97. package/dist/types/hit-geom.d.ts +15 -0
  98. package/dist/types/hybrid-backend.d.ts +21 -0
  99. package/dist/types/hybrid-segments.d.ts +11 -0
  100. package/dist/types/index.d.ts +24 -0
  101. package/dist/types/linked-src.d.ts +13 -0
  102. package/dist/types/metafile-preview.d.ts +11 -0
  103. package/dist/types/model3d-frame.d.ts +8 -0
  104. package/dist/types/model3d-render.d.ts +4 -0
  105. package/dist/types/morph-transition.d.ts +15 -0
  106. package/dist/types/node-affine.d.ts +22 -0
  107. package/dist/types/ole-render.d.ts +7 -0
  108. package/dist/types/paint-canvas.d.ts +22 -0
  109. package/dist/types/paint-svg.d.ts +19 -0
  110. package/dist/types/scene-node.d.ts +128 -0
  111. package/dist/types/selection.d.ts +22 -0
  112. package/dist/types/shape-effects.d.ts +13 -0
  113. package/dist/types/shape-front.d.ts +4 -0
  114. package/dist/types/stage-chrome.d.ts +8 -0
  115. package/dist/types/svg-backend.d.ts +88 -0
  116. package/dist/types/text-autofit.d.ts +7 -0
  117. package/dist/types/text-hit.d.ts +7 -0
  118. package/dist/types/text-layout.d.ts +35 -0
  119. package/dist/types/text-shared.d.ts +86 -0
  120. package/dist/types/text-stacked.d.ts +11 -0
  121. package/dist/types/text-warp.d.ts +37 -0
  122. package/dist/types/text-wordart.d.ts +18 -0
  123. package/dist/types/video-frame.d.ts +2 -0
  124. package/dist/types/webgl-model3d-backend.d.ts +25 -0
  125. package/dist/types/webgl-shape-backend.d.ts +33 -0
  126. package/package.json +43 -4
@@ -0,0 +1,648 @@
1
+ // ECMA-376 §19.5.8 animEffect filter 的几何实现。
2
+ // 把 (filter, prLst, progress, transition) 转成 SVG path d,供 SVG clipPath / Canvas
3
+ // Path2D 共用。返回的 path 永远定义"可见区域"。
4
+ // 规范定义的 filter 值:
5
+ // barn / blinds / box / checkerboard / circle / diamond / dissolve / fade /
6
+ // image / pixelate / plus / randombar / slide / stretch / strips / wedge / wheel / wipe
7
+ // prLst 常见取值:horz / vert / in / out / horz(in) / vert(out) / fromLeft / fromTop ...
8
+ //
9
+ // §19.5.8 entrance / exit 语义(与 PowerPoint 实际行为一致):
10
+ // filter 描述一个随时长向前推进的"sweep 边界"(例如 box(in) = 边框从外缘向中心
11
+ // 收缩、wipe(down) = 水平线从上往下扫)。
12
+ // - entrance: 已扫过的区域 = 可见区。runtime 把 val 从 0 → 1。
13
+ // - exit: 已扫过的区域 = 隐藏区。runtime 把 val 从 1 → 0。
14
+ //
15
+ // 因而 entrance 和 exit 在同一 val 下的可见区是"互补"关系,公式:
16
+ // visible_exit(val) = fullRect ∖ visible_entrance(1-val)
17
+ //
18
+ // 这条规律对所有 sweep 类 filter(box/circle/diamond/plus/barn/wipe/blinds/
19
+ // wedge/wheel/checkerboard/randombar/strips/dissolve)都成立。runtime 已经把
20
+ // transition 信息一并写入 EffectGeom.transition,这里在 entrance 几何外面包
21
+ // 一层 complement 即可——无需为每个 filter 单独写一套 exit 几何。
22
+ //
23
+ // fade / image 不属于 sweep 类,可见区永远是 fullRect,opacity 通道单独管
24
+ // visibility(见 svg-backend 的 fadeOpacityFromClip)。
25
+ const TWO_PI = Math.PI * 2;
26
+ // 解析 prLst 中的方向语义。
27
+ // PowerPoint 实际取值(ECMA-376 §19.5.8)举例:
28
+ // horizontal / vertical / across / inHorizontal / outHorizontal /
29
+ // inVertical / outVertical / in / out / inDownward / outFromCenter /
30
+ // down / up / left / right / downward / upward / leftward / rightward /
31
+ // fromBottom / fromTop / fromLeft / fromRight / horizontalIn / verticalOut ...
32
+ // side 表示"内容显现的起点边"(wipePath 据此确定 reveal 锚定边):
33
+ // left=从左侧显现向右扩(L→R),right=从右侧向左扩(R→L),
34
+ // top=从顶部向下扩(上→下),bottom=从底部向上扩(下→上)。
35
+ //
36
+ // §19.5.8 wipe 方向 token 是"混合约定"(与 PowerPoint 实际渲染一致,参 22.x 预设:
37
+ // subtype1=wipe(up)=From Bottom,subtype8=wipe(left)=From Left):
38
+ // - 水平 token 用"起点边"语义:wipe(left)="From Left"=L→R,wipe(right)="From Right"=R→L。
39
+ // (token 名 left/right 本就是边名,PowerPoint UI 即 "自左侧/自右侧"。)
40
+ // - 垂直 token 用"运动方向"语义:wipe(up)=向上扫=从底部显现(下→上)=From Bottom;
41
+ // wipe(down)=向下扫=从顶部显现(上→下)=From Top。(token 名 up/down 是运动词,
42
+ // 不是边名 top/bottom。)
43
+ // 显式 from* token(fromLeft/fromRight/fromTop/fromBottom)一律按起点边,
44
+ // 与水平裸 token 同向:fromLeft→left、fromRight→right。
45
+ function parseDir(prLst) {
46
+ const s = (prLst ?? "").toLowerCase();
47
+ const axis = /vertical|vert/.test(s)
48
+ ? "vert"
49
+ : /horizontal|across|horz/.test(s)
50
+ ? "horz"
51
+ : "diag";
52
+ // 检测 in/out 前后缀,避免与 "horiz**in**ate" 等不存在的字串误匹配。
53
+ const inOut = /(?:^|[^a-z])in(?:[a-z]|$)|inhorizontal|invertical|\(in\)|^in$/.test(s)
54
+ ? "in"
55
+ : /(?:^|[^a-z])out(?:[a-z]|$)|outhorizontal|outvertical|\(out\)|^out$/.test(s)
56
+ ? "out"
57
+ : "none";
58
+ let side = "center";
59
+ // 垂直:运动方向语义(up=向上扫=从底部显现)。fromTop/fromBottom 为起点边语义。
60
+ if (/down|downward|fromtop/.test(s))
61
+ side = "top";
62
+ else if (/up|upward|frombottom/.test(s))
63
+ side = "bottom";
64
+ // 水平:起点边语义(left=从左侧显现→向右扩 L→R)。裸 left/right 与 fromLeft/fromRight 同向。
65
+ else if (/(^|[^a-z])left|leftward|fromleft/.test(s))
66
+ side = "left";
67
+ else if (/(^|[^a-z])right|rightward|fromright/.test(s))
68
+ side = "right";
69
+ return { axis, inOut, side };
70
+ }
71
+ const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
72
+ // 整体可见矩形,作为兜底(progress=1)和 dissolve 等"非几何"滤镜使用。
73
+ function fullRect(b) {
74
+ return `M ${b.x} ${b.y} h ${b.w} v ${b.h} h ${-b.w} Z`;
75
+ }
76
+ function emptyPath() {
77
+ return "";
78
+ }
79
+ // blinds:n 条平行带从 0 → bandWidth*p 增长。
80
+ function blindsPath(b, axis, p, count = 6) {
81
+ const n = Math.max(2, count);
82
+ if (axis === "horz") {
83
+ const band = b.h / n;
84
+ let d = "";
85
+ for (let i = 0; i < n; i++) {
86
+ const y = b.y + i * band;
87
+ const h = band * p;
88
+ d += `M ${b.x} ${y} h ${b.w} v ${h} h ${-b.w} Z `;
89
+ }
90
+ return d.trim();
91
+ }
92
+ const band = b.w / n;
93
+ let d = "";
94
+ for (let i = 0; i < n; i++) {
95
+ const x = b.x + i * band;
96
+ const w = band * p;
97
+ d += `M ${x} ${b.y} v ${b.h} h ${w} v ${-b.h} Z `;
98
+ }
99
+ return d.trim();
100
+ }
101
+ // strips:N 条水平条带沿对角方向 snap-to-grid 的"阶梯擦除"。
102
+ //
103
+ // §19.5.8 strips(corner) 视觉特征(与 WPS / PowerPoint 实际渲染一致):
104
+ // - 把 bbox 切成 N 条等高水平条带
105
+ // - 每条带的 reveal 边界 x 不同,整体形成一条贴着对角线的"阶梯(staircase)"
106
+ // - p 推进时,阶梯保持形状,沿对角法线整体平移;视觉是"砖块层层叠加铺过去"
107
+ // - 因为条带高度 = H/N 固定、阶梯水平段长度 = H/N(由 wavefront 几何决定),
108
+ // 每一格约等于正方形小砖(与 WPS 截图一致)
109
+ //
110
+ // 4 个 corner 子类型代表 wave 行进方向,对应不同的起点角:
111
+ // downRight:起点 = upLeft (0,0),wave 朝 +x+y 推进 → 显现先在左上、终在右下
112
+ // downLeft: 起点 = upRight (W,0),wave 朝 -x+y 推进 → 显现先在右上、终在左下
113
+ // upRight: 起点 = downLeft (0,H),wave 朝 +x-y 推进 → 显现先在左下、终在右上
114
+ // upLeft: 起点 = downRight(W,H),wave 朝 -x-y 推进 → 显现先在右下、终在左上
115
+ //
116
+ // 统一映射到参考帧 "downRight":起点左上、wavefront 沿 +x+y、可见区在 wavefront 左上侧。
117
+ // 砖块尺寸:WPS / PowerPoint 把 bbox 长宽各 13 等分,单砖 = W/13 × H/13(aspect 跟随 bbox)。
118
+ // - N = 13 条水平 strip,每条高 H/13
119
+ // - 阶梯横向 step 长度 = W/13(不是 stripH,所以单砖不是方形而是与 bbox 同比例的长方形)
120
+ // wavefront 方程:x/W + y/H = 2·p(线性插值的"对角进度")
121
+ // - p=0 → 直线退化到 (0,0) → 可见区为空
122
+ // - p=0.5 → 直线 = 主对角线 (0,H) - (W,0) → 可见区 = 左上半三角
123
+ // - p=1 → 直线退化到对角 (W,H) → 可见区 = fullRect
124
+ // 等价写法:front = (W + ASPECT·H)·p,其中 ASPECT = W/H 把垂直距离归一到水平等价距离。
125
+ //
126
+ // 阶梯 snap:对每条 strip i(y ∈ [i·stripH, (i+1)·stripH],0=top),取 strip 顶边 y = i·stripH
127
+ // 处的 wavefront x:x_i = front - ASPECT·i·stripH = W·(2p - i/N)。strip i 的可见 x 范围
128
+ // = [0, clamp(x_i, 0, W)]。取顶边而非中线/底边能让阶梯外轮廓正好贴住 wavefront 切线、
129
+ // 相邻 strip 共边无缝衔接。
130
+ //
131
+ // 几何:用一条单一闭合多边形描述整片可见区域:左上角起,沿 top → strip 顶部水平段 →
132
+ // 阶梯下行(H-V-H-V...)→ 右下区域 → 左侧回到起点。NONZERO winding 即可填充,
133
+ // 不需要 even-odd(也不会产生 strips 之间的反走样缝)。
134
+ //
135
+ // 注:OOXML §19.5.3 明确"Exact rendering of any animation is determined by the
136
+ // rendering application",N 由实现决定。N=13 与 WPS / PowerPoint 一致。
137
+ const STRIPS_BAND_COUNT = 13;
138
+ function stripsPath(b, p, corner) {
139
+ if (p <= 0)
140
+ return emptyPath();
141
+ if (p >= 1)
142
+ return fullRect(b);
143
+ const W = b.w;
144
+ const H = b.h;
145
+ const N = STRIPS_BAND_COUNT;
146
+ const stripH = H / N;
147
+ // aspect = W/H 让阶梯横向 step = W/N、纵向 step = H/N,单砖与 bbox 长宽比一致。
148
+ // H=0 退化保护:直接用 1,避免除 0(实际上 H=0 时 stripH=0,整个动画不可见)。
149
+ const aspect = H > 0 ? W / H : 1;
150
+ // 4 corner → flipX / flipY 映射到参考帧 downRight(起点左上、wavefront +x+y)。
151
+ const flipX = corner === "downLeft" || corner === "upLeft";
152
+ const flipY = corner === "upRight" || corner === "upLeft";
153
+ // 参考帧下每条 strip 顶边 y 处的 wavefront x(可见区 x 范围 = [0, x_i])。
154
+ // wavefront 方程:x/W + y/H = 2·p,等价 x + aspect·y = (W+aspect·H)·p = 2W·p(因 aspect·H = W)。
155
+ const front = (W + aspect * H) * p;
156
+ const xAtRow = (rowY) => clamp(front - aspect * rowY, 0, W);
157
+ // 收集每条 strip 的右边界 x(参考帧)。
158
+ // strip i 顶边 y = i·stripH(0 = 最上方 strip)。
159
+ const xs = [];
160
+ for (let i = 0; i < N; i++) {
161
+ xs.push(xAtRow(i * stripH));
162
+ }
163
+ // 末位用底边 y = H 处的 wavefront x 作为底部水平段的 x(保证多边形闭合到 (xBot, H))。
164
+ const xBot = xAtRow(H);
165
+ // 找出有可见 reveal 的 strip 范围。所有 strip 都未 reveal → emptyPath。
166
+ let firstWithReveal = -1;
167
+ for (let i = 0; i < N; i++) {
168
+ if (xs[i] > 0) {
169
+ firstWithReveal = i;
170
+ break;
171
+ }
172
+ }
173
+ if (firstWithReveal === -1 && xBot <= 0)
174
+ return emptyPath();
175
+ // 构造单条闭合阶梯多边形(参考帧顺时针)。
176
+ // 路径:从 (0, 0) 开始 →
177
+ // top edge: (0,0) → (x_0, 0)
178
+ // 阶梯下行:对每条 strip i,先垂直到 (x_i, (i+1)·stripH),再水平到 (x_{i+1}, (i+1)·stripH)
179
+ // 末段:垂直到 (xBot, H),再水平回 (0, H),最后左边垂直回 (0, 0)
180
+ //
181
+ // 起点 (0, 0) 永远在可见区域内(参考帧起点角),所以多边形总是连通完整。
182
+ const verts = [];
183
+ verts.push([0, 0]);
184
+ verts.push([xs[0], 0]);
185
+ for (let i = 0; i < N; i++) {
186
+ const yBot = (i + 1) * stripH;
187
+ // 垂直下到 strip i 底边
188
+ verts.push([xs[i], yBot]);
189
+ // 水平到下一条 strip 顶边 x(最后一条用 xBot 作为底边水平段终点)
190
+ const xNext = i + 1 < N ? xs[i + 1] : xBot;
191
+ if (xNext !== xs[i])
192
+ verts.push([xNext, yBot]);
193
+ }
194
+ verts.push([0, H]);
195
+ // 反射 + 平移到 bbox 坐标。
196
+ const xform = (xy) => {
197
+ const x = flipX ? W - xy[0] : xy[0];
198
+ const y = flipY ? H - xy[1] : xy[1];
199
+ return [b.x + x, b.y + y];
200
+ };
201
+ let pts = verts.map(xform);
202
+ // 单方向翻转把顺时针 → 逆时针;反转回顺时针保持 winding 一致(NONZERO 下都能填充,
203
+ // 但反转方向后 stroke 等顺序敏感操作仍然正确)。
204
+ if (flipX !== flipY)
205
+ pts = pts.slice().reverse();
206
+ let d = `M ${pts[0][0]} ${pts[0][1]} `;
207
+ for (let i = 1; i < pts.length; i++)
208
+ d += `L ${pts[i][0]} ${pts[i][1]} `;
209
+ return d + "Z";
210
+ }
211
+ // checkerboard:n×n 棋盘格 reveal。
212
+ // §19.5.8 checkerboard(across) → 从左到右逐列扫过;(down) → 从上到下逐行。
213
+ // 算法:把每个 cell 的"激活时间"按主方向 + 棋盘奇偶性映射到 [0,1],
214
+ // cell 内部按副方向 wipe。这样两种方向的视觉差异与 PowerPoint 接近。
215
+ function checkerboardPath(b, primary, p, n = 8) {
216
+ const cw = b.w / n;
217
+ const ch = b.h / n;
218
+ let d = "";
219
+ for (let r = 0; r < n; r++) {
220
+ for (let c = 0; c < n; c++) {
221
+ // 主方向上的归一化进度(0..1);奇/偶 cell 错开半相位形成棋盘节奏。
222
+ const major = primary === "horz" ? c / Math.max(1, n - 1) : r / Math.max(1, n - 1);
223
+ const phase = ((r + c) % 2 === 0 ? 0 : 0.5) / n;
224
+ const start = major * 0.5 + phase;
225
+ const end = start + 1 / n;
226
+ if (p >= end) {
227
+ d += `M ${b.x + c * cw} ${b.y + r * ch} h ${cw} v ${ch} h ${-cw} Z `;
228
+ }
229
+ else if (p > start) {
230
+ const local = (p - start) / (end - start);
231
+ if (primary === "horz") {
232
+ const w = cw * local;
233
+ d += `M ${b.x + c * cw} ${b.y + r * ch} h ${w} v ${ch} h ${-w} Z `;
234
+ }
235
+ else {
236
+ const h = ch * local;
237
+ d += `M ${b.x + c * cw} ${b.y + r * ch} h ${cw} v ${h} h ${-cw} Z `;
238
+ }
239
+ }
240
+ }
241
+ }
242
+ return d.trim();
243
+ }
244
+ // §19.5.8 box / circle / diamond 的 in/out 子类型语义(与 PDF transition /M /I /O
245
+ // 同源、与 PowerPoint 实际渲染一致):
246
+ // (in) inward sweep :边框从 bbox 外缘向中心收缩,"中心孔"由 full 缩小到 0。
247
+ // progress=p 时,可见区 = fullRect ∖ 中心几何(尺寸 = (1-p)·full)。
248
+ // 视觉上元素从四周(环形)逐步显现到中心。
249
+ // (out) outward sweep:几何从中心向外缘扩张,"中心实体"由 0 长到 full。
250
+ // progress=p 时,可见区 = 中心几何(尺寸 = p·full)。
251
+ // 视觉上元素从中心一个小点逐步胀出到全屏。
252
+ //
253
+ // runtime(runtime.ts)已通过 transition="out" → val = 1 - u 把"进入/退出"统一为
254
+ // "progress=1 完全可见, progress=0 完全不可见",所以 filter 端只需关心 (in)/(out)
255
+ // 这个几何方向,不必区分 entrance/exit。两端边界统一:
256
+ // p≤0 → emptyPath;p≥1 → fullRect(由 effectClipPathD 顶层 guard 兜底)。
257
+ //
258
+ // 历史 bug:之前实现把 (in) 写成"中心扩张"、(out) 写成"外环收缩",刚好两个分支
259
+ // 颠倒。导致 Box/Circle/Diamond 进入动画方向看起来与 PowerPoint 相反——例如
260
+ // Box(in) 进入本应该是"外环逐步覆盖到中心",被实现成了"中心方框由小到大"。
261
+ function circlePath(b, p, inOut) {
262
+ const cx = b.x + b.w / 2;
263
+ const cy = b.y + b.h / 2;
264
+ const Rx = b.w / 2;
265
+ const Ry = b.h / 2;
266
+ const R = Math.hypot(b.w, b.h) / 2;
267
+ if (inOut === "in") {
268
+ // 外环 + 收缩孔:孔的半径 = R·(1-p)
269
+ if (p <= 0)
270
+ return emptyPath();
271
+ const f = 1 - p;
272
+ const rx = Rx * f;
273
+ const ry = Ry * f;
274
+ return `${fullRect(b)} M ${cx + rx} ${cy} A ${rx} ${ry} 0 1 0 ${cx - rx} ${cy} A ${rx} ${ry} 0 1 0 ${cx + rx} ${cy} Z`;
275
+ }
276
+ // 从中心扩张的椭圆:半径 = R·p(按 bbox 长宽比缩放)
277
+ const r = R * p;
278
+ if (r <= 0)
279
+ return emptyPath();
280
+ return `M ${cx + r * (Rx / R)} ${cy} A ${r * (Rx / R)} ${r * (Ry / R)} 0 1 0 ${cx - r * (Rx / R)} ${cy} A ${r * (Rx / R)} ${r * (Ry / R)} 0 1 0 ${cx + r * (Rx / R)} ${cy} Z`;
281
+ }
282
+ function diamondPath(b, p, inOut) {
283
+ const cx = b.x + b.w / 2;
284
+ const cy = b.y + b.h / 2;
285
+ const hx = b.w / 2;
286
+ const hy = b.h / 2;
287
+ if (inOut === "in") {
288
+ if (p <= 0)
289
+ return emptyPath();
290
+ const f = 1 - p;
291
+ return `${fullRect(b)} M ${cx} ${cy - hy * f} L ${cx + hx * f} ${cy} L ${cx} ${cy + hy * f} L ${cx - hx * f} ${cy} Z`;
292
+ }
293
+ if (p <= 0)
294
+ return emptyPath();
295
+ return `M ${cx} ${cy - hy * p} L ${cx + hx * p} ${cy} L ${cx} ${cy + hy * p} L ${cx - hx * p} ${cy} Z`;
296
+ }
297
+ function boxPath(b, p, inOut) {
298
+ const cx = b.x + b.w / 2;
299
+ const cy = b.y + b.h / 2;
300
+ if (inOut === "in") {
301
+ if (p <= 0)
302
+ return emptyPath();
303
+ const f = 1 - p;
304
+ const w = b.w * f;
305
+ const h = b.h * f;
306
+ return `${fullRect(b)} M ${cx - w / 2} ${cy - h / 2} h ${w} v ${h} h ${-w} Z`;
307
+ }
308
+ if (p <= 0)
309
+ return emptyPath();
310
+ const w = b.w * p;
311
+ const h = b.h * p;
312
+ return `M ${cx - w / 2} ${cy - h / 2} h ${w} v ${h} h ${-w} Z`;
313
+ }
314
+ // 12 顶点的"+"字形多边形(顺时针,单条 closed sub-path)。
315
+ // 用单条 path 而不是两个 overlap 矩形是为了让 even-odd 计算 hole 时
316
+ // 中心交叉处 winding = 1(不是 2),从而正确表达 cross 内部为单一连通区域。
317
+ // 退化情况:cross 覆盖 bbox 时返回 fullRect;任一维度 ≤ 0 时退化为单矩形或空。
318
+ function crossPoly(b, hH, vW) {
319
+ if (hH >= b.h || vW >= b.w)
320
+ return fullRect(b);
321
+ if (hH <= 0 && vW <= 0)
322
+ return emptyPath();
323
+ const cx = b.x + b.w / 2;
324
+ const cy = b.y + b.h / 2;
325
+ const halfH = hH / 2;
326
+ const halfW = vW / 2;
327
+ if (hH <= 0)
328
+ return `M ${cx - halfW} ${b.y} h ${vW} v ${b.h} h ${-vW} Z`;
329
+ if (vW <= 0)
330
+ return `M ${b.x} ${cy - halfH} h ${b.w} v ${hH} h ${-b.w} Z`;
331
+ return (`M ${cx - halfW} ${b.y} ` +
332
+ `L ${cx + halfW} ${b.y} ` +
333
+ `L ${cx + halfW} ${cy - halfH} ` +
334
+ `L ${b.x + b.w} ${cy - halfH} ` +
335
+ `L ${b.x + b.w} ${cy + halfH} ` +
336
+ `L ${cx + halfW} ${cy + halfH} ` +
337
+ `L ${cx + halfW} ${b.y + b.h} ` +
338
+ `L ${cx - halfW} ${b.y + b.h} ` +
339
+ `L ${cx - halfW} ${cy + halfH} ` +
340
+ `L ${b.x} ${cy + halfH} ` +
341
+ `L ${b.x} ${cy - halfH} ` +
342
+ `L ${cx - halfW} ${cy - halfH} Z`);
343
+ }
344
+ // plus:与 box/circle/diamond 同源的 inward/outward 几何 filter。
345
+ // §19.5.8 plus(in|out):
346
+ // (in) inward sweep :十字形从外向中心收拢 → 可见 = fullRect ∖ 中心收缩"+"孔。
347
+ // progress=p 时,cross 臂宽/高 = full·(1-p);
348
+ // 视觉上 4 个角先显,然后向中心合拢直到铺满。
349
+ // (out) outward sweep:十字形从中心向外扩张 → 可见 = 单个增长的"+"。
350
+ // progress=p 时,cross 臂宽/高 = full·p;
351
+ // 视觉上中心先冒出小十字,然后向四角延伸直到铺满。
352
+ // runtime 已通过 transition 反转 progress,filter 端只需用 inOut 决定几何分支。
353
+ function plusPath(b, p, inOut) {
354
+ if (p <= 0)
355
+ return emptyPath();
356
+ if (p >= 1)
357
+ return fullRect(b);
358
+ if (inOut === "in") {
359
+ const f = 1 - p;
360
+ const hH = b.h * f;
361
+ const vW = b.w * f;
362
+ return `${fullRect(b)} ${crossPoly(b, hH, vW)}`;
363
+ }
364
+ // outward:十字本身从 0 长到 full
365
+ return crossPoly(b, b.h * p, b.w * p);
366
+ }
367
+ // wedge:单个扇形从 12 点顺时针扫。
368
+ // SVG A 在扫过近 360° 时起终点重合,弧会退化成缺口,wheel(1) 收不拢。
369
+ // 用折线扇形,扫满后交给上层 fullRect。
370
+ function wedgePath(b, p) {
371
+ const angle = TWO_PI * clamp01(p);
372
+ if (angle <= 0)
373
+ return emptyPath();
374
+ if (angle >= TWO_PI - 1e-3)
375
+ return fullRect(b);
376
+ const cx = b.x + b.w / 2;
377
+ const cy = b.y + b.h / 2;
378
+ const R = Math.hypot(b.w, b.h);
379
+ const a0 = -Math.PI / 2;
380
+ const steps = Math.max(8, Math.ceil(48 * (angle / TWO_PI)));
381
+ let d = `M ${cx} ${cy} `;
382
+ for (let s = 0; s <= steps; s++) {
383
+ const a = a0 + angle * (s / steps);
384
+ d += `L ${cx + R * Math.cos(a)} ${cy + R * Math.sin(a)} `;
385
+ }
386
+ return `${d}Z`;
387
+ }
388
+ // wheel:N 辐条同步旋转。复用 wedge 数学。
389
+ function wheelPath(b, p, spokes = 1) {
390
+ const n = Math.max(1, spokes);
391
+ if (n === 1)
392
+ return wedgePath(b, p);
393
+ const cx = b.x + b.w / 2;
394
+ const cy = b.y + b.h / 2;
395
+ const R = Math.hypot(b.w, b.h);
396
+ const span = TWO_PI / n;
397
+ const reveal = span * clamp01(p);
398
+ let d = "";
399
+ const steps = Math.max(2, Math.ceil(24 / n));
400
+ for (let i = 0; i < n; i++) {
401
+ const a0 = i * span - Math.PI / 2;
402
+ d += `M ${cx} ${cy} `;
403
+ for (let s = 0; s <= steps; s++) {
404
+ const a = a0 + reveal * (s / steps);
405
+ d += `L ${cx + R * Math.cos(a)} ${cy + R * Math.sin(a)} `;
406
+ }
407
+ d += "Z ";
408
+ }
409
+ return d.trim();
410
+ }
411
+ // wipe / slide:直接复用矩形定向裁切。
412
+ function wipePath(b, p, side) {
413
+ const w = b.w * p;
414
+ const h = b.h * p;
415
+ switch (side) {
416
+ case "left":
417
+ return `M ${b.x} ${b.y} h ${w} v ${b.h} h ${-w} Z`;
418
+ case "right":
419
+ return `M ${b.x + b.w - w} ${b.y} h ${w} v ${b.h} h ${-w} Z`;
420
+ case "top":
421
+ return `M ${b.x} ${b.y} h ${b.w} v ${h} h ${-b.w} Z`;
422
+ case "bottom":
423
+ return `M ${b.x} ${b.y + b.h - h} h ${b.w} v ${h} h ${-b.w} Z`;
424
+ }
425
+ }
426
+ // barn:双向开门(horz/vert)。
427
+ // §19.5.8 barn(inHorizontal|outHorizontal|inVertical|outVertical):
428
+ // (in) 两扇门向中心收拢 → 可见区 = 外侧双带(fullRect ∖ 中间收缩带)。
429
+ // progress=p 时中间带宽 = full·(1-p),环形覆盖率随 p 上升。
430
+ // (out) 两扇门从中心向外推开 → 可见区 = 中间带,宽度 = full·p,从 0 长到 full。
431
+ // 与 box/circle/diamond 同源(同样的 inward/outward 语义)。历史 bug 同样存在
432
+ // 颠倒:旧版 "in" 输出中央带、"out" 输出外环,与 PowerPoint 真实方向相反。
433
+ function barnPath(b, axis, p, inOut) {
434
+ if (axis === "vert") {
435
+ const cx = b.x + b.w / 2;
436
+ if (inOut === "in") {
437
+ if (p <= 0)
438
+ return emptyPath();
439
+ const f = 1 - p;
440
+ const cw = b.w * f;
441
+ return `${fullRect(b)} M ${cx - cw / 2} ${b.y} h ${cw} v ${b.h} h ${-cw} Z`;
442
+ }
443
+ if (p <= 0)
444
+ return emptyPath();
445
+ const half = (b.w * p) / 2;
446
+ return `M ${cx - half} ${b.y} h ${half * 2} v ${b.h} h ${-half * 2} Z`;
447
+ }
448
+ const cy = b.y + b.h / 2;
449
+ if (inOut === "in") {
450
+ if (p <= 0)
451
+ return emptyPath();
452
+ const f = 1 - p;
453
+ const ch = b.h * f;
454
+ return `${fullRect(b)} M ${b.x} ${cy - ch / 2} h ${b.w} v ${ch} h ${-b.w} Z`;
455
+ }
456
+ if (p <= 0)
457
+ return emptyPath();
458
+ const half = (b.h * p) / 2;
459
+ return `M ${b.x} ${cy - half} h ${b.w} v ${half * 2} h ${-b.w} Z`;
460
+ }
461
+ // randomBars:N 条窄条带在伪随机时刻整条瞬间 pop in,营造"百叶窗洗牌"质感。
462
+ // PowerPoint / WPS Random Bars 的视觉特征:
463
+ // - 条带数量较多(~24 条以上),单条带很窄
464
+ // - 每条带在随机时刻"啪"地一下出现(条带内没有左右 / 上下 wipe,是瞬时显示)
465
+ // - 时刻分布在整个动画时长内,最后一条带在 p≈1 时出现
466
+ //
467
+ // 旧实现错在:N=8 太少 + 每条带在自身 1/N 窗口内做 left→right wipe,单帧只看到 1 条
468
+ // 在 wipe 中、其他要么完全显要么完全隐——视觉像缓慢的对角 wipe,几乎看不出 random。
469
+ // 现改为 N=24、纯 binary pop(th 阈值过了就整条显),让密集的随机条带感清晰。
470
+ //
471
+ // 阈值生成:用 pseudoOrder 给每条带分配 [0, 1) 内"等间隔但顺序打乱"的 th。等价于
472
+ // "把 N 个时间点随机分配给 N 条带",保证最后一条在 p=1-1/N 时也已显(不留缝)。
473
+ const RANDOM_BARS_COUNT = 24;
474
+ function randomBarsPath(b, axis, p) {
475
+ const n = RANDOM_BARS_COUNT;
476
+ const order = pseudoOrder(n);
477
+ let d = "";
478
+ if (axis === "horz") {
479
+ const band = b.h / n;
480
+ for (let i = 0; i < n; i++) {
481
+ // 阈值 = 等分时间点 + 半格偏移,让最后一条在 p ≈ 1 - 1/(2N) 时刚好出现。
482
+ const th = (order[i] + 0.5) / n;
483
+ if (p >= th) {
484
+ d += `M ${b.x} ${b.y + i * band} h ${b.w} v ${band} h ${-b.w} Z `;
485
+ }
486
+ }
487
+ return d.trim();
488
+ }
489
+ const band = b.w / n;
490
+ for (let i = 0; i < n; i++) {
491
+ const th = (order[i] + 0.5) / n;
492
+ if (p >= th) {
493
+ d += `M ${b.x + i * band} ${b.y} v ${b.h} h ${band} v ${-b.h} Z `;
494
+ }
495
+ }
496
+ return d.trim();
497
+ }
498
+ function pseudoOrder(n) {
499
+ const arr = Array.from({ length: n }, (_, i) => i);
500
+ for (let i = n - 1; i > 0; i--) {
501
+ const j = Math.floor(((Math.sin(i * 12.9898) * 43758.5453) % 1 + 1) % 1 * (i + 1));
502
+ [arr[i], arr[j]] = [arr[j], arr[i]];
503
+ }
504
+ return arr;
505
+ }
506
+ // dissolve / pixelate:按 cell 网格随机阈值 reveal。
507
+ function dissolvePath(b, p, n = 16) {
508
+ const cw = b.w / n;
509
+ const ch = b.h / n;
510
+ let d = "";
511
+ for (let r = 0; r < n; r++) {
512
+ for (let c = 0; c < n; c++) {
513
+ const seed = (r * 73856093) ^ (c * 19349663);
514
+ const th = ((Math.sin(seed) * 43758.5453) % 1 + 1) % 1;
515
+ if (p >= th) {
516
+ d += `M ${b.x + c * cw} ${b.y + r * ch} h ${cw} v ${ch} h ${-cw} Z `;
517
+ }
518
+ }
519
+ }
520
+ return d.trim();
521
+ }
522
+ function clamp(x, lo, hi) {
523
+ return x < lo ? lo : x > hi ? hi : x;
524
+ }
525
+ // entrance 路径:把 (filter, prLst, p) 解析成"已被 sweep 覆盖且代表可见"的几何。
526
+ // p∈[0,1],p=0 → empty,p=1 → fullRect。所有 sweep 类 filter 必须在 p=0 时
527
+ // 严格为 emptyPath(不带 fullRect 包裹),exit 的 complement 公式才能正确兜底。
528
+ function entranceVisibleD(filter, prLst, p, b) {
529
+ if (p >= 1)
530
+ return fullRect(b);
531
+ if (p <= 0)
532
+ return emptyPath();
533
+ const dir = parseDir(prLst);
534
+ switch (filter) {
535
+ case "wipe":
536
+ case "slide": {
537
+ const side = dir.side === "center" ? "left" : dir.side;
538
+ return wipePath(b, p, side);
539
+ }
540
+ case "blinds":
541
+ return blindsPath(b, dir.axis === "vert" ? "vert" : "horz", p);
542
+ case "strips": {
543
+ const s = (prLst ?? "").toLowerCase();
544
+ const corner = s.includes("downright") ? "downRight"
545
+ : s.includes("downleft") ? "downLeft"
546
+ : s.includes("upright") ? "upRight"
547
+ : s.includes("upleft") ? "upLeft"
548
+ : "downRight";
549
+ return stripsPath(b, p, corner);
550
+ }
551
+ case "checkerboard": {
552
+ // §19.5.8 checkerboard(across|down):
553
+ // across → 主方向 horz(列扫过)
554
+ // down → 主方向 vert(行扫过)
555
+ const s = (prLst ?? "").toLowerCase();
556
+ const primary = s.includes("down") ? "vert" : "horz";
557
+ return checkerboardPath(b, primary, p);
558
+ }
559
+ case "circle":
560
+ return circlePath(b, p, dir.inOut === "out" ? "out" : "in");
561
+ case "diamond":
562
+ return diamondPath(b, p, dir.inOut === "out" ? "out" : "in");
563
+ case "box":
564
+ return boxPath(b, p, dir.inOut === "out" ? "out" : "in");
565
+ case "plus":
566
+ return plusPath(b, p, dir.inOut === "out" ? "out" : "in");
567
+ case "wedge":
568
+ return wedgePath(b, p);
569
+ case "wheel": {
570
+ const spokeMatch = /(\d+)/.exec(prLst ?? "");
571
+ const sp = spokeMatch ? Math.max(1, Number(spokeMatch[1])) : 1;
572
+ return wheelPath(b, p, sp);
573
+ }
574
+ case "barn":
575
+ return barnPath(b, dir.axis === "vert" ? "vert" : "horz", p, dir.inOut === "out" ? "out" : "in");
576
+ case "randombar":
577
+ case "randomBar":
578
+ return randomBarsPath(b, dir.axis === "vert" ? "vert" : "horz", p);
579
+ case "dissolve":
580
+ case "pixelate":
581
+ return dissolvePath(b, p);
582
+ case "stretch":
583
+ return wipePath(b, p, dir.side === "center" ? "left" : dir.side);
584
+ default:
585
+ return fullRect(b);
586
+ }
587
+ }
588
+ export function effectClipPathD(geom) {
589
+ const { filter, bbox: b, transition } = geom;
590
+ const p = clamp01(geom.progress);
591
+ // §19.5.21 fill="hold":runtime 已对 exit 反向(val = 1 - u),所以这里 progress
592
+ // 对 entrance / exit 都是"剩余可见进度"语义:
593
+ // - entrance: p=0 → 还没开始显现;p=1 → 已经完全显现
594
+ // - exit: p=0 → 已经完全隐藏;p=1 → 还没开始隐藏
595
+ if (p >= 1)
596
+ return fullRect(b);
597
+ if (p <= 0)
598
+ return emptyPath();
599
+ // fade / image 不是 sweep 几何——可见区永远 = fullRect,opacity 通道(见
600
+ // svg-backend.fadeOpacityFromClip)负责淡入淡出。
601
+ if (filter === "fade" || filter === "image")
602
+ return fullRect(b);
603
+ if (transition === "out") {
604
+ // §19.5.8 exit:visible = full ∖ entrance_pattern(1-val)。把 entrance 几何
605
+ // 当作"已被遮盖"的形状,外面再包 fullRect 走 even-odd 做减法。
606
+ // 公式对所有 sweep filter(box/circle/diamond/plus/barn/wipe/blinds/wedge/
607
+ // wheel/checkerboard/randombar/strips/dissolve)统一成立。
608
+ //
609
+ // 特例:entrance 本身已经是 "fullRect + inner" 的环形结构(box(in) 等),
610
+ // complement 后路径里出现两次 fullRect,在 even-odd 下相互抵消,最终留下
611
+ // inner 形状——正好就是 exit 想要的"中心几何"。
612
+ const tVal = clamp01(1 - p);
613
+ const inner = entranceVisibleD(filter, geom.prLst, tVal, b);
614
+ if (!inner)
615
+ return fullRect(b);
616
+ if (inner === fullRect(b))
617
+ return emptyPath();
618
+ return `${fullRect(b)} ${inner}`;
619
+ }
620
+ return entranceVisibleD(filter, geom.prLst, p, b);
621
+ }
622
+ // 判断 filter 渲染是否使用 even-odd 填充规则。
623
+ // - exit (transition="out") 的路径始终是 "fullRect + entrance_pattern" 的 complement
624
+ // 形式,必须 even-odd 才能解释成"环差/反相"。
625
+ // - entrance: 只对天然输出 "fullRect + 中心几何" 的 inward filter
626
+ // (circle/diamond/box/barn/plus 的 (in) 子类型)需要 even-odd;其他 filter
627
+ // 都是单条或多条不交叠的 closed path,nonzero 即可。
628
+ export function effectClipUsesEvenOdd(geom) {
629
+ if (geom.transition === "out") {
630
+ // fade/image 没有 clip 几何(永远 fullRect),不需要 even-odd。
631
+ if (geom.filter === "fade" || geom.filter === "image")
632
+ return false;
633
+ return true;
634
+ }
635
+ const dir = parseDir(geom.prLst);
636
+ if (dir.inOut !== "in")
637
+ return false;
638
+ switch (geom.filter) {
639
+ case "circle":
640
+ case "diamond":
641
+ case "box":
642
+ case "barn":
643
+ case "plus":
644
+ return true;
645
+ default:
646
+ return false;
647
+ }
648
+ }