@vyaz/renderer 0.0.11 → 0.1.0

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.
@@ -0,0 +1,106 @@
1
+ /**
2
+ * CanvasRenderer.ts — Layered Canvas rendering for Line[].
3
+ *
4
+ * Takes ready Line[] with absolute coordinates and provides
5
+ * rendering functions for each visual layer:
6
+ *
7
+ * 1. Background layer — clearRect / fillRect
8
+ * 2. Text layer — text spans (dumb drawer)
9
+ * 3. Selection layer — highlighted selection range
10
+ * 4. Cursor layer — blinking caret
11
+ * 5. Debug overlay layer — bounding boxes, baselines, labels
12
+ *
13
+ * Each function is standalone so consumers (e.g. fabric.js adapter)
14
+ * can compose layers in any order or skip layers as needed.
15
+ *
16
+ * Does NOT compute anything — only draws (dumb drawer principle).
17
+ *
18
+ * @see SVGRenderer for reference SVG implementation.
19
+ */
20
+ import type { Line } from '@vyaz/core';
21
+ import type { DebugFlags } from './types.js';
22
+ import type { CharPos } from './interactive.js';
23
+ export interface CanvasRenderOptions {
24
+ /**
25
+ * How the canvas size is determined:
26
+ * 'frame' — use current ctx.canvas.width/height (default)
27
+ * 'content' — compute bounding box from lines, resize canvas to fit
28
+ */
29
+ sizing?: 'frame' | 'content';
30
+ /**
31
+ * When true: render space spans with a space character.
32
+ * When false (default): skip space spans (position is already accounted for in x).
33
+ */
34
+ preserveSpaces?: boolean;
35
+ /** Background color for clearing. If omitted, canvas is cleared transparent. */
36
+ backgroundColor?: string;
37
+ /** Debug overlay flags. */
38
+ debug?: DebugFlags;
39
+ }
40
+ /**
41
+ * Options for cursor rendering.
42
+ */
43
+ export interface CursorOptions {
44
+ /** Cursor color. Default: '#000'. */
45
+ color?: string;
46
+ /** Cursor width in px. Default: 1. */
47
+ width?: number;
48
+ /** Cursor height relative to baseline. If undefined, uses the line's ascent + descent. */
49
+ height?: number;
50
+ }
51
+ /**
52
+ * Render Line[] array to Canvas.
53
+ *
54
+ * @param ctx — Canvas 2D rendering context
55
+ * @param lines — ready Line[] with absolute coordinates
56
+ * @param options — rendering options
57
+ */
58
+ export declare function renderToCanvas(ctx: CanvasRenderingContext2D | any, lines: Line[], options?: CanvasRenderOptions): void;
59
+ /**
60
+ * Draw debug overlays on Canvas.
61
+ *
62
+ * Mirrors SVGRenderer's renderDebugToSVG (lines 658-814) but draws
63
+ * directly on Canvas 2D instead of generating SVG markup.
64
+ *
65
+ * Supported flags:
66
+ * frameBox / frame — frame container bounding box (blue dashed)
67
+ * contentBox — content bounding box (pink dotted)
68
+ * paragraphBox — per-paragraph colored boxes (requires groupLinesByParagraph)
69
+ * columnBox — column separators for multi-column layout
70
+ * box — line box outlines (red)
71
+ * baseline — baseline line (blue)
72
+ * ascentDescent — ascent/descent lines (green dashed)
73
+ * lineGap — line height fill (blue transparent)
74
+ * labels — coordinate labels
75
+ * runs — span bounding boxes (purple)
76
+ */
77
+ export declare function renderDebugToCanvas(ctx: CanvasRenderingContext2D, lines: Line[], _width: number, _height: number, flags: DebugFlags): void;
78
+ /**
79
+ * Render a text selection highlight overlay.
80
+ *
81
+ * Draws a semi-transparent blue rectangle for each character
82
+ * in the range [start, end). Supports cross-line selections.
83
+ *
84
+ * If `start` and `end` are in different lines, the full width
85
+ * of intermediate lines is highlighted.
86
+ *
87
+ * @param ctx — Canvas 2D rendering context
88
+ * @param lines — layout lines
89
+ * @param start — selection start position (inclusive)
90
+ * @param end — selection end position (exclusive)
91
+ * @param color — highlight color. Default: 'rgba(100, 150, 255, 0.3)'
92
+ */
93
+ export declare function renderSelection(ctx: CanvasRenderingContext2D, lines: Line[], start: CharPos, end: CharPos, color?: string): void;
94
+ /**
95
+ * Render a text cursor (caret) at the given character position.
96
+ *
97
+ * Draws a vertical line at the character's left edge.
98
+ * The caller is responsible for cursor blink timing.
99
+ *
100
+ * @param ctx — Canvas 2D rendering context
101
+ * @param lines — layout lines
102
+ * @param pos — cursor position (character left edge)
103
+ * @param options — cursor visual options
104
+ */
105
+ export declare function renderCursor(ctx: CanvasRenderingContext2D, lines: Line[], pos: CharPos, options?: CursorOptions): void;
106
+ //# sourceMappingURL=CanvasRenderer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CanvasRenderer.d.ts","sourceRoot":"","sources":["../src/CanvasRenderer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAQ,MAAM,YAAY,CAAC;AAC7C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAC7C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAGhD,MAAM,WAAW,mBAAmB;IAClC;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC7B;;;OAGG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,gFAAgF;IAChF,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,2BAA2B;IAC3B,KAAK,CAAC,EAAE,UAAU,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,qCAAqC;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,0FAA0F;IAC1F,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAuJD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,GAAG,EAAE,wBAAwB,GAAG,GAAG,EACnC,KAAK,EAAE,IAAI,EAAE,EACb,OAAO,GAAE,mBAAwB,GAChC,IAAI,CAmDN;AAID;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,wBAAwB,EAC7B,KAAK,EAAE,IAAI,EAAE,EACb,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,UAAU,GAChB,IAAI,CA0IN;AAID;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,wBAAwB,EAC7B,KAAK,EAAE,IAAI,EAAE,EACb,KAAK,EAAE,OAAO,EACd,GAAG,EAAE,OAAO,EACZ,KAAK,GAAE,MAAmC,GACzC,IAAI,CAuDN;AAID;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAC1B,GAAG,EAAE,wBAAwB,EAC7B,KAAK,EAAE,IAAI,EAAE,EACb,GAAG,EAAE,OAAO,EACZ,OAAO,GAAE,aAAkB,GAC1B,IAAI,CA0BN"}
@@ -0,0 +1,110 @@
1
+ /**
2
+ * SVGRenderer.ts — SVG text builder.
3
+ *
4
+ * Converts Line[] into SVG markup using an AST-first approach.
5
+ * Builds a tree of SvgNode, then serializes to string in one pass.
6
+ *
7
+ * Four presets:
8
+ * flat — all text in one <text> element, xml:space="preserve", no <tspan>
9
+ * browser — expanded <tspan> per run, xml:space="preserve", diff attributes, no textLength
10
+ * preserve — expanded <tspan> per run, xml:space="preserve", diff attributes, textLength
11
+ * glyph — <tspan> per glyph with per-character x positions, xml:space="preserve"
12
+ *
13
+ * All presets preserve whitespace via xml:space="preserve". Space spans (type: 'space')
14
+ * are rendered as separate <tspan> elements with explicit x coordinates.
15
+ *
16
+ * Usage:
17
+ * const svg = renderToSVG(lines, { preset: 'browser' })
18
+ * const svg = renderToSVG(lines, { preset: 'preserve', style: 'css', fit: 'frag' })
19
+ */
20
+ import type { Line, ParagraphLayoutResult, TextFrameLayoutResult, MultiColumnConfig } from '@vyaz/core';
21
+ import type { DebugFlags } from './types.js';
22
+ export type SvgPreset = 'flat' | 'browser' | 'preserve' | 'glyph';
23
+ export type SvgStyle = 'css' | 'xml';
24
+ export type SvgFit = 'none' | 'text' | 'frag';
25
+ export type SvgSizing = 'frame' | 'content';
26
+ export type PerAxisSizing = {
27
+ horizontal: SvgSizing;
28
+ vertical: SvgSizing;
29
+ };
30
+ export interface SVGRenderOptions {
31
+ /** Shorthand that sets structure + spacing at once. */
32
+ preset?: SvgPreset;
33
+ /** How style properties are expressed: as CSS `style` attribute or as XML presentation attributes. */
34
+ style?: SvgStyle;
35
+ /** How `textLength` is applied. */
36
+ fit?: SvgFit;
37
+ /**
38
+ * How SVG determines its canvas size.
39
+ * Single string: applies to both axes. Object: per-axis control.
40
+ * 'frame' — use explicit width/height from options.
41
+ * 'content' — compute from lines bounding box.
42
+ */
43
+ sizing?: SvgSizing | PerAxisSizing;
44
+ /** SVG canvas width (px). Used when horizontal sizing='frame' or as fallback. */
45
+ width?: number;
46
+ /** SVG canvas height (px). Used when vertical sizing='frame' or as fallback. */
47
+ height?: number;
48
+ /** CSS class for `<svg>`. */
49
+ className?: string;
50
+ /**
51
+ * Extra padding added around the SVG canvas. Content coordinates stay unchanged;
52
+ * the SVG viewBox is shifted and canvas is enlarged so debug overlays
53
+ * (frameBox / contentBox) are visible with a gap from the edge.
54
+ * Useful for snapshot tests to clearly show frame vs content boundaries.
55
+ */
56
+ contentPadding?: number;
57
+ /** Debug overlays. */
58
+ debug?: DebugFlags;
59
+ /**
60
+ * Multi-column layout configuration for debug overlays.
61
+ * When set, paragraph and column boxes are rendered per-column.
62
+ */
63
+ columns?: MultiColumnConfig;
64
+ /** Left padding from frame (needed for column debug rendering). */
65
+ paddingLeft?: number;
66
+ /**
67
+ * glyph preset only: draw `underline` / `strikethrough` as explicit `<line>`
68
+ * geometry. The glyph path positions each character with its own `x`, so it
69
+ * cannot rely on SVG `text-decoration` (which the flat/expanded paths use).
70
+ * Ignored by every other preset. Default `true`.
71
+ */
72
+ glyphDecorations?: boolean;
73
+ }
74
+ /**
75
+ * Render a layout to an SVG string.
76
+ *
77
+ * Pass a full {@link TextFrameLayoutResult} (recommended) and the canvas size /
78
+ * `sizing` are derived from its `frame*` / `content*` / `fit*` fields — you only
79
+ * add render options (preset, style, debug). Passing a bare `Line[]` is the
80
+ * low-level form: you supply `width` / `height` / `sizing` yourself.
81
+ *
82
+ * @param input — a layout result, or bare layout lines
83
+ * @param options — rendering options (preset + style/fit/sizing modifiers); any
84
+ * field here overrides the value derived from a result
85
+ * @returns SVG string
86
+ */
87
+ export declare function renderToSVG(input: Line[] | TextFrameLayoutResult, options?: SVGRenderOptions): string;
88
+ /**
89
+ * Render one ParagraphLayoutResult to SVG (convenience wrapper).
90
+ */
91
+ export declare function renderParagraphToSVG(lines: Line[], paragraphWidth: number, paragraphHeight: number, options?: SVGRenderOptions): string;
92
+ /**
93
+ * Render a ParagraphLayoutResult to SVG, auto-passing dimensions.
94
+ *
95
+ * Uses `result.width` and `result.height` as the SVG canvas size.
96
+ * This is the recommended way to render when you have a layout result
97
+ * and want `sizing: 'frame'` with correct dimensions.
98
+ *
99
+ * @param result — layout result from ParagraphLayoutEngine.layout()
100
+ * @param options — rendering options (preset, style, fit, etc.)
101
+ * @returns SVG string
102
+ *
103
+ * @example
104
+ * ```ts
105
+ * const result = paragraphLayoutEngine.layout(paragraph, 300);
106
+ * const svg = renderResultToSVG(result, { preset: 'preserve' });
107
+ * ```
108
+ */
109
+ export declare function renderResultToSVG(result: ParagraphLayoutResult, options?: SVGRenderOptions): string;
110
+ //# sourceMappingURL=SVGRenderer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SVGRenderer.d.ts","sourceRoot":"","sources":["../src/SVGRenderer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAQ,qBAAqB,EAAE,qBAAqB,EAAkB,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAE9H,OAAO,KAAK,EAAE,UAAU,EAAuB,MAAM,YAAY,CAAC;AAKlE,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,SAAS,GAAG,UAAU,GAAG,OAAO,CAAC;AAElE,MAAM,MAAM,QAAQ,GAAG,KAAK,GAAG,KAAK,CAAC;AAErC,MAAM,MAAM,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAE9C,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,SAAS,CAAC;AAE5C,MAAM,MAAM,aAAa,GAAG;IAAE,UAAU,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,SAAS,CAAA;CAAE,CAAC;AAE3E,MAAM,WAAW,gBAAgB;IAC/B,uDAAuD;IACvD,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,sGAAsG;IACtG,KAAK,CAAC,EAAE,QAAQ,CAAC;IACjB,mCAAmC;IACnC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;;OAKG;IACH,MAAM,CAAC,EAAE,SAAS,GAAG,aAAa,CAAC;IACnC,iFAAiF;IACjF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gFAAgF;IAChF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6BAA6B;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,sBAAsB;IACtB,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB;;;OAGG;IACH,OAAO,CAAC,EAAE,iBAAiB,CAAC;IAC5B,mEAAmE;IACnE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AA8zBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CACzB,KAAK,EAAE,IAAI,EAAE,GAAG,qBAAqB,EACrC,OAAO,GAAE,gBAAqB,GAC7B,MAAM,CA6QR;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,IAAI,EAAE,EACb,cAAc,EAAE,MAAM,EACtB,eAAe,EAAE,MAAM,EACvB,OAAO,CAAC,EAAE,gBAAgB,GACzB,MAAM,CAMR;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,qBAAqB,EAC7B,OAAO,CAAC,EAAE,gBAAgB,GACzB,MAAM,CAMR"}
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @vyaz/renderer — Browser entry point.
3
+ *
4
+ * Re-exports everything from the main index.
5
+ * In browser environments @vyaz/core resolves to its browser entry
6
+ * (dist/index.browser.js) via package.json exports.
7
+ *
8
+ * ✅ Safe for Vite / webpack / Rollup / esbuild browser builds.
9
+ */
10
+ export { renderToSVG, renderParagraphToSVG, renderResultToSVG } from './SVGRenderer.js';
11
+ export type { SVGRenderOptions, SvgPreset, SvgStyle, SvgFit, SvgSizing } from './SVGRenderer.js';
12
+ export { renderToCanvas, renderDebugToCanvas, renderSelection, renderCursor } from './CanvasRenderer.js';
13
+ export type { CanvasRenderOptions, CursorOptions } from './CanvasRenderer.js';
14
+ export { charAtPoint, charIndexToPos, posToCharIndex } from './interactive.js';
15
+ export type { CharPos } from './interactive.js';
16
+ export type { DebugFlags } from './types.js';
17
+ //# sourceMappingURL=index.browser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.browser.d.ts","sourceRoot":"","sources":["../src/index.browser.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,WAAW,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACxF,YAAY,EAAE,gBAAgB,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAEjG,OAAO,EAAE,cAAc,EAAE,mBAAmB,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACzG,YAAY,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAE9E,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAC/E,YAAY,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAEhD,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC"}
@@ -93,7 +93,7 @@ function resolveOptions(opts) {
93
93
  console.warn(`SVGRenderer: fit="frag" downgraded to "text" when structure="flat"`);
94
94
  fit = "text";
95
95
  }
