@vyaz/renderer 0.0.1 → 0.0.3

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.
@@ -1,22 +1,27 @@
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 an AST-first approach.
5
+ * Builds a tree of SvgNode, then serializes to string in one pass.
5
6
  *
6
- * Three presets:
7
+ * Four presets:
7
8
  * 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
9
+ * browser — expanded <tspan> per run, xml:space="preserve", diff attributes, no textLength
10
+ * preserve — expanded <tspan> per run, xml:space="preserve", diff attributes, textLength
11
+ * glyph — <tspan> per glyph with per-character x positions, xml:space="preserve"
12
+ *
13
+ * All presets preserve whitespace via xml:space="preserve". Space spans (type: 'space')
14
+ * are rendered as separate <tspan> elements with explicit x coordinates.
11
15
  *
12
16
  * Usage:
13
17
  * const svg = renderToSVG(lines, { preset: 'browser' })
14
18
  * const svg = renderToSVG(lines, { preset: 'preserve', style: 'css', fit: 'frag' })
15
19
  */
16
20
 
17
- import type { LineBox, FragmentBox, ParagraphLayoutResult } from '@vyaz/core';
18
- import type { DebugFlags } from './types.js';
19
- import { computeBBox } from './utils.js';
21
+ import type { Line, Span, ParagraphLayoutResult, ParagraphGroup, MultiColumnConfig } from '@vyaz/core';
22
+ import { groupLinesByParagraph } from '@vyaz/core';
23
+ import type { DebugFlags, SvgElement, SvgNode } from './types.js';
24
+ import { computeBBox, fmt } from './utils.js';
20
25
 
21
26
  // ── Types ────────────────────────────────────────────────────────────────
22
27
 
@@ -28,6 +33,8 @@ export type SvgFit = 'none' | 'text' | 'frag';
28
33
 
29
34
  export type SvgSizing = 'frame' | 'content';
30
35
 
