@vyaz/renderer 0.0.2 → 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,7 +1,8 @@
1
1
  /**
2
2
  * SVGRenderer.ts — SVG text builder.
3
3
  *
4
- * Converts Line[] 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
7
  * Four presets:
7
8
  * flat — all text in one <text> element, xml:space="preserve", no <tspan>
@@ -17,9 +18,10 @@
17
18
  * const svg = renderToSVG(lines, { preset: 'preserve', style: 'css', fit: 'frag' })
18
19
  */
19
20
 
20
- import type { Line, Span, ParagraphLayoutResult } from '@vyaz/core';
21
- import type { DebugFlags } from './types.js';
22
- 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';
23
25
 
24
26
  // ── Types ────────────────────────────────────────────────────────────────
25
27
 
@@ -31,6 +33,8 @@ export type SvgFit = 'none' | 'text' | 'frag';
31
33
 
32
34
  export type SvgSizing = 'frame' | 'content';
33
35
 
36
+ export type PerAxisSizing = { horizontal: SvgSizing; vertical: SvgSizing };
37
+
34
38
  export interface SVGRenderOptions {
35
39
  /** Shorthand that sets structure + spacing at once. */
36
40
  preset?: SvgPreset;
@@ -38,16 +42,35 @@ export interface SVGRenderOptions {
38
42
  style?: SvgStyle;
39
43
  /** How `textLength` is applied. */
40
44
  fit?: SvgFit;
41
- /** How SVG determines its size: 'frame' — use width/height from options; 'content' — compute BBox from lines. */
42
- sizing?: SvgSizing;
43
- /** 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. */
44
53
  width?: number;
45
- /** SVG canvas height (px). Used when sizing='frame' or as fallback. */
54
+ /** SVG canvas height (px). Used when vertical sizing='frame' or as fallback. */
46
55
  height?: number;
47
56
  /** CSS class for `<svg>`. */
48
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;
49
65
  /** Debug overlays. */
50
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;
51
74
  }
52
75
 
53
76
  type SpacingMode = 'browser' | 'preserve';
@@ -58,10 +81,12 @@ type ResolvedOptions = {
58
81
  spacing: SpacingMode;
59
82
  style: 'css' | 'xml';
60
83
  fit: 'none' | 'text' | 'frag';
61
- sizing: 'frame' | 'content';
84
+ sizingHorizontal: 'frame' | 'content';
85
+ sizingVertical: 'frame' | 'content';
62
86
  width?: number;
63
87
  height?: number;
64
88
  className?: string;
89
+ contentPadding: number;
65
90
  debug?: DebugFlags;
66
91
  };
67
92
 
@@ -70,7 +95,7 @@ type ResolvedOptions = {
70
95
  const PRESETS: Record<SvgPreset, { structure: StructureMode; spacing: SpacingMode; defaultFit: SvgFit }> = {
71
96
  flat: { structure: 'flat', spacing: 'preserve', defaultFit: 'none' },
72
97
  browser: { structure: 'expanded', spacing: 'preserve', defaultFit: 'none' },
73
- preserve: { structure: 'expanded', spacing: 'preserve', defaultFit: 'none' },
98
+ preserve: { structure: 'expanded', spacing: 'preserve', defaultFit: 'frag' },
74
99
  glyph: { structure: 'glyph', spacing: 'preserve', defaultFit: 'none' },
75
100
  };
76
101
 
@@ -100,14 +125,20 @@ function fontWeightNumeric(weight: string | number): number {
100
125
 
101
126
  function colorToRGB(color: string): string {
102
127
  if (!color) return 'rgb(0, 0, 0)';
128
+ if (color[0] !== '#') return color;
129
+
103
130
  let hex = color;
104
- if (hex.length === 4 && hex[0] === '#') {
131
+ if (hex.length === 4) {
105
132
  hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
106
133
  }
107
- if (hex.length === 7 && hex[0] === '#') {
134
+ if (hex.length === 7) {
108
135
  return `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
109
136
  }
110
- 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;
111
142
  }
112
143
 
113
144
  /** Compute gutter widths per line for justify alignment */
@@ -145,7 +176,19 @@ function resolveOptions(opts: SVGRenderOptions): ResolvedOptions {
145
176
 
146
177
  const style = opts.style ?? 'xml';
147
178
  let fit = opts.fit ?? defaultFit;
148
- 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
+ }
149
192
 
150
193
  // Validation rules
151
194
  if (structure === 'glyph' && fit !== 'none') {
@@ -157,7 +200,80 @@ function resolveOptions(opts: SVGRenderOptions): ResolvedOptions {
157
200
  fit = 'text';
158
201
  }
159
202
 
160
- 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 };
161
277
  }
162
278
 
163
279
  // ── Attribute builders ───────────────────────────────────────────────────
@@ -169,78 +285,117 @@ interface StyleState {
169
285
  color: string;
170
286
  fontStyle: string;
171
287
  decoration: string;
288
+ letterSpacing?: number;
289
+ backgroundColor?: string;
172
290
  }
173
291
 
174
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
+
175
297
  return {
176
298
  fontFamily: span.style.fontFamily || 'Arial',
177
299
  fontSize: span.fontMetrics.fontSize || 16,
178
300
  fontWeight: fontWeightNumeric(span.style.fontWeight),
179
301
  color: span.style.color || '#000000',
180
302
  fontStyle: span.style.fontStyle || 'normal',
181
- decoration: span.style.underline ? 'underline' : span.style.strikethrough ? 'line-through' : '',
303
+ decoration: decorations.join(' '),
304
+ letterSpacing: span.style.letterSpacing,
305
+ backgroundColor: span.style.backgroundColor,
182
306
  };
183
307
  }
184
308
 
185
309
  function equalStyle(a: StyleState, b: StyleState): boolean {
186
310
  return a.fontFamily === b.fontFamily && a.fontSize === b.fontSize &&
187
311
  a.fontWeight === b.fontWeight && a.color === b.color &&
188
- 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 ?? ''}`;
189
324
  }
190
325
 
191
326
  /** Build style string for CSS mode */
192
327
  function cssStyleString(s: StyleState): string {
193
328
  const parts: string[] = [];
194
329
  parts.push(`font-family: '${s.fontFamily}', sans-serif`);
195
- parts.push(`font-size: ${s.fontSize}px`);
330
+ parts.push(`font-size: ${fmt(s.fontSize)}px`);
196
331
  parts.push(`fill: ${colorToRGB(s.color)}`);
197
332
  if (s.fontWeight !== 400) parts.push(`font-weight: ${s.fontWeight}`);
198
333
  if (s.fontStyle === 'italic') parts.push(`font-style: italic`);
199
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`);
200
336
  return parts.join('; ');
201
337
  }
202
338
 
203
339
  /** Build XML presentation attributes for a style */
204
340
  function xmlStyleAttrs(s: StyleState): string {
205
- 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}"`;
206
342
  if (s.fontStyle === 'italic') attrs += ' font-style="italic"';
207
343
  if (s.decoration) attrs += ` text-decoration="${s.decoration}"`;
344
+ if (s.letterSpacing !== undefined && s.letterSpacing !== 0) attrs += ` letter-spacing="${fmt(s.letterSpacing)}"`;
208
345
  return attrs;
209
346
  }
210
347
 
211
- /** Build attributes for <text> element */
212
- function buildTextAttrs(line: Line, span: Span, 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> {
213
359
  const x = line.x;
214
360
  const y = line.y + line.baseline;
215
361
  const s = defaultStyleState(span);
216
362
 
217
- let attrs = ` x="${x}" y="${y}"`;
218
- 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;
219
368
 
220
369
  if (opts.style === 'css') {
221
- 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`;
222
374
  if (opts.spacing === 'preserve') css += '; white-space: pre';
223
- attrs += ` style="${css}"`;
375
+ attrs.style = css;
224
376
  } else {
225
- attrs += ` ${xmlStyleAttrs(s)}`;
226
- 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';
227
384
  }
228
385
 
229
- // text-anchor is only meaningful for flat mode where text sits directly in <text>.
230
- // For expanded/glyph modes, each <tspan> has explicit x="..." coordinates that already
231
- // account for alignment — text-anchor on <text> would then double-shift the text.
232
- if (opts.structure === 'flat') {
233
- const anchor = line.alignment === 'center' ? 'middle' : line.alignment === 'right' ? 'end' : 'start';
234
- if (anchor !== 'start') attrs += ` text-anchor="${anchor}"`;
235
- }
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.
236
389
 
237
390
  return attrs;
238
391
  }
239
392
 
240
393
  /** Build attributes for <tspan> (expanded mode — only diff from current style) */
241
- function buildTspanAttrs(span: Span, x: number, currentStyle: StyleState | null): { attrs: string; newStyle: StyleState } {
394
+ function buildTspanAttrs(span: Span, x: number, currentStyle: StyleState | null): { attrs: Record<string, string | number>; newStyle: StyleState } {
242
395
  const s = defaultStyleState(span);
243
- let attrs = ` x="${x}"`;
396
+ const attrs: Record<string, string | number> = {
397
+ x: fmt(x),
398
+ };
244
399
 
245
400
  if (currentStyle && equalStyle(s, currentStyle)) {
246
401
  return { attrs, newStyle: s };
@@ -249,146 +404,428 @@ function buildTspanAttrs(span: Span, x: number, currentStyle: StyleState | null)
249
404
  // textLength is NOT added here — it is handled by buildFragFitAttr() separately
250
405
  // to avoid duplicate textLength when fit='frag'.
251
406
 
252
- if (!currentStyle || s.fontWeight !== currentStyle.fontWeight) attrs += ` font-weight="${s.fontWeight}"`;
253
- if (!currentStyle || s.fontStyle !== currentStyle.fontStyle) attrs += ` font-style="${s.fontStyle}"`;
254
- if (!currentStyle || s.fontFamily !== currentStyle.fontFamily) attrs += ` font-family="${s.fontFamily}"`;
255
- if (!currentStyle || s.fontSize !== currentStyle.fontSize) attrs += ` font-size="${s.fontSize}"`;
256
- 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;
257
412
  if (!currentStyle || s.decoration !== currentStyle.decoration) {
258
- 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);
259
417
  }
260
418
 
261
419
  return { attrs, newStyle: s };
262
420
  }
263
421
 
264
- /** Build per-glyph x positions for glyph mode */
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
+ */
265
428
  function buildGlyphPositions(span: Span, _lineX: number): string {
266
429
  if (!span.glyphAdvances || span.glyphAdvances.length === 0) {
267
430
  return '';
268
431
  }
269
432
  // span.x is already absolute — computed by PositioningEngine.
270
433
  // lineX is NOT added because that would double-shift.
434
+ const ls = span.style.letterSpacing || 0;
271
435
  const spanX = span.x;
272
436
  let xPos = spanX;
273
- const positions: string[] = [xPos.toFixed(1)];
437
+ const positions: string[] = [fmt(xPos, 1)];
274
438
  for (let i = 0; i < span.glyphAdvances.length - 1; i++) {
275
- xPos += span.glyphAdvances[i];
276
- positions.push(xPos.toFixed(1));
439
+ xPos += span.glyphAdvances[i] + ls;
440
+ positions.push(fmt(xPos, 1));
277
441
  }
278
442
  return positions.join(' ');
279
443
  }
280
444
 
281
445
  /** Build textLength attribute for a line */
282
- function buildFitAttr(line: Line, opts: ResolvedOptions): string {
446
+ function buildFitAttr(line: Line, opts: ResolvedOptions): Record<string, string | number> | undefined {
283
447
  if (opts.fit === 'text') {
284
- return ` textLength="${line.width}" lengthAdjust="spacing"`;
448
+ return { textLength: fmt(line.width), lengthAdjust: 'spacing' };
285
449
  }
286
- return '';
450
+ return undefined;
287
451
  }
288
452
 
289
453
  /** Build textLength for a span */
290
- function buildSpanFitAttr(span: Span, opts: ResolvedOptions): string {
454
+ function buildSpanFitAttr(span: Span, opts: ResolvedOptions): Record<string, string | number> | undefined {
291
455
  if (opts.fit === 'frag') {
292
- return ` textLength="${span.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
+ }
473
+ }
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 };
482
+ }
483
+
484
+ /**
485
+ * Create an SVG raw node (output verbatim, no escaping).
486
+ */
487
+ function rawNode(value: string): SvgNode {
488
+ return { type: 'raw', value };
489
+ }
490
+
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
+ }
293
535
  }
294
- return '';
295
536
  }
296
537
 
297
- // ── SVG builder ──────────────────────────────────────────────────────────
538
+ // ── SVG AST Builder ──────────────────────────────────────────────────────
298
539
 
299
- class SvgBuilder {
300
- private parts: string[] = [];
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;
301
553
  private opts: ResolvedOptions;
302
554
 
303
- constructor(width: number, height: number, opts: ResolvedOptions) {
555
+ constructor(width: number, height: number, opts: ResolvedOptions, viewBox?: { x: number; y: number; w: number; h: number }) {
304
556
  this.opts = opts;
305
- const className = opts.className ? ` class="${escapeXml(opts.className)}"` : '';
306
- 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
+ };
307
565
  if (opts.className) {
308
- 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;
309
567
  }
568
+ this.root = el('svg', svgAttrs);
310
569
  }
311
570
 
312
- addText(line: Line, baseSpan: Span, runId?: string, yOverride?: number, fontSizeOverride?: number): void {
313
- let attrs: string;
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>;
314
580
  if (yOverride !== undefined && fontSizeOverride !== undefined) {
315
- // For flat mode sub/superscript — override y and font-size
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).
316
584
  const s = defaultStyleState(baseSpan);
317
585
  const x = line.x;
318
- attrs = ` x="${x}" y="${yOverride}"`;
586
+ const attrs: Record<string, string | number> = {
587
+ x: fmt(x),
588
+ y: fmt(yOverride),
589
+ };
319
590
  if (this.opts.style === 'css') {
320
- let css = cssStyleString(s);
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`;
321
593
  if (this.opts.spacing === 'preserve') css += '; white-space: pre';
322
- attrs += ` style="${css}"`;
594
+ attrs.style = css;
323
595
  } 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;
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';
329
602
  }
603
+ textAttrs = attrs;
330
604
  } else {
331
- attrs = buildTextAttrs(line, baseSpan, this.opts, runId);
605
+ textAttrs = buildTextAttrs(line, baseSpan, this.opts, runId);
332
606
  }
333
- const fit = buildFitAttr(line, this.opts);
334
- this.parts.push(` <text${attrs}${fit}>\n`);
335
- }
336
607
 
337
- addFlatSpan(text: string): void {
338
- this.parts.push(`${escapeXml(text)}`);
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;
339
616
  }
340
617
 
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);
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
+ }
344
626
  }
345
627
 
346
- closeText(): void {
347
- this.parts.push('</text>\n');
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
+ }
348
636
  }
349
637
 
350
- addExpandedSpan(span: Span, x: number, style: StyleState | null): StyleState {
638
+ /**
639
+ * Add an expanded <tspan> node to the current <text> element.
640
+ */
641
+ addTspan(span: Span, x: number, style: StyleState | null): StyleState {
351
642
  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`);
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
+ }
354
651
  return newStyle;
355
652
  }
356
653
 
357
- addGlyphSpan(span: Span, lineX: number): void {
654
+ /**
655
+ * Add a glyph-positioned <tspan> node to the current <text> element.
656
+ */
657
+ addGlyphTspan(span: Span, lineX: number): void {
358
658
  const positions = buildGlyphPositions(span, lineX);
659
+ const attrs: Record<string, string | number> = {};
359
660
  if (positions) {
360
- this.parts.push(` <tspan x="${positions}">${escapeXml(span.text)}</tspan>\n`);
361
- } else {
362
- this.parts.push(` <tspan>${escapeXml(span.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);
363
666
  }
364
667
  }
365
668
 
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);
684
+ }
366
685
 
367
- addDebug(debugOverlay: string): void {
368
- if (debugOverlay) {
369
- this.parts.push(`<!-- debug overlay -->\n${debugOverlay}\n`);
370
- }
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));
371
693
  }
372
694
 
373
- build(): string {
374
- this.parts.push('</svg>\n');
375
- 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;
376
701
  }
377
702
  }
378
703
 
379
704
  // ── Debug overlay ────────────────────────────────────────────────────────
380
705
 
381
- function renderDebugToSVG(lines: Line[], 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 {
382
715
  const parts: string[] = [];
716
+ const sw = flags.widthBorder ?? 1;
717
+
718
+ // Frame container bounding box
719
+ if ((flags.frameBox || flags.frame) && frameSize) {
720
+ parts.push(
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" />`,
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
+ }
383
730
 
384
- if (flags.frame && lines.length > 0) {
385
- const first = lines[0];
386
- const last = lines[lines.length - 1];
387
- const maxW = Math.max(...lines.map(l => l.x + l.width));
731
+ // Content bounding box
732
+ if (flags.contentBox) {
733
+ const bbox = computeBBox(lines);
388
734
  parts.push(
389
- ` <rect x="${first.x}" y="${first.y}" width="${maxW - first.x}" height="${last.y + last.height - first.y}"` +
390
- ` fill="none" stroke="rgba(255,200,0,0.6)" stroke-width="1" stroke-dasharray="6,2" />`,
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" />`,
391
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
+ }
392
829
  }
393
830
 
394
831
  for (const line of lines) {
@@ -396,27 +833,27 @@ function renderDebugToSVG(lines: Line[], width: number, height: number, flags: D
396
833
  const baselineY = line.y + line.baseline;
397
834
 
398
835
  if (flags.lineGap) {
399
- 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" />`);
400
837
  }
401
838
  if (flags.box) {
402
- 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)}" />`);
403
840
  }
404
841
  if (flags.baseline) {
405
- 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)}" />`);
406
843
  }
407
844
  if (flags.ascentDescent) {
408
- 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" />`);
409
- 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" />`);
410
847
  }
411
848
  if (flags.labels) {
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>`);
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>`);
413
850
  }
414
851
  if (flags.runs) {
415
852
  for (const span of line.spans) {
416
853
  if (span.width <= 0) continue;
417
854
  const rx = line.x + span.x;
418
855
  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" />`);
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)}" />`);
420
857
  }
421
858
  }
422
859
  }
