@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 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, "&#38;").replace(/</g, "&#60;").replace(/>/g, "&#62;").replace(/"/g, "&#34;");
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.1",
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
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * CanvasRenderer.ts — render LineBox[] → Canvas.
2
+ * CanvasRenderer.ts — render Line[] → Canvas.
3
3
  *
4
- * Takes ready LineBox[] with absolute coordinates.
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 { LineBox, FragmentBox } from '@vyaz/core';
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 fragments with a space character.
27
- * When false (default): skip space fragments (position is already accounted for in x).
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 LineBox[] array to Canvas.
50
+ * Render Line[] array to Canvas.
51
51
  *
52
52
  * @param ctx — Canvas 2D rendering context
53
- * @param lines — ready LineBox[] with absolute coordinates
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: LineBox[],
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 frag of line.fragments) {
92
- const x = line.x + frag.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(frag.style.fontStyle);
96
- const weight = fontWeightCSS(frag.style.fontWeight);
97
- const size = frag.fontMetrics.fontSize;
98
- const family = frag.style.fontFamily;
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 = frag.style.color || '#000000';
100
+ ctx.fillStyle = span.style.color || '#000000';
101
101
  ctx.textBaseline = 'alphabetic';
102
102
 
103
103
  // Draw text
104
- if (preserveSpaces || frag.type !== 'space') {
105
- ctx.fillText(frag.text, x, baselineY);
104
+ if (preserveSpaces || span.type !== 'space') {
105
+ ctx.fillText(span.text, x, baselineY);
106
106
  }
107
107
 
108
108
  // Underline
109
- if (frag.style.underline) {
109
+ if (span.style.underline) {
110
110
  const ulY = baselineY + 2;
111
- ctx.strokeStyle = frag.style.color || '#000000';
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 + frag.width, ulY);
115
+ ctx.lineTo(x + span.width, ulY);
116
116
  ctx.stroke();
117
117
  }
118
118
 
119
119
  // Strikethrough
120
- if (frag.style.strikethrough) {
121
- const stY = baselineY - frag.fontMetrics.ascent * 0.4;
122
- ctx.strokeStyle = frag.style.color || '#000000';
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 + frag.width, stY);
126
+ ctx.lineTo(x + span.width, stY);
127
127
  ctx.stroke();
128
128
  }
129
129
 
130
130
  // InlineWidget (simple rectangle)
131
- if (frag.inlineWidget) {
132
- const iw = frag.inlineWidget;
133
- const iwY = baselineY - (iw.height || frag.fontMetrics.ascent) + (iw.baselineOffset || 0);
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: LineBox[],
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 FragmentBox
233
+ // Run boxes — purple rects around Spans
234
234
  if (flags.runs) {
235
- for (const frag of line.fragments) {
236
- if (frag.width <= 0) continue;
237
- const rx = line.x + frag.x;
238
- const ry = baselineY - frag.fontMetrics.ascent;
239
- const rw = frag.width;
240
- const rh = frag.fontMetrics.ascent + frag.fontMetrics.descent;
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);
@@ -1,20 +1,23 @@
1
1
  /**
2
2
  * SVGRenderer.ts — SVG text builder.
3
3
  *
4
- * Converts LineBox[] into SVG markup using a builder pattern.
4
+ * Converts Line[] into SVG markup using a builder pattern.
5
5
  *
6
- * Three presets:
6
+ * Four presets:
7
7
  * flat — all text in one <text> element, xml:space="preserve", no <tspan>
8
- * browser — expanded <tspan> per run, NO xml:space, spaces skipped (browser collapses them)
9
- * preserve — expanded <tspan> per run, xml:space="preserve", spaces render as <tspan> </tspan>,
10
- * textLength on each line by default for pixel-perfect width
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 { LineBox, FragmentBox, ParagraphLayoutResult } from '@vyaz/core';
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: 'browser', defaultFit: 'none' },
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: LineBox, totalSlack: number): number[] {
112
- const spaceFrags = line.fragments.filter(f => f.type === 'space' || f.text.trim() === '');
113
- if (spaceFrags.length === 0) return [];
114
- const perGap = totalSlack / spaceFrags.length;
115
- return line.fragments.map(f => (f.type === 'space' || f.text.trim() === '') ? perGap : 0);
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(frag: FragmentBox): StyleState {
174
+ function defaultStyleState(span: Span): StyleState {
172
175
  return {
173
- fontFamily: frag.style.fontFamily || 'Arial',
174
- fontSize: frag.fontMetrics.fontSize || 16,
175
- fontWeight: fontWeightNumeric(frag.style.fontWeight),
176
- color: frag.style.color || '#000000',
177
- fontStyle: frag.style.fontStyle || 'normal',
178
- decoration: frag.style.underline ? 'underline' : frag.style.strikethrough ? 'line-through' : '',
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: LineBox, frag: FragmentBox, opts: ResolvedOptions, runId?: string): string {
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(frag);
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(frag: FragmentBox, x: number, currentStyle: StyleState | null): { attrs: string; newStyle: StyleState } {
239
- const s = defaultStyleState(frag);
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(frag: FragmentBox, _lineX: number): string {
263
- if (!frag.glyphAdvances || frag.glyphAdvances.length === 0) {
265
+ function buildGlyphPositions(span: Span, _lineX: number): string {
266
+ if (!span.glyphAdvances || span.glyphAdvances.length === 0) {
264
267
  return '';
265
268
  }
266
- // frag.x is already absolute — computed by PositioningEngine.
269
+ // span.x is already absolute — computed by PositioningEngine.
267
270
  // lineX is NOT added because that would double-shift.
268
- const fragX = frag.x;
269
- let xPos = fragX;
271
+ const spanX = span.x;
272
+ let xPos = spanX;
270
273
  const positions: string[] = [xPos.toFixed(1)];
271
- for (let i = 0; i < frag.glyphAdvances.length - 1; i++) {
272
- xPos += frag.glyphAdvances[i];
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: LineBox, opts: ResolvedOptions): string {
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 fragment */
287
- function buildFragFitAttr(frag: FragmentBox, opts: ResolvedOptions): string {
289
+ /** Build textLength for a span */
290
+ function buildSpanFitAttr(span: Span, opts: ResolvedOptions): string {
288
291
  if (opts.fit === 'frag') {
289
- return ` textLength="${frag.width}"`;
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: LineBox, baseFrag: FragmentBox, runId?: string): void {
310
- const attrs = buildTextAttrs(line, baseFrag, this.opts, runId);
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
- addFlatFrag(text: string): void {
316
- this.parts.push(` ${escapeXml(text)}`);
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
- addExpandedFrag(frag: FragmentBox, x: number, style: StyleState | null): StyleState {
320
- const { attrs, newStyle } = buildTspanAttrs(frag, x, style);
321
- const fit = buildFragFitAttr(frag, this.opts);
322
- this.parts.push(` <tspan${attrs}${fit}>${escapeXml(frag.text)}</tspan>\n`);
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
- addGlyphFrag(frag: FragmentBox, lineX: number): void {
327
- const positions = buildGlyphPositions(frag, lineX);
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(frag.text)}</tspan>\n`);
360
+ this.parts.push(` <tspan x="${positions}">${escapeXml(span.text)}</tspan>\n`);
330
361
  } else {
331
- this.parts.push(` <tspan>${escapeXml(frag.text)}</tspan>\n`);
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: LineBox[], width: number, height: number, flags: DebugFlags): string {
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 frag of line.fragments) {
388
- if (frag.width <= 0) continue;
389
- const rx = line.x + frag.x;
390
- const ry = baselineY - frag.fontMetrics.ascent;
391
- parts.push(` <rect x="${rx}" y="${ry}" width="${frag.width}" height="${frag.fontMetrics.ascent + frag.fontMetrics.descent}" fill="none" stroke="rgba(200,100,255,0.4)" stroke-width="0.5" />`);
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 LineBox[] into SVG string.
430
+ * Render Line[] into SVG string.
403
431
  *
404
- * @param lines — layout lines with fragments
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: LineBox[], options: SVGRenderOptions = {}): string {
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 frag of line.fragments) {
439
- if (!frag.text) continue;
440
- const runIdx = frag.itemIndex;
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 = frag.paragraphId ? `${frag.paragraphId}-${runIdx}` : undefined;
446
- builder.addText(line, frag, runId);
473
+ const runId = span.paragraphId ? `${span.paragraphId}-${runIdx}` : undefined;
474
+ builder.addText(line, span, runId);
447
475
  currentRunIdx = runIdx;
448
476
  }
449
- builder.addGlyphFrag(frag, line.x);
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
- // flat / expanded: single <text> per line
456
- const baseFrag = line.fragments.find(f => f.type === 'text' && f.text.length > 0) || line.fragments[0];
457
- if (!baseFrag) continue;
458
-
459
- builder.addText(line, baseFrag);
460
-
461
- if (opts.structure === 'flat') {
462
- // Concatenate all text on the line
463
- const fullText = line.fragments.map(f => f.text).join('');
464
- builder.addFlatFrag(fullText);
465
- } else {
466
- // expanded: each fragment as <tspan> with diff styles
467
- let currentStyle: StyleState | null = null;
468
- for (const frag of line.fragments) {
469
- if (!frag.text) continue;
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: LineBox[],
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 LineBox[] (from @vyaz/core) into SVG strings or Canvas drawings.
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 { LineBox } from '@vyaz/core';
5
+ import type { Line } from '@vyaz/core';
6
6
 
7
7
  /**
8
- * Compute the bounding box (content width + height) from an array of LineBox.
8
+ * Compute the bounding box (content width + height) from an array of Line.
9
9
  */
10
- export function computeBBox(lines: LineBox[]): { width: number; height: number } {
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;