36
+ export type PerAxisSizing = { horizontal: SvgSizing; vertical: SvgSizing };
37
+
31
38
  export interface SVGRenderOptions {
32
39
  /** Shorthand that sets structure + spacing at once. */
33
40
  preset?: SvgPreset;
@@ -35,16 +42,35 @@ export interface SVGRenderOptions {
35
42
  style?: SvgStyle;
36
43
  /** How `textLength` is applied. */
37
44
  fit?: SvgFit;
38
- /** How SVG determines its size: 'frame' — use width/height from options; 'content' — compute BBox from lines. */
39
- sizing?: SvgSizing;
40
- /** SVG canvas width (px). Used when sizing='frame' or as fallback. */
45
+ /**
46
+ * How SVG determines its canvas size.
47
+ * Single string: applies to both axes. Object: per-axis control.
48
+ * 'frame' — use explicit width/height from options.
49
+ * 'content' — compute from lines bounding box.
50
+ */
51
+ sizing?: SvgSizing | PerAxisSizing;
52
+ /** SVG canvas width (px). Used when horizontal sizing='frame' or as fallback. */
41
53
  width?: number;
42
- /** SVG canvas height (px). Used when sizing='frame' or as fallback. */
54
+ /** SVG canvas height (px). Used when vertical sizing='frame' or as fallback. */
43
55
  height?: number;
44
56
  /** CSS class for `<svg>`. */
45
57
  className?: string;
58
+ /**
59
+ * Extra padding added around the SVG canvas. Content coordinates stay unchanged;
60
+ * the SVG viewBox is shifted and canvas is enlarged so debug overlays
61
+ * (frameBox / contentBox) are visible with a gap from the edge.
62
+ * Useful for snapshot tests to clearly show frame vs content boundaries.
63
+ */
64
+ contentPadding?: number;
46
65
  /** Debug overlays. */
47
66
  debug?: DebugFlags;
67
+ /**
68
+ * Multi-column layout configuration for debug overlays.
69
+ * When set, paragraph and column boxes are rendered per-column.
70
+ */
71
+ columns?: MultiColumnConfig;
72
+ /** Left padding from frame (needed for column debug rendering). */
73
+ paddingLeft?: number;
48
74
  }
49
75
 
50
76
  type SpacingMode = 'browser' | 'preserve';
@@ -55,10 +81,12 @@ type ResolvedOptions = {
55
81
  spacing: SpacingMode;
56
82
  style: 'css' | 'xml';
57
83
  fit: 'none' | 'text' | 'frag';
58
- sizing: 'frame' | 'content';
84
+ sizingHorizontal: 'frame' | 'content';
85
+ sizingVertical: 'frame' | 'content';
59
86
  width?: number;
60
87
  height?: number;
61
88
  className?: string;
89
+ contentPadding: number;
62
90
  debug?: DebugFlags;
63
91
  };
64
92
 
@@ -66,8 +94,8 @@ type ResolvedOptions = {
66
94
 
67
95
  const PRESETS: Record<SvgPreset, { structure: StructureMode; spacing: SpacingMode; defaultFit: SvgFit }> = {
68
96
  flat: { structure: 'flat', spacing: 'preserve', defaultFit: 'none' },
69
- browser: { structure: 'expanded', spacing: 'browser', defaultFit: 'none' },
70
- preserve: { structure: 'expanded', spacing: 'preserve', defaultFit: 'none' },
97
+ browser: { structure: 'expanded', spacing: 'preserve', defaultFit: 'none' },
98
+ preserve: { structure: 'expanded', spacing: 'preserve', defaultFit: 'frag' },
71
99
  glyph: { structure: 'glyph', spacing: 'preserve', defaultFit: 'none' },
72
100
  };
73
101
 
@@ -97,22 +125,28 @@ function fontWeightNumeric(weight: string | number): number {
97
125
 
98
126
  function colorToRGB(color: string): string {
99
127
  if (!color) return 'rgb(0, 0, 0)';
128
+ if (color[0] !== '#') return color;
129
+
100
130
  let hex = color;
101
- if (hex.length === 4 && hex[0] === '#') {
131
+ if (hex.length === 4) {
102
132
  hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
103
133
  }
104
- if (hex.length === 7 && hex[0] === '#') {
134
+ if (hex.length === 7) {
105
135
  return `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
106
136
  }
107
- return 'rgb(0, 0, 0)';
137
+ if (hex.length === 9) {
138
+ const a = parseInt(hex.slice(7, 9), 16) / 255;
139
+ return `rgba(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)}, ${a.toFixed(3)})`;
140
+ }
141
+ return color;
108
142
  }
109
143
 
110
144
  /** 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);
145
+ function computeGutterWidths(line: Line, totalSlack: number): number[] {
146
+ const spaceSpans = line.spans.filter(f => f.type === 'space' || f.text.trim() === '');
147
+ if (spaceSpans.length === 0) return [];
148
+ const perGap = totalSlack / spaceSpans.length;
149
+ return line.spans.map(f => (f.type === 'space' || f.text.trim() === '') ? perGap : 0);
116
150
  }
117
151
 
118
152
  // ── Resolve options ──────────────────────────────────────────────────────
@@ -142,7 +176,19 @@ function resolveOptions(opts: SVGRenderOptions): ResolvedOptions {
142
176
 
143
177
  const style = opts.style ?? 'xml';
144
178
  let fit = opts.fit ?? defaultFit;
145
- const sizing = opts.sizing ?? 'frame';
179
+
180
+ // Normalize per-axis sizing
181
+ let sizingHorizontal: 'frame' | 'content';
182
+ let sizingVertical: 'frame' | 'content';
183
+
184
+ if (typeof opts.sizing === 'object' && opts.sizing !== null) {
185
+ sizingHorizontal = opts.sizing.horizontal ?? 'frame';
186
+ sizingVertical = opts.sizing.vertical ?? 'frame';
187
+ } else {
188
+ const s = opts.sizing ?? 'frame';
189
+ sizingHorizontal = s;
190
+ sizingVertical = s;
191
+ }
146
192
 
147
193
  // Validation rules
148
194
  if (structure === 'glyph' && fit !== 'none') {
@@ -154,7 +200,80 @@ function resolveOptions(opts: SVGRenderOptions): ResolvedOptions {
154
200
  fit = 'text';
155
201
  }
156
202
 
157
- return { structure, spacing, style, fit, sizing, width: opts.width, height: opts.height, className: opts.className, debug: opts.debug };
203
+ return { structure, spacing, style, fit, sizingHorizontal, sizingVertical, width: opts.width, height: opts.height, className: opts.className, contentPadding: opts.contentPadding ?? 0, debug: opts.debug };
204
+ }
205
+
206
+ /**
207
+ * Resolve final SVG canvas width/height + viewBox.
208
+ *
209
+ * When either axis uses 'content' sizing, computes the BBox from lines.
210
+ */
211
+ function resolveSize(lines: Line[], opts: ResolvedOptions): { width: number; height: number; viewBox: { x: number; y: number; w: number; h: number }; frameWidth?: number; frameHeight?: number } {
212
+ const needsBBox = opts.sizingHorizontal === 'content' || opts.sizingVertical === 'content';
213
+ const bbox = needsBBox ? computeBBox(lines) : null;
214
+
215
+ // Original frame dimensions (from user options) — used for frameBox overlay.
216
+ // Only set when the user explicitly provided a value, regardless of sizing mode.
217
+ // (sizing='frame' uses opts.width/height; sizing='content' may also have a frame value
218
+ // passed explicitly for frameBox purposes.)
219
+ const frameWidth = opts.width;
220
+ const frameHeight = opts.height;
221
+
222
+ let width: number;
223
+ let height: number;
224
+
225
+ if (opts.sizingHorizontal === 'content') {
226
+ width = bbox!.width;
227
+ } else {
228
+ if (opts.width === undefined) {
229
+ throw new Error(
230
+ `renderToSVG: horizontal sizing="frame" requires explicit width. ` +
231
+ `Got width=${opts.width}.`
232
+ );
233
+ }
234
+ width = opts.width;
235
+ }
236
+
237
+ if (opts.sizingVertical === 'content') {
238
+ height = bbox!.height;
239
+ } else {
240
+ if (opts.height === undefined) {
241
+ throw new Error(
242
+ `renderToSVG: vertical sizing="frame" requires explicit height. ` +
243
+ `Got height=${opts.height}.`
244
+ );
245
+ }
246
+ height = opts.height;
247
+ }
248
+
249
+ // viewBox: derived per-axis. If an axis uses 'content' sizing, use the content bbox
250
+ // for that axis; otherwise use the frame dimension.
251
+ let viewBox: { x: number; y: number; w: number; h: number };
252
+ if (bbox) {
253
+ viewBox = {
254
+ x: opts.sizingHorizontal === 'content' ? bbox.x : 0,
255
+ y: opts.sizingVertical === 'content' ? bbox.y : 0,
256
+ w: opts.sizingHorizontal === 'content' ? bbox.width : width,
257
+ h: opts.sizingVertical === 'content' ? bbox.height : height,
258
+ };
259
+ } else {
260
+ viewBox = { x: 0, y: 0, w: width, h: height };
261
+ }
262
+
263
+ // Apply contentPadding: enlarge canvas and shift viewBox so padded area is visible
264
+ const pad = opts.contentPadding || 0;
265
+ if (pad > 0) {
266
+ width += pad * 2;
267
+ height += pad * 2;
268
+ viewBox = {
269
+ x: viewBox.x - pad,
270
+ y: viewBox.y - pad,
271
+ w: viewBox.w + pad * 2,
272
+ h: viewBox.h + pad * 2,
273
+ };
274
+ }
275
+
276
+ return { width, height, viewBox, frameWidth, frameHeight };
158
277
  }
159
278
 
160
279
  // ── Attribute builders ───────────────────────────────────────────────────
@@ -166,78 +285,117 @@ interface StyleState {
166
285
  color: string;
167
286
  fontStyle: string;
168
287
  decoration: string;
288
+ letterSpacing?: number;
289
+ backgroundColor?: string;
169
290
  }
170
291
 
171
- function defaultStyleState(frag: FragmentBox): StyleState {
292
+ function defaultStyleState(span: Span): StyleState {
293
+ const decorations: string[] = [];
294
+ if (span.style.underline) decorations.push('underline');
295
+ if (span.style.strikethrough) decorations.push('line-through');
296
+
172
297
  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' : '',
298
+ fontFamily: span.style.fontFamily || 'Arial',
299
+ fontSize: span.fontMetrics.fontSize || 16,
300
+ fontWeight: fontWeightNumeric(span.style.fontWeight),
301
+ color: span.style.color || '#000000',
302
+ fontStyle: span.style.fontStyle || 'normal',
303
+ decoration: decorations.join(' '),
304
+ letterSpacing: span.style.letterSpacing,
305
+ backgroundColor: span.style.backgroundColor,
179
306
  };
180
307
  }
181
308
 
182
309
  function equalStyle(a: StyleState, b: StyleState): boolean {
183
310
  return a.fontFamily === b.fontFamily && a.fontSize === b.fontSize &&
184
311
  a.fontWeight === b.fontWeight && a.color === b.color &&
185
- a.fontStyle === b.fontStyle && a.decoration === b.decoration;
312
+ a.fontStyle === b.fontStyle && a.decoration === b.decoration &&
313
+ a.letterSpacing === b.letterSpacing &&
314
+ a.backgroundColor === b.backgroundColor;
315
+ }
316
+
317
+ /**
318
+ * Compute a deterministic string key for a span's style.
319
+ * Used to group spans by identical style when building <text> elements.
320
+ */
321
+ function styleSignature(span: Span): string {
322
+ const s = defaultStyleState(span);
323
+ return `${s.fontFamily}|${s.fontSize}|${s.fontWeight}|${s.color}|${s.fontStyle}|${s.decoration}|${s.letterSpacing ?? ''}|${s.backgroundColor ?? ''}`;
186
324
  }
187
325
 
188
326
  /** Build style string for CSS mode */
189
327
  function cssStyleString(s: StyleState): string {
190
328
  const parts: string[] = [];
191
329
  parts.push(`font-family: '${s.fontFamily}', sans-serif`);
192
- parts.push(`font-size: ${s.fontSize}px`);
330
+ parts.push(`font-size: ${fmt(s.fontSize)}px`);
193
331
  parts.push(`fill: ${colorToRGB(s.color)}`);
194
332
  if (s.fontWeight !== 400) parts.push(`font-weight: ${s.fontWeight}`);
195
333
  if (s.fontStyle === 'italic') parts.push(`font-style: italic`);
196
334
  if (s.decoration) parts.push(`text-decoration: ${s.decoration}`);
335
+ if (s.letterSpacing !== undefined && s.letterSpacing !== 0) parts.push(`letter-spacing: ${fmt(s.letterSpacing)}px`);
197
336
  return parts.join('; ');
198
337
  }
199
338
 
200
339
  /** Build XML presentation attributes for a style */
201
340
  function xmlStyleAttrs(s: StyleState): string {
202
- let attrs = `font-family="${s.fontFamily}" font-size="${s.fontSize}" fill="${s.color}" font-weight="${s.fontWeight}"`;
341
+ let attrs = `font-family="${s.fontFamily}" font-size="${fmt(s.fontSize)}" fill="${s.color}" font-weight="${s.fontWeight}"`;
203
342
  if (s.fontStyle === 'italic') attrs += ' font-style="italic"';
204
343
  if (s.decoration) attrs += ` text-decoration="${s.decoration}"`;
344
+ if (s.letterSpacing !== undefined && s.letterSpacing !== 0) attrs += ` letter-spacing="${fmt(s.letterSpacing)}"`;
205
345
  return attrs;
206
346
  }
207
347
 
208
- /** Build attributes for <text> element */
209
- function buildTextAttrs(line: LineBox, frag: FragmentBox, opts: ResolvedOptions, runId?: string): string {
348
+ /** Build attributes for <text> element.
349
+ *
350
+ * NOTE: `text-decoration` and `letter-spacing` are intentionally NOT added
351
+ * here because they would be inherited by all child `<tspan>` elements.
352
+ * SVG text-decoration on `<text>` cascades to ALL `<tspan>` descendants,
353
+ * even those that should NOT have decoration. These attributes are set
354
+ * on `<tspan>` level by `buildTspanAttrs()` instead.
355
+ *
356
+ * Flat mode (no `<tspan>`) adds them separately in the flat render path.
357
+ */
358
+ function buildTextAttrs(line: Line, span: Span, opts: ResolvedOptions, runId?: string): Record<string, string | number> {
210
359
  const x = line.x;
211
360
  const y = line.y + line.baseline;
212
- const s = defaultStyleState(frag);
361
+ const s = defaultStyleState(span);
213
362
 
214
- let attrs = ` x="${x}" y="${y}"`;
215
- if (runId) attrs += ` id="${runId}"`;
363
+ const attrs: Record<string, string | number> = {
364
+ x: fmt(x),
365
+ y: fmt(y),
366
+ };
367
+ if (runId) attrs.id = runId;
216
368
 
217
369
  if (opts.style === 'css') {
218
- let css = cssStyleString(s);
370
+ // text-decoration and letter-spacing are intentionally excluded
371
+ // from <text> to prevent inheritance by child <tspan> elements.
372
+ let css = `font-family: '${s.fontFamily}', sans-serif; font-size: ${fmt(s.fontSize)}px; fill: ${colorToRGB(s.color)}; font-weight: ${s.fontWeight}`;
373
+ if (s.fontStyle === 'italic') css += `; font-style: italic`;
219
374
  if (opts.spacing === 'preserve') css += '; white-space: pre';
220
- attrs += ` style="${css}"`;
375
+ attrs.style = css;
221
376
  } else {
222
- attrs += ` ${xmlStyleAttrs(s)}`;
223
- if (opts.spacing === 'preserve') attrs += ' xml:space="preserve"';
377
+ // Flatten xmlStyleAttrs result into individual attrs
378
+ attrs['font-family'] = s.fontFamily;
379
+ attrs['font-size'] = fmt(s.fontSize);
380
+ attrs.fill = s.color;
381
+ attrs['font-weight'] = s.fontWeight;
382
+ if (s.fontStyle === 'italic') attrs['font-style'] = 'italic';
383
+ if (opts.spacing === 'preserve') attrs['xml:space'] = 'preserve';
224
384
  }
225
385
 
226
- // text-anchor is only meaningful for flat mode where text sits directly in <text>.
227
- // For expanded/glyph modes, each <tspan> has explicit x="..." coordinates that already
228
- // account for alignment — text-anchor on <text> would then double-shift the text.
229
- if (opts.structure === 'flat') {
230
- const anchor = line.alignment === 'center' ? 'middle' : line.alignment === 'right' ? 'end' : 'start';
231
- if (anchor !== 'start') attrs += ` text-anchor="${anchor}"`;
232
- }
386
+ // text-anchor is intentionally NOT used in flat mode.
387
+ // PositioningEngine already accounts for alignment by shifting line.x.
388
+ // Adding text-anchor would double-shift the text.
233
389
 
234
390
  return attrs;
235
391
  }
236
392
 
237
393
  /** 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);
240
- let attrs = ` x="${x}"`;
394
+ function buildTspanAttrs(span: Span, x: number, currentStyle: StyleState | null): { attrs: Record<string, string | number>; newStyle: StyleState } {
395
+ const s = defaultStyleState(span);
396
+ const attrs: Record<string, string | number> = {
397
+ x: fmt(x),
398
+ };
241
399
 
242
400
  if (currentStyle && equalStyle(s, currentStyle)) {
243
401
  return { attrs, newStyle: s };
@@ -246,121 +404,428 @@ function buildTspanAttrs(frag: FragmentBox, x: number, currentStyle: StyleState
246
404
  // textLength is NOT added here — it is handled by buildFragFitAttr() separately
247
405
  // to avoid duplicate textLength when fit='frag'.
248
406
 
249
- if (!currentStyle || s.fontWeight !== currentStyle.fontWeight) attrs += ` font-weight="${s.fontWeight}"`;
250
- if (!currentStyle || s.fontStyle !== currentStyle.fontStyle) attrs += ` font-style="${s.fontStyle}"`;
251
- if (!currentStyle || s.fontFamily !== currentStyle.fontFamily) attrs += ` font-family="${s.fontFamily}"`;
252
- if (!currentStyle || s.fontSize !== currentStyle.fontSize) attrs += ` font-size="${s.fontSize}"`;
253
- if (!currentStyle || s.color !== currentStyle.color) attrs += ` fill="${s.color}"`;
407
+ if (!currentStyle || s.fontWeight !== currentStyle.fontWeight) attrs['font-weight'] = s.fontWeight;
408
+ if (!currentStyle || s.fontStyle !== currentStyle.fontStyle) attrs['font-style'] = s.fontStyle;
409
+ if (!currentStyle || s.fontFamily !== currentStyle.fontFamily) attrs['font-family'] = s.fontFamily;
410
+ if (!currentStyle || s.fontSize !== currentStyle.fontSize) attrs['font-size'] = fmt(s.fontSize);
411
+ if (!currentStyle || s.color !== currentStyle.color) attrs.fill = s.color;
254
412
  if (!currentStyle || s.decoration !== currentStyle.decoration) {
255
- if (s.decoration) attrs += ` text-decoration="${s.decoration}"`;
413
+ if (s.decoration) attrs['text-decoration'] = s.decoration;
414
+ }
415
+ if (!currentStyle || s.letterSpacing !== currentStyle.letterSpacing) {
416
+ if (s.letterSpacing !== undefined && s.letterSpacing !== 0) attrs['letter-spacing'] = fmt(s.letterSpacing);
256
417
  }
257
418
 
258
419
  return { attrs, newStyle: s };
259
420
  }
260
421
 
261
- /** Build per-glyph x positions for glyph mode */
262
- function buildGlyphPositions(frag: FragmentBox, _lineX: number): string {
263
- if (!frag.glyphAdvances || frag.glyphAdvances.length === 0) {
422
+ /** Build per-glyph x positions for glyph mode.
423
+ *
424
+ * Accounts for letterSpacing by adding it to each glyph advance.
425
+ * The `letter-spacing` attribute should NOT be set on `<tspan>` when
426
+ * using glyph mode, because the spacing is already baked into the x positions.
427
+ */
428
+ function buildGlyphPositions(span: Span, _lineX: number): string {
429
+ if (!span.glyphAdvances || span.glyphAdvances.length === 0) {
264
430
  return '';
265
431
  }
266
- // frag.x is already absolute — computed by PositioningEngine.
432
+ // span.x is already absolute — computed by PositioningEngine.
267
433
  // lineX is NOT added because that would double-shift.
268
- const fragX = frag.x;
269
- let xPos = fragX;
270
- const positions: string[] = [xPos.toFixed(1)];
271
- for (let i = 0; i < frag.glyphAdvances.length - 1; i++) {
272
- xPos += frag.glyphAdvances[i];
273
- positions.push(xPos.toFixed(1));
434
+ const ls = span.style.letterSpacing || 0;
435
+ const spanX = span.x;
436
+ let xPos = spanX;
437
+ const positions: string[] = [fmt(xPos, 1)];
438
+ for (let i = 0; i < span.glyphAdvances.length - 1; i++) {
439
+ xPos += span.glyphAdvances[i] + ls;
440
+ positions.push(fmt(xPos, 1));
274
441
  }
275
442
  return positions.join(' ');
276
443
  }
277
444
 
278
445
  /** Build textLength attribute for a line */
279
- function buildFitAttr(line: LineBox, opts: ResolvedOptions): string {
446
+ function buildFitAttr(line: Line, opts: ResolvedOptions): Record<string, string | number> | undefined {
280
447
  if (opts.fit === 'text') {
281
- return ` textLength="${line.width}" lengthAdjust="spacing"`;
448
+ return { textLength: fmt(line.width), lengthAdjust: 'spacing' };
282
449
  }
283
- return '';
450
+ return undefined;
284
451
  }
285
452
 
286
- /** Build textLength for a fragment */
287
- function buildFragFitAttr(frag: FragmentBox, opts: ResolvedOptions): string {
453
+ /** Build textLength for a span */
454
+ function buildSpanFitAttr(span: Span, opts: ResolvedOptions): Record<string, string | number> | undefined {
288
455
  if (opts.fit === 'frag') {
289
- return ` textLength="${frag.width}"`;
456
+ return { textLength: fmt(span.width) };
457
+ }
458
+ return undefined;
459
+ }
460
+
461
+ // ── SVG AST helpers ──────────────────────────────────────────────────────
462
+
463
+ /**
464
+ * Create an SVG element node.
465
+ */
466
+ function el(tag: string, attrs: Record<string, string | number | undefined> = {}, children: SvgNode[] = []): SvgElement {
467
+ // Strip undefined values from attrs
468
+ const cleanAttrs: Record<string, string | number> = {};
469
+ for (const [k, v] of Object.entries(attrs)) {
470
+ if (v !== undefined) {
471
+ cleanAttrs[k] = v;
472
+ }
290
473
  }
291
- return '';
474
+ return { type: 'element', tag, attrs: cleanAttrs, children };
475
+ }
476
+
477
+ /**
478
+ * Create an SVG text node (escaped on serialization).
479
+ */
480
+ function textNode(value: string): SvgNode {
481
+ return { type: 'text', value };
292
482
  }
293
483
 
294
- // ── SVG builder ──────────────────────────────────────────────────────────
484
+ /**
485
+ * Create an SVG raw node (output verbatim, no escaping).
486
+ */
487
+ function rawNode(value: string): SvgNode {
488
+ return { type: 'raw', value };
489
+ }
295
490
 
296
- class SvgBuilder {
297
- private parts: string[] = [];
491
+ // ── Serializer ───────────────────────────────────────────────────────────
492
+
493
+ /**
494
+ * Serialize an SVG AST node to a string.
495
+ * Uses an indent level for pretty-printing.
496
+ */
497
+ function serializeSvg(node: SvgNode, indent = 0): string {
498
+ const pad = ' '.repeat(indent);
499
+
500
+ switch (node.type) {
501
+ case 'text':
502
+ return escapeXml(node.value);
503
+
504
+ case 'raw':
505
+ return node.value;
506
+
507
+ case 'comment':
508
+ return `${pad}<!-- ${node.value} -->\n`;
509
+
510
+ case 'element': {
511
+ const tag = node.tag;
512
+ const attrsStr = Object.entries(node.attrs)
513
+ .map(([k, v]) => `${k}="${v}"`)
514
+ .join(' ');
515
+
516
+ if (node.children.length === 0) {
517
+ return `${pad}<${tag}${attrsStr ? ' ' + attrsStr : ''} />\n`;
518
+ }
519
+
520
+ // If all children are text nodes, render inline (same line as opening tag).
521
+ // This matches the old string-push behavior where <tspan>text</tspan>
522
+ // was emitted as a single string without extra newlines.
523
+ const allTextChildren = node.children.every(c => c.type === 'text');
524
+ if (allTextChildren) {
525
+ const text = node.children.map(c => (c as any).value).join('');
526
+ return `${pad}<${tag}${attrsStr ? ' ' + attrsStr : ''}>${escapeXml(text)}</${tag}>\n`;
527
+ }
528
+
529
+ const openTag = `${pad}<${tag}${attrsStr ? ' ' + attrsStr : ''}>\n`;
530
+ const childrenStr = node.children.map(c => serializeSvg(c, indent + 1)).join('');
531
+ const closeTag = `${pad}</${tag}>\n`;
532
+
533
+ return openTag + childrenStr + closeTag;
534
+ }
535
+ }
536
+ }
537
+
538
+ // ── SVG AST Builder ──────────────────────────────────────────────────────
539
+
540
+ /**
541
+ * Builds an SVG AST tree. Unlike the old imperative SvgBuilder (which pushed
542
+ * strings and relied on manual open/close tracking), this builder maintains
543
+ * a tree of SvgElement nodes. The tree structure guarantees that:
544
+ * - <tspan> nodes are always children of a <text> node
545
+ * - No orphaned closing tags
546
+ * - Correct nesting is enforced at the data level, not by call order
547
+ */
548
+ class SvgAstBuilder {
549
+ /** The root <svg> element. */
550
+ readonly root: SvgElement;
551
+ /** Reference to the currently active <text> element (if any). */
552
+ private currentText: SvgElement | null = null;
298
553
  private opts: ResolvedOptions;
299
554
 
300
- constructor(width: number, height: number, opts: ResolvedOptions) {
555
+ constructor(width: number, height: number, opts: ResolvedOptions, viewBox?: { x: number; y: number; w: number; h: number }) {
301
556
  this.opts = opts;
302
- const className = opts.className ? ` class="${escapeXml(opts.className)}"` : '';
303
- this.parts.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"${className}>\n`);
557
+ const svgAttrs: Record<string, string | number | undefined> = {
558
+ xmlns: 'http://www.w3.org/2000/svg',
559
+ width: fmt(width),
560
+ height: fmt(height),
561
+ viewBox: viewBox
562
+ ? `${fmt(viewBox.x)} ${fmt(viewBox.y)} ${fmt(viewBox.w)} ${fmt(viewBox.h)}`
563
+ : `0 0 ${fmt(width)} ${fmt(height)}`,
564
+ };
304
565
  if (opts.className) {
305
- this.parts[0] = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" class="${escapeXml(opts.className)}">\n`;
566
+ svgAttrs.class = opts.className;
306
567
  }
568
+ this.root = el('svg', svgAttrs);
307
569
  }
308
570
 
309
- addText(line: LineBox, baseFrag: FragmentBox, runId?: string): void {
310
- const attrs = buildTextAttrs(line, baseFrag, this.opts, runId);
311
- const fit = buildFitAttr(line, this.opts);
312
- this.parts.push(` <text${attrs}${fit}>\n`);
571
+ /**
572
+ * Open a new <text> element.
573
+ * Closes any previously open <text> automatically.
574
+ */
575
+ openText(line: Line, baseSpan: Span, runId?: string, yOverride?: number, fontSizeOverride?: number): void {
576
+ // Close any open text first
577
+ this.closeText();
578
+
579
+ let textAttrs: Record<string, string | number>;
580
+ if (yOverride !== undefined && fontSizeOverride !== undefined) {
581
+ // For sub/superscript — override y and font-size.
582
+ // text-decoration and letter-spacing intentionally excluded from <text>
583
+ // to prevent inheritance by child <tspan> elements (expanded mode).
584
+ const s = defaultStyleState(baseSpan);
585
+ const x = line.x;
586
+ const attrs: Record<string, string | number> = {
587
+ x: fmt(x),
588
+ y: fmt(yOverride),
589
+ };
590
+ if (this.opts.style === 'css') {
591
+ let css = `font-family: '${s.fontFamily}', sans-serif; font-size: ${fmt(fontSizeOverride)}px; fill: ${colorToRGB(s.color)}; font-weight: ${s.fontWeight}`;
592
+ if (s.fontStyle === 'italic') css += `; font-style: italic`;
593
+ if (this.opts.spacing === 'preserve') css += '; white-space: pre';
594
+ attrs.style = css;
595
+ } else {
596
+ attrs['font-family'] = s.fontFamily;
597
+ attrs['font-size'] = fmt(fontSizeOverride);
598
+ attrs.fill = s.color;
599
+ attrs['font-weight'] = s.fontWeight;
600
+ if (s.fontStyle === 'italic') attrs['font-style'] = 'italic';
601
+ if (this.opts.spacing === 'preserve') attrs['xml:space'] = 'preserve';
602
+ }
603
+ textAttrs = attrs;
604
+ } else {
605
+ textAttrs = buildTextAttrs(line, baseSpan, this.opts, runId);
606
+ }
607
+
608
+ const fitAttrs = buildFitAttr(line, this.opts);
609
+ if (fitAttrs) {
610
+ Object.assign(textAttrs, fitAttrs);
611
+ }
612
+
613
+ const textEl = el('text', textAttrs);
614
+ this.root.children.push(textEl);
615
+ this.currentText = textEl;
313
616
  }
314
617
 
315
- addFlatFrag(text: string): void {
316
- this.parts.push(` ${escapeXml(text)}`);
618
+ /**
619
+ * Add raw SVG markup as a direct child of the root <svg>.
620
+ * Used for pre-rendered debug overlays.
621
+ */
622
+ addDebug(debugMarkup: string): void {
623
+ if (debugMarkup) {
624
+ this.root.children.push(rawNode(`<!-- debug overlay -->\n${debugMarkup}\n`));
625
+ }
626
+ }
627
+
628
+ /**
629
+ * Add text content (flat mode — no <tspan>).
630
+ * Text is escaped automatically on serialization.
631
+ */
632
+ addTextContent(text: string): void {
633
+ if (this.currentText) {
634
+ this.currentText.children.push(textNode(text));
635
+ }
317
636
  }
318
637
 
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`);
638
+ /**
639
+ * Add an expanded <tspan> node to the current <text> element.
640
+ */
641
+ addTspan(span: Span, x: number, style: StyleState | null): StyleState {
642
+ const { attrs, newStyle } = buildTspanAttrs(span, x, style);
643
+ const fitAttrs = buildSpanFitAttr(span, this.opts);
644
+ if (fitAttrs) {
645
+ Object.assign(attrs, fitAttrs);
646
+ }
647
+ const tspan = el('tspan', attrs, [textNode(span.text)]);
648
+ if (this.currentText) {
649
+ this.currentText.children.push(tspan);
650
+ }
323
651
  return newStyle;
324
652
  }
325
653
 
326
- addGlyphFrag(frag: FragmentBox, lineX: number): void {
327
- const positions = buildGlyphPositions(frag, lineX);
654
+ /**
655
+ * Add a glyph-positioned <tspan> node to the current <text> element.
656
+ */
657
+ addGlyphTspan(span: Span, lineX: number): void {
658
+ const positions = buildGlyphPositions(span, lineX);
659
+ const attrs: Record<string, string | number> = {};
328
660
  if (positions) {
329
- this.parts.push(` <tspan x="${positions}">${escapeXml(frag.text)}</tspan>\n`);
330
- } else {
331
- this.parts.push(` <tspan>${escapeXml(frag.text)}</tspan>\n`);
661
+ attrs.x = positions;
662
+ }
663
+ const tspan = el('tspan', attrs, [textNode(span.text)]);
664
+ if (this.currentText) {
665
+ this.currentText.children.push(tspan);
332
666
  }
333
667
  }
334
668
 
335
- closeText(): void {
336
- this.parts.push(' </text>\n');
669
+ /**
670
+ * Add a background rect as a direct child of the root <svg>.
671
+ * Used for highlight marker (backgroundColor on spans).
672
+ * The rect is placed before any <text> elements so it renders underneath.
673
+ */
674
+ addBackgroundRect(x: number, y: number, width: number, height: number, color: string): void {
675
+ this.closeText();
676
+ const rect = el('rect', {
677
+ x: fmt(x),
678
+ y: fmt(y),
679
+ width: fmt(width),
680
+ height: fmt(height),
681
+ fill: color,
682
+ });
683
+ this.root.children.push(rect);
337
684
  }
338
685
 
339
- addDebug(debugOverlay: string): void {
340
- if (debugOverlay) {
341
- this.parts.push(`<!-- debug overlay -->\n${debugOverlay}\n`);
342
- }
686
+ /**
687
+ * Add a pre-rendered SVG line as a raw node directly under root.
688
+ * Used in flat mode when each span is its own <text>.
689
+ */
690
+ addRawLine(lineStr: string): void {
691
+ this.closeText();
692
+ this.root.children.push(rawNode(lineStr));
343
693
  }
344
694
 
345
- build(): string {
346
- this.parts.push('</svg>\n');
347
- return this.parts.join('');
695
+ /**
696
+ * Close the currently open <text> element.
697
+ * Safe to call multiple times — no-op if no text is open.
698
+ */
699
+ closeText(): void {
700
+ this.currentText = null;
348
701
  }
349
702
  }
350
703
 
351
704
  // ── Debug overlay ────────────────────────────────────────────────────────
352
705
 
353
- function renderDebugToSVG(lines: LineBox[], width: number, height: number, flags: DebugFlags): string {
706
+ function renderDebugToSVG(
707
+ lines: Line[],
708
+ flags: DebugFlags,
709
+ frameSize?: { width: number; height: number },
710
+ contentSize?: { width: number; height: number },
711
+ columns?: MultiColumnConfig,
712
+ leftPad?: number,
713
+ rightPad?: number,
714
+ ): string {
354
715
  const parts: string[] = [];
716
+ const sw = flags.widthBorder ?? 1;
355
717
 
356
- if (flags.frame && lines.length > 0) {
357
- const first = lines[0];
358
- const last = lines[lines.length - 1];
359
- const maxW = Math.max(...lines.map(l => l.x + l.width));
718
+ // Frame container bounding box
719
+ if ((flags.frameBox || flags.frame) && frameSize) {
360
720
  parts.push(
361
- ` <rect x="${first.x}" y="${first.y}" width="${maxW - first.x}" height="${last.y + last.height - first.y}"` +
362
- ` fill="none" stroke="rgba(255,200,0,0.6)" stroke-width="1" stroke-dasharray="6,2" />`,
721
+ ` <rect x="0" y="0" width="${fmt(frameSize.width)}" height="${fmt(frameSize.height)}"` +
722
+ ` fill="none" stroke="rgba(0,140,255,0.8)" stroke-width="${fmt(sw)}" stroke-dasharray="4,3" />`,
363
723
  );
724
+ if (flags.labels) {
725
+ parts.push(
726
+ ` <text x="4" y="14" font-size="10" fill="rgba(0,140,255,0.9)" font-family="monospace">frame ${fmt(frameSize.width)}×${fmt(frameSize.height)}</text>`,
727
+ );
728
+ }
729
+ }
730
+
731
+ // Content bounding box
732
+ if (flags.contentBox) {
733
+ const bbox = computeBBox(lines);
734
+ parts.push(
735
+ ` <rect x="${fmt(bbox.x)}" y="${fmt(bbox.y)}" width="${fmt(bbox.width)}" height="${fmt(bbox.height)}"` +
736
+ ` fill="none" stroke="rgba(255,60,140,0.8)" stroke-width="${fmt(sw)}" stroke-dasharray="1,2" />`,
737
+ );
738
+ if (flags.labels) {
739
+ const labelY = bbox.y + bbox.height + 14;
740
+ parts.push(
741
+ ` <text x="${fmt(bbox.x)}" y="${fmt(labelY)}" font-size="10" fill="rgba(255,60,140,0.9)" font-family="monospace">content ${fmt(bbox.width)}×${fmt(bbox.height)}</text>`,
742
+ );
743
+ }
744
+ }
745
+
746
+ // Overflow warning when content exceeds frame
747
+ if (flags.contentBox && frameSize && contentSize && flags.labels) {
748
+ const overflowX = contentSize.width > frameSize.width;
749
+ const overflowY = contentSize.height > frameSize.height;
750
+ if (overflowX || overflowY) {
751
+ parts.push(
752
+ ` <text x="4" y="${fmt(frameSize.height + 14)}" font-size="10" fill="rgba(220,0,0,0.9)" font-family="monospace">⚠ content overflow: ${overflowX ? `Δx=${fmt(contentSize.width - frameSize.width)} ` : ''}${overflowY ? `Δy=${fmt(contentSize.height - frameSize.height)}` : ''}</text>`,
753
+ );
754
+ }
755
+ }
756
+
757
+ // ── Column separators ────────────────────────────────────────────
758
+ if (columns && columns.count > 1 && (flags.paragraphBox || flags.columnBox)) {
759
+ const colCount = columns.count;
760
+ const colGap = columns.gap;
761
+ const lp = leftPad ?? 0;
762
+ // Calculate colWidth from frame or content
763
+ const totalHorizontalSpace = frameSize?.width ?? (lines.length > 0 ? Math.max(...lines.map(l => l.x + l.width)) : 0);
764
+ const usableWidth = totalHorizontalSpace - lp - (rightPad ?? 0);
765
+ const colWidth = (usableWidth - (colCount - 1) * colGap) / colCount;
766
+ for (let c = 1; c < colCount; c++) {
767
+ const sepX = lp + c * (colWidth + colGap) - colGap / 2;
768
+ parts.push(
769
+ ` <line x1="${fmt(sepX)}" y1="0" x2="${fmt(sepX)}" y2="${fmt(frameSize?.height ?? 9999)}"` +
770
+ ` stroke="rgba(100,100,100,0.15)" stroke-width="1" stroke-dasharray="2,2" />`,
771
+ );
772
+ }
773
+ }
774
+
775
+ // Paragraph bounding boxes — per-column
776
+ if (flags.paragraphBox) {
777
+ const paraGroups = groupLinesByParagraph(lines);
778
+ const paraColors = [
779
+ 'rgba(0,180,80,0.25)',
780
+ 'rgba(180,0,80,0.25)',
781
+ 'rgba(80,0,180,0.25)',
782
+ 'rgba(180,180,0,0.25)',
783
+ ];
784
+
785
+ for (let i = 0; i < paraGroups.length; i++) {
786
+ const group = paraGroups[i];
787
+ if (group.lines.length === 0) continue;
788
+
789
+ // Group lines within this paragraph by columnIndex
790
+ const colMap = new Map<number, { top: number; bottom: number }>();
791
+ for (const line of group.lines) {
792
+ const ci = line.columnIndex ?? 0;
793
+ const existing = colMap.get(ci);
794
+ const lineTop = line.y;
795
+ const lineBottom = line.y + line.height;
796
+ if (existing) {
797
+ existing.top = Math.min(existing.top, lineTop);
798
+ existing.bottom = Math.max(existing.bottom, lineBottom);
799
+ } else {
800
+ colMap.set(ci, { top: lineTop, bottom: lineBottom });
801
+ }
802
+ }
803
+
804
+ // For non-column layout, colX = 0, colW = containerRight
805
+ // For column layout, calculate per-column position
806
+ const lp = leftPad ?? 0;
807
+ const rp = rightPad ?? 0;
808
+ const totalW = frameSize?.width ?? (lines.length > 0 ? Math.max(...lines.map(l => l.x + l.width)) : 0);
809
+ const usableW = totalW - lp - rp;
810
+ const colCount = columns?.count ?? 1;
811
+ const colGap = columns?.gap ?? 0;
812
+ const colW = (usableW - (colCount - 1) * colGap) / colCount;
813
+
814
+ const color = paraColors[i % paraColors.length];
815
+ for (const [ci, rect] of colMap) {
816
+ const colX = lp + ci * (colW + colGap);
817
+ parts.push(` <rect x="${fmt(colX)}" y="${fmt(rect.top)}" width="${fmt(colW)}" height="${fmt(rect.bottom - rect.top)}" fill="none" stroke="${color}" stroke-width="${fmt(sw)}" />`);
818
+ }
819
+
820
+ const label = group.tag ? `#${group.pIdx} ${group.tag}` : `#${group.pIdx}`;
821
+ if (flags.labels) {
822
+ // Place label at top-left of the first column for this paragraph
823
+ const firstColIdx = Math.min(...Array.from(colMap.keys()));
824
+ const firstColX = lp + firstColIdx * (colW + colGap);
825
+ const firstTop = colMap.get(firstColIdx)!.top;
826
+ parts.push(` <text x="${fmt(firstColX + 4)}" y="${fmt(firstTop - 2)}" font-size="9" fill="rgba(0,0,0,0.6)" font-family="monospace">¶ ${label}</text>`);
827
+ }
828
+ }
364
829
  }
365
830
 
366
831
  for (const line of lines) {
@@ -368,27 +833,27 @@ function renderDebugToSVG(lines: LineBox[], width: number, height: number, flags
368
833
  const baselineY = line.y + line.baseline;
369
834
 
370
835
  if (flags.lineGap) {
371
- parts.push(` <rect x="${bx}" y="${by}" width="${bw}" height="${bh}" fill="rgba(0,150,255,0.10)" stroke="none" />`);
836
+ parts.push(` <rect x="${fmt(bx)}" y="${fmt(by)}" width="${fmt(bw)}" height="${fmt(bh)}" fill="rgba(0,150,255,0.10)" stroke="none" />`);
372
837
  }
373
838
  if (flags.box) {
374
- parts.push(` <rect x="${bx}" y="${by}" width="${bw}" height="${bh}" fill="none" stroke="rgba(255,100,100,0.5)" stroke-width="1" />`);
839
+ parts.push(` <rect x="${fmt(bx)}" y="${fmt(by)}" width="${fmt(bw)}" height="${fmt(bh)}" fill="none" stroke="rgba(255,100,100,0.5)" stroke-width="${fmt(sw)}" />`);
375
840
  }
376
841
  if (flags.baseline) {
377
- parts.push(` <line x1="${bx}" y1="${baselineY}" x2="${bx + bw}" y2="${baselineY}" stroke="rgba(100,100,255,0.5)" stroke-width="1" />`);
842
+ parts.push(` <line x1="${fmt(bx)}" y1="${fmt(baselineY)}" x2="${fmt(bx + bw)}" y2="${fmt(baselineY)}" stroke="rgba(100,100,255,0.5)" stroke-width="${fmt(sw)}" />`);
378
843
  }
379
844
  if (flags.ascentDescent) {
380
- 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" />`);
381
- 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" />`);
845
+ parts.push(` <line x1="${fmt(bx)}" y1="${fmt(baselineY - line.ascent)}" x2="${fmt(bx + bw)}" y2="${fmt(baselineY - line.ascent)}" stroke="rgba(100,255,100,0.4)" stroke-width="${fmt(sw)}" stroke-dasharray="3,2" />`);
846
+ parts.push(` <line x1="${fmt(bx)}" y1="${fmt(baselineY + line.descent)}" x2="${fmt(bx + bw)}" y2="${fmt(baselineY + line.descent)}" stroke="rgba(100,255,100,0.4)" stroke-width="${fmt(sw)}" stroke-dasharray="3,2" />`);
382
847
  }
383
848
  if (flags.labels) {
384
- 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>`);
849
+ parts.push(` <text x="${fmt(bx)}" y="${fmt(by - 2)}" font-size="9" fill="rgba(0,0,0,0.55)" font-family="monospace">y=${fmt(by)} x=${fmt(bx)} w=${fmt(bw)} h=${fmt(bh)} bl=${fmt(baselineY)}</text>`);
385
850
  }
386
851
  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" />`);
852
+ for (const span of line.spans) {
853
+ if (span.width <= 0) continue;
854
+ const rx = line.x + span.x;
855
+ const ry = baselineY - span.fontMetrics.ascent;
856
+ parts.push(` <rect x="${fmt(rx)}" y="${fmt(ry)}" width="${fmt(span.width)}" height="${fmt(span.fontMetrics.ascent + span.fontMetrics.descent)}" fill="none" stroke="rgba(200,100,255,0.4)" stroke-width="${fmt(sw)}" />`);
392
857
  }
393
858
  }
394
859
  }
@@ -396,106 +861,215 @@ function renderDebugToSVG(lines: LineBox[], width: number, height: number, flags
396
861
  return parts.join('\n');
397
862
  }
398
863
 
864
+ // ── Background rect helper ───────────────────────────────────────────────
865
+
866
+ /**
867
+ * Compute background rect coordinates for a span.
868
+ * Returns null if the span has no backgroundColor.
869
+ */
870
+ function getSpanBackgroundAttrs(span: Span, baselineY: number): { x: number; y: number; w: number; h: number; fill: string } | null {
871
+ if (!span.style.backgroundColor) return null;
872
+ const x = span.x;
873
+ const y = baselineY - span.fontMetrics.ascent;
874
+ const w = span.width;
875
+ const h = span.fontMetrics.ascent + span.fontMetrics.descent;
876
+ return { x, y, w, h, fill: span.style.backgroundColor };
877
+ }
878
+
399
879
  // ── Main render logic ────────────────────────────────────────────────────
400
880
 
401
881
  /**
402
- * Render LineBox[] into SVG string.
882
+ * Render Line[] into SVG string.
403
883
  *
404
- * @param lines — layout lines with fragments
884
+ * @param lines — layout lines with spans
405
885
  * @param options — rendering options (preset + style/fit/sizing modifiers)
406
886
  * @returns SVG string
407
887
  */
408
- export function renderToSVG(lines: LineBox[], options: SVGRenderOptions = {}): string {
888
+ export function renderToSVG(lines: Line[], options: SVGRenderOptions = {}): string {
409
889
  const opts = resolveOptions(options);
410
890
 
411
- // Determine canvas size
412
- let svgWidth: number;
413
- let svgHeight: number;
414
-
415
- if (opts.sizing === 'content') {
416
- const bbox = computeBBox(lines);
417
- svgWidth = bbox.width;
418
- svgHeight = bbox.height;
419
- } else {
420
- // sizing='frame' requires explicit width/height — no fallback
421
- if (opts.width === undefined || opts.height === undefined) {
422
- throw new Error(
423
- `renderToSVG: sizing="frame" requires explicit width and height. ` +
424
- `Got width=${opts.width}, height=${opts.height}. ` +
425
- `Use renderResultToSVG(result, options) to auto-pass dimensions.`
426
- );
427
- }
428
- svgWidth = opts.width;
429
- svgHeight = opts.height;
430
- }
891
+ // Determine canvas size and viewBox
892
+ const { width: svgWidth, height: svgHeight, viewBox, frameWidth, frameHeight } = resolveSize(lines, opts);
431
893
 
432
- const builder = new SvgBuilder(svgWidth, svgHeight, opts);
894
+ const builder = new SvgAstBuilder(svgWidth, svgHeight, opts, viewBox);
433
895
 
434
896
  for (const line of lines) {
897
+ const baselineY = line.y + line.baseline;
898
+
899
+ // First pass: render background rects for all highlighted spans
900
+ for (const span of line.spans) {
901
+ if (!span.text || !span.style.backgroundColor) continue;
902
+ const bg = getSpanBackgroundAttrs(span, baselineY);
903
+ if (bg) {
904
+ const rx = line.x + bg.x;
905
+ builder.addBackgroundRect(rx, bg.y, bg.w, bg.h, bg.fill);
906
+ }
907
+ }
908
+
435
909
  if (opts.structure === 'glyph') {
436
910
  // Per-glyph positioning with run-based <text> grouping
437
911
  let currentRunIdx = -1;
438
- for (const frag of line.fragments) {
439
- if (!frag.text) continue;
440
- const runIdx = frag.itemIndex;
912
+ for (const span of line.spans) {
913
+ if (!span.text) continue;
914
+ const runIdx = span.itemIndex;
441
915
  if (runIdx !== currentRunIdx) {
442
- if (currentRunIdx !== -1) {
443
- builder.closeText();
444
- }
445
- const runId = frag.paragraphId ? `${frag.paragraphId}-${runIdx}` : undefined;
446
- builder.addText(line, frag, runId);
916
+ builder.closeText();
917
+ const runId = span.tag ? `${span.tag}-${runIdx}` : undefined;
918
+ builder.openText(line, span, runId);
447
919
  currentRunIdx = runIdx;
448
920
  }
449
- builder.addGlyphFrag(frag, line.x);
921
+ builder.addGlyphTspan(span, line.x);
450
922
  }
451
- if (currentRunIdx !== -1) {
452
- builder.closeText();
923
+ builder.closeText();
924
+ } else if (opts.structure === 'flat') {
925
+ // flat mode: each unique style → separate <text> element.
926
+ // Group spans by (targetY + styleSignature) so bold/normal/italic
927
+ // each get their own <text>. Never merge spans with different styles.
928
+ interface FlatGroup { spans: Span[]; targetY: number; signature: string; fontSize: number }
929
+ const groups: FlatGroup[] = [];
930
+ for (const span of line.spans) {
931
+ if (!span.text) continue;
932
+ const offset = span.fontMetrics.baselineOffset || 0;
933
+ const targetY = Math.round((line.y + line.baseline + offset) * 100) / 100;
934
+ const sig = styleSignature(span);
935
+ const fontSize = span.fontMetrics.fontSize;
936
+ const last = groups[groups.length - 1];
937
+ if (last && last.targetY === targetY && last.signature === sig) {
938
+ last.spans.push(span);
939
+ } else {
940
+ groups.push({ spans: [span], targetY, signature: sig, fontSize });
941
+ }
942
+ }
943
+ // Find the first text span's x to use as baseline offset
944
+ // Use first text or marker span's x for baseline offset
945
+ const firstTextX = line.spans.find(s => s.type === 'text' || s.type === 'marker')?.x ?? 0;
946
+ const fitAttr = buildFitAttr(line, opts);
947
+ for (const group of groups) {
948
+ const s = defaultStyleState(group.spans[0]);
949
+ const text = group.spans.map(sp => escapeXml(sp.text)).join('');
950
+ // line.x includes padding.left. span.x includes alignment offset but NOT padding.
951
+ // For single-span groups: span.x = firstTextX → x = line.x (correct for padding & alignment).
952
+ // For sub/super script: span.x differs from firstTextX → x = line.x + span.x - firstTextX.
953
+ const groupX = line.x + (group.spans[0].x - firstTextX);
954
+ const textAttrs: Record<string, string | number> = {
955
+ x: fmt(groupX),
956
+ y: fmt(group.targetY),
957
+ 'font-family': s.fontFamily,
958
+ 'font-size': fmt(group.fontSize),
959
+ fill: s.color,
960
+ 'font-weight': s.fontWeight,
961
+ };
962
+ if (s.fontStyle === 'italic') textAttrs['font-style'] = 'italic';
963
+ if (s.decoration) textAttrs['text-decoration'] = s.decoration;
964
+ if (s.letterSpacing !== undefined && s.letterSpacing !== 0) textAttrs['letter-spacing'] = fmt(s.letterSpacing);
965
+ textAttrs['xml:space'] = 'preserve';
966
+ if (fitAttr) {
967
+ Object.assign(textAttrs, fitAttr);
968
+ }
969
+ // For flat mode, use addRawLine to skip text tracking
970
+ const attrsStr = Object.entries(textAttrs)
971
+ .map(([k, v]) => `${k}="${v}"`)
972
+ .join(' ');
973
+ builder.addRawLine(` <text ${attrsStr}>${text}</text>\n`);
453
974
  }
454
975
  } 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;
976
+ // expanded: group spans by (targetY + styleSignature) so each
977
+ // unique style combination gets its own <text> element.
978
+ // Inside each group, <tspan> is used per span with diff attributes.
979
+ type TspanGroup = { targetY: number; spans: Span[]; signature: string };
980
+ const groups: TspanGroup[] = [];
981
+ for (const span of line.spans) {
982
+ if (!span.text) continue;
983
+ const offset = span.fontMetrics.baselineOffset || 0;
984
+ const targetY = Math.round((line.y + line.baseline + offset) * 100) / 100;
985
+ const sig = styleSignature(span);
986
+ const last = groups[groups.length - 1];
987
+ if (last && last.targetY === targetY && last.signature === sig) {
988
+ last.spans.push(span);
989
+ } else {
990
+ groups.push({ targetY, spans: [span], signature: sig });
991
+ }
992
+ }
458
993
 
459
- builder.addText(line, baseFrag);
994
+ for (const group of groups) {
995
+ const baseSpan = group.spans.find(f => f.type === 'text' && f.text.length > 0) || group.spans[0];
996
+ if (!baseSpan) continue;
997
+
998
+ // Only use yOverride when the group targetY differs from the line baseline
999
+ const lineBaseY = Math.round((line.y + line.baseline) * 100) / 100;
1000
+ const needsOffset = group.targetY !== lineBaseY;
1001
+ if (needsOffset) {
1002
+ // For offset groups (sub/superscript), override y and font-size from first span.
1003
+ // text-decoration and letter-spacing intentionally excluded from <text>
1004
+ // to prevent inheritance by child <tspan> elements.
1005
+ const s = defaultStyleState(baseSpan);
1006
+ const firstTextX = line.spans.find(s => s.type === 'text')?.x ?? 0;
1007
+ const groupX = line.x + (group.spans[0].x - firstTextX);
1008
+ const fontSize = baseSpan.fontMetrics.fontSize;
1009
+ const textAttrs: Record<string, string | number> = {
1010
+ x: fmt(groupX),
1011
+ y: fmt(group.targetY),
1012
+ 'font-family': s.fontFamily,
1013
+ 'font-size': fmt(fontSize),
1014
+ fill: s.color,
1015
+ 'font-weight': s.fontWeight,
1016
+ };
1017
+ if (s.fontStyle === 'italic') textAttrs['font-style'] = 'italic';
1018
+ textAttrs['xml:space'] = 'preserve';
1019
+ const fit = buildFitAttr(line, opts);
1020
+ if (fit) {
1021
+ Object.assign(textAttrs, fit);
1022
+ }
1023
+ builder.openText(line, baseSpan, undefined, group.targetY, fontSize);
1024
+ } else {
1025
+ builder.openText(line, baseSpan);
1026
+ }
460
1027
 
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
1028
  let currentStyle: StyleState | null = null;
468
- for (const frag of line.fragments) {
469
- if (!frag.text) continue;
470
- const x = frag.x;
1029
+ for (const span of group.spans) {
1030
+ if (!span.text) continue;
1031
+ const x = span.x;
471
1032
 
472
- const shouldRender = frag.type !== 'space' || opts.spacing === 'preserve';
1033
+ const shouldRender = span.type !== 'space' || opts.spacing === 'preserve';
473
1034
  if (shouldRender) {
474
- const newStyle = builder.addExpandedFrag(frag, x, currentStyle);
475
- if (frag.type !== 'space') {
1035
+ const newStyle = builder.addTspan(span, x, currentStyle);
1036
+ if (span.type !== 'space') {
476
1037
  currentStyle = newStyle;
477
1038
  }
478
1039
  }
479
1040
  }
1041
+ builder.closeText();
480
1042
  }
481
-
482
- builder.closeText();
483
1043
  }
484
1044
  }
485
1045
 
486
1046
  if (opts.debug) {
487
- const debugSvg = renderDebugToSVG(lines, svgWidth, svgHeight, opts.debug);
1047
+ // frameSize: only when both axes are explicitly set as 'frame'
1048
+ const frameSize = frameWidth !== undefined && frameHeight !== undefined
1049
+ ? { width: frameWidth, height: frameHeight }
1050
+ : undefined;
1051
+ const contentBbox = computeBBox(lines);
1052
+ const contentSize = { width: contentBbox.width, height: contentBbox.height };
1053
+ const debugSvg = renderDebugToSVG(
1054
+ lines,
1055
+ opts.debug,
1056
+ frameSize,
1057
+ contentSize,
1058
+ options.columns,
1059
+ options.paddingLeft,
1060
+ 0, // rightPad — not tracked in options yet
1061
+ );
488
1062
  builder.addDebug(debugSvg);
489
1063
  }
490
1064
 
491
- return builder.build();
1065
+ return serializeSvg(builder.root);
492
1066
  }
493
1067
 
494
1068
  /**
495
1069
  * Render one ParagraphLayoutResult to SVG (convenience wrapper).
496
1070
  */
497
1071
  export function renderParagraphToSVG(
498
- lines: LineBox[],
1072
+ lines: Line[],
499
1073
  paragraphWidth: number,
500
1074
  paragraphHeight: number,
501
1075
  options?: SVGRenderOptions,