@vyaz/renderer 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,18 +1,48 @@
1
1
  // @bun
2
+ // ../core/src/utils/groupLinesByParagraph.ts
3
+ function groupLinesByParagraph(lines) {
4
+ const map = new Map;
5
+ for (const line of lines) {
6
+ const idx = line.spans[0]?.pIdx ?? -1;
7
+ let entry = map.get(idx);
8
+ if (!entry) {
9
+ entry = { lines: [] };
10
+ map.set(idx, entry);
11
+ }
12
+ entry.lines.push(line);
13
+ if (!entry.tag) {
14
+ entry.tag = line.spans[0]?.tag;
15
+ }
16
+ }
17
+ const groups = [];
18
+ const sortedKeys = Array.from(map.keys()).sort((a, b) => a - b);
19
+ for (const key of sortedKeys) {
20
+ if (key === -1)
21
+ continue;
22
+ const entry = map.get(key);
23
+ groups.push({ lines: entry.lines, pIdx: key, tag: entry.tag });
24
+ }
25
+ return groups;
26
+ }
2
27
  // src/utils.ts
28
+ function fmt(n, precision = 2) {
29
+ return (Math.round(n * 10 ** precision) / 10 ** precision).toString();
30
+ }
3
31
  function computeBBox(lines) {
4
32
  if (lines.length === 0)
5
- return { width: 0, height: 0 };
6
- const width = Math.max(...lines.map((l) => l.x + l.width));
7
- const height = lines[lines.length - 1].y + lines[lines.length - 1].height;
8
- return { width, height };
33
+ return { x: 0, y: 0, width: 0, height: 0 };
34
+ const minX = Math.min(...lines.map((l) => l.x));
35
+ const maxX = Math.max(...lines.map((l) => l.x + l.width));
36
+ const minY = Math.min(...lines.map((l) => l.y));
37
+ const maxY = lines[lines.length - 1].y + lines[lines.length - 1].height;
38
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
9
39
  }
10
40
 
11
41
  // src/SVGRenderer.ts
12
42
  var PRESETS = {
13
43
  flat: { structure: "flat", spacing: "preserve", defaultFit: "none" },
14
44
  browser: { structure: "expanded", spacing: "preserve", defaultFit: "none" },
15
- preserve: { structure: "expanded", spacing: "preserve", defaultFit: "none" },
45
+ preserve: { structure: "expanded", spacing: "preserve", defaultFit: "frag" },
16
46
  glyph: { structure: "glyph", spacing: "preserve", defaultFit: "none" }
17
47
  };
