@nowline/renderer 0.8.6 → 0.10.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.
Files changed (86) hide show
  1. package/dist/image-size.d.ts +9 -0
  2. package/dist/image-size.d.ts.map +1 -0
  3. package/dist/image-size.js +104 -0
  4. package/dist/image-size.js.map +1 -0
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +4 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/logo.d.ts +24 -0
  10. package/dist/logo.d.ts.map +1 -0
  11. package/dist/logo.js +60 -0
  12. package/dist/logo.js.map +1 -0
  13. package/dist/stage.d.ts +78 -0
  14. package/dist/stage.d.ts.map +1 -0
  15. package/dist/stage.js +88 -0
  16. package/dist/stage.js.map +1 -0
  17. package/dist/svg/edges.d.ts +3 -0
  18. package/dist/svg/edges.d.ts.map +1 -0
  19. package/dist/svg/edges.js +144 -0
  20. package/dist/svg/edges.js.map +1 -0
  21. package/dist/svg/footnotes.d.ts +4 -0
  22. package/dist/svg/footnotes.d.ts.map +1 -0
  23. package/dist/svg/footnotes.js +81 -0
  24. package/dist/svg/footnotes.js.map +1 -0
  25. package/dist/svg/header.d.ts +6 -0
  26. package/dist/svg/header.d.ts.map +1 -0
  27. package/dist/svg/header.js +215 -0
  28. package/dist/svg/header.js.map +1 -0
  29. package/dist/svg/include-region.d.ts +4 -0
  30. package/dist/svg/include-region.d.ts.map +1 -0
  31. package/dist/svg/include-region.js +157 -0
  32. package/dist/svg/include-region.js.map +1 -0
  33. package/dist/svg/item.d.ts +36 -0
  34. package/dist/svg/item.d.ts.map +1 -0
  35. package/dist/svg/item.js +549 -0
  36. package/dist/svg/item.js.map +1 -0
  37. package/dist/svg/markers.d.ts +7 -0
  38. package/dist/svg/markers.d.ts.map +1 -0
  39. package/dist/svg/markers.js +151 -0
  40. package/dist/svg/markers.js.map +1 -0
  41. package/dist/svg/nowline.d.ts +4 -0
  42. package/dist/svg/nowline.d.ts.map +1 -0
  43. package/dist/svg/nowline.js +133 -0
  44. package/dist/svg/nowline.js.map +1 -0
  45. package/dist/svg/primitives.d.ts +7 -0
  46. package/dist/svg/primitives.d.ts.map +1 -0
  47. package/dist/svg/primitives.js +70 -0
  48. package/dist/svg/primitives.js.map +1 -0
  49. package/dist/svg/render.d.ts +13 -7
  50. package/dist/svg/render.d.ts.map +1 -1
  51. package/dist/svg/render.js +64 -1701
  52. package/dist/svg/render.js.map +1 -1
  53. package/dist/svg/sanitize.d.ts +10 -0
  54. package/dist/svg/sanitize.d.ts.map +1 -1
  55. package/dist/svg/sanitize.js +264 -16
  56. package/dist/svg/sanitize.js.map +1 -1
  57. package/dist/svg/swimlanes.d.ts +5 -0
  58. package/dist/svg/swimlanes.d.ts.map +1 -0
  59. package/dist/svg/swimlanes.js +228 -0
  60. package/dist/svg/swimlanes.js.map +1 -0
  61. package/dist/svg/timeline.d.ts +24 -0
  62. package/dist/svg/timeline.d.ts.map +1 -0
  63. package/dist/svg/timeline.js +284 -0
  64. package/dist/svg/timeline.js.map +1 -0
  65. package/dist/svg/waves.d.ts +62 -0
  66. package/dist/svg/waves.d.ts.map +1 -0
  67. package/dist/svg/waves.js +397 -0
  68. package/dist/svg/waves.js.map +1 -0
  69. package/package.json +6 -6
  70. package/src/image-size.ts +113 -0
  71. package/src/index.ts +10 -1
  72. package/src/logo.ts +82 -0
  73. package/src/stage.ts +191 -0
  74. package/src/svg/edges.ts +154 -0
  75. package/src/svg/footnotes.ts +115 -0
  76. package/src/svg/header.ts +285 -0
  77. package/src/svg/include-region.ts +201 -0
  78. package/src/svg/item.ts +672 -0
  79. package/src/svg/markers.ts +178 -0
  80. package/src/svg/nowline.ts +152 -0
  81. package/src/svg/primitives.ts +86 -0
  82. package/src/svg/render.ts +94 -2110
  83. package/src/svg/sanitize.ts +286 -16
  84. package/src/svg/swimlanes.ts +317 -0
  85. package/src/svg/timeline.ts +343 -0
  86. package/src/svg/waves.ts +502 -0
