@sliiu/present-renderer 0.0.0-stage → 0.1.0

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/THIRD-PARTY-NOTICES.md +221 -0
  4. package/dist/cjs/arrow-geom.js +38 -0
  5. package/dist/cjs/canvas-backend.js +1368 -0
  6. package/dist/cjs/cell-borders.js +25 -0
  7. package/dist/cjs/chart-canvas.js +1059 -0
  8. package/dist/cjs/chart-ex-render.js +275 -0
  9. package/dist/cjs/chart-geom.js +313 -0
  10. package/dist/cjs/chart-svg.js +1110 -0
  11. package/dist/cjs/compose.js +74 -0
  12. package/dist/cjs/effect-clip.js +652 -0
  13. package/dist/cjs/entrance-hide.js +63 -0
  14. package/dist/cjs/font-stack.js +103 -0
  15. package/dist/cjs/hit-geom.js +50 -0
  16. package/dist/cjs/hybrid-backend.js +93 -0
  17. package/dist/cjs/hybrid-segments.js +54 -0
  18. package/dist/cjs/index.js +70 -0
  19. package/dist/cjs/linked-src.js +116 -0
  20. package/dist/cjs/metafile-preview.js +142 -0
  21. package/dist/cjs/model3d-frame.js +199 -0
  22. package/dist/cjs/model3d-render.js +78 -0
  23. package/dist/cjs/morph-transition.js +57 -0
  24. package/dist/cjs/node-affine.js +79 -0
  25. package/dist/cjs/ole-render.js +291 -0
  26. package/dist/cjs/package.json +1 -0
  27. package/dist/cjs/paint-canvas.js +701 -0
  28. package/dist/cjs/paint-svg.js +674 -0
  29. package/dist/cjs/scene-node.js +762 -0
  30. package/dist/cjs/selection.js +130 -0
  31. package/dist/cjs/shape-effects.js +66 -0
  32. package/dist/cjs/shape-front.js +38 -0
  33. package/dist/cjs/stage-chrome.js +15 -0
  34. package/dist/cjs/svg-backend.js +2172 -0
  35. package/dist/cjs/text-autofit.js +48 -0
  36. package/dist/cjs/text-hit.js +284 -0
  37. package/dist/cjs/text-layout.js +408 -0
  38. package/dist/cjs/text-shared.js +238 -0
  39. package/dist/cjs/text-stacked.js +82 -0
  40. package/dist/cjs/text-warp.js +377 -0
  41. package/dist/cjs/text-wordart.js +33 -0
  42. package/dist/cjs/video-frame.js +113 -0
  43. package/dist/cjs/webgl-model3d-backend.js +285 -0
  44. package/dist/cjs/webgl-shape-backend.js +360 -0
  45. package/dist/esm/arrow-geom.js +33 -0
  46. package/dist/esm/canvas-backend.js +1363 -0
  47. package/dist/esm/cell-borders.js +21 -0
  48. package/dist/esm/chart-canvas.js +1056 -0
  49. package/dist/esm/chart-ex-render.js +261 -0
  50. package/dist/esm/chart-geom.js +296 -0
  51. package/dist/esm/chart-svg.js +1107 -0
  52. package/dist/esm/compose.js +71 -0
  53. package/dist/esm/effect-clip.js +648 -0
  54. package/dist/esm/entrance-hide.js +59 -0
  55. package/dist/esm/font-stack.js +100 -0
  56. package/dist/esm/hit-geom.js +45 -0
  57. package/dist/esm/hybrid-backend.js +89 -0
  58. package/dist/esm/hybrid-segments.js +51 -0
  59. package/dist/esm/index.js +24 -0
  60. package/dist/esm/linked-src.js +109 -0
  61. package/dist/esm/metafile-preview.js +135 -0
  62. package/dist/esm/model3d-frame.js +158 -0
  63. package/dist/esm/model3d-render.js +73 -0
  64. package/dist/esm/morph-transition.js +54 -0
  65. package/dist/esm/node-affine.js +71 -0
  66. package/dist/esm/ole-render.js +286 -0
  67. package/dist/esm/paint-canvas.js +693 -0
  68. package/dist/esm/paint-svg.js +670 -0
  69. package/dist/esm/scene-node.js +750 -0
  70. package/dist/esm/selection.js +122 -0
  71. package/dist/esm/shape-effects.js +60 -0
  72. package/dist/esm/shape-front.js +35 -0
  73. package/dist/esm/stage-chrome.js +12 -0
  74. package/dist/esm/svg-backend.js +2168 -0
  75. package/dist/esm/text-autofit.js +44 -0
  76. package/dist/esm/text-hit.js +280 -0
  77. package/dist/esm/text-layout.js +400 -0
  78. package/dist/esm/text-shared.js +221 -0
  79. package/dist/esm/text-stacked.js +78 -0
  80. package/dist/esm/text-warp.js +371 -0
  81. package/dist/esm/text-wordart.js +30 -0
  82. package/dist/esm/video-frame.js +109 -0
  83. package/dist/esm/webgl-model3d-backend.js +248 -0
  84. package/dist/esm/webgl-shape-backend.js +323 -0
  85. package/dist/types/arrow-geom.d.ts +10 -0
  86. package/dist/types/canvas-backend.d.ts +55 -0
  87. package/dist/types/cell-borders.d.ts +9 -0
  88. package/dist/types/chart-canvas.d.ts +2 -0
  89. package/dist/types/chart-ex-render.d.ts +79 -0
  90. package/dist/types/chart-geom.d.ts +112 -0
  91. package/dist/types/chart-svg.d.ts +2 -0
  92. package/dist/types/compose.d.ts +2 -0
  93. package/dist/types/effect-clip.d.ts +10 -0
  94. package/dist/types/entrance-hide.d.ts +16 -0
  95. package/dist/types/font-stack.d.ts +9 -0
  96. package/dist/types/hit-geom.d.ts +15 -0
  97. package/dist/types/hybrid-backend.d.ts +21 -0
  98. package/dist/types/hybrid-segments.d.ts +11 -0
  99. package/dist/types/index.d.ts +24 -0
  100. package/dist/types/linked-src.d.ts +13 -0
  101. package/dist/types/metafile-preview.d.ts +11 -0
  102. package/dist/types/model3d-frame.d.ts +8 -0
  103. package/dist/types/model3d-render.d.ts +4 -0
  104. package/dist/types/morph-transition.d.ts +15 -0
  105. package/dist/types/node-affine.d.ts +22 -0
  106. package/dist/types/ole-render.d.ts +7 -0
  107. package/dist/types/paint-canvas.d.ts +22 -0
  108. package/dist/types/paint-svg.d.ts +19 -0
  109. package/dist/types/scene-node.d.ts +128 -0
  110. package/dist/types/selection.d.ts +22 -0
  111. package/dist/types/shape-effects.d.ts +13 -0
  112. package/dist/types/shape-front.d.ts +4 -0
  113. package/dist/types/stage-chrome.d.ts +8 -0
  114. package/dist/types/svg-backend.d.ts +88 -0
  115. package/dist/types/text-autofit.d.ts +7 -0
  116. package/dist/types/text-hit.d.ts +7 -0
  117. package/dist/types/text-layout.d.ts +35 -0
  118. package/dist/types/text-shared.d.ts +86 -0
  119. package/dist/types/text-stacked.d.ts +11 -0
  120. package/dist/types/text-warp.d.ts +37 -0
  121. package/dist/types/text-wordart.d.ts +18 -0
  122. package/dist/types/video-frame.d.ts +2 -0
  123. package/dist/types/webgl-model3d-backend.d.ts +25 -0
  124. package/dist/types/webgl-shape-backend.d.ts +33 -0
  125. package/package.json +33 -4
  126. package/README.md +0 -3
