@vyaz/renderer 0.0.5 → 0.0.10
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/dist/index.browser.js +1151 -0
- package/dist/index.js +28 -0
- package/dist/src/index.browser.d.ts +16 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +12 -3
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
// src/SVGRenderer.ts
|
|
2
|
+
import { groupLinesByParagraph } from "@vyaz/core";
|
|
3
|
+
|
|
4
|
+
// src/utils.ts
|
|
5
|
+
function fmt(n, precision = 2) {
|
|
6
|
+
return (Math.round(n * 10 ** precision) / 10 ** precision).toString();
|
|
7
|
+
}
|
|
8
|
+
function computeBBox(lines) {
|
|
9
|
+
if (lines.length === 0)
|
|
10
|
+
return { x: 0, y: 0, width: 0, height: 0 };
|
|
11
|
+
const minX = Math.min(...lines.map((l) => l.x));
|
|
12
|
+
const maxX = Math.max(...lines.map((l) => l.x + l.width));
|
|
13
|
+
const minY = Math.min(...lines.map((l) => l.y));
|
|
14
|
+
const maxY = lines[lines.length - 1].y + lines[lines.length - 1].height;
|
|
15
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// src/SVGRenderer.ts
|
|
19
|
+
var PRESETS = {
|
|
20
|
+
flat: { structure: "flat", spacing: "preserve", defaultFit: "none" },
|
|
21
|
+
browser: { structure: "expanded", spacing: "preserve", defaultFit: "none" },
|
|
22
|
+
preserve: { structure: "expanded", spacing: "preserve", defaultFit: "frag" },
|
|
23
|
+
glyph: { structure: "glyph", spacing: "preserve", defaultFit: "none" }
|
|
24
|
+
};
|
|
25
|
+
function escapeXml(text) {
|
|
26
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
27
|
+
}
|
|
28
|
+
function fontWeightNumeric(weight) {
|
|
29
|
+
if (weight === "bold")
|
|
30
|
+
return 700;
|
|
31
|
+
if (weight === "normal")
|
|
32
|
+
return 400;
|
|
33
|
+
if (typeof weight === "number")
|
|
34
|
+
return weight;
|
|
35
|
+
return 400;
|
|
36
|
+
}
|
|
37
|
+
function colorToRGB(color) {
|
|
38
|
+
if (!color)
|
|
39
|
+
return "rgb(0, 0, 0)";
|
|
40
|
+
if (color[0] !== "#")
|
|
41
|
+
return color;
|
|
42
|
+
let hex = color;
|
|
43
|
+
if (hex.length === 4) {
|
|
44
|
+
hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
|
|
45
|
+
}
|
|
46
|
+
if (hex.length === 7) {
|
|
47
|
+
return `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
|
|
48
|
+
}
|
|
49
|
+
if (hex.length === 9) {
|
|
50
|
+
const a = parseInt(hex.slice(7, 9), 16) / 255;
|
|
51
|
+
return `rgba(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)}, ${a.toFixed(3)})`;
|
|
52
|
+
}
|
|
53
|
+
return color;
|
|
54
|
+
}
|
|
55
|
+
function resolveOptions(opts) {
|
|
56
|
+
let structure;
|
|
57
|
+
let spacing;
|
|
58
|
+
let defaultFit;
|
|
59
|
+
if (opts.preset) {
|
|
60
|
+
const preset = PRESETS[opts.preset];
|
|
61
|
+
if (!preset) {
|
|
62
|
+
console.warn(`SVGRenderer: unknown preset "${opts.preset}", falling back to browser`);
|
|
63
|
+
structure = "expanded";
|
|
64
|
+
spacing = "browser";
|
|
65
|
+
defaultFit = "none";
|
|
66
|
+
} else {
|
|
67
|
+
structure = preset.structure;
|
|
68
|
+
spacing = preset.spacing;
|
|
69
|
+
defaultFit = preset.defaultFit;
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
structure = "expanded";
|
|
73
|
+
spacing = "browser";
|
|
74
|
+
defaultFit = "none";
|
|
75
|
+
}
|
|
76
|
+
const style = opts.style ?? "xml";
|
|
77
|
+
let fit = opts.fit ?? defaultFit;
|
|
78
|
+
let sizingHorizontal;
|
|
79
|
+
let sizingVertical;
|
|
80
|
+
if (typeof opts.sizing === "object" && opts.sizing !== null) {
|
|
81
|
+
sizingHorizontal = opts.sizing.horizontal ?? "frame";
|
|
82
|
+
sizingVertical = opts.sizing.vertical ?? "frame";
|
|
83
|
+
} else {
|
|
84
|
+
const s = opts.sizing ?? "frame";
|
|
85
|
+
sizingHorizontal = s;
|
|
86
|
+
sizingVertical = s;
|
|
87
|
+
}
|
|
88
|
+
if (structure === "glyph" && fit !== "none") {
|
|
89
|
+
console.warn(`SVGRenderer: fit="${fit}" is ignored when structure="glyph"`);
|
|
90
|
+
fit = "none";
|
|
91
|
+
}
|
|
92
|
+
if (structure === "flat" && fit === "frag") {
|
|
93
|
+
console.warn(`SVGRenderer: fit="frag" downgraded to "text" when structure="flat"`);
|
|
94
|
+
fit = "text";
|
|
95
|
+
}
|
|
96
|
+
return { structure, spacing, style, fit, sizingHorizontal, sizingVertical, width: opts.width, height: opts.height, className: opts.className, contentPadding: opts.contentPadding ?? 0, debug: opts.debug };
|
|
97
|
+
}
|
|
98
|
+
function resolveSize(lines, opts) {
|
|
99
|
+
const needsBBox = opts.sizingHorizontal === "content" || opts.sizingVertical === "content";
|
|
100
|
+
const bbox = needsBBox ? computeBBox(lines) : null;
|
|
101
|
+
const frameWidth = opts.width;
|
|
102
|
+
const frameHeight = opts.height;
|
|
103
|
+
let width;
|
|
104
|
+
let height;
|
|
105
|
+
if (opts.sizingHorizontal === "content") {
|
|
106
|
+
width = bbox.width;
|
|
107
|
+
} else {
|
|
108
|
+
if (opts.width === undefined) {
|
|
109
|
+
throw new Error(`renderToSVG: horizontal sizing="frame" requires explicit width. ` + `Got width=${opts.width}.`);
|
|
110
|
+
}
|
|
111
|
+
width = opts.width;
|
|
112
|
+
}
|
|
113
|
+
if (opts.sizingVertical === "content") {
|
|
114
|
+
height = bbox.height;
|
|
115
|
+
} else {
|
|
116
|
+
if (opts.height === undefined) {
|
|
117
|
+
throw new Error(`renderToSVG: vertical sizing="frame" requires explicit height. ` + `Got height=${opts.height}.`);
|
|
118
|
+
}
|
|
119
|
+
height = opts.height;
|
|
120
|
+
}
|
|
121
|
+
let viewBox;
|
|
122
|
+
if (bbox) {
|
|
123
|
+
viewBox = {
|
|
124
|
+
x: opts.sizingHorizontal === "content" ? bbox.x : 0,
|
|
125
|
+
y: opts.sizingVertical === "content" ? bbox.y : 0,
|
|
126
|
+
w: opts.sizingHorizontal === "content" ? bbox.width : width,
|
|
127
|
+
h: opts.sizingVertical === "content" ? bbox.height : height
|
|
128
|
+
};
|
|
129
|
+
} else {
|
|
130
|
+
viewBox = { x: 0, y: 0, w: width, h: height };
|
|
131
|
+
}
|
|
132
|
+
const pad = opts.contentPadding || 0;
|
|
133
|
+
if (pad > 0) {
|
|
134
|
+
width += pad * 2;
|
|
135
|
+
height += pad * 2;
|
|
136
|
+
viewBox = {
|
|
137
|
+
x: viewBox.x - pad,
|
|
138
|
+
y: viewBox.y - pad,
|
|
139
|
+
w: viewBox.w + pad * 2,
|
|
140
|
+
h: viewBox.h + pad * 2
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return { width, height, viewBox, frameWidth, frameHeight };
|
|
144
|
+
}
|
|
145
|
+
function defaultStyleState(span) {
|
|
146
|
+
const decorations = [];
|
|
147
|
+
if (span.style.underline)
|
|
148
|
+
decorations.push("underline");
|
|
149
|
+
if (span.style.strikethrough)
|
|
150
|
+
decorations.push("line-through");
|
|
151
|
+
return {
|
|
152
|
+
fontFamily: span.style.fontFamily || "Arial",
|
|
153
|
+
fontSize: span.fontMetrics.fontSize || 16,
|
|
154
|
+
fontWeight: fontWeightNumeric(span.style.fontWeight),
|
|
155
|
+
color: span.style.color || "#000000",
|
|
156
|
+
fontStyle: span.style.fontStyle || "normal",
|
|
157
|
+
decoration: decorations.join(" "),
|
|
158
|
+
letterSpacing: span.style.letterSpacing,
|
|
159
|
+
backgroundColor: span.style.backgroundColor
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function equalStyle(a, b) {
|
|
163
|
+
return a.fontFamily === b.fontFamily && a.fontSize === b.fontSize && a.fontWeight === b.fontWeight && a.color === b.color && a.fontStyle === b.fontStyle && a.decoration === b.decoration && a.letterSpacing === b.letterSpacing && a.backgroundColor === b.backgroundColor;
|
|
164
|
+
}
|
|
165
|
+
function styleSignature(span) {
|
|
166
|
+
const s = defaultStyleState(span);
|
|
167
|
+
return `${s.fontFamily}|${s.fontSize}|${s.fontWeight}|${s.color}|${s.fontStyle}|${s.decoration}|${s.letterSpacing ?? ""}|${s.backgroundColor ?? ""}`;
|
|
168
|
+
}
|
|
169
|
+
function buildTextAttrs(line, span, opts, runId) {
|
|
170
|
+
const x = line.x;
|
|
171
|
+
const y = line.y + line.baseline;
|
|
172
|
+
const s = defaultStyleState(span);
|
|
173
|
+
const attrs = {
|
|
174
|
+
x: fmt(x),
|
|
175
|
+
y: fmt(y)
|
|
176
|
+
};
|
|
177
|
+
if (runId)
|
|
178
|
+
attrs.id = runId;
|
|
179
|
+
if (opts.style === "css") {
|
|
180
|
+
let css = `font-family: '${s.fontFamily}', sans-serif; font-size: ${fmt(s.fontSize)}px; fill: ${colorToRGB(s.color)}; font-weight: ${s.fontWeight}`;
|
|
181
|
+
if (s.fontStyle === "italic")
|
|
182
|
+
css += `; font-style: italic`;
|
|
183
|
+
if (opts.spacing === "preserve")
|
|
184
|
+
css += "; white-space: pre";
|
|
185
|
+
attrs.style = css;
|
|
186
|
+
} else {
|
|
187
|
+
attrs["font-family"] = s.fontFamily;
|
|
188
|
+
attrs["font-size"] = fmt(s.fontSize);
|
|
189
|
+
attrs.fill = s.color;
|
|
190
|
+
attrs["font-weight"] = s.fontWeight;
|
|
191
|
+
if (s.fontStyle === "italic")
|
|
192
|
+
attrs["font-style"] = "italic";
|
|
193
|
+
if (opts.spacing === "preserve")
|
|
194
|
+
attrs["xml:space"] = "preserve";
|
|
195
|
+
}
|
|
196
|
+
return attrs;
|
|
197
|
+
}
|
|
198
|
+
function buildTspanAttrs(span, x, currentStyle) {
|
|
199
|
+
const s = defaultStyleState(span);
|
|
200
|
+
const attrs = {
|
|
201
|
+
x: fmt(x)
|
|
202
|
+
};
|
|
203
|
+
if (currentStyle && equalStyle(s, currentStyle)) {
|
|
204
|
+
return { attrs, newStyle: s };
|
|
205
|
+
}
|
|
206
|
+
if (!currentStyle || s.fontWeight !== currentStyle.fontWeight)
|
|
207
|
+
attrs["font-weight"] = s.fontWeight;
|
|
208
|
+
if (!currentStyle || s.fontStyle !== currentStyle.fontStyle)
|
|
209
|
+
attrs["font-style"] = s.fontStyle;
|
|
210
|
+
if (!currentStyle || s.fontFamily !== currentStyle.fontFamily)
|
|
211
|
+
attrs["font-family"] = s.fontFamily;
|
|
212
|
+
if (!currentStyle || s.fontSize !== currentStyle.fontSize)
|
|
213
|
+
attrs["font-size"] = fmt(s.fontSize);
|
|
214
|
+
if (!currentStyle || s.color !== currentStyle.color)
|
|
215
|
+
attrs.fill = s.color;
|
|
216
|
+
if (!currentStyle || s.decoration !== currentStyle.decoration) {
|
|
217
|
+
if (s.decoration)
|
|
218
|
+
attrs["text-decoration"] = s.decoration;
|
|
219
|
+
}
|
|
220
|
+
if (!currentStyle || s.letterSpacing !== currentStyle.letterSpacing) {
|
|
221
|
+
if (s.letterSpacing !== undefined && s.letterSpacing !== 0)
|
|
222
|
+
attrs["letter-spacing"] = fmt(s.letterSpacing);
|
|
223
|
+
}
|
|
224
|
+
return { attrs, newStyle: s };
|
|
225
|
+
}
|
|
226
|
+
function buildGlyphPositions(span, _lineX) {
|
|
227
|
+
if (!span.glyphAdvances || span.glyphAdvances.length === 0) {
|
|
228
|
+
return "";
|
|
229
|
+
}
|
|
230
|
+
const ls = span.style.letterSpacing || 0;
|
|
231
|
+
const spanX = span.x;
|
|
232
|
+
let xPos = spanX;
|
|
233
|
+
const positions = [fmt(xPos, 1)];
|
|
234
|
+
for (let i = 0;i < span.glyphAdvances.length - 1; i++) {
|
|
235
|
+
xPos += span.glyphAdvances[i] + ls;
|
|
236
|
+
positions.push(fmt(xPos, 1));
|
|
237
|
+
}
|
|
238
|
+
return positions.join(" ");
|
|
239
|
+
}
|
|
240
|
+
function buildFitAttr(line, opts) {
|
|
241
|
+
if (opts.fit === "text") {
|
|
242
|
+
return { textLength: fmt(line.width), lengthAdjust: "spacing" };
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
function buildSpanFitAttr(span, opts) {
|
|
247
|
+
if (opts.fit === "frag") {
|
|
248
|
+
return { textLength: fmt(span.width) };
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
function el(tag, attrs = {}, children = []) {
|
|
253
|
+
const cleanAttrs = {};
|
|
254
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
255
|
+
if (v !== undefined) {
|
|
256
|
+
cleanAttrs[k] = v;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { type: "element", tag, attrs: cleanAttrs, children };
|
|
260
|
+
}
|
|
261
|
+
function textNode(value) {
|
|
262
|
+
return { type: "text", value };
|
|
263
|
+
}
|
|
264
|
+
function rawNode(value) {
|
|
265
|
+
return { type: "raw", value };
|
|
266
|
+
}
|
|
267
|
+
function serializeSvg(node, indent = 0) {
|
|
268
|
+
const pad = " ".repeat(indent);
|
|
269
|
+
switch (node.type) {
|
|
270
|
+
case "text":
|
|
271
|
+
return escapeXml(node.value);
|
|
272
|
+
case "raw":
|
|
273
|
+
return node.value;
|
|
274
|
+
case "comment":
|
|
275
|
+
return `${pad}<!-- ${node.value} -->
|
|
276
|
+
`;
|
|
277
|
+
case "element": {
|
|
278
|
+
const tag = node.tag;
|
|
279
|
+
const attrsStr = Object.entries(node.attrs).map(([k, v]) => `${k}="${v}"`).join(" ");
|
|
280
|
+
if (node.children.length === 0) {
|
|
281
|
+
return `${pad}<${tag}${attrsStr ? " " + attrsStr : ""} />
|
|
282
|
+
`;
|
|
283
|
+
}
|
|
284
|
+
const allTextChildren = node.children.every((c) => c.type === "text");
|
|
285
|
+
if (allTextChildren) {
|
|
286
|
+
const text = node.children.map((c) => c.value).join("");
|
|
287
|
+
return `${pad}<${tag}${attrsStr ? " " + attrsStr : ""}>${escapeXml(text)}</${tag}>
|
|
288
|
+
`;
|
|
289
|
+
}
|
|
290
|
+
const openTag = `${pad}<${tag}${attrsStr ? " " + attrsStr : ""}>
|
|
291
|
+
`;
|
|
292
|
+
const childrenStr = node.children.map((c) => serializeSvg(c, indent + 1)).join("");
|
|
293
|
+
const closeTag = `${pad}</${tag}>
|
|
294
|
+
`;
|
|
295
|
+
return openTag + childrenStr + closeTag;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
class SvgAstBuilder {
|
|
301
|
+
root;
|
|
302
|
+
currentText = null;
|
|
303
|
+
opts;
|
|
304
|
+
constructor(width, height, opts, viewBox) {
|
|
305
|
+
this.opts = opts;
|
|
306
|
+
const svgAttrs = {
|
|
307
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
308
|
+
width: fmt(width),
|
|
309
|
+
height: fmt(height),
|
|
310
|
+
viewBox: viewBox ? `${fmt(viewBox.x)} ${fmt(viewBox.y)} ${fmt(viewBox.w)} ${fmt(viewBox.h)}` : `0 0 ${fmt(width)} ${fmt(height)}`
|
|
311
|
+
};
|
|
312
|
+
if (opts.className) {
|
|
313
|
+
svgAttrs.class = opts.className;
|
|
314
|
+
}
|
|
315
|
+
this.root = el("svg", svgAttrs);
|
|
316
|
+
}
|
|
317
|
+
openText(line, baseSpan, runId, yOverride, fontSizeOverride) {
|
|
318
|
+
this.closeText();
|
|
319
|
+
let textAttrs;
|
|
320
|
+
if (yOverride !== undefined && fontSizeOverride !== undefined) {
|
|
321
|
+
const s = defaultStyleState(baseSpan);
|
|
322
|
+
const x = line.x;
|
|
323
|
+
const attrs = {
|
|
324
|
+
x: fmt(x),
|
|
325
|
+
y: fmt(yOverride)
|
|
326
|
+
};
|
|
327
|
+
if (this.opts.style === "css") {
|
|
328
|
+
let css = `font-family: '${s.fontFamily}', sans-serif; font-size: ${fmt(fontSizeOverride)}px; fill: ${colorToRGB(s.color)}; font-weight: ${s.fontWeight}`;
|
|
329
|
+
if (s.fontStyle === "italic")
|
|
330
|
+
css += `; font-style: italic`;
|
|
331
|
+
if (this.opts.spacing === "preserve")
|
|
332
|
+
css += "; white-space: pre";
|
|
333
|
+
attrs.style = css;
|
|
334
|
+
} else {
|
|
335
|
+
attrs["font-family"] = s.fontFamily;
|
|
336
|
+
attrs["font-size"] = fmt(fontSizeOverride);
|
|
337
|
+
attrs.fill = s.color;
|
|
338
|
+
attrs["font-weight"] = s.fontWeight;
|
|
339
|
+
if (s.fontStyle === "italic")
|
|
340
|
+
attrs["font-style"] = "italic";
|
|
341
|
+
if (this.opts.spacing === "preserve")
|
|
342
|
+
attrs["xml:space"] = "preserve";
|
|
343
|
+
}
|
|
344
|
+
textAttrs = attrs;
|
|
345
|
+
} else {
|
|
346
|
+
textAttrs = buildTextAttrs(line, baseSpan, this.opts, runId);
|
|
347
|
+
}
|
|
348
|
+
const fitAttrs = buildFitAttr(line, this.opts);
|
|
349
|
+
if (fitAttrs) {
|
|
350
|
+
Object.assign(textAttrs, fitAttrs);
|
|
351
|
+
}
|
|
352
|
+
const textEl = el("text", textAttrs);
|
|
353
|
+
this.root.children.push(textEl);
|
|
354
|
+
this.currentText = textEl;
|
|
355
|
+
}
|
|
356
|
+
addDebug(debugMarkup) {
|
|
357
|
+
if (debugMarkup) {
|
|
358
|
+
this.root.children.push(rawNode(`<!-- debug overlay -->
|
|
359
|
+
${debugMarkup}
|
|
360
|
+
`));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
addTextContent(text) {
|
|
364
|
+
if (this.currentText) {
|
|
365
|
+
this.currentText.children.push(textNode(text));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
addTspan(span, x, style) {
|
|
369
|
+
const { attrs, newStyle } = buildTspanAttrs(span, x, style);
|
|
370
|
+
const fitAttrs = buildSpanFitAttr(span, this.opts);
|
|
371
|
+
if (fitAttrs) {
|
|
372
|
+
Object.assign(attrs, fitAttrs);
|
|
373
|
+
}
|
|
374
|
+
const tspan = el("tspan", attrs, [textNode(span.text)]);
|
|
375
|
+
if (this.currentText) {
|
|
376
|
+
this.currentText.children.push(tspan);
|
|
377
|
+
}
|
|
378
|
+
return newStyle;
|
|
379
|
+
}
|
|
380
|
+
addGlyphTspan(span, lineX) {
|
|
381
|
+
const positions = buildGlyphPositions(span, lineX);
|
|
382
|
+
const attrs = {};
|
|
383
|
+
if (positions) {
|
|
384
|
+
attrs.x = positions;
|
|
385
|
+
}
|
|
386
|
+
const tspan = el("tspan", attrs, [textNode(span.text)]);
|
|
387
|
+
if (this.currentText) {
|
|
388
|
+
this.currentText.children.push(tspan);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
addBackgroundRect(x, y, width, height, color) {
|
|
392
|
+
this.closeText();
|
|
393
|
+
const rect = el("rect", {
|
|
394
|
+
x: fmt(x),
|
|
395
|
+
y: fmt(y),
|
|
396
|
+
width: fmt(width),
|
|
397
|
+
height: fmt(height),
|
|
398
|
+
fill: color
|
|
399
|
+
});
|
|
400
|
+
this.root.children.push(rect);
|
|
401
|
+
}
|
|
402
|
+
addRawLine(lineStr) {
|
|
403
|
+
this.closeText();
|
|
404
|
+
this.root.children.push(rawNode(lineStr));
|
|
405
|
+
}
|
|
406
|
+
closeText() {
|
|
407
|
+
this.currentText = null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function renderDebugToSVG(lines, flags, frameSize, contentSize, columns, leftPad, rightPad) {
|
|
411
|
+
const parts = [];
|
|
412
|
+
const sw = flags.widthBorder ?? 1;
|
|
413
|
+
if ((flags.frameBox || flags.frame) && frameSize) {
|
|
414
|
+
parts.push(` <rect x="0" y="0" width="${fmt(frameSize.width)}" height="${fmt(frameSize.height)}"` + ` fill="none" stroke="rgba(0,140,255,0.8)" stroke-width="${fmt(sw)}" stroke-dasharray="4,3" />`);
|
|
415
|
+
if (flags.labels) {
|
|
416
|
+
parts.push(` <text x="4" y="14" font-size="10" fill="rgba(0,140,255,0.9)" font-family="monospace">frame ${fmt(frameSize.width)}×${fmt(frameSize.height)}</text>`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (flags.contentBox) {
|
|
420
|
+
const bbox = computeBBox(lines);
|
|
421
|
+
parts.push(` <rect x="${fmt(bbox.x)}" y="${fmt(bbox.y)}" width="${fmt(bbox.width)}" height="${fmt(bbox.height)}"` + ` fill="none" stroke="rgba(255,60,140,0.8)" stroke-width="${fmt(sw)}" stroke-dasharray="1,2" />`);
|
|
422
|
+
if (flags.labels) {
|
|
423
|
+
const labelY = bbox.y + bbox.height + 14;
|
|
424
|
+
parts.push(` <text x="${fmt(bbox.x)}" y="${fmt(labelY)}" font-size="10" fill="rgba(255,60,140,0.9)" font-family="monospace">content ${fmt(bbox.width)}×${fmt(bbox.height)}</text>`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (flags.contentBox && frameSize && contentSize && flags.labels) {
|
|
428
|
+
const overflowX = contentSize.width > frameSize.width;
|
|
429
|
+
const overflowY = contentSize.height > frameSize.height;
|
|
430
|
+
if (overflowX || overflowY) {
|
|
431
|
+
parts.push(` <text x="4" y="${fmt(frameSize.height + 14)}" font-size="10" fill="rgba(220,0,0,0.9)" font-family="monospace">⚠ content overflow: ${overflowX ? `Δx=${fmt(contentSize.width - frameSize.width)} ` : ""}${overflowY ? `Δy=${fmt(contentSize.height - frameSize.height)}` : ""}</text>`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (columns && columns.count > 1 && (flags.paragraphBox || flags.columnBox)) {
|
|
435
|
+
const colCount = columns.count;
|
|
436
|
+
const colGap = columns.gap;
|
|
437
|
+
const lp = leftPad ?? 0;
|
|
438
|
+
const totalHorizontalSpace = frameSize?.width ?? (lines.length > 0 ? Math.max(...lines.map((l) => l.x + l.width)) : 0);
|
|
439
|
+
const usableWidth = totalHorizontalSpace - lp - (rightPad ?? 0);
|
|
440
|
+
const colWidth = (usableWidth - (colCount - 1) * colGap) / colCount;
|
|
441
|
+
for (let c = 1;c < colCount; c++) {
|
|
442
|
+
const sepX = lp + c * (colWidth + colGap) - colGap / 2;
|
|
443
|
+
parts.push(` <line x1="${fmt(sepX)}" y1="0" x2="${fmt(sepX)}" y2="${fmt(frameSize?.height ?? 9999)}"` + ` stroke="rgba(100,100,100,0.15)" stroke-width="1" stroke-dasharray="2,2" />`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (flags.paragraphBox) {
|
|
447
|
+
const paraGroups = groupLinesByParagraph(lines);
|
|
448
|
+
const paraColors = [
|
|
449
|
+
"rgba(0,180,80,0.25)",
|
|
450
|
+
"rgba(180,0,80,0.25)",
|
|
451
|
+
"rgba(80,0,180,0.25)",
|
|
452
|
+
"rgba(180,180,0,0.25)"
|
|
453
|
+
];
|
|
454
|
+
for (let i = 0;i < paraGroups.length; i++) {
|
|
455
|
+
const group = paraGroups[i];
|
|
456
|
+
if (group.lines.length === 0)
|
|
457
|
+
continue;
|
|
458
|
+
const colMap = new Map;
|
|
459
|
+
for (const line of group.lines) {
|
|
460
|
+
const ci = line.columnIndex ?? 0;
|
|
461
|
+
const existing = colMap.get(ci);
|
|
462
|
+
const lineTop = line.y;
|
|
463
|
+
const lineBottom = line.y + line.height;
|
|
464
|
+
if (existing) {
|
|
465
|
+
existing.top = Math.min(existing.top, lineTop);
|
|
466
|
+
existing.bottom = Math.max(existing.bottom, lineBottom);
|
|
467
|
+
} else {
|
|
468
|
+
colMap.set(ci, { top: lineTop, bottom: lineBottom });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const lp = leftPad ?? 0;
|
|
472
|
+
const rp = rightPad ?? 0;
|
|
473
|
+
const totalW = frameSize?.width ?? (lines.length > 0 ? Math.max(...lines.map((l) => l.x + l.width)) : 0);
|
|
474
|
+
const usableW = totalW - lp - rp;
|
|
475
|
+
const colCount = columns?.count ?? 1;
|
|
476
|
+
const colGap = columns?.gap ?? 0;
|
|
477
|
+
const colW = (usableW - (colCount - 1) * colGap) / colCount;
|
|
478
|
+
const color = paraColors[i % paraColors.length];
|
|
479
|
+
for (const [ci, rect] of colMap) {
|
|
480
|
+
const colX = lp + ci * (colW + colGap);
|
|
481
|
+
parts.push(` <rect x="${fmt(colX)}" y="${fmt(rect.top)}" width="${fmt(colW)}" height="${fmt(rect.bottom - rect.top)}" fill="none" stroke="${color}" stroke-width="${fmt(sw)}" />`);
|
|
482
|
+
}
|
|
483
|
+
const label = group.tag ? `#${group.pIdx} ${group.tag}` : `#${group.pIdx}`;
|
|
484
|
+
if (flags.labels) {
|
|
485
|
+
const firstColIdx = Math.min(...Array.from(colMap.keys()));
|
|
486
|
+
const firstColX = lp + firstColIdx * (colW + colGap);
|
|
487
|
+
const firstTop = colMap.get(firstColIdx).top;
|
|
488
|
+
parts.push(` <text x="${fmt(firstColX + 4)}" y="${fmt(firstTop - 2)}" font-size="9" fill="rgba(0,0,0,0.6)" font-family="monospace">¶ ${label}</text>`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
for (const line of lines) {
|
|
493
|
+
const { x: bx, y: by, width: bw, height: bh } = line;
|
|
494
|
+
const baselineY = line.y + line.baseline;
|
|
495
|
+
if (flags.lineGap) {
|
|
496
|
+
parts.push(` <rect x="${fmt(bx)}" y="${fmt(by)}" width="${fmt(bw)}" height="${fmt(bh)}" fill="rgba(0,150,255,0.10)" stroke="none" />`);
|
|
497
|
+
}
|
|
498
|
+
if (flags.box) {
|
|
499
|
+
parts.push(` <rect x="${fmt(bx)}" y="${fmt(by)}" width="${fmt(bw)}" height="${fmt(bh)}" fill="none" stroke="rgba(255,100,100,0.5)" stroke-width="${fmt(sw)}" />`);
|
|
500
|
+
}
|
|
501
|
+
if (flags.baseline) {
|
|
502
|
+
parts.push(` <line x1="${fmt(bx)}" y1="${fmt(baselineY)}" x2="${fmt(bx + bw)}" y2="${fmt(baselineY)}" stroke="rgba(100,100,255,0.5)" stroke-width="${fmt(sw)}" />`);
|
|
503
|
+
}
|
|
504
|
+
if (flags.ascentDescent) {
|
|
505
|
+
parts.push(` <line x1="${fmt(bx)}" y1="${fmt(baselineY - line.ascent)}" x2="${fmt(bx + bw)}" y2="${fmt(baselineY - line.ascent)}" stroke="rgba(100,255,100,0.4)" stroke-width="${fmt(sw)}" stroke-dasharray="3,2" />`);
|
|
506
|
+
parts.push(` <line x1="${fmt(bx)}" y1="${fmt(baselineY + line.descent)}" x2="${fmt(bx + bw)}" y2="${fmt(baselineY + line.descent)}" stroke="rgba(100,255,100,0.4)" stroke-width="${fmt(sw)}" stroke-dasharray="3,2" />`);
|
|
507
|
+
}
|
|
508
|
+
if (flags.labels) {
|
|
509
|
+
parts.push(` <text x="${fmt(bx)}" y="${fmt(by - 2)}" font-size="9" fill="rgba(0,0,0,0.55)" font-family="monospace">y=${fmt(by)} x=${fmt(bx)} w=${fmt(bw)} h=${fmt(bh)} bl=${fmt(baselineY)}</text>`);
|
|
510
|
+
}
|
|
511
|
+
if (flags.runs) {
|
|
512
|
+
for (const span of line.spans) {
|
|
513
|
+
if (span.width <= 0)
|
|
514
|
+
continue;
|
|
515
|
+
const rx = line.x + span.x;
|
|
516
|
+
const ry = baselineY - span.fontMetrics.ascent;
|
|
517
|
+
parts.push(` <rect x="${fmt(rx)}" y="${fmt(ry)}" width="${fmt(span.width)}" height="${fmt(span.fontMetrics.ascent + span.fontMetrics.descent)}" fill="none" stroke="rgba(200,100,255,0.4)" stroke-width="${fmt(sw)}" />`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return parts.join(`
|
|
522
|
+
`);
|
|
523
|
+
}
|
|
524
|
+
function getSpanBackgroundAttrs(span, baselineY) {
|
|
525
|
+
if (!span.style.backgroundColor)
|
|
526
|
+
return null;
|
|
527
|
+
const x = span.x;
|
|
528
|
+
const y = baselineY - span.fontMetrics.ascent;
|
|
529
|
+
const w = span.width;
|
|
530
|
+
const h = span.fontMetrics.ascent + span.fontMetrics.descent;
|
|
531
|
+
return { x, y, w, h, fill: span.style.backgroundColor };
|
|
532
|
+
}
|
|
533
|
+
function renderToSVG(lines, options = {}) {
|
|
534
|
+
const opts = resolveOptions(options);
|
|
535
|
+
const { width: svgWidth, height: svgHeight, viewBox, frameWidth, frameHeight } = resolveSize(lines, opts);
|
|
536
|
+
const builder = new SvgAstBuilder(svgWidth, svgHeight, opts, viewBox);
|
|
537
|
+
for (const line of lines) {
|
|
538
|
+
const baselineY = line.y + line.baseline;
|
|
539
|
+
for (const span of line.spans) {
|
|
540
|
+
if (!span.text || !span.style.backgroundColor)
|
|
541
|
+
continue;
|
|
542
|
+
const bg = getSpanBackgroundAttrs(span, baselineY);
|
|
543
|
+
if (bg) {
|
|
544
|
+
const rx = line.x + bg.x;
|
|
545
|
+
builder.addBackgroundRect(rx, bg.y, bg.w, bg.h, bg.fill);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (opts.structure === "glyph") {
|
|
549
|
+
let currentRunIdx = -1;
|
|
550
|
+
for (const span of line.spans) {
|
|
551
|
+
if (!span.text)
|
|
552
|
+
continue;
|
|
553
|
+
const runIdx = span.itemIndex;
|
|
554
|
+
if (runIdx !== currentRunIdx) {
|
|
555
|
+
builder.closeText();
|
|
556
|
+
const runId = span.tag ? `${span.tag}-${runIdx}` : undefined;
|
|
557
|
+
builder.openText(line, span, runId);
|
|
558
|
+
currentRunIdx = runIdx;
|
|
559
|
+
}
|
|
560
|
+
builder.addGlyphTspan(span, line.x);
|
|
561
|
+
}
|
|
562
|
+
builder.closeText();
|
|
563
|
+
} else if (opts.structure === "flat") {
|
|
564
|
+
const groups = [];
|
|
565
|
+
for (const span of line.spans) {
|
|
566
|
+
if (!span.text)
|
|
567
|
+
continue;
|
|
568
|
+
const offset = span.fontMetrics.baselineOffset || 0;
|
|
569
|
+
const targetY = Math.round((line.y + line.baseline + offset) * 100) / 100;
|
|
570
|
+
const sig = styleSignature(span);
|
|
571
|
+
const fontSize = span.fontMetrics.fontSize;
|
|
572
|
+
const last = groups[groups.length - 1];
|
|
573
|
+
if (last && last.targetY === targetY && last.signature === sig) {
|
|
574
|
+
last.spans.push(span);
|
|
575
|
+
} else {
|
|
576
|
+
groups.push({ spans: [span], targetY, signature: sig, fontSize });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const firstTextX = line.spans.find((s) => s.type === "text" || s.type === "marker")?.x ?? 0;
|
|
580
|
+
const fitAttr = buildFitAttr(line, opts);
|
|
581
|
+
for (const group of groups) {
|
|
582
|
+
const s = defaultStyleState(group.spans[0]);
|
|
583
|
+
const text = group.spans.map((sp) => escapeXml(sp.text)).join("");
|
|
584
|
+
const groupX = line.x + (group.spans[0].x - firstTextX);
|
|
585
|
+
const textAttrs = {
|
|
586
|
+
x: fmt(groupX),
|
|
587
|
+
y: fmt(group.targetY),
|
|
588
|
+
"font-family": s.fontFamily,
|
|
589
|
+
"font-size": fmt(group.fontSize),
|
|
590
|
+
fill: s.color,
|
|
591
|
+
"font-weight": s.fontWeight
|
|
592
|
+
};
|
|
593
|
+
if (s.fontStyle === "italic")
|
|
594
|
+
textAttrs["font-style"] = "italic";
|
|
595
|
+
if (s.decoration)
|
|
596
|
+
textAttrs["text-decoration"] = s.decoration;
|
|
597
|
+
if (s.letterSpacing !== undefined && s.letterSpacing !== 0)
|
|
598
|
+
textAttrs["letter-spacing"] = fmt(s.letterSpacing);
|
|
599
|
+
textAttrs["xml:space"] = "preserve";
|
|
600
|
+
if (fitAttr) {
|
|
601
|
+
Object.assign(textAttrs, fitAttr);
|
|
602
|
+
}
|
|
603
|
+
const attrsStr = Object.entries(textAttrs).map(([k, v]) => `${k}="${v}"`).join(" ");
|
|
604
|
+
builder.addRawLine(` <text ${attrsStr}>${text}</text>
|
|
605
|
+
`);
|
|
606
|
+
}
|
|
607
|
+
} else {
|
|
608
|
+
const groups = [];
|
|
609
|
+
for (const span of line.spans) {
|
|
610
|
+
if (!span.text)
|
|
611
|
+
continue;
|
|
612
|
+
const offset = span.fontMetrics.baselineOffset || 0;
|
|
613
|
+
const targetY = Math.round((line.y + line.baseline + offset) * 100) / 100;
|
|
614
|
+
const sig = styleSignature(span);
|
|
615
|
+
const last = groups[groups.length - 1];
|
|
616
|
+
if (last && last.targetY === targetY && last.signature === sig) {
|
|
617
|
+
last.spans.push(span);
|
|
618
|
+
} else {
|
|
619
|
+
groups.push({ targetY, spans: [span], signature: sig });
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
for (const group of groups) {
|
|
623
|
+
const baseSpan = group.spans.find((f) => f.type === "text" && f.text.length > 0) || group.spans[0];
|
|
624
|
+
if (!baseSpan)
|
|
625
|
+
continue;
|
|
626
|
+
const lineBaseY = Math.round((line.y + line.baseline) * 100) / 100;
|
|
627
|
+
const needsOffset = group.targetY !== lineBaseY;
|
|
628
|
+
if (needsOffset) {
|
|
629
|
+
const s = defaultStyleState(baseSpan);
|
|
630
|
+
const firstTextX = line.spans.find((s2) => s2.type === "text")?.x ?? 0;
|
|
631
|
+
const groupX = line.x + (group.spans[0].x - firstTextX);
|
|
632
|
+
const fontSize = baseSpan.fontMetrics.fontSize;
|
|
633
|
+
const textAttrs = {
|
|
634
|
+
x: fmt(groupX),
|
|
635
|
+
y: fmt(group.targetY),
|
|
636
|
+
"font-family": s.fontFamily,
|
|
637
|
+
"font-size": fmt(fontSize),
|
|
638
|
+
fill: s.color,
|
|
639
|
+
"font-weight": s.fontWeight
|
|
640
|
+
};
|
|
641
|
+
if (s.fontStyle === "italic")
|
|
642
|
+
textAttrs["font-style"] = "italic";
|
|
643
|
+
textAttrs["xml:space"] = "preserve";
|
|
644
|
+
const fit = buildFitAttr(line, opts);
|
|
645
|
+
if (fit) {
|
|
646
|
+
Object.assign(textAttrs, fit);
|
|
647
|
+
}
|
|
648
|
+
builder.openText(line, baseSpan, undefined, group.targetY, fontSize);
|
|
649
|
+
} else {
|
|
650
|
+
builder.openText(line, baseSpan);
|
|
651
|
+
}
|
|
652
|
+
let currentStyle = null;
|
|
653
|
+
for (const span of group.spans) {
|
|
654
|
+
if (!span.text)
|
|
655
|
+
continue;
|
|
656
|
+
const x = span.x;
|
|
657
|
+
const shouldRender = span.type !== "space" || opts.spacing === "preserve";
|
|
658
|
+
if (shouldRender) {
|
|
659
|
+
const newStyle = builder.addTspan(span, x, currentStyle);
|
|
660
|
+
if (span.type !== "space") {
|
|
661
|
+
currentStyle = newStyle;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
builder.closeText();
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (opts.debug) {
|
|
670
|
+
const frameSize = frameWidth !== undefined && frameHeight !== undefined ? { width: frameWidth, height: frameHeight } : undefined;
|
|
671
|
+
const contentBbox = computeBBox(lines);
|
|
672
|
+
const contentSize = { width: contentBbox.width, height: contentBbox.height };
|
|
673
|
+
const debugSvg = renderDebugToSVG(lines, opts.debug, frameSize, contentSize, options.columns, options.paddingLeft, 0);
|
|
674
|
+
builder.addDebug(debugSvg);
|
|
675
|
+
}
|
|
676
|
+
return serializeSvg(builder.root);
|
|
677
|
+
}
|
|
678
|
+
function renderParagraphToSVG(lines, paragraphWidth, paragraphHeight, options) {
|
|
679
|
+
return renderToSVG(lines, {
|
|
680
|
+
width: paragraphWidth,
|
|
681
|
+
height: paragraphHeight,
|
|
682
|
+
...options
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
function renderResultToSVG(result, options) {
|
|
686
|
+
return renderToSVG(result.lines, {
|
|
687
|
+
width: result.width,
|
|
688
|
+
height: result.height,
|
|
689
|
+
...options
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
// src/CanvasRenderer.ts
|
|
693
|
+
function fontWeightNumeric2(weight) {
|
|
694
|
+
if (weight === "bold")
|
|
695
|
+
return 700;
|
|
696
|
+
if (weight === "normal")
|
|
697
|
+
return 400;
|
|
698
|
+
if (typeof weight === "number")
|
|
699
|
+
return weight;
|
|
700
|
+
return 400;
|
|
701
|
+
}
|
|
702
|
+
function fontStyleCSS(style) {
|
|
703
|
+
return style === "italic" ? "italic" : "normal";
|
|
704
|
+
}
|
|
705
|
+
function buildFontString(span) {
|
|
706
|
+
const style = fontStyleCSS(span.style.fontStyle);
|
|
707
|
+
const weight = fontWeightNumeric2(span.style.fontWeight);
|
|
708
|
+
const size = span.fontMetrics.fontSize;
|
|
709
|
+
const family = span.style.fontFamily;
|
|
710
|
+
return `${style} ${weight} ${size}px ${family}`;
|
|
711
|
+
}
|
|
712
|
+
function groupSpansByBaseline(line, spans) {
|
|
713
|
+
const groups = [];
|
|
714
|
+
for (const span of spans) {
|
|
715
|
+
if (!span.text)
|
|
716
|
+
continue;
|
|
717
|
+
const offset = span.fontMetrics.baselineOffset || 0;
|
|
718
|
+
const targetY = Math.round((line.y + line.baseline + offset) * 100) / 100;
|
|
719
|
+
const last = groups[groups.length - 1];
|
|
720
|
+
if (last && last.targetY === targetY) {
|
|
721
|
+
last.spans.push(span);
|
|
722
|
+
} else {
|
|
723
|
+
groups.push({ targetY, spans: [span] });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return groups;
|
|
727
|
+
}
|
|
728
|
+
function drawSpanBackground(ctx, line, span) {
|
|
729
|
+
const baselineY = line.y + line.baseline;
|
|
730
|
+
const x = line.x + span.x;
|
|
731
|
+
const y = baselineY - span.fontMetrics.ascent;
|
|
732
|
+
const w = span.width;
|
|
733
|
+
const h = span.fontMetrics.ascent + span.fontMetrics.descent;
|
|
734
|
+
ctx.fillStyle = span.style.backgroundColor || "transparent";
|
|
735
|
+
if (span.style.backgroundColor) {
|
|
736
|
+
ctx.fillRect(x, y, w, h);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
function renderSpan(ctx, line, span, baselineY, preserveSpaces) {
|
|
740
|
+
const x = line.x + span.x;
|
|
741
|
+
drawSpanBackground(ctx, line, span);
|
|
742
|
+
ctx.font = buildFontString(span);
|
|
743
|
+
ctx.fillStyle = span.style.color || "#000000";
|
|
744
|
+
ctx.textBaseline = "alphabetic";
|
|
745
|
+
const ls = span.style.letterSpacing;
|
|
746
|
+
if (ls !== undefined && ls !== 0) {
|
|
747
|
+
ctx.letterSpacing = `${ls}px`;
|
|
748
|
+
} else {
|
|
749
|
+
ctx.letterSpacing = "normal";
|
|
750
|
+
}
|
|
751
|
+
if (preserveSpaces || span.type !== "space") {
|
|
752
|
+
ctx.fillText(span.text, x, baselineY);
|
|
753
|
+
}
|
|
754
|
+
ctx.letterSpacing = "normal";
|
|
755
|
+
if (span.style.underline) {
|
|
756
|
+
const ulY = baselineY + 2;
|
|
757
|
+
ctx.strokeStyle = span.style.color || "#000000";
|
|
758
|
+
ctx.lineWidth = 1;
|
|
759
|
+
ctx.beginPath();
|
|
760
|
+
ctx.moveTo(x, ulY);
|
|
761
|
+
ctx.lineTo(x + span.width, ulY);
|
|
762
|
+
ctx.stroke();
|
|
763
|
+
}
|
|
764
|
+
if (span.style.strikethrough) {
|
|
765
|
+
const stY = baselineY - span.fontMetrics.ascent * 0.4;
|
|
766
|
+
ctx.strokeStyle = span.style.color || "#000000";
|
|
767
|
+
ctx.lineWidth = 1;
|
|
768
|
+
ctx.beginPath();
|
|
769
|
+
ctx.moveTo(x, stY);
|
|
770
|
+
ctx.lineTo(x + span.width, stY);
|
|
771
|
+
ctx.stroke();
|
|
772
|
+
}
|
|
773
|
+
if (span.inlineWidget) {
|
|
774
|
+
const iw = span.inlineWidget;
|
|
775
|
+
const iwY = baselineY - (iw.height || span.fontMetrics.ascent) + (iw.baselineOffset || 0);
|
|
776
|
+
ctx.fillStyle = "#cccccc";
|
|
777
|
+
ctx.fillRect(x, iwY, iw.width, iw.height);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
function renderToCanvas(ctx, lines, options = {}) {
|
|
781
|
+
const sizing = options.sizing ?? "frame";
|
|
782
|
+
const preserveSpaces = options.preserveSpaces ?? false;
|
|
783
|
+
let canvasWidth;
|
|
784
|
+
let canvasHeight;
|
|
785
|
+
if (sizing === "content") {
|
|
786
|
+
const bbox = computeBBox(lines);
|
|
787
|
+
canvasWidth = bbox.width;
|
|
788
|
+
canvasHeight = bbox.height;
|
|
789
|
+
ctx.canvas.width = canvasWidth;
|
|
790
|
+
ctx.canvas.height = canvasHeight;
|
|
791
|
+
} else {
|
|
792
|
+
canvasWidth = ctx.canvas.width;
|
|
793
|
+
canvasHeight = ctx.canvas.height;
|
|
794
|
+
}
|
|
795
|
+
if (options.backgroundColor) {
|
|
796
|
+
ctx.fillStyle = options.backgroundColor;
|
|
797
|
+
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
|
798
|
+
} else {
|
|
799
|
+
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
|
800
|
+
}
|
|
801
|
+
for (const line of lines) {
|
|
802
|
+
const drawableSpans = line.spans.filter((s) => preserveSpaces || s.type !== "space");
|
|
803
|
+
if (drawableSpans.length === 0)
|
|
804
|
+
continue;
|
|
805
|
+
const groups = groupSpansByBaseline(line, drawableSpans);
|
|
806
|
+
for (const group of groups) {
|
|
807
|
+
for (const span of group.spans) {
|
|
808
|
+
renderSpan(ctx, line, span, group.targetY, preserveSpaces);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (options.debug) {
|
|
813
|
+
renderDebugToCanvas(ctx, lines, canvasWidth, canvasHeight, options.debug);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
function renderDebugToCanvas(ctx, lines, _width, _height, flags) {
|
|
817
|
+
ctx.save();
|
|
818
|
+
const sw = flags.widthBorder ?? 1;
|
|
819
|
+
const hasBBox = lines.length > 0;
|
|
820
|
+
const bbox = hasBBox ? computeBBox(lines) : null;
|
|
821
|
+
if ((flags.frameBox || flags.frame) && hasBBox) {
|
|
822
|
+
const first = lines[0];
|
|
823
|
+
const last = lines[lines.length - 1];
|
|
824
|
+
const maxW = Math.max(...lines.map((l) => l.x + l.width));
|
|
825
|
+
const frameX = first.x;
|
|
826
|
+
const frameY = first.y;
|
|
827
|
+
const frameW = maxW - frameX;
|
|
828
|
+
const frameH = last.y + last.height - first.y;
|
|
829
|
+
ctx.strokeStyle = "rgba(0,140,255,0.8)";
|
|
830
|
+
ctx.lineWidth = sw;
|
|
831
|
+
ctx.setLineDash([4, 3]);
|
|
832
|
+
ctx.strokeRect(frameX, frameY, frameW, frameH);
|
|
833
|
+
ctx.setLineDash([]);
|
|
834
|
+
if (flags.labels && bbox) {
|
|
835
|
+
ctx.font = "10px monospace";
|
|
836
|
+
ctx.fillStyle = "rgba(0,140,255,0.9)";
|
|
837
|
+
ctx.textBaseline = "top";
|
|
838
|
+
ctx.fillText(`frame ${frameW.toFixed(0)}×${frameH.toFixed(0)}`, 4, 4);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if (flags.contentBox && bbox) {
|
|
842
|
+
ctx.strokeStyle = "rgba(255,60,140,0.8)";
|
|
843
|
+
ctx.lineWidth = sw;
|
|
844
|
+
ctx.setLineDash([1, 2]);
|
|
845
|
+
ctx.strokeRect(bbox.x, bbox.y, bbox.width, bbox.height);
|
|
846
|
+
ctx.setLineDash([]);
|
|
847
|
+
if (flags.labels) {
|
|
848
|
+
const labelY = bbox.y + bbox.height + 4;
|
|
849
|
+
ctx.font = "10px monospace";
|
|
850
|
+
ctx.fillStyle = "rgba(255,60,140,0.9)";
|
|
851
|
+
ctx.textBaseline = "top";
|
|
852
|
+
ctx.fillText(`content ${bbox.width.toFixed(0)}×${bbox.height.toFixed(0)}`, bbox.x, labelY);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (flags.contentBox && bbox && _width > 0 && _height > 0) {
|
|
856
|
+
const overflowX = bbox.width > _width;
|
|
857
|
+
const overflowY = bbox.height > _height;
|
|
858
|
+
if (overflowX || overflowY) {
|
|
859
|
+
ctx.font = "10px monospace";
|
|
860
|
+
ctx.fillStyle = "rgba(220,0,0,0.9)";
|
|
861
|
+
ctx.textBaseline = "top";
|
|
862
|
+
const warnParts = [];
|
|
863
|
+
if (overflowX)
|
|
864
|
+
warnParts.push(`Δx=${(bbox.width - _width).toFixed(0)}`);
|
|
865
|
+
if (overflowY)
|
|
866
|
+
warnParts.push(`Δy=${(bbox.height - _height).toFixed(0)}`);
|
|
867
|
+
ctx.fillText(`⚠ content overflow: ${warnParts.join(" ")}`, 4, _height + 4);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
for (const line of lines) {
|
|
871
|
+
const bx = line.x;
|
|
872
|
+
const by = line.y;
|
|
873
|
+
const bw = line.width;
|
|
874
|
+
const bh = line.height;
|
|
875
|
+
const baselineY = line.y + line.baseline;
|
|
876
|
+
if (flags.lineGap) {
|
|
877
|
+
ctx.fillStyle = "rgba(0,150,255,0.10)";
|
|
878
|
+
ctx.fillRect(bx, by, bw, bh);
|
|
879
|
+
}
|
|
880
|
+
if (flags.box) {
|
|
881
|
+
ctx.strokeStyle = "rgba(255,100,100,0.5)";
|
|
882
|
+
ctx.lineWidth = sw;
|
|
883
|
+
ctx.strokeRect(bx, by, bw, bh);
|
|
884
|
+
}
|
|
885
|
+
if (flags.baseline) {
|
|
886
|
+
ctx.strokeStyle = "rgba(100,100,255,0.5)";
|
|
887
|
+
ctx.lineWidth = sw;
|
|
888
|
+
ctx.beginPath();
|
|
889
|
+
ctx.moveTo(bx, baselineY);
|
|
890
|
+
ctx.lineTo(bx + bw, baselineY);
|
|
891
|
+
ctx.stroke();
|
|
892
|
+
}
|
|
893
|
+
if (flags.ascentDescent) {
|
|
894
|
+
const ascentY = baselineY - line.ascent;
|
|
895
|
+
const descentY = baselineY + line.descent;
|
|
896
|
+
ctx.strokeStyle = "rgba(100,255,100,0.4)";
|
|
897
|
+
ctx.lineWidth = sw;
|
|
898
|
+
ctx.setLineDash([3, 2]);
|
|
899
|
+
ctx.beginPath();
|
|
900
|
+
ctx.moveTo(bx, ascentY);
|
|
901
|
+
ctx.lineTo(bx + bw, ascentY);
|
|
902
|
+
ctx.stroke();
|
|
903
|
+
ctx.beginPath();
|
|
904
|
+
ctx.moveTo(bx, descentY);
|
|
905
|
+
ctx.lineTo(bx + bw, descentY);
|
|
906
|
+
ctx.stroke();
|
|
907
|
+
ctx.setLineDash([]);
|
|
908
|
+
}
|
|
909
|
+
if (flags.labels) {
|
|
910
|
+
const labelY = by - 2;
|
|
911
|
+
const label = `y=${by.toFixed(1)} x=${bx.toFixed(1)} w=${bw.toFixed(1)} h=${bh.toFixed(1)} bl=${baselineY.toFixed(1)}`;
|
|
912
|
+
ctx.font = "9px monospace";
|
|
913
|
+
ctx.fillStyle = "rgba(0,0,0,0.55)";
|
|
914
|
+
ctx.textBaseline = "bottom";
|
|
915
|
+
ctx.fillText(label, bx, labelY);
|
|
916
|
+
}
|
|
917
|
+
if (flags.runs) {
|
|
918
|
+
for (const span of line.spans) {
|
|
919
|
+
if (span.width <= 0)
|
|
920
|
+
continue;
|
|
921
|
+
const rx = line.x + span.x;
|
|
922
|
+
const ry = baselineY - span.fontMetrics.ascent;
|
|
923
|
+
const rw = span.width;
|
|
924
|
+
const rh = span.fontMetrics.ascent + span.fontMetrics.descent;
|
|
925
|
+
ctx.strokeStyle = "rgba(200,100,255,0.4)";
|
|
926
|
+
ctx.lineWidth = sw;
|
|
927
|
+
ctx.strokeRect(rx, ry, rw, rh);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
ctx.restore();
|
|
932
|
+
}
|
|
933
|
+
function renderSelection(ctx, lines, start, end, color = "rgba(100, 150, 255, 0.3)") {
|
|
934
|
+
ctx.save();
|
|
935
|
+
ctx.fillStyle = color;
|
|
936
|
+
const liMin = Math.min(start.lineIndex, end.lineIndex);
|
|
937
|
+
const liMax = Math.max(start.lineIndex, end.lineIndex);
|
|
938
|
+
for (let li = liMin;li <= liMax; li++) {
|
|
939
|
+
const line = lines[li];
|
|
940
|
+
if (!line)
|
|
941
|
+
continue;
|
|
942
|
+
const baselineY = line.y + line.baseline;
|
|
943
|
+
let xStart;
|
|
944
|
+
let xEnd;
|
|
945
|
+
if (li === liMin && li === liMax) {
|
|
946
|
+
xStart = Math.min(start.x, end.x);
|
|
947
|
+
xEnd = Math.max(start.x, end.x);
|
|
948
|
+
if (xEnd === xStart) {
|
|
949
|
+
const spansEnd = line.x + line.spans[line.spans.length - 1].x + line.spans[line.spans.length - 1].width;
|
|
950
|
+
xEnd = spansEnd;
|
|
951
|
+
}
|
|
952
|
+
} else if (li === liMin) {
|
|
953
|
+
xStart = start.x;
|
|
954
|
+
const lastSpan = line.spans[line.spans.length - 1];
|
|
955
|
+
xEnd = line.x + lastSpan.x + lastSpan.width;
|
|
956
|
+
} else if (li === liMax) {
|
|
957
|
+
xStart = line.x;
|
|
958
|
+
xEnd = end.x;
|
|
959
|
+
} else {
|
|
960
|
+
xStart = line.x;
|
|
961
|
+
const lastSpan = line.spans[line.spans.length - 1];
|
|
962
|
+
xEnd = line.x + lastSpan.x + lastSpan.width;
|
|
963
|
+
}
|
|
964
|
+
const y = baselineY - line.ascent;
|
|
965
|
+
const h = line.ascent + line.descent;
|
|
966
|
+
const w = xEnd - xStart;
|
|
967
|
+
if (w > 0) {
|
|
968
|
+
ctx.fillRect(xStart, y, w, h);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
ctx.restore();
|
|
972
|
+
}
|
|
973
|
+
function renderCursor(ctx, lines, pos, options = {}) {
|
|
974
|
+
const color = options.color ?? "#000";
|
|
975
|
+
const width = options.width ?? 1;
|
|
976
|
+
ctx.save();
|
|
977
|
+
let cursorHeight;
|
|
978
|
+
if (options.height !== undefined) {
|
|
979
|
+
cursorHeight = options.height;
|
|
980
|
+
} else {
|
|
981
|
+
const line = lines[pos.lineIndex];
|
|
982
|
+
cursorHeight = line.ascent + line.descent;
|
|
983
|
+
}
|
|
984
|
+
const topY = pos.y - (options.height !== undefined ? options.height : lines[pos.lineIndex].ascent);
|
|
985
|
+
ctx.strokeStyle = color;
|
|
986
|
+
ctx.lineWidth = width;
|
|
987
|
+
ctx.beginPath();
|
|
988
|
+
ctx.moveTo(pos.x, topY);
|
|
989
|
+
ctx.lineTo(pos.x, topY + cursorHeight);
|
|
990
|
+
ctx.stroke();
|
|
991
|
+
ctx.restore();
|
|
992
|
+
}
|
|
993
|
+
// src/interactive.ts
|
|
994
|
+
function getCharAdvances(span) {
|
|
995
|
+
if (span.glyphAdvances && span.glyphAdvances.length > 0) {
|
|
996
|
+
return Array.from(span.glyphAdvances);
|
|
997
|
+
}
|
|
998
|
+
const len = span.text.length;
|
|
999
|
+
if (len === 0)
|
|
1000
|
+
return [];
|
|
1001
|
+
const avgWidth = span.width / len;
|
|
1002
|
+
return new Array(len).fill(avgWidth);
|
|
1003
|
+
}
|
|
1004
|
+
function buildCharSegments(lines) {
|
|
1005
|
+
const segments = [];
|
|
1006
|
+
for (let li = 0;li < lines.length; li++) {
|
|
1007
|
+
const line = lines[li];
|
|
1008
|
+
const baselineY = line.y + line.baseline;
|
|
1009
|
+
for (let si = 0;si < line.spans.length; si++) {
|
|
1010
|
+
const span = line.spans[si];
|
|
1011
|
+
const advances = getCharAdvances(span);
|
|
1012
|
+
const text = span.text;
|
|
1013
|
+
let charX = line.x + span.x;
|
|
1014
|
+
for (let ci = 0;ci < text.length; ci++) {
|
|
1015
|
+
const advance = ci < advances.length ? advances[ci] : 0;
|
|
1016
|
+
segments.push({
|
|
1017
|
+
lineIndex: li,
|
|
1018
|
+
spanIndex: si,
|
|
1019
|
+
charIndex: ci,
|
|
1020
|
+
pIdx: span.pIdx,
|
|
1021
|
+
x: charX,
|
|
1022
|
+
y: baselineY,
|
|
1023
|
+
width: advance,
|
|
1024
|
+
char: text[ci],
|
|
1025
|
+
line,
|
|
1026
|
+
span
|
|
1027
|
+
});
|
|
1028
|
+
charX += advance;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return segments;
|
|
1033
|
+
}
|
|
1034
|
+
function charAtPoint(lines, px, py) {
|
|
1035
|
+
if (lines.length === 0)
|
|
1036
|
+
return null;
|
|
1037
|
+
let nearestLineIdx = 0;
|
|
1038
|
+
let minYDist = Infinity;
|
|
1039
|
+
for (let li = 0;li < lines.length; li++) {
|
|
1040
|
+
const line2 = lines[li];
|
|
1041
|
+
const baselineY2 = line2.y + line2.baseline;
|
|
1042
|
+
const yDist = Math.abs(py - baselineY2);
|
|
1043
|
+
const inLineBox = py >= line2.y && py <= line2.y + line2.height;
|
|
1044
|
+
const distance = inLineBox ? 0 : yDist;
|
|
1045
|
+
if (distance < minYDist) {
|
|
1046
|
+
minYDist = distance;
|
|
1047
|
+
nearestLineIdx = li;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
const line = lines[nearestLineIdx];
|
|
1051
|
+
const baselineY = line.y + line.baseline;
|
|
1052
|
+
let nearestSpanIdx = 0;
|
|
1053
|
+
let nearestCharIdx = 0;
|
|
1054
|
+
let nearestX = line.x;
|
|
1055
|
+
let nearestWidth = 0;
|
|
1056
|
+
let nearestSpan = line.spans[0];
|
|
1057
|
+
let minXDist = Infinity;
|
|
1058
|
+
for (let si = 0;si < line.spans.length; si++) {
|
|
1059
|
+
const span = line.spans[si];
|
|
1060
|
+
const advances = getCharAdvances(span);
|
|
1061
|
+
const text = span.text;
|
|
1062
|
+
let charX = line.x + span.x;
|
|
1063
|
+
for (let ci = 0;ci < text.length; ci++) {
|
|
1064
|
+
const advance = ci < advances.length ? advances[ci] : 0;
|
|
1065
|
+
const charCenter = charX + advance / 2;
|
|
1066
|
+
const xDist = Math.abs(px - charCenter);
|
|
1067
|
+
if (xDist < minXDist) {
|
|
1068
|
+
minXDist = xDist;
|
|
1069
|
+
nearestSpanIdx = si;
|
|
1070
|
+
nearestCharIdx = ci;
|
|
1071
|
+
nearestX = charX;
|
|
1072
|
+
nearestWidth = advance;
|
|
1073
|
+
nearestSpan = span;
|
|
1074
|
+
}
|
|
1075
|
+
charX += advance;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return {
|
|
1079
|
+
lineIndex: nearestLineIdx,
|
|
1080
|
+
spanIndex: nearestSpanIdx,
|
|
1081
|
+
charIndex: nearestCharIdx,
|
|
1082
|
+
pIdx: nearestSpan.pIdx,
|
|
1083
|
+
x: nearestX,
|
|
1084
|
+
y: baselineY,
|
|
1085
|
+
width: nearestWidth,
|
|
1086
|
+
line,
|
|
1087
|
+
span: nearestSpan
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
function charIndexToPos(lines, charIndex) {
|
|
1091
|
+
const segments = buildCharSegments(lines);
|
|
1092
|
+
let globalIdx = 0;
|
|
1093
|
+
for (const seg of segments) {
|
|
1094
|
+
if (globalIdx === charIndex) {
|
|
1095
|
+
return {
|
|
1096
|
+
lineIndex: seg.lineIndex,
|
|
1097
|
+
spanIndex: seg.spanIndex,
|
|
1098
|
+
charIndex: seg.charIndex,
|
|
1099
|
+
pIdx: seg.pIdx,
|
|
1100
|
+
x: seg.x,
|
|
1101
|
+
y: seg.y,
|
|
1102
|
+
width: seg.width,
|
|
1103
|
+
line: seg.line,
|
|
1104
|
+
span: seg.span
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
globalIdx++;
|
|
1108
|
+
}
|
|
1109
|
+
if (charIndex >= globalIdx && segments.length > 0) {
|
|
1110
|
+
const last = segments[segments.length - 1];
|
|
1111
|
+
return {
|
|
1112
|
+
lineIndex: last.lineIndex,
|
|
1113
|
+
spanIndex: last.spanIndex,
|
|
1114
|
+
charIndex: last.charIndex + 1,
|
|
1115
|
+
pIdx: last.pIdx,
|
|
1116
|
+
x: last.x + last.width,
|
|
1117
|
+
y: last.y,
|
|
1118
|
+
width: 0,
|
|
1119
|
+
line: last.line,
|
|
1120
|
+
span: last.span
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
1125
|
+
function posToCharIndex(lines, pos) {
|
|
1126
|
+
let index = 0;
|
|
1127
|
+
for (let li = 0;li < pos.lineIndex; li++) {
|
|
1128
|
+
const line2 = lines[li];
|
|
1129
|
+
for (const span of line2.spans) {
|
|
1130
|
+
index += span.text.length;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
const line = lines[pos.lineIndex];
|
|
1134
|
+
for (let si = 0;si < pos.spanIndex; si++) {
|
|
1135
|
+
index += line.spans[si].text.length;
|
|
1136
|
+
}
|
|
1137
|
+
index += pos.charIndex;
|
|
1138
|
+
return index;
|
|
1139
|
+
}
|
|
1140
|
+
export {
|
|
1141
|
+
renderToSVG,
|
|
1142
|
+
renderToCanvas,
|
|
1143
|
+
renderSelection,
|
|
1144
|
+
renderResultToSVG,
|
|
1145
|
+
renderParagraphToSVG,
|
|
1146
|
+
renderDebugToCanvas,
|
|
1147
|
+
renderCursor,
|
|
1148
|
+
posToCharIndex,
|
|
1149
|
+
charIndexToPos,
|
|
1150
|
+
charAtPoint
|
|
1151
|
+
};
|