96
- return { structure, spacing, style, fit, sizingHorizontal, sizingVertical, width: opts.width, height: opts.height, className: opts.className, contentPadding: opts.contentPadding ?? 0, debug: opts.debug };
96
+ return { structure, spacing, style, fit, sizingHorizontal, sizingVertical, width: opts.width, height: opts.height, className: opts.className, contentPadding: opts.contentPadding ?? 0, debug: opts.debug, glyphDecorations: opts.glyphDecorations ?? true };
97
97
  }
98
98
  function resolveSize(lines, opts) {
99
99
  const needsBBox = opts.sizingHorizontal === "content" || opts.sizingVertical === "content";
@@ -223,13 +223,12 @@ function buildTspanAttrs(span, x, currentStyle) {
223
223
  }
224
224
  return { attrs, newStyle: s };
225
225
  }
226
- function buildGlyphPositions(span, _lineX) {
226
+ function buildGlyphPositions(span, spanDX) {
227
227
  if (!span.glyphAdvances || span.glyphAdvances.length === 0) {
228
- return "";
228
+ return (span.type === "marker" || span.type === "space") && span.text ? fmt(span.x + spanDX, 1) : "";
229
229
  }
230
230
  const ls = span.style.letterSpacing || 0;
231
- const spanX = span.x;
232
- let xPos = spanX;
231
+ let xPos = span.x + spanDX;
233
232
  const positions = [fmt(xPos, 1)];
234
233
  for (let i = 0;i < span.glyphAdvances.length - 1; i++) {
235
234
  xPos += span.glyphAdvances[i] + ls;
@@ -324,6 +323,8 @@ class SvgAstBuilder {
324
323
  x: fmt(x),
325
324
  y: fmt(yOverride)
326
325
  };
326
+ if (runId)
327
+ attrs.id = runId;
327
328
  if (this.opts.style === "css") {
328
329
  let css = `font-family: '${s.fontFamily}', sans-serif; font-size: ${fmt(fontSizeOverride)}px; fill: ${colorToRGB(s.color)}; font-weight: ${s.fontWeight}`;
329
330
  if (s.fontStyle === "italic")
@@ -399,6 +400,17 @@ ${debugMarkup}
399
400
  });
400
401
  this.root.children.push(rect);
401
402
  }
403
+ addDecorationLine(x, width, y, color, thickness) {
404
+ this.closeText();
405
+ this.root.children.push(el("line", {
406
+ x1: fmt(x),
407
+ y1: fmt(y),
408
+ x2: fmt(x + width),
409
+ y2: fmt(y),
410
+ stroke: color,
411
+ "stroke-width": fmt(thickness)
412
+ }));
413
+ }
402
414
  addRawLine(lineStr) {
403
415
  this.closeText();
404
416
  this.root.children.push(rawNode(lineStr));
@@ -530,36 +542,105 @@ function getSpanBackgroundAttrs(span, baselineY) {
530
542
  const h = span.fontMetrics.ascent + span.fontMetrics.descent;
531
543
  return { x, y, w, h, fill: span.style.backgroundColor };
532
544
  }
533
- function renderToSVG(lines, options = {}) {
534
- const opts = resolveOptions(options);
545
+ function renderToSVG(input, options = {}) {
546
+ let lines;
547
+ let resolvedOptions;
548
+ if (Array.isArray(input)) {
549
+ lines = input;
550
+ resolvedOptions = options;
551
+ } else {
552
+ lines = input.lines;
553
+ const callerSizes = options.sizing !== undefined || options.width !== undefined || options.height !== undefined;
554
+ if (callerSizes) {
555
+ resolvedOptions = options;
556
+ } else {
557
+ const fw = input.fit.horizontal === "frame" && input.frame.width != null;
558
+ const fh = input.fit.vertical === "frame" && input.frame.height != null;
559
+ resolvedOptions = {
560
+ sizing: {
561
+ horizontal: fw ? "frame" : "content",
562
+ vertical: fh ? "frame" : "content"
563
+ },
564
+ width: fw ? input.frame.width : input.content.width,
565
+ height: fh ? input.frame.height : input.content.height,
566
+ ...options
567
+ };
568
+ }
569
+ }
570
+ const opts = resolveOptions(resolvedOptions);
535
571
  const { width: svgWidth, height: svgHeight, viewBox, frameWidth, frameHeight } = resolveSize(lines, opts);
536
572
  const builder = new SvgAstBuilder(svgWidth, svgHeight, opts, viewBox);
537
573
  for (const line of lines) {
538
574
  const baselineY = line.y + line.baseline;
575
+ const bgFirstTextX = line.spans.find((s) => s.type === "text" || s.type === "marker")?.x ?? 0;
539
576
  for (const span of line.spans) {
540
577
  if (!span.text || !span.style.backgroundColor)
541
578
  continue;
542
579
  const bg = getSpanBackgroundAttrs(span, baselineY);
543
580
  if (bg) {
544
- const rx = line.x + bg.x;
581
+ const rx = line.x + bg.x - bgFirstTextX;
545
582
  builder.addBackgroundRect(rx, bg.y, bg.w, bg.h, bg.fill);
546
583
  }
547
584
  }
548
585
  if (opts.structure === "glyph") {
586
+ const glyphFirstTextX = line.spans.find((s) => s.type === "text" || s.type === "marker")?.x ?? 0;
587
+ const glyphSpanDX = line.x - glyphFirstTextX;
549
588
  let currentRunIdx = -1;
589
+ let currentSig = "";
550
590
  for (const span of line.spans) {
551
591
  if (!span.text)
552
592
  continue;
553
593
  const runIdx = span.itemIndex;
554
- if (runIdx !== currentRunIdx) {
594
+ const sig = styleSignature(span);
595
+ if (runIdx !== currentRunIdx || sig !== currentSig) {
555
596
  builder.closeText();
556
597
  const runId = span.tag ? `${span.tag}-${runIdx}` : undefined;
557
- builder.openText(line, span, runId);
598
+ const offset = span.fontMetrics.baselineOffset || 0;
599
+ if (offset !== 0) {
600
+ const targetY = Math.round((line.y + line.baseline + offset) * 100) / 100;
601
+ builder.openText(line, span, runId, targetY, span.fontMetrics.fontSize);
602
+ } else {
603
+ builder.openText(line, span, runId);
604
+ }
558
605
  currentRunIdx = runIdx;
606
+ currentSig = sig;
559
607
  }
560
- builder.addGlyphTspan(span, line.x);
608
+ builder.addGlyphTspan(span, glyphSpanDX);
561
609
  }
562
610
  builder.closeText();
611
+ if (opts.glyphDecorations) {
612
+ let cur = null;
613
+ const flushDeco = () => {
614
+ if (!cur)
615
+ return;
616
+ const w = cur.end - cur.x;
617
+ if (cur.u)
618
+ builder.addDecorationLine(cur.x, w, cur.baseY + 2, cur.color, 1);
619
+ if (cur.s)
620
+ builder.addDecorationLine(cur.x, w, cur.baseY - cur.ascent * 0.4, cur.color, 1);
621
+ cur = null;
622
+ };
623
+ for (const span of line.spans) {
624
+ if (!span.text)
625
+ continue;
626
+ const u = !!span.style.underline;
627
+ const s = !!span.style.strikethrough;
628
+ if (!u && !s) {
629
+ flushDeco();
630
+ continue;
631
+ }
632
+ const color = span.style.color || "#000000";
633
+ const x = span.x + glyphSpanDX;
634
+ const baseY = line.y + line.baseline;
635
+ if (cur && cur.u === u && cur.s === s && cur.color === color && Math.abs(cur.end - x) < 0.01) {
636
+ cur.end = x + span.width;
637
+ } else {
638
+ flushDeco();
639
+ cur = { x, end: x + span.width, baseY, ascent: span.fontMetrics.ascent, color, u, s };
640
+ }
641
+ }
642
+ flushDeco();
643
+ }
563
644
  } else if (opts.structure === "flat") {
564
645
  const groups = [];
565
646
  for (const span of line.spans) {
@@ -649,11 +730,13 @@ function renderToSVG(lines, options = {}) {
649
730
  } else {
650
731
  builder.openText(line, baseSpan);
651
732
  }
733
+ const lineFirstTextX = line.spans.find((s) => s.type === "text" || s.type === "marker")?.x ?? 0;
734
+ const spanDX = line.x - lineFirstTextX;
652
735
  let currentStyle = null;
653
736
  for (const span of group.spans) {
654
737
  if (!span.text)
655
738
  continue;
656
- const x = span.x;
739
+ const x = span.x + spanDX;
657
740
  const shouldRender = span.type !== "space" || opts.spacing === "preserve";
658
741
  if (shouldRender) {
659
742
  const newStyle = builder.addTspan(span, x, currentStyle);
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @vyaz/renderer — SVG and Canvas renderers.
3
+ *
4
+ * Converts Line[] (from @vyaz/core) into SVG strings or Canvas drawings.
5
+ */
6
+ export { renderToSVG, renderParagraphToSVG, renderResultToSVG } from './SVGRenderer.js';
7
+ export type { SVGRenderOptions, SvgPreset, SvgStyle, SvgFit, SvgSizing } from './SVGRenderer.js';
8
+ export { renderToCanvas, renderDebugToCanvas, renderSelection, renderCursor } from './CanvasRenderer.js';
9
+ export type { CanvasRenderOptions, CursorOptions } from './CanvasRenderer.js';
10
+ export { charAtPoint, charIndexToPos, posToCharIndex } from './interactive.js';
11
+ export type { CharPos } from './interactive.js';
12
+ export type { DebugFlags } from './types.js';
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,WAAW,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACxF,YAAY,EAAE,gBAAgB,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAEjG,OAAO,EAAE,cAAc,EAAE,mBAAmB,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACzG,YAAY,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAE9E,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAC/E,YAAY,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAEhD,YAAY,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -1,19 +1,19 @@
1
1
  // @bun
2
2
  // ../core/src/types/Document.ts
3
- var DEFAULT_PARAGRAPH_STYLE = {
3
+ var DEFAULT_PARAGRAPH_STYLE = Object.freeze({
4
4
  alignment: "left",
5
5
  lineHeight: 1.15,
6
6
  spaceBefore: 0,
7
7
  spaceAfter: 0,
8
8
  whiteSpace: "normal"
9
- };
10
- var DEFAULT_TEXT_STYLE = {
9
+ });
10
+ var DEFAULT_TEXT_STYLE = Object.freeze({
11
11
  fontFamily: "Arial",
12
12
  fontSize: 12,
13
13
  fontWeight: "normal",
14
14
  fontStyle: "normal",
15
15
  color: "#000000"
16
- };
16
+ });
17
17
 
18
18
  // ../core/src/utils/list.ts
19
19
  var BULLET_CHARACTERS = {
@@ -144,7 +144,7 @@ function resolveOptions(opts) {
144
144
  console.warn(`SVGRenderer: fit="frag" downgraded to "text" when structure="flat"`);
145
145
  fit = "text";
146
146
  }
147
- return { structure, spacing, style, fit, sizingHorizontal, sizingVertical, width: opts.width, height: opts.height, className: opts.className, contentPadding: opts.contentPadding ?? 0, debug: opts.debug };
147
+ return { structure, spacing, style, fit, sizingHorizontal, sizingVertical, width: opts.width, height: opts.height, className: opts.className, contentPadding: opts.contentPadding ?? 0, debug: opts.debug, glyphDecorations: opts.glyphDecorations ?? true };
148
148
  }
149
149
  function resolveSize(lines, opts) {
150
150
  const needsBBox = opts.sizingHorizontal === "content" || opts.sizingVertical === "content";
@@ -274,13 +274,12 @@ function buildTspanAttrs(span, x, currentStyle) {
274
274
  }
275
275
  return { attrs, newStyle: s };
276
276
  }
277
- function buildGlyphPositions(span, _lineX) {
277
+ function buildGlyphPositions(span, spanDX) {
278
278
  if (!span.glyphAdvances || span.glyphAdvances.length === 0) {
279
- return "";
279
+ return (span.type === "marker" || span.type === "space") && span.text ? fmt(span.x + spanDX, 1) : "";
280
280
  }
281
281
  const ls = span.style.letterSpacing || 0;
282
- const spanX = span.x;
283
- let xPos = spanX;
282
+ let xPos = span.x + spanDX;
284
283
  const positions = [fmt(xPos, 1)];
285
284
  for (let i = 0;i < span.glyphAdvances.length - 1; i++) {
286
285
  xPos += span.glyphAdvances[i] + ls;
@@ -375,6 +374,8 @@ class SvgAstBuilder {
375
374
  x: fmt(x),
376
375
  y: fmt(yOverride)
377
376
  };
377
+ if (runId)
378
+ attrs.id = runId;
378
379
  if (this.opts.style === "css") {
379
380
  let css = `font-family: '${s.fontFamily}', sans-serif; font-size: ${fmt(fontSizeOverride)}px; fill: ${colorToRGB(s.color)}; font-weight: ${s.fontWeight}`;
380
381
  if (s.fontStyle === "italic")
@@ -450,6 +451,17 @@ ${debugMarkup}
450
451
  });
451
452
  this.root.children.push(rect);
452
453
  }
454
+ addDecorationLine(x, width, y, color, thickness) {
455
+ this.closeText();
456
+ this.root.children.push(el("line", {
457
+ x1: fmt(x),
458
+ y1: fmt(y),
459
+ x2: fmt(x + width),
460
+ y2: fmt(y),
461
+ stroke: color,
462
+ "stroke-width": fmt(thickness)
463
+ }));
464
+ }
453
465
  addRawLine(lineStr) {
454
466
  this.closeText();
455
467
  this.root.children.push(rawNode(lineStr));
@@ -581,36 +593,105 @@ function getSpanBackgroundAttrs(span, baselineY) {
581
593
  const h = span.fontMetrics.ascent + span.fontMetrics.descent;
582
594
  return { x, y, w, h, fill: span.style.backgroundColor };
583
595
  }
584
- function renderToSVG(lines, options = {}) {
585
- const opts = resolveOptions(options);
596
+ function renderToSVG(input, options = {}) {
597
+ let lines;
598
+ let resolvedOptions;
599
+ if (Array.isArray(input)) {
600
+ lines = input;
601
+ resolvedOptions = options;
602
+ } else {
603
+ lines = input.lines;
604
+ const callerSizes = options.sizing !== undefined || options.width !== undefined || options.height !== undefined;
605
+ if (callerSizes) {
606
+ resolvedOptions = options;
607
+ } else {
608
+ const fw = input.fit.horizontal === "frame" && input.frame.width != null;
609
+ const fh = input.fit.vertical === "frame" && input.frame.height != null;
610
+ resolvedOptions = {
611
+ sizing: {
612
+ horizontal: fw ? "frame" : "content",
613
+ vertical: fh ? "frame" : "content"
614
+ },
615
+ width: fw ? input.frame.width : input.content.width,
616
+ height: fh ? input.frame.height : input.content.height,
617
+ ...options
618
+ };
619
+ }
620
+ }
621
+ const opts = resolveOptions(resolvedOptions);
586
622
  const { width: svgWidth, height: svgHeight, viewBox, frameWidth, frameHeight } = resolveSize(lines, opts);
587
623
  const builder = new SvgAstBuilder(svgWidth, svgHeight, opts, viewBox);
588
624
  for (const line of lines) {
589
625
  const baselineY = line.y + line.baseline;
626
+ const bgFirstTextX = line.spans.find((s) => s.type === "text" || s.type === "marker")?.x ?? 0;
590
627
  for (const span of line.spans) {
591
628
  if (!span.text || !span.style.backgroundColor)
592
629
  continue;
593
630
  const bg = getSpanBackgroundAttrs(span, baselineY);
594
631
  if (bg) {
595
- const rx = line.x + bg.x;
632
+ const rx = line.x + bg.x - bgFirstTextX;
596
633
  builder.addBackgroundRect(rx, bg.y, bg.w, bg.h, bg.fill);
597
634
  }
598
635
  }
599
636
  if (opts.structure === "glyph") {
637
+ const glyphFirstTextX = line.spans.find((s) => s.type === "text" || s.type === "marker")?.x ?? 0;
638
+ const glyphSpanDX = line.x - glyphFirstTextX;
600
639
  let currentRunIdx = -1;
640
+ let currentSig = "";
601
641
  for (const span of line.spans) {
602
642
  if (!span.text)
603
643
  continue;
604
644
  const runIdx = span.itemIndex;
605
- if (runIdx !== currentRunIdx) {
645
+ const sig = styleSignature(span);
646
+ if (runIdx !== currentRunIdx || sig !== currentSig) {
606
647
  builder.closeText();
607
648
  const runId = span.tag ? `${span.tag}-${runIdx}` : undefined;
608
- builder.openText(line, span, runId);
649
+ const offset = span.fontMetrics.baselineOffset || 0;
650
+ if (offset !== 0) {
651
+ const targetY = Math.round((line.y + line.baseline + offset) * 100) / 100;
652
+ builder.openText(line, span, runId, targetY, span.fontMetrics.fontSize);
653
+ } else {
654
+ builder.openText(line, span, runId);
655
+ }
609
656
  currentRunIdx = runIdx;
657
+ currentSig = sig;
610
658
  }
611
- builder.addGlyphTspan(span, line.x);
659
+ builder.addGlyphTspan(span, glyphSpanDX);
612
660
  }
613
661
  builder.closeText();
662
+ if (opts.glyphDecorations) {
663
+ let cur = null;
664
+ const flushDeco = () => {
665
+ if (!cur)
666
+ return;
667
+ const w = cur.end - cur.x;
668
+ if (cur.u)
669
+ builder.addDecorationLine(cur.x, w, cur.baseY + 2, cur.color, 1);
670
+ if (cur.s)
671
+ builder.addDecorationLine(cur.x, w, cur.baseY - cur.ascent * 0.4, cur.color, 1);
672
+ cur = null;
673
+ };
674
+ for (const span of line.spans) {
675
+ if (!span.text)
676
+ continue;
677
+ const u = !!span.style.underline;
678
+ const s = !!span.style.strikethrough;
679
+ if (!u && !s) {
680
+ flushDeco();
681
+ continue;
682
+ }
683
+ const color = span.style.color || "#000000";
684
+ const x = span.x + glyphSpanDX;
685
+ const baseY = line.y + line.baseline;
686
+ if (cur && cur.u === u && cur.s === s && cur.color === color && Math.abs(cur.end - x) < 0.01) {
687
+ cur.end = x + span.width;
688
+ } else {
689
+ flushDeco();
690
+ cur = { x, end: x + span.width, baseY, ascent: span.fontMetrics.ascent, color, u, s };
691
+ }
692
+ }
693
+ flushDeco();
694
+ }
614
695
  } else if (opts.structure === "flat") {
615
696
  const groups = [];
616
697
  for (const span of line.spans) {
@@ -700,11 +781,13 @@ function renderToSVG(lines, options = {}) {
700
781
  } else {
701
782
  builder.openText(line, baseSpan);
702
783
  }
784
+ const lineFirstTextX = line.spans.find((s) => s.type === "text" || s.type === "marker")?.x ?? 0;
785
+ const spanDX = line.x - lineFirstTextX;
703
786
  let currentStyle = null;
704
787
  for (const span of group.spans) {
705
788
  if (!span.text)
706
789
  continue;
707
- const x = span.x;
790
+ const x = span.x + spanDX;
708
791
  const shouldRender = span.type !== "space" || opts.spacing === "preserve";
709
792
  if (shouldRender) {
710
793
  const newStyle = builder.addTspan(span, x, currentStyle);