@nowline/renderer 0.5.0 → 0.6.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.
package/src/svg/render.ts CHANGED
@@ -95,6 +95,16 @@ export interface AssetBytes {
95
95
 
96
96
  export type AssetResolver = (ref: string) => Promise<AssetBytes>;
97
97
 
98
+ /**
99
+ * Per-role `font-family` strings the renderer stamps onto `<text>` elements.
100
+ * Defaults to the shared, portable `FONT_STACK` (generic CSS stacks). Raster
101
+ * and preview callers override this with a *pinned* family (e.g. the bundled
102
+ * `DejaVu Sans` / `DejaVu Sans Mono`) so the rendered SVG names exactly the
103
+ * font that resvg / the webview `@font-face` actually provide — the WYSIWYG
104
+ * contract. The `.svg` file export keeps the default portable stack.
105
+ */
106
+ export type FontFamilies = Record<'sans' | 'serif' | 'mono', string>;
107
+
98
108
  export interface RenderOptions {
99
109
  assetResolver?: AssetResolver;
100
110
  noLinks?: boolean;
@@ -102,6 +112,11 @@ export interface RenderOptions {
102
112
  warn?: (message: string) => void;
103
113
  // Override the deterministic id prefix (defaults to 'nl').
104
114
  idPrefix?: string;
115
+ /**
116
+ * Override per-role `font-family` strings. Defaults to the portable
117
+ * `FONT_STACK`. Set to a pinned family for raster/preview WYSIWYG.
118
+ */
119
+ fontFamilies?: FontFamilies;
105
120
  }
106
121
 
107
122
  // `TEXT_SIZE_PX`, `CORNER_RADIUS_PX`, `FONT_STACK` come from
@@ -125,9 +140,13 @@ function textSizePx(bucket: ResolvedStyle['textSize']): number {
125
140
  return (TEXT_SIZE_PX as Record<string, number>)[bucket] ?? 14;
126
141
  }
127
142
 
128
- function fontAttrs(style: ResolvedStyle, overrideSize?: number): Record<string, string | number> {
143
+ function fontAttrs(
144
+ style: ResolvedStyle,
145
+ fonts: FontFamilies,
146
+ overrideSize?: number,
147
+ ): Record<string, string | number> {
129
148
  return {
130
- 'font-family': FONT_STACK[style.font],
149
+ 'font-family': fonts[style.font],
131
150
  'font-size': overrideSize ?? textSizePx(style.textSize),
132
151
  'font-weight': WEIGHT_NUM[style.weight] ?? 400,
133
152
  'font-style': style.italic ? 'italic' : 'normal',
@@ -452,7 +471,12 @@ function rectFrame(
452
471
  });
453
472
  }
454
473
 
455
- function renderHeader(h: PositionedHeader, idPrefix: string, palette: Theme): string {
474
+ function renderHeader(
475
+ h: PositionedHeader,
476
+ idPrefix: string,
477
+ palette: Theme,
478
+ fonts: FontFamilies,
479
+ ): string {
456
480
  // The layout has already sized the card to its (wrapped) text content
457
481
  // and stashed the bounds in `h.cardBox`, with `h.titleLines` /
458
482
  // `h.authorLines` ready to render line-by-line. See sizeBesideHeader
@@ -485,7 +509,7 @@ function renderHeader(h: PositionedHeader, idPrefix: string, palette: Theme): st
485
509
  {
486
510
  x: num(cardX + HEADER_CARD_PADDING_X),
487
511
  y: num(cardY + HEADER_CARD_PADDING_TOP + i * HEADER_TITLE_LINE_HEIGHT_PX),
488
- 'font-family': FONT_STACK[h.style.font],
512
+ 'font-family': fonts[h.style.font],
489
513
  'font-size': HEADER_TITLE_FONT_SIZE_PX,
490
514
  'font-weight': 600,
491
515
  fill: h.style.text,
@@ -511,7 +535,7 @@ function renderHeader(h: PositionedHeader, idPrefix: string, palette: Theme): st
511
535
  HEADER_TITLE_TO_AUTHOR_GAP_PX +
512
536
  j * HEADER_AUTHOR_LINE_HEIGHT_PX,
513
537
  ),
514
- 'font-family': FONT_STACK[h.style.font],
538
+ 'font-family': fonts[h.style.font],
515
539
  'font-size': HEADER_AUTHOR_FONT_SIZE_PX,
516
540
  fill: authorColor,
517
541
  },
@@ -602,7 +626,7 @@ function renderGridLines(t: PositionedTimelineScale, swimlaneTopY: number, palet
602
626
  return tag('g', { 'data-layer': 'grid' }, parts.join(''));
603
627
  }
604
628
 
605
- function renderTimeline(t: PositionedTimelineScale, palette: Theme): string {
629
+ function renderTimeline(t: PositionedTimelineScale, palette: Theme, fonts: FontFamilies): string {
606
630
  const panelFill = palette.timeline.panelFill;
607
631
  const borderColor = palette.timeline.border;
608
632
  const labelColor = palette.timeline.labelText;
@@ -683,7 +707,7 @@ function renderTimeline(t: PositionedTimelineScale, palette: Theme): string {
683
707
  {
684
708
  x: num(tick.labelX),
685
709
  y: num(tickPanelY + TIMELINE_TICK_LABEL_BASELINE_OFFSET_PX),
686
- 'font-family': FONT_STACK.sans,
710
+ 'font-family': fonts.sans,
687
711
  'font-size': 10,
688
712
  fill: labelColor,
689
713
  'text-anchor': 'middle',
@@ -698,7 +722,7 @@ function renderTimeline(t: PositionedTimelineScale, palette: Theme): string {
698
722
  {
699
723
  x: num(tick.labelX),
700
724
  y: num(bottomTickPanelY! + TIMELINE_TICK_LABEL_BASELINE_OFFSET_PX),
701
- 'font-family': FONT_STACK.sans,
725
+ 'font-family': fonts.sans,
702
726
  'font-size': 10,
703
727
  fill: labelColor,
704
728
  'text-anchor': 'middle',
@@ -711,7 +735,7 @@ function renderTimeline(t: PositionedTimelineScale, palette: Theme): string {
711
735
  return tag('g', { 'data-layer': 'timeline' }, parts.join(''));
712
736
  }
713
737
 
714
- function renderNowline(n: PositionedNowline | null, palette: Theme): string {
738
+ function renderNowline(n: PositionedNowline | null, palette: Theme, fonts: FontFamilies): string {
715
739
  if (!n) return '';
716
740
  const color = palette.nowline.stroke;
717
741
  const labelTextColor = palette.nowline.labelText;
@@ -735,7 +759,7 @@ function renderNowline(n: PositionedNowline | null, palette: Theme): string {
735
759
  // The squared edge IS the line; the rounded edge points into the
736
760
  // chart, so the pill always hugs the line and never overflows.
737
761
  const pillBg = renderNowPillBg(n, color);
738
- const label = renderNowPillLabel(n, labelTextColor);
762
+ const label = renderNowPillLabel(n, labelTextColor, fonts);
739
763
  return tag('g', { 'data-layer': 'nowline' }, line + pillBg + label);
740
764
  }
741
765
 
@@ -812,7 +836,11 @@ function renderNowPillBg(n: PositionedNowline, color: string): string {
812
836
  return tag('path', { d, fill: color });
813
837
  }
814
838
 
815
- function renderNowPillLabel(n: PositionedNowline, labelTextColor: string): string {
839
+ function renderNowPillLabel(
840
+ n: PositionedNowline,
841
+ labelTextColor: string,
842
+ fonts: FontFamilies,
843
+ ): string {
816
844
  const baselineY = n.pillTopY + NOW_PILL_LABEL_BASELINE_OFFSET_PX;
817
845
  const edgeX = squaredEdgeX(n);
818
846
  let labelX: number;
@@ -831,7 +859,7 @@ function renderNowPillLabel(n: PositionedNowline, labelTextColor: string): strin
831
859
  {
832
860
  x: num(labelX),
833
861
  y: num(baselineY),
834
- 'font-family': FONT_STACK.sans,
862
+ 'font-family': fonts.sans,
835
863
  'font-size': NOW_PILL_LABEL_FONT_SIZE_PX,
836
864
  'font-weight': 700,
837
865
  fill: labelTextColor,
@@ -846,6 +874,7 @@ function renderItem(
846
874
  options: RenderOptions,
847
875
  idPrefix: string,
848
876
  palette: Theme,
877
+ fonts: FontFamilies,
849
878
  ): string {
850
879
  const parts: string[] = [];
851
880
  const shadow = shadowFilterUrl(idPrefix, i.style.shadow);
@@ -949,7 +978,7 @@ function renderItem(
949
978
  {
950
979
  x: num(captionX),
951
980
  y: num(i.box.y + ITEM_CAPTION_TITLE_BASELINE_OFFSET_PX),
952
- 'font-family': FONT_STACK[i.style.font],
981
+ 'font-family': fonts[i.style.font],
953
982
  'font-size': ITEM_CAPTION_TITLE_FONT_SIZE_PX,
954
983
  'font-weight': 600,
955
984
  fill: titleColor,
@@ -973,7 +1002,7 @@ function renderItem(
973
1002
  x: captionX,
974
1003
  baselineY: i.box.y + ITEM_CAPTION_META_BASELINE_OFFSET_PX,
975
1004
  fontSize: ITEM_CAPTION_META_FONT_SIZE_PX,
976
- fontFamily: FONT_STACK[i.style.font],
1005
+ fontFamily: fonts[i.style.font],
977
1006
  color: metaColor,
978
1007
  }),
979
1008
  );
@@ -1004,7 +1033,7 @@ function renderItem(
1004
1033
  {
1005
1034
  x: num(fx),
1006
1035
  y: num(footnoteY),
1007
- 'font-family': FONT_STACK.sans,
1036
+ 'font-family': fonts.sans,
1008
1037
  'font-size': 10,
1009
1038
  'font-weight': 700,
1010
1039
  fill: captionOutsideTextColor,
@@ -1023,7 +1052,7 @@ function renderItem(
1023
1052
  {
1024
1053
  x: num(fx),
1025
1054
  y: num(footnoteY),
1026
- 'font-family': FONT_STACK.sans,
1055
+ 'font-family': fonts.sans,
1027
1056
  'font-size': 10,
1028
1057
  'font-weight': 700,
1029
1058
  fill: i.style.text,
@@ -1108,7 +1137,7 @@ function renderItem(
1108
1137
  {
1109
1138
  x: num(i.overflowBox.x + i.overflowBox.width / 2),
1110
1139
  y: num(i.overflowBox.y + i.overflowBox.height / 2 + 3),
1111
- 'font-family': FONT_STACK.sans,
1140
+ 'font-family': fonts.sans,
1112
1141
  'font-size': 9,
1113
1142
  'font-weight': 700,
1114
1143
  fill: captionColor,
@@ -1140,7 +1169,7 @@ function renderItem(
1140
1169
  {
1141
1170
  x: num(chip.box.x + chip.box.width / 2),
1142
1171
  y: num(chip.box.y + chip.box.height / 2 + 3),
1143
- ...fontAttrs(chip.style, TEXT_SIZE_PX.xs),
1172
+ ...fontAttrs(chip.style, fonts, TEXT_SIZE_PX.xs),
1144
1173
  'text-anchor': 'middle',
1145
1174
  },
1146
1175
  chip.text,
@@ -1155,6 +1184,7 @@ function renderGroup(
1155
1184
  options: RenderOptions,
1156
1185
  idPrefix: string,
1157
1186
  palette: Theme,
1187
+ fonts: FontFamilies,
1158
1188
  ): string {
1159
1189
  const parts: string[] = [];
1160
1190
  const hasFill = g.style.bg !== 'none' && g.style.bg !== '#ffffff';
@@ -1211,7 +1241,7 @@ function renderGroup(
1211
1241
  {
1212
1242
  x: num(tabX + GROUP_TITLE_TAB_PAD_X_PX),
1213
1243
  y: num(tabY + GROUP_TITLE_TAB_LABEL_BASELINE_OFFSET_PX),
1214
- 'font-family': FONT_STACK[g.style.font],
1244
+ 'font-family': fonts[g.style.font],
1215
1245
  'font-size': GROUP_TITLE_TAB_LABEL_FONT_SIZE_PX,
1216
1246
  'font-weight': 600,
1217
1247
  fill: '#ffffff',
@@ -1259,7 +1289,7 @@ function renderGroup(
1259
1289
  {
1260
1290
  x: num(g.box.x + 6),
1261
1291
  y: num(g.box.y - 2),
1262
- ...fontAttrs(g.style, TEXT_SIZE_PX.xs),
1292
+ ...fontAttrs(g.style, fonts, TEXT_SIZE_PX.xs),
1263
1293
  'fill-opacity': 0.7,
1264
1294
  },
1265
1295
  g.title,
@@ -1273,7 +1303,7 @@ function renderGroup(
1273
1303
  // bounding box.
1274
1304
  parts.push(renderInlineDatePins(g.inlineDatePins, g.style.fg));
1275
1305
  for (const c of g.children) {
1276
- parts.push(renderTrackChild(c, options, idPrefix, palette));
1306
+ parts.push(renderTrackChild(c, options, idPrefix, palette, fonts));
1277
1307
  }
1278
1308
  void palette;
1279
1309
  return tag('g', { 'data-layer': 'group', 'data-id': g.id ?? null }, parts.join(''));
@@ -1284,6 +1314,7 @@ function renderParallel(
1284
1314
  options: RenderOptions,
1285
1315
  idPrefix: string,
1286
1316
  palette: Theme,
1317
+ fonts: FontFamilies,
1287
1318
  ): string {
1288
1319
  const parts: string[] = [];
1289
1320
  // `bracket: solid|dashed` parallels render explicit [ ] brackets framing
@@ -1323,7 +1354,7 @@ function renderParallel(
1323
1354
  {
1324
1355
  x: num(p.box.x + 4),
1325
1356
  y: num(p.box.y - 2),
1326
- ...fontAttrs(p.style, TEXT_SIZE_PX.xs),
1357
+ ...fontAttrs(p.style, fonts, TEXT_SIZE_PX.xs),
1327
1358
  'fill-opacity': 0.7,
1328
1359
  },
1329
1360
  p.title,
@@ -1334,7 +1365,7 @@ function renderParallel(
1334
1365
  // `before:DATE`). Painted before children so child bars sit on top.
1335
1366
  parts.push(renderInlineDatePins(p.inlineDatePins, p.style.fg));
1336
1367
  for (const c of p.children) {
1337
- parts.push(renderTrackChild(c, options, idPrefix, palette));
1368
+ parts.push(renderTrackChild(c, options, idPrefix, palette, fonts));
1338
1369
  }
1339
1370
  return tag('g', { 'data-layer': 'parallel', 'data-id': p.id ?? null }, parts.join(''));
1340
1371
  }
@@ -1344,10 +1375,11 @@ function renderTrackChild(
1344
1375
  options: RenderOptions,
1345
1376
  idPrefix: string,
1346
1377
  palette: Theme,
1378
+ fonts: FontFamilies,
1347
1379
  ): string {
1348
- if (c.kind === 'item') return renderItem(c, options, idPrefix, palette);
1349
- if (c.kind === 'group') return renderGroup(c, options, idPrefix, palette);
1350
- return renderParallel(c, options, idPrefix, palette);
1380
+ if (c.kind === 'item') return renderItem(c, options, idPrefix, palette, fonts);
1381
+ if (c.kind === 'group') return renderGroup(c, options, idPrefix, palette, fonts);
1382
+ return renderParallel(c, options, idPrefix, palette, fonts);
1351
1383
  }
1352
1384
 
1353
1385
  // Renders only the swimlane's background tint rect. Emitted before the
@@ -1425,6 +1457,7 @@ function renderSwimlane(
1425
1457
  options: RenderOptions,
1426
1458
  idPrefix: string,
1427
1459
  palette: Theme,
1460
+ fonts: FontFamilies,
1428
1461
  ): string {
1429
1462
  const tabFill = palette.swimlane.tabFill;
1430
1463
  const tabStroke = palette.swimlane.tabStroke;
@@ -1481,7 +1514,7 @@ function renderSwimlane(
1481
1514
  {
1482
1515
  x: num(tab.titleX),
1483
1516
  y: num(labelY),
1484
- 'font-family': FONT_STACK[s.style.font],
1517
+ 'font-family': fonts[s.style.font],
1485
1518
  'font-size': 12,
1486
1519
  'font-weight': 600,
1487
1520
  fill: tabText,
@@ -1495,7 +1528,7 @@ function renderSwimlane(
1495
1528
  {
1496
1529
  x: num(tab.ownerX),
1497
1530
  y: num(labelY),
1498
- 'font-family': FONT_STACK[s.style.font],
1531
+ 'font-family': fonts[s.style.font],
1499
1532
  'font-size': 10,
1500
1533
  fill: ownerText,
1501
1534
  },
@@ -1515,7 +1548,7 @@ function renderSwimlane(
1515
1548
  tab.badgeX,
1516
1549
  labelY,
1517
1550
  LANE_BADGE_FONT_SIZE_PX,
1518
- FONT_STACK[s.style.font],
1551
+ fonts[s.style.font],
1519
1552
  ownerText,
1520
1553
  ),
1521
1554
  );
@@ -1526,7 +1559,7 @@ function renderSwimlane(
1526
1559
  {
1527
1560
  x: num(tab.footnoteRightX),
1528
1561
  y: num(tabY + 14),
1529
- 'font-family': FONT_STACK.sans,
1562
+ 'font-family': fonts.sans,
1530
1563
  'font-size': LANE_BADGE_FONT_SIZE_PX,
1531
1564
  'font-weight': 700,
1532
1565
  fill: footnoteColor,
@@ -1538,7 +1571,7 @@ function renderSwimlane(
1538
1571
  }
1539
1572
  }
1540
1573
  for (const c of s.children) {
1541
- parts.push(renderTrackChild(c, options, idPrefix, palette));
1574
+ parts.push(renderTrackChild(c, options, idPrefix, palette, fonts));
1542
1575
  }
1543
1576
  // m13: tri-state utilization underline along the band's bottom edge.
1544
1577
  // Painted after items so it overlays any item that happens to extend
@@ -1548,7 +1581,7 @@ function renderSwimlane(
1548
1581
  return tag('g', { 'data-layer': 'swimlane', 'data-id': s.id ?? null }, parts.join(''));
1549
1582
  }
1550
1583
 
1551
- function renderAnchor(a: PositionedAnchor, palette: Theme): string {
1584
+ function renderAnchor(a: PositionedAnchor, palette: Theme, fonts: FontFamilies): string {
1552
1585
  const size = a.radius;
1553
1586
  const cx = a.center.x;
1554
1587
  const cy = a.center.y;
@@ -1573,7 +1606,7 @@ function renderAnchor(a: PositionedAnchor, palette: Theme): string {
1573
1606
  const labelAttrs: Record<string, string | number | null | undefined> = {
1574
1607
  x: num(labelX),
1575
1608
  y: num(cy + 4),
1576
- 'font-family': FONT_STACK.sans,
1609
+ 'font-family': fonts.sans,
1577
1610
  'font-size': 10,
1578
1611
  fill: labelColor,
1579
1612
  };
@@ -1595,7 +1628,7 @@ function renderAnchorCutLine(a: PositionedAnchor, palette: Theme): string {
1595
1628
  });
1596
1629
  }
1597
1630
 
1598
- function renderMilestone(m: PositionedMilestone, palette: Theme): string {
1631
+ function renderMilestone(m: PositionedMilestone, palette: Theme, fonts: FontFamilies): string {
1599
1632
  const cx = m.center.x;
1600
1633
  const cy = m.center.y;
1601
1634
  const r = m.radius;
@@ -1614,7 +1647,7 @@ function renderMilestone(m: PositionedMilestone, palette: Theme): string {
1614
1647
  const labelAttrs: Record<string, string | number | null | undefined> = {
1615
1648
  x: num(labelX),
1616
1649
  y: num(cy + 4),
1617
- 'font-family': FONT_STACK.sans,
1650
+ 'font-family': fonts.sans,
1618
1651
  'font-size': 10,
1619
1652
  'font-weight': 600,
1620
1653
  fill: labelColor,
@@ -1715,7 +1748,12 @@ function roundedOrthogonalPath(points: Point[], radius: number): string {
1715
1748
  return parts.join(' ');
1716
1749
  }
1717
1750
 
1718
- function renderFootnotes(f: PositionedFootnoteArea, idPrefix: string, palette: Theme): string {
1751
+ function renderFootnotes(
1752
+ f: PositionedFootnoteArea,
1753
+ idPrefix: string,
1754
+ palette: Theme,
1755
+ fonts: FontFamilies,
1756
+ ): string {
1719
1757
  if (f.entries.length === 0) return '';
1720
1758
  const panelFill = palette.footnotePanel.fill;
1721
1759
  const borderColor = palette.footnotePanel.border;
@@ -1743,7 +1781,7 @@ function renderFootnotes(f: PositionedFootnoteArea, idPrefix: string, palette: T
1743
1781
  {
1744
1782
  x: num(f.box.x + FOOTNOTE_PANEL_PADDING_PX),
1745
1783
  y: num(f.box.y + FOOTNOTE_HEADER_BASELINE_OFFSET_PX),
1746
- 'font-family': FONT_STACK.sans,
1784
+ 'font-family': fonts.sans,
1747
1785
  'font-size': 12,
1748
1786
  'font-weight': 700,
1749
1787
  fill: headerColor,
@@ -1763,7 +1801,7 @@ function renderFootnotes(f: PositionedFootnoteArea, idPrefix: string, palette: T
1763
1801
  {
1764
1802
  x: num(numberX),
1765
1803
  y: num(y),
1766
- 'font-family': FONT_STACK.sans,
1804
+ 'font-family': fonts.sans,
1767
1805
  'font-size': 10,
1768
1806
  'font-weight': 700,
1769
1807
  fill: numberColor,
@@ -1776,7 +1814,7 @@ function renderFootnotes(f: PositionedFootnoteArea, idPrefix: string, palette: T
1776
1814
  {
1777
1815
  x: num(titleX),
1778
1816
  y: num(y),
1779
- 'font-family': FONT_STACK.sans,
1817
+ 'font-family': fonts.sans,
1780
1818
  'font-size': 11,
1781
1819
  'font-weight': 600,
1782
1820
  fill: titleColor,
@@ -1790,7 +1828,7 @@ function renderFootnotes(f: PositionedFootnoteArea, idPrefix: string, palette: T
1790
1828
  {
1791
1829
  x: num(titleX + Math.max(120, e.title.length * 6)),
1792
1830
  y: num(y),
1793
- 'font-family': FONT_STACK.sans,
1831
+ 'font-family': fonts.sans,
1794
1832
  'font-size': 11,
1795
1833
  fill: descColor,
1796
1834
  },
@@ -1807,6 +1845,7 @@ function renderIncludeRegion(
1807
1845
  options: RenderOptions,
1808
1846
  idPrefix: string,
1809
1847
  palette: Theme,
1848
+ fonts: FontFamilies,
1810
1849
  ): string {
1811
1850
  const border = palette.includeRegion.border;
1812
1851
  const fill = palette.includeRegion.fill;
@@ -1857,7 +1896,7 @@ function renderIncludeRegion(
1857
1896
  {
1858
1897
  x: num(chrome.tabLabelX),
1859
1898
  y: num(tabY + FRAME_TAB_LABEL_BASELINE_OFFSET_PX),
1860
- 'font-family': FONT_STACK.sans,
1899
+ 'font-family': fonts.sans,
1861
1900
  'font-size': 11,
1862
1901
  'font-weight': 600,
1863
1902
  fill: tabText,
@@ -1915,7 +1954,7 @@ function renderIncludeRegion(
1915
1954
  {
1916
1955
  x: num(chrome.sourceTextX),
1917
1956
  y: num(sourceTextY),
1918
- 'font-family': FONT_STACK.mono,
1957
+ 'font-family': fonts.mono,
1919
1958
  'font-size': sourceFontSize,
1920
1959
  fill: badgeText,
1921
1960
  },
@@ -1924,7 +1963,7 @@ function renderIncludeRegion(
1924
1963
 
1925
1964
  // Nested swimlanes (laid out by buildIncludeRegions against the parent's timeline).
1926
1965
  const nested = r.nestedSwimlanes
1927
- .map((s) => renderSwimlane(s, options, idPrefix, palette))
1966
+ .map((s) => renderSwimlane(s, options, idPrefix, palette, fonts))
1928
1967
  .join('');
1929
1968
 
1930
1969
  return tag(
@@ -1940,7 +1979,7 @@ function renderIncludeRegion(
1940
1979
  // entire string is clickable. Glyph anatomy (positions, widths, scale)
1941
1980
  // lives in `themes/shared.ts` (`ATTRIBUTION_*`); the layout reserves a
1942
1981
  // box of exactly that size at canvas-bottom-right.
1943
- function renderAttributionMark(model: PositionedRoadmap): string {
1982
+ function renderAttributionMark(model: PositionedRoadmap, fonts: FontFamilies): string {
1944
1983
  const muted = model.palette.attribution.mark;
1945
1984
  const accent = model.palette.attribution.link;
1946
1985
  if (model.swimlanes.length === 0) return '';
@@ -1955,7 +1994,7 @@ function renderAttributionMark(model: PositionedRoadmap): string {
1955
1994
  {
1956
1995
  x: '0',
1957
1996
  y: baselineY,
1958
- 'font-family': FONT_STACK.sans,
1997
+ 'font-family': fonts.sans,
1959
1998
  'font-size': ATTRIBUTION_PREFIX_FONT_SIZE,
1960
1999
  'font-weight': 400,
1961
2000
  fill: muted,
@@ -1966,7 +2005,7 @@ function renderAttributionMark(model: PositionedRoadmap): string {
1966
2005
  {
1967
2006
  x: ATTRIBUTION_NOW_LOGICAL_X,
1968
2007
  y: baselineY,
1969
- 'font-family': FONT_STACK.sans,
2008
+ 'font-family': fonts.sans,
1970
2009
  'font-size': ATTRIBUTION_WORDMARK_FONT_SIZE,
1971
2010
  'font-weight': 700,
1972
2011
  fill: muted,
@@ -1984,7 +2023,7 @@ function renderAttributionMark(model: PositionedRoadmap): string {
1984
2023
  {
1985
2024
  x: ATTRIBUTION_INE_LOGICAL_X,
1986
2025
  y: baselineY,
1987
- 'font-family': FONT_STACK.sans,
2026
+ 'font-family': fonts.sans,
1988
2027
  'font-size': ATTRIBUTION_WORDMARK_FONT_SIZE,
1989
2028
  'font-weight': 400,
1990
2029
  fill: muted,
@@ -2078,6 +2117,10 @@ export async function renderSvg(
2078
2117
  const idPrefix = ids.next('root');
2079
2118
 
2080
2119
  const palette = model.palette;
2120
+ // Per-role family strings stamped onto every <text>. Defaults to the
2121
+ // portable FONT_STACK; raster/preview callers pass a pinned bundled
2122
+ // family so the SVG names exactly the font the consumer provides.
2123
+ const fonts: FontFamilies = options.fontFamilies ?? FONT_STACK;
2081
2124
  const parts: string[] = [];
2082
2125
 
2083
2126
  // <defs> — shadows + arrowhead markers (palette-driven fills baked in).
@@ -2110,7 +2153,7 @@ export async function renderSvg(
2110
2153
  // swimlane background rects emitted later — so the major dotted
2111
2154
  // and minor grid lines never actually rendered in the chart body.
2112
2155
  // They now ship as their own layer below.
2113
- parts.push(renderTimeline(model.timeline, palette));
2156
+ parts.push(renderTimeline(model.timeline, palette, fonts));
2114
2157
 
2115
2158
  // Swimlane backgrounds — emitted as their own pass so the grid
2116
2159
  // lines can be drawn on top of them, then the swimlane content
@@ -2136,11 +2179,13 @@ export async function renderSvg(
2136
2179
  }
2137
2180
 
2138
2181
  // Swimlane content (frame tabs + items) on top of the grid lines.
2139
- for (const s of model.swimlanes) parts.push(renderSwimlane(s, options, idPrefix, palette));
2182
+ for (const s of model.swimlanes)
2183
+ parts.push(renderSwimlane(s, options, idPrefix, palette, fonts));
2140
2184
 
2141
2185
  // Include regions (drawn after own swimlanes so the dashed border + tab
2142
2186
  // overlay the chart, with their own nested swimlanes inside).
2143
- for (const r of model.includes) parts.push(renderIncludeRegion(r, options, idPrefix, palette));
2187
+ for (const r of model.includes)
2188
+ parts.push(renderIncludeRegion(r, options, idPrefix, palette, fonts));
2144
2189
 
2145
2190
  // Normal / overflow dependency edges on top of items but below
2146
2191
  // cut-lines / nowline. Under-bar edges already painted above.
@@ -2154,16 +2199,16 @@ export async function renderSvg(
2154
2199
  for (const m of model.milestones) parts.push(renderMilestoneCutLine(m, palette));
2155
2200
 
2156
2201
  // Marker-row diamonds + labels.
2157
- for (const a of model.anchors) parts.push(renderAnchor(a, palette));
2158
- for (const m of model.milestones) parts.push(renderMilestone(m, palette));
2202
+ for (const a of model.anchors) parts.push(renderAnchor(a, palette, fonts));
2203
+ for (const m of model.milestones) parts.push(renderMilestone(m, palette, fonts));
2159
2204
 
2160
2205
  // Now-line
2161
- parts.push(renderNowline(model.nowline, palette));
2206
+ parts.push(renderNowline(model.nowline, palette, fonts));
2162
2207
 
2163
2208
  // Footnotes + header last (always on top)
2164
- parts.push(renderFootnotes(model.footnotes, idPrefix, palette));
2165
- parts.push(renderHeader(model.header, idPrefix, palette));
2166
- parts.push(renderAttributionMark(model));
2209
+ parts.push(renderFootnotes(model.footnotes, idPrefix, palette, fonts));
2210
+ parts.push(renderHeader(model.header, idPrefix, palette, fonts));
2211
+ parts.push(renderAttributionMark(model, fonts));
2167
2212
 
2168
2213
  // Logo (if header carries one)
2169
2214
  if (model.header.logo && options.assetResolver) {