@vyaz/renderer 0.0.1
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/package.json +20 -0
- package/src/CanvasRenderer.ts +249 -0
- package/src/SVGRenderer.ts +536 -0
- package/src/index.ts +13 -0
- package/src/types.ts +14 -0
- package/src/utils.ts +15 -0
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vyaz/renderer",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"types": "./src/index.ts",
|
|
7
|
+
"files": ["dist", "src"],
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@vyaz/core": "*"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"typescript": "^5.4.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CanvasRenderer.ts — render LineBox[] → Canvas.
|
|
3
|
+
*
|
|
4
|
+
* Takes ready LineBox[] with absolute coordinates.
|
|
5
|
+
* Does not compute anything — only draws (dumb drawer principle).
|
|
6
|
+
*
|
|
7
|
+
* Options:
|
|
8
|
+
* - sizing: 'frame' | 'content' — auto canvas size
|
|
9
|
+
* - preserveSpaces: boolean — draw spaces (instead of skip)
|
|
10
|
+
* - backgroundColor: string — background color
|
|
11
|
+
* - debug: DebugFlags — debug overlays
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { LineBox, FragmentBox } from '@vyaz/core';
|
|
15
|
+
import type { DebugFlags } from './types.js';
|
|
16
|
+
import { computeBBox } from './utils.js';
|
|
17
|
+
|
|
18
|
+
export interface CanvasRenderOptions {
|
|
19
|
+
/**
|
|
20
|
+
* How the canvas size is determined:
|
|
21
|
+
* 'frame' — use current ctx.canvas.width/height (default)
|
|
22
|
+
* 'content' — compute bounding box from lines, resize canvas to fit
|
|
23
|
+
*/
|
|
24
|
+
sizing?: 'frame' | 'content';
|
|
25
|
+
/**
|
|
26
|
+
* When true: render space fragments with a space character.
|
|
27
|
+
* When false (default): skip space fragments (position is already accounted for in x).
|
|
28
|
+
*/
|
|
29
|
+
preserveSpaces?: boolean;
|
|
30
|
+
/** Background color for clearing. If omitted, canvas is cleared transparent. */
|
|
31
|
+
backgroundColor?: string;
|
|
32
|
+
/** Debug overlay flags. */
|
|
33
|
+
debug?: DebugFlags;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Font weight to CSS value */
|
|
37
|
+
function fontWeightCSS(weight: string | number): string {
|
|
38
|
+
if (weight === 'bold') return 'bold';
|
|
39
|
+
if (weight === 'normal') return 'normal';
|
|
40
|
+
if (typeof weight === 'number') return String(weight);
|
|
41
|
+
return 'normal';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Font style to CSS value */
|
|
45
|
+
function fontStyleCSS(style: string): string {
|
|
46
|
+
return style === 'italic' ? 'italic' : 'normal';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Render LineBox[] array to Canvas.
|
|
51
|
+
*
|
|
52
|
+
* @param ctx — Canvas 2D rendering context
|
|
53
|
+
* @param lines — ready LineBox[] with absolute coordinates
|
|
54
|
+
* @param options — rendering options
|
|
55
|
+
*/
|
|
56
|
+
export function renderToCanvas(
|
|
57
|
+
ctx: CanvasRenderingContext2D | any,
|
|
58
|
+
lines: LineBox[],
|
|
59
|
+
options: CanvasRenderOptions = {},
|
|
60
|
+
): void {
|
|
61
|
+
const sizing = options.sizing ?? 'frame';
|
|
62
|
+
const preserveSpaces = options.preserveSpaces ?? false;
|
|
63
|
+
|
|
64
|
+
// ── Sizing: content mode resizes canvas ──────────────────────────
|
|
65
|
+
let canvasWidth: number;
|
|
66
|
+
let canvasHeight: number;
|
|
67
|
+
|
|
68
|
+
if (sizing === 'content') {
|
|
69
|
+
const bbox = computeBBox(lines);
|
|
70
|
+
canvasWidth = bbox.width;
|
|
71
|
+
canvasHeight = bbox.height;
|
|
72
|
+
ctx.canvas.width = canvasWidth;
|
|
73
|
+
ctx.canvas.height = canvasHeight;
|
|
74
|
+
} else {
|
|
75
|
+
canvasWidth = ctx.canvas.width;
|
|
76
|
+
canvasHeight = ctx.canvas.height;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Background ───────────────────────────────────────────────────
|
|
80
|
+
if (options.backgroundColor) {
|
|
81
|
+
ctx.fillStyle = options.backgroundColor;
|
|
82
|
+
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
|
83
|
+
} else {
|
|
84
|
+
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Render lines ─────────────────────────────────────────────────
|
|
88
|
+
for (const line of lines) {
|
|
89
|
+
const baselineY = line.y + line.baseline;
|
|
90
|
+
|
|
91
|
+
for (const frag of line.fragments) {
|
|
92
|
+
const x = line.x + frag.x;
|
|
93
|
+
|
|
94
|
+
// Font setting
|
|
95
|
+
const style = fontStyleCSS(frag.style.fontStyle);
|
|
96
|
+
const weight = fontWeightCSS(frag.style.fontWeight);
|
|
97
|
+
const size = frag.fontMetrics.fontSize;
|
|
98
|
+
const family = frag.style.fontFamily;
|
|
99
|
+
ctx.font = `${style} ${weight} ${size}px ${family}`;
|
|
100
|
+
ctx.fillStyle = frag.style.color || '#000000';
|
|
101
|
+
ctx.textBaseline = 'alphabetic';
|
|
102
|
+
|
|
103
|
+
// Draw text
|
|
104
|
+
if (preserveSpaces || frag.type !== 'space') {
|
|
105
|
+
ctx.fillText(frag.text, x, baselineY);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Underline
|
|
109
|
+
if (frag.style.underline) {
|
|
110
|
+
const ulY = baselineY + 2;
|
|
111
|
+
ctx.strokeStyle = frag.style.color || '#000000';
|
|
112
|
+
ctx.lineWidth = 1;
|
|
113
|
+
ctx.beginPath();
|
|
114
|
+
ctx.moveTo(x, ulY);
|
|
115
|
+
ctx.lineTo(x + frag.width, ulY);
|
|
116
|
+
ctx.stroke();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Strikethrough
|
|
120
|
+
if (frag.style.strikethrough) {
|
|
121
|
+
const stY = baselineY - frag.fontMetrics.ascent * 0.4;
|
|
122
|
+
ctx.strokeStyle = frag.style.color || '#000000';
|
|
123
|
+
ctx.lineWidth = 1;
|
|
124
|
+
ctx.beginPath();
|
|
125
|
+
ctx.moveTo(x, stY);
|
|
126
|
+
ctx.lineTo(x + frag.width, stY);
|
|
127
|
+
ctx.stroke();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// InlineWidget (simple rectangle)
|
|
131
|
+
if (frag.inlineWidget) {
|
|
132
|
+
const iw = frag.inlineWidget;
|
|
133
|
+
const iwY = baselineY - (iw.height || frag.fontMetrics.ascent) + (iw.baselineOffset || 0);
|
|
134
|
+
ctx.fillStyle = '#cccccc';
|
|
135
|
+
ctx.fillRect(x, iwY, iw.width, iw.height);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Debug overlay
|
|
141
|
+
if (options.debug) {
|
|
142
|
+
renderDebugToCanvas(ctx, lines, canvasWidth, canvasHeight, options.debug);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Debug overlay ────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/** Draw debug overlays on canvas */
|
|
149
|
+
export function renderDebugToCanvas(
|
|
150
|
+
ctx: CanvasRenderingContext2D,
|
|
151
|
+
lines: LineBox[],
|
|
152
|
+
_width: number,
|
|
153
|
+
_height: number,
|
|
154
|
+
flags: DebugFlags,
|
|
155
|
+
): void {
|
|
156
|
+
ctx.save();
|
|
157
|
+
|
|
158
|
+
// Text frame — outer bounding box of all lines
|
|
159
|
+
if (flags.frame && lines.length > 0) {
|
|
160
|
+
const first = lines[0];
|
|
161
|
+
const last = lines[lines.length - 1];
|
|
162
|
+
const maxW = Math.max(...lines.map(l => l.x + l.width));
|
|
163
|
+
const frameX = first.x;
|
|
164
|
+
const frameY = first.y;
|
|
165
|
+
const frameW = maxW - frameX;
|
|
166
|
+
const frameH = last.y + last.height - first.y;
|
|
167
|
+
ctx.strokeStyle = 'rgba(255,200,0,0.6)';
|
|
168
|
+
ctx.lineWidth = 1;
|
|
169
|
+
ctx.setLineDash([6, 2]);
|
|
170
|
+
ctx.strokeRect(frameX, frameY, frameW, frameH);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
ctx.setLineDash([]);
|
|
174
|
+
|
|
175
|
+
for (const line of lines) {
|
|
176
|
+
const bx = line.x;
|
|
177
|
+
const by = line.y;
|
|
178
|
+
const bw = line.width;
|
|
179
|
+
const bh = line.height;
|
|
180
|
+
const baselineY = line.y + line.baseline;
|
|
181
|
+
|
|
182
|
+
// Line gap — blue filled rect for lineHeight visualization
|
|
183
|
+
if (flags.lineGap) {
|
|
184
|
+
ctx.fillStyle = 'rgba(0,150,255,0.10)';
|
|
185
|
+
ctx.fillRect(bx, by, bw, bh);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Bounding box — red rect
|
|
189
|
+
if (flags.box) {
|
|
190
|
+
ctx.strokeStyle = 'rgba(255,100,100,0.5)';
|
|
191
|
+
ctx.lineWidth = 1;
|
|
192
|
+
ctx.strokeRect(bx, by, bw, bh);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Baseline — blue line
|
|
196
|
+
if (flags.baseline) {
|
|
197
|
+
ctx.strokeStyle = 'rgba(100,100,255,0.5)';
|
|
198
|
+
ctx.lineWidth = 1;
|
|
199
|
+
ctx.beginPath();
|
|
200
|
+
ctx.moveTo(bx, baselineY);
|
|
201
|
+
ctx.lineTo(bx + bw, baselineY);
|
|
202
|
+
ctx.stroke();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Ascent / Descent — green dashed lines
|
|
206
|
+
if (flags.ascentDescent) {
|
|
207
|
+
const ascentY = baselineY - line.ascent;
|
|
208
|
+
const descentY = baselineY + line.descent;
|
|
209
|
+
ctx.strokeStyle = 'rgba(100,255,100,0.4)';
|
|
210
|
+
ctx.lineWidth = 0.5;
|
|
211
|
+
ctx.setLineDash([3, 2]);
|
|
212
|
+
ctx.beginPath();
|
|
213
|
+
ctx.moveTo(bx, ascentY);
|
|
214
|
+
ctx.lineTo(bx + bw, ascentY);
|
|
215
|
+
ctx.stroke();
|
|
216
|
+
ctx.beginPath();
|
|
217
|
+
ctx.moveTo(bx, descentY);
|
|
218
|
+
ctx.lineTo(bx + bw, descentY);
|
|
219
|
+
ctx.stroke();
|
|
220
|
+
ctx.setLineDash([]);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Labels — small monospace text
|
|
224
|
+
if (flags.labels) {
|
|
225
|
+
const labelY = by - 2;
|
|
226
|
+
const label = `y=${by.toFixed(1)} x=${bx.toFixed(1)} w=${bw.toFixed(1)} h=${bh.toFixed(1)} bl=${baselineY.toFixed(1)}`;
|
|
227
|
+
ctx.font = '9px monospace';
|
|
228
|
+
ctx.fillStyle = 'rgba(0,0,0,0.55)';
|
|
229
|
+
ctx.textBaseline = 'bottom';
|
|
230
|
+
ctx.fillText(label, bx, labelY);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Run boxes — purple rects around FragmentBox
|
|
234
|
+
if (flags.runs) {
|
|
235
|
+
for (const frag of line.fragments) {
|
|
236
|
+
if (frag.width <= 0) continue;
|
|
237
|
+
const rx = line.x + frag.x;
|
|
238
|
+
const ry = baselineY - frag.fontMetrics.ascent;
|
|
239
|
+
const rw = frag.width;
|
|
240
|
+
const rh = frag.fontMetrics.ascent + frag.fontMetrics.descent;
|
|
241
|
+
ctx.strokeStyle = 'rgba(200,100,255,0.4)';
|
|
242
|
+
ctx.lineWidth = 0.5;
|
|
243
|
+
ctx.strokeRect(rx, ry, rw, rh);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
ctx.restore();
|
|
249
|
+
}
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVGRenderer.ts — SVG text builder.
|
|
3
|
+
*
|
|
4
|
+
* Converts LineBox[] into SVG markup using a builder pattern.
|
|
5
|
+
*
|
|
6
|
+
* Three presets:
|
|
7
|
+
* flat — all text in one <text> element, xml:space="preserve", no <tspan>
|
|
8
|
+
* browser — expanded <tspan> per run, NO xml:space, spaces skipped (browser collapses them)
|
|
9
|
+
* preserve — expanded <tspan> per run, xml:space="preserve", spaces render as <tspan> </tspan>,
|
|
10
|
+
* textLength on each line by default for pixel-perfect width
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* const svg = renderToSVG(lines, { preset: 'browser' })
|
|
14
|
+
* const svg = renderToSVG(lines, { preset: 'preserve', style: 'css', fit: 'frag' })
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { LineBox, FragmentBox, ParagraphLayoutResult } from '@vyaz/core';
|
|
18
|
+
import type { DebugFlags } from './types.js';
|
|
19
|
+
import { computeBBox } from './utils.js';
|
|
20
|
+
|
|
21
|
+
// ── Types ────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export type SvgPreset = 'flat' | 'browser' | 'preserve' | 'glyph';
|
|
24
|
+
|
|
25
|
+
export type SvgStyle = 'css' | 'xml';
|
|
26
|
+
|
|
27
|
+
export type SvgFit = 'none' | 'text' | 'frag';
|
|
28
|
+
|
|
29
|
+
export type SvgSizing = 'frame' | 'content';
|
|
30
|
+
|
|
31
|
+
export interface SVGRenderOptions {
|
|
32
|
+
/** Shorthand that sets structure + spacing at once. */
|
|
33
|
+
preset?: SvgPreset;
|
|
34
|
+
/** How style properties are expressed: as CSS `style` attribute or as XML presentation attributes. */
|
|
35
|
+
style?: SvgStyle;
|
|
36
|
+
/** How `textLength` is applied. */
|
|
37
|
+
fit?: SvgFit;
|
|
38
|
+
/** How SVG determines its size: 'frame' — use width/height from options; 'content' — compute BBox from lines. */
|
|
39
|
+
sizing?: SvgSizing;
|
|
40
|
+
/** SVG canvas width (px). Used when sizing='frame' or as fallback. */
|
|
41
|
+
width?: number;
|
|
42
|
+
/** SVG canvas height (px). Used when sizing='frame' or as fallback. */
|
|
43
|
+
height?: number;
|
|
44
|
+
/** CSS class for `<svg>`. */
|
|
45
|
+
className?: string;
|
|
46
|
+
/** Debug overlays. */
|
|
47
|
+
debug?: DebugFlags;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type SpacingMode = 'browser' | 'preserve';
|
|
51
|
+
type StructureMode = 'flat' | 'expanded' | 'glyph';
|
|
52
|
+
|
|
53
|
+
type ResolvedOptions = {
|
|
54
|
+
structure: StructureMode;
|
|
55
|
+
spacing: SpacingMode;
|
|
56
|
+
style: 'css' | 'xml';
|
|
57
|
+
fit: 'none' | 'text' | 'frag';
|
|
58
|
+
sizing: 'frame' | 'content';
|
|
59
|
+
width?: number;
|
|
60
|
+
height?: number;
|
|
61
|
+
className?: string;
|
|
62
|
+
debug?: DebugFlags;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ── Preset map ───────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
const PRESETS: Record<SvgPreset, { structure: StructureMode; spacing: SpacingMode; defaultFit: SvgFit }> = {
|
|
68
|
+
flat: { structure: 'flat', spacing: 'preserve', defaultFit: 'none' },
|
|
69
|
+
browser: { structure: 'expanded', spacing: 'browser', defaultFit: 'none' },
|
|
70
|
+
preserve: { structure: 'expanded', spacing: 'preserve', defaultFit: 'none' },
|
|
71
|
+
glyph: { structure: 'glyph', spacing: 'preserve', defaultFit: 'none' },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
function escapeXml(text: string): string {
|
|
77
|
+
return text
|
|
78
|
+
.replace(/&/g, '&')
|
|
79
|
+
.replace(/</g, '<')
|
|
80
|
+
.replace(/>/g, '>')
|
|
81
|
+
.replace(/"/g, '"');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function fontWeightCSS(weight: string | number): string {
|
|
85
|
+
if (weight === 'bold') return 'bold';
|
|
86
|
+
if (weight === 'normal') return '400';
|
|
87
|
+
if (typeof weight === 'number') return String(weight);
|
|
88
|
+
return '400';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function fontWeightNumeric(weight: string | number): number {
|
|
92
|
+
if (weight === 'bold') return 700;
|
|
93
|
+
if (weight === 'normal') return 400;
|
|
94
|
+
if (typeof weight === 'number') return weight;
|
|
95
|
+
return 400;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function colorToRGB(color: string): string {
|
|
99
|
+
if (!color) return 'rgb(0, 0, 0)';
|
|
100
|
+
let hex = color;
|
|
101
|
+
if (hex.length === 4 && hex[0] === '#') {
|
|
102
|
+
hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
|
|
103
|
+
}
|
|
104
|
+
if (hex.length === 7 && hex[0] === '#') {
|
|
105
|
+
return `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
|
|
106
|
+
}
|
|
107
|
+
return 'rgb(0, 0, 0)';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Compute gutter widths per line for justify alignment */
|
|
111
|
+
function computeGutterWidths(line: LineBox, totalSlack: number): number[] {
|
|
112
|
+
const spaceFrags = line.fragments.filter(f => f.type === 'space' || f.text.trim() === '');
|
|
113
|
+
if (spaceFrags.length === 0) return [];
|
|
114
|
+
const perGap = totalSlack / spaceFrags.length;
|
|
115
|
+
return line.fragments.map(f => (f.type === 'space' || f.text.trim() === '') ? perGap : 0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Resolve options ──────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
function resolveOptions(opts: SVGRenderOptions): ResolvedOptions {
|
|
121
|
+
let structure: StructureMode;
|
|
122
|
+
let spacing: SpacingMode;
|
|
123
|
+
let defaultFit: SvgFit;
|
|
124
|
+
|
|
125
|
+
if (opts.preset) {
|
|
126
|
+
const preset = PRESETS[opts.preset];
|
|
127
|
+
if (!preset) {
|
|
128
|
+
console.warn(`SVGRenderer: unknown preset "${opts.preset}", falling back to browser`);
|
|
129
|
+
structure = 'expanded';
|
|
130
|
+
spacing = 'browser';
|
|
131
|
+
defaultFit = 'none';
|
|
132
|
+
} else {
|
|
133
|
+
structure = preset.structure;
|
|
134
|
+
spacing = preset.spacing;
|
|
135
|
+
defaultFit = preset.defaultFit;
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
structure = 'expanded';
|
|
139
|
+
spacing = 'browser';
|
|
140
|
+
defaultFit = 'none';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const style = opts.style ?? 'xml';
|
|
144
|
+
let fit = opts.fit ?? defaultFit;
|
|
145
|
+
const sizing = opts.sizing ?? 'frame';
|
|
146
|
+
|
|
147
|
+
// Validation rules
|
|
148
|
+
if (structure === 'glyph' && fit !== 'none') {
|
|
149
|
+
console.warn(`SVGRenderer: fit="${fit}" is ignored when structure="glyph"`);
|
|
150
|
+
fit = 'none';
|
|
151
|
+
}
|
|
152
|
+
if (structure === 'flat' && fit === 'frag') {
|
|
153
|
+
console.warn(`SVGRenderer: fit="frag" downgraded to "text" when structure="flat"`);
|
|
154
|
+
fit = 'text';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { structure, spacing, style, fit, sizing, width: opts.width, height: opts.height, className: opts.className, debug: opts.debug };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Attribute builders ───────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
interface StyleState {
|
|
163
|
+
fontFamily: string;
|
|
164
|
+
fontSize: number;
|
|
165
|
+
fontWeight: number;
|
|
166
|
+
color: string;
|
|
167
|
+
fontStyle: string;
|
|
168
|
+
decoration: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function defaultStyleState(frag: FragmentBox): StyleState {
|
|
172
|
+
return {
|
|
173
|
+
fontFamily: frag.style.fontFamily || 'Arial',
|
|
174
|
+
fontSize: frag.fontMetrics.fontSize || 16,
|
|
175
|
+
fontWeight: fontWeightNumeric(frag.style.fontWeight),
|
|
176
|
+
color: frag.style.color || '#000000',
|
|
177
|
+
fontStyle: frag.style.fontStyle || 'normal',
|
|
178
|
+
decoration: frag.style.underline ? 'underline' : frag.style.strikethrough ? 'line-through' : '',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function equalStyle(a: StyleState, b: StyleState): boolean {
|
|
183
|
+
return a.fontFamily === b.fontFamily && a.fontSize === b.fontSize &&
|
|
184
|
+
a.fontWeight === b.fontWeight && a.color === b.color &&
|
|
185
|
+
a.fontStyle === b.fontStyle && a.decoration === b.decoration;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Build style string for CSS mode */
|
|
189
|
+
function cssStyleString(s: StyleState): string {
|
|
190
|
+
const parts: string[] = [];
|
|
191
|
+
parts.push(`font-family: '${s.fontFamily}', sans-serif`);
|
|
192
|
+
parts.push(`font-size: ${s.fontSize}px`);
|
|
193
|
+
parts.push(`fill: ${colorToRGB(s.color)}`);
|
|
194
|
+
if (s.fontWeight !== 400) parts.push(`font-weight: ${s.fontWeight}`);
|
|
195
|
+
if (s.fontStyle === 'italic') parts.push(`font-style: italic`);
|
|
196
|
+
if (s.decoration) parts.push(`text-decoration: ${s.decoration}`);
|
|
197
|
+
return parts.join('; ');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Build XML presentation attributes for a style */
|
|
201
|
+
function xmlStyleAttrs(s: StyleState): string {
|
|
202
|
+
let attrs = `font-family="${s.fontFamily}" font-size="${s.fontSize}" fill="${s.color}" font-weight="${s.fontWeight}"`;
|
|
203
|
+
if (s.fontStyle === 'italic') attrs += ' font-style="italic"';
|
|
204
|
+
if (s.decoration) attrs += ` text-decoration="${s.decoration}"`;
|
|
205
|
+
return attrs;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Build attributes for <text> element */
|
|
209
|
+
function buildTextAttrs(line: LineBox, frag: FragmentBox, opts: ResolvedOptions, runId?: string): string {
|
|
210
|
+
const x = line.x;
|
|
211
|
+
const y = line.y + line.baseline;
|
|
212
|
+
const s = defaultStyleState(frag);
|
|
213
|
+
|
|
214
|
+
let attrs = ` x="${x}" y="${y}"`;
|
|
215
|
+
if (runId) attrs += ` id="${runId}"`;
|
|
216
|
+
|
|
217
|
+
if (opts.style === 'css') {
|
|
218
|
+
let css = cssStyleString(s);
|
|
219
|
+
if (opts.spacing === 'preserve') css += '; white-space: pre';
|
|
220
|
+
attrs += ` style="${css}"`;
|
|
221
|
+
} else {
|
|
222
|
+
attrs += ` ${xmlStyleAttrs(s)}`;
|
|
223
|
+
if (opts.spacing === 'preserve') attrs += ' xml:space="preserve"';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// text-anchor is only meaningful for flat mode where text sits directly in <text>.
|
|
227
|
+
// For expanded/glyph modes, each <tspan> has explicit x="..." coordinates that already
|
|
228
|
+
// account for alignment — text-anchor on <text> would then double-shift the text.
|
|
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
|
+
}
|
|
233
|
+
|
|
234
|
+
return attrs;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Build attributes for <tspan> (expanded mode — only diff from current style) */
|
|
238
|
+
function buildTspanAttrs(frag: FragmentBox, x: number, currentStyle: StyleState | null): { attrs: string; newStyle: StyleState } {
|
|
239
|
+
const s = defaultStyleState(frag);
|
|
240
|
+
let attrs = ` x="${x}"`;
|
|
241
|
+
|
|
242
|
+
if (currentStyle && equalStyle(s, currentStyle)) {
|
|
243
|
+
return { attrs, newStyle: s };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// textLength is NOT added here — it is handled by buildFragFitAttr() separately
|
|
247
|
+
// to avoid duplicate textLength when fit='frag'.
|
|
248
|
+
|
|
249
|
+
if (!currentStyle || s.fontWeight !== currentStyle.fontWeight) attrs += ` font-weight="${s.fontWeight}"`;
|
|
250
|
+
if (!currentStyle || s.fontStyle !== currentStyle.fontStyle) attrs += ` font-style="${s.fontStyle}"`;
|
|
251
|
+
if (!currentStyle || s.fontFamily !== currentStyle.fontFamily) attrs += ` font-family="${s.fontFamily}"`;
|
|
252
|
+
if (!currentStyle || s.fontSize !== currentStyle.fontSize) attrs += ` font-size="${s.fontSize}"`;
|
|
253
|
+
if (!currentStyle || s.color !== currentStyle.color) attrs += ` fill="${s.color}"`;
|
|
254
|
+
if (!currentStyle || s.decoration !== currentStyle.decoration) {
|
|
255
|
+
if (s.decoration) attrs += ` text-decoration="${s.decoration}"`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return { attrs, newStyle: s };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Build per-glyph x positions for glyph mode */
|
|
262
|
+
function buildGlyphPositions(frag: FragmentBox, _lineX: number): string {
|
|
263
|
+
if (!frag.glyphAdvances || frag.glyphAdvances.length === 0) {
|
|
264
|
+
return '';
|
|
265
|
+
}
|
|
266
|
+
// frag.x is already absolute — computed by PositioningEngine.
|
|
267
|
+
// lineX is NOT added because that would double-shift.
|
|
268
|
+
const fragX = frag.x;
|
|
269
|
+
let xPos = fragX;
|
|
270
|
+
const positions: string[] = [xPos.toFixed(1)];
|
|
271
|
+
for (let i = 0; i < frag.glyphAdvances.length - 1; i++) {
|
|
272
|
+
xPos += frag.glyphAdvances[i];
|
|
273
|
+
positions.push(xPos.toFixed(1));
|
|
274
|
+
}
|
|
275
|
+
return positions.join(' ');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Build textLength attribute for a line */
|
|
279
|
+
function buildFitAttr(line: LineBox, opts: ResolvedOptions): string {
|
|
280
|
+
if (opts.fit === 'text') {
|
|
281
|
+
return ` textLength="${line.width}" lengthAdjust="spacing"`;
|
|
282
|
+
}
|
|
283
|
+
return '';
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Build textLength for a fragment */
|
|
287
|
+
function buildFragFitAttr(frag: FragmentBox, opts: ResolvedOptions): string {
|
|
288
|
+
if (opts.fit === 'frag') {
|
|
289
|
+
return ` textLength="${frag.width}"`;
|
|
290
|
+
}
|
|
291
|
+
return '';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── SVG builder ──────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
class SvgBuilder {
|
|
297
|
+
private parts: string[] = [];
|
|
298
|
+
private opts: ResolvedOptions;
|
|
299
|
+
|
|
300
|
+
constructor(width: number, height: number, opts: ResolvedOptions) {
|
|
301
|
+
this.opts = opts;
|
|
302
|
+
const className = opts.className ? ` class="${escapeXml(opts.className)}"` : '';
|
|
303
|
+
this.parts.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"${className}>\n`);
|
|
304
|
+
if (opts.className) {
|
|
305
|
+
this.parts[0] = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" class="${escapeXml(opts.className)}">\n`;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
addText(line: LineBox, baseFrag: FragmentBox, runId?: string): void {
|
|
310
|
+
const attrs = buildTextAttrs(line, baseFrag, this.opts, runId);
|
|
311
|
+
const fit = buildFitAttr(line, this.opts);
|
|
312
|
+
this.parts.push(` <text${attrs}${fit}>\n`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
addFlatFrag(text: string): void {
|
|
316
|
+
this.parts.push(` ${escapeXml(text)}`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
addExpandedFrag(frag: FragmentBox, x: number, style: StyleState | null): StyleState {
|
|
320
|
+
const { attrs, newStyle } = buildTspanAttrs(frag, x, style);
|
|
321
|
+
const fit = buildFragFitAttr(frag, this.opts);
|
|
322
|
+
this.parts.push(` <tspan${attrs}${fit}>${escapeXml(frag.text)}</tspan>\n`);
|
|
323
|
+
return newStyle;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
addGlyphFrag(frag: FragmentBox, lineX: number): void {
|
|
327
|
+
const positions = buildGlyphPositions(frag, lineX);
|
|
328
|
+
if (positions) {
|
|
329
|
+
this.parts.push(` <tspan x="${positions}">${escapeXml(frag.text)}</tspan>\n`);
|
|
330
|
+
} else {
|
|
331
|
+
this.parts.push(` <tspan>${escapeXml(frag.text)}</tspan>\n`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
closeText(): void {
|
|
336
|
+
this.parts.push(' </text>\n');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
addDebug(debugOverlay: string): void {
|
|
340
|
+
if (debugOverlay) {
|
|
341
|
+
this.parts.push(`<!-- debug overlay -->\n${debugOverlay}\n`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
build(): string {
|
|
346
|
+
this.parts.push('</svg>\n');
|
|
347
|
+
return this.parts.join('');
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── Debug overlay ────────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
function renderDebugToSVG(lines: LineBox[], width: number, height: number, flags: DebugFlags): string {
|
|
354
|
+
const parts: string[] = [];
|
|
355
|
+
|
|
356
|
+
if (flags.frame && lines.length > 0) {
|
|
357
|
+
const first = lines[0];
|
|
358
|
+
const last = lines[lines.length - 1];
|
|
359
|
+
const maxW = Math.max(...lines.map(l => l.x + l.width));
|
|
360
|
+
parts.push(
|
|
361
|
+
` <rect x="${first.x}" y="${first.y}" width="${maxW - first.x}" height="${last.y + last.height - first.y}"` +
|
|
362
|
+
` fill="none" stroke="rgba(255,200,0,0.6)" stroke-width="1" stroke-dasharray="6,2" />`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
for (const line of lines) {
|
|
367
|
+
const bx = line.x, by = line.y, bw = line.width, bh = line.height;
|
|
368
|
+
const baselineY = line.y + line.baseline;
|
|
369
|
+
|
|
370
|
+
if (flags.lineGap) {
|
|
371
|
+
parts.push(` <rect x="${bx}" y="${by}" width="${bw}" height="${bh}" fill="rgba(0,150,255,0.10)" stroke="none" />`);
|
|
372
|
+
}
|
|
373
|
+
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="1" />`);
|
|
375
|
+
}
|
|
376
|
+
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="1" />`);
|
|
378
|
+
}
|
|
379
|
+
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="0.5" stroke-dasharray="3,2" />`);
|
|
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="0.5" stroke-dasharray="3,2" />`);
|
|
382
|
+
}
|
|
383
|
+
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.toFixed(1)} x=${bx.toFixed(1)} w=${bw.toFixed(1)} h=${bh.toFixed(1)} bl=${baselineY.toFixed(1)}</text>`);
|
|
385
|
+
}
|
|
386
|
+
if (flags.runs) {
|
|
387
|
+
for (const frag of line.fragments) {
|
|
388
|
+
if (frag.width <= 0) continue;
|
|
389
|
+
const rx = line.x + frag.x;
|
|
390
|
+
const ry = baselineY - frag.fontMetrics.ascent;
|
|
391
|
+
parts.push(` <rect x="${rx}" y="${ry}" width="${frag.width}" height="${frag.fontMetrics.ascent + frag.fontMetrics.descent}" fill="none" stroke="rgba(200,100,255,0.4)" stroke-width="0.5" />`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return parts.join('\n');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── Main render logic ────────────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Render LineBox[] into SVG string.
|
|
403
|
+
*
|
|
404
|
+
* @param lines — layout lines with fragments
|
|
405
|
+
* @param options — rendering options (preset + style/fit/sizing modifiers)
|
|
406
|
+
* @returns SVG string
|
|
407
|
+
*/
|
|
408
|
+
export function renderToSVG(lines: LineBox[], options: SVGRenderOptions = {}): string {
|
|
409
|
+
const opts = resolveOptions(options);
|
|
410
|
+
|
|
411
|
+
// Determine canvas size
|
|
412
|
+
let svgWidth: number;
|
|
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
|
+
}
|
|
431
|
+
|
|
432
|
+
const builder = new SvgBuilder(svgWidth, svgHeight, opts);
|
|
433
|
+
|
|
434
|
+
for (const line of lines) {
|
|
435
|
+
if (opts.structure === 'glyph') {
|
|
436
|
+
// Per-glyph positioning with run-based <text> grouping
|
|
437
|
+
let currentRunIdx = -1;
|
|
438
|
+
for (const frag of line.fragments) {
|
|
439
|
+
if (!frag.text) continue;
|
|
440
|
+
const runIdx = frag.itemIndex;
|
|
441
|
+
if (runIdx !== currentRunIdx) {
|
|
442
|
+
if (currentRunIdx !== -1) {
|
|
443
|
+
builder.closeText();
|
|
444
|
+
}
|
|
445
|
+
const runId = frag.paragraphId ? `${frag.paragraphId}-${runIdx}` : undefined;
|
|
446
|
+
builder.addText(line, frag, runId);
|
|
447
|
+
currentRunIdx = runIdx;
|
|
448
|
+
}
|
|
449
|
+
builder.addGlyphFrag(frag, line.x);
|
|
450
|
+
}
|
|
451
|
+
if (currentRunIdx !== -1) {
|
|
452
|
+
builder.closeText();
|
|
453
|
+
}
|
|
454
|
+
} else {
|
|
455
|
+
// flat / expanded: single <text> per line
|
|
456
|
+
const baseFrag = line.fragments.find(f => f.type === 'text' && f.text.length > 0) || line.fragments[0];
|
|
457
|
+
if (!baseFrag) continue;
|
|
458
|
+
|
|
459
|
+
builder.addText(line, baseFrag);
|
|
460
|
+
|
|
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
|
+
let currentStyle: StyleState | null = null;
|
|
468
|
+
for (const frag of line.fragments) {
|
|
469
|
+
if (!frag.text) continue;
|
|
470
|
+
const x = frag.x;
|
|
471
|
+
|
|
472
|
+
const shouldRender = frag.type !== 'space' || opts.spacing === 'preserve';
|
|
473
|
+
if (shouldRender) {
|
|
474
|
+
const newStyle = builder.addExpandedFrag(frag, x, currentStyle);
|
|
475
|
+
if (frag.type !== 'space') {
|
|
476
|
+
currentStyle = newStyle;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
builder.closeText();
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (opts.debug) {
|
|
487
|
+
const debugSvg = renderDebugToSVG(lines, svgWidth, svgHeight, opts.debug);
|
|
488
|
+
builder.addDebug(debugSvg);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return builder.build();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Render one ParagraphLayoutResult to SVG (convenience wrapper).
|
|
496
|
+
*/
|
|
497
|
+
export function renderParagraphToSVG(
|
|
498
|
+
lines: LineBox[],
|
|
499
|
+
paragraphWidth: number,
|
|
500
|
+
paragraphHeight: number,
|
|
501
|
+
options?: SVGRenderOptions,
|
|
502
|
+
): string {
|
|
503
|
+
return renderToSVG(lines, {
|
|
504
|
+
width: paragraphWidth,
|
|
505
|
+
height: paragraphHeight,
|
|
506
|
+
...options,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Render a ParagraphLayoutResult to SVG, auto-passing dimensions.
|
|
512
|
+
*
|
|
513
|
+
* Uses `result.width` and `result.height` as the SVG canvas size.
|
|
514
|
+
* This is the recommended way to render when you have a layout result
|
|
515
|
+
* and want `sizing: 'frame'` with correct dimensions.
|
|
516
|
+
*
|
|
517
|
+
* @param result — layout result from ParagraphLayoutEngine.layout()
|
|
518
|
+
* @param options — rendering options (preset, style, fit, etc.)
|
|
519
|
+
* @returns SVG string
|
|
520
|
+
*
|
|
521
|
+
* @example
|
|
522
|
+
* ```ts
|
|
523
|
+
* const result = paragraphLayoutEngine.layout(paragraph, 300);
|
|
524
|
+
* const svg = renderResultToSVG(result, { preset: 'preserve' });
|
|
525
|
+
* ```
|
|
526
|
+
*/
|
|
527
|
+
export function renderResultToSVG(
|
|
528
|
+
result: ParagraphLayoutResult,
|
|
529
|
+
options?: SVGRenderOptions,
|
|
530
|
+
): string {
|
|
531
|
+
return renderToSVG(result.lines, {
|
|
532
|
+
width: result.width,
|
|
533
|
+
height: result.height,
|
|
534
|
+
...options,
|
|
535
|
+
});
|
|
536
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vyaz/renderer — SVG and Canvas renderers.
|
|
3
|
+
*
|
|
4
|
+
* Converts LineBox[] (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
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render/utils.ts — shared renderer utilities.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { LineBox } from '@vyaz/core';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Compute the bounding box (content width + height) from an array of LineBox.
|
|
9
|
+
*/
|
|
10
|
+
export function computeBBox(lines: LineBox[]): { 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
|
+
}
|