@vyaz/renderer 0.0.3 → 0.0.4

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,1110 +0,0 @@
1
- /**
2
- * SVGRenderer.ts — SVG text builder.
3
- *
4
- * Converts Line[] into SVG markup using an AST-first approach.
5
- * Builds a tree of SvgNode, then serializes to string in one pass.
6
- *
7
- * Four presets:
8
- * flat — all text in one <text> element, xml:space="preserve", no <tspan>
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.
15
- *
16
- * Usage:
17
- * const svg = renderToSVG(lines, { preset: 'browser' })
18
- * const svg = renderToSVG(lines, { preset: 'preserve', style: 'css', fit: 'frag' })
19
- */
20
-
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';
25
-
26
- // ── Types ────────────────────────────────────────────────────────────────
27
-
28
- export type SvgPreset = 'flat' | 'browser' | 'preserve' | 'glyph';
29
-
30
- export type SvgStyle = 'css' | 'xml';
31
-
32
- export type SvgFit = 'none' | 'text' | 'frag';
33
-
34
- export type SvgSizing = 'frame' | 'content';
35
-
36
- export type PerAxisSizing = { horizontal: SvgSizing; vertical: SvgSizing };
37
-
38
- export interface SVGRenderOptions {
39
- /** Shorthand that sets structure + spacing at once. */
40
- preset?: SvgPreset;
41
- /** How style properties are expressed: as CSS `style` attribute or as XML presentation attributes. */
42
- style?: SvgStyle;
43
- /** How `textLength` is applied. */
44
- fit?: SvgFit;
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. */
53
- width?: number;
54
- /** SVG canvas height (px). Used when vertical sizing='frame' or as fallback. */
55
- height?: number;
56
- /** CSS class for `<svg>`. */
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;
65
- /** Debug overlays. */
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;
74
- }
75
-
76
- type SpacingMode = 'browser' | 'preserve';
77
- type StructureMode = 'flat' | 'expanded' | 'glyph';
78
-
79
- type ResolvedOptions = {
80
- structure: StructureMode;
81
- spacing: SpacingMode;
82
- style: 'css' | 'xml';
83
- fit: 'none' | 'text' | 'frag';
84
- sizingHorizontal: 'frame' | 'content';
85
- sizingVertical: 'frame' | 'content';
86
- width?: number;
87
- height?: number;
88
- className?: string;
89
- contentPadding: number;
90
- debug?: DebugFlags;
91
- };
92
-
93
- // ── Preset map ───────────────────────────────────────────────────────────
94
-
95
- const PRESETS: Record<SvgPreset, { structure: StructureMode; spacing: SpacingMode; defaultFit: SvgFit }> = {
96
- flat: { structure: 'flat', spacing: 'preserve', defaultFit: 'none' },
97
- browser: { structure: 'expanded', spacing: 'preserve', defaultFit: 'none' },
98
- preserve: { structure: 'expanded', spacing: 'preserve', defaultFit: 'frag' },
99
- glyph: { structure: 'glyph', spacing: 'preserve', defaultFit: 'none' },
100
- };
101
-
102
- // ── Helpers ──────────────────────────────────────────────────────────────
103
-
104
- function escapeXml(text: string): string {
105
- return text
106
- .replace(/&/g, '&#38;')
107
- .replace(/</g, '&#60;')
108
- .replace(/>/g, '&#62;')
109
- .replace(/"/g, '&#34;');
110
- }
111
-
112
- function fontWeightCSS(weight: string | number): string {
113
- if (weight === 'bold') return 'bold';
114
- if (weight === 'normal') return '400';
115
- if (typeof weight === 'number') return String(weight);
116
- return '400';
117
- }
118
-
119
- function fontWeightNumeric(weight: string | number): number {
120
- if (weight === 'bold') return 700;
121
- if (weight === 'normal') return 400;
122
- if (typeof weight === 'number') return weight;
123
- return 400;
124
- }
125
-
126
- function colorToRGB(color: string): string {
127
- if (!color) return 'rgb(0, 0, 0)';
128
- if (color[0] !== '#') return color;
129
-
130
- let hex = color;
131
- if (hex.length === 4) {
132
- hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
133
- }
134
- if (hex.length === 7) {
135
- return `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
136
- }
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;
142
- }
143
-
144
- /** Compute gutter widths per line for justify alignment */
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);
150
- }
151
-
152
- // ── Resolve options ──────────────────────────────────────────────────────
153
-
154
- function resolveOptions(opts: SVGRenderOptions): ResolvedOptions {
155
- let structure: StructureMode;
156
- let spacing: SpacingMode;
157
- let defaultFit: SvgFit;
158
-
159
- if (opts.preset) {
160
- const preset = PRESETS[opts.preset];
161
- if (!preset) {
162
- console.warn(`SVGRenderer: unknown preset "${opts.preset}", falling back to browser`);
163
- structure = 'expanded';
164
- spacing = 'browser';
165
- defaultFit = 'none';
166
- } else {
167
- structure = preset.structure;
168
- spacing = preset.spacing;
169
- defaultFit = preset.defaultFit;
170
- }
171
- } else {
172
- structure = 'expanded';
173
- spacing = 'browser';
174
- defaultFit = 'none';
175
- }
176
-
177
- const style = opts.style ?? 'xml';
178
- let fit = opts.fit ?? defaultFit;
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
- }
192
-
193
- // Validation rules
194
- if (structure === 'glyph' && fit !== 'none') {
195
- console.warn(`SVGRenderer: fit="${fit}" is ignored when structure="glyph"`);
196
- fit = 'none';
197
- }
198
- if (structure === 'flat' && fit === 'frag') {
199
- console.warn(`SVGRenderer: fit="frag" downgraded to "text" when structure="flat"`);
200
- fit = 'text';
201
- }
202
-
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 };
277
- }
278
-
279
- // ── Attribute builders ───────────────────────────────────────────────────
280
-
281
- interface StyleState {
282
- fontFamily: string;
283
- fontSize: number;
284
- fontWeight: number;
285
- color: string;
286
- fontStyle: string;
287
- decoration: string;
288
- letterSpacing?: number;
289
- backgroundColor?: string;
290
- }
291
-
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
-
297
- return {
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,
306
- };
307
- }
308
-
309
- function equalStyle(a: StyleState, b: StyleState): boolean {
310
- return a.fontFamily === b.fontFamily && a.fontSize === b.fontSize &&
311
- a.fontWeight === b.fontWeight && a.color === b.color &&
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 ?? ''}`;
324
- }
325
-
326
- /** Build style string for CSS mode */
327
- function cssStyleString(s: StyleState): string {
328
- const parts: string[] = [];
329
- parts.push(`font-family: '${s.fontFamily}', sans-serif`);
330
- parts.push(`font-size: ${fmt(s.fontSize)}px`);
331
- parts.push(`fill: ${colorToRGB(s.color)}`);
332
- if (s.fontWeight !== 400) parts.push(`font-weight: ${s.fontWeight}`);
333
- if (s.fontStyle === 'italic') parts.push(`font-style: italic`);
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`);
336
- return parts.join('; ');
337
- }
338
-
339
- /** Build XML presentation attributes for a style */
340
- function xmlStyleAttrs(s: StyleState): string {
341
- let attrs = `font-family="${s.fontFamily}" font-size="${fmt(s.fontSize)}" fill="${s.color}" font-weight="${s.fontWeight}"`;
342
- if (s.fontStyle === 'italic') attrs += ' font-style="italic"';
343
- if (s.decoration) attrs += ` text-decoration="${s.decoration}"`;
344
- if (s.letterSpacing !== undefined && s.letterSpacing !== 0) attrs += ` letter-spacing="${fmt(s.letterSpacing)}"`;
345
- return attrs;
346
- }
347
-
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> {
359
- const x = line.x;
360
- const y = line.y + line.baseline;
361
- const s = defaultStyleState(span);
362
-
363
- const attrs: Record<string, string | number> = {
364
- x: fmt(x),
365
- y: fmt(y),
366
- };
367
- if (runId) attrs.id = runId;
368
-
369
- if (opts.style === 'css') {
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`;
374
- if (opts.spacing === 'preserve') css += '; white-space: pre';
375
- attrs.style = css;
376
- } else {
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';
384
- }
385
-
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.
389
-
390
- return attrs;
391
- }
392
-
393
- /** Build attributes for <tspan> (expanded mode — only diff from current style) */
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
- };
399
-
400
- if (currentStyle && equalStyle(s, currentStyle)) {
401
- return { attrs, newStyle: s };
402
- }
403
-
404
- // textLength is NOT added here — it is handled by buildFragFitAttr() separately
405
- // to avoid duplicate textLength when fit='frag'.
406
-
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;
412
- if (!currentStyle || s.decoration !== currentStyle.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);
417
- }
418
-
419
- return { attrs, newStyle: s };
420
- }
421
-
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) {
430
- return '';
431
- }
432
- // span.x is already absolute — computed by PositioningEngine.
433
- // lineX is NOT added because that would double-shift.
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));
441
- }
442
- return positions.join(' ');
443
- }
444
-
445
- /** Build textLength attribute for a line */
446
- function buildFitAttr(line: Line, opts: ResolvedOptions): Record<string, string | number> | undefined {
447
- if (opts.fit === 'text') {
448
- return { textLength: fmt(line.width), lengthAdjust: 'spacing' };
449
- }
450
- return undefined;
451
- }
452
-
453
- /** Build textLength for a span */
454
- function buildSpanFitAttr(span: Span, opts: ResolvedOptions): Record<string, string | number> | undefined {
455
- if (opts.fit === 'frag') {
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
- }
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;
553
- private opts: ResolvedOptions;
554
-
555
- constructor(width: number, height: number, opts: ResolvedOptions, viewBox?: { x: number; y: number; w: number; h: number }) {
556
- this.opts = opts;
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
- };
565
- if (opts.className) {
566
- svgAttrs.class = opts.className;
567
- }
568
- this.root = el('svg', svgAttrs);
569
- }
570
-
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;
616
- }
617
-
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
- }
636
- }
637
-
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
- }
651
- return newStyle;
652
- }
653
-
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> = {};
660
- if (positions) {
661
- attrs.x = positions;
662
- }
663
- const tspan = el('tspan', attrs, [textNode(span.text)]);
664
- if (this.currentText) {
665
- this.currentText.children.push(tspan);
666
- }
667
- }
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
- }
685
-
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));
693
- }
694
-
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;
701
- }
702
- }
703
-
704
- // ── Debug overlay ────────────────────────────────────────────────────────
705
-
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 {
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
- }
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
- }
829
- }
830
-
831
- for (const line of lines) {
832
- const bx = line.x, by = line.y, bw = line.width, bh = line.height;
833
- const baselineY = line.y + line.baseline;
834
-
835
- if (flags.lineGap) {
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" />`);
837
- }
838
- if (flags.box) {
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)}" />`);
840
- }
841
- if (flags.baseline) {
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)}" />`);
843
- }
844
- if (flags.ascentDescent) {
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" />`);
847
- }
848
- if (flags.labels) {
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>`);
850
- }
851
- if (flags.runs) {
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)}" />`);
857
- }
858
- }
859
- }
860
-
861
- return parts.join('\n');
862
- }
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
-
879
- // ── Main render logic ────────────────────────────────────────────────────
880
-
881
- /**
882
- * Render Line[] into SVG string.
883
- *
884
- * @param lines — layout lines with spans
885
- * @param options — rendering options (preset + style/fit/sizing modifiers)
886
- * @returns SVG string
887
- */
888
- export function renderToSVG(lines: Line[], options: SVGRenderOptions = {}): string {
889
- const opts = resolveOptions(options);
890
-
891
- // Determine canvas size and viewBox
892
- const { width: svgWidth, height: svgHeight, viewBox, frameWidth, frameHeight } = resolveSize(lines, opts);
893
-
894
- const builder = new SvgAstBuilder(svgWidth, svgHeight, opts, viewBox);
895
-
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
-
909
- if (opts.structure === 'glyph') {
910
- // Per-glyph positioning with run-based <text> grouping
911
- let currentRunIdx = -1;
912
- for (const span of line.spans) {
913
- if (!span.text) continue;
914
- const runIdx = span.itemIndex;
915
- if (runIdx !== currentRunIdx) {
916
- builder.closeText();
917
- const runId = span.tag ? `${span.tag}-${runIdx}` : undefined;
918
- builder.openText(line, span, runId);
919
- currentRunIdx = runIdx;
920
- }
921
- builder.addGlyphTspan(span, line.x);
922
- }
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`);
974
- }
975
- } else {
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
- }
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
- }
1027
-
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
- }
1039
- }
1040
- }
1041
- builder.closeText();
1042
- }
1043
- }
1044
- }
1045
-
1046
- if (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
- );
1062
- builder.addDebug(debugSvg);
1063
- }
1064
-
1065
- return serializeSvg(builder.root);
1066
- }
1067
-
1068
- /**
1069
- * Render one ParagraphLayoutResult to SVG (convenience wrapper).
1070
- */
1071
- export function renderParagraphToSVG(
1072
- lines: Line[],
1073
- paragraphWidth: number,
1074
- paragraphHeight: number,
1075
- options?: SVGRenderOptions,
1076
- ): string {
1077
- return renderToSVG(lines, {
1078
- width: paragraphWidth,
1079
- height: paragraphHeight,
1080
- ...options,
1081
- });
1082
- }
1083
-
1084
- /**
1085
- * Render a ParagraphLayoutResult to SVG, auto-passing dimensions.
1086
- *
1087
- * Uses `result.width` and `result.height` as the SVG canvas size.
1088
- * This is the recommended way to render when you have a layout result
1089
- * and want `sizing: 'frame'` with correct dimensions.
1090
- *
1091
- * @param result — layout result from ParagraphLayoutEngine.layout()
1092
- * @param options — rendering options (preset, style, fit, etc.)
1093
- * @returns SVG string
1094
- *
1095
- * @example
1096
- * ```ts
1097
- * const result = paragraphLayoutEngine.layout(paragraph, 300);
1098
- * const svg = renderResultToSVG(result, { preset: 'preserve' });
1099
- * ```
1100
- */
1101
- export function renderResultToSVG(
1102
- result: ParagraphLayoutResult,
1103
- options?: SVGRenderOptions,
1104
- ): string {
1105
- return renderToSVG(result.lines, {
1106
- width: result.width,
1107
- height: result.height,
1108
- ...options,
1109
- });
1110
- }