@vyaz/renderer 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,582 +0,0 @@
1
- /**
2
- * SVGRenderer.ts — SVG text builder.
3
- *
4
- * Converts Line[] into SVG markup using a builder pattern.
5
- *
6
- * Four presets:
7
- * flat — all text in one <text> element, xml:space="preserve", no <tspan>
8
- * browser — expanded <tspan> per run, xml:space="preserve", diff attributes, no textLength
9
- * preserve — expanded <tspan> per run, xml:space="preserve", diff attributes, textLength
10
- * glyph — <tspan> per glyph with per-character x positions, xml:space="preserve"
11
- *
12
- * All presets preserve whitespace via xml:space="preserve". Space spans (type: 'space')
13
- * are rendered as separate <tspan> elements with explicit x coordinates.
14
- *
15
- * Usage:
16
- * const svg = renderToSVG(lines, { preset: 'browser' })
17
- * const svg = renderToSVG(lines, { preset: 'preserve', style: 'css', fit: 'frag' })
18
- */
19
-
20
- import type { Line, Span, ParagraphLayoutResult } from '@vyaz/core';
21
- import type { DebugFlags } from './types.js';
22
- import { computeBBox } from './utils.js';
23
-
24
- // ── Types ────────────────────────────────────────────────────────────────
25
-
26
- export type SvgPreset = 'flat' | 'browser' | 'preserve' | 'glyph';
27
-
28
- export type SvgStyle = 'css' | 'xml';
29
-
30
- export type SvgFit = 'none' | 'text' | 'frag';
31
-
32
- export type SvgSizing = 'frame' | 'content';
33
-
34
- export interface SVGRenderOptions {
35
- /** Shorthand that sets structure + spacing at once. */
36
- preset?: SvgPreset;
37
- /** How style properties are expressed: as CSS `style` attribute or as XML presentation attributes. */
38
- style?: SvgStyle;
39
- /** How `textLength` is applied. */
40
- fit?: SvgFit;
41
- /** How SVG determines its size: 'frame' — use width/height from options; 'content' — compute BBox from lines. */
42
- sizing?: SvgSizing;
43
- /** SVG canvas width (px). Used when sizing='frame' or as fallback. */
44
- width?: number;
45
- /** SVG canvas height (px). Used when sizing='frame' or as fallback. */
46
- height?: number;
47
- /** CSS class for `<svg>`. */
48
- className?: string;
49
- /** Debug overlays. */
50
- debug?: DebugFlags;
51
- }
52
-
53
- type SpacingMode = 'browser' | 'preserve';
54
- type StructureMode = 'flat' | 'expanded' | 'glyph';
55
-
56
- type ResolvedOptions = {
57
- structure: StructureMode;
58
- spacing: SpacingMode;
59
- style: 'css' | 'xml';
60
- fit: 'none' | 'text' | 'frag';
61
- sizing: 'frame' | 'content';
62
- width?: number;
63
- height?: number;
64
- className?: string;
65
- debug?: DebugFlags;
66
- };
67
-
68
- // ── Preset map ───────────────────────────────────────────────────────────
69
-
70
- const PRESETS: Record<SvgPreset, { structure: StructureMode; spacing: SpacingMode; defaultFit: SvgFit }> = {
71
- flat: { structure: 'flat', spacing: 'preserve', defaultFit: 'none' },
72
- browser: { structure: 'expanded', spacing: 'preserve', defaultFit: 'none' },
73
- preserve: { structure: 'expanded', spacing: 'preserve', defaultFit: 'none' },
74
- glyph: { structure: 'glyph', spacing: 'preserve', defaultFit: 'none' },
75
- };
76
-
77
- // ── Helpers ──────────────────────────────────────────────────────────────
78
-
79
- function escapeXml(text: string): string {
80
- return text
81
- .replace(/&/g, '&#38;')
82
- .replace(/</g, '&#60;')
83
- .replace(/>/g, '&#62;')
84
- .replace(/"/g, '&#34;');
85
- }
86
-
87
- function fontWeightCSS(weight: string | number): string {
88
- if (weight === 'bold') return 'bold';
89
- if (weight === 'normal') return '400';
90
- if (typeof weight === 'number') return String(weight);
91
- return '400';
92
- }
93
-
94
- function fontWeightNumeric(weight: string | number): number {
95
- if (weight === 'bold') return 700;
96
- if (weight === 'normal') return 400;
97
- if (typeof weight === 'number') return weight;
98
- return 400;
99
- }
100
-
101
- function colorToRGB(color: string): string {
102
- if (!color) return 'rgb(0, 0, 0)';
103
- let hex = color;
104
- if (hex.length === 4 && hex[0] === '#') {
105
- hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
106
- }
107
- if (hex.length === 7 && hex[0] === '#') {
108
- return `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
109
- }
110
- return 'rgb(0, 0, 0)';
111
- }
112
-
113
- /** Compute gutter widths per line for justify alignment */
114
- function computeGutterWidths(line: Line, totalSlack: number): number[] {
115
- const spaceSpans = line.spans.filter(f => f.type === 'space' || f.text.trim() === '');
116
- if (spaceSpans.length === 0) return [];
117
- const perGap = totalSlack / spaceSpans.length;
118
- return line.spans.map(f => (f.type === 'space' || f.text.trim() === '') ? perGap : 0);
119
- }
120
-
121
- // ── Resolve options ──────────────────────────────────────────────────────
122
-
123
- function resolveOptions(opts: SVGRenderOptions): ResolvedOptions {
124
- let structure: StructureMode;
125
- let spacing: SpacingMode;
126
- let defaultFit: SvgFit;
127
-
128
- if (opts.preset) {
129
- const preset = PRESETS[opts.preset];
130
- if (!preset) {
131
- console.warn(`SVGRenderer: unknown preset "${opts.preset}", falling back to browser`);
132
- structure = 'expanded';
133
- spacing = 'browser';
134
- defaultFit = 'none';
135
- } else {
136
- structure = preset.structure;
137
- spacing = preset.spacing;
138
- defaultFit = preset.defaultFit;
139
- }
140
- } else {
141
- structure = 'expanded';
142
- spacing = 'browser';
143
- defaultFit = 'none';
144
- }
145
-
146
- const style = opts.style ?? 'xml';
147
- let fit = opts.fit ?? defaultFit;
148
- const sizing = opts.sizing ?? 'frame';
149
-
150
- // Validation rules
151
- if (structure === 'glyph' && fit !== 'none') {
152
- console.warn(`SVGRenderer: fit="${fit}" is ignored when structure="glyph"`);
153
- fit = 'none';
154
- }
155
- if (structure === 'flat' && fit === 'frag') {
156
- console.warn(`SVGRenderer: fit="frag" downgraded to "text" when structure="flat"`);
157
- fit = 'text';
158
- }
159
-
160
- return { structure, spacing, style, fit, sizing, width: opts.width, height: opts.height, className: opts.className, debug: opts.debug };
161
- }
162
-
163
- // ── Attribute builders ───────────────────────────────────────────────────
164
-
165
- interface StyleState {
166
- fontFamily: string;
167
- fontSize: number;
168
- fontWeight: number;
169
- color: string;
170
- fontStyle: string;
171
- decoration: string;
172
- }
173
-
174
- function defaultStyleState(span: Span): StyleState {
175
- return {
176
- fontFamily: span.style.fontFamily || 'Arial',
177
- fontSize: span.fontMetrics.fontSize || 16,
178
- fontWeight: fontWeightNumeric(span.style.fontWeight),
179
- color: span.style.color || '#000000',
180
- fontStyle: span.style.fontStyle || 'normal',
181
- decoration: span.style.underline ? 'underline' : span.style.strikethrough ? 'line-through' : '',
182
- };
183
- }
184
-
185
- function equalStyle(a: StyleState, b: StyleState): boolean {
186
- return a.fontFamily === b.fontFamily && a.fontSize === b.fontSize &&
187
- a.fontWeight === b.fontWeight && a.color === b.color &&
188
- a.fontStyle === b.fontStyle && a.decoration === b.decoration;
189
- }
190
-
191
- /** Build style string for CSS mode */
192
- function cssStyleString(s: StyleState): string {
193
- const parts: string[] = [];
194
- parts.push(`font-family: '${s.fontFamily}', sans-serif`);
195
- parts.push(`font-size: ${s.fontSize}px`);
196
- parts.push(`fill: ${colorToRGB(s.color)}`);
197
- if (s.fontWeight !== 400) parts.push(`font-weight: ${s.fontWeight}`);
198
- if (s.fontStyle === 'italic') parts.push(`font-style: italic`);
199
- if (s.decoration) parts.push(`text-decoration: ${s.decoration}`);
200
- return parts.join('; ');
201
- }
202
-
203
- /** Build XML presentation attributes for a style */
204
- function xmlStyleAttrs(s: StyleState): string {
205
- let attrs = `font-family="${s.fontFamily}" font-size="${s.fontSize}" fill="${s.color}" font-weight="${s.fontWeight}"`;
206
- if (s.fontStyle === 'italic') attrs += ' font-style="italic"';
207
- if (s.decoration) attrs += ` text-decoration="${s.decoration}"`;
208
- return attrs;
209
- }
210
-
211
- /** Build attributes for <text> element */
212
- function buildTextAttrs(line: Line, span: Span, opts: ResolvedOptions, runId?: string): string {
213
- const x = line.x;
214
- const y = line.y + line.baseline;
215
- const s = defaultStyleState(span);
216
-
217
- let attrs = ` x="${x}" y="${y}"`;
218
- if (runId) attrs += ` id="${runId}"`;
219
-
220
- if (opts.style === 'css') {
221
- let css = cssStyleString(s);
222
- if (opts.spacing === 'preserve') css += '; white-space: pre';
223
- attrs += ` style="${css}"`;
224
- } else {
225
- attrs += ` ${xmlStyleAttrs(s)}`;
226
- if (opts.spacing === 'preserve') attrs += ' xml:space="preserve"';
227
- }
228
-
229
- // text-anchor is only meaningful for flat mode where text sits directly in <text>.
230
- // For expanded/glyph modes, each <tspan> has explicit x="..." coordinates that already
231
- // account for alignment — text-anchor on <text> would then double-shift the text.
232
- if (opts.structure === 'flat') {
233
- const anchor = line.alignment === 'center' ? 'middle' : line.alignment === 'right' ? 'end' : 'start';
234
- if (anchor !== 'start') attrs += ` text-anchor="${anchor}"`;
235
- }
236
-
237
- return attrs;
238
- }
239
-
240
- /** Build attributes for <tspan> (expanded mode — only diff from current style) */
241
- function buildTspanAttrs(span: Span, x: number, currentStyle: StyleState | null): { attrs: string; newStyle: StyleState } {
242
- const s = defaultStyleState(span);
243
- let attrs = ` x="${x}"`;
244
-
245
- if (currentStyle && equalStyle(s, currentStyle)) {
246
- return { attrs, newStyle: s };
247
- }
248
-
249
- // textLength is NOT added here — it is handled by buildFragFitAttr() separately
250
- // to avoid duplicate textLength when fit='frag'.
251
-
252
- if (!currentStyle || s.fontWeight !== currentStyle.fontWeight) attrs += ` font-weight="${s.fontWeight}"`;
253
- if (!currentStyle || s.fontStyle !== currentStyle.fontStyle) attrs += ` font-style="${s.fontStyle}"`;
254
- if (!currentStyle || s.fontFamily !== currentStyle.fontFamily) attrs += ` font-family="${s.fontFamily}"`;
255
- if (!currentStyle || s.fontSize !== currentStyle.fontSize) attrs += ` font-size="${s.fontSize}"`;
256
- if (!currentStyle || s.color !== currentStyle.color) attrs += ` fill="${s.color}"`;
257
- if (!currentStyle || s.decoration !== currentStyle.decoration) {
258
- if (s.decoration) attrs += ` text-decoration="${s.decoration}"`;
259
- }
260
-
261
- return { attrs, newStyle: s };
262
- }
263
-
264
- /** Build per-glyph x positions for glyph mode */
265
- function buildGlyphPositions(span: Span, _lineX: number): string {
266
- if (!span.glyphAdvances || span.glyphAdvances.length === 0) {
267
- return '';
268
- }
269
- // span.x is already absolute — computed by PositioningEngine.
270
- // lineX is NOT added because that would double-shift.
271
- const spanX = span.x;
272
- let xPos = spanX;
273
- const positions: string[] = [xPos.toFixed(1)];
274
- for (let i = 0; i < span.glyphAdvances.length - 1; i++) {
275
- xPos += span.glyphAdvances[i];
276
- positions.push(xPos.toFixed(1));
277
- }
278
- return positions.join(' ');
279
- }
280
-
281
- /** Build textLength attribute for a line */
282
- function buildFitAttr(line: Line, opts: ResolvedOptions): string {
283
- if (opts.fit === 'text') {
284
- return ` textLength="${line.width}" lengthAdjust="spacing"`;
285
- }
286
- return '';
287
- }
288
-
289
- /** Build textLength for a span */
290
- function buildSpanFitAttr(span: Span, opts: ResolvedOptions): string {
291
- if (opts.fit === 'frag') {
292
- return ` textLength="${span.width}"`;
293
- }
294
- return '';
295
- }
296
-
297
- // ── SVG builder ──────────────────────────────────────────────────────────
298
-
299
- class SvgBuilder {
300
- private parts: string[] = [];
301
- private opts: ResolvedOptions;
302
-
303
- constructor(width: number, height: number, opts: ResolvedOptions) {
304
- this.opts = opts;
305
- const className = opts.className ? ` class="${escapeXml(opts.className)}"` : '';
306
- this.parts.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"${className}>\n`);
307
- if (opts.className) {
308
- this.parts[0] = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" class="${escapeXml(opts.className)}">\n`;
309
- }
310
- }
311
-
312
- addText(line: Line, baseSpan: Span, runId?: string, yOverride?: number, fontSizeOverride?: number): void {
313
- let attrs: string;
314
- if (yOverride !== undefined && fontSizeOverride !== undefined) {
315
- // For flat mode sub/superscript — override y and font-size
316
- const s = defaultStyleState(baseSpan);
317
- const x = line.x;
318
- attrs = ` x="${x}" y="${yOverride}"`;
319
- if (this.opts.style === 'css') {
320
- let css = cssStyleString(s);
321
- if (this.opts.spacing === 'preserve') css += '; white-space: pre';
322
- attrs += ` style="${css}"`;
323
- } else {
324
- let xml = ` font-family="${s.fontFamily}" font-size="${fontSizeOverride}" fill="${s.color}" font-weight="${s.fontWeight}"`;
325
- if (s.fontStyle === 'italic') xml += ' font-style="italic"';
326
- if (s.decoration) xml += ` text-decoration="${s.decoration}"`;
327
- if (this.opts.spacing === 'preserve') xml += ' xml:space="preserve"';
328
- attrs += xml;
329
- }
330
- } else {
331
- attrs = buildTextAttrs(line, baseSpan, this.opts, runId);
332
- }
333
- const fit = buildFitAttr(line, this.opts);
334
- this.parts.push(` <text${attrs}${fit}>\n`);
335
- }
336
-
337
- addFlatSpan(text: string): void {
338
- this.parts.push(`${escapeXml(text)}`);
339
- }
340
-
341
- /** Write a raw SVG line (for flat mode where each span is its own <text>). */
342
- pushLine(line: string): void {
343
- this.parts.push(line);
344
- }
345
-
346
- closeText(): void {
347
- this.parts.push('</text>\n');
348
- }
349
-
350
- addExpandedSpan(span: Span, x: number, style: StyleState | null): StyleState {
351
- const { attrs, newStyle } = buildTspanAttrs(span, x, style);
352
- const fit = buildSpanFitAttr(span, this.opts);
353
- this.parts.push(` <tspan${attrs}${fit}>${escapeXml(span.text)}</tspan>\n`);
354
- return newStyle;
355
- }
356
-
357
- addGlyphSpan(span: Span, lineX: number): void {
358
- const positions = buildGlyphPositions(span, lineX);
359
- if (positions) {
360
- this.parts.push(` <tspan x="${positions}">${escapeXml(span.text)}</tspan>\n`);
361
- } else {
362
- this.parts.push(` <tspan>${escapeXml(span.text)}</tspan>\n`);
363
- }
364
- }
365
-
366
-
367
- addDebug(debugOverlay: string): void {
368
- if (debugOverlay) {
369
- this.parts.push(`<!-- debug overlay -->\n${debugOverlay}\n`);
370
- }
371
- }
372
-
373
- build(): string {
374
- this.parts.push('</svg>\n');
375
- return this.parts.join('');
376
- }
377
- }
378
-
379
- // ── Debug overlay ────────────────────────────────────────────────────────
380
-
381
- function renderDebugToSVG(lines: Line[], width: number, height: number, flags: DebugFlags): string {
382
- const parts: string[] = [];
383
-
384
- if (flags.frame && lines.length > 0) {
385
- const first = lines[0];
386
- const last = lines[lines.length - 1];
387
- const maxW = Math.max(...lines.map(l => l.x + l.width));
388
- parts.push(
389
- ` <rect x="${first.x}" y="${first.y}" width="${maxW - first.x}" height="${last.y + last.height - first.y}"` +
390
- ` fill="none" stroke="rgba(255,200,0,0.6)" stroke-width="1" stroke-dasharray="6,2" />`,
391
- );
392
- }
393
-
394
- for (const line of lines) {
395
- const bx = line.x, by = line.y, bw = line.width, bh = line.height;
396
- const baselineY = line.y + line.baseline;
397
-
398
- if (flags.lineGap) {
399
- parts.push(` <rect x="${bx}" y="${by}" width="${bw}" height="${bh}" fill="rgba(0,150,255,0.10)" stroke="none" />`);
400
- }
401
- if (flags.box) {
402
- parts.push(` <rect x="${bx}" y="${by}" width="${bw}" height="${bh}" fill="none" stroke="rgba(255,100,100,0.5)" stroke-width="1" />`);
403
- }
404
- if (flags.baseline) {
405
- parts.push(` <line x1="${bx}" y1="${baselineY}" x2="${bx + bw}" y2="${baselineY}" stroke="rgba(100,100,255,0.5)" stroke-width="1" />`);
406
- }
407
- if (flags.ascentDescent) {
408
- parts.push(` <line x1="${bx}" y1="${baselineY - line.ascent}" x2="${bx + bw}" y2="${baselineY - line.ascent}" stroke="rgba(100,255,100,0.4)" stroke-width="0.5" stroke-dasharray="3,2" />`);
409
- parts.push(` <line x1="${bx}" y1="${baselineY + line.descent}" x2="${bx + bw}" y2="${baselineY + line.descent}" stroke="rgba(100,255,100,0.4)" stroke-width="0.5" stroke-dasharray="3,2" />`);
410
- }
411
- if (flags.labels) {
412
- parts.push(` <text x="${bx}" y="${by - 2}" font-size="9" fill="rgba(0,0,0,0.55)" font-family="monospace">y=${by.toFixed(1)} x=${bx.toFixed(1)} w=${bw.toFixed(1)} h=${bh.toFixed(1)} bl=${baselineY.toFixed(1)}</text>`);
413
- }
414
- if (flags.runs) {
415
- for (const span of line.spans) {
416
- if (span.width <= 0) continue;
417
- const rx = line.x + span.x;
418
- const ry = baselineY - span.fontMetrics.ascent;
419
- parts.push(` <rect x="${rx}" y="${ry}" width="${span.width}" height="${span.fontMetrics.ascent + span.fontMetrics.descent}" fill="none" stroke="rgba(200,100,255,0.4)" stroke-width="0.5" />`);
420
- }
421
- }
422
- }
423
-
424
- return parts.join('\n');
425
- }
426
-
427
- // ── Main render logic ────────────────────────────────────────────────────
428
-
429
- /**
430
- * Render Line[] into SVG string.
431
- *
432
- * @param lines — layout lines with spans
433
- * @param options — rendering options (preset + style/fit/sizing modifiers)
434
- * @returns SVG string
435
- */
436
- export function renderToSVG(lines: Line[], options: SVGRenderOptions = {}): string {
437
- const opts = resolveOptions(options);
438
-
439
- // Determine canvas size
440
- let svgWidth: number;
441
- let svgHeight: number;
442
-
443
- if (opts.sizing === 'content') {
444
- const bbox = computeBBox(lines);
445
- svgWidth = bbox.width;
446
- svgHeight = bbox.height;
447
- } else {
448
- // sizing='frame' requires explicit width/height — no fallback
449
- if (opts.width === undefined || opts.height === undefined) {
450
- throw new Error(
451
- `renderToSVG: sizing="frame" requires explicit width and height. ` +
452
- `Got width=${opts.width}, height=${opts.height}. ` +
453
- `Use renderResultToSVG(result, options) to auto-pass dimensions.`
454
- );
455
- }
456
- svgWidth = opts.width;
457
- svgHeight = opts.height;
458
- }
459
-
460
- const builder = new SvgBuilder(svgWidth, svgHeight, opts);
461
-
462
- for (const line of lines) {
463
- if (opts.structure === 'glyph') {
464
- // Per-glyph positioning with run-based <text> grouping
465
- let currentRunIdx = -1;
466
- for (const span of line.spans) {
467
- if (!span.text) continue;
468
- const runIdx = span.itemIndex;
469
- if (runIdx !== currentRunIdx) {
470
- if (currentRunIdx !== -1) {
471
- builder.closeText();
472
- }
473
- const runId = span.paragraphId ? `${span.paragraphId}-${runIdx}` : undefined;
474
- builder.addText(line, span, runId);
475
- currentRunIdx = runIdx;
476
- }
477
- builder.addGlyphSpan(span, line.x);
478
- }
479
- if (currentRunIdx !== -1) {
480
- builder.closeText();
481
- }
482
- } else if (opts.structure === 'flat') {
483
- // flat mode: group spans by (baseline + offset). Each group → one <text>.
484
- const groups: { spans: Span[]; targetY: number; fontSize: number }[] = [];
485
- for (const span of line.spans) {
486
- if (!span.text) continue;
487
- const offset = span.fontMetrics.baselineOffset || 0;
488
- const targetY = Math.round((line.y + line.baseline + offset) * 100) / 100;
489
- const fontSize = span.fontMetrics.fontSize;
490
- const last = groups[groups.length - 1];
491
- if (last && last.targetY === targetY && last.fontSize === fontSize) {
492
- last.spans.push(span);
493
- } else {
494
- groups.push({ spans: [span], targetY, fontSize });
495
- }
496
- }
497
- for (const group of groups) {
498
- const s = defaultStyleState(group.spans[0]);
499
- const text = group.spans.map(sp => escapeXml(sp.text)).join('');
500
- let attrs = ` x="${line.x}" y="${group.targetY}" font-family="${s.fontFamily}" font-size="${group.fontSize}" fill="${s.color}" font-weight="${s.fontWeight}"`;
501
- if (s.fontStyle === 'italic') attrs += ' font-style="italic"';
502
- if (s.decoration) attrs += ` text-decoration="${s.decoration}"`;
503
- attrs += ' xml:space="preserve"';
504
- // text-anchor for center/right alignment
505
- if (line.alignment === 'center') attrs += ' text-anchor="middle"';
506
- else if (line.alignment === 'right') attrs += ' text-anchor="end"';
507
- builder.pushLine(` <text${attrs}>${text}</text>\n`);
508
- }
509
- } else {
510
- // expanded: single <text> per line with <tspan> children
511
- const baseSpan = line.spans.find(f => f.type === 'text' && f.text.length > 0) || line.spans[0];
512
- if (!baseSpan) continue;
513
-
514
- builder.addText(line, baseSpan);
515
- let currentStyle: StyleState | null = null;
516
- for (const span of line.spans) {
517
- if (!span.text) continue;
518
- const x = span.x;
519
-
520
- const shouldRender = span.type !== 'space' || opts.spacing === 'preserve';
521
- if (shouldRender) {
522
- const newStyle = builder.addExpandedSpan(span, x, currentStyle);
523
- if (span.type !== 'space') {
524
- currentStyle = newStyle;
525
- }
526
- }
527
- }
528
- builder.closeText();
529
- }
530
- }
531
-
532
- if (opts.debug) {
533
- const debugSvg = renderDebugToSVG(lines, svgWidth, svgHeight, opts.debug);
534
- builder.addDebug(debugSvg);
535
- }
536
-
537
- return builder.build();
538
- }
539
-
540
- /**
541
- * Render one ParagraphLayoutResult to SVG (convenience wrapper).
542
- */
543
- export function renderParagraphToSVG(
544
- lines: Line[],
545
- paragraphWidth: number,
546
- paragraphHeight: number,
547
- options?: SVGRenderOptions,
548
- ): string {
549
- return renderToSVG(lines, {
550
- width: paragraphWidth,
551
- height: paragraphHeight,
552
- ...options,
553
- });
554
- }
555
-
556
- /**
557
- * Render a ParagraphLayoutResult to SVG, auto-passing dimensions.
558
- *
559
- * Uses `result.width` and `result.height` as the SVG canvas size.
560
- * This is the recommended way to render when you have a layout result
561
- * and want `sizing: 'frame'` with correct dimensions.
562
- *
563
- * @param result — layout result from ParagraphLayoutEngine.layout()
564
- * @param options — rendering options (preset, style, fit, etc.)
565
- * @returns SVG string
566
- *
567
- * @example
568
- * ```ts
569
- * const result = paragraphLayoutEngine.layout(paragraph, 300);
570
- * const svg = renderResultToSVG(result, { preset: 'preserve' });
571
- * ```
572
- */
573
- export function renderResultToSVG(
574
- result: ParagraphLayoutResult,
575
- options?: SVGRenderOptions,
576
- ): string {
577
- return renderToSVG(result.lines, {
578
- width: result.width,
579
- height: result.height,
580
- ...options,
581
- });
582
- }
package/src/index.ts DELETED
@@ -1,13 +0,0 @@
1
- /**
2
- * @vyaz/renderer — SVG and Canvas renderers.
3
- *
4
- * Converts Line[] (from @vyaz/core) into SVG strings or Canvas drawings.
5
- */
6
-
7
- export { renderToSVG, renderParagraphToSVG, renderResultToSVG } from './SVGRenderer.js';
8
- export type { SVGRenderOptions, SvgPreset, SvgStyle, SvgFit, SvgSizing } from './SVGRenderer.js';
9
-
10
- export { renderToCanvas, renderDebugToCanvas } from './CanvasRenderer.js';
11
- export type { CanvasRenderOptions } from './CanvasRenderer.js';
12
-
13
- export type { DebugFlags } from './types.js';
package/src/types.ts DELETED
@@ -1,14 +0,0 @@
1
- /**
2
- * Render types shared across all renderers (SVG, Canvas, etc.).
3
- */
4
-
5
- export interface DebugFlags {
6
- box?: boolean;
7
- baseline?: boolean;
8
- ascentDescent?: boolean;
9
- frame?: boolean;
10
- labels?: boolean;
11
- runs?: boolean;
12
- /** Show filled rect for each line's lineHeight (background fill). */
13
- lineGap?: boolean;
14
- }
package/src/utils.ts DELETED
@@ -1,15 +0,0 @@
1
- /**
2
- * render/utils.ts — shared renderer utilities.
3
- */
4
-
5
- import type { Line } from '@vyaz/core';
6
-
7
- /**
8
- * Compute the bounding box (content width + height) from an array of Line.
9
- */
10
- export function computeBBox(lines: Line[]): { width: number; height: number } {
11
- if (lines.length === 0) return { width: 0, height: 0 };
12
- const width = Math.max(...lines.map(l => l.x + l.width));
13
- const height = lines[lines.length - 1].y + lines[lines.length - 1].height;
14
- return { width, height };
15
- }