@vyaz/renderer 0.0.14 → 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.
@@ -17,7 +17,7 @@
17
17
  * const svg = renderToSVG(lines, { preset: 'browser' })
18
18
  * const svg = renderToSVG(lines, { preset: 'preserve', style: 'css', fit: 'frag' })
19
19
  */
20
- import type { Line, ParagraphLayoutResult, MultiColumnConfig } from '@vyaz/core';
20
+ import type { Line, ParagraphLayoutResult, TextFrameLayoutResult, MultiColumnConfig } from '@vyaz/core';
21
21
  import type { DebugFlags } from './types.js';
22
22
  export type SvgPreset = 'flat' | 'browser' | 'preserve' | 'glyph';
23
23
  export type SvgStyle = 'css' | 'xml';
@@ -63,15 +63,28 @@ export interface SVGRenderOptions {
63
63
  columns?: MultiColumnConfig;
64
64
  /** Left padding from frame (needed for column debug rendering). */
65
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;
66
73
  }
67
74
  /**
68
- * Render Line[] into SVG string.
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.
69
81
  *
70
- * @param lines — layout lines with spans
71
- * @param options — rendering options (preset + style/fit/sizing modifiers)
82
+ * @param inputa 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
72
85
  * @returns SVG string
73
86
  */
74
- export declare function renderToSVG(lines: Line[], options?: SVGRenderOptions): string;
87
+ export declare function renderToSVG(input: Line[] | TextFrameLayoutResult, options?: SVGRenderOptions): string;
75
88
  /**
76
89
  * Render one ParagraphLayoutResult to SVG (convenience wrapper).
77
90
  */
@@ -1 +1 @@
1
- {"version":3,"file":"SVGRenderer.d.ts","sourceRoot":"","sources":["../src/SVGRenderer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAQ,qBAAqB,EAAkB,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEvG,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;CACtB;AAuyBD;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,OAAO,GAAE,gBAAqB,GAAG,MAAM,CAkLjF;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"}
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"}
@@ -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);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vyaz/renderer",
3
- "version": "0.0.14",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -24,7 +24,7 @@
24
24
  "prepublishOnly": "bun run build"
25
25
  },
26
26
  "dependencies": {
27
- "@vyaz/core": "^0.0.14"
27
+ "@vyaz/core": "^0.1.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/node": "^26.1.1",