package/src/stage.ts ADDED
@@ -0,0 +1,191 @@
1
+ // One staging function for every render surface (specs/surface-options.md
2
+ // §3.3): parse and validate, resolve includes, apply the include-error
3
+ // precedence, load the logo, build the wave plan and the day schedule, lay
4
+ // out. Rendering, diagnostics formatting,
5
+ // `strict` and layout insights stay with each caller, which maps the result
6
+ // to its own shape.
7
+
8
+ import {
9
+ buildWavePlan,
10
+ collectDocumentDiagnostics,
11
+ isRoutedResolveDiagnostic,
12
+ type NowlineFile,
13
+ type NowlineServices,
14
+ type RawDiagnostic,
15
+ type ResolveDiagnostic,
16
+ type ResolveResult,
17
+ resolveIncludes,
18
+ type WavePlan,
19
+ } from '@nowline/core';
20
+ import {
21
+ type LayoutOptions,
22
+ layoutRoadmap,
23
+ type PositionedRoadmap,
24
+ type RoadmapSchedule,
25
+ roadmapLogoRef,
26
+ scheduleRoadmap,
27
+ } from '@nowline/layout';
28
+ import { type LangiumDocument, type LangiumSharedCoreServices, URI } from 'langium';
29
+ import { type LoadedLogo, loadLogo } from './logo.js';
30
+ import type { AssetResolver } from './svg/render.js';
31
+
32
+ /** The layout options a stage reads; a staged roadmap records the values it used. */
33
+ export type StageLayout = Pick<
34
+ LayoutOptions,
35
+ 'theme' | 'today' | 'width' | 'locale' | 'nonWorking'
36
+ >;
37
+
38
+ /** Langium's `DiagnosticSeverity.Error`. */
39
+ const SEVERITY_ERROR = 1;
40
+
41
+ export interface StageInputs {
42
+ source: string;
43
+ /** Absolute path: base for includes and assets. */
44
+ sourcePath: string;
45
+ /** The caller's Langium services (each pipeline keeps its own instance). */
46
+ services: { shared: LangiumSharedCoreServices; Nowline: NowlineServices };
47
+ readSource: (absPath: string) => Promise<string>;
48
+ /** Omitted: no asset access (the embed), so a `logo:` lays out as if absent. */
49
+ assetResolver?: AssetResolver;
50
+ /** A document the caller already parsed and validated from `source`. */
51
+ document?: LangiumDocument<NowlineFile>;
52
+ layout: StageLayout;
53
+ /**
54
+ * An include error the caller tolerates, such as the browser's
55
+ * single-file mode skipping every include: it takes no part in the
56
+ * include-error precedence and stays in `resolved.diagnostics` for the
57
+ * caller to report. Omitted: every include error counts.
58
+ */
59
+ isToleratedIncludeError?: (diagnostic: ResolveDiagnostic) => boolean;
60
+ }
61
+
62
+ export type StageResult =
63
+ | {
64
+ kind: 'invalid';
65
+ document: LangiumDocument<NowlineFile>;
66
+ /** Lexer, parser and validator diagnostics with errors among them. */
67
+ diagnostics: RawDiagnostic[];
68
+ }
69
+ | {
70
+ kind: 'include-error';
71
+ document: LangiumDocument<NowlineFile>;
72
+ /** The first uncoded include error, when one exists: it wins (waves.md §6.1). */
73
+ legacy?: ResolveDiagnostic;
74
+ /** Routed (wave-rule) diagnostics, errors and warnings; empty when `legacy` is set. */
75
+ routed: ResolveDiagnostic[];
76
+ /** Every resolver diagnostic in order, for callers that report more than the winner. */
77
+ resolved: ResolveResult;
78
+ }
79
+ | {
80
+ kind: 'staged';
81
+ document: LangiumDocument<NowlineFile>;
82
+ ast: NowlineFile;
83
+ resolved: ResolveResult;
84
+ /** Routed warnings (no errors). */
85
+ routed: ResolveDiagnostic[];
86
+ /** The loaded logo: size for layout, bytes for `RenderOptions.logo`. */
87
+ logo?: LoadedLogo;
88
+ /** Asset warnings (missing or unreadable logo); the caller applies `strict`. */
89
+ warnings: string[];
90
+ /** The wave plan (`buildWavePlan`); `null` when the roadmap declares no waves. */
91
+ plan: WavePlan | null;
92
+ /**
93
+ * The day schedule `model` was laid out with (`scheduleRoadmap`,
94
+ * `plan` included as `schedule.plan`); exporters reuse it through
95
+ * `ExportInputs.schedule` (specs/scheduler-unification.md §3.6).
96
+ */
97
+ schedule: RoadmapSchedule;
98
+ model: PositionedRoadmap;
99
+ /** The layout options `model` was laid out with, picked from `StageInputs.layout`. */
100
+ layout: StageLayout;
101
+ };
102
+
103
+ /** A roadmap that staged without errors, ready to render or export. */
104
+ export type StagedRoadmap = Extract<StageResult, { kind: 'staged' }>;
105
+
106
+ let documentCounter = 0;
107
+
108
+ function freshUri(): URI {
109
+ // A fresh URI per stage: reusing one lets Langium mutate the prior
110
+ // document in place.
111
+ return URI.parse(`memory:///stage-${++documentCounter}.nowline`);
112
+ }
113
+
114
+ async function buildDocument(inputs: StageInputs): Promise<LangiumDocument<NowlineFile>> {
115
+ if (inputs.document) return inputs.document;
116
+ const { workspace } = inputs.services.shared;
117
+ const document = workspace.LangiumDocumentFactory.fromString<NowlineFile>(
118
+ inputs.source,
119
+ freshUri(),
120
+ );
121
+ await workspace.DocumentBuilder.build([document], { validation: true });
122
+ return document;
123
+ }
124
+
125
+ function isErrorDiagnostic(raw: RawDiagnostic): boolean {
126
+ return raw.origin !== 'validation' || raw.diagnostic.severity === SEVERITY_ERROR;
127
+ }
128
+
129
+ /**
130
+ * Parse, validate, resolve includes, load the logo, schedule and lay out `source`.
131
+ * Returns `invalid` on a lexer, parser or validator error, `include-error`
132
+ * on a resolver error, and `staged` with the positioned model otherwise.
133
+ */
134
+ export async function stageRoadmap(inputs: StageInputs): Promise<StageResult> {
135
+ const document = await buildDocument(inputs);
136
+ const diagnostics = collectDocumentDiagnostics(document);
137
+ if (diagnostics.some(isErrorDiagnostic)) {
138
+ return { kind: 'invalid', document, diagnostics };
139
+ }
140
+
141
+ const ast = document.parseResult.value;
142
+ const resolved = await resolveIncludes(ast, inputs.sourcePath, {
143
+ services: inputs.services.Nowline,
144
+ readFile: inputs.readSource,
145
+ });
146
+
147
+ // An uncoded include error is more fundamental than the wave-rule
148
+ // errors: the first one wins and hides the routed diagnostics.
149
+ const isTolerated = inputs.isToleratedIncludeError ?? (() => false);
150
+ const errors = resolved.diagnostics.filter((d) => d.severity === 'error' && !isTolerated(d));
151
+ const routed = resolved.diagnostics.filter(isRoutedResolveDiagnostic);
152
+ const legacy = errors.find((d) => !isRoutedResolveDiagnostic(d));
153
+ if (legacy) {
154
+ return { kind: 'include-error', document, legacy, routed: [], resolved };
155
+ }
156
+ if (errors.length > 0) {
157
+ return { kind: 'include-error', document, routed, resolved };
158
+ }
159
+
160
+ // A failed logo load warns and lays out as if no logo were declared.
161
+ const warnings: string[] = [];
162
+ const logo = await loadLogo(roadmapLogoRef(ast), inputs.assetResolver, (message) =>
163
+ warnings.push(message),
164
+ );
165
+ // Named keys, not a spread: a caller may pass a wider options object.
166
+ const { theme, today, width, locale, nonWorking } = inputs.layout;
167
+ const layout: StageLayout = { theme, today, width, locale, nonWorking };
168
+ // One wave plan and one day schedule per staging: layout reuses the
169
+ // schedule and the exporters read it from `ExportInputs`
170
+ // (specs/scheduler-unification.md §3.6).
171
+ const plan = buildWavePlan(resolved) ?? null;
172
+ const schedule = scheduleRoadmap(ast, resolved, { today, plan });
173
+ const model = layoutRoadmap(ast, resolved, {
174
+ ...layout,
175
+ schedule,
176
+ logo: logo?.dimensions,
177
+ });
178
+ return {
179
+ kind: 'staged',
180
+ document,
181
+ ast,
182
+ resolved,
183
+ routed,
184
+ logo,
185
+ warnings,
186
+ plan,
187
+ schedule,
188
+ model,
189
+ layout,
190
+ };
191
+ }
@@ -0,0 +1,154 @@
1
+ // The dependency-edge layer: the arrows that join one item to another, their
2
+ // solid and dotted slack legs, and the rounded orthogonal paths they follow.
3
+
4
+ import type { Point, PositionedDependencyEdge, Theme } from '@nowline/layout';
5
+ import { ARROW_HEAD_LENGTH_PX, ARROW_HEAD_WIDTH_PX, EDGE_CORNER_RADIUS } from '@nowline/layout';
6
+ import { num, tag } from './xml.js';
7
+
8
+ /** Dash pattern of a dependency's slack leg: the dotted look of milestone slack lines. */
9
+ const SLACK_DASH = '3 3';
10
+
11
+ // A dependency arrow (specs/true-width-layout.md §3.4). It paints behind
12
+ // the bars (see `renderRoadmap`), so a leg that crosses a bar passes behind
13
+ // it. Vertical legs are solid; the horizontal leg is dotted when the target
14
+ // waits. The head is drawn, not a marker, so it can shrink to a narrow
15
+ // target and become a dot (§8 O6).
16
+ export function renderEdge(e: PositionedDependencyEdge, palette: Theme): string {
17
+ const points = e.waypoints;
18
+ if (points.length < 2) return '';
19
+ const color = palette.dependency.edgeStroke;
20
+ const tip = points[points.length - 1];
21
+ const prev = points[points.length - 2];
22
+ const len = Math.hypot(tip.x - prev.x, tip.y - prev.y);
23
+ if (len === 0) return '';
24
+ const ux = (tip.x - prev.x) / len;
25
+ const uy = (tip.y - prev.y) / len;
26
+ const half = e.headWidth / 2;
27
+ // The leg stops where the head starts, so a dotted leg never shows
28
+ // through it.
29
+ const headLength =
30
+ e.head === 'dot' ? half : (e.headWidth * ARROW_HEAD_LENGTH_PX) / ARROW_HEAD_WIDTH_PX;
31
+ // A last leg no longer than the head is all head.
32
+ const leg =
33
+ len > headLength
34
+ ? [...points.slice(0, -1), { x: tip.x - ux * headLength, y: tip.y - uy * headLength }]
35
+ : points.slice(0, -1);
36
+ const parts = [legPaths(leg, e.slack, color)];
37
+ const headFill = palette.arrowhead.neutral;
38
+ if (e.head === 'dot') {
39
+ parts.push(
40
+ tag('circle', {
41
+ cx: num(tip.x - ux * half),
42
+ cy: num(tip.y - uy * half),
43
+ r: num(half),
44
+ fill: headFill,
45
+ }),
46
+ );
47
+ } else {
48
+ const bx = tip.x - ux * headLength;
49
+ const by = tip.y - uy * headLength;
50
+ parts.push(
51
+ tag('path', {
52
+ d:
53
+ `M${num(bx - uy * half)} ${num(by + ux * half)} ` +
54
+ `L${num(tip.x)} ${num(tip.y)} ` +
55
+ `L${num(bx + uy * half)} ${num(by - ux * half)} z`,
56
+ fill: headFill,
57
+ }),
58
+ );
59
+ }
60
+ return tag('g', { 'data-layer': 'dependency' }, parts.join(''));
61
+ }
62
+
63
+ // An arrow's legs: one rounded path, or for a slack arrow its solid
64
+ // vertical legs and its dotted horizontal ones. Empty when the head is all
65
+ // there is (a leg no longer than the head).
66
+ function legPaths(leg: Point[], slack: boolean, color: string): string {
67
+ if (leg.length < 2) return '';
68
+ const stroke = { fill: 'none', stroke: color, 'stroke-width': 1.1, 'stroke-linejoin': 'round' };
69
+ if (!slack)
70
+ return tag('path', { d: roundedOrthogonalPath(leg, EDGE_CORNER_RADIUS), ...stroke });
71
+ const { solid, dotted } = splitSlackPath(leg, EDGE_CORNER_RADIUS);
72
+ return (
73
+ (solid ? tag('path', { d: solid, ...stroke }) : '') +
74
+ (dotted
75
+ ? tag('path', {
76
+ d: dotted,
77
+ ...stroke,
78
+ 'stroke-dasharray': SLACK_DASH,
79
+ 'stroke-linecap': 'round',
80
+ })
81
+ : '')
82
+ );
83
+ }
84
+
85
+ // A slack arrow's path in two parts: its vertical legs with the rounded
86
+ // corners (solid) and its horizontal legs (dotted), so the dash pattern
87
+ // marks the wait alone.
88
+ function splitSlackPath(points: Point[], radius: number): { solid: string; dotted: string } {
89
+ const n = points.length;
90
+ const before: Point[] = [];
91
+ const after: Point[] = [];
92
+ const solid: string[] = [];
93
+ for (let i = 1; i < n - 1; i++) {
94
+ const prev = points[i - 1];
95
+ const cur = points[i];
96
+ const next = points[i + 1];
97
+ const inLen = Math.hypot(cur.x - prev.x, cur.y - prev.y);
98
+ const outLen = Math.hypot(next.x - cur.x, next.y - cur.y);
99
+ const r = Math.min(radius, inLen / 2, outLen / 2);
100
+ const dIn = { x: Math.sign(cur.x - prev.x), y: Math.sign(cur.y - prev.y) };
101
+ const dOut = { x: Math.sign(next.x - cur.x), y: Math.sign(next.y - cur.y) };
102
+ before[i] = { x: cur.x - dIn.x * r, y: cur.y - dIn.y * r };
103
+ after[i] = { x: cur.x + dOut.x * r, y: cur.y + dOut.y * r };
104
+ if (r > 0) {
105
+ solid.push(
106
+ `M${num(before[i].x)} ${num(before[i].y)} ` +
107
+ `Q${num(cur.x)} ${num(cur.y)} ${num(after[i].x)} ${num(after[i].y)}`,
108
+ );
109
+ }
110
+ }
111
+ const dotted: string[] = [];
112
+ for (let i = 0; i < n - 1; i++) {
113
+ const a = i === 0 ? points[0] : after[i];
114
+ const b = i === n - 2 ? points[n - 1] : before[i + 1];
115
+ const piece = `M${num(a.x)} ${num(a.y)} L${num(b.x)} ${num(b.y)}`;
116
+ if (Math.abs(points[i + 1].y - points[i].y) < 1e-9) dotted.push(piece);
117
+ else solid.push(piece);
118
+ }
119
+ return { solid: solid.join(' '), dotted: dotted.join(' ') };
120
+ }
121
+
122
+ // Build an SVG path for a sequence of orthogonal waypoints, inserting a
123
+ // quarter-arc at every interior bend. Falls back to straight segments when
124
+ // adjacent points aren't axis-aligned (defensive — the layout always emits
125
+ // orthogonal segments).
126
+ function roundedOrthogonalPath(points: Point[], radius: number): string {
127
+ if (points.length < 2) return '';
128
+ const parts: string[] = [`M${num(points[0].x)} ${num(points[0].y)}`];
129
+ for (let i = 1; i < points.length; i++) {
130
+ const prev = points[i - 1];
131
+ const cur = points[i];
132
+ if (i === points.length - 1) {
133
+ parts.push(`L${num(cur.x)} ${num(cur.y)}`);
134
+ continue;
135
+ }
136
+ const next = points[i + 1];
137
+ const dxIn = Math.sign(cur.x - prev.x);
138
+ const dyIn = Math.sign(cur.y - prev.y);
139
+ const dxOut = Math.sign(next.x - cur.x);
140
+ const dyOut = Math.sign(next.y - cur.y);
141
+ const inLen = Math.hypot(cur.x - prev.x, cur.y - prev.y);
142
+ const outLen = Math.hypot(next.x - cur.x, next.y - cur.y);
143
+ const r = Math.min(radius, inLen / 2, outLen / 2);
144
+ if (r <= 0 || (dxIn !== 0 && dxOut !== 0) || (dyIn !== 0 && dyOut !== 0)) {
145
+ parts.push(`L${num(cur.x)} ${num(cur.y)}`);
146
+ continue;
147
+ }
148
+ const beforeBend = { x: cur.x - dxIn * r, y: cur.y - dyIn * r };
149
+ const afterBend = { x: cur.x + dxOut * r, y: cur.y + dyOut * r };
150
+ parts.push(`L${num(beforeBend.x)} ${num(beforeBend.y)}`);
151
+ parts.push(`Q${num(cur.x)} ${num(cur.y)} ${num(afterBend.x)} ${num(afterBend.y)}`);
152
+ }
153
+ return parts.join(' ');
154
+ }
@@ -0,0 +1,115 @@
1
+ // The footnotes layer: the panel at the bottom of the chart that lists each
2
+ // footnote's number, title and description, drawn only when the roadmap has
3
+ // footnotes.
4
+
5
+ import type { PositionedFootnoteArea, Theme } from '@nowline/layout';
6
+ import { FOOTNOTE_HEADER_BASELINE_OFFSET_PX, FOOTNOTE_PANEL_PADDING_PX } from '@nowline/layout';
7
+ import type { FontFamilies } from './render.js';
8
+ import { num, tag, textTag } from './xml.js';
9
+
10
+ export function renderFootnotes(
11
+ f: PositionedFootnoteArea,
12
+ idPrefix: string,
13
+ palette: Theme,
14
+ fonts: FontFamilies,
15
+ ): string {
16
+ if (f.entries.length === 0) return '';
17
+ const panelFill = palette.footnotePanel.fill;
18
+ const borderColor = palette.footnotePanel.border;
19
+ const headerColor = palette.footnotePanel.header;
20
+ const titleColor = palette.footnotePanel.title;
21
+ const descColor = palette.footnotePanel.description;
22
+ const numberColor = palette.footnotePanel.number;
23
+ const parts: string[] = [];
24
+ parts.push(
25
+ tag('rect', {
26
+ x: num(f.box.x),
27
+ y: num(f.box.y),
28
+ width: num(f.box.width),
29
+ height: num(f.box.height),
30
+ rx: 6,
31
+ ry: 6,
32
+ fill: panelFill,
33
+ stroke: borderColor,
34
+ 'stroke-width': 1,
35
+ filter: `url(#${idPrefix}-shadow-subtle)`,
36
+ }),
37
+ );
38
+ parts.push(
39
+ textTag(
40
+ {
41
+ x: num(f.box.x + FOOTNOTE_PANEL_PADDING_PX),
42
+ y: num(f.box.y + FOOTNOTE_HEADER_BASELINE_OFFSET_PX),
43
+ 'font-family': fonts.sans,
44
+ 'font-size': 12,
45
+ 'font-weight': 700,
46
+ fill: headerColor,
47
+ },
48
+ 'Footnotes',
49
+ ),
50
+ );
51
+ // Layout places every row: the entry's number on its first row, the
52
+ // title and description lines (wrapped ones included) at their own
53
+ // baselines, and a region's heading row above its first entry.
54
+ const numberX = f.box.x + FOOTNOTE_PANEL_PADDING_PX;
55
+ for (const e of f.entries) {
56
+ if (e.region !== undefined && e.regionHeadingY !== undefined) {
57
+ parts.push(
58
+ textTag(
59
+ {
60
+ x: num(numberX),
61
+ y: num(e.regionHeadingY),
62
+ 'font-family': fonts.sans,
63
+ 'font-size': 10,
64
+ 'font-weight': 700,
65
+ fill: headerColor,
66
+ },
67
+ e.region,
68
+ ),
69
+ );
70
+ }
71
+ parts.push(
72
+ textTag(
73
+ {
74
+ x: num(numberX),
75
+ y: num(e.y),
76
+ 'font-family': fonts.sans,
77
+ 'font-size': 10,
78
+ 'font-weight': 700,
79
+ fill: numberColor,
80
+ },
81
+ String(e.number),
82
+ ),
83
+ );
84
+ for (const line of e.titleLines) {
85
+ parts.push(
86
+ textTag(
87
+ {
88
+ x: num(line.x),
89
+ y: num(line.y),
90
+ 'font-family': fonts.sans,
91
+ 'font-size': 11,
92
+ 'font-weight': 600,
93
+ fill: titleColor,
94
+ },
95
+ line.text,
96
+ ),
97
+ );
98
+ }
99
+ for (const line of e.descriptionLines ?? []) {
100
+ parts.push(
101
+ textTag(
102
+ {
103
+ x: num(line.x),
104
+ y: num(line.y),
105
+ 'font-family': fonts.sans,
106
+ 'font-size': 11,
107
+ fill: descColor,
108
+ },
109
+ line.text,
110
+ ),
111
+ );
112
+ }
113
+ }
114
+ return tag('g', { 'data-layer': 'footnotes' }, parts.join(''));
115
+ }