@@ -0,0 +1,1363 @@
1
+ import { withFontFallback } from "./font-stack.js";
2
+ import { runDisplayText } from "@sliiu/present-core";
3
+ import { mergeParaOverrides, netFlipParity, shapeRenderFill, shapeRenderFillBBox } from "./scene-node.js";
4
+ import { applyStroke, getImage, paintToCanvasStyle, adjustImage } from "./paint-canvas.js";
5
+ import { previewImageSrc } from "./metafile-preview.js";
6
+ import { cellBorderSegments, cellHasPerSideBorders } from "./cell-borders.js";
7
+ import { getVideoPosterFrame } from "./video-frame.js";
8
+ import { isFileLinkedSrc, drawLinkedImagePlaceholder, displayImageSrc } from "./linked-src.js";
9
+ import { arrowPathD, arrowRefX, arrowSize } from "./arrow-geom.js";
10
+ import { drawChartCanvas } from "./chart-canvas.js";
11
+ import { effectClipPathD, effectClipUsesEvenOdd } from "./effect-clip.js";
12
+ import { isEastAsianChar as isEastAsianCharShared, mapVisualAlignWithAnchorCtr, wrapParagraphIntoLines, } from "./text-layout.js";
13
+ import { buildStackedGlyphs, wrapGlyphsIntoColumns, } from "./text-stacked.js";
14
+ import { collectShapeEffects, cssFilterForEffects } from "./shape-effects.js";
15
+ import { autofitTextShape } from "./text-autofit.js";
16
+ import { wordArtFitTransform } from "./text-wordart.js";
17
+ import { flattenWarpGlyphs, placeGlyphsOnWarp, sampleWarpPath, warpActive } from "./text-warp.js";
18
+ import { drawOleCanvas } from "./ole-render.js";
19
+ import { drawModel3DCanvas } from "./model3d-render.js";
20
+ import { shape3DInfo } from "@sliiu/present-three-bake";
21
+ // vert 旋转角 / 竖排判定 / bullet 编号 / 缺省 insets 全部来自 ./text-shared,
22
+ // 与 svg-backend 和编辑器的行内文本编辑器共用同一套规则。
23
+ import { DEFAULT_TEXT_INSETS, FALLBACK_FONT_SIZE, lineHeightFactor, paragraphFallbackFontSize, advanceBulletLabel, bulletFontSize, formatBulletNumber, isVerticalStack, pickRunEffectsCssFilter, stackedColumnsLtr, newBulletNumState, vertRotateDeg, } from "./text-shared.js";
24
+ const isEastAsianChar = isEastAsianCharShared;
25
+ function drawCroppedImage(ctx, img, natW, natH, crop, dx, dy, dw, dh) {
26
+ if (!crop || natW <= 0 || natH <= 0) {
27
+ ctx.drawImage(img, dx, dy, dw, dh);
28
+ return;
29
+ }
30
+ const cw = 1 - crop.l - crop.r;
31
+ const ch = 1 - crop.t - crop.b;
32
+ if (cw <= 1e-6 || ch <= 1e-6)
33
+ return;
34
+ let sx = natW * crop.l;
35
+ let sy = natH * crop.t;
36
+ let sw = natW * cw;
37
+ let sh = natH * ch;
38
+ let x = dx;
39
+ let y = dy;
40
+ let w = dw;
41
+ let h = dh;
42
+ if (sx < 0) {
43
+ const cut = -sx / sw;
44
+ x += w * cut;
45
+ w *= 1 - cut;
46
+ sw += sx;
47
+ sx = 0;
48
+ }
49
+ if (sy < 0) {
50
+ const cut = -sy / sh;
51
+ y += h * cut;
52
+ h *= 1 - cut;
53
+ sh += sy;
54
+ sy = 0;
55
+ }
56
+ if (sx + sw > natW) {
57
+ const cut = (sx + sw - natW) / sw;
58
+ w *= 1 - cut;
59
+ sw = natW - sx;
60
+ }
61
+ if (sy + sh > natH) {
62
+ const cut = (sy + sh - natH) / sh;
63
+ h *= 1 - cut;
64
+ sh = natH - sy;
65
+ }
66
+ if (sw <= 0 || sh <= 0 || w <= 0 || h <= 0)
67
+ return;
68
+ ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h);
69
+ }
70
+ export class CanvasBackend {
71
+ canvas;
72
+ ctx;
73
+ scene;
74
+ dpr = window.devicePixelRatio || 1;
75
+ rafId = 0;
76
+ dispose = [];
77
+ shapeFilter;
78
+ transparent;
79
+ /** 给文字 3D 正面贴图用:画字形,不把已经烘好的 PNG 再画一遍。 */
80
+ drawingFront = false;
81
+ // 离屏 SVGPathElement,仅用于 getPointAtLength 量取 line/path 两端的端点与切线方向,
82
+ // 给箭头(headEnd/tailEnd)定位定向。不挂进 DOM。
83
+ arrowProbe;
84
+ constructor(scene, container, opts = {}) {
85
+ this.scene = scene;
86
+ this.shapeFilter = opts.shapeFilter ?? (() => true);
87
+ this.transparent = !!opts.transparent;
88
+ this.dpr = opts.pixelRatio ?? (window.devicePixelRatio || 1);
89
+ this.canvas = document.createElement("canvas");
90
+ this.canvas.style.width = "100%";
91
+ this.canvas.style.height = "100%";
92
+ // 与根 SVG 相同:行内画布的基线空隙会把适应窗口的舞台撑出滚动条。
93
+ this.canvas.style.display = "block";
94
+ if (!this.transparent)
95
+ this.canvas.style.background = "#fff";
96
+ this.resize(scene.width, scene.height);
97
+ container.appendChild(this.canvas);
98
+ const c2d = this.canvas.getContext("2d");
99
+ if (!c2d)
100
+ throw new Error("2d context unavailable");
101
+ this.ctx = c2d;
102
+ scene.root.walk((n) => {
103
+ const off = n.on(() => this.scheduleRedraw());
104
+ this.dispose.push(off);
105
+ });
106
+ this.scheduleRedraw();
107
+ }
108
+ resize(w, h) {
109
+ this.canvas.width = w * this.dpr;
110
+ this.canvas.height = h * this.dpr;
111
+ }
112
+ scheduleRedraw() {
113
+ if (this.rafId)
114
+ return;
115
+ this.rafId = requestAnimationFrame(() => {
116
+ this.rafId = 0;
117
+ this.redraw();
118
+ });
119
+ }
120
+ /** 同步刷新到 canvas,并取消 pending rAF。详见 SvgBackend.flush。 */
121
+ flush() {
122
+ if (this.rafId) {
123
+ cancelAnimationFrame(this.rafId);
124
+ this.rafId = 0;
125
+ }
126
+ this.redraw();
127
+ }
128
+ redraw() {
129
+ const ctx = this.ctx;
130
+ ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
131
+ if (this.transparent) {
132
+ ctx.clearRect(0, 0, this.scene.width, this.scene.height);
133
+ }
134
+ else {
135
+ ctx.fillStyle = "#ffffff";
136
+ ctx.fillRect(0, 0, this.scene.width, this.scene.height);
137
+ }
138
+ for (const c of this.scene.root.children)
139
+ this.drawNode(c);
140
+ }
141
+ drawNode(node) {
142
+ const s = node.shape;
143
+ const visible = (node.visibleOverride ?? s.visible) !== false;
144
+ if (!visible)
145
+ return;
146
+ const ctx = this.ctx;
147
+ ctx.save();
148
+ ctx.globalAlpha *= node.opacity;
149
+ // §19.5.8 animEffect filter="fade" 把 progress 编码到 opacity 通道,runtime 已经
150
+ // 在 progress 内表达了 in/out 方向(in:0→1, out:1→0),这里直接相乘即可。
151
+ const fadeClip = node.clip;
152
+ if (fadeClip && fadeClip.kind === "effect" && fadeClip.filter === "fade") {
153
+ const tr = fadeClip.transition ?? "none";
154
+ const p = tr === "none" ? 1 : Math.max(0, Math.min(1, fadeClip.progress ?? 0));
155
+ ctx.globalAlpha *= p;
156
+ }
157
+ const cx = s.bbox.x + s.bbox.w / 2;
158
+ const cy = s.bbox.y + s.bbox.h / 2;
159
+ const flipX = s.transform?.scaleX ?? 1;
160
+ const flipY = s.transform?.scaleY ?? 1;
161
+ const sx = node.scale * node.scaleX * node.widthScale * (flipX < 0 ? -1 : 1);
162
+ const sy = node.scale * node.scaleY * node.heightScale * (flipY < 0 ? -1 : 1);
163
+ ctx.translate(node.tx, node.ty);
164
+ ctx.translate(cx, cy);
165
+ if (node.rotation)
166
+ ctx.rotate((node.rotation * Math.PI) / 180);
167
+ if (sx !== 1 || sy !== 1)
168
+ ctx.scale(sx, sy);
169
+ ctx.translate(-cx, -cy);
170
+ // filter="fade" 走 opacity,不应作几何 clip(参考 svg-backend.applyClip 注释)。
171
+ if (node.clip && !(node.clip.kind === "effect" && node.clip.filter === "fade")) {
172
+ this.applyClip(s.bbox, node.clip);
173
+ }
174
+ if (this.shapeFilter(s))
175
+ this.drawShape(s, node);
176
+ if (s.kind === "group") {
177
+ for (const c of node.children)
178
+ this.drawNode(c);
179
+ }
180
+ ctx.restore();
181
+ }
182
+ applyClip(b, c) {
183
+ const ctx = this.ctx;
184
+ if (c.kind === "wheel") {
185
+ const cx = b.x + b.w / 2;
186
+ const cy = b.y + b.h / 2;
187
+ const R = Math.hypot(b.w, b.h);
188
+ const n = Math.max(1, Math.floor(c.spokes));
189
+ const p = Math.max(0, Math.min(1, c.progress));
190
+ const span = (2 * Math.PI) / n;
191
+ ctx.beginPath();
192
+ for (let i = 0; i < n; i++) {
193
+ const a0 = i * span - Math.PI / 2;
194
+ ctx.moveTo(cx, cy);
195
+ ctx.arc(cx, cy, R, a0, a0 + span * p, false);
196
+ ctx.closePath();
197
+ }
198
+ ctx.clip();
199
+ return;
200
+ }
201
+ if (c.kind === "effect") {
202
+ // §19.5.21 entrance 完成态(progress>=1)= 完全可见:effectClipPathD 此时只是
203
+ // fullRect(bbox),几何上不裁内部像素,但会切掉超出 bbox 的 line 箭头 marker /
204
+ // stroke overshoot("播放完箭头消失")。完成态直接不施加任何 clip。
205
+ if ((c.progress ?? 0) >= 1)
206
+ return;
207
+ const geom = { filter: c.filter, prLst: c.prLst, progress: c.progress, transition: c.transition, bbox: b };
208
+ const d = effectClipPathD(geom);
209
+ if (!d) {
210
+ ctx.beginPath();
211
+ ctx.rect(0, 0, 0, 0);
212
+ ctx.clip();
213
+ return;
214
+ }
215
+ const path = new Path2D(d);
216
+ ctx.clip(path, effectClipUsesEvenOdd(geom) ? "evenodd" : "nonzero");
217
+ return;
218
+ }
219
+ const p = Math.max(0, Math.min(1, c.progress));
220
+ let x = b.x;
221
+ let y = b.y;
222
+ let w = b.w;
223
+ let h = b.h;
224
+ switch (c.direction) {
225
+ case "left":
226
+ w = b.w * p;
227
+ break;
228
+ case "right":
229
+ x = b.x + b.w * (1 - p);
230
+ w = b.w * p;
231
+ break;
232
+ case "top":
233
+ h = b.h * p;
234
+ break;
235
+ case "bottom":
236
+ y = b.y + b.h * (1 - p);
237
+ h = b.h * p;
238
+ break;
239
+ }
240
+ ctx.beginPath();
241
+ ctx.rect(x, y, w, h);
242
+ ctx.clip();
243
+ }
244
+ drawShape(s, node) {
245
+ const ctx = this.ctx;
246
+ if (s.kind === "group")
247
+ return;
248
+ // shape 级 outerShdw / glow → ctx.filter(drawNode 的 save/restore 会自动还原)。
249
+ // innerShdw / softEdge / reflection 无 Canvas 2D 等价原语,hybrid 模式下由 SVG
250
+ // 层渲染这些形状;纯 Canvas 后端为已知近似(只画 outer+glow)。
251
+ const fxFilter = cssFilterForEffects(collectShapeEffects(s));
252
+ if (fxFilter)
253
+ ctx.filter = ctx.filter && ctx.filter !== "none" ? `${ctx.filter} ${fxFilter}` : fxFilter;
254
+ // 离屏光栅:three-bake 把 3D 烘进 meta3D.bakedSrc 后,image 和自选形状都画这张 PNG。
255
+ // 还没烘出来时继续走下面的 2D。live 舞台上的 3D 形状不进这个 backend。
256
+ const info3d = this.drawingFront ? undefined : shape3DInfo(s);
257
+ const baked3d = info3d?.bakedSrc;
258
+ if (baked3d) {
259
+ const bimg = getImage(baked3d, () => this.scheduleRedraw());
260
+ if (bimg.complete && bimg.naturalWidth > 0) {
261
+ const bb = info3d?.bakedBBox ?? s.bbox;
262
+ ctx.drawImage(bimg, bb.x, bb.y, bb.w, bb.h);
263
+ }
264
+ return;
265
+ }
266
+ // gradFill path="shape" 需要轮廓类别:矩形 → 框带 pattern,其它 → 径向近似。
267
+ // text 的 fill 画在矩形背景上(下方 case "text")→ 同样按 rect 解释。
268
+ const paintHint = s.kind === "rect" || s.kind === "text" ? "rect" : s.kind === "ellipse" ? "ellipse" : "other";
269
+ const fill = shapeRenderFill(s, this.scene, node);
270
+ const fillBBox = shapeRenderFillBBox(s, this.scene, node);
271
+ const fillStyle = node.fillOverride
272
+ ? node.fillOverride
273
+ : paintToCanvasStyle(ctx, fill, fillBBox, () => this.scheduleRedraw(), paintHint);
274
+ ctx.fillStyle = fillStyle;
275
+ let hasStroke = false;
276
+ if (node.strokeOverride) {
277
+ ctx.strokeStyle = node.strokeOverride;
278
+ ctx.lineWidth = node.strokeWidthOverride ?? s.stroke?.width ?? 1;
279
+ hasStroke = true;
280
+ }
281
+ else {
282
+ hasStroke = applyStroke(ctx, s.stroke, s.bbox, () => this.scheduleRedraw());
283
+ if (hasStroke && node.strokeWidthOverride !== null) {
284
+ ctx.lineWidth = node.strokeWidthOverride;
285
+ }
286
+ }
287
+ switch (s.kind) {
288
+ case "rect": {
289
+ if (s.rx || s.ry) {
290
+ this.roundRect(s.bbox.x, s.bbox.y, s.bbox.w, s.bbox.h, s.rx ?? s.ry ?? 0);
291
+ }
292
+ else {
293
+ ctx.beginPath();
294
+ ctx.rect(s.bbox.x, s.bbox.y, s.bbox.w, s.bbox.h);
295
+ }
296
+ if (fill && fill.kind !== "none")
297
+ ctx.fill();
298
+ if (hasStroke)
299
+ ctx.stroke();
300
+ break;
301
+ }
302
+ case "ellipse":
303
+ ctx.beginPath();
304
+ ctx.ellipse(s.bbox.x + s.bbox.w / 2, s.bbox.y + s.bbox.h / 2, s.bbox.w / 2, s.bbox.h / 2, 0, 0, Math.PI * 2);
305
+ if (fill && fill.kind !== "none")
306
+ ctx.fill();
307
+ if (hasStroke)
308
+ ctx.stroke();
309
+ break;
310
+ case "path": {
311
+ const p = new Path2D(s.d);
312
+ if (fill && fill.kind !== "none")
313
+ ctx.fill(p, s.fillRule ?? "nonzero");
314
+ if (hasStroke)
315
+ ctx.stroke(p);
316
+ if (hasStroke)
317
+ this.drawArrowheads(s.stroke, s.d);
318
+ break;
319
+ }
320
+ case "line":
321
+ ctx.beginPath();
322
+ ctx.moveTo(s.x1, s.y1);
323
+ ctx.lineTo(s.x2, s.y2);
324
+ if (hasStroke)
325
+ ctx.stroke();
326
+ if (hasStroke)
327
+ this.drawArrowheads(s.stroke, `M ${s.x1} ${s.y1} L ${s.x2} ${s.y2}`);
328
+ break;
329
+ case "polygon":
330
+ ctx.beginPath();
331
+ s.points.forEach((p, i) => {
332
+ if (i === 0)
333
+ ctx.moveTo(p[0], p[1]);
334
+ else
335
+ ctx.lineTo(p[0], p[1]);
336
+ });
337
+ ctx.closePath();
338
+ if (fill && fill.kind !== "none")
339
+ ctx.fill();
340
+ if (hasStroke)
341
+ ctx.stroke();
342
+ break;
343
+ case "text": {
344
+ // 与 svg-backend 对齐:textShape 的 fill / stroke 是合法的(用作文字框
345
+ // 背景与轮廓,如表格 a:tc / placeholder)。在文本之前先画一个矩形背景,
346
+ // 复用上面已经 setup 好的 fillStyle / strokeStyle。
347
+ if (fill && fill.kind !== "none") {
348
+ ctx.beginPath();
349
+ ctx.rect(s.bbox.x, s.bbox.y, s.bbox.w, s.bbox.h);
350
+ ctx.fill();
351
+ }
352
+ if (cellHasPerSideBorders(s) && s.cellMeta?.borders) {
353
+ for (const seg of cellBorderSegments(s.bbox, s.cellMeta.borders)) {
354
+ if (!applyStroke(ctx, seg.stroke, s.bbox, () => this.scheduleRedraw()))
355
+ continue;
356
+ ctx.beginPath();
357
+ ctx.moveTo(seg.x1, seg.y1);
358
+ ctx.lineTo(seg.x2, seg.y2);
359
+ ctx.stroke();
360
+ }
361
+ }
362
+ else if (hasStroke) {
363
+ ctx.beginPath();
364
+ ctx.rect(s.bbox.x, s.bbox.y, s.bbox.w, s.bbox.h);
365
+ ctx.stroke();
366
+ }
367
+ this.drawText(s, node);
368
+ break;
369
+ }
370
+ case "image": {
371
+ if (isFileLinkedSrc(s.src)) {
372
+ const display = displayImageSrc(s.src, s.bbox.w, s.bbox.h);
373
+ if (display.startsWith("data:image/svg+xml")) {
374
+ drawLinkedImagePlaceholder(ctx, s.bbox, s.src);
375
+ break;
376
+ }
377
+ }
378
+ const redraw = () => this.scheduleRedraw();
379
+ const probe = getImage(s.src, redraw);
380
+ const shown = previewImageSrc(s.src, probe, redraw);
381
+ if (!shown)
382
+ break;
383
+ const img = shown === s.src ? probe : getImage(shown, redraw);
384
+ const drawn = s.adjust && img.complete && img.naturalWidth > 0 ? adjustImage(img, s.adjust) : img;
385
+ const natW = drawn instanceof HTMLCanvasElement ? drawn.width : img.naturalWidth;
386
+ const natH = drawn instanceof HTMLCanvasElement ? drawn.height : img.naturalHeight;
387
+ // shape 级效果已在 drawShape 顶部统一写入 ctx.filter(outer+glow)。
388
+ // 已烘焙的 3D 在 drawShape 开头画掉并返回。这里只剩没有 bakedSrc 的平面图。
389
+ const prevFilter = ctx.filter;
390
+ if (natW > 0) {
391
+ const cr = s.cropRect;
392
+ const geom = s.clipD ? new Path2D(s.clipD) : null;
393
+ if (geom) {
394
+ ctx.save();
395
+ ctx.clip(geom, (s.clipD.match(/[Mm]/g)?.length ?? 0) > 1 ? "evenodd" : "nonzero");
396
+ }
397
+ if (cr) {
398
+ drawCroppedImage(ctx, drawn, natW, natH, cr, s.bbox.x, s.bbox.y, s.bbox.w, s.bbox.h);
399
+ }
400
+ else {
401
+ ctx.drawImage(drawn, s.bbox.x, s.bbox.y, s.bbox.w, s.bbox.h);
402
+ }
403
+ if (geom)
404
+ ctx.restore();
405
+ }
406
+ if (hasStroke) {
407
+ if (s.clipD)
408
+ ctx.stroke(new Path2D(s.clipD));
409
+ else {
410
+ ctx.beginPath();
411
+ ctx.rect(s.bbox.x, s.bbox.y, s.bbox.w, s.bbox.h);
412
+ ctx.stroke();
413
+ }
414
+ }
415
+ ctx.filter = prevFilter;
416
+ break;
417
+ }
418
+ case "chart":
419
+ drawChartCanvas(ctx, s);
420
+ break;
421
+ case "video": {
422
+ if (s.poster) {
423
+ const img = getImage(s.poster, () => this.scheduleRedraw());
424
+ if (img.complete && img.naturalWidth > 0) {
425
+ drawCroppedImage(ctx, img, img.naturalWidth, img.naturalHeight, s.cropRect, s.bbox.x, s.bbox.y, s.bbox.w, s.bbox.h);
426
+ }
427
+ else {
428
+ this.drawMediaPlaceholder(s.bbox, "video");
429
+ }
430
+ }
431
+ else {
432
+ const frame = getVideoPosterFrame(s.src, () => this.scheduleRedraw(), s.trim?.start ?? 0.08);
433
+ if (frame && frame.complete && frame.naturalWidth > 0) {
434
+ drawCroppedImage(ctx, frame, frame.naturalWidth, frame.naturalHeight, s.cropRect, s.bbox.x, s.bbox.y, s.bbox.w, s.bbox.h);
435
+ }
436
+ else {
437
+ this.drawMediaPlaceholder(s.bbox, "video");
438
+ }
439
+ }
440
+ break;
441
+ }
442
+ case "audio":
443
+ this.drawMediaPlaceholder(s.bbox, "audio");
444
+ break;
445
+ case "webEmbed":
446
+ this.drawMediaPlaceholder(s.bbox, "video");
447
+ break;
448
+ case "ole":
449
+ drawOleCanvas(ctx, s);
450
+ break;
451
+ case "model3d":
452
+ drawModel3DCanvas(ctx, s, () => this.scheduleRedraw());
453
+ break;
454
+ }
455
+ }
456
+ /** Canvas 导出 / 缩略图:audio 无 DOM;video 无 poster 且第一帧未就绪时画占位图标。 */
457
+ drawMediaPlaceholder(bbox, kind) {
458
+ const ctx = this.ctx;
459
+ const { x, y, w, h } = bbox;
460
+ ctx.save();
461
+ ctx.fillStyle = "#f3f4f6";
462
+ ctx.strokeStyle = "#9ca3af";
463
+ ctx.lineWidth = 1;
464
+ ctx.setLineDash([4, 3]);
465
+ const rr = Math.min(w, h) * 0.12;
466
+ if (typeof ctx.roundRect === "function") {
467
+ ctx.beginPath();
468
+ ctx.roundRect(x, y, w, h, rr);
469
+ ctx.fill();
470
+ ctx.stroke();
471
+ }
472
+ else {
473
+ ctx.fillRect(x, y, w, h);
474
+ ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
475
+ }
476
+ ctx.setLineDash([]);
477
+ const cx = x + w / 2;
478
+ const cy = y + h / 2;
479
+ const r = Math.min(w, h) * 0.22;
480
+ ctx.fillStyle = "#9ca3af";
481
+ if (kind === "audio") {
482
+ ctx.beginPath();
483
+ ctx.moveTo(cx - r, cy - r * 0.45);
484
+ ctx.lineTo(cx - r * 0.25, cy - r * 0.45);
485
+ ctx.lineTo(cx + r * 0.35, cy - r);
486
+ ctx.lineTo(cx + r * 0.35, cy + r);
487
+ ctx.lineTo(cx - r * 0.25, cy + r * 0.45);
488
+ ctx.lineTo(cx - r, cy + r * 0.45);
489
+ ctx.closePath();
490
+ ctx.fill();
491
+ ctx.strokeStyle = "#9ca3af";
492
+ ctx.lineWidth = Math.max(1, r * 0.12);
493
+ ctx.setLineDash([]);
494
+ for (let i = 1; i <= 2; i++) {
495
+ const rArc = r * (0.5 + i * 0.3);
496
+ const ax = cx + r * 0.5;
497
+ ctx.beginPath();
498
+ ctx.arc(ax, cy, rArc, -Math.PI * 0.35, Math.PI * 0.35);
499
+ ctx.stroke();
500
+ }
501
+ }
502
+ else {
503
+ ctx.beginPath();
504
+ ctx.arc(cx, cy, r * 1.1, 0, Math.PI * 2);
505
+ ctx.fill();
506
+ ctx.fillStyle = "#f3f4f6";
507
+ const t = r * 0.55;
508
+ ctx.beginPath();
509
+ ctx.moveTo(cx - t * 0.35, cy - t);
510
+ ctx.lineTo(cx + t, cy);
511
+ ctx.lineTo(cx - t * 0.35, cy + t);
512
+ ctx.closePath();
513
+ ctx.fill();
514
+ }
515
+ ctx.restore();
516
+ }
517
+ // 线端箭头(headEnd / tailEnd)。SVG backend 用 <marker> 渲染,Canvas 此前完全没画,
518
+ // 导致带箭头的 line / 弧线 / 连接符在 PNG / PDF 导出里丢箭头。这里用离屏 SVGPathElement
519
+ // 量取两端端点与切线方向,复用 arrow-geom 的几何(与 SVG 完全一致)在 canvas 上补画。
520
+ drawArrowheads(stroke, d) {
521
+ if (!stroke)
522
+ return;
523
+ const head = stroke.headEnd;
524
+ const tail = stroke.tailEnd;
525
+ const hasHead = !!head && head.kind !== "none";
526
+ const hasTail = !!tail && tail.kind !== "none";
527
+ if (!hasHead && !hasTail)
528
+ return;
529
+ const ends = this.measurePathEnds(d);
530
+ if (!ends)
531
+ return;
532
+ const ctx = this.ctx;
533
+ const sw = ctx.lineWidth || stroke.width || 1;
534
+ ctx.save();
535
+ // 箭头填充跟随线的描边色(solid 为颜色,gradient/pattern 则复用 strokeStyle,
536
+ // 与 SVG 的 context-stroke 语义对齐)。
537
+ ctx.fillStyle = ctx.strokeStyle;
538
+ if (hasHead)
539
+ this.drawOneArrow(ends.sx, ends.sy, ends.sAngle, head, "start", sw);
540
+ if (hasTail)
541
+ this.drawOneArrow(ends.ex, ends.ey, ends.eAngle, tail, "end", sw);
542
+ ctx.restore();
543
+ }
544
+ drawOneArrow(x, y, angle, end, side, sw) {
545
+ const { w, l } = arrowSize(end);
546
+ const refX = arrowRefX(end.kind, side, l);
547
+ const p = new Path2D(arrowPathD(end.kind, w, l, side));
548
+ const ctx = this.ctx;
549
+ ctx.save();
550
+ // markerUnits="strokeWidth":箭头尺寸按线宽缩放;orient="auto":+X 对齐线方向;
551
+ // refX/refY 这个锚点落在端点上。
552
+ ctx.translate(x, y);
553
+ ctx.rotate(angle);
554
+ ctx.scale(sw, sw);
555
+ ctx.translate(-refX, 0);
556
+ ctx.fill(p);
557
+ ctx.restore();
558
+ }
559
+ measurePathEnds(d) {
560
+ try {
561
+ if (!this.arrowProbe) {
562
+ this.arrowProbe = document.createElementNS("http://www.w3.org/2000/svg", "path");
563
+ }
564
+ const el = this.arrowProbe;
565
+ el.setAttribute("d", d);
566
+ const total = el.getTotalLength();
567
+ if (!Number.isFinite(total) || total <= 0)
568
+ return undefined;
569
+ const eps = Math.max(0.01, Math.min(1, total * 0.01));
570
+ const p0 = el.getPointAtLength(0);
571
+ const pe = el.getPointAtLength(total);
572
+ const pa = el.getPointAtLength(Math.min(total, eps));
573
+ const pb = el.getPointAtLength(Math.max(0, total - eps));
574
+ // start 侧:箭头朝向"离开起点的切线"反向(尖端向外),path 已镜像,这里给离开方向即可。
575
+ const sAngle = Math.atan2(pa.y - p0.y, pa.x - p0.x);
576
+ // end 侧:箭头沿"行进方向"(指向终点之外)。
577
+ const eAngle = Math.atan2(pe.y - pb.y, pe.x - pb.x);
578
+ return { sx: p0.x, sy: p0.y, sAngle, ex: pe.x, ey: pe.y, eAngle };
579
+ }
580
+ catch {
581
+ return undefined;
582
+ }
583
+ }
584
+ drawText(s, node, pass = "main") {
585
+ const ctx = this.ctx;
586
+ if (pass === "main") {
587
+ const runs = s.paragraphs.flatMap((p) => p.runs);
588
+ const refl = runs.flatMap((r) => r.effects ?? []).find((e) => e.kind === "reflection");
589
+ if (refl && refl.kind === "reflection") {
590
+ ctx.save();
591
+ const bottom = s.bbox.y + s.bbox.h;
592
+ const dist = refl.dist ?? 2;
593
+ ctx.translate(0, bottom + dist);
594
+ ctx.scale(1, -1);
595
+ ctx.translate(0, -bottom);
596
+ ctx.globalAlpha *= refl.startAlpha ?? 0.4;
597
+ this.drawText(s, node, "reflect");
598
+ ctx.restore();
599
+ }
600
+ const filter = pickRunEffectsCssFilter(runs);
601
+ if (filter) {
602
+ ctx.save();
603
+ ctx.filter = ctx.filter && ctx.filter !== "none" ? `${ctx.filter} ${filter}` : filter;
604
+ }
605
+ else {
606
+ ctx.save();
607
+ }
608
+ }
609
+ else {
610
+ ctx.save();
611
+ }
612
+ ctx.textBaseline = "alphabetic";
613
+ // §20.1.7.6 flipH/flipV:翻转只镜像几何与子元素位置,文字字形保持可读。drawNode
614
+ // 已把自身/祖先的 flip 当 scale(-1) 加到 ctx;这里按累计翻转奇偶性绕文本框中心
615
+ // 反向翻转抵消,字形恢复可读(位置仍随父级镜像)。
616
+ const parity = netFlipParity(node);
617
+ const counterFlip = parity.x < 0 || parity.y < 0;
618
+ if (counterFlip) {
619
+ ctx.save();
620
+ const fcx = s.bbox.x + s.bbox.w / 2;
621
+ const fcy = s.bbox.y + s.bbox.h / 2;
622
+ ctx.translate(fcx, fcy);
623
+ ctx.scale(parity.x, parity.y);
624
+ ctx.translate(-fcx, -fcy);
625
+ }
626
+ const bodyRot = s.bodyRot ?? 0;
627
+ const vertRot = vertRotateDeg(s.vert);
628
+ const vertStack = isVerticalStack(s.vert);
629
+ const uprightCorr = s.upright ? -(node.rotation ?? 0) : 0;
630
+ const totalRot = bodyRot + vertRot + uprightCorr;
631
+ const ins = s.insets ?? DEFAULT_TEXT_INSETS;
632
+ const swap = vertRot !== 0;
633
+ const baseW = Math.max(0, s.bbox.w - ins.l - ins.r);
634
+ const baseH = Math.max(0, s.bbox.h - ins.t - ins.b);
635
+ const innerW = swap ? baseH : baseW;
636
+ const innerH = swap ? baseW : baseH;
637
+ const innerX = swap ? s.bbox.x + s.bbox.w / 2 - innerW / 2 : s.bbox.x + ins.l;
638
+ const innerY = swap ? s.bbox.y + s.bbox.h / 2 - innerH / 2 : s.bbox.y + ins.t;
639
+ if (totalRot !== 0) {
640
+ ctx.save();
641
+ const cx = s.bbox.x + s.bbox.w / 2;
642
+ const cy = s.bbox.y + s.bbox.h / 2;
643
+ ctx.translate(cx, cy);
644
+ ctx.rotate((totalRot * Math.PI) / 180);
645
+ ctx.translate(-cx, -cy);
646
+ }
647
+ const wordArt = !vertStack && !warpActive(s) && s.fromWordArt ? this.measureCanvasWordArt(s) : null;
648
+ if (wordArt) {
649
+ ctx.save();
650
+ ctx.transform(wordArt.sx, 0, 0, wordArt.sy, wordArt.tx, wordArt.ty);
651
+ }
652
+ if (vertStack) {
653
+ this.drawTextStacked(s, node, innerX, innerY, baseW, baseH);
654
+ }
655
+ else if (warpActive(s)) {
656
+ this.drawTextWarped(s, innerX, innerY, innerW, innerH);
657
+ }
658
+ else {
659
+ this.drawTextHorizontal(s, node, innerX, innerY, innerW, innerH);
660
+ }
661
+ if (wordArt)
662
+ ctx.restore();
663
+ if (totalRot !== 0)
664
+ ctx.restore();
665
+ if (counterFlip)
666
+ ctx.restore();
667
+ ctx.restore();
668
+ }
669
+ /**
670
+ * 单行艺术字的墨迹框。多段、项目符号、竖排不在这里估,那些仍走 SVG 的 getBBox。
671
+ * 估算必须和 drawTextHorizontal 的顶对齐 / 居中公式一致,否则拉伸会把字拽偏。
672
+ */
673
+ measureCanvasWordArt(s) {
674
+ if (s.paragraphs.length !== 1)
675
+ return null;
676
+ const p = s.paragraphs[0];
677
+ if (p.bullet && p.bullet.kind !== "none")
678
+ return null;
679
+ const text = p.runs.map((r) => r.text ?? "").join("");
680
+ if (!text)
681
+ return null;
682
+ const run = p.runs[0];
683
+ if (!run)
684
+ return null;
685
+ const fs = run.fontSize ?? FALLBACK_FONT_SIZE;
686
+ const italic = run.italic ? "italic " : "";
687
+ const weight = run.fontWeight ?? "normal";
688
+ const ctx = this.ctx;
689
+ ctx.save();
690
+ ctx.font = `${italic}${weight} ${fs}px ${withFontFallback(run.fontFamily)}`;
691
+ const m = ctx.measureText(text);
692
+ ctx.restore();
693
+ const left = m.actualBoundingBoxLeft || 0;
694
+ const right = m.actualBoundingBoxRight || m.width;
695
+ const ascent = m.actualBoundingBoxAscent || fs * 0.8;
696
+ const descent = m.actualBoundingBoxDescent || fs * 0.2;
697
+ const ins = s.insets ?? DEFAULT_TEXT_INSETS;
698
+ const innerX = s.bbox.x + ins.l;
699
+ const innerY = s.bbox.y + ins.t;
700
+ const innerW = Math.max(0, s.bbox.w - ins.l - ins.r);
701
+ const innerH = Math.max(0, s.bbox.h - ins.t - ins.b);
702
+ const lineH = p.lineHeightPx ?? fs * lineHeightFactor(p);
703
+ const lineAscent = lineH - fs * 0.25;
704
+ let yCursor = innerY;
705
+ if (s.vAlign === "middle")
706
+ yCursor = innerY + Math.max(0, (innerH - lineH) / 2);
707
+ else if (s.vAlign === "bottom")
708
+ yCursor = innerY + (innerH - lineH);
709
+ const baseline = yCursor + lineAscent;
710
+ const align = mapVisualAlignWithAnchorCtr(s, p);
711
+ let x = innerX;
712
+ if (align === "middle")
713
+ x = innerX + (innerW - m.width) / 2;
714
+ else if (align === "end")
715
+ x = innerX + innerW - m.width;
716
+ return wordArtFitTransform({ x: x - left, y: baseline - ascent, w: left + right, h: ascent + descent }, s.bbox);
717
+ }
718
+ drawTextWarped(s, innerX, innerY, innerW, innerH) {
719
+ const ctx = this.ctx;
720
+ const prst = s.prstTxWarp?.prst ?? "textArchUp";
721
+ const glyphs = flattenWarpGlyphs(s, (run, ch) => {
722
+ const fs = run.fontSize ?? FALLBACK_FONT_SIZE;
723
+ const it = run.italic ? "italic " : "";
724
+ ctx.font = `${it}${run.fontWeight ?? "normal"} ${fs}px ${withFontFallback(run.fontFamily)}`;
725
+ return ctx.measureText(ch).width;
726
+ });
727
+ const samples = sampleWarpPath(prst, { x: innerX, y: innerY, w: innerW, h: innerH }, s.prstTxWarp?.avLst);
728
+ const placed = placeGlyphsOnWarp(glyphs, samples, prst);
729
+ ctx.textAlign = "center";
730
+ ctx.textBaseline = "middle";
731
+ for (const g of placed) {
732
+ if (g.ch === " ")
733
+ continue;
734
+ ctx.save();
735
+ ctx.translate(g.x, g.y);
736
+ ctx.rotate((g.angle * Math.PI) / 180);
737
+ if (g.scaleY !== 1)
738
+ ctx.scale(1, g.scaleY);
739
+ const it = g.run.italic ? "italic " : "";
740
+ ctx.font = `${it}${g.run.fontWeight ?? "normal"} ${g.fontSize}px ${withFontFallback(g.run.fontFamily)}`;
741
+ ctx.fillStyle = this.runFillStyle(g.run, undefined, {
742
+ x: -g.fontSize / 2,
743
+ y: -g.fontSize / 2,
744
+ w: g.fontSize,
745
+ h: g.fontSize,
746
+ });
747
+ this.fillTextWithOutline(g.ch, 0, 0, g.run.textOutline);
748
+ ctx.restore();
749
+ }
750
+ ctx.textAlign = "start";
751
+ ctx.textBaseline = "alphabetic";
752
+ }
753
+ drawTextHorizontal(s, node, innerX, innerY, innerW, innerH) {
754
+ const ctx = this.ctx;
755
+ const numCol = Math.max(1, Math.floor(s.numCol ?? 1));
756
+ const spcCol = Math.max(0, s.spcCol ?? 0);
757
+ const colW = numCol > 1 ? Math.max(0, (innerW - spcCol * (numCol - 1)) / numCol) : innerW;
758
+ // §21.1.2.1.1 normAutofit — 溢出时运行时重算缩放(与 svg-backend 同步;多栏跳过)。
759
+ if (numCol === 1) {
760
+ s = autofitTextShape(s, innerH, (sh) => sh.paragraphs.reduce((acc, p) => {
761
+ const l = layoutParagraphHorizCanvas(sh, p, colW, ctx, this.scene.slide.index);
762
+ return acc + l.before + l.totalH + l.after;
763
+ }, 0));
764
+ }
765
+ const paraLayouts = s.paragraphs.map((p) => layoutParagraphHorizCanvas(s, p, colW, ctx, this.scene.slide.index));
766
+ const colsLayout = numCol > 1
767
+ ? distributeParagraphsAcrossColumnsByLayout(paraLayouts, innerH, numCol)
768
+ : [paraLayouts.map((_, i) => i)];
769
+ const totalCols = colsLayout.length;
770
+ const numState = newBulletNumState();
771
+ // §21.1.2.1.1 vertOverflow / horzOverflow — 当 clip 或 ellipsis 时把整体绘制
772
+ // 限制在 inner box 内(坐标系是已经 bodyRot/vert 旋转之后)。
773
+ const vertOverflow = s.vertOverflow ?? "overflow";
774
+ const horzOverflow = s.horzOverflow ?? "overflow";
775
+ let pushedClip = false;
776
+ if (vertOverflow !== "overflow" || horzOverflow !== "overflow") {
777
+ ctx.save();
778
+ ctx.beginPath();
779
+ ctx.rect(innerX, innerY, innerW, innerH);
780
+ ctx.clip();
781
+ pushedClip = true;
782
+ }
783
+ // 与 svg-backend 同步(详见 svg 注释):
784
+ // - vAlign="middle":永远钳到非负(PowerPoint 实际表现是 middle 溢出退化为 top-anchor);
785
+ // - vAlign="bottom":仅 vertOverflow="overflow" 时允许负偏移(top 上溢);clip/ellipsis 钳到 0。
786
+ const clampMiddle = (space) => Math.max(0, space);
787
+ const clampBottom = (space) => vertOverflow === "overflow" ? space : Math.max(0, space);
788
+ // §21.1.2.1.1 spcFirstLastPara:见 svg-backend 同名注释。仅 explicit false 抑制。
789
+ const respectFirstLast = s.spcFirstLastPara !== false;
790
+ for (let cIdx = 0; cIdx < totalCols; cIdx++) {
791
+ const visualCol = s.rtlCol ? totalCols - 1 - cIdx : cIdx;
792
+ const colX = innerX + visualCol * (colW + spcCol);
793
+ const idxs = colsLayout[cIdx];
794
+ const colLayouts = idxs.map((i) => paraLayouts[i]);
795
+ const lastIdx = colLayouts.length - 1;
796
+ const effBefore = (k) => !respectFirstLast && k === 0 ? 0 : colLayouts[k].before;
797
+ const effAfter = (k) => !respectFirstLast && k === lastIdx ? 0 : colLayouts[k].after;
798
+ const totalH = colLayouts.reduce((acc, l, k) => acc + effBefore(k) + l.totalH + effAfter(k), 0);
799
+ let yCursor = innerY;
800
+ if (s.vAlign === "middle")
801
+ yCursor = innerY + clampMiddle((innerH - totalH) / 2);
802
+ else if (s.vAlign === "bottom")
803
+ yCursor = innerY + clampBottom(innerH - totalH);
804
+ const colBottom = innerY + innerH;
805
+ for (let k = 0; k < idxs.length; k++) {
806
+ const i = idxs[k];
807
+ const p = s.paragraphs[i];
808
+ const pl = colLayouts[k];
809
+ const ellipsis = vertOverflow === "ellipsis";
810
+ this.drawWrappedParagraphCanvas(p, pl, node, i, {
811
+ innerX: colX,
812
+ innerY,
813
+ innerW: colW,
814
+ innerH,
815
+ yCursor,
816
+ colBottom,
817
+ ellipsis,
818
+ numState,
819
+ spaceBefore: effBefore(k),
820
+ spaceAfter: effAfter(k),
821
+ });
822
+ yCursor += effBefore(k) + pl.totalH + effAfter(k);
823
+ }
824
+ }
825
+ if (pushedClip)
826
+ ctx.restore();
827
+ }
828
+ drawWrappedParagraphCanvas(p, pl, node, i, layout) {
829
+ const ctx = this.ctx;
830
+ const { innerX, innerW, colBottom, ellipsis, numState } = layout;
831
+ const { rtl, align, marL, indent, hasBullet, lines } = pl;
832
+ // 段前间距由调用方按 spcFirstLastPara 取舍(首段时可能为 0),不再走 pl.before。
833
+ const yStart = layout.yCursor + layout.spaceBefore;
834
+ const textLeft = rtl ? innerX : innerX + marL;
835
+ const textRight = rtl ? innerX + innerW - marL : innerX + innerW;
836
+ const para = node.paraOverrides.get(i);
837
+ const visible = para?.visible ?? true;
838
+ if (!visible)
839
+ return;
840
+ const charOv = node.charOverrides.get(i);
841
+ const bulletAbs = hasBullet
842
+ ? rtl
843
+ ? innerX + innerW - marL - indent
844
+ : innerX + marL + indent
845
+ : textLeft;
846
+ // 决定"最后一行"——可能是真正末行,或 ellipsis 模式下"还能放下"的最后一行。
847
+ let lastVisibleLineIdx = lines.length - 1;
848
+ if (ellipsis) {
849
+ let acc = yStart;
850
+ for (let li = 0; li < lines.length; li++) {
851
+ const lh = lines[li].height;
852
+ if (acc + lh > colBottom && li > 0) {
853
+ lastVisibleLineIdx = li - 1;
854
+ break;
855
+ }
856
+ acc += lh;
857
+ }
858
+ }
859
+ ctx.save();
860
+ if (para?.clip && para.clip.kind === "effect") {
861
+ const geom = {
862
+ filter: para.clip.filter,
863
+ prLst: para.clip.prLst,
864
+ progress: para.clip.progress,
865
+ transition: para.clip.transition,
866
+ bbox: { x: textLeft, y: yStart, w: Math.max(1, textRight - textLeft), h: pl.totalH },
867
+ };
868
+ const d = effectClipPathD(geom);
869
+ if (d) {
870
+ const path = new Path2D(d);
871
+ ctx.clip(path, effectClipUsesEvenOdd(geom) ? "evenodd" : "nonzero");
872
+ }
873
+ }
874
+ if (para) {
875
+ ctx.globalAlpha *= para.opacity ?? 1;
876
+ if (para.tx || para.ty)
877
+ ctx.translate(para.tx ?? 0, para.ty ?? 0);
878
+ const sCombo = para.scale ?? 1;
879
+ const sX = (para.scaleX ?? 1) * sCombo;
880
+ const sY = (para.scaleY ?? 1) * sCombo;
881
+ const rot = para.rotation ?? 0;
882
+ if (sX !== 1 || sY !== 1 || rot !== 0) {
883
+ const cx = (textLeft + textRight) / 2;
884
+ const cy = yStart + pl.totalH / 2;
885
+ ctx.translate(cx, cy);
886
+ if (rot)
887
+ ctx.rotate((rot * Math.PI) / 180);
888
+ if (sX !== 1 || sY !== 1)
889
+ ctx.scale(sX, sY);
890
+ ctx.translate(-cx, -cy);
891
+ }
892
+ }
893
+ let yLine = yStart;
894
+ for (let li = 0; li < lines.length; li++) {
895
+ const line = lines[li];
896
+ const baseline = yLine + line.ascent;
897
+ let lineLeft = textLeft;
898
+ let lineRight = textRight;
899
+ if (li === 0 && pl.firstLineIndent > 0) {
900
+ if (rtl)
901
+ lineRight = textRight - pl.firstLineIndent;
902
+ else
903
+ lineLeft = textLeft + pl.firstLineIndent;
904
+ }
905
+ const lineAreaW = Math.max(0, lineRight - lineLeft);
906
+ // line.width 由 text-layout 用 measureText 计算,包含末尾空白;这里 center / end 对齐时
907
+ // 要扣掉行末空白宽度,否则中心 / 右沿被尾空白挤偏(与 CSS text-align 对非 pre 内容的
908
+ // 处理一致)。仅当最后一个 segment 文本含 trailing ASCII / 全角空白 / \t 时才扣。
909
+ let lineW = Math.max(0, line.width - line.hang);
910
+ if ((align === "middle" || align === "end") && line.segments.length > 0) {
911
+ const last = line.segments[line.segments.length - 1];
912
+ const tail = last.text.match(/[ \t\u3000]+$/);
913
+ if (tail) {
914
+ const lastRfs = (para?.fontSize ?? last.run.fontSize) ?? line.maxFs;
915
+ const lastReduced = last.run.baseline
916
+ ? lastRfs * (1 - Math.min(0.5, Math.abs(last.run.baseline)))
917
+ : lastRfs;
918
+ const lastItalic = (para?.fontStyle ?? last.run.italic) ? "italic " : "";
919
+ const lastFw = (para?.fontWeight ?? last.run.fontWeight) ?? "normal";
920
+ const lastFa = withFontFallback(para?.fontFamily ?? last.run.fontFamily);
921
+ ctx.save();
922
+ ctx.font = `${lastItalic}${lastFw} ${lastReduced}px ${lastFa}`;
923
+ lineW = Math.max(0, lineW - ctx.measureText(tail[0]).width);
924
+ ctx.restore();
925
+ }
926
+ }
927
+ let x = lineLeft;
928
+ if (align === "middle")
929
+ x = lineLeft + (lineAreaW - lineW) / 2;
930
+ else if (align === "end")
931
+ x = lineRight - lineW;
932
+ // bullet 仅在第一行渲染。
933
+ if (li === 0)
934
+ this.drawBullet(p, bulletAbs, baseline, numState);
935
+ const appendEllipsis = ellipsis && li === lastVisibleLineIdx && li < lines.length - 1;
936
+ if (charOv && charOv.size > 0 && lines.length === 1) {
937
+ this.drawRunsWithCharOverrides(p, line.maxFs, x, baseline, charOv, para);
938
+ }
939
+ else {
940
+ for (let si = 0; si < line.segments.length; si++) {
941
+ const seg = line.segments[si];
942
+ const run = seg.run;
943
+ const rfs = (para?.fontSize ?? run.fontSize) ?? line.maxFs;
944
+ const reduced = run.baseline ? rfs * (1 - Math.min(0.5, Math.abs(run.baseline))) : rfs;
945
+ const italicSrc = para?.fontStyle ?? run.italic;
946
+ const it = italicSrc ? "italic " : "";
947
+ const fw = (para?.fontWeight ?? run.fontWeight) ?? "normal";
948
+ const fa = withFontFallback(para?.fontFamily ?? run.fontFamily);
949
+ ctx.font = `${it}${fw} ${reduced}px ${fa}`;
950
+ ctx.fillStyle = this.runFillStyle(run, para, { x, y: baseline - rfs, w: seg.width, h: rfs });
951
+ const yShift = run.baseline ? -run.baseline * rfs : 0;
952
+ const drawText = seg.text + (appendEllipsis && si === line.segments.length - 1 ? "…" : "");
953
+ this.fillTextWithOutline(drawText, x, baseline + yShift, run.textOutline);
954
+ x += seg.width + (appendEllipsis && si === line.segments.length - 1 ? ctx.measureText("…").width : 0);
955
+ }
956
+ }
957
+ yLine += line.height;
958
+ if (ellipsis && li === lastVisibleLineIdx)
959
+ break;
960
+ }
961
+ ctx.restore();
962
+ }
963
+ // 逐字符竖排(wordArtVert / eaVert / mongolianVert)。
964
+ // 段内字符流按 innerH 拆 1+ 列;列宽 = 该列字符最大 fontSize;列从右往左(mongolianVert / rtlCol 反向)。
965
+ // 与 svg-backend 的 renderTextStacked 行为一致——共享 buildStackedGlyphs / wrapGlyphsIntoColumns。
966
+ drawTextStacked(s, node, innerX, innerY, innerW, innerH) {
967
+ void innerW;
968
+ const ctx = this.ctx;
969
+ const vert = s.vert;
970
+ const ltrColumns = stackedColumnsLtr(vert, s.rtlCol);
971
+ const spcCol = Math.max(0, s.spcCol ?? 0);
972
+ const numState = newBulletNumState();
973
+ // 与 svg-backend renderStackedColumn 同步:middle/start 永远钳到非负;end/bottom 仅
974
+ // vertOverflow="overflow" 时允许负偏移(顶端上溢)。
975
+ const allowVertOverflow = (s.vertOverflow ?? "overflow") === "overflow";
976
+ const clampMiddle = (sp) => Math.max(0, sp);
977
+ const clampEnd = (sp) => (allowVertOverflow ? sp : Math.max(0, sp));
978
+ const paraColumns = [];
979
+ for (let pi = 0; pi < s.paragraphs.length; pi++) {
980
+ const p = s.paragraphs[pi];
981
+ const para = node.paraOverrides.get(pi);
982
+ const glyphs = buildStackedGlyphs(p, para, vert, { slideIndex: this.scene.slide.index });
983
+ if (glyphs.length === 0) {
984
+ const fs = p.runs.find((r) => r.fontSize)?.fontSize ?? FALLBACK_FONT_SIZE;
985
+ paraColumns.push({ paraIdx: pi, paragraph: p, glyphs: [], width: fs, isFirstColOfPara: true });
986
+ continue;
987
+ }
988
+ const wrapped = wrapGlyphsIntoColumns(glyphs, innerH);
989
+ for (let wi = 0; wi < wrapped.length; wi++) {
990
+ const colGlyphs = wrapped[wi];
991
+ const colW = colGlyphs.reduce((m, g) => Math.max(m, g.fontSize), 0);
992
+ paraColumns.push({
993
+ paraIdx: pi,
994
+ paragraph: p,
995
+ glyphs: colGlyphs,
996
+ width: colW,
997
+ isFirstColOfPara: wi === 0,
998
+ });
999
+ }
1000
+ }
1001
+ let cursorX = ltrColumns ? innerX : innerX + innerW;
1002
+ for (const col of paraColumns) {
1003
+ if (!ltrColumns)
1004
+ cursorX -= col.width;
1005
+ const p = col.paragraph;
1006
+ const para = node.paraOverrides.get(col.paraIdx);
1007
+ const cx = cursorX + col.width / 2;
1008
+ const glyphs = col.glyphs;
1009
+ const totalH = glyphs.reduce((acc, g) => acc + g.stepY, 0);
1010
+ const space = innerH - totalH;
1011
+ let yCursor = innerY;
1012
+ let extraStep = 0;
1013
+ const pAlign = p.align;
1014
+ if (pAlign === "middle")
1015
+ yCursor = innerY + clampMiddle(space / 2);
1016
+ else if (pAlign === "end")
1017
+ yCursor = innerY + clampEnd(space);
1018
+ else if (pAlign === "justify" && glyphs.length > 1 && space > 0) {
1019
+ extraStep = space / (glyphs.length - 1);
1020
+ }
1021
+ else {
1022
+ if (s.vAlign === "middle")
1023
+ yCursor = innerY + clampMiddle(space / 2);
1024
+ else if (s.vAlign === "bottom")
1025
+ yCursor = innerY + clampEnd(space);
1026
+ }
1027
+ const visible = para?.visible ?? true;
1028
+ if (!visible) {
1029
+ if (ltrColumns)
1030
+ cursorX += col.width + spcCol;
1031
+ else
1032
+ cursorX -= spcCol;
1033
+ continue;
1034
+ }
1035
+ ctx.save();
1036
+ if (para)
1037
+ ctx.globalAlpha *= para.opacity ?? 1;
1038
+ // bullet 仅出现在该段的首列。advanceBulletLabel 会更新 numState(编号递增),
1039
+ // 非首列必须跳过调用,否则编号会多走一次。
1040
+ const bulletText = col.isFirstColOfPara && glyphs.length > 0 ? advanceBulletLabel(p, numState) : null;
1041
+ if (bulletText) {
1042
+ const fs = glyphs[0].fontSize;
1043
+ const sizePts = p.bullet && "sizePts" in p.bullet ? p.bullet.sizePts : undefined;
1044
+ const sizePct = p.bullet && "sizePct" in p.bullet ? p.bullet.sizePct : undefined;
1045
+ const bfs = sizePts !== undefined ? sizePts * (96 / 72) : sizePct ? fs * sizePct : fs;
1046
+ ctx.textAlign = "center";
1047
+ ctx.font = `${bfs}px ${withFontFallback((p.bullet && "font" in p.bullet && p.bullet.font) || p.runs[0]?.fontFamily)}`;
1048
+ ctx.fillStyle = (p.bullet && "color" in p.bullet && p.bullet.color) || p.runs[0]?.color || "#000";
1049
+ ctx.fillText(bulletText, cx, yCursor + fs);
1050
+ yCursor += glyphs[0].stepY;
1051
+ }
1052
+ for (let gi = 0; gi < glyphs.length; gi++) {
1053
+ const g = glyphs[gi];
1054
+ const baseline = yCursor + g.fontSize;
1055
+ const italicSrc = para?.fontStyle ?? g.run.italic;
1056
+ const it = italicSrc ? "italic " : "";
1057
+ const fw = (para?.fontWeight ?? g.run.fontWeight) ?? "normal";
1058
+ const fa = withFontFallback(para?.fontFamily ?? g.run.fontFamily);
1059
+ ctx.font = `${it}${fw} ${g.fontSize}px ${fa}`;
1060
+ ctx.textAlign = "center";
1061
+ ctx.fillStyle = this.runFillStyle(g.run, para, {
1062
+ x: cx - g.fontSize / 2,
1063
+ y: baseline - g.fontSize,
1064
+ w: g.fontSize,
1065
+ h: g.fontSize,
1066
+ });
1067
+ if (!g.upright) {
1068
+ ctx.save();
1069
+ ctx.translate(cx, baseline - g.fontSize / 2);
1070
+ ctx.rotate(Math.PI / 2);
1071
+ this.fillTextWithOutline(g.ch, 0, g.fontSize / 2, g.run.textOutline);
1072
+ ctx.restore();
1073
+ }
1074
+ else {
1075
+ this.fillTextWithOutline(g.ch, cx, baseline, g.run.textOutline);
1076
+ }
1077
+ yCursor += g.stepY;
1078
+ if (gi < glyphs.length - 1)
1079
+ yCursor += extraStep;
1080
+ }
1081
+ ctx.restore();
1082
+ if (ltrColumns)
1083
+ cursorX += col.width + spcCol;
1084
+ else
1085
+ cursorX -= spcCol;
1086
+ }
1087
+ }
1088
+ // ECMA-376 §21.1.2.3.9 rPr/ln — 文字描边:先 stroke 再 fill。
1089
+ runFillStyle(run, para, bbox) {
1090
+ const paint = run.fill;
1091
+ if (paint && paint.kind !== "none" && paint.kind !== "solid") {
1092
+ return paintToCanvasStyle(this.ctx, paint, bbox, () => this.scheduleRedraw());
1093
+ }
1094
+ if (paint?.kind === "solid")
1095
+ return paint.color;
1096
+ return (para?.fontColor ?? para?.fillColor ?? run.color) ?? "#000";
1097
+ }
1098
+ fillTextWithOutline(text, x, y, outline) {
1099
+ const ctx = this.ctx;
1100
+ if (outline) {
1101
+ ctx.save();
1102
+ const ok = applyStroke(ctx, outline, { x, y, w: 0, h: 0 }, () => this.scheduleRedraw());
1103
+ if (ok)
1104
+ ctx.strokeText(text, x, y);
1105
+ ctx.restore();
1106
+ }
1107
+ ctx.fillText(text, x, y);
1108
+ }
1109
+ drawBullet(p, bulletX, baseline, state) {
1110
+ const bullet = p.bullet;
1111
+ const lvl = p.level ?? 0;
1112
+ if (!bullet || bullet.kind === "none") {
1113
+ if (state.lastType === "number") {
1114
+ state.lastType = "";
1115
+ state.count = 0;
1116
+ }
1117
+ state.lastLevel = lvl;
1118
+ return;
1119
+ }
1120
+ const hasText = p.runs.some((r) => r.text && r.text !== "");
1121
+ if (!hasText && bullet.kind !== "picture")
1122
+ return;
1123
+ const firstRun = p.runs[0];
1124
+ const runFs = firstRun?.fontSize ?? FALLBACK_FONT_SIZE;
1125
+ const bulletFs = bulletFontSize(bullet, runFs);
1126
+ const ctx = this.ctx;
1127
+ if (bullet.kind === "picture") {
1128
+ const img = getImage(bullet.src, () => this.scheduleRedraw());
1129
+ if (img.complete && img.naturalWidth > 0) {
1130
+ ctx.drawImage(img, bulletX, baseline - bulletFs, bulletFs, bulletFs);
1131
+ }
1132
+ if (state.lastType === "number") {
1133
+ state.lastType = "";
1134
+ state.count = 0;
1135
+ }
1136
+ state.lastLevel = lvl;
1137
+ return;
1138
+ }
1139
+ let text = "";
1140
+ if (bullet.kind === "char") {
1141
+ text = bullet.char;
1142
+ if (state.lastType === "number") {
1143
+ state.lastType = "";
1144
+ state.count = 0;
1145
+ }
1146
+ }
1147
+ else if (bullet.kind === "number") {
1148
+ if (state.lastType !== "number" || state.lastLevel !== lvl) {
1149
+ state.count = (bullet.startAt ?? 1) - 1;
1150
+ state.lastType = "number";
1151
+ }
1152
+ state.count += 1;
1153
+ text = formatBulletNumber(state.count, bullet.numType);
1154
+ }
1155
+ state.lastLevel = lvl;
1156
+ const family = withFontFallback(bullet.font ?? firstRun?.fontFamily);
1157
+ ctx.save();
1158
+ ctx.font = `${bulletFs}px ${family}`;
1159
+ ctx.fillStyle = bullet.color ?? firstRun?.color ?? "#000";
1160
+ ctx.textBaseline = "alphabetic";
1161
+ ctx.fillText(text, bulletX, baseline);
1162
+ ctx.restore();
1163
+ }
1164
+ drawRunsWithCharOverrides(p, fallbackFs, startX, baseline, overrides, paraOv) {
1165
+ const ctx = this.ctx;
1166
+ const entries = [];
1167
+ let absIdx = 0;
1168
+ for (const run of p.runs) {
1169
+ const runText = runDisplayText(run, { slideIndex: this.scene.slide.index });
1170
+ const rfs = run.fontSize ?? fallbackFs;
1171
+ const it = run.italic ? "italic " : "";
1172
+ const fw = run.fontWeight ?? "normal";
1173
+ const fa = withFontFallback(run.fontFamily);
1174
+ ctx.font = `${it}${fw} ${rfs}px ${fa}`;
1175
+ for (const ch of Array.from(runText)) {
1176
+ const w = ctx.measureText(ch).width;
1177
+ const ov = findCharOverrideCanvas(overrides, absIdx);
1178
+ entries.push({ ch, run, width: w, ov });
1179
+ absIdx += ch.length;
1180
+ }
1181
+ }
1182
+ const segs = [];
1183
+ let cur = null;
1184
+ let cursor = 0;
1185
+ for (const e of entries) {
1186
+ if (!cur || cur.ov !== e.ov) {
1187
+ cur = { ov: e.ov, entries: [], offsetX: cursor, width: 0 };
1188
+ segs.push(cur);
1189
+ }
1190
+ cur.entries.push(e);
1191
+ cur.width += e.width;
1192
+ cursor += e.width;
1193
+ }
1194
+ for (const seg of segs) {
1195
+ const segX = startX + seg.offsetX;
1196
+ const ov = seg.ov;
1197
+ const merged = mergeParaOverrides(paraOv, ov);
1198
+ const drawChars = () => {
1199
+ let x = segX;
1200
+ for (const e of seg.entries) {
1201
+ const rfs = (merged?.fontSize ?? e.run.fontSize) ?? fallbackFs;
1202
+ const italicSrc = merged?.fontStyle ?? e.run.italic;
1203
+ const it = italicSrc ? "italic " : "";
1204
+ const fw = (merged?.fontWeight ?? e.run.fontWeight) ?? "normal";
1205
+ const fa = withFontFallback(merged?.fontFamily ?? e.run.fontFamily);
1206
+ ctx.font = `${it}${fw} ${rfs}px ${fa}`;
1207
+ ctx.fillStyle = this.runFillStyle(e.run, merged, { x, y: baseline - rfs, w: e.width, h: rfs });
1208
+ // textOutline 在 char-range 路径下也保持 paint-order(先 stroke 再 fill)。
1209
+ this.fillTextWithOutline(e.ch, x, baseline, e.run.textOutline);
1210
+ x += e.width;
1211
+ }
1212
+ };
1213
+ if (!ov) {
1214
+ drawChars();
1215
+ continue;
1216
+ }
1217
+ const op = ov.opacity ?? 1;
1218
+ if (ov.visible === false || op <= 0)
1219
+ continue;
1220
+ ctx.save();
1221
+ ctx.globalAlpha *= op;
1222
+ const dx = ov.tx ?? 0;
1223
+ const dy = ov.ty ?? 0;
1224
+ const sCombo = ov.scale ?? 1;
1225
+ const sX = (ov.scaleX ?? 1) * sCombo;
1226
+ const sY = (ov.scaleY ?? 1) * sCombo;
1227
+ const rot = ov.rotation ?? 0;
1228
+ if (dx || dy)
1229
+ ctx.translate(dx, dy);
1230
+ if (sX !== 1 || sY !== 1 || rot !== 0) {
1231
+ const cx = segX + seg.width / 2;
1232
+ const cy = baseline - (seg.entries[0]?.run.fontSize ?? fallbackFs) * 0.35;
1233
+ ctx.translate(cx, cy);
1234
+ if (rot)
1235
+ ctx.rotate((rot * Math.PI) / 180);
1236
+ if (sX !== 1 || sY !== 1)
1237
+ ctx.scale(sX, sY);
1238
+ ctx.translate(-cx, -cy);
1239
+ }
1240
+ drawChars();
1241
+ ctx.restore();
1242
+ }
1243
+ }
1244
+ roundRect(x, y, w, h, r) {
1245
+ const ctx = this.ctx;
1246
+ const rr = Math.min(r, w / 2, h / 2);
1247
+ ctx.beginPath();
1248
+ ctx.moveTo(x + rr, y);
1249
+ ctx.arcTo(x + w, y, x + w, y + h, rr);
1250
+ ctx.arcTo(x + w, y + h, x, y + h, rr);
1251
+ ctx.arcTo(x, y + h, x, y, rr);
1252
+ ctx.arcTo(x, y, x + w, y, rr);
1253
+ ctx.closePath();
1254
+ }
1255
+ /** 按未旋转的 bbox 把这个 shape 画到一张独立画布,左上角对齐 bbox 原点。 */
1256
+ renderShapeFront(shapeId) {
1257
+ const node = this.scene.root.findById(shapeId);
1258
+ if (!node || node.shape.kind === "group")
1259
+ return null;
1260
+ const s = node.shape;
1261
+ const c = document.createElement("canvas");
1262
+ const dpr = this.dpr;
1263
+ c.width = Math.max(1, Math.ceil(Math.max(s.bbox.w, 1) * dpr));
1264
+ c.height = Math.max(1, Math.ceil(Math.max(s.bbox.h, 1) * dpr));
1265
+ const ctx = c.getContext("2d");
1266
+ if (!ctx)
1267
+ return null;
1268
+ const prev = this.ctx;
1269
+ this.ctx = ctx;
1270
+ this.drawingFront = true;
1271
+ try {
1272
+ ctx.setTransform(dpr, 0, 0, dpr, -s.bbox.x * dpr, -s.bbox.y * dpr);
1273
+ this.drawShape(s, node);
1274
+ }
1275
+ finally {
1276
+ this.drawingFront = false;
1277
+ this.ctx = prev;
1278
+ }
1279
+ return c;
1280
+ }
1281
+ destroy() {
1282
+ if (this.rafId) {
1283
+ cancelAnimationFrame(this.rafId);
1284
+ this.rafId = 0;
1285
+ }
1286
+ for (const d of this.dispose)
1287
+ d();
1288
+ this.dispose = [];
1289
+ this.canvas.remove();
1290
+ }
1291
+ }
1292
+ /** 离屏把一个 shape 的正面画成画布。调用方负责不再使用返回的 canvas 之后的生命周期。 */
1293
+ export function rasterizeShapeFront(scene, shapeId) {
1294
+ if (typeof document === "undefined")
1295
+ return null;
1296
+ const host = document.createElement("div");
1297
+ const be = new CanvasBackend(scene, host, { transparent: true, pixelRatio: 1 });
1298
+ try {
1299
+ return be.renderShapeFront(shapeId);
1300
+ }
1301
+ finally {
1302
+ be.destroy();
1303
+ }
1304
+ }
1305
+ function findCharOverrideCanvas(overrides, charIdx) {
1306
+ for (const ov of overrides.values()) {
1307
+ if (charIdx >= ov.start && charIdx < ov.end)
1308
+ return ov;
1309
+ }
1310
+ return undefined;
1311
+ }
1312
+ function layoutParagraphHorizCanvas(s, p, maxWidth, ctx, slideIndex) {
1313
+ const fallbackFs = paragraphFallbackFontSize(p);
1314
+ const before = p.spaceBeforePx ?? 0;
1315
+ const after = p.spaceAfterPx ?? 0;
1316
+ const noWrap = s.wrap === false;
1317
+ const rtl = !!p.rtl;
1318
+ const align = mapVisualAlignWithAnchorCtr(s, p);
1319
+ const marL = (rtl ? p.marginRight : p.marginLeft) ?? 0;
1320
+ const indent = p.indent ?? 0;
1321
+ const hasBullet = !!p.bullet && p.bullet.kind !== "none";
1322
+ const firstLineIndent = hasBullet ? 0 : Math.max(0, indent);
1323
+ const textAreaW = Math.max(0, maxWidth - marL);
1324
+ const firstLineW = Math.max(0, textAreaW - firstLineIndent);
1325
+ const lines = wrapParagraphIntoLines(p, textAreaW, fallbackFs, ctx, firstLineW, noWrap, { slideIndex }, s.compatLnSpc);
1326
+ const totalH = lines.reduce((acc, l) => acc + l.height, 0);
1327
+ return {
1328
+ lines,
1329
+ totalH,
1330
+ before,
1331
+ after,
1332
+ rtl,
1333
+ align,
1334
+ marL,
1335
+ indent,
1336
+ hasBullet,
1337
+ firstLineIndent,
1338
+ };
1339
+ }
1340
+ function distributeParagraphsAcrossColumnsByLayout(layouts, innerH, numCol) {
1341
+ const cols = Array.from({ length: numCol }, () => []);
1342
+ const heights = new Array(numCol).fill(0);
1343
+ let curCol = 0;
1344
+ for (let i = 0; i < layouts.length; i++) {
1345
+ const l = layouts[i];
1346
+ const h = l.before + l.totalH + l.after;
1347
+ if (curCol < numCol - 1 && heights[curCol] > 0 && heights[curCol] + h > innerH) {
1348
+ curCol += 1;
1349
+ }
1350
+ cols[curCol].push(i);
1351
+ heights[curCol] += h;
1352
+ }
1353
+ return cols;
1354
+ }
1355
+ function hexWithAlpha(color, alpha) {
1356
+ if (alpha >= 1)
1357
+ return color;
1358
+ if (/^#([0-9a-f]{6})$/i.test(color)) {
1359
+ const a = Math.round(alpha * 255).toString(16).padStart(2, "0");
1360
+ return color + a;
1361
+ }
1362
+ return color;
1363
+ }