@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.
- package/COPYRIGHT +5 -0
- package/LICENSE +661 -0
- package/THIRD-PARTY-NOTICES.md +221 -0
- package/dist/cjs/arrow-geom.js +38 -0
- package/dist/cjs/canvas-backend.js +1368 -0
- package/dist/cjs/cell-borders.js +25 -0
- package/dist/cjs/chart-canvas.js +1059 -0
- package/dist/cjs/chart-ex-render.js +275 -0
- package/dist/cjs/chart-geom.js +313 -0
- package/dist/cjs/chart-svg.js +1110 -0
- package/dist/cjs/compose.js +74 -0
- package/dist/cjs/effect-clip.js +652 -0
- package/dist/cjs/entrance-hide.js +63 -0
- package/dist/cjs/font-stack.js +103 -0
- package/dist/cjs/hit-geom.js +50 -0
- package/dist/cjs/hybrid-backend.js +93 -0
- package/dist/cjs/hybrid-segments.js +54 -0
- package/dist/cjs/index.js +70 -0
- package/dist/cjs/linked-src.js +116 -0
- package/dist/cjs/metafile-preview.js +142 -0
- package/dist/cjs/model3d-frame.js +199 -0
- package/dist/cjs/model3d-render.js +78 -0
- package/dist/cjs/morph-transition.js +57 -0
- package/dist/cjs/node-affine.js +79 -0
- package/dist/cjs/ole-render.js +291 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/paint-canvas.js +701 -0
- package/dist/cjs/paint-svg.js +674 -0
- package/dist/cjs/scene-node.js +762 -0
- package/dist/cjs/selection.js +130 -0
- package/dist/cjs/shape-effects.js +66 -0
- package/dist/cjs/shape-front.js +38 -0
- package/dist/cjs/stage-chrome.js +15 -0
- package/dist/cjs/svg-backend.js +2172 -0
- package/dist/cjs/text-autofit.js +48 -0
- package/dist/cjs/text-hit.js +284 -0
- package/dist/cjs/text-layout.js +408 -0
- package/dist/cjs/text-shared.js +238 -0
- package/dist/cjs/text-stacked.js +82 -0
- package/dist/cjs/text-warp.js +377 -0
- package/dist/cjs/text-wordart.js +33 -0
- package/dist/cjs/video-frame.js +113 -0
- package/dist/cjs/webgl-model3d-backend.js +285 -0
- package/dist/cjs/webgl-shape-backend.js +360 -0
- package/dist/esm/arrow-geom.js +33 -0
- package/dist/esm/canvas-backend.js +1363 -0
- package/dist/esm/cell-borders.js +21 -0
- package/dist/esm/chart-canvas.js +1056 -0
- package/dist/esm/chart-ex-render.js +261 -0
- package/dist/esm/chart-geom.js +296 -0
- package/dist/esm/chart-svg.js +1107 -0
- package/dist/esm/compose.js +71 -0
- package/dist/esm/effect-clip.js +648 -0
- package/dist/esm/entrance-hide.js +59 -0
- package/dist/esm/font-stack.js +100 -0
- package/dist/esm/hit-geom.js +45 -0
- package/dist/esm/hybrid-backend.js +89 -0
- package/dist/esm/hybrid-segments.js +51 -0
- package/dist/esm/index.js +24 -0
- package/dist/esm/linked-src.js +109 -0
- package/dist/esm/metafile-preview.js +135 -0
- package/dist/esm/model3d-frame.js +158 -0
- package/dist/esm/model3d-render.js +73 -0
- package/dist/esm/morph-transition.js +54 -0
- package/dist/esm/node-affine.js +71 -0
- package/dist/esm/ole-render.js +286 -0
- package/dist/esm/paint-canvas.js +693 -0
- package/dist/esm/paint-svg.js +670 -0
- package/dist/esm/scene-node.js +750 -0
- package/dist/esm/selection.js +122 -0
- package/dist/esm/shape-effects.js +60 -0
- package/dist/esm/shape-front.js +35 -0
- package/dist/esm/stage-chrome.js +12 -0
- package/dist/esm/svg-backend.js +2168 -0
- package/dist/esm/text-autofit.js +44 -0
- package/dist/esm/text-hit.js +280 -0
- package/dist/esm/text-layout.js +400 -0
- package/dist/esm/text-shared.js +221 -0
- package/dist/esm/text-stacked.js +78 -0
- package/dist/esm/text-warp.js +371 -0
- package/dist/esm/text-wordart.js +30 -0
- package/dist/esm/video-frame.js +109 -0
- package/dist/esm/webgl-model3d-backend.js +248 -0
- package/dist/esm/webgl-shape-backend.js +323 -0
- package/dist/types/arrow-geom.d.ts +10 -0
- package/dist/types/canvas-backend.d.ts +55 -0
- package/dist/types/cell-borders.d.ts +9 -0
- package/dist/types/chart-canvas.d.ts +2 -0
- package/dist/types/chart-ex-render.d.ts +79 -0
- package/dist/types/chart-geom.d.ts +112 -0
- package/dist/types/chart-svg.d.ts +2 -0
- package/dist/types/compose.d.ts +2 -0
- package/dist/types/effect-clip.d.ts +10 -0
- package/dist/types/entrance-hide.d.ts +16 -0
- package/dist/types/font-stack.d.ts +9 -0
- package/dist/types/hit-geom.d.ts +15 -0
- package/dist/types/hybrid-backend.d.ts +21 -0
- package/dist/types/hybrid-segments.d.ts +11 -0
- package/dist/types/index.d.ts +24 -0
- package/dist/types/linked-src.d.ts +13 -0
- package/dist/types/metafile-preview.d.ts +11 -0
- package/dist/types/model3d-frame.d.ts +8 -0
- package/dist/types/model3d-render.d.ts +4 -0
- package/dist/types/morph-transition.d.ts +15 -0
- package/dist/types/node-affine.d.ts +22 -0
- package/dist/types/ole-render.d.ts +7 -0
- package/dist/types/paint-canvas.d.ts +22 -0
- package/dist/types/paint-svg.d.ts +19 -0
- package/dist/types/scene-node.d.ts +128 -0
- package/dist/types/selection.d.ts +22 -0
- package/dist/types/shape-effects.d.ts +13 -0
- package/dist/types/shape-front.d.ts +4 -0
- package/dist/types/stage-chrome.d.ts +8 -0
- package/dist/types/svg-backend.d.ts +88 -0
- package/dist/types/text-autofit.d.ts +7 -0
- package/dist/types/text-hit.d.ts +7 -0
- package/dist/types/text-layout.d.ts +35 -0
- package/dist/types/text-shared.d.ts +86 -0
- package/dist/types/text-stacked.d.ts +11 -0
- package/dist/types/text-warp.d.ts +37 -0
- package/dist/types/text-wordart.d.ts +18 -0
- package/dist/types/video-frame.d.ts +2 -0
- package/dist/types/webgl-model3d-backend.d.ts +25 -0
- package/dist/types/webgl-shape-backend.d.ts +33 -0
- package/package.json +33 -4
- package/README.md +0 -3
|
@@ -0,0 +1,2172 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SvgBackend = void 0;
|
|
4
|
+
const font_stack_1 = require("./font-stack");
|
|
5
|
+
const core_1 = require("@sliiu/present-core");
|
|
6
|
+
const scene_node_1 = require("./scene-node");
|
|
7
|
+
const paint_svg_1 = require("./paint-svg");
|
|
8
|
+
const cell_borders_1 = require("./cell-borders");
|
|
9
|
+
const linked_src_1 = require("./linked-src");
|
|
10
|
+
const paint_canvas_1 = require("./paint-canvas");
|
|
11
|
+
const metafile_preview_1 = require("./metafile-preview");
|
|
12
|
+
const chart_svg_1 = require("./chart-svg");
|
|
13
|
+
const ole_render_1 = require("./ole-render");
|
|
14
|
+
const model3d_render_1 = require("./model3d-render");
|
|
15
|
+
const video_frame_1 = require("./video-frame");
|
|
16
|
+
const effect_clip_1 = require("./effect-clip");
|
|
17
|
+
const text_layout_1 = require("./text-layout");
|
|
18
|
+
const text_stacked_1 = require("./text-stacked");
|
|
19
|
+
const shape_effects_1 = require("./shape-effects");
|
|
20
|
+
const text_autofit_1 = require("./text-autofit");
|
|
21
|
+
const text_wordart_1 = require("./text-wordart");
|
|
22
|
+
const text_warp_1 = require("./text-warp");
|
|
23
|
+
const three_bake_1 = require("@sliiu/present-three-bake");
|
|
24
|
+
const text_shared_1 = require("./text-shared");
|
|
25
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
26
|
+
// 每个 SvgBackend 实例一个唯一序号,用来给 <defs> 里的 clipPath id 加前缀。
|
|
27
|
+
// 编辑器里同一篇 PPT 会被多个 backend 同时渲染(主画布 + 各缩略图 + 放映 player),
|
|
28
|
+
// 它们对同一个 shape 生成相同的 clip id(如 imgclip-sp_36),导致全局 id 冲突:
|
|
29
|
+
// url(#imgclip-sp_36) 只会解析到文档里第一个同 id 元素,进入放映时主画布若被隐藏/
|
|
30
|
+
// 销毁,放映 player 的 <image> 就指向了失效的 clipPath → 裁剪失效 → 放大的精灵图
|
|
31
|
+
// 整张露出(米粒放大叠在一起)。加实例前缀后每个 backend 只引用自己的 clipPath。
|
|
32
|
+
let svgBackendSeq = 0;
|
|
33
|
+
// ECMA-376 §21.1.2.1.1 bodyPr — `vert` 用来指明文本框的整体走向。
|
|
34
|
+
// 实现策略(OOXML 规定的视觉等价物):
|
|
35
|
+
// - "horz" → 水平正向
|
|
36
|
+
// - "vert" → 文本框内容旋转 90° 顺时针;行从右往左堆叠(视觉上每行像横向文字侧躺)
|
|
37
|
+
// - "vert270" → 旋转 270° 顺时针(即 90° 逆时针);行从左往右堆叠
|
|
38
|
+
// - "wordArtVert" → 字符竖直堆叠(不旋转字符);列从左往右(WPS「行顺序从左到右」)
|
|
39
|
+
// - "wordArtVertRtl"→ 同 wordArtVert,列从右往左(WPS「行顺序从右到左」)
|
|
40
|
+
// - "eaVert" → 同 wordArtVertRtl,但拉丁脚本字符顺时针 90° 旋转(CJK 直立)
|
|
41
|
+
// - "mongolianVert" → 同 wordArtVert,列从左往右
|
|
42
|
+
// 我们把前两种用 group transform 来旋转整个段落树(W/H 互换),
|
|
43
|
+
// 后三种用"逐字符竖排"布局(每字一行,按字号推进 y),以更贴近 PowerPoint/WPS 的真实排版。
|
|
44
|
+
// vertRotateDeg / isVerticalStack / DEFAULT_TEXT_INSETS 都来自 ./text-shared,
|
|
45
|
+
// 与 canvas-backend 和编辑器的行内文本编辑器共用同一套判定。
|
|
46
|
+
// ECMA-376 §20.1.5.5 scene3d/<a:camera> — 把相机转成 CSS 3D。需要挤出、倒角或
|
|
47
|
+
// 明显相机角的文字改走 three-bake,这里不再调用,避免和贴图各转一次。
|
|
48
|
+
//
|
|
49
|
+
// 角度映射约定(与 packages/three-bake/src/camera.ts:setupCamera 中的相机摆放等价):
|
|
50
|
+
// - PowerPoint lat 取负值 = 相机抬高从上往下俯视 ⇒ CSS 上把对象的"顶边"往后倾,
|
|
51
|
+
// 等价于 `rotateX(-lat°)` (lat=-20 → rotateX(20°))。
|
|
52
|
+
// - PowerPoint lon 取正值 = 相机绕 Y 轴右移、看向对象左侧 ⇒ 对象的"右边"远离视点,
|
|
53
|
+
// CSS 中 `rotateY` 的右手系约定下需要 `rotateY(lon°)` 取**正号**(实测对得上)。
|
|
54
|
+
// - rev 是相机自身绕镜头轴的滚转(rev>0 顺时针)⇒ CSS 上旋转对象自身 = `rotateZ(rev°)`。
|
|
55
|
+
// fov→perspective 距离:CSS `perspective(P)` 是观察者到投影面的距离(px)。
|
|
56
|
+
function cssTransformFromBody3D(info, bbox) {
|
|
57
|
+
const cam = info?.scene3d?.camera;
|
|
58
|
+
if (!cam)
|
|
59
|
+
return "";
|
|
60
|
+
const r = (0, three_bake_1.resolveCamera)(cam);
|
|
61
|
+
const near0 = (v) => Math.abs(v) < 0.5;
|
|
62
|
+
if (near0(r.lat) && near0(r.lon) && near0(r.rev))
|
|
63
|
+
return "";
|
|
64
|
+
const maxDim = Math.max(bbox.w, bbox.h, 1);
|
|
65
|
+
const fov = Math.max(5, r.fov || 50);
|
|
66
|
+
const persp = r.isOrtho ? 0 : (maxDim / 2) / Math.tan((fov * Math.PI) / 360);
|
|
67
|
+
const parts = [];
|
|
68
|
+
if (persp > 0)
|
|
69
|
+
parts.push(`perspective(${Math.round(persp)}px)`);
|
|
70
|
+
parts.push(`rotateX(${(-r.lat).toFixed(2)}deg)`);
|
|
71
|
+
parts.push(`rotateY(${r.lon.toFixed(2)}deg)`);
|
|
72
|
+
if (!near0(r.rev))
|
|
73
|
+
parts.push(`rotateZ(${r.rev.toFixed(2)}deg)`);
|
|
74
|
+
if (r.zoom && Math.abs(r.zoom - 1) > 0.01)
|
|
75
|
+
parts.push(`scale(${r.zoom})`);
|
|
76
|
+
return parts.join(" ");
|
|
77
|
+
}
|
|
78
|
+
// 与 text-layout.ts 共享的"东亚 / CJK"判定(renderer 内部别名导出,供 stacked vert 等使用)。
|
|
79
|
+
const isEastAsianChar = text_layout_1.isEastAsianChar;
|
|
80
|
+
// PowerPoint 的 animEffect filter="fade" 是 opacity-based 过渡,而不是几何 mask。
|
|
81
|
+
// runtime.applyTrack 已经把 transition 的方向编码进 progress:
|
|
82
|
+
// transition="in" → progress = u(0→1)
|
|
83
|
+
// transition="out" → progress = 1 - u(1→0)
|
|
84
|
+
// 所以这里 fadeMul 直接取 progress 即可(不再二次反转):
|
|
85
|
+
// in 时 opacity *= progress(0→1)→ fade-in
|
|
86
|
+
// out 时 opacity *= progress(1→0)→ fade-out
|
|
87
|
+
// none/缺失 → 保持 1
|
|
88
|
+
// 注意 effect-clip 路径里 fade 在 progress=1 时已返回 fullRect(不挡 shape),
|
|
89
|
+
// 所以这里只负责 opacity 部分;其他 filter(wipe/blinds/diamond 等)仍走 clip 几何。
|
|
90
|
+
function fadeOpacityFromClip(clip) {
|
|
91
|
+
if (!clip || clip.kind !== "effect" || clip.filter !== "fade")
|
|
92
|
+
return 1;
|
|
93
|
+
if (clip.transition === "none")
|
|
94
|
+
return 1;
|
|
95
|
+
return Math.max(0, Math.min(1, clip.progress ?? 0));
|
|
96
|
+
}
|
|
97
|
+
function findCharOverride(overrides, charIdx) {
|
|
98
|
+
for (const ov of overrides.values()) {
|
|
99
|
+
if (charIdx >= ov.start && charIdx < ov.end)
|
|
100
|
+
return ov;
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
function hasRangeTransform(ov) {
|
|
105
|
+
return ((ov.scaleX !== undefined && ov.scaleX !== 1) ||
|
|
106
|
+
(ov.scaleY !== undefined && ov.scaleY !== 1) ||
|
|
107
|
+
(ov.scale !== undefined && ov.scale !== 1) ||
|
|
108
|
+
(ov.rotation !== undefined && ov.rotation !== 0));
|
|
109
|
+
}
|
|
110
|
+
/** 视频画面按 srcRect 放大后放进 foreignObject,窗口外的部分被裁掉。用像素,避免百分比相对宽度。 */
|
|
111
|
+
function layoutMediaCrop(el, crop, boxW, boxH) {
|
|
112
|
+
const l = crop?.l ?? 0;
|
|
113
|
+
const t = crop?.t ?? 0;
|
|
114
|
+
const r = crop?.r ?? 0;
|
|
115
|
+
const b = crop?.b ?? 0;
|
|
116
|
+
const cw = Math.max(1e-6, 1 - l - r);
|
|
117
|
+
const ch = Math.max(1e-6, 1 - t - b);
|
|
118
|
+
el.style.position = "absolute";
|
|
119
|
+
el.style.left = `${(-l / cw) * boxW}px`;
|
|
120
|
+
el.style.top = `${(-t / ch) * boxH}px`;
|
|
121
|
+
el.style.width = `${boxW / cw}px`;
|
|
122
|
+
el.style.height = `${boxH / ch}px`;
|
|
123
|
+
}
|
|
124
|
+
class SvgBackend {
|
|
125
|
+
root;
|
|
126
|
+
defs;
|
|
127
|
+
bindings = new Map();
|
|
128
|
+
scene;
|
|
129
|
+
// 本实例的 clip id 前缀,避免多 backend 同 id 冲突(见文件顶部 svgBackendSeq 注释)。
|
|
130
|
+
uid = `b${svgBackendSeq++}`;
|
|
131
|
+
rafId = 0;
|
|
132
|
+
dispose = [];
|
|
133
|
+
shapeFilter;
|
|
134
|
+
preview;
|
|
135
|
+
measureCtx = null;
|
|
136
|
+
textFxSeq = 0;
|
|
137
|
+
textInnerFilters = new Map();
|
|
138
|
+
getMeasureCtx() {
|
|
139
|
+
if (!this.measureCtx) {
|
|
140
|
+
const c = document.createElement("canvas");
|
|
141
|
+
this.measureCtx = c.getContext("2d");
|
|
142
|
+
}
|
|
143
|
+
return this.measureCtx;
|
|
144
|
+
}
|
|
145
|
+
constructor(scene, container, opts = {}) {
|
|
146
|
+
this.scene = scene;
|
|
147
|
+
this.shapeFilter = opts.shapeFilter ?? (() => true);
|
|
148
|
+
this.preview = opts.preview === true;
|
|
149
|
+
this.root = document.createElementNS(SVG_NS, "svg");
|
|
150
|
+
this.root.setAttribute("viewBox", `0 0 ${scene.width} ${scene.height}`);
|
|
151
|
+
this.root.setAttribute("width", "100%");
|
|
152
|
+
this.root.setAttribute("height", "100%");
|
|
153
|
+
// 默认 display:inline 会在基线下方留出一行空隙。舞台按视口贴齐时,
|
|
154
|
+
// 这几像素会让滚动容器认为内容溢出,适应窗口仍出现滚动条。
|
|
155
|
+
this.root.style.display = "block";
|
|
156
|
+
if (!opts.transparent)
|
|
157
|
+
this.root.style.background = "#fff";
|
|
158
|
+
if (opts.clipToSlide === false) {
|
|
159
|
+
this.root.style.overflow = "visible";
|
|
160
|
+
this.root.setAttribute("overflow", "visible");
|
|
161
|
+
}
|
|
162
|
+
this.defs = document.createElementNS(SVG_NS, "defs");
|
|
163
|
+
this.root.appendChild(this.defs);
|
|
164
|
+
container.appendChild(this.root);
|
|
165
|
+
this.mount(scene.root, this.root);
|
|
166
|
+
this.scheduleRedraw();
|
|
167
|
+
scene.root.walk((n) => {
|
|
168
|
+
const off = n.on(() => this.scheduleRedraw());
|
|
169
|
+
this.dispose.push(off);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
scheduleRedraw() {
|
|
173
|
+
if (this.rafId)
|
|
174
|
+
return;
|
|
175
|
+
this.rafId = requestAnimationFrame(() => {
|
|
176
|
+
this.rafId = 0;
|
|
177
|
+
this.redraw(this.scene.root);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* 同步刷新当前 scene 到 DOM,并取消 pending rAF。
|
|
182
|
+
* 主要用于编辑器中切换/重建 backend 时,确保新 backend 的视觉在旧 backend
|
|
183
|
+
* 被销毁前已经写入 DOM,避免出现"先空一帧再绘制"的闪屏。
|
|
184
|
+
*/
|
|
185
|
+
flush() {
|
|
186
|
+
if (this.rafId) {
|
|
187
|
+
cancelAnimationFrame(this.rafId);
|
|
188
|
+
this.rafId = 0;
|
|
189
|
+
}
|
|
190
|
+
this.redraw(this.scene.root);
|
|
191
|
+
}
|
|
192
|
+
mount(node, parentEl) {
|
|
193
|
+
const g = document.createElementNS(SVG_NS, "g");
|
|
194
|
+
g.setAttribute("data-id", node.id);
|
|
195
|
+
g.setAttribute("data-shape-id", node.id);
|
|
196
|
+
g.setAttribute("data-name", node.shape.name);
|
|
197
|
+
g.setAttribute("data-shape-name", node.shape.name);
|
|
198
|
+
const ph = node.shape.placeholder?.type;
|
|
199
|
+
if (ph)
|
|
200
|
+
g.setAttribute("data-placeholder-type", ph);
|
|
201
|
+
parentEl.appendChild(g);
|
|
202
|
+
const accepted = this.shapeFilter(node.shape);
|
|
203
|
+
const shapeEl = accepted ? this.createShapeEl(node.shape) : undefined;
|
|
204
|
+
if (shapeEl)
|
|
205
|
+
g.appendChild(shapeEl);
|
|
206
|
+
this.bindings.set(node.id, { el: g, shapeEl });
|
|
207
|
+
if (node.shape.kind === "group") {
|
|
208
|
+
for (const c of node.children)
|
|
209
|
+
this.mount(c, g);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
createShapeEl(shape) {
|
|
213
|
+
switch (shape.kind) {
|
|
214
|
+
case "rect":
|
|
215
|
+
return document.createElementNS(SVG_NS, "rect");
|
|
216
|
+
case "ellipse":
|
|
217
|
+
return document.createElementNS(SVG_NS, "ellipse");
|
|
218
|
+
case "path":
|
|
219
|
+
return document.createElementNS(SVG_NS, "path");
|
|
220
|
+
case "line":
|
|
221
|
+
return document.createElementNS(SVG_NS, "line");
|
|
222
|
+
case "polygon":
|
|
223
|
+
return document.createElementNS(SVG_NS, "polygon");
|
|
224
|
+
case "text":
|
|
225
|
+
return document.createElementNS(SVG_NS, "g");
|
|
226
|
+
case "image":
|
|
227
|
+
return document.createElementNS(SVG_NS, "image");
|
|
228
|
+
case "chart":
|
|
229
|
+
return document.createElementNS(SVG_NS, "g");
|
|
230
|
+
case "video":
|
|
231
|
+
case "audio":
|
|
232
|
+
case "ole":
|
|
233
|
+
case "webEmbed":
|
|
234
|
+
return document.createElementNS(SVG_NS, "g");
|
|
235
|
+
case "model3d":
|
|
236
|
+
return document.createElementNS(SVG_NS, "g");
|
|
237
|
+
case "group":
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
redraw(node) {
|
|
242
|
+
const b = this.bindings.get(node.id);
|
|
243
|
+
if (!b)
|
|
244
|
+
return;
|
|
245
|
+
const s = node.shape;
|
|
246
|
+
const visible = (node.visibleOverride ?? s.visible) !== false;
|
|
247
|
+
b.el.style.display = visible ? "" : "none";
|
|
248
|
+
const cx = s.bbox.x + s.bbox.w / 2;
|
|
249
|
+
const cy = s.bbox.y + s.bbox.h / 2;
|
|
250
|
+
// §20.1.7.6 flipH/flipV → shape.transform.scaleX/scaleY ∈ {1,-1},与动画基线
|
|
251
|
+
// scaleX/scaleY 解耦地乘入最终 transform;animScale / widthScale 等动画乘子
|
|
252
|
+
// 即使归 1 也不会冲掉翻转。
|
|
253
|
+
const flipX = s.transform?.scaleX ?? 1;
|
|
254
|
+
const flipY = s.transform?.scaleY ?? 1;
|
|
255
|
+
const sx = node.scale * node.scaleX * node.widthScale * (flipX < 0 ? -1 : 1);
|
|
256
|
+
const sy = node.scale * node.scaleY * node.heightScale * (flipY < 0 ? -1 : 1);
|
|
257
|
+
const transforms = [];
|
|
258
|
+
transforms.push(`translate(${node.tx},${node.ty})`);
|
|
259
|
+
if (node.rotation !== 0 || sx !== 1 || sy !== 1) {
|
|
260
|
+
transforms.push(`translate(${cx},${cy})`);
|
|
261
|
+
if (node.rotation !== 0)
|
|
262
|
+
transforms.push(`rotate(${node.rotation})`);
|
|
263
|
+
if (sx !== 1 || sy !== 1)
|
|
264
|
+
transforms.push(`scale(${sx},${sy})`);
|
|
265
|
+
transforms.push(`translate(${-cx},${-cy})`);
|
|
266
|
+
}
|
|
267
|
+
// ECMA-376 §20.1.5.5 scene3d — image shape 走 packages/three-bake 的 WebGL bake
|
|
268
|
+
// 把"3D 旋转 + 倒角 + 侧面 + 高光"烘进 PNG(packages/player/src/bake-3d.ts)。
|
|
269
|
+
// 之前这里还保留过一条 CSS 3D transform 的 fallback 路径,但 bake 一旦完成
|
|
270
|
+
// setProperty('src') 立刻把 2D 图换成 3D PNG,会看到"扁平倾斜 → 立体"的跳变;
|
|
271
|
+
// 而且 cropRect 在 bake 完成前还活着,CSS 3D 旋转的是被 clip 出来的小窗口
|
|
272
|
+
// (image 实际尺寸 ≈ src/(1-b),旋转 pivot 又是 bbox 中心),第一帧的几何
|
|
273
|
+
// 也对不齐 bake 之后的位置。直接交给 SVG transform 走 2D,bake 替换 src 时
|
|
274
|
+
// 不再有"双重 3D"。如果 bake 失败(无 WebGL 环境)就退化成普通 2D image,
|
|
275
|
+
// 至少形状大小 / 位置都正确。
|
|
276
|
+
const ss = b.el.style;
|
|
277
|
+
if (ss.transform)
|
|
278
|
+
ss.transform = "";
|
|
279
|
+
if (ss.transformOrigin)
|
|
280
|
+
ss.transformOrigin = "";
|
|
281
|
+
if (ss.transformBox)
|
|
282
|
+
ss.transformBox = "";
|
|
283
|
+
b.el.setAttribute("transform", transforms.join(" "));
|
|
284
|
+
// §19.5.8 animEffect filter="fade" 在 PowerPoint 中是 opacity-based 过渡(不是几何 mask),
|
|
285
|
+
// progress 直接乘到 opacity 上:in 时 0→1,out 时 1→0;其他 filter 仍走 clip 几何。
|
|
286
|
+
// node.opacity 已包含 set/anim opacity 末态,这里再乘 fade 进度即可。
|
|
287
|
+
const fadeMul = fadeOpacityFromClip(node.clip);
|
|
288
|
+
b.el.setAttribute("opacity", String(node.opacity * fadeMul));
|
|
289
|
+
this.applyShapeEffects(b, node, s);
|
|
290
|
+
if (b.shapeEl && this.paintBaked3D(s, b)) {
|
|
291
|
+
// 非 image 的 3D 已换成 baked PNG。image 仍走 applyShapeAttrs 里的 image 分支。
|
|
292
|
+
}
|
|
293
|
+
else if (b.shapeEl) {
|
|
294
|
+
this.applyShapeAttrs(s, b.shapeEl, node, b);
|
|
295
|
+
}
|
|
296
|
+
this.applyClip(node, b);
|
|
297
|
+
for (const c of node.children)
|
|
298
|
+
this.redraw(c);
|
|
299
|
+
}
|
|
300
|
+
// ECMA-376 §20.1.8.45 effectLst — shape 级效果链:
|
|
301
|
+
// outerShdw / glow → CSS filter(drop-shadow 链)
|
|
302
|
+
// innerShdw / softEdge → SVG <filter> 原语(url() 拼进同一条 CSS filter 链)
|
|
303
|
+
// reflection → <use> 克隆 + 渐变 mask(见 applyReflection)
|
|
304
|
+
// parser 保证"带 txBody 的形状"拆成 [base=几何(带效果), text=文字(无效果)],
|
|
305
|
+
// 所以这里作用到 node group 等价于只作用到图形体。
|
|
306
|
+
applyShapeEffects(b, node, s) {
|
|
307
|
+
const fx = (0, shape_effects_1.collectShapeEffects)(s);
|
|
308
|
+
const parts = [];
|
|
309
|
+
const css = (0, shape_effects_1.cssFilterForEffects)(fx);
|
|
310
|
+
if (css)
|
|
311
|
+
parts.push(css);
|
|
312
|
+
if (fx.inner || fx.softEdge) {
|
|
313
|
+
const id = `${this.uid}-fx-${node.id}`;
|
|
314
|
+
this.ensureEffectFilter(b, id, fx);
|
|
315
|
+
parts.push(`url(#${id})`);
|
|
316
|
+
}
|
|
317
|
+
else if (b.fxFilter) {
|
|
318
|
+
b.fxFilter.remove();
|
|
319
|
+
b.fxFilter = undefined;
|
|
320
|
+
}
|
|
321
|
+
const filterStr = parts.join(" ");
|
|
322
|
+
if (b.el.style.filter !== filterStr)
|
|
323
|
+
b.el.style.filter = filterStr;
|
|
324
|
+
this.applyReflection(b, node, s, fx);
|
|
325
|
+
}
|
|
326
|
+
ensureEffectFilter(b, id, fx) {
|
|
327
|
+
let f = b.fxFilter;
|
|
328
|
+
if (!f) {
|
|
329
|
+
f = document.createElementNS(SVG_NS, "filter");
|
|
330
|
+
this.defs.appendChild(f);
|
|
331
|
+
b.fxFilter = f;
|
|
332
|
+
}
|
|
333
|
+
// 每帧 redraw 都会到这里:用签名比对做幂等,避免动画期间反复重建 DOM。
|
|
334
|
+
const sig = JSON.stringify([id, fx.inner ?? null, fx.softEdge ?? null]);
|
|
335
|
+
if (f.getAttribute("data-sig") === sig)
|
|
336
|
+
return;
|
|
337
|
+
f.setAttribute("data-sig", sig);
|
|
338
|
+
f.setAttribute("id", id);
|
|
339
|
+
// 放宽 filter 区域,避免默认 10% 边距把 blur 裁掉。
|
|
340
|
+
f.setAttribute("x", "-30%");
|
|
341
|
+
f.setAttribute("y", "-30%");
|
|
342
|
+
f.setAttribute("width", "160%");
|
|
343
|
+
f.setAttribute("height", "160%");
|
|
344
|
+
let inner = "";
|
|
345
|
+
let src = "SourceGraphic";
|
|
346
|
+
if (fx.softEdge) {
|
|
347
|
+
// §20.1.8.53 softEdge — 对 alpha 通道做高斯羽化后 composite in,
|
|
348
|
+
// 线性变换轻度收缩 alpha 让边缘向内羽化(更接近 PowerPoint 的向内收边)。
|
|
349
|
+
const std = Math.max(0.01, fx.softEdge.radius / 2);
|
|
350
|
+
inner +=
|
|
351
|
+
`<feGaussianBlur in="SourceAlpha" stdDeviation="${std}" result="se-blur"/>` +
|
|
352
|
+
`<feComponentTransfer in="se-blur" result="se-a"><feFuncA type="linear" slope="1.3" intercept="-0.3"/></feComponentTransfer>` +
|
|
353
|
+
`<feComposite in="${src}" in2="se-a" operator="in" result="se-out"/>`;
|
|
354
|
+
src = "se-out";
|
|
355
|
+
}
|
|
356
|
+
if (fx.inner) {
|
|
357
|
+
// §20.1.8.40 innerShdw — 经典配方:反相 alpha → blur → offset → 上色 →
|
|
358
|
+
// 裁回形状内部 → 叠加在图形体上。
|
|
359
|
+
const std = Math.max(0.01, fx.inner.blur / 2);
|
|
360
|
+
inner +=
|
|
361
|
+
`<feComponentTransfer in="SourceAlpha" result="is-inv"><feFuncA type="table" tableValues="1 0"/></feComponentTransfer>` +
|
|
362
|
+
`<feGaussianBlur in="is-inv" stdDeviation="${std}" result="is-blur"/>` +
|
|
363
|
+
`<feOffset in="is-blur" dx="${fx.inner.dx}" dy="${fx.inner.dy}" result="is-off"/>` +
|
|
364
|
+
`<feFlood flood-color="${fx.inner.color}" flood-opacity="${fx.inner.alpha}" result="is-col"/>` +
|
|
365
|
+
`<feComposite in="is-col" in2="is-off" operator="in" result="is-shadow"/>` +
|
|
366
|
+
`<feComposite in="is-shadow" in2="SourceAlpha" operator="in" result="is-clip"/>` +
|
|
367
|
+
`<feMerge><feMergeNode in="${src}"/><feMergeNode in="is-clip"/></feMerge>`;
|
|
368
|
+
}
|
|
369
|
+
f.innerHTML = inner;
|
|
370
|
+
}
|
|
371
|
+
// §20.1.8.50 reflection — 用 <use> 引用图形体做垂直镜像,套"上实下虚"的渐变
|
|
372
|
+
// mask 模拟倒影渐隐,blur 直接用 CSS。倒影插在 shapeEl 之前(z 序在图形体下)。
|
|
373
|
+
applyReflection(b, node, s, fx) {
|
|
374
|
+
const refl = fx.reflection;
|
|
375
|
+
if (!refl || !b.shapeEl || s.bbox.h <= 0) {
|
|
376
|
+
if (b.reflectUse) {
|
|
377
|
+
b.reflectUse.remove();
|
|
378
|
+
b.reflectUse = undefined;
|
|
379
|
+
}
|
|
380
|
+
if (b.reflectMask) {
|
|
381
|
+
b.reflectMask.remove();
|
|
382
|
+
b.reflectMask = undefined;
|
|
383
|
+
}
|
|
384
|
+
if (b.reflectGrad) {
|
|
385
|
+
b.reflectGrad.remove();
|
|
386
|
+
b.reflectGrad = undefined;
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (!b.shapeEl.id)
|
|
391
|
+
b.shapeEl.id = `${this.uid}-shape-${node.id}`;
|
|
392
|
+
const gradId = `${this.uid}-reflgrad-${node.id}`;
|
|
393
|
+
const maskId = `${this.uid}-reflmask-${node.id}`;
|
|
394
|
+
if (!b.reflectGrad) {
|
|
395
|
+
b.reflectGrad = document.createElementNS(SVG_NS, "linearGradient");
|
|
396
|
+
b.reflectGrad.setAttribute("id", gradId);
|
|
397
|
+
this.defs.appendChild(b.reflectGrad);
|
|
398
|
+
}
|
|
399
|
+
if (!b.reflectMask) {
|
|
400
|
+
b.reflectMask = document.createElementNS(SVG_NS, "mask");
|
|
401
|
+
b.reflectMask.setAttribute("id", maskId);
|
|
402
|
+
this.defs.appendChild(b.reflectMask);
|
|
403
|
+
}
|
|
404
|
+
if (!b.reflectUse) {
|
|
405
|
+
b.reflectUse = document.createElementNS(SVG_NS, "use");
|
|
406
|
+
b.el.insertBefore(b.reflectUse, b.shapeEl);
|
|
407
|
+
}
|
|
408
|
+
// 倒影几何:绕 bbox 底边镜像(scale -sy),沿 dir 的垂直分量平移 dist。
|
|
409
|
+
const bottom = s.bbox.y + s.bbox.h;
|
|
410
|
+
const rad = ((refl.dir ?? 90) * Math.PI) / 180;
|
|
411
|
+
const dy = Math.sin(rad) * (refl.dist ?? 0);
|
|
412
|
+
const dx = Math.cos(rad) * (refl.dist ?? 0);
|
|
413
|
+
const sy = refl.sy !== undefined ? Math.abs(refl.sy) : 1;
|
|
414
|
+
const reflTop = bottom + dy;
|
|
415
|
+
const reflH = s.bbox.h * sy;
|
|
416
|
+
const sig = JSON.stringify([refl, s.bbox, b.shapeEl.id]);
|
|
417
|
+
if (b.reflectUse.getAttribute("data-sig") === sig)
|
|
418
|
+
return;
|
|
419
|
+
b.reflectUse.setAttribute("data-sig", sig);
|
|
420
|
+
b.reflectUse.setAttribute("href", `#${b.shapeEl.id}`);
|
|
421
|
+
b.reflectUse.setAttribute("transform", `translate(${dx}, ${bottom * (1 + sy) + dy}) scale(1, ${-sy})`);
|
|
422
|
+
b.reflectUse.setAttribute("mask", `url(#${maskId})`);
|
|
423
|
+
b.reflectUse.style.filter = refl.blur > 0 ? `blur(${refl.blur}px)` : "";
|
|
424
|
+
// 渐变:靠近本体一端 startAlpha,向远端 endAlpha(stPos/endPos 是沿渐隐方向的分数)。
|
|
425
|
+
b.reflectGrad.setAttribute("gradientUnits", "userSpaceOnUse");
|
|
426
|
+
b.reflectGrad.setAttribute("x1", "0");
|
|
427
|
+
b.reflectGrad.setAttribute("x2", "0");
|
|
428
|
+
b.reflectGrad.setAttribute("y1", String(reflTop));
|
|
429
|
+
b.reflectGrad.setAttribute("y2", String(reflTop + reflH));
|
|
430
|
+
b.reflectGrad.innerHTML =
|
|
431
|
+
`<stop offset="${refl.startPos}" stop-color="#fff" stop-opacity="${refl.startAlpha}"/>` +
|
|
432
|
+
`<stop offset="${refl.endPos}" stop-color="#fff" stop-opacity="${refl.endAlpha}"/>`;
|
|
433
|
+
b.reflectMask.setAttribute("maskUnits", "userSpaceOnUse");
|
|
434
|
+
b.reflectMask.setAttribute("x", String(s.bbox.x - 50));
|
|
435
|
+
b.reflectMask.setAttribute("y", String(reflTop - 50));
|
|
436
|
+
b.reflectMask.setAttribute("width", String(s.bbox.w + 100));
|
|
437
|
+
b.reflectMask.setAttribute("height", String(reflH + 100));
|
|
438
|
+
// mask 内容 rect:注意 mask 坐标是 userSpaceOnUse,但 <use> 自带 transform(镜像),
|
|
439
|
+
// mask 应用在 <use> 的**变换后**坐标系 → 直接用倒影落点区域。
|
|
440
|
+
b.reflectMask.innerHTML = `<rect x="${s.bbox.x - 50}" y="${reflTop - 50}" width="${s.bbox.w + 100}" height="${reflH + 100}" fill="url(#${gradId})"/>`;
|
|
441
|
+
}
|
|
442
|
+
/** 离屏 3D:用 baked PNG 替换矢量或文字。image 的未烘焙分支仍走 applyShapeAttrs。 */
|
|
443
|
+
paintBaked3D(s, b) {
|
|
444
|
+
if (s.kind === "group")
|
|
445
|
+
return false;
|
|
446
|
+
const info = (0, three_bake_1.shape3DInfo)(s);
|
|
447
|
+
const baked = info?.bakedSrc;
|
|
448
|
+
if (!baked || !b.shapeEl)
|
|
449
|
+
return false;
|
|
450
|
+
const bb = info?.bakedBBox ?? s.bbox;
|
|
451
|
+
let im = b.shapeEl;
|
|
452
|
+
if (im.tagName.toLowerCase() !== "image") {
|
|
453
|
+
const next = document.createElementNS(SVG_NS, "image");
|
|
454
|
+
im.replaceWith(next);
|
|
455
|
+
b.shapeEl = next;
|
|
456
|
+
im = next;
|
|
457
|
+
}
|
|
458
|
+
this.removeImageClip(b);
|
|
459
|
+
im.setAttribute("x", String(bb.x));
|
|
460
|
+
im.setAttribute("y", String(bb.y));
|
|
461
|
+
im.setAttribute("width", String(bb.w));
|
|
462
|
+
im.setAttribute("height", String(bb.h));
|
|
463
|
+
im.setAttribute("href", baked);
|
|
464
|
+
im.setAttributeNS("http://www.w3.org/1999/xlink", "href", baked);
|
|
465
|
+
im.setAttribute("preserveAspectRatio", "none");
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
applyShapeAttrs(s, el, node, b) {
|
|
469
|
+
if (s.kind !== "group" && s.kind !== "image" && s.kind !== "text") {
|
|
470
|
+
// gradFill path="shape" 的等值线沿形状轮廓走,renderPaint 需要知道轮廓类别
|
|
471
|
+
// 才能选精确渲染路径(矩形 → 框带 pattern;椭圆 → objectBoundingBox 椭圆)。
|
|
472
|
+
const hint = s.kind === "rect" ? "rect" : s.kind === "ellipse" ? "ellipse" : "other";
|
|
473
|
+
const fill = (0, scene_node_1.shapeRenderFill)(s, this.scene, node);
|
|
474
|
+
const fillBBox = (0, scene_node_1.shapeRenderFillBBox)(s, this.scene, node);
|
|
475
|
+
const fillPaint = node.fillOverride
|
|
476
|
+
? { paint: node.fillOverride }
|
|
477
|
+
: (0, paint_svg_1.renderPaint)(fill, this.defs, fillBBox, () => this.scheduleRedraw(), hint);
|
|
478
|
+
el.setAttribute("fill", fillPaint.paint);
|
|
479
|
+
if ("opacity" in fillPaint && fillPaint.opacity !== undefined) {
|
|
480
|
+
el.setAttribute("fill-opacity", String(fillPaint.opacity));
|
|
481
|
+
}
|
|
482
|
+
if (node.strokeOverride) {
|
|
483
|
+
el.setAttribute("stroke", node.strokeOverride);
|
|
484
|
+
el.setAttribute("stroke-width", String(node.strokeWidthOverride ?? s.stroke?.width ?? 1));
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
(0, paint_svg_1.applyStroke)(el, s.stroke, this.defs, `${this.uid}-`);
|
|
488
|
+
if (node.strokeWidthOverride !== null) {
|
|
489
|
+
el.setAttribute("stroke-width", String(node.strokeWidthOverride));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
switch (s.kind) {
|
|
494
|
+
case "rect":
|
|
495
|
+
el.setAttribute("x", String(s.bbox.x));
|
|
496
|
+
el.setAttribute("y", String(s.bbox.y));
|
|
497
|
+
el.setAttribute("width", String(s.bbox.w));
|
|
498
|
+
el.setAttribute("height", String(s.bbox.h));
|
|
499
|
+
if (s.rx !== undefined)
|
|
500
|
+
el.setAttribute("rx", String(s.rx));
|
|
501
|
+
if (s.ry !== undefined)
|
|
502
|
+
el.setAttribute("ry", String(s.ry));
|
|
503
|
+
break;
|
|
504
|
+
case "ellipse":
|
|
505
|
+
el.setAttribute("cx", String(s.bbox.x + s.bbox.w / 2));
|
|
506
|
+
el.setAttribute("cy", String(s.bbox.y + s.bbox.h / 2));
|
|
507
|
+
el.setAttribute("rx", String(s.bbox.w / 2));
|
|
508
|
+
el.setAttribute("ry", String(s.bbox.h / 2));
|
|
509
|
+
break;
|
|
510
|
+
case "path":
|
|
511
|
+
el.setAttribute("d", s.d);
|
|
512
|
+
if (s.fillRule)
|
|
513
|
+
el.setAttribute("fill-rule", s.fillRule);
|
|
514
|
+
break;
|
|
515
|
+
case "line":
|
|
516
|
+
el.setAttribute("x1", String(s.x1));
|
|
517
|
+
el.setAttribute("y1", String(s.y1));
|
|
518
|
+
el.setAttribute("x2", String(s.x2));
|
|
519
|
+
el.setAttribute("y2", String(s.y2));
|
|
520
|
+
break;
|
|
521
|
+
case "polygon":
|
|
522
|
+
el.setAttribute("points", s.points.map((p) => `${p[0]},${p[1]}`).join(" "));
|
|
523
|
+
break;
|
|
524
|
+
case "text":
|
|
525
|
+
this.renderText(s, el, node);
|
|
526
|
+
break;
|
|
527
|
+
case "image": {
|
|
528
|
+
const im = el;
|
|
529
|
+
// 离屏光栅副通道:若已烘出 3D PNG(meta3D.bakedSrc),用它 + bakedBBox 显示,
|
|
530
|
+
// 跳过 cropRect(3D / 裁剪都已 burn 进 PNG)。live 视图不会走到这里。
|
|
531
|
+
const baked3d = s.meta3D?.bakedSrc;
|
|
532
|
+
if (baked3d) {
|
|
533
|
+
const bb = s.meta3D?.bakedBBox ?? s.bbox;
|
|
534
|
+
this.removeImageClip(b);
|
|
535
|
+
im.setAttribute("x", String(bb.x));
|
|
536
|
+
im.setAttribute("y", String(bb.y));
|
|
537
|
+
im.setAttribute("width", String(bb.w));
|
|
538
|
+
im.setAttribute("height", String(bb.h));
|
|
539
|
+
im.setAttribute("href", baked3d);
|
|
540
|
+
im.setAttributeNS("http://www.w3.org/1999/xlink", "href", baked3d);
|
|
541
|
+
im.setAttribute("preserveAspectRatio", "none");
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
const cr = s.cropRect;
|
|
545
|
+
let ix = s.bbox.x;
|
|
546
|
+
let iy = s.bbox.y;
|
|
547
|
+
let iw = s.bbox.w;
|
|
548
|
+
let ih = s.bbox.h;
|
|
549
|
+
if (cr) {
|
|
550
|
+
const cw = Math.max(1e-6, 1 - cr.l - cr.r);
|
|
551
|
+
const ch = Math.max(1e-6, 1 - cr.t - cr.b);
|
|
552
|
+
iw = s.bbox.w / cw;
|
|
553
|
+
ih = s.bbox.h / ch;
|
|
554
|
+
ix = s.bbox.x - cr.l * iw;
|
|
555
|
+
iy = s.bbox.y - cr.t * ih;
|
|
556
|
+
}
|
|
557
|
+
if (cr || s.clipD)
|
|
558
|
+
this.applyImageClip(node.id, b, s.bbox, s.clipD);
|
|
559
|
+
else
|
|
560
|
+
this.removeImageClip(b);
|
|
561
|
+
im.setAttribute("x", String(ix));
|
|
562
|
+
im.setAttribute("y", String(iy));
|
|
563
|
+
im.setAttribute("width", String(iw));
|
|
564
|
+
im.setAttribute("height", String(ih));
|
|
565
|
+
const redraw = () => this.scheduleRedraw();
|
|
566
|
+
const probe = (0, paint_canvas_1.getImage)(s.src, redraw);
|
|
567
|
+
const shown = (0, metafile_preview_1.previewImageSrc)(s.src, probe, redraw);
|
|
568
|
+
const href = !shown
|
|
569
|
+
? ""
|
|
570
|
+
: (s.adjust && (0, paint_canvas_1.rasterizedAdjustHref)(shown, s.adjust, redraw)) ||
|
|
571
|
+
(0, linked_src_1.displayImageSrc)(shown, s.bbox.w, s.bbox.h);
|
|
572
|
+
im.setAttribute("href", href);
|
|
573
|
+
im.setAttributeNS("http://www.w3.org/1999/xlink", "href", href);
|
|
574
|
+
im.setAttribute("preserveAspectRatio", "none");
|
|
575
|
+
this.applyImageStroke(s, b, node);
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
case "chart":
|
|
579
|
+
(0, chart_svg_1.renderChart)(el, s);
|
|
580
|
+
break;
|
|
581
|
+
case "video":
|
|
582
|
+
this.renderMedia(s, el, b, "video", node);
|
|
583
|
+
break;
|
|
584
|
+
case "audio":
|
|
585
|
+
this.renderMedia(s, el, b, "audio", node);
|
|
586
|
+
break;
|
|
587
|
+
case "ole":
|
|
588
|
+
(0, ole_render_1.renderOleSvg)(el, s);
|
|
589
|
+
break;
|
|
590
|
+
case "webEmbed":
|
|
591
|
+
this.renderWebEmbed(s, el, b, node);
|
|
592
|
+
break;
|
|
593
|
+
case "model3d":
|
|
594
|
+
(0, model3d_render_1.renderModel3DSvg)(el, s, () => this.scheduleRedraw());
|
|
595
|
+
break;
|
|
596
|
+
case "group":
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
renderMedia(s, container, b, kind, node) {
|
|
601
|
+
if (this.preview) {
|
|
602
|
+
const poster = kind === "video" ? s.poster : undefined;
|
|
603
|
+
const videoCrop = kind === "video" ? s.cropRect : undefined;
|
|
604
|
+
if (poster) {
|
|
605
|
+
this.renderPreviewPoster(container, s.bbox, poster, videoCrop);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (kind === "video") {
|
|
609
|
+
const frame = (0, video_frame_1.getVideoPosterFrame)(s.src, () => this.scheduleRedraw(), s.trim?.start ?? 0.08);
|
|
610
|
+
if (frame?.src) {
|
|
611
|
+
this.renderPreviewPoster(container, s.bbox, frame.src, videoCrop);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
this.renderMediaPlaceholder(container, s.bbox, kind);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
let fo = container.firstElementChild;
|
|
619
|
+
if (!fo) {
|
|
620
|
+
fo = document.createElementNS(SVG_NS, "foreignObject");
|
|
621
|
+
container.appendChild(fo);
|
|
622
|
+
const el = document.createElement(kind);
|
|
623
|
+
el.style.position = "absolute";
|
|
624
|
+
el.style.margin = "0";
|
|
625
|
+
// foreignObject 里的控件必须自己接指针。点在进度条 / 播放上不能穿透到
|
|
626
|
+
// stage,否则放映的 pointerdown 会把它当成翻页。
|
|
627
|
+
el.style.pointerEvents = "auto";
|
|
628
|
+
el.preload = "metadata";
|
|
629
|
+
if (kind === "audio") {
|
|
630
|
+
el.style.display = "none";
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
const v = el;
|
|
634
|
+
v.playsInline = true;
|
|
635
|
+
v.controls = s.controls ?? true;
|
|
636
|
+
v.style.objectFit = "fill";
|
|
637
|
+
if (s.poster)
|
|
638
|
+
v.poster = s.poster;
|
|
639
|
+
}
|
|
640
|
+
el.muted = !!s.muted;
|
|
641
|
+
el.loop = !!s.loop;
|
|
642
|
+
if (s.volume !== undefined)
|
|
643
|
+
el.volume = Math.max(0, Math.min(1, s.volume));
|
|
644
|
+
// 媒体控件交互(点击播放/暂停、拖动进度条等)必须独立于 stage 容器的
|
|
645
|
+
// click/pointerdown 默认推进逻辑。否则用户点视频的 play 按钮会被 player
|
|
646
|
+
// 的 stage.pointerdown 监听器吞掉并触发"前进到下一步/下一页"。
|
|
647
|
+
// 这里统一在 video/audio 上吃掉 pointerdown / mousedown / click / pointerup
|
|
648
|
+
// 的冒泡,让宿主 stage 收不到原始事件;播放器的 hyperlink/动画/前进逻辑
|
|
649
|
+
// 走 stage 路径,对视频区域完全失效(这与 PowerPoint 行为一致)。
|
|
650
|
+
const stopBubble = (ev) => ev.stopPropagation();
|
|
651
|
+
el.addEventListener("pointerdown", stopBubble);
|
|
652
|
+
el.addEventListener("mousedown", stopBubble);
|
|
653
|
+
el.addEventListener("click", stopBubble);
|
|
654
|
+
el.addEventListener("dblclick", stopBubble);
|
|
655
|
+
el.addEventListener("touchstart", stopBubble, { passive: true });
|
|
656
|
+
fo.appendChild(el);
|
|
657
|
+
b.mediaEl = el;
|
|
658
|
+
node.mediaEl = el;
|
|
659
|
+
}
|
|
660
|
+
if (b.mediaEl && s.src && b.mediaEl.src !== s.src)
|
|
661
|
+
b.mediaEl.src = s.src;
|
|
662
|
+
if (b.mediaEl) {
|
|
663
|
+
b.mediaEl.muted = !!s.muted;
|
|
664
|
+
b.mediaEl.loop = !!s.loop;
|
|
665
|
+
if (s.volume !== undefined)
|
|
666
|
+
b.mediaEl.volume = Math.max(0, Math.min(1, s.volume));
|
|
667
|
+
if (kind === "video") {
|
|
668
|
+
const poster = s.poster;
|
|
669
|
+
const v = b.mediaEl;
|
|
670
|
+
if (poster) {
|
|
671
|
+
if (v.poster !== poster)
|
|
672
|
+
v.poster = poster;
|
|
673
|
+
}
|
|
674
|
+
else if (v.poster) {
|
|
675
|
+
v.removeAttribute("poster");
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
b.mediaTrimDetach?.();
|
|
679
|
+
b.mediaTrimDetach = (0, core_1.attachMediaTrimPlayback)(b.mediaEl, s.trim, s.duration, !!s.loop);
|
|
680
|
+
}
|
|
681
|
+
fo.setAttribute("x", String(s.bbox.x));
|
|
682
|
+
fo.setAttribute("y", String(s.bbox.y));
|
|
683
|
+
fo.setAttribute("width", String(s.bbox.w));
|
|
684
|
+
fo.setAttribute("height", String(s.bbox.h));
|
|
685
|
+
fo.setAttribute("overflow", "hidden");
|
|
686
|
+
fo.setAttribute("pointer-events", "auto");
|
|
687
|
+
fo.style.pointerEvents = "auto";
|
|
688
|
+
if (b.mediaEl) {
|
|
689
|
+
layoutMediaCrop(b.mediaEl, kind === "video" ? s.cropRect : undefined, s.bbox.w, s.bbox.h);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
renderWebEmbed(s, container, b, node) {
|
|
693
|
+
if (this.preview || !(0, core_1.isAllowedWebEmbedSrc)(s.embedUrl)) {
|
|
694
|
+
this.renderMediaPlaceholder(container, s.bbox, "video");
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
let fo = container.firstElementChild;
|
|
698
|
+
if (!fo) {
|
|
699
|
+
fo = document.createElementNS(SVG_NS, "foreignObject");
|
|
700
|
+
container.appendChild(fo);
|
|
701
|
+
const iframe = document.createElement("iframe");
|
|
702
|
+
iframe.style.width = "100%";
|
|
703
|
+
iframe.style.height = "100%";
|
|
704
|
+
iframe.style.border = "none";
|
|
705
|
+
// 放映 / HTML 导出需要点进 iframe(HybridBackend 的 svg 层是 pointer-events:none,
|
|
706
|
+
// 必须在 foreignObject 内显式恢复)。编辑器会在 adaptMediaForEditor 里改回 none,
|
|
707
|
+
// 否则跨域 iframe 会把点击吞进子文档,舞台选不中也拖不动。
|
|
708
|
+
iframe.style.pointerEvents = "auto";
|
|
709
|
+
iframe.setAttribute("referrerpolicy", "strict-origin-when-cross-origin");
|
|
710
|
+
iframe.setAttribute("sandbox", "allow-scripts allow-popups allow-popups-to-escape-sandbox allow-presentation allow-forms");
|
|
711
|
+
iframe.setAttribute("allow", "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share");
|
|
712
|
+
if (s.allowFullscreen !== false) {
|
|
713
|
+
iframe.setAttribute("allowfullscreen", "");
|
|
714
|
+
}
|
|
715
|
+
const stopBubble = (ev) => ev.stopPropagation();
|
|
716
|
+
iframe.addEventListener("pointerdown", stopBubble);
|
|
717
|
+
iframe.addEventListener("mousedown", stopBubble);
|
|
718
|
+
iframe.addEventListener("click", stopBubble);
|
|
719
|
+
iframe.addEventListener("dblclick", stopBubble);
|
|
720
|
+
iframe.addEventListener("touchstart", stopBubble, { passive: true });
|
|
721
|
+
fo.appendChild(iframe);
|
|
722
|
+
b.webEmbedEl = iframe;
|
|
723
|
+
}
|
|
724
|
+
const iframe = b.webEmbedEl;
|
|
725
|
+
if (iframe && iframe.src !== s.embedUrl) {
|
|
726
|
+
iframe.src = s.embedUrl;
|
|
727
|
+
}
|
|
728
|
+
fo.setAttribute("x", String(s.bbox.x));
|
|
729
|
+
fo.setAttribute("y", String(s.bbox.y));
|
|
730
|
+
fo.setAttribute("width", String(s.bbox.w));
|
|
731
|
+
fo.setAttribute("height", String(s.bbox.h));
|
|
732
|
+
}
|
|
733
|
+
renderPreviewPoster(container, b, src, crop) {
|
|
734
|
+
while (container.firstChild)
|
|
735
|
+
container.removeChild(container.firstChild);
|
|
736
|
+
const im = document.createElementNS(SVG_NS, "image");
|
|
737
|
+
let ix = b.x;
|
|
738
|
+
let iy = b.y;
|
|
739
|
+
let iw = b.w;
|
|
740
|
+
let ih = b.h;
|
|
741
|
+
if (crop && (crop.l || crop.t || crop.r || crop.b)) {
|
|
742
|
+
const cw = Math.max(1e-6, 1 - crop.l - crop.r);
|
|
743
|
+
const ch = Math.max(1e-6, 1 - crop.t - crop.b);
|
|
744
|
+
iw = b.w / cw;
|
|
745
|
+
ih = b.h / ch;
|
|
746
|
+
ix = b.x - crop.l * iw;
|
|
747
|
+
iy = b.y - crop.t * ih;
|
|
748
|
+
const rawId = (container.getAttribute("data-id") ?? "m").replace(/[^A-Za-z0-9_-]/g, "");
|
|
749
|
+
const cpId = `${this.uid}-poster-${rawId || "m"}`;
|
|
750
|
+
let cp = this.defs.querySelector(`#${cpId}`);
|
|
751
|
+
if (!cp) {
|
|
752
|
+
cp = document.createElementNS(SVG_NS, "clipPath");
|
|
753
|
+
cp.setAttribute("id", cpId);
|
|
754
|
+
cp.appendChild(document.createElementNS(SVG_NS, "rect"));
|
|
755
|
+
this.defs.appendChild(cp);
|
|
756
|
+
}
|
|
757
|
+
const rect = cp.firstChild;
|
|
758
|
+
rect.setAttribute("x", String(b.x));
|
|
759
|
+
rect.setAttribute("y", String(b.y));
|
|
760
|
+
rect.setAttribute("width", String(b.w));
|
|
761
|
+
rect.setAttribute("height", String(b.h));
|
|
762
|
+
im.setAttribute("clip-path", `url(#${cpId})`);
|
|
763
|
+
}
|
|
764
|
+
im.setAttribute("preserveAspectRatio", "none");
|
|
765
|
+
im.setAttribute("x", String(ix));
|
|
766
|
+
im.setAttribute("y", String(iy));
|
|
767
|
+
im.setAttribute("width", String(iw));
|
|
768
|
+
im.setAttribute("height", String(ih));
|
|
769
|
+
im.setAttribute("href", src);
|
|
770
|
+
im.setAttributeNS("http://www.w3.org/1999/xlink", "href", src);
|
|
771
|
+
im.style.pointerEvents = "none";
|
|
772
|
+
container.appendChild(im);
|
|
773
|
+
}
|
|
774
|
+
/** Static tile used when `preview` is on — no live iframe / video / audio. */
|
|
775
|
+
renderMediaPlaceholder(container, b, kind) {
|
|
776
|
+
while (container.firstChild)
|
|
777
|
+
container.removeChild(container.firstChild);
|
|
778
|
+
const g = document.createElementNS(SVG_NS, "g");
|
|
779
|
+
g.style.pointerEvents = "none";
|
|
780
|
+
const bg = document.createElementNS(SVG_NS, "rect");
|
|
781
|
+
bg.setAttribute("x", String(b.x));
|
|
782
|
+
bg.setAttribute("y", String(b.y));
|
|
783
|
+
bg.setAttribute("width", String(b.w));
|
|
784
|
+
bg.setAttribute("height", String(b.h));
|
|
785
|
+
bg.setAttribute("rx", String(Math.min(b.w, b.h) * 0.12));
|
|
786
|
+
bg.setAttribute("fill", "#f3f4f6");
|
|
787
|
+
bg.setAttribute("stroke", "#9ca3af");
|
|
788
|
+
bg.setAttribute("stroke-dasharray", "4 3");
|
|
789
|
+
bg.setAttribute("stroke-width", "1");
|
|
790
|
+
g.appendChild(bg);
|
|
791
|
+
const cx = b.x + b.w / 2;
|
|
792
|
+
const cy = b.y + b.h / 2;
|
|
793
|
+
const r = Math.min(b.w, b.h) * 0.22;
|
|
794
|
+
if (kind === "audio") {
|
|
795
|
+
const speaker = document.createElementNS(SVG_NS, "path");
|
|
796
|
+
speaker.setAttribute("d", `M ${cx - r} ${cy - r * 0.45}` +
|
|
797
|
+
` L ${cx - r * 0.25} ${cy - r * 0.45}` +
|
|
798
|
+
` L ${cx + r * 0.35} ${cy - r}` +
|
|
799
|
+
` L ${cx + r * 0.35} ${cy + r}` +
|
|
800
|
+
` L ${cx - r * 0.25} ${cy + r * 0.45}` +
|
|
801
|
+
` L ${cx - r} ${cy + r * 0.45} Z`);
|
|
802
|
+
speaker.setAttribute("fill", "#9ca3af");
|
|
803
|
+
g.appendChild(speaker);
|
|
804
|
+
for (let i = 1; i <= 2; i++) {
|
|
805
|
+
const arc = document.createElementNS(SVG_NS, "path");
|
|
806
|
+
const rArc = r * (0.5 + i * 0.3);
|
|
807
|
+
const ax = cx + r * 0.5;
|
|
808
|
+
arc.setAttribute("d", `M ${ax} ${cy - rArc * 0.7} A ${rArc} ${rArc} 0 0 1 ${ax} ${cy + rArc * 0.7}`);
|
|
809
|
+
arc.setAttribute("stroke", "#9ca3af");
|
|
810
|
+
arc.setAttribute("stroke-width", String(Math.max(1, r * 0.12)));
|
|
811
|
+
arc.setAttribute("fill", "none");
|
|
812
|
+
arc.setAttribute("stroke-linecap", "round");
|
|
813
|
+
g.appendChild(arc);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
const disc = document.createElementNS(SVG_NS, "circle");
|
|
818
|
+
disc.setAttribute("cx", String(cx));
|
|
819
|
+
disc.setAttribute("cy", String(cy));
|
|
820
|
+
disc.setAttribute("r", String(r * 1.1));
|
|
821
|
+
disc.setAttribute("fill", "#9ca3af");
|
|
822
|
+
g.appendChild(disc);
|
|
823
|
+
const tri = document.createElementNS(SVG_NS, "polygon");
|
|
824
|
+
const t = r * 0.55;
|
|
825
|
+
tri.setAttribute("points", `${cx - t * 0.35},${cy - t} ${cx + t},${cy} ${cx - t * 0.35},${cy + t}`);
|
|
826
|
+
tri.setAttribute("fill", "#f3f4f6");
|
|
827
|
+
g.appendChild(tri);
|
|
828
|
+
}
|
|
829
|
+
container.appendChild(g);
|
|
830
|
+
}
|
|
831
|
+
renderText(s, container, node) {
|
|
832
|
+
while (container.firstChild)
|
|
833
|
+
container.removeChild(container.firstChild);
|
|
834
|
+
// §20.1.7.6 flipH/flipV:PowerPoint 对 shape / group 的翻转只镜像**几何与子元素
|
|
835
|
+
// 位置**,文字字形本身保持可读(不镜像)。但 backend 把 flip 当 scale(-1) 加在
|
|
836
|
+
// 节点 transform 上,会连同文字一起镜像。这里按"自身 + 所有祖先 group"的累计
|
|
837
|
+
// flip 奇偶性,对文字内容绕文本框自身中心做一次反向翻转抵消镜像——位置仍随
|
|
838
|
+
// 父级镜像(落到正确的对称位置),字形恢复可读。
|
|
839
|
+
const parity = (0, scene_node_1.netFlipParity)(node);
|
|
840
|
+
let host = container;
|
|
841
|
+
if (parity.x < 0 || parity.y < 0) {
|
|
842
|
+
const fcx = s.bbox.x + s.bbox.w / 2;
|
|
843
|
+
const fcy = s.bbox.y + s.bbox.h / 2;
|
|
844
|
+
const counter = document.createElementNS(SVG_NS, "g");
|
|
845
|
+
counter.setAttribute("transform", `translate(${fcx},${fcy}) scale(${parity.x},${parity.y}) translate(${-fcx},${-fcy})`);
|
|
846
|
+
container.appendChild(counter);
|
|
847
|
+
host = counter;
|
|
848
|
+
}
|
|
849
|
+
// ECMA-376 §20.1.2.2 sp/spPr — text frame 同样可以有 fill / stroke(典型场景:
|
|
850
|
+
// 表格 a:tc 单元格、placeholder 背景色、callout 文字框轮廓等)。原本 svg-backend
|
|
851
|
+
// 的通用 applyShapeAttrs 把 text 排除,会导致 textShape 的 fill / stroke 丢失。
|
|
852
|
+
// 这里在文本内容之前先绘制一个矩形背景,复用 paint-svg 的 renderPaint / applyStroke,
|
|
853
|
+
// 与其它形状的填充逻辑保持一致。
|
|
854
|
+
const perSide = (0, cell_borders_1.cellHasPerSideBorders)(s);
|
|
855
|
+
const fill = (0, scene_node_1.shapeRenderFill)(s, this.scene, node);
|
|
856
|
+
const fillBBox = (0, scene_node_1.shapeRenderFillBBox)(s, this.scene, node);
|
|
857
|
+
const hasBg = !!fill || (!!s.stroke && !perSide);
|
|
858
|
+
if (hasBg) {
|
|
859
|
+
const bg = document.createElementNS(SVG_NS, "rect");
|
|
860
|
+
bg.setAttribute("x", String(s.bbox.x));
|
|
861
|
+
bg.setAttribute("y", String(s.bbox.y));
|
|
862
|
+
bg.setAttribute("width", String(s.bbox.w));
|
|
863
|
+
bg.setAttribute("height", String(s.bbox.h));
|
|
864
|
+
const fillPaint = node.fillOverride
|
|
865
|
+
? { paint: node.fillOverride }
|
|
866
|
+
: (0, paint_svg_1.renderPaint)(fill, this.defs, fillBBox, () => this.scheduleRedraw(), "rect");
|
|
867
|
+
bg.setAttribute("fill", fillPaint.paint);
|
|
868
|
+
if ("opacity" in fillPaint && fillPaint.opacity !== undefined) {
|
|
869
|
+
bg.setAttribute("fill-opacity", String(fillPaint.opacity));
|
|
870
|
+
}
|
|
871
|
+
if (perSide) {
|
|
872
|
+
bg.setAttribute("stroke", "none");
|
|
873
|
+
}
|
|
874
|
+
else if (node.strokeOverride) {
|
|
875
|
+
bg.setAttribute("stroke", node.strokeOverride);
|
|
876
|
+
bg.setAttribute("stroke-width", String(node.strokeWidthOverride ?? s.stroke?.width ?? 1));
|
|
877
|
+
}
|
|
878
|
+
else if (s.stroke) {
|
|
879
|
+
(0, paint_svg_1.applyStroke)(bg, s.stroke, this.defs, `${this.uid}-`);
|
|
880
|
+
if (node.strokeWidthOverride !== null && node.strokeWidthOverride !== undefined) {
|
|
881
|
+
bg.setAttribute("stroke-width", String(node.strokeWidthOverride));
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
host.appendChild(bg);
|
|
885
|
+
}
|
|
886
|
+
if (perSide && s.cellMeta?.borders) {
|
|
887
|
+
for (const seg of (0, cell_borders_1.cellBorderSegments)(s.bbox, s.cellMeta.borders)) {
|
|
888
|
+
const ln = document.createElementNS(SVG_NS, "line");
|
|
889
|
+
ln.setAttribute("x1", String(seg.x1));
|
|
890
|
+
ln.setAttribute("y1", String(seg.y1));
|
|
891
|
+
ln.setAttribute("x2", String(seg.x2));
|
|
892
|
+
ln.setAttribute("y2", String(seg.y2));
|
|
893
|
+
(0, paint_svg_1.applyStroke)(ln, seg.stroke, this.defs, `${this.uid}-`);
|
|
894
|
+
host.appendChild(ln);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
// 需要 3D 的文字走 WebGL / 烘焙贴图,这里不再叠一层 CSS 相机,避免和挤出体各转一次。
|
|
898
|
+
const css3d = (0, three_bake_1.needs3DRender)(s.body3D) ? "" : cssTransformFromBody3D(s.body3D, s.bbox);
|
|
899
|
+
if (css3d) {
|
|
900
|
+
const wrap3d = document.createElementNS(SVG_NS, "g");
|
|
901
|
+
const cx3 = s.bbox.x + s.bbox.w / 2;
|
|
902
|
+
const cy3 = s.bbox.y + s.bbox.h / 2;
|
|
903
|
+
wrap3d.style.transformBox = "view-box";
|
|
904
|
+
wrap3d.style.transformOrigin = `${cx3}px ${cy3}px`;
|
|
905
|
+
wrap3d.style.transform = css3d;
|
|
906
|
+
host.appendChild(wrap3d);
|
|
907
|
+
host = wrap3d;
|
|
908
|
+
}
|
|
909
|
+
// ECMA-376 §21.1.2.1.1 bodyPr@rot / @vert — 整个文字框的旋转。
|
|
910
|
+
// - bodyRot:文字框相对于形状的 2D 旋转角度(degree,°)。
|
|
911
|
+
// - vert:除 horz/wordArtVert/eaVert/mongolianVert 外,"vert"/"vert270" 等价于
|
|
912
|
+
// 一次外部旋转。两者叠加后,作用于"段落布局完成后的内容组"——即旋转的中心
|
|
913
|
+
// 仍是文字框(bbox)的几何中心,与 PowerPoint 表现一致。
|
|
914
|
+
const bodyRot = s.bodyRot ?? 0;
|
|
915
|
+
const vertRot = (0, text_shared_1.vertRotateDeg)(s.vert);
|
|
916
|
+
const vertStack = (0, text_shared_1.isVerticalStack)(s.vert);
|
|
917
|
+
// upright(§21.1.2.1.1 @upright):CJK 习惯下保持文字直立——即把形状自身
|
|
918
|
+
// 的旋转抵消掉,文字旋转仅来自 bodyRot/vert。这里 node.rotation 已经是形状的
|
|
919
|
+
// 最终旋转(包含动画)。我们将 -node.rotation 加到文字框上。
|
|
920
|
+
const uprightCorr = s.upright ? -(node.rotation ?? 0) : 0;
|
|
921
|
+
const totalRot = bodyRot + vertRot + uprightCorr;
|
|
922
|
+
// 内边距(§21.1.2.1.1 lIns/tIns/rIns/bIns),缺省由 OOXML 给出:lr=91440EMU=7.2pt,tb=45720EMU=3.6pt。
|
|
923
|
+
const ins = s.insets ?? text_shared_1.DEFAULT_TEXT_INSETS;
|
|
924
|
+
// 注意:当 vert==="vert"/"vert270" 走 transform 路径时,布局发生在"旋转前坐标系"下,
|
|
925
|
+
// 因此布局区域的宽 = 形状原 height,高 = 形状原 width。eaVert/wordArtVert/mongolianVert
|
|
926
|
+
// 走自定义竖排 layout,宽高保持原样。bodyRot 同理(旋转外置)。
|
|
927
|
+
const swap = vertRot !== 0;
|
|
928
|
+
const baseW = Math.max(0, s.bbox.w - ins.l - ins.r);
|
|
929
|
+
const baseH = Math.max(0, s.bbox.h - ins.t - ins.b);
|
|
930
|
+
const innerW = swap ? baseH : baseW;
|
|
931
|
+
const innerH = swap ? baseW : baseH;
|
|
932
|
+
const innerX = swap ? s.bbox.x + s.bbox.w / 2 - innerW / 2 : s.bbox.x + ins.l;
|
|
933
|
+
const innerY = swap ? s.bbox.y + s.bbox.h / 2 - innerH / 2 : s.bbox.y + ins.t;
|
|
934
|
+
// 当存在 bodyRot/vert/upright 时,把内容渲染进 outerG,再绕形状中心旋转。
|
|
935
|
+
let outerG = host;
|
|
936
|
+
if (totalRot !== 0) {
|
|
937
|
+
const cx = s.bbox.x + s.bbox.w / 2;
|
|
938
|
+
const cy = s.bbox.y + s.bbox.h / 2;
|
|
939
|
+
const wrap = document.createElementNS(SVG_NS, "g");
|
|
940
|
+
wrap.setAttribute("transform", `rotate(${totalRot} ${cx} ${cy})`);
|
|
941
|
+
host.appendChild(wrap);
|
|
942
|
+
outerG = wrap;
|
|
943
|
+
}
|
|
944
|
+
if (vertStack) {
|
|
945
|
+
this.renderTextStacked(s, outerG, node, innerX, innerY, baseW, baseH);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if ((0, text_warp_1.warpActive)(s)) {
|
|
949
|
+
this.renderTextWarped(s, outerG, innerX, innerY, innerW, innerH);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
if (s.fromWordArt) {
|
|
953
|
+
const fitG = document.createElementNS(SVG_NS, "g");
|
|
954
|
+
outerG.appendChild(fitG);
|
|
955
|
+
this.renderTextHorizontal(s, fitG, node, innerX, innerY, innerW, innerH);
|
|
956
|
+
this.applyWordArtFit(fitG, s.bbox);
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
this.renderTextHorizontal(s, outerG, node, innerX, innerY, innerW, innerH);
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* 艺术字:把已画出的墨迹外接矩形拉到形状框。
|
|
963
|
+
*/
|
|
964
|
+
applyWordArtFit(g, box) {
|
|
965
|
+
const texts = [...g.querySelectorAll("text")];
|
|
966
|
+
// 祖先上的 transform 会让 userSpaceOnUse 的图片填充画不出来,所以改字号和
|
|
967
|
+
// textLength,不在组上缩放。
|
|
968
|
+
if (texts.length !== 1) {
|
|
969
|
+
const ink = this.wordArtInkBox(g) ?? this.wordArtFallbackBox(g);
|
|
970
|
+
if (!ink)
|
|
971
|
+
return;
|
|
972
|
+
const fit = (0, text_wordart_1.wordArtFitTransform)(ink, box);
|
|
973
|
+
if (!fit)
|
|
974
|
+
return;
|
|
975
|
+
g.setAttribute("transform", `matrix(${fit.sx} 0 0 ${fit.sy} ${fit.tx} ${fit.ty})`);
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
const text = texts[0];
|
|
979
|
+
const anchorEl = text.querySelector("tspan");
|
|
980
|
+
const runEl = anchorEl?.querySelector("tspan") ?? anchorEl;
|
|
981
|
+
if (!anchorEl || !runEl)
|
|
982
|
+
return;
|
|
983
|
+
const ch = text.textContent ?? "";
|
|
984
|
+
if (!ch)
|
|
985
|
+
return;
|
|
986
|
+
const size = Number(runEl.getAttribute("font-size")) || 16;
|
|
987
|
+
const weight = runEl.getAttribute("font-weight") || "normal";
|
|
988
|
+
const italic = runEl.getAttribute("font-style") === "italic" ? "italic " : "";
|
|
989
|
+
const family = runEl.getAttribute("font-family") || "sans-serif";
|
|
990
|
+
const ctx = this.getMeasureCtx();
|
|
991
|
+
ctx.font = `${italic}${weight} ${size}px ${family}`;
|
|
992
|
+
const m = ctx.measureText(ch);
|
|
993
|
+
const inkH = m.actualBoundingBoxAscent + m.actualBoundingBoxDescent;
|
|
994
|
+
const inkW = m.actualBoundingBoxLeft + m.actualBoundingBoxRight;
|
|
995
|
+
if (!(inkH > 0.5 && inkW > 0.5 && m.actualBoundingBoxAscent > 0))
|
|
996
|
+
return;
|
|
997
|
+
const scaleY = box.h / inkH;
|
|
998
|
+
if (!Number.isFinite(scaleY) || scaleY > 8 || scaleY < 0.125)
|
|
999
|
+
return;
|
|
1000
|
+
if (Math.abs(scaleY - 1) < 0.02 && Math.abs(box.w / m.width - 1) < 0.02)
|
|
1001
|
+
return;
|
|
1002
|
+
runEl.setAttribute("font-size", String(size * scaleY));
|
|
1003
|
+
text.setAttribute("textLength", String(box.w));
|
|
1004
|
+
text.setAttribute("lengthAdjust", "spacingAndGlyphs");
|
|
1005
|
+
const anchor = anchorEl.getAttribute("text-anchor") || "start";
|
|
1006
|
+
const ax = anchor === "middle" ? box.x + box.w / 2 : anchor === "end" ? box.x + box.w : box.x;
|
|
1007
|
+
anchorEl.setAttribute("x", String(ax));
|
|
1008
|
+
anchorEl.setAttribute("y", String(box.y + m.actualBoundingBoxAscent * scaleY));
|
|
1009
|
+
}
|
|
1010
|
+
/** 字形墨迹,不含 em 方框上下的空档。getBBox 含整格,拉满后笔画仍缩在框里。 */
|
|
1011
|
+
wordArtInkBox(g) {
|
|
1012
|
+
const ctx = this.getMeasureCtx();
|
|
1013
|
+
let minX = Infinity;
|
|
1014
|
+
let minY = Infinity;
|
|
1015
|
+
let maxX = -Infinity;
|
|
1016
|
+
let maxY = -Infinity;
|
|
1017
|
+
let any = false;
|
|
1018
|
+
for (const text of g.querySelectorAll("text")) {
|
|
1019
|
+
const anchorEl = text.querySelector("tspan");
|
|
1020
|
+
if (!anchorEl)
|
|
1021
|
+
continue;
|
|
1022
|
+
const anchor = anchorEl.getAttribute("text-anchor") || "start";
|
|
1023
|
+
const ax = Number(anchorEl.getAttribute("x"));
|
|
1024
|
+
const baseline = Number(anchorEl.getAttribute("y"));
|
|
1025
|
+
if (!Number.isFinite(ax) || !Number.isFinite(baseline))
|
|
1026
|
+
continue;
|
|
1027
|
+
const runEl = anchorEl.querySelector("tspan") ?? anchorEl;
|
|
1028
|
+
const ch = text.textContent ?? "";
|
|
1029
|
+
if (!ch)
|
|
1030
|
+
continue;
|
|
1031
|
+
const size = Number(runEl.getAttribute("font-size")) || 16;
|
|
1032
|
+
const weight = runEl.getAttribute("font-weight") || "normal";
|
|
1033
|
+
const italic = runEl.getAttribute("font-style") === "italic" ? "italic " : "";
|
|
1034
|
+
const family = runEl.getAttribute("font-family") || "sans-serif";
|
|
1035
|
+
ctx.font = `${italic}${weight} ${size}px ${family}`;
|
|
1036
|
+
const m = ctx.measureText(ch);
|
|
1037
|
+
if (!(m.actualBoundingBoxAscent > 0))
|
|
1038
|
+
continue;
|
|
1039
|
+
const left = m.actualBoundingBoxLeft;
|
|
1040
|
+
const right = m.actualBoundingBoxRight;
|
|
1041
|
+
let origin = ax;
|
|
1042
|
+
if (anchor === "middle")
|
|
1043
|
+
origin = ax - m.width / 2;
|
|
1044
|
+
else if (anchor === "end")
|
|
1045
|
+
origin = ax - m.width;
|
|
1046
|
+
const x0 = origin - left;
|
|
1047
|
+
const x1 = origin + right;
|
|
1048
|
+
const y0 = baseline - m.actualBoundingBoxAscent;
|
|
1049
|
+
const y1 = baseline + m.actualBoundingBoxDescent;
|
|
1050
|
+
if (x0 < minX)
|
|
1051
|
+
minX = x0;
|
|
1052
|
+
if (y0 < minY)
|
|
1053
|
+
minY = y0;
|
|
1054
|
+
if (x1 > maxX)
|
|
1055
|
+
maxX = x1;
|
|
1056
|
+
if (y1 > maxY)
|
|
1057
|
+
maxY = y1;
|
|
1058
|
+
any = true;
|
|
1059
|
+
}
|
|
1060
|
+
if (!any || !(maxX > minX) || !(maxY > minY))
|
|
1061
|
+
return null;
|
|
1062
|
+
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
|
1063
|
+
}
|
|
1064
|
+
wordArtFallbackBox(g) {
|
|
1065
|
+
try {
|
|
1066
|
+
const bb = g.getBBox();
|
|
1067
|
+
if (!(bb.width > 0 && bb.height > 0))
|
|
1068
|
+
return null;
|
|
1069
|
+
return { x: bb.x, y: bb.y, w: bb.width, h: bb.height };
|
|
1070
|
+
}
|
|
1071
|
+
catch {
|
|
1072
|
+
return null;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
renderTextWarped(s, container, innerX, innerY, innerW, innerH) {
|
|
1076
|
+
const meas = typeof document !== "undefined" ? document.createElement("canvas").getContext("2d") : null;
|
|
1077
|
+
const prst = s.prstTxWarp?.prst ?? "textArchUp";
|
|
1078
|
+
const glyphs = (0, text_warp_1.flattenWarpGlyphs)(s, (run, ch) => {
|
|
1079
|
+
const fs = run.fontSize ?? text_shared_1.FALLBACK_FONT_SIZE;
|
|
1080
|
+
if (!meas)
|
|
1081
|
+
return fs * 0.6;
|
|
1082
|
+
const it = run.italic ? "italic " : "";
|
|
1083
|
+
meas.font = `${it}${run.fontWeight ?? "normal"} ${fs}px ${(0, font_stack_1.withFontFallback)(run.fontFamily)}`;
|
|
1084
|
+
return meas.measureText(ch).width;
|
|
1085
|
+
});
|
|
1086
|
+
const samples = (0, text_warp_1.sampleWarpPath)(prst, { x: innerX, y: innerY, w: innerW, h: innerH }, s.prstTxWarp?.avLst);
|
|
1087
|
+
const placed = (0, text_warp_1.placeGlyphsOnWarp)(glyphs, samples, prst);
|
|
1088
|
+
for (const g of placed) {
|
|
1089
|
+
if (g.ch === " ")
|
|
1090
|
+
continue;
|
|
1091
|
+
const t = document.createElementNS(SVG_NS, "text");
|
|
1092
|
+
t.setAttribute("x", "0");
|
|
1093
|
+
t.setAttribute("y", "0");
|
|
1094
|
+
t.setAttribute("text-anchor", "middle");
|
|
1095
|
+
t.setAttribute("dominant-baseline", "middle");
|
|
1096
|
+
const scale = g.scaleY !== 1 ? ` scale(1 ${g.scaleY})` : "";
|
|
1097
|
+
t.setAttribute("transform", `translate(${g.x} ${g.y}) rotate(${g.angle})${scale}`);
|
|
1098
|
+
this.applyRunStyle(t, g.run);
|
|
1099
|
+
t.textContent = g.ch;
|
|
1100
|
+
container.appendChild(t);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
// §21.1.2.1.1 标准的横排 + bodyRot/vert/vert270 旋转布局。
|
|
1104
|
+
// vert/vert270 由调用方通过外层 group 旋转处理;这里按"水平排版"对待。
|
|
1105
|
+
renderTextHorizontal(s, container, node, innerX, innerY, innerW, innerH) {
|
|
1106
|
+
// ECMA-376 §21.1.2.1.1 numCol / spcCol — 多栏布局:把段落均分到 N 栏;
|
|
1107
|
+
// rtlCol 控制栏顺序(默认从左到右,rtlCol="1" 从右到左)。
|
|
1108
|
+
const numCol = Math.max(1, Math.floor(s.numCol ?? 1));
|
|
1109
|
+
const spcCol = Math.max(0, s.spcCol ?? 0);
|
|
1110
|
+
const colW = numCol > 1 ? Math.max(0, (innerW - spcCol * (numCol - 1)) / numCol) : innerW;
|
|
1111
|
+
// §21.1.2.1.1 normAutofit — 编辑后文字变多导致溢出时运行时重算缩放
|
|
1112
|
+
// (多栏场景 autofit 语义复杂且罕见,跳过)。派生 shape 仅用于本帧渲染。
|
|
1113
|
+
if (numCol === 1) {
|
|
1114
|
+
s = (0, text_autofit_1.autofitTextShape)(s, innerH, (sh) => sh.paragraphs.reduce((acc, p) => {
|
|
1115
|
+
const l = this.layoutParagraphHoriz(sh, p, colW);
|
|
1116
|
+
return acc + l.before + l.totalH + l.after;
|
|
1117
|
+
}, 0));
|
|
1118
|
+
}
|
|
1119
|
+
// 先按列宽对每个段落做 wrap,再分列。
|
|
1120
|
+
const paraLayouts = s.paragraphs.map((p) => this.layoutParagraphHoriz(s, p, colW));
|
|
1121
|
+
const colsLayout = numCol > 1
|
|
1122
|
+
? this.distributeParagraphsAcrossColumns(paraLayouts, innerH, numCol)
|
|
1123
|
+
: [paraLayouts.map((_, i) => i)];
|
|
1124
|
+
const totalCols = colsLayout.length;
|
|
1125
|
+
// ECMA-376 §21.1.2.1.1 bodyPr@vertOverflow — overflow|ellipsis|clip。
|
|
1126
|
+
// clip / ellipsis 都需要把绘制裁切到 innerW × innerH(坐标系是已经做完
|
|
1127
|
+
// bodyRot/vert 旋转后的 inner box)。
|
|
1128
|
+
let drawTarget = container;
|
|
1129
|
+
const vertOverflow = s.vertOverflow ?? "overflow";
|
|
1130
|
+
const horzOverflow = s.horzOverflow ?? "overflow";
|
|
1131
|
+
if (vertOverflow !== "overflow" || horzOverflow !== "overflow") {
|
|
1132
|
+
const clipId = `${this.uid}-text-clip-${node.id}`;
|
|
1133
|
+
const clip = document.createElementNS(SVG_NS, "clipPath");
|
|
1134
|
+
clip.setAttribute("id", clipId);
|
|
1135
|
+
const r = document.createElementNS(SVG_NS, "rect");
|
|
1136
|
+
r.setAttribute("x", String(innerX));
|
|
1137
|
+
r.setAttribute("y", String(innerY));
|
|
1138
|
+
r.setAttribute("width", String(innerW));
|
|
1139
|
+
r.setAttribute("height", String(innerH));
|
|
1140
|
+
clip.appendChild(r);
|
|
1141
|
+
this.defs.appendChild(clip);
|
|
1142
|
+
const wrap = document.createElementNS(SVG_NS, "g");
|
|
1143
|
+
wrap.setAttribute("clip-path", `url(#${clipId})`);
|
|
1144
|
+
container.appendChild(wrap);
|
|
1145
|
+
drawTarget = wrap;
|
|
1146
|
+
}
|
|
1147
|
+
let bulletNumState = (0, text_shared_1.newBulletNumState)();
|
|
1148
|
+
// ECMA-376 §21.1.2.1.1 + PowerPoint 实际表现:
|
|
1149
|
+
// - vAlign="middle" 时,**即使 vertOverflow="overflow"**,PowerPoint 也只允许
|
|
1150
|
+
// bottom 溢出(即 totalH>innerH 时退化为 top-anchor,不让顶部跑到 inner box 上方)。
|
|
1151
|
+
// 如果允许上溢,会让"形状内文字"在文字过长时整体往上漂,破坏视觉重心。
|
|
1152
|
+
// - vAlign="bottom" 时,反过来允许 top 溢出(bottom anchor 必须把文字底贴 innerY+innerH,
|
|
1153
|
+
// 不够装就向上扩)。
|
|
1154
|
+
// - vertOverflow="clip" / "ellipsis" 始终钳到非负(依赖 clipPath 裁切;让前 N 行可见而非
|
|
1155
|
+
// 整体截掉)。
|
|
1156
|
+
const clampMiddle = (space) => Math.max(0, space); // 中央对齐:永不上溢
|
|
1157
|
+
const clampBottom = (space) => vertOverflow === "overflow" ? space : Math.max(0, space);
|
|
1158
|
+
// ECMA-376 §21.1.2.1.1 bodyPr@spcFirstLastPara — 控制**列内**第一段的 spcBef
|
|
1159
|
+
// 和最后一段的 spcAft 是否生效。规范默认 false,但 PowerPoint 实际渲染常为
|
|
1160
|
+
// true(取决于 shape 类型与版本),样本里也几乎全部依赖该默认值。我们采取
|
|
1161
|
+
// 折中:仅当 attribute 显式写为 false 时才抑制首末段的对应间距,未显式声明
|
|
1162
|
+
// 时维持 "全应用"(与现有 baseline 行为一致;显式 true 等价于默认)。
|
|
1163
|
+
const respectFirstLast = s.spcFirstLastPara !== false;
|
|
1164
|
+
for (let cIdx = 0; cIdx < totalCols; cIdx++) {
|
|
1165
|
+
const visualCol = s.rtlCol ? totalCols - 1 - cIdx : cIdx;
|
|
1166
|
+
const colX = innerX + visualCol * (colW + spcCol);
|
|
1167
|
+
const idxs = colsLayout[cIdx];
|
|
1168
|
+
const colLayouts = idxs.map((i) => paraLayouts[i]);
|
|
1169
|
+
const lastIdx = colLayouts.length - 1;
|
|
1170
|
+
const effBefore = (k) => !respectFirstLast && k === 0 ? 0 : colLayouts[k].before;
|
|
1171
|
+
const effAfter = (k) => !respectFirstLast && k === lastIdx ? 0 : colLayouts[k].after;
|
|
1172
|
+
const totalH = colLayouts.reduce((acc, l, k) => acc + effBefore(k) + l.totalH + effAfter(k), 0);
|
|
1173
|
+
let yCursor = innerY;
|
|
1174
|
+
if (s.vAlign === "middle")
|
|
1175
|
+
yCursor = innerY + clampMiddle((innerH - totalH) / 2);
|
|
1176
|
+
else if (s.vAlign === "bottom")
|
|
1177
|
+
yCursor = innerY + clampBottom(innerH - totalH);
|
|
1178
|
+
const colBottom = innerY + innerH;
|
|
1179
|
+
for (let k = 0; k < idxs.length; k++) {
|
|
1180
|
+
const i = idxs[k];
|
|
1181
|
+
const p = s.paragraphs[i];
|
|
1182
|
+
const layout = colLayouts[k];
|
|
1183
|
+
// §21.1.2.1.1 vertOverflow=ellipsis:当段落(或某行)超出底部时,
|
|
1184
|
+
// 在最后一行尾部追加省略号;clip 仅依赖 clipPath 已经裁切。
|
|
1185
|
+
const ellipsis = vertOverflow === "ellipsis";
|
|
1186
|
+
this.renderHorizontalParagraph(s, drawTarget, node, p, i, layout, {
|
|
1187
|
+
innerX: colX,
|
|
1188
|
+
innerY,
|
|
1189
|
+
innerW: colW,
|
|
1190
|
+
innerH,
|
|
1191
|
+
yCursor,
|
|
1192
|
+
bulletNumState,
|
|
1193
|
+
colBottom,
|
|
1194
|
+
ellipsis,
|
|
1195
|
+
spaceBefore: effBefore(k),
|
|
1196
|
+
spaceAfter: effAfter(k),
|
|
1197
|
+
});
|
|
1198
|
+
yCursor += effBefore(k) + layout.totalH + effAfter(k);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
// 段落级 wrap + 行高 + RTL/对齐计算:把一个 TextParagraph 拆成若干 visual line。
|
|
1203
|
+
// 返回的对象包含 totalH(所有行的高度之和)、before/after(段前/段后间距),
|
|
1204
|
+
// 以及 layout 阶段需要的 marL/indent/rtl/align 等。
|
|
1205
|
+
layoutParagraphHoriz(_s, p, maxWidth) {
|
|
1206
|
+
const fallbackFs = (0, text_shared_1.paragraphFallbackFontSize)(p);
|
|
1207
|
+
const before = p.spaceBeforePx ?? 0;
|
|
1208
|
+
const after = p.spaceAfterPx ?? 0;
|
|
1209
|
+
const noWrap = _s.wrap === false;
|
|
1210
|
+
const rtl = !!p.rtl;
|
|
1211
|
+
const align = (0, text_layout_1.mapVisualAlignWithAnchorCtr)(_s, p);
|
|
1212
|
+
const marL = (rtl ? p.marginRight : p.marginLeft) ?? 0;
|
|
1213
|
+
const indent = p.indent ?? 0;
|
|
1214
|
+
const hasBullet = !!p.bullet && p.bullet.kind !== "none";
|
|
1215
|
+
// §21.1.2.2.6 pPr@indent — 正值是首行缩进;负值通常用于 hanging indent + bullet。
|
|
1216
|
+
// 当存在 bullet 时,正负 indent 都仅影响 bullet 位置,文本主体自始至终从 marL 起;
|
|
1217
|
+
// 当不存在 bullet 时,正 indent 仅作用于"首行",让第一行向 marL+indent 处推。
|
|
1218
|
+
const firstLineIndent = hasBullet ? 0 : Math.max(0, indent);
|
|
1219
|
+
const textAreaW = Math.max(0, maxWidth - marL);
|
|
1220
|
+
const firstLineW = Math.max(0, textAreaW - firstLineIndent);
|
|
1221
|
+
const ctx = this.getMeasureCtx();
|
|
1222
|
+
const lines = (0, text_layout_1.wrapParagraphIntoLines)(p, textAreaW, fallbackFs, ctx, firstLineW, noWrap, {
|
|
1223
|
+
slideIndex: this.scene.slide.index,
|
|
1224
|
+
}, _s.compatLnSpc);
|
|
1225
|
+
const totalH = lines.reduce((acc, l) => acc + l.height, 0);
|
|
1226
|
+
return {
|
|
1227
|
+
lines,
|
|
1228
|
+
totalH,
|
|
1229
|
+
before,
|
|
1230
|
+
after,
|
|
1231
|
+
rtl,
|
|
1232
|
+
align,
|
|
1233
|
+
marL,
|
|
1234
|
+
indent,
|
|
1235
|
+
hasBullet,
|
|
1236
|
+
firstLineIndent,
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
// 把段落按"wrap 后高度等分"地分到多列:尽量保证每列总高度 ≤ innerH,溢出再开下一列。
|
|
1240
|
+
// 总列数最多不超过 numCol;若内容超出,剩余段落集中到最后一列(OOXML 没有强制
|
|
1241
|
+
// 约束溢出列的处理;此实现与 PowerPoint 把溢出留在最后一列的视觉一致)。
|
|
1242
|
+
distributeParagraphsAcrossColumns(layouts, innerH, numCol) {
|
|
1243
|
+
const cols = Array.from({ length: numCol }, () => []);
|
|
1244
|
+
const heights = new Array(numCol).fill(0);
|
|
1245
|
+
let curCol = 0;
|
|
1246
|
+
for (let i = 0; i < layouts.length; i++) {
|
|
1247
|
+
const l = layouts[i];
|
|
1248
|
+
const h = l.before + l.totalH + l.after;
|
|
1249
|
+
if (curCol < numCol - 1 &&
|
|
1250
|
+
heights[curCol] > 0 &&
|
|
1251
|
+
heights[curCol] + h > innerH) {
|
|
1252
|
+
curCol += 1;
|
|
1253
|
+
}
|
|
1254
|
+
cols[curCol].push(i);
|
|
1255
|
+
heights[curCol] += h;
|
|
1256
|
+
}
|
|
1257
|
+
return cols;
|
|
1258
|
+
}
|
|
1259
|
+
renderHorizontalParagraph(_s, container, node, p, i, pl, layout) {
|
|
1260
|
+
const { innerX, innerW, bulletNumState, colBottom, ellipsis } = layout;
|
|
1261
|
+
const { rtl, align, marL, indent, hasBullet, lines } = pl;
|
|
1262
|
+
// 段前间距已由调用方按 spcFirstLastPara 取舍(首段时可能为 0),不再走 pl.before。
|
|
1263
|
+
const yStart = layout.yCursor + layout.spaceBefore;
|
|
1264
|
+
const textLeft = rtl ? innerX : innerX + marL;
|
|
1265
|
+
const textRight = rtl ? innerX + innerW - marL : innerX + innerW;
|
|
1266
|
+
const textAreaW = Math.max(0, textRight - textLeft);
|
|
1267
|
+
const para = node.paraOverrides.get(i);
|
|
1268
|
+
const paraG = document.createElementNS(SVG_NS, "g");
|
|
1269
|
+
const opacity = para?.opacity ?? 1;
|
|
1270
|
+
const tx = para?.tx ?? 0;
|
|
1271
|
+
const ty = para?.ty ?? 0;
|
|
1272
|
+
const scale = para?.scale ?? 1;
|
|
1273
|
+
const sX = (para?.scaleX ?? 1) * scale;
|
|
1274
|
+
const sY = (para?.scaleY ?? 1) * scale;
|
|
1275
|
+
const rot = para?.rotation ?? 0;
|
|
1276
|
+
const visible = para?.visible ?? true;
|
|
1277
|
+
if (!visible)
|
|
1278
|
+
paraG.style.display = "none";
|
|
1279
|
+
const paraFadeMul = fadeOpacityFromClip(para?.clip ?? null);
|
|
1280
|
+
paraG.setAttribute("opacity", String(opacity * paraFadeMul));
|
|
1281
|
+
// hanging indent 的 bullet 在 textLeft 之外(rtl 时翻到右边),clip 包络也要相应扩张。
|
|
1282
|
+
const bulletAbs = hasBullet
|
|
1283
|
+
? rtl
|
|
1284
|
+
? innerX + innerW - marL - indent
|
|
1285
|
+
: innerX + marL + indent
|
|
1286
|
+
: textLeft;
|
|
1287
|
+
const clipLeft = Math.min(textLeft, bulletAbs);
|
|
1288
|
+
const clipRight = Math.max(textRight, bulletAbs);
|
|
1289
|
+
this.applyParaClip(paraG, para?.clip, node.id, i, {
|
|
1290
|
+
x: clipLeft,
|
|
1291
|
+
y: yStart,
|
|
1292
|
+
w: Math.max(1, clipRight - clipLeft),
|
|
1293
|
+
h: pl.totalH,
|
|
1294
|
+
});
|
|
1295
|
+
if (tx !== 0 || ty !== 0 || sX !== 1 || sY !== 1 || rot !== 0) {
|
|
1296
|
+
const parts = [];
|
|
1297
|
+
if (tx !== 0 || ty !== 0)
|
|
1298
|
+
parts.push(`translate(${tx}px,${ty}px)`);
|
|
1299
|
+
if (sX !== 1 || sY !== 1 || rot !== 0) {
|
|
1300
|
+
paraG.style.transformBox = "fill-box";
|
|
1301
|
+
paraG.style.transformOrigin = "center center";
|
|
1302
|
+
if (rot !== 0)
|
|
1303
|
+
parts.push(`rotate(${rot}deg)`);
|
|
1304
|
+
if (sX !== 1 || sY !== 1)
|
|
1305
|
+
parts.push(`scale(${sX},${sY})`);
|
|
1306
|
+
}
|
|
1307
|
+
if (parts.length > 0)
|
|
1308
|
+
paraG.style.transform = parts.join(" ");
|
|
1309
|
+
}
|
|
1310
|
+
const runEffectsFilter = (0, text_shared_1.pickRunEffectsCssFilter)(p.runs);
|
|
1311
|
+
if (runEffectsFilter) {
|
|
1312
|
+
const prev = paraG.style.filter;
|
|
1313
|
+
paraG.style.filter = prev ? `${prev} ${runEffectsFilter}` : runEffectsFilter;
|
|
1314
|
+
}
|
|
1315
|
+
const charOverrides = node.charOverrides.get(i);
|
|
1316
|
+
const anchor = align === "middle" ? "middle" : align === "end" ? "end" : "start";
|
|
1317
|
+
let yLine = yStart;
|
|
1318
|
+
// 找到"最后一行"——可能是真正的末行,也可能是 ellipsis 模式下"还能放下"的最后一行。
|
|
1319
|
+
let lastVisibleLineIdx = lines.length - 1;
|
|
1320
|
+
if (ellipsis) {
|
|
1321
|
+
let acc = yStart;
|
|
1322
|
+
for (let li = 0; li < lines.length; li++) {
|
|
1323
|
+
const lh = lines[li].height;
|
|
1324
|
+
if (acc + lh > colBottom && li > 0) {
|
|
1325
|
+
lastVisibleLineIdx = li - 1;
|
|
1326
|
+
break;
|
|
1327
|
+
}
|
|
1328
|
+
acc += lh;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
for (let li = 0; li < lines.length; li++) {
|
|
1332
|
+
const line = lines[li];
|
|
1333
|
+
const baseline = yLine + line.ascent;
|
|
1334
|
+
// 每行的水平起锚点:受 align/rtl/firstLineIndent 影响。
|
|
1335
|
+
let lineLeft = textLeft;
|
|
1336
|
+
let lineRight = textRight;
|
|
1337
|
+
if (li === 0 && pl.firstLineIndent > 0) {
|
|
1338
|
+
if (rtl)
|
|
1339
|
+
lineRight = textRight - pl.firstLineIndent;
|
|
1340
|
+
else
|
|
1341
|
+
lineLeft = textLeft + pl.firstLineIndent;
|
|
1342
|
+
}
|
|
1343
|
+
const lineAreaW = Math.max(0, lineRight - lineLeft);
|
|
1344
|
+
const hang = line.hang;
|
|
1345
|
+
const ax = align === "middle"
|
|
1346
|
+
? lineLeft + lineAreaW / 2 + hang / 2
|
|
1347
|
+
: align === "end"
|
|
1348
|
+
? lineRight + hang
|
|
1349
|
+
: lineLeft;
|
|
1350
|
+
// bullet 仅在第一行渲染。
|
|
1351
|
+
if (li === 0) {
|
|
1352
|
+
const bulletEl = this.renderBullet(p, bulletAbs, baseline, { height: line.height, ascent: line.ascent }, bulletNumState);
|
|
1353
|
+
if (bulletEl)
|
|
1354
|
+
paraG.appendChild(bulletEl);
|
|
1355
|
+
}
|
|
1356
|
+
if (line.segments.length === 0) {
|
|
1357
|
+
yLine += line.height;
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
// ellipsis:仅在"被截断"的那一行末尾追加 …
|
|
1361
|
+
const appendEllipsis = ellipsis && li === lastVisibleLineIdx && li < lines.length - 1;
|
|
1362
|
+
if (charOverrides && charOverrides.size > 0 && lines.length === 1) {
|
|
1363
|
+
// 字符级动画走单行 segmented 路径(与原行为保持一致)。
|
|
1364
|
+
this.renderParaSegmented(paraG, p, charOverrides, ax, baseline, anchor, line.height, para);
|
|
1365
|
+
}
|
|
1366
|
+
else {
|
|
1367
|
+
const textEl = document.createElementNS(SVG_NS, "text");
|
|
1368
|
+
// ECMA-376 §21.1.2.3 a:t 用 xml:space="preserve" 表示文本中的空白字符
|
|
1369
|
+
// (含连续空格、Tab、首/尾空白)必须按字面渲染。SVG <text> 的默认
|
|
1370
|
+
// xml:space="default" 会执行 XML 1.0 空白折叠:连续空白合并为一个,并
|
|
1371
|
+
// 把首尾空白去掉。这会让 layout 阶段 ctx.measureText 算出的"含空格段宽"
|
|
1372
|
+
// 与 SVG 实际渲染宽度不一致——文字被错误地居中/右对齐/截断,并且
|
|
1373
|
+
// `"a b"` 渲染成 `"a b"`。
|
|
1374
|
+
// 在 <text> 上声明一次 preserve,所有后代 tspan 都会继承(SVG 1.1 §5.10.1)。
|
|
1375
|
+
textEl.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve");
|
|
1376
|
+
const tspan = document.createElementNS(SVG_NS, "tspan");
|
|
1377
|
+
tspan.setAttribute("x", String(ax));
|
|
1378
|
+
tspan.setAttribute("text-anchor", anchor);
|
|
1379
|
+
tspan.setAttribute("y", String(baseline));
|
|
1380
|
+
// §21.1.2.2.6 @rtl — SVG 的 Unicode bidi 算法已经按字符的 bidi class 处理
|
|
1381
|
+
// RTL 字符(阿拉伯/希伯来)的字符级翻转。我们只需要在"对齐方向"上把 start↔end
|
|
1382
|
+
// 互换(已通过 mapVisualAlign + ax 完成)。**不要**额外写 direction=rtl,否则
|
|
1383
|
+
// 在与 text-anchor 组合时会把拉丁文 run 的视觉顺序也翻转。
|
|
1384
|
+
for (let si = 0; si < line.segments.length; si++) {
|
|
1385
|
+
const seg = line.segments[si];
|
|
1386
|
+
const run = seg.run;
|
|
1387
|
+
const t = document.createElementNS(SVG_NS, "tspan");
|
|
1388
|
+
this.applyRunStyle(t, run, para);
|
|
1389
|
+
// xml:space="preserve" 会让 SVG 把"行末空白"也计入 text-anchor=middle/end 的宽度,
|
|
1390
|
+
// 导致 center/right 对齐的文本被尾空白挤偏(CSS `text-align` 对非 pre 内容是忽略
|
|
1391
|
+
// 行末空白的,PowerPoint/WPS 的视觉表现与 CSS 一致)。这里仅对**最后一个 segment**
|
|
1392
|
+
// 在 center/end 对齐时去除末尾空白,保留行中间的连续空格(仍然按字面渲染)。
|
|
1393
|
+
// 单字符竖排路径不会走到这(renderStackedColumn 单独走 g.ch),所以那边不需要同步。
|
|
1394
|
+
const isLastSeg = si === line.segments.length - 1;
|
|
1395
|
+
const trimTail = isLastSeg && (anchor === "middle" || anchor === "end");
|
|
1396
|
+
const rawText = trimTail ? seg.text.replace(/[ \t\u3000]+$/, "") : seg.text;
|
|
1397
|
+
if (!seg.text) {
|
|
1398
|
+
t.setAttribute("dx", String(seg.width));
|
|
1399
|
+
t.textContent = "";
|
|
1400
|
+
tspan.appendChild(t);
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
if (run.baseline) {
|
|
1404
|
+
const fs = (para?.fontSize ?? run.fontSize) ?? text_shared_1.FALLBACK_FONT_SIZE;
|
|
1405
|
+
const shift = run.baseline * fs;
|
|
1406
|
+
t.setAttribute("dy", String(-shift));
|
|
1407
|
+
const reduced = fs * (1 - Math.min(0.5, Math.abs(run.baseline)));
|
|
1408
|
+
t.setAttribute("font-size", String(reduced));
|
|
1409
|
+
t.textContent = rawText + (appendEllipsis && isLastSeg ? "…" : "");
|
|
1410
|
+
tspan.appendChild(t);
|
|
1411
|
+
const reset = document.createElementNS(SVG_NS, "tspan");
|
|
1412
|
+
reset.setAttribute("dy", String(shift));
|
|
1413
|
+
reset.textContent = "\u200B";
|
|
1414
|
+
tspan.appendChild(reset);
|
|
1415
|
+
continue;
|
|
1416
|
+
}
|
|
1417
|
+
t.textContent = rawText + (appendEllipsis && isLastSeg ? "…" : "");
|
|
1418
|
+
tspan.appendChild(t);
|
|
1419
|
+
}
|
|
1420
|
+
textEl.appendChild(tspan);
|
|
1421
|
+
paraG.appendChild(textEl);
|
|
1422
|
+
}
|
|
1423
|
+
yLine += line.height;
|
|
1424
|
+
if (ellipsis && li === lastVisibleLineIdx)
|
|
1425
|
+
break;
|
|
1426
|
+
}
|
|
1427
|
+
container.appendChild(paraG);
|
|
1428
|
+
this.attachTextRunEffects(container, paraG, p.runs);
|
|
1429
|
+
}
|
|
1430
|
+
// wordArtVert / eaVert / mongolianVert — 逐字符竖排堆叠。
|
|
1431
|
+
// 段内字符流按列高拆成 1+ 列;列宽 = 该列内最大字号;列从右往左(mongolianVert / rtlCol 反向)。
|
|
1432
|
+
// 拆点:半角 / 全角空格、\t、零宽空格;若无可拆点则硬切。
|
|
1433
|
+
// - eaVert:CJK 直立;非 CJK 字符顺时针旋转 90°(保持横向阅读体验)。
|
|
1434
|
+
// - wordArtVert / mongolianVert:所有字符直立。
|
|
1435
|
+
// bullet 仅出现在段的首列。
|
|
1436
|
+
renderTextStacked(s, container, node, innerX, innerY, innerW, innerH) {
|
|
1437
|
+
const vert = s.vert;
|
|
1438
|
+
// wordArtVertRtl:列从右往左。wordArtVert / mongolianVert 默认从左往右。
|
|
1439
|
+
const ltrColumns = (0, text_shared_1.stackedColumnsLtr)(vert, s.rtlCol);
|
|
1440
|
+
const spcCol = Math.max(0, s.spcCol ?? 0);
|
|
1441
|
+
let bulletNumState = (0, text_shared_1.newBulletNumState)();
|
|
1442
|
+
const paraColumns = [];
|
|
1443
|
+
for (let pi = 0; pi < s.paragraphs.length; pi++) {
|
|
1444
|
+
const p = s.paragraphs[pi];
|
|
1445
|
+
const para = node.paraOverrides.get(pi);
|
|
1446
|
+
const glyphs = (0, text_stacked_1.buildStackedGlyphs)(p, para, vert, { slideIndex: this.scene.slide.index });
|
|
1447
|
+
if (glyphs.length === 0) {
|
|
1448
|
+
// 空段:保留一根"占位列"用于视觉段间距;列宽 = 段任一 run 的 fontSize。
|
|
1449
|
+
const fs = p.runs.find((r) => r.fontSize)?.fontSize ?? text_shared_1.FALLBACK_FONT_SIZE;
|
|
1450
|
+
paraColumns.push({ paraIdx: pi, paragraph: p, glyphs: [], width: fs, isFirstColOfPara: true });
|
|
1451
|
+
continue;
|
|
1452
|
+
}
|
|
1453
|
+
// ---- Step 2:按 innerH 把段拆成多列;空白处可拆,空白进下一列时被丢弃。 ----
|
|
1454
|
+
const wrapped = (0, text_stacked_1.wrapGlyphsIntoColumns)(glyphs, innerH);
|
|
1455
|
+
for (let wi = 0; wi < wrapped.length; wi++) {
|
|
1456
|
+
const colGlyphs = wrapped[wi];
|
|
1457
|
+
const colW = colGlyphs.reduce((m, g) => Math.max(m, g.fontSize), 0);
|
|
1458
|
+
paraColumns.push({
|
|
1459
|
+
paraIdx: pi,
|
|
1460
|
+
paragraph: p,
|
|
1461
|
+
glyphs: colGlyphs,
|
|
1462
|
+
width: colW,
|
|
1463
|
+
isFirstColOfPara: wi === 0,
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
// ---- Step 3:按方向放置每一列;R→L 时从右边界开始向左推进。 ----
|
|
1468
|
+
let cursorX = ltrColumns ? innerX : innerX + innerW;
|
|
1469
|
+
for (const col of paraColumns) {
|
|
1470
|
+
if (!ltrColumns)
|
|
1471
|
+
cursorX -= col.width;
|
|
1472
|
+
this.renderStackedColumn(s, container, node, col.paragraph, col.paraIdx, col.glyphs, col.isFirstColOfPara, vert, { x: cursorX, y: innerY, w: col.width, h: innerH }, bulletNumState);
|
|
1473
|
+
if (ltrColumns)
|
|
1474
|
+
cursorX += col.width + spcCol;
|
|
1475
|
+
else
|
|
1476
|
+
cursorX -= spcCol;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
renderStackedColumn(_s, container, node, p, paraIdx, glyphs, showBullet, vert, box, bulletNumState) {
|
|
1480
|
+
void vert;
|
|
1481
|
+
const para = node.paraOverrides.get(paraIdx);
|
|
1482
|
+
const paraG = document.createElementNS(SVG_NS, "g");
|
|
1483
|
+
const opacity = para?.opacity ?? 1;
|
|
1484
|
+
const fadeMul = fadeOpacityFromClip(para?.clip ?? null);
|
|
1485
|
+
paraG.setAttribute("opacity", String(opacity * fadeMul));
|
|
1486
|
+
if ((para?.visible ?? true) === false)
|
|
1487
|
+
paraG.style.display = "none";
|
|
1488
|
+
const totalH = glyphs.reduce((acc, g) => acc + g.stepY, 0);
|
|
1489
|
+
// 与 horizontal 路径相同的 clamp 规则(详见 renderTextHorizontal 注释):
|
|
1490
|
+
// - middle / start:永远钳到非负("center anchor 退化为 top anchor",匹配 PowerPoint);
|
|
1491
|
+
// - end / bottom:仅 vertOverflow="overflow" 时允许负偏移(顶端上溢);clip/ellipsis 钳到 0。
|
|
1492
|
+
const allowVertOverflow = (_s.vertOverflow ?? "overflow") === "overflow";
|
|
1493
|
+
const clampMiddle = (space) => Math.max(0, space);
|
|
1494
|
+
const clampEnd = (space) => allowVertOverflow ? space : Math.max(0, space);
|
|
1495
|
+
// 竖排下主轴 = 垂直方向;p.align 在主轴上控制段内字符的"上/中/下/两端对齐"。
|
|
1496
|
+
// 段级 algn 优先于 shape 级 vAlign:因为段对齐是 per-paragraph,而 vAlign 是块级,
|
|
1497
|
+
// 在竖排里两者落到同一根轴上时段优先(这与 PowerPoint 表现一致)。
|
|
1498
|
+
let yCursor = box.y;
|
|
1499
|
+
let extraStep = 0; // justify 时分配给字符间的额外间距
|
|
1500
|
+
const space = box.h - totalH;
|
|
1501
|
+
const pAlign = p.align;
|
|
1502
|
+
if (pAlign === "middle")
|
|
1503
|
+
yCursor = box.y + clampMiddle(space / 2);
|
|
1504
|
+
else if (pAlign === "end")
|
|
1505
|
+
yCursor = box.y + clampEnd(space);
|
|
1506
|
+
else if (pAlign === "justify" && glyphs.length > 1 && space > 0) {
|
|
1507
|
+
extraStep = space / (glyphs.length - 1);
|
|
1508
|
+
}
|
|
1509
|
+
else {
|
|
1510
|
+
// start / 缺省:回退到 shape vAlign(保持旧行为)
|
|
1511
|
+
if (_s.vAlign === "middle")
|
|
1512
|
+
yCursor = box.y + clampMiddle(space / 2);
|
|
1513
|
+
else if (_s.vAlign === "bottom")
|
|
1514
|
+
yCursor = box.y + clampEnd(space);
|
|
1515
|
+
}
|
|
1516
|
+
const cx = box.x + box.w / 2;
|
|
1517
|
+
// bullet 仅在该段的"首列"出现;后续 wrap 出来的列复用同一段的样式但不重复 bullet。
|
|
1518
|
+
// 注意:advanceBulletLabel 同时更新 bulletNumState(编号递增 / 重置),所以非首列必须跳过调用。
|
|
1519
|
+
const bulletText = showBullet && glyphs.length > 0 ? (0, text_shared_1.advanceBulletLabel)(p, bulletNumState) : null;
|
|
1520
|
+
if (bulletText) {
|
|
1521
|
+
const t = document.createElementNS(SVG_NS, "text");
|
|
1522
|
+
// 与 horizontal 路径保持一致:bullet 文本(编号"1.")/(2."等含点号")的
|
|
1523
|
+
// 显示宽度依赖空白保留,否则居中锚点偏移。
|
|
1524
|
+
t.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve");
|
|
1525
|
+
t.setAttribute("x", String(cx));
|
|
1526
|
+
t.setAttribute("y", String(yCursor + glyphs[0].fontSize));
|
|
1527
|
+
t.setAttribute("text-anchor", "middle");
|
|
1528
|
+
t.setAttribute("font-size", String(glyphs[0].fontSize * (p.bullet && "sizePct" in p.bullet && p.bullet.sizePct ? p.bullet.sizePct : 1)));
|
|
1529
|
+
if (p.bullet && "color" in p.bullet && p.bullet.color)
|
|
1530
|
+
t.setAttribute("fill", p.bullet.color);
|
|
1531
|
+
else if (p.runs[0]?.color)
|
|
1532
|
+
t.setAttribute("fill", p.runs[0].color);
|
|
1533
|
+
if (p.bullet && "font" in p.bullet && p.bullet.font)
|
|
1534
|
+
t.setAttribute("font-family", p.bullet.font);
|
|
1535
|
+
else if (p.runs[0]?.fontFamily)
|
|
1536
|
+
t.setAttribute("font-family", (0, font_stack_1.withFontFallback)(p.runs[0].fontFamily));
|
|
1537
|
+
t.textContent = bulletText;
|
|
1538
|
+
paraG.appendChild(t);
|
|
1539
|
+
yCursor += glyphs[0].stepY;
|
|
1540
|
+
}
|
|
1541
|
+
for (let gi = 0; gi < glyphs.length; gi++) {
|
|
1542
|
+
const g = glyphs[gi];
|
|
1543
|
+
const baseline = yCursor + g.fontSize; // 字符底部对齐"行底"近似
|
|
1544
|
+
const t = document.createElementNS(SVG_NS, "text");
|
|
1545
|
+
// 竖排单字符渲染:单个字符通常不含空白,但全角空格 \u3000 等
|
|
1546
|
+
// CJK 留白要保留。统一加 preserve 与 horizontal 路径一致。
|
|
1547
|
+
t.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve");
|
|
1548
|
+
t.setAttribute("x", String(cx));
|
|
1549
|
+
t.setAttribute("y", String(baseline));
|
|
1550
|
+
t.setAttribute("text-anchor", "middle");
|
|
1551
|
+
// applyRunStyle 内部已经处理 textOutline + paint-order。
|
|
1552
|
+
this.applyRunStyle(t, g.run, para);
|
|
1553
|
+
t.textContent = g.ch;
|
|
1554
|
+
if (!g.upright) {
|
|
1555
|
+
// 非 CJK 字符在 eaVert 下顺时针 90°:以字符中心为锚,旋转 +90°。
|
|
1556
|
+
// 旋转后字符的"水平基线"变成竖直方向,视觉上字符"侧躺"在列内向下读。
|
|
1557
|
+
t.setAttribute("transform", `rotate(90 ${cx} ${baseline - g.fontSize / 2})`);
|
|
1558
|
+
}
|
|
1559
|
+
paraG.appendChild(t);
|
|
1560
|
+
yCursor += g.stepY;
|
|
1561
|
+
// justify:在每对相邻字符间分配额外空间(最后一个字符不再加)。
|
|
1562
|
+
if (gi < glyphs.length - 1)
|
|
1563
|
+
yCursor += extraStep;
|
|
1564
|
+
}
|
|
1565
|
+
container.appendChild(paraG);
|
|
1566
|
+
this.attachTextRunEffects(container, paraG, p.runs);
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Run-level innerShdw / reflection. Outer shadow and glow stay on the CSS
|
|
1570
|
+
* filter from pickRunEffectsCssFilter. Inner shadow is an SVG filter; reflection
|
|
1571
|
+
* is a mirrored <use> under the paragraph.
|
|
1572
|
+
*/
|
|
1573
|
+
attachTextRunEffects(container, paraG, runs) {
|
|
1574
|
+
const effects = runs.flatMap((r) => r.effects ?? []);
|
|
1575
|
+
const inner = effects.find((e) => e.kind === "inner");
|
|
1576
|
+
if (inner) {
|
|
1577
|
+
const id = this.ensureTextInnerFilter(inner);
|
|
1578
|
+
const url = `url(#${id})`;
|
|
1579
|
+
const prev = paraG.style.filter;
|
|
1580
|
+
paraG.style.filter = prev ? `${prev} ${url}` : url;
|
|
1581
|
+
}
|
|
1582
|
+
const refl = effects.find((e) => e.kind === "reflection");
|
|
1583
|
+
if (!refl)
|
|
1584
|
+
return;
|
|
1585
|
+
let box;
|
|
1586
|
+
try {
|
|
1587
|
+
box = paraG.getBBox();
|
|
1588
|
+
}
|
|
1589
|
+
catch {
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
if (box.height <= 0)
|
|
1593
|
+
return;
|
|
1594
|
+
if (!paraG.id)
|
|
1595
|
+
paraG.id = `${this.uid}-txfx-${this.textFxSeq++}`;
|
|
1596
|
+
const bottom = box.y + box.height;
|
|
1597
|
+
const dist = refl.dist ?? 2;
|
|
1598
|
+
const use = document.createElementNS(SVG_NS, "use");
|
|
1599
|
+
use.setAttribute("href", `#${paraG.id}`);
|
|
1600
|
+
use.setAttribute("transform", `translate(0 ${bottom + dist}) scale(1 -1) translate(0 ${-bottom})`);
|
|
1601
|
+
use.setAttribute("opacity", String(refl.startAlpha ?? 0.4));
|
|
1602
|
+
if (refl.blur > 0)
|
|
1603
|
+
use.style.filter = `blur(${refl.blur}px)`;
|
|
1604
|
+
container.insertBefore(use, paraG);
|
|
1605
|
+
}
|
|
1606
|
+
ensureTextInnerFilter(inner) {
|
|
1607
|
+
const sig = `${inner.blur}|${inner.dx}|${inner.dy}|${inner.color}|${inner.alpha}`;
|
|
1608
|
+
const cached = this.textInnerFilters.get(sig);
|
|
1609
|
+
if (cached)
|
|
1610
|
+
return cached;
|
|
1611
|
+
const id = `${this.uid}-txinner-${this.textInnerFilters.size}`;
|
|
1612
|
+
const f = document.createElementNS(SVG_NS, "filter");
|
|
1613
|
+
f.setAttribute("id", id);
|
|
1614
|
+
f.setAttribute("x", "-20%");
|
|
1615
|
+
f.setAttribute("y", "-20%");
|
|
1616
|
+
f.setAttribute("width", "140%");
|
|
1617
|
+
f.setAttribute("height", "140%");
|
|
1618
|
+
const std = Math.max(0.01, inner.blur / 2);
|
|
1619
|
+
f.innerHTML =
|
|
1620
|
+
`<feComponentTransfer in="SourceAlpha" result="is-inv"><feFuncA type="table" tableValues="1 0"/></feComponentTransfer>` +
|
|
1621
|
+
`<feGaussianBlur in="is-inv" stdDeviation="${std}" result="is-blur"/>` +
|
|
1622
|
+
`<feOffset in="is-blur" dx="${inner.dx}" dy="${inner.dy}" result="is-off"/>` +
|
|
1623
|
+
`<feFlood flood-color="${inner.color}" flood-opacity="${inner.alpha}" result="is-col"/>` +
|
|
1624
|
+
`<feComposite in="is-col" in2="is-off" operator="in" result="is-shadow"/>` +
|
|
1625
|
+
`<feComposite in="is-shadow" in2="SourceAlpha" operator="in" result="is-clip"/>` +
|
|
1626
|
+
`<feMerge><feMergeNode in="SourceGraphic"/><feMergeNode in="is-clip"/></feMerge>`;
|
|
1627
|
+
this.defs.appendChild(f);
|
|
1628
|
+
this.textInnerFilters.set(sig, id);
|
|
1629
|
+
return id;
|
|
1630
|
+
}
|
|
1631
|
+
renderBullet(p, bulletX, baseline, metrics, state) {
|
|
1632
|
+
const bullet = p.bullet;
|
|
1633
|
+
const lvl = p.level ?? 0;
|
|
1634
|
+
if (!bullet || bullet.kind === "none") {
|
|
1635
|
+
if (state.lastType === "number") {
|
|
1636
|
+
state.lastType = "";
|
|
1637
|
+
state.count = 0;
|
|
1638
|
+
}
|
|
1639
|
+
state.lastLevel = lvl;
|
|
1640
|
+
return null;
|
|
1641
|
+
}
|
|
1642
|
+
if (!hasAnyText(p) && bullet.kind !== "picture")
|
|
1643
|
+
return null;
|
|
1644
|
+
const firstRun = p.runs[0];
|
|
1645
|
+
const runFs = firstRun?.fontSize ?? text_shared_1.FALLBACK_FONT_SIZE;
|
|
1646
|
+
const bulletFs = (0, text_shared_1.bulletFontSize)(bullet, runFs);
|
|
1647
|
+
if (bullet.kind === "picture") {
|
|
1648
|
+
const img = document.createElementNS(SVG_NS, "image");
|
|
1649
|
+
img.setAttribute("href", bullet.src);
|
|
1650
|
+
img.setAttributeNS("http://www.w3.org/1999/xlink", "href", bullet.src);
|
|
1651
|
+
img.setAttribute("x", String(bulletX));
|
|
1652
|
+
img.setAttribute("y", String(baseline - bulletFs));
|
|
1653
|
+
img.setAttribute("width", String(bulletFs));
|
|
1654
|
+
img.setAttribute("height", String(bulletFs));
|
|
1655
|
+
img.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
|
1656
|
+
if (state.lastType === "number") {
|
|
1657
|
+
state.lastType = "";
|
|
1658
|
+
state.count = 0;
|
|
1659
|
+
}
|
|
1660
|
+
state.lastLevel = lvl;
|
|
1661
|
+
return img;
|
|
1662
|
+
}
|
|
1663
|
+
let text = "";
|
|
1664
|
+
if (bullet.kind === "char") {
|
|
1665
|
+
text = bullet.char;
|
|
1666
|
+
if (state.lastType === "number") {
|
|
1667
|
+
state.lastType = "";
|
|
1668
|
+
state.count = 0;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
else if (bullet.kind === "number") {
|
|
1672
|
+
if (state.lastType !== "number" || state.lastLevel !== lvl) {
|
|
1673
|
+
state.count = (bullet.startAt ?? 1) - 1;
|
|
1674
|
+
state.lastType = "number";
|
|
1675
|
+
}
|
|
1676
|
+
state.count += 1;
|
|
1677
|
+
text = (0, text_shared_1.formatBulletNumber)(state.count, bullet.numType);
|
|
1678
|
+
}
|
|
1679
|
+
state.lastLevel = lvl;
|
|
1680
|
+
const t = document.createElementNS(SVG_NS, "text");
|
|
1681
|
+
// bullet 文本含"1.""(2)"等带标点的编号;同样需要 preserve 以避免空白折叠。
|
|
1682
|
+
t.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve");
|
|
1683
|
+
t.setAttribute("x", String(bulletX));
|
|
1684
|
+
t.setAttribute("y", String(baseline));
|
|
1685
|
+
t.setAttribute("text-anchor", "start");
|
|
1686
|
+
if (bullet.font)
|
|
1687
|
+
t.setAttribute("font-family", bullet.font);
|
|
1688
|
+
else if (firstRun?.fontFamily)
|
|
1689
|
+
t.setAttribute("font-family", (0, font_stack_1.withFontFallback)(firstRun.fontFamily));
|
|
1690
|
+
t.setAttribute("font-size", String(bulletFs));
|
|
1691
|
+
if (bullet.color)
|
|
1692
|
+
t.setAttribute("fill", bullet.color);
|
|
1693
|
+
else if (firstRun?.color)
|
|
1694
|
+
t.setAttribute("fill", firstRun.color);
|
|
1695
|
+
t.textContent = text;
|
|
1696
|
+
void metrics;
|
|
1697
|
+
return t;
|
|
1698
|
+
}
|
|
1699
|
+
applyRunStyle(el, run, override) {
|
|
1700
|
+
const fontFamily = override?.fontFamily ?? run.fontFamily;
|
|
1701
|
+
if (fontFamily)
|
|
1702
|
+
el.setAttribute("font-family", (0, font_stack_1.withFontFallback)(fontFamily));
|
|
1703
|
+
const fontSize = override?.fontSize ?? run.fontSize ?? text_shared_1.FALLBACK_FONT_SIZE;
|
|
1704
|
+
el.setAttribute("font-size", String(fontSize));
|
|
1705
|
+
// ECMA-376 §21.1.2.3.7 rPr@b — bold。仅在显式声明(true/false)时写入 font-weight,
|
|
1706
|
+
// 缺省时不写以让上游/CSS 继承生效。`b="0"` 时写 400 是为了"显式压回 normal",
|
|
1707
|
+
// 防止 placeholder 默认样式被一个空 rPr 误升级到加粗。
|
|
1708
|
+
const fontWeight = override?.fontWeight ?? run.fontWeight;
|
|
1709
|
+
if (fontWeight !== undefined)
|
|
1710
|
+
el.setAttribute("font-weight", String(fontWeight));
|
|
1711
|
+
const italic = override?.fontStyle ?? run.italic;
|
|
1712
|
+
if (italic)
|
|
1713
|
+
el.setAttribute("font-style", "italic");
|
|
1714
|
+
else if (italic === false)
|
|
1715
|
+
el.setAttribute("font-style", "normal");
|
|
1716
|
+
const underlineSrc = override?.underline ?? run.underline;
|
|
1717
|
+
const isUnderline = typeof underlineSrc === "string" ? underlineSrc !== "none" : !!underlineSrc;
|
|
1718
|
+
const decos = [];
|
|
1719
|
+
if (isUnderline)
|
|
1720
|
+
decos.push("underline");
|
|
1721
|
+
if (run.strikethrough && run.strikethrough !== "none")
|
|
1722
|
+
decos.push("line-through");
|
|
1723
|
+
if (decos.length > 0) {
|
|
1724
|
+
el.setAttribute("text-decoration", decos.join(" "));
|
|
1725
|
+
const style = typeof underlineSrc === "string" && underlineSrc !== "single" && underlineSrc !== "true"
|
|
1726
|
+
? underlineSrc
|
|
1727
|
+
: undefined;
|
|
1728
|
+
if (style)
|
|
1729
|
+
el.setAttribute("text-decoration-style", style);
|
|
1730
|
+
if (run.underlineColor)
|
|
1731
|
+
el.setAttribute("text-decoration-color", run.underlineColor);
|
|
1732
|
+
}
|
|
1733
|
+
const color = override?.fontColor ?? override?.fillColor ?? run.color;
|
|
1734
|
+
const paint = run.fill;
|
|
1735
|
+
if (paint && paint.kind !== "none" && paint.kind !== "solid") {
|
|
1736
|
+
const fs = override?.fontSize ?? run.fontSize ?? text_shared_1.FALLBACK_FONT_SIZE;
|
|
1737
|
+
const painted = (0, paint_svg_1.renderPaint)(paint, this.defs, { x: 0, y: 0, w: fs * 4, h: fs }, () => this.scheduleRedraw());
|
|
1738
|
+
if (painted.defNode)
|
|
1739
|
+
this.defs.appendChild(painted.defNode);
|
|
1740
|
+
el.setAttribute("fill", painted.paint);
|
|
1741
|
+
if (painted.opacity != null)
|
|
1742
|
+
el.setAttribute("fill-opacity", String(painted.opacity));
|
|
1743
|
+
}
|
|
1744
|
+
else if (paint?.kind === "solid") {
|
|
1745
|
+
el.setAttribute("fill", paint.color);
|
|
1746
|
+
}
|
|
1747
|
+
else if (color) {
|
|
1748
|
+
el.setAttribute("fill", color);
|
|
1749
|
+
}
|
|
1750
|
+
if (run.letterSpacing)
|
|
1751
|
+
el.setAttribute("letter-spacing", String(run.letterSpacing));
|
|
1752
|
+
if (run.caps === "small")
|
|
1753
|
+
el.setAttribute("font-variant", "small-caps");
|
|
1754
|
+
if (run.caps === "all")
|
|
1755
|
+
el.style.textTransform = "uppercase";
|
|
1756
|
+
// ECMA-376 §21.1.2.3.9 rPr/ln — 文字描边(textOutline)。OOXML 期望
|
|
1757
|
+
// 字身先描边再填色(描边在外,不蚕食字形),因此显式声明 paint-order。
|
|
1758
|
+
if (run.textOutline) {
|
|
1759
|
+
(0, paint_svg_1.applyStroke)(el, run.textOutline, this.defs, `${this.uid}-`);
|
|
1760
|
+
el.setAttribute("paint-order", "stroke fill");
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
renderParaSegmented(paraG, p, overrides, ax, baseline, anchor, lineHeight, paraOv) {
|
|
1764
|
+
const ctx = this.getMeasureCtx();
|
|
1765
|
+
const entries = [];
|
|
1766
|
+
let absIdx = 0;
|
|
1767
|
+
for (const run of p.runs) {
|
|
1768
|
+
const fs = run.fontSize ?? text_shared_1.FALLBACK_FONT_SIZE;
|
|
1769
|
+
const fw = run.fontWeight;
|
|
1770
|
+
const fa = (0, font_stack_1.withFontFallback)(run.fontFamily);
|
|
1771
|
+
const it = run.italic ? "italic " : "";
|
|
1772
|
+
ctx.font = `${it}${fw ?? "normal"} ${fs}px ${fa}`;
|
|
1773
|
+
for (const ch of Array.from(run.text)) {
|
|
1774
|
+
const width = ctx.measureText(ch).width + (run.letterSpacing ?? 0);
|
|
1775
|
+
const ov = findCharOverride(overrides, absIdx);
|
|
1776
|
+
entries.push({ ch, run, width, ov, absIdx });
|
|
1777
|
+
absIdx += ch.length;
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
const totalWidth = entries.reduce((acc, e) => acc + e.width, 0);
|
|
1781
|
+
const startX = anchor === "middle" ? ax - totalWidth / 2 : anchor === "end" ? ax - totalWidth : ax;
|
|
1782
|
+
const segments = [];
|
|
1783
|
+
let cur = null;
|
|
1784
|
+
let cursorX = 0;
|
|
1785
|
+
for (const e of entries) {
|
|
1786
|
+
if (!cur || cur.ov !== e.ov) {
|
|
1787
|
+
cur = { ov: e.ov, entries: [], offsetX: cursorX, width: 0 };
|
|
1788
|
+
segments.push(cur);
|
|
1789
|
+
}
|
|
1790
|
+
cur.entries.push(e);
|
|
1791
|
+
cur.width += e.width;
|
|
1792
|
+
cursorX += e.width;
|
|
1793
|
+
}
|
|
1794
|
+
for (const seg of segments) {
|
|
1795
|
+
const segX = startX + seg.offsetX;
|
|
1796
|
+
const wrap = document.createElementNS(SVG_NS, "g");
|
|
1797
|
+
const textEl = document.createElementNS(SVG_NS, "text");
|
|
1798
|
+
// char-range 路径下每个字符宽度都已用 measureText 算入 segment 偏移,
|
|
1799
|
+
// 渲染时必须保留所有空白才能与 layout 对齐。
|
|
1800
|
+
textEl.setAttributeNS("http://www.w3.org/XML/1998/namespace", "xml:space", "preserve");
|
|
1801
|
+
textEl.setAttribute("x", String(segX));
|
|
1802
|
+
textEl.setAttribute("y", String(baseline));
|
|
1803
|
+
textEl.setAttribute("text-anchor", "start");
|
|
1804
|
+
let lastRun = null;
|
|
1805
|
+
let curTspan = null;
|
|
1806
|
+
for (const e of seg.entries) {
|
|
1807
|
+
if (e.run !== lastRun || !curTspan) {
|
|
1808
|
+
curTspan = document.createElementNS(SVG_NS, "tspan");
|
|
1809
|
+
this.applyRunStyle(curTspan, e.run, (0, scene_node_1.mergeParaOverrides)(paraOv, seg.ov));
|
|
1810
|
+
textEl.appendChild(curTspan);
|
|
1811
|
+
lastRun = e.run;
|
|
1812
|
+
}
|
|
1813
|
+
curTspan.appendChild(document.createTextNode(e.ch));
|
|
1814
|
+
}
|
|
1815
|
+
wrap.appendChild(textEl);
|
|
1816
|
+
const ov = seg.ov;
|
|
1817
|
+
if (ov) {
|
|
1818
|
+
const op = ov.opacity ?? 1;
|
|
1819
|
+
if (op !== 1)
|
|
1820
|
+
wrap.setAttribute("opacity", String(op));
|
|
1821
|
+
if (ov.visible === false)
|
|
1822
|
+
wrap.setAttribute("visibility", "hidden");
|
|
1823
|
+
const tx = ov.tx ?? 0;
|
|
1824
|
+
const ty = ov.ty ?? 0;
|
|
1825
|
+
const sX = (ov.scaleX ?? 1) * (ov.scale ?? 1);
|
|
1826
|
+
const sY = (ov.scaleY ?? 1) * (ov.scale ?? 1);
|
|
1827
|
+
const rot = ov.rotation ?? 0;
|
|
1828
|
+
if (tx !== 0 || ty !== 0 || sX !== 1 || sY !== 1 || rot !== 0) {
|
|
1829
|
+
const cx = segX + seg.width / 2;
|
|
1830
|
+
const cy = baseline - lineHeight * 0.35;
|
|
1831
|
+
const parts = [];
|
|
1832
|
+
if (tx !== 0 || ty !== 0)
|
|
1833
|
+
parts.push(`translate(${tx},${ty})`);
|
|
1834
|
+
if (sX !== 1 || sY !== 1 || rot !== 0) {
|
|
1835
|
+
parts.push(`translate(${cx},${cy})`);
|
|
1836
|
+
if (rot !== 0)
|
|
1837
|
+
parts.push(`rotate(${rot})`);
|
|
1838
|
+
if (sX !== 1 || sY !== 1)
|
|
1839
|
+
parts.push(`scale(${sX},${sY})`);
|
|
1840
|
+
parts.push(`translate(${-cx},${-cy})`);
|
|
1841
|
+
}
|
|
1842
|
+
wrap.setAttribute("transform", parts.join(" "));
|
|
1843
|
+
}
|
|
1844
|
+
// §19.5.8 char-range 的 effect:clip 渲染(fade/wipe/diamond 等 entrance filter)。
|
|
1845
|
+
// 之前只在 paragraph 级渲染 clip,char-range 级被忽略 → fade 等过渡完全不可见,
|
|
1846
|
+
// 字符在动画期间一直显示 → 视觉上"先在最终位置显示再做动画"。
|
|
1847
|
+
if (ov.clip && ov.clip.kind === "effect") {
|
|
1848
|
+
this.applyCharClip(wrap, ov.clip, segX, baseline - lineHeight + (lineHeight * 0.15), seg.width, lineHeight);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
paraG.appendChild(wrap);
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
renderTextRunsWithOverrides(parent, p, overrides) {
|
|
1855
|
+
let absIdx = 0;
|
|
1856
|
+
let appliedDx = 0;
|
|
1857
|
+
let appliedDy = 0;
|
|
1858
|
+
for (const run of p.runs) {
|
|
1859
|
+
const chars = Array.from(run.text);
|
|
1860
|
+
const runEl = document.createElementNS(SVG_NS, "tspan");
|
|
1861
|
+
this.applyRunStyle(runEl, run);
|
|
1862
|
+
parent.appendChild(runEl);
|
|
1863
|
+
for (const ch of chars) {
|
|
1864
|
+
const ov = findCharOverride(overrides, absIdx);
|
|
1865
|
+
const targetDx = ov?.tx ?? 0;
|
|
1866
|
+
const targetDy = ov?.ty ?? 0;
|
|
1867
|
+
const deltaDx = targetDx - appliedDx;
|
|
1868
|
+
const deltaDy = targetDy - appliedDy;
|
|
1869
|
+
const seg = document.createElementNS(SVG_NS, "tspan");
|
|
1870
|
+
seg.textContent = ch;
|
|
1871
|
+
if (deltaDx)
|
|
1872
|
+
seg.setAttribute("dx", String(deltaDx));
|
|
1873
|
+
if (deltaDy)
|
|
1874
|
+
seg.setAttribute("dy", String(deltaDy));
|
|
1875
|
+
if (ov) {
|
|
1876
|
+
const op = ov.opacity ?? 1;
|
|
1877
|
+
if (op !== 1)
|
|
1878
|
+
seg.setAttribute("opacity", String(op));
|
|
1879
|
+
if (ov.visible === false)
|
|
1880
|
+
seg.setAttribute("visibility", "hidden");
|
|
1881
|
+
}
|
|
1882
|
+
runEl.appendChild(seg);
|
|
1883
|
+
appliedDx = targetDx;
|
|
1884
|
+
appliedDy = targetDy;
|
|
1885
|
+
absIdx += ch.length;
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
applyImageClip(nodeId, b, box, d) {
|
|
1890
|
+
if (!b.shapeEl)
|
|
1891
|
+
return;
|
|
1892
|
+
const cpId = `${this.uid}-imgclip-${nodeId}`;
|
|
1893
|
+
// 裁剪图(cropRect)在 SVG 里是"整张精灵图放大铺开 + 用这个 clip 矩形只露出
|
|
1894
|
+
// bbox 那一小块"。clip 一旦失效,露出的就是放大 N 倍的整图(见 image case 里
|
|
1895
|
+
// iw=bbox.w/(1-l-r))。因此 clipPath 元素和 shapeEl 上的 clip-path 属性都必须
|
|
1896
|
+
// 每次重绘幂等地重新挂回:
|
|
1897
|
+
// - clipPath 可能被 removeImageClip 摘过属性、或在 hybrid 多 backend / 重挂
|
|
1898
|
+
// 场景里从 defs 脱离;
|
|
1899
|
+
// - shapeEl 可能被重建成新的 <image> 而旧 b.imgClipPath 还在。
|
|
1900
|
+
// 旧实现只在"首次创建"时 setAttribute,导致属性一旦被摘就再也补不回来 →
|
|
1901
|
+
// 米粒精灵图放大叠在一起。
|
|
1902
|
+
// custGeom 用路径代替矩形:clipPath 子节点是并集,不能再叠一个矩形。
|
|
1903
|
+
const mode = d ? "path" : "rect";
|
|
1904
|
+
if (!b.imgClipPath || !b.imgClipPath.isConnected || b.imgClipPath.dataset.mode !== mode) {
|
|
1905
|
+
b.imgClipPath?.remove();
|
|
1906
|
+
const cp = document.createElementNS(SVG_NS, "clipPath");
|
|
1907
|
+
cp.setAttribute("id", cpId);
|
|
1908
|
+
cp.dataset.mode = mode;
|
|
1909
|
+
if (d) {
|
|
1910
|
+
const p = document.createElementNS(SVG_NS, "path");
|
|
1911
|
+
cp.appendChild(p);
|
|
1912
|
+
b.imgClipShape = p;
|
|
1913
|
+
b.imgClipRect = undefined;
|
|
1914
|
+
}
|
|
1915
|
+
else {
|
|
1916
|
+
const r = document.createElementNS(SVG_NS, "rect");
|
|
1917
|
+
cp.appendChild(r);
|
|
1918
|
+
b.imgClipRect = r;
|
|
1919
|
+
b.imgClipShape = undefined;
|
|
1920
|
+
}
|
|
1921
|
+
this.defs.appendChild(cp);
|
|
1922
|
+
b.imgClipPath = cp;
|
|
1923
|
+
}
|
|
1924
|
+
if (b.shapeEl.getAttribute("clip-path") !== `url(#${cpId})`) {
|
|
1925
|
+
b.shapeEl.setAttribute("clip-path", `url(#${cpId})`);
|
|
1926
|
+
}
|
|
1927
|
+
if (d && b.imgClipShape) {
|
|
1928
|
+
b.imgClipShape.setAttribute("d", d);
|
|
1929
|
+
if ((d.match(/[Mm]/g)?.length ?? 0) > 1)
|
|
1930
|
+
b.imgClipShape.setAttribute("clip-rule", "evenodd");
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
const r = b.imgClipRect;
|
|
1934
|
+
r.setAttribute("x", String(box.x));
|
|
1935
|
+
r.setAttribute("y", String(box.y));
|
|
1936
|
+
r.setAttribute("width", String(box.w));
|
|
1937
|
+
r.setAttribute("height", String(box.h));
|
|
1938
|
+
}
|
|
1939
|
+
removeImageClip(b) {
|
|
1940
|
+
if (b.imgClipPath && b.shapeEl) {
|
|
1941
|
+
b.shapeEl.removeAttribute("clip-path");
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
applyImageStroke(s, b, node) {
|
|
1945
|
+
const stroke = s.stroke;
|
|
1946
|
+
if (!stroke) {
|
|
1947
|
+
if (b.imgStrokeRect) {
|
|
1948
|
+
b.imgStrokeRect.remove();
|
|
1949
|
+
b.imgStrokeRect = undefined;
|
|
1950
|
+
}
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
if (!b.imgStrokeRect) {
|
|
1954
|
+
const r = document.createElementNS(SVG_NS, "rect");
|
|
1955
|
+
r.setAttribute("fill", "none");
|
|
1956
|
+
b.el.appendChild(r);
|
|
1957
|
+
b.imgStrokeRect = r;
|
|
1958
|
+
}
|
|
1959
|
+
const r = b.imgStrokeRect;
|
|
1960
|
+
r.setAttribute("x", String(s.bbox.x));
|
|
1961
|
+
r.setAttribute("y", String(s.bbox.y));
|
|
1962
|
+
r.setAttribute("width", String(s.bbox.w));
|
|
1963
|
+
r.setAttribute("height", String(s.bbox.h));
|
|
1964
|
+
if (node.strokeOverride) {
|
|
1965
|
+
r.setAttribute("stroke", node.strokeOverride);
|
|
1966
|
+
r.setAttribute("stroke-width", String(node.strokeWidthOverride ?? stroke.width));
|
|
1967
|
+
}
|
|
1968
|
+
else {
|
|
1969
|
+
(0, paint_svg_1.applyStroke)(r, stroke, this.defs, `${this.uid}-`);
|
|
1970
|
+
if (node.strokeWidthOverride !== null) {
|
|
1971
|
+
r.setAttribute("stroke-width", String(node.strokeWidthOverride));
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
// char-range 级别的 effect clip:与 paragraph 级 applyParaClip 共用渲染逻辑,
|
|
1976
|
+
// 但 bbox 用字符段实际像素范围,id 加 seg 起点保证 char-range 之间互不覆盖。
|
|
1977
|
+
applyCharClip(wrap, clip, x, y, w, h) {
|
|
1978
|
+
if (clip.kind !== "effect")
|
|
1979
|
+
return;
|
|
1980
|
+
const id = `${this.uid}-clip-seg-${Math.round(x)}-${Math.round(y)}-${Math.round(w)}-${Math.round(h)}-${clip.filter}-${Math.round((clip.progress ?? 0) * 1000)}`;
|
|
1981
|
+
let cp = this.defs.querySelector(`#${CSS.escape(id)}`);
|
|
1982
|
+
if (!cp) {
|
|
1983
|
+
cp = document.createElementNS(SVG_NS, "clipPath");
|
|
1984
|
+
cp.setAttribute("id", id);
|
|
1985
|
+
this.defs.appendChild(cp);
|
|
1986
|
+
}
|
|
1987
|
+
while (cp.firstChild)
|
|
1988
|
+
cp.removeChild(cp.firstChild);
|
|
1989
|
+
const geom = {
|
|
1990
|
+
filter: clip.filter,
|
|
1991
|
+
prLst: clip.prLst,
|
|
1992
|
+
progress: clip.progress,
|
|
1993
|
+
transition: clip.transition,
|
|
1994
|
+
bbox: { x, y, w: Math.max(1, w), h: Math.max(1, h) },
|
|
1995
|
+
};
|
|
1996
|
+
const d = (0, effect_clip_1.effectClipPathD)(geom);
|
|
1997
|
+
if (!d)
|
|
1998
|
+
return;
|
|
1999
|
+
const path = document.createElementNS(SVG_NS, "path");
|
|
2000
|
+
path.setAttribute("d", d);
|
|
2001
|
+
if ((0, effect_clip_1.effectClipUsesEvenOdd)(geom))
|
|
2002
|
+
path.setAttribute("clip-rule", "evenodd");
|
|
2003
|
+
cp.appendChild(path);
|
|
2004
|
+
wrap.setAttribute("clip-path", `url(#${id})`);
|
|
2005
|
+
}
|
|
2006
|
+
applyParaClip(paraG, clip, nodeId, paraIdx, bbox) {
|
|
2007
|
+
if (!clip || clip.kind !== "effect")
|
|
2008
|
+
return;
|
|
2009
|
+
// filter="fade" 走 opacity 通道(见 fadeOpacityFromClip),不需要 clip-path;
|
|
2010
|
+
// 否则 clip-path 仍会被设上,潜在地切掉 hanging-indent 的 bullet 等内容。
|
|
2011
|
+
if (clip.filter === "fade")
|
|
2012
|
+
return;
|
|
2013
|
+
const id = `${this.uid}-clip-${nodeId}-p${paraIdx}`;
|
|
2014
|
+
let cp = this.defs.querySelector(`#${CSS.escape(id)}`);
|
|
2015
|
+
if (!cp) {
|
|
2016
|
+
cp = document.createElementNS(SVG_NS, "clipPath");
|
|
2017
|
+
cp.setAttribute("id", id);
|
|
2018
|
+
this.defs.appendChild(cp);
|
|
2019
|
+
}
|
|
2020
|
+
while (cp.firstChild)
|
|
2021
|
+
cp.removeChild(cp.firstChild);
|
|
2022
|
+
const geom = {
|
|
2023
|
+
filter: clip.filter,
|
|
2024
|
+
prLst: clip.prLst,
|
|
2025
|
+
progress: clip.progress,
|
|
2026
|
+
transition: clip.transition,
|
|
2027
|
+
bbox,
|
|
2028
|
+
};
|
|
2029
|
+
const d = (0, effect_clip_1.effectClipPathD)(geom);
|
|
2030
|
+
if (!d)
|
|
2031
|
+
return;
|
|
2032
|
+
const path = document.createElementNS(SVG_NS, "path");
|
|
2033
|
+
path.setAttribute("d", d);
|
|
2034
|
+
if ((0, effect_clip_1.effectClipUsesEvenOdd)(geom))
|
|
2035
|
+
path.setAttribute("clip-rule", "evenodd");
|
|
2036
|
+
cp.appendChild(path);
|
|
2037
|
+
paraG.setAttribute("clip-path", `url(#${id})`);
|
|
2038
|
+
}
|
|
2039
|
+
applyClip(node, b) {
|
|
2040
|
+
const clip = node.clip;
|
|
2041
|
+
// §19.5.8 animEffect filter="fade" 是 opacity-based 过渡(见 fadeOpacityFromClip),
|
|
2042
|
+
// 不需要任何几何 clip。如果走 effect-clip 路径会把整 shape 裁到 bbox,导致
|
|
2043
|
+
// 像 line 的 marker(箭头)这种超出 bbox 的几何被切掉。
|
|
2044
|
+
const isFade = clip?.kind === "effect" && clip.filter === "fade";
|
|
2045
|
+
// §19.5.21 fill="hold":entrance 完成后 progress 恒为 1,effectClipPathD 退化成
|
|
2046
|
+
// fullRect(bbox)——视觉上不裁任何 *内部* 像素,却仍会把超出 bbox 的几何(line 的
|
|
2047
|
+
// 箭头 marker、stroke overshoot 等)切掉,导致"播放完箭头消失、编辑器里却在"。
|
|
2048
|
+
// 完成态(progress>=1)= 完全可见,直接撤掉几何 clip。
|
|
2049
|
+
const isComplete = clip?.kind === "effect" && (clip.progress ?? 0) >= 1;
|
|
2050
|
+
if (!clip || isFade || isComplete) {
|
|
2051
|
+
if (b.clipPath)
|
|
2052
|
+
b.el.removeAttribute("clip-path");
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
2055
|
+
if (!b.clipPath) {
|
|
2056
|
+
const cp = document.createElementNS(SVG_NS, "clipPath");
|
|
2057
|
+
const cpId = `${this.uid}-clip-${node.id}`;
|
|
2058
|
+
cp.setAttribute("id", cpId);
|
|
2059
|
+
this.defs.appendChild(cp);
|
|
2060
|
+
b.clipPath = cp;
|
|
2061
|
+
}
|
|
2062
|
+
// 始终(重新)挂上 clip-path 引用:completeState/replay 时上面会 removeAttribute,
|
|
2063
|
+
// 若 b.clipPath 已存在则不会再进创建分支,必须在这里幂等地补回属性。
|
|
2064
|
+
b.el.setAttribute("clip-path", `url(#${this.uid}-clip-${node.id})`);
|
|
2065
|
+
const cp = b.clipPath;
|
|
2066
|
+
if (clip.kind === "wheel") {
|
|
2067
|
+
while (cp.firstChild)
|
|
2068
|
+
cp.removeChild(cp.firstChild);
|
|
2069
|
+
b.clipRect = undefined;
|
|
2070
|
+
const path = document.createElementNS(SVG_NS, "path");
|
|
2071
|
+
const d = wheelPathD(node.shape.bbox, clip.spokes, clip.progress);
|
|
2072
|
+
path.setAttribute("d", d);
|
|
2073
|
+
cp.appendChild(path);
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
if (clip.kind === "effect") {
|
|
2077
|
+
while (cp.firstChild)
|
|
2078
|
+
cp.removeChild(cp.firstChild);
|
|
2079
|
+
b.clipRect = undefined;
|
|
2080
|
+
const geom = {
|
|
2081
|
+
filter: clip.filter,
|
|
2082
|
+
prLst: clip.prLst,
|
|
2083
|
+
progress: clip.progress,
|
|
2084
|
+
transition: clip.transition,
|
|
2085
|
+
bbox: node.shape.bbox,
|
|
2086
|
+
};
|
|
2087
|
+
const d = (0, effect_clip_1.effectClipPathD)(geom);
|
|
2088
|
+
if (d) {
|
|
2089
|
+
const path = document.createElementNS(SVG_NS, "path");
|
|
2090
|
+
path.setAttribute("d", d);
|
|
2091
|
+
if ((0, effect_clip_1.effectClipUsesEvenOdd)(geom))
|
|
2092
|
+
path.setAttribute("clip-rule", "evenodd");
|
|
2093
|
+
cp.appendChild(path);
|
|
2094
|
+
}
|
|
2095
|
+
return;
|
|
2096
|
+
}
|
|
2097
|
+
if (!b.clipRect) {
|
|
2098
|
+
while (cp.firstChild)
|
|
2099
|
+
cp.removeChild(cp.firstChild);
|
|
2100
|
+
const r = document.createElementNS(SVG_NS, "rect");
|
|
2101
|
+
cp.appendChild(r);
|
|
2102
|
+
b.clipRect = r;
|
|
2103
|
+
}
|
|
2104
|
+
const rect = b.clipRect;
|
|
2105
|
+
const { x, y, w, h } = rectFor(node.shape.bbox, clip);
|
|
2106
|
+
rect.setAttribute("x", String(x));
|
|
2107
|
+
rect.setAttribute("y", String(y));
|
|
2108
|
+
rect.setAttribute("width", String(w));
|
|
2109
|
+
rect.setAttribute("height", String(h));
|
|
2110
|
+
}
|
|
2111
|
+
destroy() {
|
|
2112
|
+
for (const d of this.dispose)
|
|
2113
|
+
d();
|
|
2114
|
+
this.dispose = [];
|
|
2115
|
+
this.root.remove();
|
|
2116
|
+
this.bindings.clear();
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
exports.SvgBackend = SvgBackend;
|
|
2120
|
+
function paragraphMetrics(p) {
|
|
2121
|
+
const sizes = p.runs.map((r) => r.fontSize ?? 0).filter((n) => n > 0);
|
|
2122
|
+
const fs = sizes.length ? Math.max(...sizes) : text_shared_1.FALLBACK_FONT_SIZE;
|
|
2123
|
+
const height = p.lineHeightPx ?? fs * (0, text_shared_1.lineHeightFactor)(p);
|
|
2124
|
+
return {
|
|
2125
|
+
height,
|
|
2126
|
+
ascent: height - fs * 0.25,
|
|
2127
|
+
before: p.spaceBeforePx ?? 0,
|
|
2128
|
+
after: p.spaceAfterPx ?? 0,
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
function hasAnyText(p) {
|
|
2132
|
+
for (const r of p.runs)
|
|
2133
|
+
if (r.text && r.text !== "")
|
|
2134
|
+
return true;
|
|
2135
|
+
return false;
|
|
2136
|
+
}
|
|
2137
|
+
function rectFor(b, c) {
|
|
2138
|
+
if (c.kind === "wheel" || c.kind === "effect")
|
|
2139
|
+
return { x: b.x, y: b.y, w: b.w, h: b.h };
|
|
2140
|
+
const p = Math.max(0, Math.min(1, c.progress));
|
|
2141
|
+
switch (c.direction) {
|
|
2142
|
+
case "left":
|
|
2143
|
+
return { x: b.x, y: b.y, w: b.w * p, h: b.h };
|
|
2144
|
+
case "right":
|
|
2145
|
+
return { x: b.x + b.w * (1 - p), y: b.y, w: b.w * p, h: b.h };
|
|
2146
|
+
case "top":
|
|
2147
|
+
return { x: b.x, y: b.y, w: b.w, h: b.h * p };
|
|
2148
|
+
case "bottom":
|
|
2149
|
+
return { x: b.x, y: b.y + b.h * (1 - p), w: b.w, h: b.h * p };
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
function wheelPathD(b, spokes, progress) {
|
|
2153
|
+
const p = Math.max(0, Math.min(1, progress));
|
|
2154
|
+
const cx = b.x + b.w / 2;
|
|
2155
|
+
const cy = b.y + b.h / 2;
|
|
2156
|
+
const R = Math.hypot(b.w, b.h);
|
|
2157
|
+
const n = Math.max(1, Math.floor(spokes));
|
|
2158
|
+
const span = (2 * Math.PI) / n;
|
|
2159
|
+
const steps = Math.max(2, Math.ceil(24 / n));
|
|
2160
|
+
let d = "";
|
|
2161
|
+
for (let i = 0; i < n; i++) {
|
|
2162
|
+
const a0 = i * span - Math.PI / 2;
|
|
2163
|
+
const reveal = span * p;
|
|
2164
|
+
d += `M ${cx} ${cy} `;
|
|
2165
|
+
for (let s = 0; s <= steps; s++) {
|
|
2166
|
+
const a = a0 + reveal * (s / steps);
|
|
2167
|
+
d += `L ${cx + R * Math.cos(a)} ${cy + R * Math.sin(a)} `;
|
|
2168
|
+
}
|
|
2169
|
+
d += "Z ";
|
|
2170
|
+
}
|
|
2171
|
+
return d.trim();
|
|
2172
|
+
}
|