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