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