@vyaz/renderer 0.0.1 → 0.0.2
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.js +574 -0
- package/package.json +4 -1
- package/src/CanvasRenderer.ts +35 -35
- package/src/SVGRenderer.ts +135 -89
- package/src/index.ts +1 -1
- package/src/utils.ts +3 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/utils.ts
|
|
3
|
+
function computeBBox(lines) {
|
|
4
|
+
if (lines.length === 0)
|
|
5
|
+
return { width: 0, height: 0 };
|
|
6
|
+
const width = Math.max(...lines.map((l) => l.x + l.width));
|
|
7
|
+
const height = lines[lines.length - 1].y + lines[lines.length - 1].height;
|
|
8
|
+
return { width, height };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/SVGRenderer.ts
|
|
12
|
+
var PRESETS = {
|
|
13
|
+
flat: { structure: "flat", spacing: "preserve", defaultFit: "none" },
|
|
14
|
+
browser: { structure: "expanded", spacing: "preserve", defaultFit: "none" },
|
|
15
|
+
preserve: { structure: "expanded", spacing: "preserve", defaultFit: "none" },
|
|
16
|
+
glyph: { structure: "glyph", spacing: "preserve", defaultFit: "none" }
|
|
17
|
+
};
|
|
18
|
+
function escapeXml(text) {
|
|
19
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
20
|
+
}
|
|
21
|
+
function fontWeightNumeric(weight) {
|
|
22
|
+
if (weight === "bold")
|
|
23
|
+
return 700;
|
|
24
|
+
if (weight === "normal")
|
|
25
|
+
return 400;
|
|
26
|
+
if (typeof weight === "number")
|
|
27
|
+
return weight;
|
|
28
|
+
return 400;
|
|
29
|
+
}
|
|
30
|
+
function colorToRGB(color) {
|
|
31
|
+
if (!color)
|
|
32
|
+
return "rgb(0, 0, 0)";
|
|
33
|
+
let hex = color;
|
|
34
|
+
if (hex.length === 4 && hex[0] === "#") {
|
|
35
|
+
hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
|
|
36
|
+
}
|
|
37
|
+
if (hex.length === 7 && hex[0] === "#") {
|
|
38
|
+
return `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
|
|
39
|
+
}
|
|
40
|
+
return "rgb(0, 0, 0)";
|
|
41
|
+
}
|
|
42
|
+
function resolveOptions(opts) {
|
|
43
|
+
let structure;
|
|
44
|
+
let spacing;
|
|
45
|
+
let defaultFit;
|
|
46
|
+
if (opts.preset) {
|
|
47
|
+
const preset = PRESETS[opts.preset];
|
|
48
|
+
if (!preset) {
|
|
49
|
+
console.warn(`SVGRenderer: unknown preset "${opts.preset}", falling back to browser`);
|
|
50
|
+
structure = "expanded";
|
|
51
|
+
spacing = "browser";
|
|
52
|
+
defaultFit = "none";
|
|
53
|
+
} else {
|
|
54
|
+
structure = preset.structure;
|
|
55
|
+
spacing = preset.spacing;
|
|
56
|
+
defaultFit = preset.defaultFit;
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
structure = "expanded";
|
|
60
|
+
spacing = "browser";
|
|
61
|
+
defaultFit = "none";
|
|
62
|
+
}
|
|
63
|
+
const style = opts.style ?? "xml";
|
|
64
|
+
let fit = opts.fit ?? defaultFit;
|
|
65
|
+
const sizing = opts.sizing ?? "frame";
|
|
66
|
+
if (structure === "glyph" && fit !== "none") {
|
|
67
|
+
console.warn(`SVGRenderer: fit="${fit}" is ignored when structure="glyph"`);
|
|
68
|
+
fit = "none";
|
|
69
|
+
}
|
|
70
|
+
if (structure === "flat" && fit === "frag") {
|
|
71
|
+
console.warn(`SVGRenderer: fit="frag" downgraded to "text" when structure="flat"`);
|
|
72
|
+
fit = "text";
|
|
73
|
+
}
|
|
74
|
+
return { structure, spacing, style, fit, sizing, width: opts.width, height: opts.height, className: opts.className, debug: opts.debug };
|
|
75
|
+
}
|
|
76
|
+
function defaultStyleState(span) {
|
|
77
|
+
return {
|
|
78
|
+
fontFamily: span.style.fontFamily || "Arial",
|
|
79
|
+
fontSize: span.fontMetrics.fontSize || 16,
|
|
80
|
+
fontWeight: fontWeightNumeric(span.style.fontWeight),
|
|
81
|
+
color: span.style.color || "#000000",
|
|
82
|
+
fontStyle: span.style.fontStyle || "normal",
|
|
83
|
+
decoration: span.style.underline ? "underline" : span.style.strikethrough ? "line-through" : ""
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function equalStyle(a, b) {
|
|
87
|
+
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;
|
|
88
|
+
}
|
|
89
|
+
function cssStyleString(s) {
|
|
90
|
+
const parts = [];
|
|
91
|
+
parts.push(`font-family: '${s.fontFamily}', sans-serif`);
|
|
92
|
+
parts.push(`font-size: ${s.fontSize}px`);
|
|
93
|
+
parts.push(`fill: ${colorToRGB(s.color)}`);
|
|
94
|
+
if (s.fontWeight !== 400)
|
|
95
|
+
parts.push(`font-weight: ${s.fontWeight}`);
|
|
96
|
+
if (s.fontStyle === "italic")
|
|
97
|
+
parts.push(`font-style: italic`);
|
|
98
|
+
if (s.decoration)
|
|
99
|
+
parts.push(`text-decoration: ${s.decoration}`);
|
|
100
|
+
return parts.join("; ");
|
|
101
|
+
}
|
|
102
|
+
function xmlStyleAttrs(s) {
|
|
103
|
+
let attrs = `font-family="${s.fontFamily}" font-size="${s.fontSize}" fill="${s.color}" font-weight="${s.fontWeight}"`;
|
|
104
|
+
if (s.fontStyle === "italic")
|
|
105
|
+
attrs += ' font-style="italic"';
|
|
106
|
+
if (s.decoration)
|
|
107
|
+
attrs += ` text-decoration="${s.decoration}"`;
|
|
108
|
+
return attrs;
|
|
109
|
+
}
|
|
110
|
+
function buildTextAttrs(line, span, opts, runId) {
|
|
111
|
+
const x = line.x;
|
|
112
|
+
const y = line.y + line.baseline;
|
|
113
|
+
const s = defaultStyleState(span);
|
|
114
|
+
let attrs = ` x="${x}" y="${y}"`;
|
|
115
|
+
if (runId)
|
|
116
|
+
attrs += ` id="${runId}"`;
|
|
117
|
+
if (opts.style === "css") {
|
|
118
|
+
let css = cssStyleString(s);
|
|
119
|
+
if (opts.spacing === "preserve")
|
|
120
|
+
css += "; white-space: pre";
|
|
121
|
+
attrs += ` style="${css}"`;
|
|
122
|
+
} else {
|
|
123
|
+
attrs += ` ${xmlStyleAttrs(s)}`;
|
|
124
|
+
if (opts.spacing === "preserve")
|
|
125
|
+
attrs += ' xml:space="preserve"';
|
|
126
|
+
}
|
|
127
|
+
if (opts.structure === "flat") {
|
|
128
|
+
const anchor = line.alignment === "center" ? "middle" : line.alignment === "right" ? "end" : "start";
|
|
129
|
+
if (anchor !== "start")
|
|
130
|
+
attrs += ` text-anchor="${anchor}"`;
|
|
131
|
+
}
|
|
132
|
+
return attrs;
|
|
133
|
+
}
|
|
134
|
+
function buildTspanAttrs(span, x, currentStyle) {
|
|
135
|
+
const s = defaultStyleState(span);
|
|
136
|
+
let attrs = ` x="${x}"`;
|
|
137
|
+
if (currentStyle && equalStyle(s, currentStyle)) {
|
|
138
|
+
return { attrs, newStyle: s };
|
|
139
|
+
}
|
|
140
|
+
if (!currentStyle || s.fontWeight !== currentStyle.fontWeight)
|
|
141
|
+
attrs += ` font-weight="${s.fontWeight}"`;
|
|
142
|
+
if (!currentStyle || s.fontStyle !== currentStyle.fontStyle)
|
|
143
|
+
attrs += ` font-style="${s.fontStyle}"`;
|
|
144
|
+
if (!currentStyle || s.fontFamily !== currentStyle.fontFamily)
|
|
145
|
+
attrs += ` font-family="${s.fontFamily}"`;
|
|
146
|
+
if (!currentStyle || s.fontSize !== currentStyle.fontSize)
|
|
147
|
+
attrs += ` font-size="${s.fontSize}"`;
|
|
148
|
+
if (!currentStyle || s.color !== currentStyle.color)
|
|
149
|
+
attrs += ` fill="${s.color}"`;
|
|
150
|
+
if (!currentStyle || s.decoration !== currentStyle.decoration) {
|
|
151
|
+
if (s.decoration)
|
|
152
|
+
attrs += ` text-decoration="${s.decoration}"`;
|
|
153
|
+
}
|
|
154
|
+
return { attrs, newStyle: s };
|
|
155
|
+
}
|
|
156
|
+
function buildGlyphPositions(span, _lineX) {
|
|
157
|
+
if (!span.glyphAdvances || span.glyphAdvances.length === 0) {
|
|
158
|
+
return "";
|
|
159
|
+
}
|
|
160
|
+
const spanX = span.x;
|
|
161
|
+
let xPos = spanX;
|
|
162
|
+
const positions = [xPos.toFixed(1)];
|
|
163
|
+
for (let i = 0;i < span.glyphAdvances.length - 1; i++) {
|
|
164
|
+
xPos += span.glyphAdvances[i];
|
|
165
|
+
positions.push(xPos.toFixed(1));
|
|
166
|
+
}
|
|
167
|
+
return positions.join(" ");
|
|
168
|
+
}
|
|
169
|
+
function buildFitAttr(line, opts) {
|
|
170
|
+
if (opts.fit === "text") {
|
|
171
|
+
return ` textLength="${line.width}" lengthAdjust="spacing"`;
|
|
172
|
+
}
|
|
173
|
+
return "";
|
|
174
|
+
}
|
|
175
|
+
function buildSpanFitAttr(span, opts) {
|
|
176
|
+
if (opts.fit === "frag") {
|
|
177
|
+
return ` textLength="${span.width}"`;
|
|
178
|
+
}
|
|
179
|
+
return "";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
class SvgBuilder {
|
|
183
|
+
parts = [];
|
|
184
|
+
opts;
|
|
185
|
+
constructor(width, height, opts) {
|
|
186
|
+
this.opts = opts;
|
|
187
|
+
const className = opts.className ? ` class="${escapeXml(opts.className)}"` : "";
|
|
188
|
+
this.parts.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"${className}>
|
|
189
|
+
`);
|
|
190
|
+
if (opts.className) {
|
|
191
|
+
this.parts[0] = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" class="${escapeXml(opts.className)}">
|
|
192
|
+
`;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
addText(line, baseSpan, runId, yOverride, fontSizeOverride) {
|
|
196
|
+
let attrs;
|
|
197
|
+
if (yOverride !== undefined && fontSizeOverride !== undefined) {
|
|
198
|
+
const s = defaultStyleState(baseSpan);
|
|
199
|
+
const x = line.x;
|
|
200
|
+
attrs = ` x="${x}" y="${yOverride}"`;
|
|
201
|
+
if (this.opts.style === "css") {
|
|
202
|
+
let css = cssStyleString(s);
|
|
203
|
+
if (this.opts.spacing === "preserve")
|
|
204
|
+
css += "; white-space: pre";
|
|
205
|
+
attrs += ` style="${css}"`;
|
|
206
|
+
} else {
|
|
207
|
+
let xml = ` font-family="${s.fontFamily}" font-size="${fontSizeOverride}" fill="${s.color}" font-weight="${s.fontWeight}"`;
|
|
208
|
+
if (s.fontStyle === "italic")
|
|
209
|
+
xml += ' font-style="italic"';
|
|
210
|
+
if (s.decoration)
|
|
211
|
+
xml += ` text-decoration="${s.decoration}"`;
|
|
212
|
+
if (this.opts.spacing === "preserve")
|
|
213
|
+
xml += ' xml:space="preserve"';
|
|
214
|
+
attrs += xml;
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
attrs = buildTextAttrs(line, baseSpan, this.opts, runId);
|
|
218
|
+
}
|
|
219
|
+
const fit = buildFitAttr(line, this.opts);
|
|
220
|
+
this.parts.push(` <text${attrs}${fit}>
|
|
221
|
+
`);
|
|
222
|
+
}
|
|
223
|
+
addFlatSpan(text) {
|
|
224
|
+
this.parts.push(`${escapeXml(text)}`);
|
|
225
|
+
}
|
|
226
|
+
pushLine(line) {
|
|
227
|
+
this.parts.push(line);
|
|
228
|
+
}
|
|
229
|
+
closeText() {
|
|
230
|
+
this.parts.push(`</text>
|
|
231
|
+
`);
|
|
232
|
+
}
|
|
233
|
+
addExpandedSpan(span, x, style) {
|
|
234
|
+
const { attrs, newStyle } = buildTspanAttrs(span, x, style);
|
|
235
|
+
const fit = buildSpanFitAttr(span, this.opts);
|
|
236
|
+
this.parts.push(` <tspan${attrs}${fit}>${escapeXml(span.text)}</tspan>
|
|
237
|
+
`);
|
|
238
|
+
return newStyle;
|
|
239
|
+
}
|
|
240
|
+
addGlyphSpan(span, lineX) {
|
|
241
|
+
const positions = buildGlyphPositions(span, lineX);
|
|
242
|
+
if (positions) {
|
|
243
|
+
this.parts.push(` <tspan x="${positions}">${escapeXml(span.text)}</tspan>
|
|
244
|
+
`);
|
|
245
|
+
} else {
|
|
246
|
+
this.parts.push(` <tspan>${escapeXml(span.text)}</tspan>
|
|
247
|
+
`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
addDebug(debugOverlay) {
|
|
251
|
+
if (debugOverlay) {
|
|
252
|
+
this.parts.push(`<!-- debug overlay -->
|
|
253
|
+
${debugOverlay}
|
|
254
|
+
`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
build() {
|
|
258
|
+
this.parts.push(`</svg>
|
|
259
|
+
`);
|
|
260
|
+
return this.parts.join("");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function renderDebugToSVG(lines, width, height, flags) {
|
|
264
|
+
const parts = [];
|
|
265
|
+
if (flags.frame && lines.length > 0) {
|
|
266
|
+
const first = lines[0];
|
|
267
|
+
const last = lines[lines.length - 1];
|
|
268
|
+
const maxW = Math.max(...lines.map((l) => l.x + l.width));
|
|
269
|
+
parts.push(` <rect x="${first.x}" y="${first.y}" width="${maxW - first.x}" height="${last.y + last.height - first.y}"` + ` fill="none" stroke="rgba(255,200,0,0.6)" stroke-width="1" stroke-dasharray="6,2" />`);
|
|
270
|
+
}
|
|
271
|
+
for (const line of lines) {
|
|
272
|
+
const { x: bx, y: by, width: bw, height: bh } = line;
|
|
273
|
+
const baselineY = line.y + line.baseline;
|
|
274
|
+
if (flags.lineGap) {
|
|
275
|
+
parts.push(` <rect x="${bx}" y="${by}" width="${bw}" height="${bh}" fill="rgba(0,150,255,0.10)" stroke="none" />`);
|
|
276
|
+
}
|
|
277
|
+
if (flags.box) {
|
|
278
|
+
parts.push(` <rect x="${bx}" y="${by}" width="${bw}" height="${bh}" fill="none" stroke="rgba(255,100,100,0.5)" stroke-width="1" />`);
|
|
279
|
+
}
|
|
280
|
+
if (flags.baseline) {
|
|
281
|
+
parts.push(` <line x1="${bx}" y1="${baselineY}" x2="${bx + bw}" y2="${baselineY}" stroke="rgba(100,100,255,0.5)" stroke-width="1" />`);
|
|
282
|
+
}
|
|
283
|
+
if (flags.ascentDescent) {
|
|
284
|
+
parts.push(` <line x1="${bx}" y1="${baselineY - line.ascent}" x2="${bx + bw}" y2="${baselineY - line.ascent}" stroke="rgba(100,255,100,0.4)" stroke-width="0.5" stroke-dasharray="3,2" />`);
|
|
285
|
+
parts.push(` <line x1="${bx}" y1="${baselineY + line.descent}" x2="${bx + bw}" y2="${baselineY + line.descent}" stroke="rgba(100,255,100,0.4)" stroke-width="0.5" stroke-dasharray="3,2" />`);
|
|
286
|
+
}
|
|
287
|
+
if (flags.labels) {
|
|
288
|
+
parts.push(` <text x="${bx}" y="${by - 2}" font-size="9" fill="rgba(0,0,0,0.55)" font-family="monospace">y=${by.toFixed(1)} x=${bx.toFixed(1)} w=${bw.toFixed(1)} h=${bh.toFixed(1)} bl=${baselineY.toFixed(1)}</text>`);
|
|
289
|
+
}
|
|
290
|
+
if (flags.runs) {
|
|
291
|
+
for (const span of line.spans) {
|
|
292
|
+
if (span.width <= 0)
|
|
293
|
+
continue;
|
|
294
|
+
const rx = line.x + span.x;
|
|
295
|
+
const ry = baselineY - span.fontMetrics.ascent;
|
|
296
|
+
parts.push(` <rect x="${rx}" y="${ry}" width="${span.width}" height="${span.fontMetrics.ascent + span.fontMetrics.descent}" fill="none" stroke="rgba(200,100,255,0.4)" stroke-width="0.5" />`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return parts.join(`
|
|
301
|
+
`);
|
|
302
|
+
}
|
|
303
|
+
function renderToSVG(lines, options = {}) {
|
|
304
|
+
const opts = resolveOptions(options);
|
|
305
|
+
let svgWidth;
|
|
306
|
+
let svgHeight;
|
|
307
|
+
if (opts.sizing === "content") {
|
|
308
|
+
const bbox = computeBBox(lines);
|
|
309
|
+
svgWidth = bbox.width;
|
|
310
|
+
svgHeight = bbox.height;
|
|
311
|
+
} else {
|
|
312
|
+
if (opts.width === undefined || opts.height === undefined) {
|
|
313
|
+
throw new Error(`renderToSVG: sizing="frame" requires explicit width and height. ` + `Got width=${opts.width}, height=${opts.height}. ` + `Use renderResultToSVG(result, options) to auto-pass dimensions.`);
|
|
314
|
+
}
|
|
315
|
+
svgWidth = opts.width;
|
|
316
|
+
svgHeight = opts.height;
|
|
317
|
+
}
|
|
318
|
+
const builder = new SvgBuilder(svgWidth, svgHeight, opts);
|
|
319
|
+
for (const line of lines) {
|
|
320
|
+
if (opts.structure === "glyph") {
|
|
321
|
+
let currentRunIdx = -1;
|
|
322
|
+
for (const span of line.spans) {
|
|
323
|
+
if (!span.text)
|
|
324
|
+
continue;
|
|
325
|
+
const runIdx = span.itemIndex;
|
|
326
|
+
if (runIdx !== currentRunIdx) {
|
|
327
|
+
if (currentRunIdx !== -1) {
|
|
328
|
+
builder.closeText();
|
|
329
|
+
}
|
|
330
|
+
const runId = span.paragraphId ? `${span.paragraphId}-${runIdx}` : undefined;
|
|
331
|
+
builder.addText(line, span, runId);
|
|
332
|
+
currentRunIdx = runIdx;
|
|
333
|
+
}
|
|
334
|
+
builder.addGlyphSpan(span, line.x);
|
|
335
|
+
}
|
|
336
|
+
if (currentRunIdx !== -1) {
|
|
337
|
+
builder.closeText();
|
|
338
|
+
}
|
|
339
|
+
} else if (opts.structure === "flat") {
|
|
340
|
+
const groups = [];
|
|
341
|
+
for (const span of line.spans) {
|
|
342
|
+
if (!span.text)
|
|
343
|
+
continue;
|
|
344
|
+
const offset = span.fontMetrics.baselineOffset || 0;
|
|
345
|
+
const targetY = Math.round((line.y + line.baseline + offset) * 100) / 100;
|
|
346
|
+
const fontSize = span.fontMetrics.fontSize;
|
|
347
|
+
const last = groups[groups.length - 1];
|
|
348
|
+
if (last && last.targetY === targetY && last.fontSize === fontSize) {
|
|
349
|
+
last.spans.push(span);
|
|
350
|
+
} else {
|
|
351
|
+
groups.push({ spans: [span], targetY, fontSize });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
for (const group of groups) {
|
|
355
|
+
const s = defaultStyleState(group.spans[0]);
|
|
356
|
+
const text = group.spans.map((sp) => escapeXml(sp.text)).join("");
|
|
357
|
+
let attrs = ` x="${line.x}" y="${group.targetY}" font-family="${s.fontFamily}" font-size="${group.fontSize}" fill="${s.color}" font-weight="${s.fontWeight}"`;
|
|
358
|
+
if (s.fontStyle === "italic")
|
|
359
|
+
attrs += ' font-style="italic"';
|
|
360
|
+
if (s.decoration)
|
|
361
|
+
attrs += ` text-decoration="${s.decoration}"`;
|
|
362
|
+
attrs += ' xml:space="preserve"';
|
|
363
|
+
if (line.alignment === "center")
|
|
364
|
+
attrs += ' text-anchor="middle"';
|
|
365
|
+
else if (line.alignment === "right")
|
|
366
|
+
attrs += ' text-anchor="end"';
|
|
367
|
+
builder.pushLine(` <text${attrs}>${text}</text>
|
|
368
|
+
`);
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
const baseSpan = line.spans.find((f) => f.type === "text" && f.text.length > 0) || line.spans[0];
|
|
372
|
+
if (!baseSpan)
|
|
373
|
+
continue;
|
|
374
|
+
builder.addText(line, baseSpan);
|
|
375
|
+
let currentStyle = null;
|
|
376
|
+
for (const span of line.spans) {
|
|
377
|
+
if (!span.text)
|
|
378
|
+
continue;
|
|
379
|
+
const x = span.x;
|
|
380
|
+
const shouldRender = span.type !== "space" || opts.spacing === "preserve";
|
|
381
|
+
if (shouldRender) {
|
|
382
|
+
const newStyle = builder.addExpandedSpan(span, x, currentStyle);
|
|
383
|
+
if (span.type !== "space") {
|
|
384
|
+
currentStyle = newStyle;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
builder.closeText();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (opts.debug) {
|
|
392
|
+
const debugSvg = renderDebugToSVG(lines, svgWidth, svgHeight, opts.debug);
|
|
393
|
+
builder.addDebug(debugSvg);
|
|
394
|
+
}
|
|
395
|
+
return builder.build();
|
|
396
|
+
}
|
|
397
|
+
function renderParagraphToSVG(lines, paragraphWidth, paragraphHeight, options) {
|
|
398
|
+
return renderToSVG(lines, {
|
|
399
|
+
width: paragraphWidth,
|
|
400
|
+
height: paragraphHeight,
|
|
401
|
+
...options
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
function renderResultToSVG(result, options) {
|
|
405
|
+
return renderToSVG(result.lines, {
|
|
406
|
+
width: result.width,
|
|
407
|
+
height: result.height,
|
|
408
|
+
...options
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
// src/CanvasRenderer.ts
|
|
412
|
+
function fontWeightCSS(weight) {
|
|
413
|
+
if (weight === "bold")
|
|
414
|
+
return "bold";
|
|
415
|
+
if (weight === "normal")
|
|
416
|
+
return "normal";
|
|
417
|
+
if (typeof weight === "number")
|
|
418
|
+
return String(weight);
|
|
419
|
+
return "normal";
|
|
420
|
+
}
|
|
421
|
+
function fontStyleCSS(style) {
|
|
422
|
+
return style === "italic" ? "italic" : "normal";
|
|
423
|
+
}
|
|
424
|
+
function renderToCanvas(ctx, lines, options = {}) {
|
|
425
|
+
const sizing = options.sizing ?? "frame";
|
|
426
|
+
const preserveSpaces = options.preserveSpaces ?? false;
|
|
427
|
+
let canvasWidth;
|
|
428
|
+
let canvasHeight;
|
|
429
|
+
if (sizing === "content") {
|
|
430
|
+
const bbox = computeBBox(lines);
|
|
431
|
+
canvasWidth = bbox.width;
|
|
432
|
+
canvasHeight = bbox.height;
|
|
433
|
+
ctx.canvas.width = canvasWidth;
|
|
434
|
+
ctx.canvas.height = canvasHeight;
|
|
435
|
+
} else {
|
|
436
|
+
canvasWidth = ctx.canvas.width;
|
|
437
|
+
canvasHeight = ctx.canvas.height;
|
|
438
|
+
}
|
|
439
|
+
if (options.backgroundColor) {
|
|
440
|
+
ctx.fillStyle = options.backgroundColor;
|
|
441
|
+
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
|
442
|
+
} else {
|
|
443
|
+
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
|
444
|
+
}
|
|
445
|
+
for (const line of lines) {
|
|
446
|
+
const baselineY = line.y + line.baseline;
|
|
447
|
+
for (const span of line.spans) {
|
|
448
|
+
const x = line.x + span.x;
|
|
449
|
+
const style = fontStyleCSS(span.style.fontStyle);
|
|
450
|
+
const weight = fontWeightCSS(span.style.fontWeight);
|
|
451
|
+
const size = span.fontMetrics.fontSize;
|
|
452
|
+
const family = span.style.fontFamily;
|
|
453
|
+
ctx.font = `${style} ${weight} ${size}px ${family}`;
|
|
454
|
+
ctx.fillStyle = span.style.color || "#000000";
|
|
455
|
+
ctx.textBaseline = "alphabetic";
|
|
456
|
+
if (preserveSpaces || span.type !== "space") {
|
|
457
|
+
ctx.fillText(span.text, x, baselineY);
|
|
458
|
+
}
|
|
459
|
+
if (span.style.underline) {
|
|
460
|
+
const ulY = baselineY + 2;
|
|
461
|
+
ctx.strokeStyle = span.style.color || "#000000";
|
|
462
|
+
ctx.lineWidth = 1;
|
|
463
|
+
ctx.beginPath();
|
|
464
|
+
ctx.moveTo(x, ulY);
|
|
465
|
+
ctx.lineTo(x + span.width, ulY);
|
|
466
|
+
ctx.stroke();
|
|
467
|
+
}
|
|
468
|
+
if (span.style.strikethrough) {
|
|
469
|
+
const stY = baselineY - span.fontMetrics.ascent * 0.4;
|
|
470
|
+
ctx.strokeStyle = span.style.color || "#000000";
|
|
471
|
+
ctx.lineWidth = 1;
|
|
472
|
+
ctx.beginPath();
|
|
473
|
+
ctx.moveTo(x, stY);
|
|
474
|
+
ctx.lineTo(x + span.width, stY);
|
|
475
|
+
ctx.stroke();
|
|
476
|
+
}
|
|
477
|
+
if (span.inlineWidget) {
|
|
478
|
+
const iw = span.inlineWidget;
|
|
479
|
+
const iwY = baselineY - (iw.height || span.fontMetrics.ascent) + (iw.baselineOffset || 0);
|
|
480
|
+
ctx.fillStyle = "#cccccc";
|
|
481
|
+
ctx.fillRect(x, iwY, iw.width, iw.height);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (options.debug) {
|
|
486
|
+
renderDebugToCanvas(ctx, lines, canvasWidth, canvasHeight, options.debug);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function renderDebugToCanvas(ctx, lines, _width, _height, flags) {
|
|
490
|
+
ctx.save();
|
|
491
|
+
if (flags.frame && lines.length > 0) {
|
|
492
|
+
const first = lines[0];
|
|
493
|
+
const last = lines[lines.length - 1];
|
|
494
|
+
const maxW = Math.max(...lines.map((l) => l.x + l.width));
|
|
495
|
+
const frameX = first.x;
|
|
496
|
+
const frameY = first.y;
|
|
497
|
+
const frameW = maxW - frameX;
|
|
498
|
+
const frameH = last.y + last.height - first.y;
|
|
499
|
+
ctx.strokeStyle = "rgba(255,200,0,0.6)";
|
|
500
|
+
ctx.lineWidth = 1;
|
|
501
|
+
ctx.setLineDash([6, 2]);
|
|
502
|
+
ctx.strokeRect(frameX, frameY, frameW, frameH);
|
|
503
|
+
}
|
|
504
|
+
ctx.setLineDash([]);
|
|
505
|
+
for (const line of lines) {
|
|
506
|
+
const bx = line.x;
|
|
507
|
+
const by = line.y;
|
|
508
|
+
const bw = line.width;
|
|
509
|
+
const bh = line.height;
|
|
510
|
+
const baselineY = line.y + line.baseline;
|
|
511
|
+
if (flags.lineGap) {
|
|
512
|
+
ctx.fillStyle = "rgba(0,150,255,0.10)";
|
|
513
|
+
ctx.fillRect(bx, by, bw, bh);
|
|
514
|
+
}
|
|
515
|
+
if (flags.box) {
|
|
516
|
+
ctx.strokeStyle = "rgba(255,100,100,0.5)";
|
|
517
|
+
ctx.lineWidth = 1;
|
|
518
|
+
ctx.strokeRect(bx, by, bw, bh);
|
|
519
|
+
}
|
|
520
|
+
if (flags.baseline) {
|
|
521
|
+
ctx.strokeStyle = "rgba(100,100,255,0.5)";
|
|
522
|
+
ctx.lineWidth = 1;
|
|
523
|
+
ctx.beginPath();
|
|
524
|
+
ctx.moveTo(bx, baselineY);
|
|
525
|
+
ctx.lineTo(bx + bw, baselineY);
|
|
526
|
+
ctx.stroke();
|
|
527
|
+
}
|
|
528
|
+
if (flags.ascentDescent) {
|
|
529
|
+
const ascentY = baselineY - line.ascent;
|
|
530
|
+
const descentY = baselineY + line.descent;
|
|
531
|
+
ctx.strokeStyle = "rgba(100,255,100,0.4)";
|
|
532
|
+
ctx.lineWidth = 0.5;
|
|
533
|
+
ctx.setLineDash([3, 2]);
|
|
534
|
+
ctx.beginPath();
|
|
535
|
+
ctx.moveTo(bx, ascentY);
|
|
536
|
+
ctx.lineTo(bx + bw, ascentY);
|
|
537
|
+
ctx.stroke();
|
|
538
|
+
ctx.beginPath();
|
|
539
|
+
ctx.moveTo(bx, descentY);
|
|
540
|
+
ctx.lineTo(bx + bw, descentY);
|
|
541
|
+
ctx.stroke();
|
|
542
|
+
ctx.setLineDash([]);
|
|
543
|
+
}
|
|
544
|
+
if (flags.labels) {
|
|
545
|
+
const labelY = by - 2;
|
|
546
|
+
const label = `y=${by.toFixed(1)} x=${bx.toFixed(1)} w=${bw.toFixed(1)} h=${bh.toFixed(1)} bl=${baselineY.toFixed(1)}`;
|
|
547
|
+
ctx.font = "9px monospace";
|
|
548
|
+
ctx.fillStyle = "rgba(0,0,0,0.55)";
|
|
549
|
+
ctx.textBaseline = "bottom";
|
|
550
|
+
ctx.fillText(label, bx, labelY);
|
|
551
|
+
}
|
|
552
|
+
if (flags.runs) {
|
|
553
|
+
for (const span of line.spans) {
|
|
554
|
+
if (span.width <= 0)
|
|
555
|
+
continue;
|
|
556
|
+
const rx = line.x + span.x;
|
|
557
|
+
const ry = baselineY - span.fontMetrics.ascent;
|
|
558
|
+
const rw = span.width;
|
|
559
|
+
const rh = span.fontMetrics.ascent + span.fontMetrics.descent;
|
|
560
|
+
ctx.strokeStyle = "rgba(200,100,255,0.4)";
|
|
561
|
+
ctx.lineWidth = 0.5;
|
|
562
|
+
ctx.strokeRect(rx, ry, rw, rh);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
ctx.restore();
|
|
567
|
+
}
|
|
568
|
+
export {
|
|
569
|
+
renderToSVG,
|
|
570
|
+
renderToCanvas,
|
|
571
|
+
renderResultToSVG,
|
|
572
|
+
renderParagraphToSVG,
|
|
573
|
+
renderDebugToCanvas
|
|
574
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vyaz/renderer",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
"@vyaz/core": "*"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
|
+
"@types/node": "^26.1.1",
|
|
19
|
+
"is-svg": "^6.1.0",
|
|
20
|
+
"svg-parser": "^2.0.4",
|
|
18
21
|
"typescript": "^5.4.0"
|
|
19
22
|
}
|
|
20
23
|
}
|
package/src/CanvasRenderer.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CanvasRenderer.ts — render
|
|
2
|
+
* CanvasRenderer.ts — render Line[] → Canvas.
|
|
3
3
|
*
|
|
4
|
-
* Takes ready
|
|
4
|
+
* Takes ready Line[] with absolute coordinates.
|
|
5
5
|
* Does not compute anything — only draws (dumb drawer principle).
|
|
6
6
|
*
|
|
7
7
|
* Options:
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - debug: DebugFlags — debug overlays
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type {
|
|
14
|
+
import type { Line, Span } from '@vyaz/core';
|
|
15
15
|
import type { DebugFlags } from './types.js';
|
|
16
16
|
import { computeBBox } from './utils.js';
|
|
17
17
|
|
|
@@ -23,8 +23,8 @@ export interface CanvasRenderOptions {
|
|
|
23
23
|
*/
|
|
24
24
|
sizing?: 'frame' | 'content';
|
|
25
25
|
/**
|
|
26
|
-
* When true: render space
|
|
27
|
-
* When false (default): skip space
|
|
26
|
+
* When true: render space spans with a space character.
|
|
27
|
+
* When false (default): skip space spans (position is already accounted for in x).
|
|
28
28
|
*/
|
|
29
29
|
preserveSpaces?: boolean;
|
|
30
30
|
/** Background color for clearing. If omitted, canvas is cleared transparent. */
|
|
@@ -47,15 +47,15 @@ function fontStyleCSS(style: string): string {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* Render
|
|
50
|
+
* Render Line[] array to Canvas.
|
|
51
51
|
*
|
|
52
52
|
* @param ctx — Canvas 2D rendering context
|
|
53
|
-
* @param lines — ready
|
|
53
|
+
* @param lines — ready Line[] with absolute coordinates
|
|
54
54
|
* @param options — rendering options
|
|
55
55
|
*/
|
|
56
56
|
export function renderToCanvas(
|
|
57
57
|
ctx: CanvasRenderingContext2D | any,
|
|
58
|
-
lines:
|
|
58
|
+
lines: Line[],
|
|
59
59
|
options: CanvasRenderOptions = {},
|
|
60
60
|
): void {
|
|
61
61
|
const sizing = options.sizing ?? 'frame';
|
|
@@ -88,49 +88,49 @@ export function renderToCanvas(
|
|
|
88
88
|
for (const line of lines) {
|
|
89
89
|
const baselineY = line.y + line.baseline;
|
|
90
90
|
|
|
91
|
-
for (const
|
|
92
|
-
const x = line.x +
|
|
91
|
+
for (const span of line.spans) {
|
|
92
|
+
const x = line.x + span.x;
|
|
93
93
|
|
|
94
94
|
// Font setting
|
|
95
|
-
const style = fontStyleCSS(
|
|
96
|
-
const weight = fontWeightCSS(
|
|
97
|
-
const size =
|
|
98
|
-
const family =
|
|
95
|
+
const style = fontStyleCSS(span.style.fontStyle);
|
|
96
|
+
const weight = fontWeightCSS(span.style.fontWeight);
|
|
97
|
+
const size = span.fontMetrics.fontSize;
|
|
98
|
+
const family = span.style.fontFamily;
|
|
99
99
|
ctx.font = `${style} ${weight} ${size}px ${family}`;
|
|
100
|
-
ctx.fillStyle =
|
|
100
|
+
ctx.fillStyle = span.style.color || '#000000';
|
|
101
101
|
ctx.textBaseline = 'alphabetic';
|
|
102
102
|
|
|
103
103
|
// Draw text
|
|
104
|
-
if (preserveSpaces ||
|
|
105
|
-
ctx.fillText(
|
|
104
|
+
if (preserveSpaces || span.type !== 'space') {
|
|
105
|
+
ctx.fillText(span.text, x, baselineY);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
// Underline
|
|
109
|
-
if (
|
|
109
|
+
if (span.style.underline) {
|
|
110
110
|
const ulY = baselineY + 2;
|
|
111
|
-
ctx.strokeStyle =
|
|
111
|
+
ctx.strokeStyle = span.style.color || '#000000';
|
|
112
112
|
ctx.lineWidth = 1;
|
|
113
113
|
ctx.beginPath();
|
|
114
114
|
ctx.moveTo(x, ulY);
|
|
115
|
-
ctx.lineTo(x +
|
|
115
|
+
ctx.lineTo(x + span.width, ulY);
|
|
116
116
|
ctx.stroke();
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
// Strikethrough
|
|
120
|
-
if (
|
|
121
|
-
const stY = baselineY -
|
|
122
|
-
ctx.strokeStyle =
|
|
120
|
+
if (span.style.strikethrough) {
|
|
121
|
+
const stY = baselineY - span.fontMetrics.ascent * 0.4;
|
|
122
|
+
ctx.strokeStyle = span.style.color || '#000000';
|
|
123
123
|
ctx.lineWidth = 1;
|
|
124
124
|
ctx.beginPath();
|
|
125
125
|
ctx.moveTo(x, stY);
|
|
126
|
-
ctx.lineTo(x +
|
|
126
|
+
ctx.lineTo(x + span.width, stY);
|
|
127
127
|
ctx.stroke();
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
// InlineWidget (simple rectangle)
|
|
131
|
-
if (
|
|
132
|
-
const iw =
|
|
133
|
-
const iwY = baselineY - (iw.height ||
|
|
131
|
+
if (span.inlineWidget) {
|
|
132
|
+
const iw = span.inlineWidget;
|
|
133
|
+
const iwY = baselineY - (iw.height || span.fontMetrics.ascent) + (iw.baselineOffset || 0);
|
|
134
134
|
ctx.fillStyle = '#cccccc';
|
|
135
135
|
ctx.fillRect(x, iwY, iw.width, iw.height);
|
|
136
136
|
}
|
|
@@ -148,7 +148,7 @@ export function renderToCanvas(
|
|
|
148
148
|
/** Draw debug overlays on canvas */
|
|
149
149
|
export function renderDebugToCanvas(
|
|
150
150
|
ctx: CanvasRenderingContext2D,
|
|
151
|
-
lines:
|
|
151
|
+
lines: Line[],
|
|
152
152
|
_width: number,
|
|
153
153
|
_height: number,
|
|
154
154
|
flags: DebugFlags,
|
|
@@ -230,14 +230,14 @@ export function renderDebugToCanvas(
|
|
|
230
230
|
ctx.fillText(label, bx, labelY);
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
-
// Run boxes — purple rects around
|
|
233
|
+
// Run boxes — purple rects around Spans
|
|
234
234
|
if (flags.runs) {
|
|
235
|
-
for (const
|
|
236
|
-
if (
|
|
237
|
-
const rx = line.x +
|
|
238
|
-
const ry = baselineY -
|
|
239
|
-
const rw =
|
|
240
|
-
const rh =
|
|
235
|
+
for (const span of line.spans) {
|
|
236
|
+
if (span.width <= 0) continue;
|
|
237
|
+
const rx = line.x + span.x;
|
|
238
|
+
const ry = baselineY - span.fontMetrics.ascent;
|
|
239
|
+
const rw = span.width;
|
|
240
|
+
const rh = span.fontMetrics.ascent + span.fontMetrics.descent;
|
|
241
241
|
ctx.strokeStyle = 'rgba(200,100,255,0.4)';
|
|
242
242
|
ctx.lineWidth = 0.5;
|
|
243
243
|
ctx.strokeRect(rx, ry, rw, rh);
|
package/src/SVGRenderer.ts
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SVGRenderer.ts — SVG text builder.
|
|
3
3
|
*
|
|
4
|
-
* Converts
|
|
4
|
+
* Converts Line[] into SVG markup using a builder pattern.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* Four presets:
|
|
7
7
|
* flat — all text in one <text> element, xml:space="preserve", no <tspan>
|
|
8
|
-
* browser — expanded <tspan> per run,
|
|
9
|
-
* preserve — expanded <tspan> per run, xml:space="preserve",
|
|
10
|
-
*
|
|
8
|
+
* browser — expanded <tspan> per run, xml:space="preserve", diff attributes, no textLength
|
|
9
|
+
* preserve — expanded <tspan> per run, xml:space="preserve", diff attributes, textLength
|
|
10
|
+
* glyph — <tspan> per glyph with per-character x positions, xml:space="preserve"
|
|
11
|
+
*
|
|
12
|
+
* All presets preserve whitespace via xml:space="preserve". Space spans (type: 'space')
|
|
13
|
+
* are rendered as separate <tspan> elements with explicit x coordinates.
|
|
11
14
|
*
|
|
12
15
|
* Usage:
|
|
13
16
|
* const svg = renderToSVG(lines, { preset: 'browser' })
|
|
14
17
|
* const svg = renderToSVG(lines, { preset: 'preserve', style: 'css', fit: 'frag' })
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
|
-
import type {
|
|
20
|
+
import type { Line, Span, ParagraphLayoutResult } from '@vyaz/core';
|
|
18
21
|
import type { DebugFlags } from './types.js';
|
|
19
22
|
import { computeBBox } from './utils.js';
|
|
20
23
|
|
|
@@ -66,7 +69,7 @@ type ResolvedOptions = {
|
|
|
66
69
|
|
|
67
70
|
const PRESETS: Record<SvgPreset, { structure: StructureMode; spacing: SpacingMode; defaultFit: SvgFit }> = {
|
|
68
71
|
flat: { structure: 'flat', spacing: 'preserve', defaultFit: 'none' },
|
|
69
|
-
browser: { structure: 'expanded', spacing: '
|
|
72
|
+
browser: { structure: 'expanded', spacing: 'preserve', defaultFit: 'none' },
|
|
70
73
|
preserve: { structure: 'expanded', spacing: 'preserve', defaultFit: 'none' },
|
|
71
74
|
glyph: { structure: 'glyph', spacing: 'preserve', defaultFit: 'none' },
|
|
72
75
|
};
|
|
@@ -108,11 +111,11 @@ function colorToRGB(color: string): string {
|
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
/** Compute gutter widths per line for justify alignment */
|
|
111
|
-
function computeGutterWidths(line:
|
|
112
|
-
const
|
|
113
|
-
if (
|
|
114
|
-
const perGap = totalSlack /
|
|
115
|
-
return line.
|
|
114
|
+
function computeGutterWidths(line: Line, totalSlack: number): number[] {
|
|
115
|
+
const spaceSpans = line.spans.filter(f => f.type === 'space' || f.text.trim() === '');
|
|
116
|
+
if (spaceSpans.length === 0) return [];
|
|
117
|
+
const perGap = totalSlack / spaceSpans.length;
|
|
118
|
+
return line.spans.map(f => (f.type === 'space' || f.text.trim() === '') ? perGap : 0);
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
// ── Resolve options ──────────────────────────────────────────────────────
|
|
@@ -168,14 +171,14 @@ interface StyleState {
|
|
|
168
171
|
decoration: string;
|
|
169
172
|
}
|
|
170
173
|
|
|
171
|
-
function defaultStyleState(
|
|
174
|
+
function defaultStyleState(span: Span): StyleState {
|
|
172
175
|
return {
|
|
173
|
-
fontFamily:
|
|
174
|
-
fontSize:
|
|
175
|
-
fontWeight: fontWeightNumeric(
|
|
176
|
-
color:
|
|
177
|
-
fontStyle:
|
|
178
|
-
decoration:
|
|
176
|
+
fontFamily: span.style.fontFamily || 'Arial',
|
|
177
|
+
fontSize: span.fontMetrics.fontSize || 16,
|
|
178
|
+
fontWeight: fontWeightNumeric(span.style.fontWeight),
|
|
179
|
+
color: span.style.color || '#000000',
|
|
180
|
+
fontStyle: span.style.fontStyle || 'normal',
|
|
181
|
+
decoration: span.style.underline ? 'underline' : span.style.strikethrough ? 'line-through' : '',
|
|
179
182
|
};
|
|
180
183
|
}
|
|
181
184
|
|
|
@@ -206,10 +209,10 @@ function xmlStyleAttrs(s: StyleState): string {
|
|
|
206
209
|
}
|
|
207
210
|
|
|
208
211
|
/** Build attributes for <text> element */
|
|
209
|
-
function buildTextAttrs(line:
|
|
212
|
+
function buildTextAttrs(line: Line, span: Span, opts: ResolvedOptions, runId?: string): string {
|
|
210
213
|
const x = line.x;
|
|
211
214
|
const y = line.y + line.baseline;
|
|
212
|
-
const s = defaultStyleState(
|
|
215
|
+
const s = defaultStyleState(span);
|
|
213
216
|
|
|
214
217
|
let attrs = ` x="${x}" y="${y}"`;
|
|
215
218
|
if (runId) attrs += ` id="${runId}"`;
|
|
@@ -235,8 +238,8 @@ function buildTextAttrs(line: LineBox, frag: FragmentBox, opts: ResolvedOptions,
|
|
|
235
238
|
}
|
|
236
239
|
|
|
237
240
|
/** Build attributes for <tspan> (expanded mode — only diff from current style) */
|
|
238
|
-
function buildTspanAttrs(
|
|
239
|
-
const s = defaultStyleState(
|
|
241
|
+
function buildTspanAttrs(span: Span, x: number, currentStyle: StyleState | null): { attrs: string; newStyle: StyleState } {
|
|
242
|
+
const s = defaultStyleState(span);
|
|
240
243
|
let attrs = ` x="${x}"`;
|
|
241
244
|
|
|
242
245
|
if (currentStyle && equalStyle(s, currentStyle)) {
|
|
@@ -259,34 +262,34 @@ function buildTspanAttrs(frag: FragmentBox, x: number, currentStyle: StyleState
|
|
|
259
262
|
}
|
|
260
263
|
|
|
261
264
|
/** Build per-glyph x positions for glyph mode */
|
|
262
|
-
function buildGlyphPositions(
|
|
263
|
-
if (!
|
|
265
|
+
function buildGlyphPositions(span: Span, _lineX: number): string {
|
|
266
|
+
if (!span.glyphAdvances || span.glyphAdvances.length === 0) {
|
|
264
267
|
return '';
|
|
265
268
|
}
|
|
266
|
-
//
|
|
269
|
+
// span.x is already absolute — computed by PositioningEngine.
|
|
267
270
|
// lineX is NOT added because that would double-shift.
|
|
268
|
-
const
|
|
269
|
-
let xPos =
|
|
271
|
+
const spanX = span.x;
|
|
272
|
+
let xPos = spanX;
|
|
270
273
|
const positions: string[] = [xPos.toFixed(1)];
|
|
271
|
-
for (let i = 0; i <
|
|
272
|
-
xPos +=
|
|
274
|
+
for (let i = 0; i < span.glyphAdvances.length - 1; i++) {
|
|
275
|
+
xPos += span.glyphAdvances[i];
|
|
273
276
|
positions.push(xPos.toFixed(1));
|
|
274
277
|
}
|
|
275
278
|
return positions.join(' ');
|
|
276
279
|
}
|
|
277
280
|
|
|
278
281
|
/** Build textLength attribute for a line */
|
|
279
|
-
function buildFitAttr(line:
|
|
282
|
+
function buildFitAttr(line: Line, opts: ResolvedOptions): string {
|
|
280
283
|
if (opts.fit === 'text') {
|
|
281
284
|
return ` textLength="${line.width}" lengthAdjust="spacing"`;
|
|
282
285
|
}
|
|
283
286
|
return '';
|
|
284
287
|
}
|
|
285
288
|
|
|
286
|
-
/** Build textLength for a
|
|
287
|
-
function
|
|
289
|
+
/** Build textLength for a span */
|
|
290
|
+
function buildSpanFitAttr(span: Span, opts: ResolvedOptions): string {
|
|
288
291
|
if (opts.fit === 'frag') {
|
|
289
|
-
return ` textLength="${
|
|
292
|
+
return ` textLength="${span.width}"`;
|
|
290
293
|
}
|
|
291
294
|
return '';
|
|
292
295
|
}
|
|
@@ -306,35 +309,60 @@ class SvgBuilder {
|
|
|
306
309
|
}
|
|
307
310
|
}
|
|
308
311
|
|
|
309
|
-
addText(line:
|
|
310
|
-
|
|
312
|
+
addText(line: Line, baseSpan: Span, runId?: string, yOverride?: number, fontSizeOverride?: number): void {
|
|
313
|
+
let attrs: string;
|
|
314
|
+
if (yOverride !== undefined && fontSizeOverride !== undefined) {
|
|
315
|
+
// For flat mode sub/superscript — override y and font-size
|
|
316
|
+
const s = defaultStyleState(baseSpan);
|
|
317
|
+
const x = line.x;
|
|
318
|
+
attrs = ` x="${x}" y="${yOverride}"`;
|
|
319
|
+
if (this.opts.style === 'css') {
|
|
320
|
+
let css = cssStyleString(s);
|
|
321
|
+
if (this.opts.spacing === 'preserve') css += '; white-space: pre';
|
|
322
|
+
attrs += ` style="${css}"`;
|
|
323
|
+
} else {
|
|
324
|
+
let xml = ` font-family="${s.fontFamily}" font-size="${fontSizeOverride}" fill="${s.color}" font-weight="${s.fontWeight}"`;
|
|
325
|
+
if (s.fontStyle === 'italic') xml += ' font-style="italic"';
|
|
326
|
+
if (s.decoration) xml += ` text-decoration="${s.decoration}"`;
|
|
327
|
+
if (this.opts.spacing === 'preserve') xml += ' xml:space="preserve"';
|
|
328
|
+
attrs += xml;
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
attrs = buildTextAttrs(line, baseSpan, this.opts, runId);
|
|
332
|
+
}
|
|
311
333
|
const fit = buildFitAttr(line, this.opts);
|
|
312
334
|
this.parts.push(` <text${attrs}${fit}>\n`);
|
|
313
335
|
}
|
|
314
336
|
|
|
315
|
-
|
|
316
|
-
this.parts.push(
|
|
337
|
+
addFlatSpan(text: string): void {
|
|
338
|
+
this.parts.push(`${escapeXml(text)}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Write a raw SVG line (for flat mode where each span is its own <text>). */
|
|
342
|
+
pushLine(line: string): void {
|
|
343
|
+
this.parts.push(line);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
closeText(): void {
|
|
347
|
+
this.parts.push('</text>\n');
|
|
317
348
|
}
|
|
318
349
|
|
|
319
|
-
|
|
320
|
-
const { attrs, newStyle } = buildTspanAttrs(
|
|
321
|
-
const fit =
|
|
322
|
-
this.parts.push(` <tspan${attrs}${fit}>${escapeXml(
|
|
350
|
+
addExpandedSpan(span: Span, x: number, style: StyleState | null): StyleState {
|
|
351
|
+
const { attrs, newStyle } = buildTspanAttrs(span, x, style);
|
|
352
|
+
const fit = buildSpanFitAttr(span, this.opts);
|
|
353
|
+
this.parts.push(` <tspan${attrs}${fit}>${escapeXml(span.text)}</tspan>\n`);
|
|
323
354
|
return newStyle;
|
|
324
355
|
}
|
|
325
356
|
|
|
326
|
-
|
|
327
|
-
const positions = buildGlyphPositions(
|
|
357
|
+
addGlyphSpan(span: Span, lineX: number): void {
|
|
358
|
+
const positions = buildGlyphPositions(span, lineX);
|
|
328
359
|
if (positions) {
|
|
329
|
-
this.parts.push(` <tspan x="${positions}">${escapeXml(
|
|
360
|
+
this.parts.push(` <tspan x="${positions}">${escapeXml(span.text)}</tspan>\n`);
|
|
330
361
|
} else {
|
|
331
|
-
this.parts.push(` <tspan>${escapeXml(
|
|
362
|
+
this.parts.push(` <tspan>${escapeXml(span.text)}</tspan>\n`);
|
|
332
363
|
}
|
|
333
364
|
}
|
|
334
365
|
|
|
335
|
-
closeText(): void {
|
|
336
|
-
this.parts.push(' </text>\n');
|
|
337
|
-
}
|
|
338
366
|
|
|
339
367
|
addDebug(debugOverlay: string): void {
|
|
340
368
|
if (debugOverlay) {
|
|
@@ -350,7 +378,7 @@ class SvgBuilder {
|
|
|
350
378
|
|
|
351
379
|
// ── Debug overlay ────────────────────────────────────────────────────────
|
|
352
380
|
|
|
353
|
-
function renderDebugToSVG(lines:
|
|
381
|
+
function renderDebugToSVG(lines: Line[], width: number, height: number, flags: DebugFlags): string {
|
|
354
382
|
const parts: string[] = [];
|
|
355
383
|
|
|
356
384
|
if (flags.frame && lines.length > 0) {
|
|
@@ -384,11 +412,11 @@ function renderDebugToSVG(lines: LineBox[], width: number, height: number, flags
|
|
|
384
412
|
parts.push(` <text x="${bx}" y="${by - 2}" font-size="9" fill="rgba(0,0,0,0.55)" font-family="monospace">y=${by.toFixed(1)} x=${bx.toFixed(1)} w=${bw.toFixed(1)} h=${bh.toFixed(1)} bl=${baselineY.toFixed(1)}</text>`);
|
|
385
413
|
}
|
|
386
414
|
if (flags.runs) {
|
|
387
|
-
for (const
|
|
388
|
-
if (
|
|
389
|
-
const rx = line.x +
|
|
390
|
-
const ry = baselineY -
|
|
391
|
-
parts.push(` <rect x="${rx}" y="${ry}" width="${
|
|
415
|
+
for (const span of line.spans) {
|
|
416
|
+
if (span.width <= 0) continue;
|
|
417
|
+
const rx = line.x + span.x;
|
|
418
|
+
const ry = baselineY - span.fontMetrics.ascent;
|
|
419
|
+
parts.push(` <rect x="${rx}" y="${ry}" width="${span.width}" height="${span.fontMetrics.ascent + span.fontMetrics.descent}" fill="none" stroke="rgba(200,100,255,0.4)" stroke-width="0.5" />`);
|
|
392
420
|
}
|
|
393
421
|
}
|
|
394
422
|
}
|
|
@@ -399,13 +427,13 @@ function renderDebugToSVG(lines: LineBox[], width: number, height: number, flags
|
|
|
399
427
|
// ── Main render logic ────────────────────────────────────────────────────
|
|
400
428
|
|
|
401
429
|
/**
|
|
402
|
-
* Render
|
|
430
|
+
* Render Line[] into SVG string.
|
|
403
431
|
*
|
|
404
|
-
* @param lines — layout lines with
|
|
432
|
+
* @param lines — layout lines with spans
|
|
405
433
|
* @param options — rendering options (preset + style/fit/sizing modifiers)
|
|
406
434
|
* @returns SVG string
|
|
407
435
|
*/
|
|
408
|
-
export function renderToSVG(lines:
|
|
436
|
+
export function renderToSVG(lines: Line[], options: SVGRenderOptions = {}): string {
|
|
409
437
|
const opts = resolveOptions(options);
|
|
410
438
|
|
|
411
439
|
// Determine canvas size
|
|
@@ -435,50 +463,68 @@ export function renderToSVG(lines: LineBox[], options: SVGRenderOptions = {}): s
|
|
|
435
463
|
if (opts.structure === 'glyph') {
|
|
436
464
|
// Per-glyph positioning with run-based <text> grouping
|
|
437
465
|
let currentRunIdx = -1;
|
|
438
|
-
for (const
|
|
439
|
-
if (!
|
|
440
|
-
const runIdx =
|
|
466
|
+
for (const span of line.spans) {
|
|
467
|
+
if (!span.text) continue;
|
|
468
|
+
const runIdx = span.itemIndex;
|
|
441
469
|
if (runIdx !== currentRunIdx) {
|
|
442
470
|
if (currentRunIdx !== -1) {
|
|
443
471
|
builder.closeText();
|
|
444
472
|
}
|
|
445
|
-
const runId =
|
|
446
|
-
builder.addText(line,
|
|
473
|
+
const runId = span.paragraphId ? `${span.paragraphId}-${runIdx}` : undefined;
|
|
474
|
+
builder.addText(line, span, runId);
|
|
447
475
|
currentRunIdx = runIdx;
|
|
448
476
|
}
|
|
449
|
-
builder.
|
|
477
|
+
builder.addGlyphSpan(span, line.x);
|
|
450
478
|
}
|
|
451
479
|
if (currentRunIdx !== -1) {
|
|
452
480
|
builder.closeText();
|
|
453
481
|
}
|
|
482
|
+
} else if (opts.structure === 'flat') {
|
|
483
|
+
// flat mode: group spans by (baseline + offset). Each group → one <text>.
|
|
484
|
+
const groups: { spans: Span[]; targetY: number; fontSize: number }[] = [];
|
|
485
|
+
for (const span of line.spans) {
|
|
486
|
+
if (!span.text) continue;
|
|
487
|
+
const offset = span.fontMetrics.baselineOffset || 0;
|
|
488
|
+
const targetY = Math.round((line.y + line.baseline + offset) * 100) / 100;
|
|
489
|
+
const fontSize = span.fontMetrics.fontSize;
|
|
490
|
+
const last = groups[groups.length - 1];
|
|
491
|
+
if (last && last.targetY === targetY && last.fontSize === fontSize) {
|
|
492
|
+
last.spans.push(span);
|
|
493
|
+
} else {
|
|
494
|
+
groups.push({ spans: [span], targetY, fontSize });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
for (const group of groups) {
|
|
498
|
+
const s = defaultStyleState(group.spans[0]);
|
|
499
|
+
const text = group.spans.map(sp => escapeXml(sp.text)).join('');
|
|
500
|
+
let attrs = ` x="${line.x}" y="${group.targetY}" font-family="${s.fontFamily}" font-size="${group.fontSize}" fill="${s.color}" font-weight="${s.fontWeight}"`;
|
|
501
|
+
if (s.fontStyle === 'italic') attrs += ' font-style="italic"';
|
|
502
|
+
if (s.decoration) attrs += ` text-decoration="${s.decoration}"`;
|
|
503
|
+
attrs += ' xml:space="preserve"';
|
|
504
|
+
// text-anchor for center/right alignment
|
|
505
|
+
if (line.alignment === 'center') attrs += ' text-anchor="middle"';
|
|
506
|
+
else if (line.alignment === 'right') attrs += ' text-anchor="end"';
|
|
507
|
+
builder.pushLine(` <text${attrs}>${text}</text>\n`);
|
|
508
|
+
}
|
|
454
509
|
} else {
|
|
455
|
-
//
|
|
456
|
-
const
|
|
457
|
-
if (!
|
|
458
|
-
|
|
459
|
-
builder.addText(line,
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const x = frag.x;
|
|
471
|
-
|
|
472
|
-
const shouldRender = frag.type !== 'space' || opts.spacing === 'preserve';
|
|
473
|
-
if (shouldRender) {
|
|
474
|
-
const newStyle = builder.addExpandedFrag(frag, x, currentStyle);
|
|
475
|
-
if (frag.type !== 'space') {
|
|
476
|
-
currentStyle = newStyle;
|
|
477
|
-
}
|
|
510
|
+
// expanded: single <text> per line with <tspan> children
|
|
511
|
+
const baseSpan = line.spans.find(f => f.type === 'text' && f.text.length > 0) || line.spans[0];
|
|
512
|
+
if (!baseSpan) continue;
|
|
513
|
+
|
|
514
|
+
builder.addText(line, baseSpan);
|
|
515
|
+
let currentStyle: StyleState | null = null;
|
|
516
|
+
for (const span of line.spans) {
|
|
517
|
+
if (!span.text) continue;
|
|
518
|
+
const x = span.x;
|
|
519
|
+
|
|
520
|
+
const shouldRender = span.type !== 'space' || opts.spacing === 'preserve';
|
|
521
|
+
if (shouldRender) {
|
|
522
|
+
const newStyle = builder.addExpandedSpan(span, x, currentStyle);
|
|
523
|
+
if (span.type !== 'space') {
|
|
524
|
+
currentStyle = newStyle;
|
|
478
525
|
}
|
|
479
526
|
}
|
|
480
527
|
}
|
|
481
|
-
|
|
482
528
|
builder.closeText();
|
|
483
529
|
}
|
|
484
530
|
}
|
|
@@ -495,7 +541,7 @@ export function renderToSVG(lines: LineBox[], options: SVGRenderOptions = {}): s
|
|
|
495
541
|
* Render one ParagraphLayoutResult to SVG (convenience wrapper).
|
|
496
542
|
*/
|
|
497
543
|
export function renderParagraphToSVG(
|
|
498
|
-
lines:
|
|
544
|
+
lines: Line[],
|
|
499
545
|
paragraphWidth: number,
|
|
500
546
|
paragraphHeight: number,
|
|
501
547
|
options?: SVGRenderOptions,
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @vyaz/renderer — SVG and Canvas renderers.
|
|
3
3
|
*
|
|
4
|
-
* Converts
|
|
4
|
+
* Converts Line[] (from @vyaz/core) into SVG strings or Canvas drawings.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
export { renderToSVG, renderParagraphToSVG, renderResultToSVG } from './SVGRenderer.js';
|
package/src/utils.ts
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* render/utils.ts — shared renderer utilities.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type { Line } from '@vyaz/core';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Compute the bounding box (content width + height) from an array of
|
|
8
|
+
* Compute the bounding box (content width + height) from an array of Line.
|
|
9
9
|
*/
|
|
10
|
-
export function computeBBox(lines:
|
|
10
|
+
export function computeBBox(lines: Line[]): { width: number; height: number } {
|
|
11
11
|
if (lines.length === 0) return { width: 0, height: 0 };
|
|
12
12
|
const width = Math.max(...lines.map(l => l.x + l.width));
|
|
13
13
|
const height = lines[lines.length - 1].y + lines[lines.length - 1].height;
|