@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.
- package/dist/index.js +789 -220
- package/package.json +1 -1
- package/src/CanvasRenderer.ts +388 -78
- package/src/SVGRenderer.ts +694 -166
- package/src/index.ts +6 -3
- package/src/interactive.ts +284 -0
- package/src/types.ts +59 -4
- package/src/utils.ts +19 -6
package/src/SVGRenderer.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SVGRenderer.ts — SVG text builder.
|
|
3
3
|
*
|
|
4
|
-
* Converts Line[] into SVG markup using
|
|
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
|
|
22
|
-
import {
|
|
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
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
|
375
|
+
attrs.style = css;
|
|
224
376
|
} else {
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
230
|
-
//
|
|
231
|
-
//
|
|
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
|
|
394
|
+
function buildTspanAttrs(span: Span, x: number, currentStyle: StyleState | null): { attrs: Record<string, string | number>; newStyle: StyleState } {
|
|
242
395
|
const s = defaultStyleState(span);
|
|
243
|
-
|
|
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
|
|
253
|
-
if (!currentStyle || s.fontStyle !== currentStyle.fontStyle) attrs
|
|
254
|
-
if (!currentStyle || s.fontFamily !== currentStyle.fontFamily) attrs
|
|
255
|
-
if (!currentStyle || s.fontSize !== currentStyle.fontSize) attrs
|
|
256
|
-
if (!currentStyle || s.color !== currentStyle.color) attrs
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
538
|
+
// ── SVG AST Builder ──────────────────────────────────────────────────────
|
|
298
539
|
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
306
|
-
|
|
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
|
-
|
|
566
|
+
svgAttrs.class = opts.className;
|
|
309
567
|
}
|
|
568
|
+
this.root = el('svg', svgAttrs);
|
|
310
569
|
}
|
|
311
570
|
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
594
|
+
attrs.style = css;
|
|
323
595
|
} else {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
attrs
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
/**
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
361
|
-
}
|
|
362
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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(
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
const
|
|
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="${
|
|
390
|
-
` fill="none" stroke="rgba(255,
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
|
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="
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
471
|
-
|
|
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.
|
|
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:
|
|
484
|
-
|
|
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[] = [];
|
|
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.
|
|
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
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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:
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
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.
|
|
1065
|
+
return serializeSvg(builder.root);
|
|
538
1066
|
}
|
|
539
1067
|
|
|
540
1068
|
/**
|