18
48
  function escapeXml(text) {
@@ -30,14 +60,20 @@ function fontWeightNumeric(weight) {
30
60
  function colorToRGB(color) {
31
61
  if (!color)
32
62
  return "rgb(0, 0, 0)";
63
+ if (color[0] !== "#")
64
+ return color;
33
65
  let hex = color;
34
- if (hex.length === 4 && hex[0] === "#") {
66
+ if (hex.length === 4) {
35
67
  hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
36
68
  }
37
- if (hex.length === 7 && hex[0] === "#") {
69
+ if (hex.length === 7) {
38
70
  return `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`;
39
71
  }
40
- return "rgb(0, 0, 0)";
72
+ if (hex.length === 9) {
73
+ const a = parseInt(hex.slice(7, 9), 16) / 255;
74
+ return `rgba(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)}, ${a.toFixed(3)})`;
75
+ }
76
+ return color;
41
77
  }
42
78
  function resolveOptions(opts) {
43
79
  let structure;
@@ -62,7 +98,16 @@ function resolveOptions(opts) {
62
98
  }
63
99
  const style = opts.style ?? "xml";
64
100
  let fit = opts.fit ?? defaultFit;
65
- const sizing = opts.sizing ?? "frame";
101
+ let sizingHorizontal;
102
+ let sizingVertical;
103
+ if (typeof opts.sizing === "object" && opts.sizing !== null) {
104
+ sizingHorizontal = opts.sizing.horizontal ?? "frame";
105
+ sizingVertical = opts.sizing.vertical ?? "frame";
106
+ } else {
107
+ const s = opts.sizing ?? "frame";
108
+ sizingHorizontal = s;
109
+ sizingVertical = s;
110
+ }
66
111
  if (structure === "glyph" && fit !== "none") {
67
112
  console.warn(`SVGRenderer: fit="${fit}" is ignored when structure="glyph"`);
68
113
  fit = "none";
@@ -71,85 +116,132 @@ function resolveOptions(opts) {
71
116
  console.warn(`SVGRenderer: fit="frag" downgraded to "text" when structure="flat"`);
72
117
  fit = "text";
73
118
  }
74
- return { structure, spacing, style, fit, sizing, width: opts.width, height: opts.height, className: opts.className, debug: opts.debug };
119
+ return { structure, spacing, style, fit, sizingHorizontal, sizingVertical, width: opts.width, height: opts.height, className: opts.className, contentPadding: opts.contentPadding ?? 0, debug: opts.debug };
120
+ }
121
+ function resolveSize(lines, opts) {
122
+ const needsBBox = opts.sizingHorizontal === "content" || opts.sizingVertical === "content";
123
+ const bbox = needsBBox ? computeBBox(lines) : null;
124
+ const frameWidth = opts.width;
125
+ const frameHeight = opts.height;
126
+ let width;
127
+ let height;
128
+ if (opts.sizingHorizontal === "content") {
129
+ width = bbox.width;
130
+ } else {
131
+ if (opts.width === undefined) {
132
+ throw new Error(`renderToSVG: horizontal sizing="frame" requires explicit width. ` + `Got width=${opts.width}.`);
133
+ }
134
+ width = opts.width;
135
+ }
136
+ if (opts.sizingVertical === "content") {
137
+ height = bbox.height;
138
+ } else {
139
+ if (opts.height === undefined) {
140
+ throw new Error(`renderToSVG: vertical sizing="frame" requires explicit height. ` + `Got height=${opts.height}.`);
141
+ }
142
+ height = opts.height;
143
+ }
144
+ let viewBox;
145
+ if (bbox) {
146
+ viewBox = {
147
+ x: opts.sizingHorizontal === "content" ? bbox.x : 0,
148
+ y: opts.sizingVertical === "content" ? bbox.y : 0,
149
+ w: opts.sizingHorizontal === "content" ? bbox.width : width,
150
+ h: opts.sizingVertical === "content" ? bbox.height : height
151
+ };
152
+ } else {
153
+ viewBox = { x: 0, y: 0, w: width, h: height };
154
+ }
155
+ const pad = opts.contentPadding || 0;
156
+ if (pad > 0) {
157
+ width += pad * 2;
158
+ height += pad * 2;
159
+ viewBox = {
160
+ x: viewBox.x - pad,
161
+ y: viewBox.y - pad,
162
+ w: viewBox.w + pad * 2,
163
+ h: viewBox.h + pad * 2
164
+ };
165
+ }
166
+ return { width, height, viewBox, frameWidth, frameHeight };
75
167
  }
76
168
  function defaultStyleState(span) {
169
+ const decorations = [];
170
+ if (span.style.underline)
171
+ decorations.push("underline");
172
+ if (span.style.strikethrough)
173
+ decorations.push("line-through");
77
174
  return {
78
175
  fontFamily: span.style.fontFamily || "Arial",
79
176
  fontSize: span.fontMetrics.fontSize || 16,
80
177
  fontWeight: fontWeightNumeric(span.style.fontWeight),
81
178
  color: span.style.color || "#000000",
82
179
  fontStyle: span.style.fontStyle || "normal",
83
- decoration: span.style.underline ? "underline" : span.style.strikethrough ? "line-through" : ""
180
+ decoration: decorations.join(" "),
181
+ letterSpacing: span.style.letterSpacing
84
182
  };
85
183
  }
86
184
  function equalStyle(a, b) {
87
- return a.fontFamily === b.fontFamily && a.fontSize === b.fontSize && a.fontWeight === b.fontWeight && a.color === b.color && a.fontStyle === b.fontStyle && a.decoration === b.decoration;
185
+ return a.fontFamily === b.fontFamily && a.fontSize === b.fontSize && a.fontWeight === b.fontWeight && a.color === b.color && a.fontStyle === b.fontStyle && a.decoration === b.decoration && a.letterSpacing === b.letterSpacing;
88
186
  }
89
- function cssStyleString(s) {
90
- const parts = [];
91
- parts.push(`font-family: '${s.fontFamily}', sans-serif`);
92
- parts.push(`font-size: ${s.fontSize}px`);
93
- parts.push(`fill: ${colorToRGB(s.color)}`);
94
- if (s.fontWeight !== 400)
95
- parts.push(`font-weight: ${s.fontWeight}`);
96
- if (s.fontStyle === "italic")
97
- parts.push(`font-style: italic`);
98
- if (s.decoration)
99
- parts.push(`text-decoration: ${s.decoration}`);
100
- return parts.join("; ");
101
- }
102
- function xmlStyleAttrs(s) {
103
- let attrs = `font-family="${s.fontFamily}" font-size="${s.fontSize}" fill="${s.color}" font-weight="${s.fontWeight}"`;
104
- if (s.fontStyle === "italic")
105
- attrs += ' font-style="italic"';
106
- if (s.decoration)
107
- attrs += ` text-decoration="${s.decoration}"`;
108
- return attrs;
187
+ function styleSignature(span) {
188
+ const s = defaultStyleState(span);
189
+ return `${s.fontFamily}|${s.fontSize}|${s.fontWeight}|${s.color}|${s.fontStyle}|${s.decoration}|${s.letterSpacing ?? ""}`;
109
190
  }
110
191
  function buildTextAttrs(line, span, opts, runId) {
111
192
  const x = line.x;
112
193
  const y = line.y + line.baseline;
113
194
  const s = defaultStyleState(span);
114
- let attrs = ` x="${x}" y="${y}"`;
195
+ const attrs = {
196
+ x: fmt(x),
197
+ y: fmt(y)
198
+ };
115
199
  if (runId)
116
- attrs += ` id="${runId}"`;
200
+ attrs.id = runId;
117
201
  if (opts.style === "css") {
118
- let css = cssStyleString(s);
202
+ let css = `font-family: '${s.fontFamily}', sans-serif; font-size: ${fmt(s.fontSize)}px; fill: ${colorToRGB(s.color)}; font-weight: ${s.fontWeight}`;
203
+ if (s.fontStyle === "italic")
204
+ css += `; font-style: italic`;
119
205
  if (opts.spacing === "preserve")
120
206
  css += "; white-space: pre";
121
- attrs += ` style="${css}"`;
207
+ attrs.style = css;
122
208
  } else {
123
- attrs += ` ${xmlStyleAttrs(s)}`;
209
+ attrs["font-family"] = s.fontFamily;
210
+ attrs["font-size"] = fmt(s.fontSize);
211
+ attrs.fill = s.color;
212
+ attrs["font-weight"] = s.fontWeight;
213
+ if (s.fontStyle === "italic")
214
+ attrs["font-style"] = "italic";
124
215
  if (opts.spacing === "preserve")
125
- attrs += ' xml:space="preserve"';
126
- }
127
- if (opts.structure === "flat") {
128
- const anchor = line.alignment === "center" ? "middle" : line.alignment === "right" ? "end" : "start";
129
- if (anchor !== "start")
130
- attrs += ` text-anchor="${anchor}"`;
216
+ attrs["xml:space"] = "preserve";
131
217
  }
132
218
  return attrs;
133
219
  }
134
220
  function buildTspanAttrs(span, x, currentStyle) {
135
221
  const s = defaultStyleState(span);
136
- let attrs = ` x="${x}"`;
222
+ const attrs = {
223
+ x: fmt(x)
224
+ };
137
225
  if (currentStyle && equalStyle(s, currentStyle)) {
138
226
  return { attrs, newStyle: s };
139
227
  }
140
228
  if (!currentStyle || s.fontWeight !== currentStyle.fontWeight)
141
- attrs += ` font-weight="${s.fontWeight}"`;
229
+ attrs["font-weight"] = s.fontWeight;
142
230
  if (!currentStyle || s.fontStyle !== currentStyle.fontStyle)
143
- attrs += ` font-style="${s.fontStyle}"`;
231
+ attrs["font-style"] = s.fontStyle;
144
232
  if (!currentStyle || s.fontFamily !== currentStyle.fontFamily)
145
- attrs += ` font-family="${s.fontFamily}"`;
233
+ attrs["font-family"] = s.fontFamily;
146
234
  if (!currentStyle || s.fontSize !== currentStyle.fontSize)
147
- attrs += ` font-size="${s.fontSize}"`;
235
+ attrs["font-size"] = fmt(s.fontSize);
148
236
  if (!currentStyle || s.color !== currentStyle.color)
149
- attrs += ` fill="${s.color}"`;
237
+ attrs.fill = s.color;
150
238
  if (!currentStyle || s.decoration !== currentStyle.decoration) {
151
239
  if (s.decoration)
152
- attrs += ` text-decoration="${s.decoration}"`;
240
+ attrs["text-decoration"] = s.decoration;
241
+ }
242
+ if (!currentStyle || s.letterSpacing !== currentStyle.letterSpacing) {
243
+ if (s.letterSpacing !== undefined && s.letterSpacing !== 0)
244
+ attrs["letter-spacing"] = fmt(s.letterSpacing);
153
245
  }
154
246
  return { attrs, newStyle: s };
155
247
  }
@@ -157,135 +249,275 @@ function buildGlyphPositions(span, _lineX) {
157
249
  if (!span.glyphAdvances || span.glyphAdvances.length === 0) {
158
250
  return "";
159
251
  }
252
+ const ls = span.style.letterSpacing || 0;
160
253
  const spanX = span.x;
161
254
  let xPos = spanX;
162
- const positions = [xPos.toFixed(1)];
255
+ const positions = [fmt(xPos, 1)];
163
256
  for (let i = 0;i < span.glyphAdvances.length - 1; i++) {
164
- xPos += span.glyphAdvances[i];
165
- positions.push(xPos.toFixed(1));
257
+ xPos += span.glyphAdvances[i] + ls;
258
+ positions.push(fmt(xPos, 1));
166
259
  }
167
260
  return positions.join(" ");
168
261
  }
169
262
  function buildFitAttr(line, opts) {
170
263
  if (opts.fit === "text") {
171
- return ` textLength="${line.width}" lengthAdjust="spacing"`;
264
+ return { textLength: fmt(line.width), lengthAdjust: "spacing" };
172
265
  }
173
- return "";
266
+ return;
174
267
  }
175
268
  function buildSpanFitAttr(span, opts) {
176
269
  if (opts.fit === "frag") {
177
- return ` textLength="${span.width}"`;
270
+ return { textLength: fmt(span.width) };
271
+ }
272
+ return;
273
+ }
274
+ function el(tag, attrs = {}, children = []) {
275
+ const cleanAttrs = {};
276
+ for (const [k, v] of Object.entries(attrs)) {
277
+ if (v !== undefined) {
278
+ cleanAttrs[k] = v;
279
+ }
280
+ }
281
+ return { type: "element", tag, attrs: cleanAttrs, children };
282
+ }
283
+ function textNode(value) {
284
+ return { type: "text", value };
285
+ }
286
+ function rawNode(value) {
287
+ return { type: "raw", value };
288
+ }
289
+ function serializeSvg(node, indent = 0) {
290
+ const pad = " ".repeat(indent);
291
+ switch (node.type) {
292
+ case "text":
293
+ return escapeXml(node.value);
294
+ case "raw":
295
+ return node.value;
296
+ case "comment":
297
+ return `${pad}<!-- ${node.value} -->
298
+ `;
299
+ case "element": {
300
+ const tag = node.tag;
301
+ const attrsStr = Object.entries(node.attrs).map(([k, v]) => `${k}="${v}"`).join(" ");
302
+ if (node.children.length === 0) {
303
+ return `${pad}<${tag}${attrsStr ? " " + attrsStr : ""} />
304
+ `;
305
+ }
306
+ const allTextChildren = node.children.every((c) => c.type === "text");
307
+ if (allTextChildren) {
308
+ const text = node.children.map((c) => c.value).join("");
309
+ return `${pad}<${tag}${attrsStr ? " " + attrsStr : ""}>${escapeXml(text)}</${tag}>
310
+ `;
311
+ }
312
+ const openTag = `${pad}<${tag}${attrsStr ? " " + attrsStr : ""}>
313
+ `;
314
+ const childrenStr = node.children.map((c) => serializeSvg(c, indent + 1)).join("");
315
+ const closeTag = `${pad}</${tag}>
316
+ `;
317
+ return openTag + childrenStr + closeTag;
318
+ }
178
319
  }
179
- return "";
180
320
  }
181
321
 
182
- class SvgBuilder {
183
- parts = [];
322
+ class SvgAstBuilder {
323
+ root;
324
+ currentText = null;
184
325
  opts;
185
- constructor(width, height, opts) {
326
+ constructor(width, height, opts, viewBox) {
186
327
  this.opts = opts;
187
- const className = opts.className ? ` class="${escapeXml(opts.className)}"` : "";
188
- this.parts.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"${className}>
189
- `);
328
+ const svgAttrs = {
329
+ xmlns: "http://www.w3.org/2000/svg",
330
+ width: fmt(width),
331
+ height: fmt(height),
332
+ viewBox: viewBox ? `${fmt(viewBox.x)} ${fmt(viewBox.y)} ${fmt(viewBox.w)} ${fmt(viewBox.h)}` : `0 0 ${fmt(width)} ${fmt(height)}`
333
+ };
190
334
  if (opts.className) {
191
- this.parts[0] = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" class="${escapeXml(opts.className)}">
192
- `;
335
+ svgAttrs.class = opts.className;
193
336
  }
337
+ this.root = el("svg", svgAttrs);
194
338
  }
195
- addText(line, baseSpan, runId, yOverride, fontSizeOverride) {
196
- let attrs;
339
+ openText(line, baseSpan, runId, yOverride, fontSizeOverride) {
340
+ this.closeText();
341
+ let textAttrs;
197
342
  if (yOverride !== undefined && fontSizeOverride !== undefined) {
198
343
  const s = defaultStyleState(baseSpan);
199
344
  const x = line.x;
200
- attrs = ` x="${x}" y="${yOverride}"`;
345
+ const attrs = {
346
+ x: fmt(x),
347
+ y: fmt(yOverride)
348
+ };
201
349
  if (this.opts.style === "css") {
202
- let css = cssStyleString(s);
350
+ let css = `font-family: '${s.fontFamily}', sans-serif; font-size: ${fmt(fontSizeOverride)}px; fill: ${colorToRGB(s.color)}; font-weight: ${s.fontWeight}`;
351
+ if (s.fontStyle === "italic")
352
+ css += `; font-style: italic`;
203
353
  if (this.opts.spacing === "preserve")
204
354
  css += "; white-space: pre";
205
- attrs += ` style="${css}"`;
355
+ attrs.style = css;
206
356
  } else {
207
- let xml = ` font-family="${s.fontFamily}" font-size="${fontSizeOverride}" fill="${s.color}" font-weight="${s.fontWeight}"`;
357
+ attrs["font-family"] = s.fontFamily;
358
+ attrs["font-size"] = fmt(fontSizeOverride);
359
+ attrs.fill = s.color;
360
+ attrs["font-weight"] = s.fontWeight;
208
361
  if (s.fontStyle === "italic")
209
- xml += ' font-style="italic"';
210
- if (s.decoration)
211
- xml += ` text-decoration="${s.decoration}"`;
362
+ attrs["font-style"] = "italic";
212
363
  if (this.opts.spacing === "preserve")
213
- xml += ' xml:space="preserve"';
214
- attrs += xml;
364
+ attrs["xml:space"] = "preserve";
215
365
  }
366
+ textAttrs = attrs;
216
367
  } else {
217
- attrs = buildTextAttrs(line, baseSpan, this.opts, runId);
368
+ textAttrs = buildTextAttrs(line, baseSpan, this.opts, runId);
218
369
  }
219
- const fit = buildFitAttr(line, this.opts);
220
- this.parts.push(` <text${attrs}${fit}>
221
- `);
222
- }
223
- addFlatSpan(text) {
224
- this.parts.push(`${escapeXml(text)}`);
370
+ const fitAttrs = buildFitAttr(line, this.opts);
371
+ if (fitAttrs) {
372
+ Object.assign(textAttrs, fitAttrs);
373
+ }
374
+ const textEl = el("text", textAttrs);
375
+ this.root.children.push(textEl);
376
+ this.currentText = textEl;
225
377
  }
226
- pushLine(line) {
227
- this.parts.push(line);
378
+ addDebug(debugMarkup) {
379
+ if (debugMarkup) {
380
+ this.root.children.push(rawNode(`<!-- debug overlay -->
381
+ ${debugMarkup}
382
+ `));
383
+ }
228
384
  }
229
- closeText() {
230
- this.parts.push(`</text>
231
- `);
385
+ addTextContent(text) {
386
+ if (this.currentText) {
387
+ this.currentText.children.push(textNode(text));
388
+ }
232
389
  }
233
- addExpandedSpan(span, x, style) {
390
+ addTspan(span, x, style) {
234
391
  const { attrs, newStyle } = buildTspanAttrs(span, x, style);
235
- const fit = buildSpanFitAttr(span, this.opts);
236
- this.parts.push(` <tspan${attrs}${fit}>${escapeXml(span.text)}</tspan>
237
- `);
392
+ const fitAttrs = buildSpanFitAttr(span, this.opts);
393
+ if (fitAttrs) {
394
+ Object.assign(attrs, fitAttrs);
395
+ }
396
+ const tspan = el("tspan", attrs, [textNode(span.text)]);
397
+ if (this.currentText) {
398
+ this.currentText.children.push(tspan);
399
+ }
238
400
  return newStyle;
239
401
  }
240
- addGlyphSpan(span, lineX) {
402
+ addGlyphTspan(span, lineX) {
241
403
  const positions = buildGlyphPositions(span, lineX);
404
+ const attrs = {};
242
405
  if (positions) {
243
- this.parts.push(` <tspan x="${positions}">${escapeXml(span.text)}</tspan>
244
- `);
245
- } else {
246
- this.parts.push(` <tspan>${escapeXml(span.text)}</tspan>
247
- `);
406
+ attrs.x = positions;
248
407
  }
249
- }
250
- addDebug(debugOverlay) {
251
- if (debugOverlay) {
252
- this.parts.push(`<!-- debug overlay -->
253
- ${debugOverlay}
254
- `);
408
+ const tspan = el("tspan", attrs, [textNode(span.text)]);
409
+ if (this.currentText) {
410
+ this.currentText.children.push(tspan);
255
411
  }
256
412
  }
257
- build() {
258
- this.parts.push(`</svg>
259
- `);
260
- return this.parts.join("");
413
+ addRawLine(lineStr) {
414
+ this.closeText();
415
+ this.root.children.push(rawNode(lineStr));
416
+ }
417
+ closeText() {
418
+ this.currentText = null;
261
419
  }
262
420
  }
263
- function renderDebugToSVG(lines, width, height, flags) {
421
+ function renderDebugToSVG(lines, flags, frameSize, contentSize, columns, leftPad, rightPad) {
264
422
  const parts = [];
265
- if (flags.frame && lines.length > 0) {
266
- const first = lines[0];
267
- const last = lines[lines.length - 1];
268
- const maxW = Math.max(...lines.map((l) => l.x + l.width));
269
- parts.push(` <rect x="${first.x}" y="${first.y}" width="${maxW - first.x}" height="${last.y + last.height - first.y}"` + ` fill="none" stroke="rgba(255,200,0,0.6)" stroke-width="1" stroke-dasharray="6,2" />`);
423
+ const sw = flags.widthBorder ?? 1;
424
+ if ((flags.frameBox || flags.frame) && frameSize) {
425
+ parts.push(` <rect x="0" y="0" width="${fmt(frameSize.width)}" height="${fmt(frameSize.height)}"` + ` fill="none" stroke="rgba(0,140,255,0.8)" stroke-width="${fmt(sw)}" stroke-dasharray="4,3" />`);
426
+ if (flags.labels) {
427
+ parts.push(` <text x="4" y="14" font-size="10" fill="rgba(0,140,255,0.9)" font-family="monospace">frame ${fmt(frameSize.width)}\xD7${fmt(frameSize.height)}</text>`);
428
+ }
429
+ }
430
+ if (flags.contentBox) {
431
+ const bbox = computeBBox(lines);
432
+ parts.push(` <rect x="${fmt(bbox.x)}" y="${fmt(bbox.y)}" width="${fmt(bbox.width)}" height="${fmt(bbox.height)}"` + ` fill="none" stroke="rgba(255,60,140,0.8)" stroke-width="${fmt(sw)}" stroke-dasharray="1,2" />`);
433
+ if (flags.labels) {
434
+ const labelY = bbox.y + bbox.height + 14;
435
+ parts.push(` <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)}\xD7${fmt(bbox.height)}</text>`);
436
+ }
437
+ }
438
+ if (flags.contentBox && frameSize && contentSize && flags.labels) {
439
+ const overflowX = contentSize.width > frameSize.width;
440
+ const overflowY = contentSize.height > frameSize.height;
441
+ if (overflowX || overflowY) {
442
+ parts.push(` <text x="4" y="${fmt(frameSize.height + 14)}" font-size="10" fill="rgba(220,0,0,0.9)" font-family="monospace">\u26A0 content overflow: ${overflowX ? `\u0394x=${fmt(contentSize.width - frameSize.width)} ` : ""}${overflowY ? `\u0394y=${fmt(contentSize.height - frameSize.height)}` : ""}</text>`);
443
+ }
444
+ }
445
+ if (columns && columns.count > 1 && (flags.paragraphBox || flags.columnBox)) {
446
+ const colCount = columns.count;
447
+ const colGap = columns.gap;
448
+ const lp = leftPad ?? 0;
449
+ const totalHorizontalSpace = frameSize?.width ?? (lines.length > 0 ? Math.max(...lines.map((l) => l.x + l.width)) : 0);
450
+ const usableWidth = totalHorizontalSpace - lp - (rightPad ?? 0);
451
+ const colWidth = (usableWidth - (colCount - 1) * colGap) / colCount;
452
+ for (let c = 1;c < colCount; c++) {
453
+ const sepX = lp + c * (colWidth + colGap) - colGap / 2;
454
+ parts.push(` <line x1="${fmt(sepX)}" y1="0" x2="${fmt(sepX)}" y2="${fmt(frameSize?.height ?? 9999)}"` + ` stroke="rgba(100,100,100,0.15)" stroke-width="1" stroke-dasharray="2,2" />`);
455
+ }
456
+ }
457
+ if (flags.paragraphBox) {
458
+ const paraGroups = groupLinesByParagraph(lines);
459
+ const paraColors = [
460
+ "rgba(0,180,80,0.25)",
461
+ "rgba(180,0,80,0.25)",
462
+ "rgba(80,0,180,0.25)",
463
+ "rgba(180,180,0,0.25)"
464
+ ];
465
+ for (let i = 0;i < paraGroups.length; i++) {
466
+ const group = paraGroups[i];
467
+ if (group.lines.length === 0)
468
+ continue;
469
+ const colMap = new Map;
470
+ for (const line of group.lines) {
471
+ const ci = line.columnIndex ?? 0;
472
+ const existing = colMap.get(ci);
473
+ const lineTop = line.y;
474
+ const lineBottom = line.y + line.height;
475
+ if (existing) {
476
+ existing.top = Math.min(existing.top, lineTop);
477
+ existing.bottom = Math.max(existing.bottom, lineBottom);
478
+ } else {
479
+ colMap.set(ci, { top: lineTop, bottom: lineBottom });
480
+ }
481
+ }
482
+ const lp = leftPad ?? 0;
483
+ const rp = rightPad ?? 0;
484
+ const totalW = frameSize?.width ?? (lines.length > 0 ? Math.max(...lines.map((l) => l.x + l.width)) : 0);
485
+ const usableW = totalW - lp - rp;
486
+ const colCount = columns?.count ?? 1;
487
+ const colGap = columns?.gap ?? 0;
488
+ const colW = (usableW - (colCount - 1) * colGap) / colCount;
489
+ const color = paraColors[i % paraColors.length];
490
+ for (const [ci, rect] of colMap) {
491
+ const colX = lp + ci * (colW + colGap);
492
+ 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)}" />`);
493
+ }
494
+ const label = group.tag ? `#${group.pIdx} ${group.tag}` : `#${group.pIdx}`;
495
+ if (flags.labels) {
496
+ const firstColIdx = Math.min(...Array.from(colMap.keys()));
497
+ const firstColX = lp + firstColIdx * (colW + colGap);
498
+ const firstTop = colMap.get(firstColIdx).top;
499
+ parts.push(` <text x="${fmt(firstColX + 4)}" y="${fmt(firstTop - 2)}" font-size="9" fill="rgba(0,0,0,0.6)" font-family="monospace">\xB6 ${label}</text>`);
500
+ }
501
+ }
270
502
  }
271
503
  for (const line of lines) {
272
504
  const { x: bx, y: by, width: bw, height: bh } = line;
273
505
  const baselineY = line.y + line.baseline;
274
506
  if (flags.lineGap) {
275
- parts.push(` <rect x="${bx}" y="${by}" width="${bw}" height="${bh}" fill="rgba(0,150,255,0.10)" stroke="none" />`);
507
+ parts.push(` <rect x="${fmt(bx)}" y="${fmt(by)}" width="${fmt(bw)}" height="${fmt(bh)}" fill="rgba(0,150,255,0.10)" stroke="none" />`);
276
508
  }
277
509
  if (flags.box) {
278
- parts.push(` <rect x="${bx}" y="${by}" width="${bw}" height="${bh}" fill="none" stroke="rgba(255,100,100,0.5)" stroke-width="1" />`);
510
+ 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)}" />`);
279
511
  }
280
512
  if (flags.baseline) {
281
- parts.push(` <line x1="${bx}" y1="${baselineY}" x2="${bx + bw}" y2="${baselineY}" stroke="rgba(100,100,255,0.5)" stroke-width="1" />`);
513
+ 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)}" />`);
282
514
  }
283
515
  if (flags.ascentDescent) {
284
- 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" />`);
285
- 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" />`);
516
+ 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" />`);
517
+ 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" />`);
286
518
  }
287
519
  if (flags.labels) {
288
- 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>`);
520
+ 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>`);
289
521
  }
290
522
  if (flags.runs) {
291
523
  for (const span of line.spans) {
@@ -293,7 +525,7 @@ function renderDebugToSVG(lines, width, height, flags) {
293
525
  continue;
294
526
  const rx = line.x + span.x;
295
527
  const ry = baselineY - span.fontMetrics.ascent;
296
- 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" />`);
528
+ 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)}" />`);
297
529
  }
298
530
  }
299
531
  }
@@ -302,20 +534,8 @@ function renderDebugToSVG(lines, width, height, flags) {
302
534
  }
303
535
  function renderToSVG(lines, options = {}) {
304
536
  const opts = resolveOptions(options);
305
- let svgWidth;
306
- let svgHeight;
307
- if (opts.sizing === "content") {
308
- const bbox = computeBBox(lines);
309
- svgWidth = bbox.width;
310
- svgHeight = bbox.height;
311
- } else {
312
- if (opts.width === undefined || opts.height === undefined) {
313
- throw new Error(`renderToSVG: sizing="frame" requires explicit width and height. ` + `Got width=${opts.width}, height=${opts.height}. ` + `Use renderResultToSVG(result, options) to auto-pass dimensions.`);
314
- }
315
- svgWidth = opts.width;
316
- svgHeight = opts.height;
317
- }
318
- const builder = new SvgBuilder(svgWidth, svgHeight, opts);
537
+ const { width: svgWidth, height: svgHeight, viewBox, frameWidth, frameHeight } = resolveSize(lines, opts);
538
+ const builder = new SvgAstBuilder(svgWidth, svgHeight, opts, viewBox);
319
539
  for (const line of lines) {
320
540
  if (opts.structure === "glyph") {
321
541
  let currentRunIdx = -1;
@@ -324,18 +544,14 @@ function renderToSVG(lines, options = {}) {
324
544
  continue;
325
545
  const runIdx = span.itemIndex;
326
546
  if (runIdx !== currentRunIdx) {
327
- if (currentRunIdx !== -1) {
328
- builder.closeText();
329
- }
330
- const runId = span.paragraphId ? `${span.paragraphId}-${runIdx}` : undefined;
331
- builder.addText(line, span, runId);
547
+ builder.closeText();
548
+ const runId = span.tag ? `${span.tag}-${runIdx}` : undefined;
549
+ builder.openText(line, span, runId);
332
550
  currentRunIdx = runIdx;
333
551
  }
334
- builder.addGlyphSpan(span, line.x);
335
- }
336
- if (currentRunIdx !== -1) {
337
- builder.closeText();
552
+ builder.addGlyphTspan(span, line.x);
338
553
  }
554
+ builder.closeText();
339
555
  } else if (opts.structure === "flat") {
340
556
  const groups = [];
341
557
  for (const span of line.spans) {
@@ -343,56 +559,113 @@ function renderToSVG(lines, options = {}) {
343
559
  continue;
344
560
  const offset = span.fontMetrics.baselineOffset || 0;
345
561
  const targetY = Math.round((line.y + line.baseline + offset) * 100) / 100;
562
+ const sig = styleSignature(span);
346
563
  const fontSize = span.fontMetrics.fontSize;
347
564
  const last = groups[groups.length - 1];
348
- if (last && last.targetY === targetY && last.fontSize === fontSize) {
565
+ if (last && last.targetY === targetY && last.signature === sig) {
349
566
  last.spans.push(span);
350
567
  } else {
351
- groups.push({ spans: [span], targetY, fontSize });
568
+ groups.push({ spans: [span], targetY, signature: sig, fontSize });
352
569
  }
353
570
  }
571
+ const firstTextX = line.spans.find((s) => s.type === "text" || s.type === "marker")?.x ?? 0;
572
+ const fitAttr = buildFitAttr(line, opts);
354
573
  for (const group of groups) {
355
574
  const s = defaultStyleState(group.spans[0]);
356
575
  const text = group.spans.map((sp) => escapeXml(sp.text)).join("");
357
- let attrs = ` x="${line.x}" y="${group.targetY}" font-family="${s.fontFamily}" font-size="${group.fontSize}" fill="${s.color}" font-weight="${s.fontWeight}"`;
576
+ const groupX = line.x + (group.spans[0].x - firstTextX);
577
+ const textAttrs = {
578
+ x: fmt(groupX),
579
+ y: fmt(group.targetY),
580
+ "font-family": s.fontFamily,
581
+ "font-size": fmt(group.fontSize),
582
+ fill: s.color,
583
+ "font-weight": s.fontWeight
584
+ };
358
585
  if (s.fontStyle === "italic")
359
- attrs += ' font-style="italic"';
586
+ textAttrs["font-style"] = "italic";
360
587
  if (s.decoration)
361
- attrs += ` text-decoration="${s.decoration}"`;
362
- attrs += ' xml:space="preserve"';
363
- if (line.alignment === "center")
364
- attrs += ' text-anchor="middle"';
365
- else if (line.alignment === "right")
366
- attrs += ' text-anchor="end"';
367
- builder.pushLine(` <text${attrs}>${text}</text>
588
+ textAttrs["text-decoration"] = s.decoration;
589
+ if (s.letterSpacing !== undefined && s.letterSpacing !== 0)
590
+ textAttrs["letter-spacing"] = fmt(s.letterSpacing);
591
+ textAttrs["xml:space"] = "preserve";
592
+ if (fitAttr) {
593
+ Object.assign(textAttrs, fitAttr);
594
+ }
595
+ const attrsStr = Object.entries(textAttrs).map(([k, v]) => `${k}="${v}"`).join(" ");
596
+ builder.addRawLine(` <text ${attrsStr}>${text}</text>
368
597
  `);
369
598
  }
370
599
  } else {
371
- const baseSpan = line.spans.find((f) => f.type === "text" && f.text.length > 0) || line.spans[0];
372
- if (!baseSpan)
373
- continue;
374
- builder.addText(line, baseSpan);
375
- let currentStyle = null;
600
+ const groups = [];
376
601
  for (const span of line.spans) {
377
602
  if (!span.text)
378
603
  continue;
379
- const x = span.x;
380
- const shouldRender = span.type !== "space" || opts.spacing === "preserve";
381
- if (shouldRender) {
382
- const newStyle = builder.addExpandedSpan(span, x, currentStyle);
383
- if (span.type !== "space") {
384
- currentStyle = newStyle;
604
+ const offset = span.fontMetrics.baselineOffset || 0;
605
+ const targetY = Math.round((line.y + line.baseline + offset) * 100) / 100;
606
+ const sig = styleSignature(span);
607
+ const last = groups[groups.length - 1];
608
+ if (last && last.targetY === targetY && last.signature === sig) {
609
+ last.spans.push(span);
610
+ } else {
611
+ groups.push({ targetY, spans: [span], signature: sig });
612
+ }
613
+ }
614
+ for (const group of groups) {
615
+ const baseSpan = group.spans.find((f) => f.type === "text" && f.text.length > 0) || group.spans[0];
616
+ if (!baseSpan)
617
+ continue;
618
+ const lineBaseY = Math.round((line.y + line.baseline) * 100) / 100;
619
+ const needsOffset = group.targetY !== lineBaseY;
620
+ if (needsOffset) {
621
+ const s = defaultStyleState(baseSpan);
622
+ const firstTextX = line.spans.find((s2) => s2.type === "text")?.x ?? 0;
623
+ const groupX = line.x + (group.spans[0].x - firstTextX);
624
+ const fontSize = baseSpan.fontMetrics.fontSize;
625
+ const textAttrs = {
626
+ x: fmt(groupX),
627
+ y: fmt(group.targetY),
628
+ "font-family": s.fontFamily,
629
+ "font-size": fmt(fontSize),
630
+ fill: s.color,
631
+ "font-weight": s.fontWeight
632
+ };
633
+ if (s.fontStyle === "italic")
634
+ textAttrs["font-style"] = "italic";
635
+ textAttrs["xml:space"] = "preserve";
636
+ const fit = buildFitAttr(line, opts);
637
+ if (fit) {
638
+ Object.assign(textAttrs, fit);
639
+ }
640
+ builder.openText(line, baseSpan, undefined, group.targetY, fontSize);
641
+ } else {
642
+ builder.openText(line, baseSpan);
643
+ }
644
+ let currentStyle = null;
645
+ for (const span of group.spans) {
646
+ if (!span.text)
647
+ continue;
648
+ const x = span.x;
649
+ const shouldRender = span.type !== "space" || opts.spacing === "preserve";
650
+ if (shouldRender) {
651
+ const newStyle = builder.addTspan(span, x, currentStyle);
652
+ if (span.type !== "space") {
653
+ currentStyle = newStyle;
654
+ }
385
655
  }
386
656
  }
657
+ builder.closeText();
387
658
  }
388
- builder.closeText();
389
659
  }
390
660
  }
391
661
  if (opts.debug) {
392
- const debugSvg = renderDebugToSVG(lines, svgWidth, svgHeight, opts.debug);
662
+ const frameSize = frameWidth !== undefined && frameHeight !== undefined ? { width: frameWidth, height: frameHeight } : undefined;
663
+ const contentBbox = computeBBox(lines);
664
+ const contentSize = { width: contentBbox.width, height: contentBbox.height };
665
+ const debugSvg = renderDebugToSVG(lines, opts.debug, frameSize, contentSize, options.columns, options.paddingLeft, 0);
393
666
  builder.addDebug(debugSvg);
394
667
  }
395
- return builder.build();
668
+ return serializeSvg(builder.root);
396
669
  }
397
670
  function renderParagraphToSVG(lines, paragraphWidth, paragraphHeight, options) {
398
671
  return renderToSVG(lines, {
@@ -409,18 +682,93 @@ function renderResultToSVG(result, options) {
409
682
  });
410
683
  }
411
684
  // src/CanvasRenderer.ts
412
- function fontWeightCSS(weight) {
685
+ function fontWeightNumeric2(weight) {
413
686
  if (weight === "bold")
414
- return "bold";
687
+ return 700;
415
688
  if (weight === "normal")
416
- return "normal";
689
+ return 400;
417
690
  if (typeof weight === "number")
418
- return String(weight);
419
- return "normal";
691
+ return weight;
692
+ return 400;
420
693
  }
421
694
  function fontStyleCSS(style) {
422
695
  return style === "italic" ? "italic" : "normal";
423
696
  }
697
+ function buildFontString(span) {
698
+ const style = fontStyleCSS(span.style.fontStyle);
699
+ const weight = fontWeightNumeric2(span.style.fontWeight);
700
+ const size = span.fontMetrics.fontSize;
701
+ const family = span.style.fontFamily;
702
+ return `${style} ${weight} ${size}px ${family}`;
703
+ }
704
+ function groupSpansByBaseline(line, spans) {
705
+ const groups = [];
706
+ for (const span of spans) {
707
+ if (!span.text)
708
+ continue;
709
+ const offset = span.fontMetrics.baselineOffset || 0;
710
+ const targetY = Math.round((line.y + line.baseline + offset) * 100) / 100;
711
+ const last = groups[groups.length - 1];
712
+ if (last && last.targetY === targetY) {
713
+ last.spans.push(span);
714
+ } else {
715
+ groups.push({ targetY, spans: [span] });
716
+ }
717
+ }
718
+ return groups;
719
+ }
720
+ function drawSpanBackground(ctx, line, span) {
721
+ const baselineY = line.y + line.baseline;
722
+ const x = line.x + span.x;
723
+ const y = baselineY - span.fontMetrics.ascent;
724
+ const w = span.width;
725
+ const h = span.fontMetrics.ascent + span.fontMetrics.descent;
726
+ ctx.fillStyle = span.style.backgroundColor || "transparent";
727
+ if (span.style.backgroundColor) {
728
+ ctx.fillRect(x, y, w, h);
729
+ }
730
+ }
731
+ function renderSpan(ctx, line, span, baselineY, preserveSpaces) {
732
+ const x = line.x + span.x;
733
+ drawSpanBackground(ctx, line, span);
734
+ ctx.font = buildFontString(span);
735
+ ctx.fillStyle = span.style.color || "#000000";
736
+ ctx.textBaseline = "alphabetic";
737
+ const ls = span.style.letterSpacing;
738
+ if (ls !== undefined && ls !== 0) {
739
+ ctx.letterSpacing = `${ls}px`;
740
+ } else {
741
+ ctx.letterSpacing = "normal";
742
+ }
743
+ if (preserveSpaces || span.type !== "space") {
744
+ ctx.fillText(span.text, x, baselineY);
745
+ }
746
+ ctx.letterSpacing = "normal";
747
+ if (span.style.underline) {
748
+ const ulY = baselineY + 2;
749
+ ctx.strokeStyle = span.style.color || "#000000";
750
+ ctx.lineWidth = 1;
751
+ ctx.beginPath();
752
+ ctx.moveTo(x, ulY);
753
+ ctx.lineTo(x + span.width, ulY);
754
+ ctx.stroke();
755
+ }
756
+ if (span.style.strikethrough) {
757
+ const stY = baselineY - span.fontMetrics.ascent * 0.4;
758
+ ctx.strokeStyle = span.style.color || "#000000";
759
+ ctx.lineWidth = 1;
760
+ ctx.beginPath();
761
+ ctx.moveTo(x, stY);
762
+ ctx.lineTo(x + span.width, stY);
763
+ ctx.stroke();
764
+ }
765
+ if (span.inlineWidget) {
766
+ const iw = span.inlineWidget;
767
+ const iwY = baselineY - (iw.height || span.fontMetrics.ascent) + (iw.baselineOffset || 0);
768
+ ctx.fillStyle = "#cccccc";
769
+ ctx.fillRect(x, iwY, iw.width, iw.height);
770
+ }
771
+ }
424
772
  function renderToCanvas(ctx, lines, options = {}) {
425
773
  const sizing = options.sizing ?? "frame";
426
774
  const preserveSpaces = options.preserveSpaces ?? false;
@@ -443,42 +791,13 @@ function renderToCanvas(ctx, lines, options = {}) {
443
791
  ctx.clearRect(0, 0, canvasWidth, canvasHeight);
444
792
  }
445
793
  for (const line of lines) {
446
- const baselineY = line.y + line.baseline;
447
- for (const span of line.spans) {
448
- const x = line.x + span.x;
449
- const style = fontStyleCSS(span.style.fontStyle);
450
- const weight = fontWeightCSS(span.style.fontWeight);
451
- const size = span.fontMetrics.fontSize;
452
- const family = span.style.fontFamily;
453
- ctx.font = `${style} ${weight} ${size}px ${family}`;
454
- ctx.fillStyle = span.style.color || "#000000";
455
- ctx.textBaseline = "alphabetic";
456
- if (preserveSpaces || span.type !== "space") {
457
- ctx.fillText(span.text, x, baselineY);
458
- }
459
- if (span.style.underline) {
460
- const ulY = baselineY + 2;
461
- ctx.strokeStyle = span.style.color || "#000000";
462
- ctx.lineWidth = 1;
463
- ctx.beginPath();
464
- ctx.moveTo(x, ulY);
465
- ctx.lineTo(x + span.width, ulY);
466
- ctx.stroke();
467
- }
468
- if (span.style.strikethrough) {
469
- const stY = baselineY - span.fontMetrics.ascent * 0.4;
470
- ctx.strokeStyle = span.style.color || "#000000";
471
- ctx.lineWidth = 1;
472
- ctx.beginPath();
473
- ctx.moveTo(x, stY);
474
- ctx.lineTo(x + span.width, stY);
475
- ctx.stroke();
476
- }
477
- if (span.inlineWidget) {
478
- const iw = span.inlineWidget;
479
- const iwY = baselineY - (iw.height || span.fontMetrics.ascent) + (iw.baselineOffset || 0);
480
- ctx.fillStyle = "#cccccc";
481
- ctx.fillRect(x, iwY, iw.width, iw.height);
794
+ const drawableSpans = line.spans.filter((s) => preserveSpaces || s.type !== "space");
795
+ if (drawableSpans.length === 0)
796
+ continue;
797
+ const groups = groupSpansByBaseline(line, drawableSpans);
798
+ for (const group of groups) {
799
+ for (const span of group.spans) {
800
+ renderSpan(ctx, line, span, group.targetY, preserveSpaces);
482
801
  }
483
802
  }
484
803
  }
@@ -488,7 +807,10 @@ function renderToCanvas(ctx, lines, options = {}) {
488
807
  }
489
808
  function renderDebugToCanvas(ctx, lines, _width, _height, flags) {
490
809
  ctx.save();
491
- if (flags.frame && lines.length > 0) {
810
+ const sw = flags.widthBorder ?? 1;
811
+ const hasBBox = lines.length > 0;
812
+ const bbox = hasBBox ? computeBBox(lines) : null;
813
+ if ((flags.frameBox || flags.frame) && hasBBox) {
492
814
  const first = lines[0];
493
815
  const last = lines[lines.length - 1];
494
816
  const maxW = Math.max(...lines.map((l) => l.x + l.width));
@@ -496,12 +818,47 @@ function renderDebugToCanvas(ctx, lines, _width, _height, flags) {
496
818
  const frameY = first.y;
497
819
  const frameW = maxW - frameX;
498
820
  const frameH = last.y + last.height - first.y;
499
- ctx.strokeStyle = "rgba(255,200,0,0.6)";
500
- ctx.lineWidth = 1;
501
- ctx.setLineDash([6, 2]);
821
+ ctx.strokeStyle = "rgba(0,140,255,0.8)";
822
+ ctx.lineWidth = sw;
823
+ ctx.setLineDash([4, 3]);
502
824
  ctx.strokeRect(frameX, frameY, frameW, frameH);
825
+ ctx.setLineDash([]);
826
+ if (flags.labels && bbox) {
827
+ ctx.font = "10px monospace";
828
+ ctx.fillStyle = "rgba(0,140,255,0.9)";
829
+ ctx.textBaseline = "top";
830
+ ctx.fillText(`frame ${frameW.toFixed(0)}\xD7${frameH.toFixed(0)}`, 4, 4);
831
+ }
832
+ }
833
+ if (flags.contentBox && bbox) {
834
+ ctx.strokeStyle = "rgba(255,60,140,0.8)";
835
+ ctx.lineWidth = sw;
836
+ ctx.setLineDash([1, 2]);
837
+ ctx.strokeRect(bbox.x, bbox.y, bbox.width, bbox.height);
838
+ ctx.setLineDash([]);
839
+ if (flags.labels) {
840
+ const labelY = bbox.y + bbox.height + 4;
841
+ ctx.font = "10px monospace";
842
+ ctx.fillStyle = "rgba(255,60,140,0.9)";
843
+ ctx.textBaseline = "top";
844
+ ctx.fillText(`content ${bbox.width.toFixed(0)}\xD7${bbox.height.toFixed(0)}`, bbox.x, labelY);
845
+ }
846
+ }
847
+ if (flags.contentBox && bbox && _width > 0 && _height > 0) {
848
+ const overflowX = bbox.width > _width;
849
+ const overflowY = bbox.height > _height;
850
+ if (overflowX || overflowY) {
851
+ ctx.font = "10px monospace";
852
+ ctx.fillStyle = "rgba(220,0,0,0.9)";
853
+ ctx.textBaseline = "top";
854
+ const warnParts = [];
855
+ if (overflowX)
856
+ warnParts.push(`\u0394x=${(bbox.width - _width).toFixed(0)}`);
857
+ if (overflowY)
858
+ warnParts.push(`\u0394y=${(bbox.height - _height).toFixed(0)}`);
859
+ ctx.fillText(`\u26A0 content overflow: ${warnParts.join(" ")}`, 4, _height + 4);
860
+ }
503
861
  }
504
- ctx.setLineDash([]);
505
862
  for (const line of lines) {
506
863
  const bx = line.x;
507
864
  const by = line.y;
@@ -514,12 +871,12 @@ function renderDebugToCanvas(ctx, lines, _width, _height, flags) {
514
871
  }
515
872
  if (flags.box) {
516
873
  ctx.strokeStyle = "rgba(255,100,100,0.5)";
517
- ctx.lineWidth = 1;
874
+ ctx.lineWidth = sw;
518
875
  ctx.strokeRect(bx, by, bw, bh);
519
876
  }
520
877
  if (flags.baseline) {
521
878
  ctx.strokeStyle = "rgba(100,100,255,0.5)";
522
- ctx.lineWidth = 1;
879
+ ctx.lineWidth = sw;
523
880
  ctx.beginPath();
524
881
  ctx.moveTo(bx, baselineY);
525
882
  ctx.lineTo(bx + bw, baselineY);
@@ -529,7 +886,7 @@ function renderDebugToCanvas(ctx, lines, _width, _height, flags) {
529
886
  const ascentY = baselineY - line.ascent;
530
887
  const descentY = baselineY + line.descent;
531
888
  ctx.strokeStyle = "rgba(100,255,100,0.4)";
532
- ctx.lineWidth = 0.5;
889
+ ctx.lineWidth = sw;
533
890
  ctx.setLineDash([3, 2]);
534
891
  ctx.beginPath();
535
892
  ctx.moveTo(bx, ascentY);
@@ -558,17 +915,229 @@ function renderDebugToCanvas(ctx, lines, _width, _height, flags) {
558
915
  const rw = span.width;
559
916
  const rh = span.fontMetrics.ascent + span.fontMetrics.descent;
560
917
  ctx.strokeStyle = "rgba(200,100,255,0.4)";
561
- ctx.lineWidth = 0.5;
918
+ ctx.lineWidth = sw;
562
919
  ctx.strokeRect(rx, ry, rw, rh);
563
920
  }
564
921
  }
565
922
  }
566
923
  ctx.restore();
567
924
  }
925
+ function renderSelection(ctx, lines, start, end, color = "rgba(100, 150, 255, 0.3)") {
926
+ ctx.save();
927
+ ctx.fillStyle = color;
928
+ const liMin = Math.min(start.lineIndex, end.lineIndex);
929
+ const liMax = Math.max(start.lineIndex, end.lineIndex);
930
+ for (let li = liMin;li <= liMax; li++) {
931
+ const line = lines[li];
932
+ if (!line)
933
+ continue;
934
+ const baselineY = line.y + line.baseline;
935
+ let xStart;
936
+ let xEnd;
937
+ if (li === liMin && li === liMax) {
938
+ xStart = Math.min(start.x, end.x);
939
+ xEnd = Math.max(start.x, end.x);
940
+ if (xEnd === xStart) {
941
+ const spansEnd = line.x + line.spans[line.spans.length - 1].x + line.spans[line.spans.length - 1].width;
942
+ xEnd = spansEnd;
943
+ }
944
+ } else if (li === liMin) {
945
+ xStart = start.x;
946
+ const lastSpan = line.spans[line.spans.length - 1];
947
+ xEnd = line.x + lastSpan.x + lastSpan.width;
948
+ } else if (li === liMax) {
949
+ xStart = line.x;
950
+ xEnd = end.x;
951
+ } else {
952
+ xStart = line.x;
953
+ const lastSpan = line.spans[line.spans.length - 1];
954
+ xEnd = line.x + lastSpan.x + lastSpan.width;
955
+ }
956
+ const y = baselineY - line.ascent;
957
+ const h = line.ascent + line.descent;
958
+ const w = xEnd - xStart;
959
+ if (w > 0) {
960
+ ctx.fillRect(xStart, y, w, h);
961
+ }
962
+ }
963
+ ctx.restore();
964
+ }
965
+ function renderCursor(ctx, lines, pos, options = {}) {
966
+ const color = options.color ?? "#000";
967
+ const width = options.width ?? 1;
968
+ ctx.save();
969
+ let cursorHeight;
970
+ if (options.height !== undefined) {
971
+ cursorHeight = options.height;
972
+ } else {
973
+ const line = lines[pos.lineIndex];
974
+ cursorHeight = line.ascent + line.descent;
975
+ }
976
+ const topY = pos.y - (options.height !== undefined ? options.height : lines[pos.lineIndex].ascent);
977
+ ctx.strokeStyle = color;
978
+ ctx.lineWidth = width;
979
+ ctx.beginPath();
980
+ ctx.moveTo(pos.x, topY);
981
+ ctx.lineTo(pos.x, topY + cursorHeight);
982
+ ctx.stroke();
983
+ ctx.restore();
984
+ }
985
+ // src/interactive.ts
986
+ function getCharAdvances(span) {
987
+ if (span.glyphAdvances && span.glyphAdvances.length > 0) {
988
+ return Array.from(span.glyphAdvances);
989
+ }
990
+ const len = span.text.length;
991
+ if (len === 0)
992
+ return [];
993
+ const avgWidth = span.width / len;
994
+ return new Array(len).fill(avgWidth);
995
+ }
996
+ function buildCharSegments(lines) {
997
+ const segments = [];
998
+ for (let li = 0;li < lines.length; li++) {
999
+ const line = lines[li];
1000
+ const baselineY = line.y + line.baseline;
1001
+ for (let si = 0;si < line.spans.length; si++) {
1002
+ const span = line.spans[si];
1003
+ const advances = getCharAdvances(span);
1004
+ const text = span.text;
1005
+ let charX = line.x + span.x;
1006
+ for (let ci = 0;ci < text.length; ci++) {
1007
+ const advance = ci < advances.length ? advances[ci] : 0;
1008
+ segments.push({
1009
+ lineIndex: li,
1010
+ spanIndex: si,
1011
+ charIndex: ci,
1012
+ pIdx: span.pIdx,
1013
+ x: charX,
1014
+ y: baselineY,
1015
+ width: advance,
1016
+ char: text[ci],
1017
+ line,
1018
+ span
1019
+ });
1020
+ charX += advance;
1021
+ }
1022
+ }
1023
+ }
1024
+ return segments;
1025
+ }
1026
+ function charAtPoint(lines, px, py) {
1027
+ if (lines.length === 0)
1028
+ return null;
1029
+ let nearestLineIdx = 0;
1030
+ let minYDist = Infinity;
1031
+ for (let li = 0;li < lines.length; li++) {
1032
+ const line2 = lines[li];
1033
+ const baselineY2 = line2.y + line2.baseline;
1034
+ const yDist = Math.abs(py - baselineY2);
1035
+ const inLineBox = py >= line2.y && py <= line2.y + line2.height;
1036
+ const distance = inLineBox ? 0 : yDist;
1037
+ if (distance < minYDist) {
1038
+ minYDist = distance;
1039
+ nearestLineIdx = li;
1040
+ }
1041
+ }
1042
+ const line = lines[nearestLineIdx];
1043
+ const baselineY = line.y + line.baseline;
1044
+ let nearestSpanIdx = 0;
1045
+ let nearestCharIdx = 0;
1046
+ let nearestX = line.x;
1047
+ let nearestWidth = 0;
1048
+ let nearestSpan = line.spans[0];
1049
+ let minXDist = Infinity;
1050
+ for (let si = 0;si < line.spans.length; si++) {
1051
+ const span = line.spans[si];
1052
+ const advances = getCharAdvances(span);
1053
+ const text = span.text;
1054
+ let charX = line.x + span.x;
1055
+ for (let ci = 0;ci < text.length; ci++) {
1056
+ const advance = ci < advances.length ? advances[ci] : 0;
1057
+ const charCenter = charX + advance / 2;
1058
+ const xDist = Math.abs(px - charCenter);
1059
+ if (xDist < minXDist) {
1060
+ minXDist = xDist;
1061
+ nearestSpanIdx = si;
1062
+ nearestCharIdx = ci;
1063
+ nearestX = charX;
1064
+ nearestWidth = advance;
1065
+ nearestSpan = span;
1066
+ }
1067
+ charX += advance;
1068
+ }
1069
+ }
1070
+ return {
1071
+ lineIndex: nearestLineIdx,
1072
+ spanIndex: nearestSpanIdx,
1073
+ charIndex: nearestCharIdx,
1074
+ pIdx: nearestSpan.pIdx,
1075
+ x: nearestX,
1076
+ y: baselineY,
1077
+ width: nearestWidth,
1078
+ line,
1079
+ span: nearestSpan
1080
+ };
1081
+ }
1082
+ function charIndexToPos(lines, charIndex) {
1083
+ const segments = buildCharSegments(lines);
1084
+ let globalIdx = 0;
1085
+ for (const seg of segments) {
1086
+ if (globalIdx === charIndex) {
1087
+ return {
1088
+ lineIndex: seg.lineIndex,
1089
+ spanIndex: seg.spanIndex,
1090
+ charIndex: seg.charIndex,
1091
+ pIdx: seg.pIdx,
1092
+ x: seg.x,
1093
+ y: seg.y,
1094
+ width: seg.width,
1095
+ line: seg.line,
1096
+ span: seg.span
1097
+ };
1098
+ }
1099
+ globalIdx++;
1100
+ }
1101
+ if (charIndex >= globalIdx && segments.length > 0) {
1102
+ const last = segments[segments.length - 1];
1103
+ return {
1104
+ lineIndex: last.lineIndex,
1105
+ spanIndex: last.spanIndex,
1106
+ charIndex: last.charIndex + 1,
1107
+ pIdx: last.pIdx,
1108
+ x: last.x + last.width,
1109
+ y: last.y,
1110
+ width: 0,
1111
+ line: last.line,
1112
+ span: last.span
1113
+ };
1114
+ }
1115
+ return null;
1116
+ }
1117
+ function posToCharIndex(lines, pos) {
1118
+ let index = 0;
1119
+ for (let li = 0;li < pos.lineIndex; li++) {
1120
+ const line2 = lines[li];
1121
+ for (const span of line2.spans) {
1122
+ index += span.text.length;
1123
+ }
1124
+ }
1125
+ const line = lines[pos.lineIndex];
1126
+ for (let si = 0;si < pos.spanIndex; si++) {
1127
+ index += line.spans[si].text.length;
1128
+ }
1129
+ index += pos.charIndex;
1130
+ return index;
1131
+ }
568
1132
  export {
569
1133
  renderToSVG,
570
1134
  renderToCanvas,
1135
+ renderSelection,
571
1136
  renderResultToSVG,
572
1137
  renderParagraphToSVG,
573
- renderDebugToCanvas
1138
+ renderDebugToCanvas,
1139
+ renderCursor,
1140
+ posToCharIndex,
1141
+ charIndexToPos,
1142
+ charAtPoint
574
1143
  };