@@ -424,6 +861,21 @@ function renderDebugToSVG(lines: Line[], width: number, height: number, flags: D
424
861
  return parts.join('\n');
425
862
  }
426
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
+
427
879
  // ── Main render logic ────────────────────────────────────────────────────
428
880
 
429
881
  /**
@@ -436,30 +888,24 @@ function renderDebugToSVG(lines: Line[], width: number, height: number, flags: D
436
888
  export function renderToSVG(lines: Line[], options: SVGRenderOptions = {}): string {
437
889
  const opts = resolveOptions(options);
438
890
 
439
- // Determine canvas size
440
- let svgWidth: number;
441
- let svgHeight: number;
442
-
443
- if (opts.sizing === 'content') {
444
- const bbox = computeBBox(lines);
445
- svgWidth = bbox.width;
446
- svgHeight = bbox.height;
447
- } else {
448
- // sizing='frame' requires explicit width/height — no fallback
449
- if (opts.width === undefined || opts.height === undefined) {
450
- throw new Error(
451
- `renderToSVG: sizing="frame" requires explicit width and height. ` +
452
- `Got width=${opts.width}, height=${opts.height}. ` +
453
- `Use renderResultToSVG(result, options) to auto-pass dimensions.`
454
- );
455
- }
456
- svgWidth = opts.width;
457
- svgHeight = opts.height;
458
- }
891
+ // Determine canvas size and viewBox
892
+ const { width: svgWidth, height: svgHeight, viewBox, frameWidth, frameHeight } = resolveSize(lines, opts);
459
893
 
460
- const builder = new SvgBuilder(svgWidth, svgHeight, opts);
894
+ const builder = new SvgAstBuilder(svgWidth, svgHeight, opts, viewBox);
461
895
 
462
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
+
463
909
  if (opts.structure === 'glyph') {
464
910
  // Per-glyph positioning with run-based <text> grouping
465
911
  let currentRunIdx = -1;
@@ -467,74 +913,156 @@ export function renderToSVG(lines: Line[], options: SVGRenderOptions = {}): stri
467
913
  if (!span.text) continue;
468
914
  const runIdx = span.itemIndex;
469
915
  if (runIdx !== currentRunIdx) {
470
- if (currentRunIdx !== -1) {
471
- builder.closeText();
472
- }
473
- const runId = span.paragraphId ? `${span.paragraphId}-${runIdx}` : undefined;
474
- builder.addText(line, span, runId);
916
+ builder.closeText();
917
+ const runId = span.tag ? `${span.tag}-${runIdx}` : undefined;
918
+ builder.openText(line, span, runId);
475
919
  currentRunIdx = runIdx;
476
920
  }
477
- builder.addGlyphSpan(span, line.x);
478
- }
479
- if (currentRunIdx !== -1) {
480
- builder.closeText();
921
+ builder.addGlyphTspan(span, line.x);
481
922
  }
923
+ builder.closeText();
482
924
  } 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 }[] = [];
925
+ // flat mode: each unique styleseparate <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[] = [];
485
930
  for (const span of line.spans) {
486
931
  if (!span.text) continue;
487
932
  const offset = span.fontMetrics.baselineOffset || 0;
488
933
  const targetY = Math.round((line.y + line.baseline + offset) * 100) / 100;
934
+ const sig = styleSignature(span);
489
935
  const fontSize = span.fontMetrics.fontSize;
490
936
  const last = groups[groups.length - 1];
491
- if (last && last.targetY === targetY && last.fontSize === fontSize) {
937
+ if (last && last.targetY === targetY && last.signature === sig) {
492
938
  last.spans.push(span);
493
939
  } else {
494
- groups.push({ spans: [span], targetY, fontSize });
940
+ groups.push({ spans: [span], targetY, signature: sig, fontSize });
495
941
  }
496
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);
497
947
  for (const group of groups) {
498
948
  const s = defaultStyleState(group.spans[0]);
499
949
  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`);
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`);
508
974
  }
509
975
  } else {
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;
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[] = [];
516
981
  for (const span of line.spans) {
517
982
  if (!span.text) continue;
518
- const x = span.x;
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
+ }
993
+
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
+ }
519
1027
 
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;
1028
+ let currentStyle: StyleState | null = null;
1029
+ for (const span of group.spans) {
1030
+ if (!span.text) continue;
1031
+ const x = span.x;
1032
+
1033
+ const shouldRender = span.type !== 'space' || opts.spacing === 'preserve';
1034
+ if (shouldRender) {
1035
+ const newStyle = builder.addTspan(span, x, currentStyle);
1036
+ if (span.type !== 'space') {
1037
+ currentStyle = newStyle;
1038
+ }
525
1039
  }
526
1040
  }
1041
+ builder.closeText();
527
1042
  }
528
- builder.closeText();
529
1043
  }
530
1044
  }
531
1045
 
532
1046
  if (opts.debug) {
533
- 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
+ );
534
1062
  builder.addDebug(debugSvg);
535
1063
  }
536
1064
 
537
- return builder.build();
1065
+ return serializeSvg(builder.root);
538
1066
  }
539
1067
 
540
1068
  /**