@twinfinity/printing 6.1.0-beta → 6.1.2-ci.30738-beta

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 (157) hide show
  1. package/dist/BuildInfo.js +3 -3
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +3 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/layout/analyzeTemplate.d.ts +4 -4
  7. package/dist/layout/analyzeTemplate.d.ts.map +1 -1
  8. package/dist/layout/analyzeTemplate.js +18 -16
  9. package/dist/layout/analyzeTemplate.js.map +1 -1
  10. package/dist/layout/classClosure.d.ts +41 -0
  11. package/dist/layout/classClosure.d.ts.map +1 -0
  12. package/dist/layout/classClosure.js +143 -0
  13. package/dist/layout/classClosure.js.map +1 -0
  14. package/dist/layout/clipUtils.d.ts +11 -0
  15. package/dist/layout/clipUtils.d.ts.map +1 -1
  16. package/dist/layout/clipUtils.js +67 -6
  17. package/dist/layout/clipUtils.js.map +1 -1
  18. package/dist/layout/filters.d.ts +14 -8
  19. package/dist/layout/filters.d.ts.map +1 -1
  20. package/dist/layout/filters.js +28 -3
  21. package/dist/layout/filters.js.map +1 -1
  22. package/dist/layout/index.d.ts +24 -57
  23. package/dist/layout/index.d.ts.map +1 -1
  24. package/dist/layout/index.js +20 -337
  25. package/dist/layout/index.js.map +1 -1
  26. package/dist/layout/itemBuilders.d.ts +11 -0
  27. package/dist/layout/itemBuilders.d.ts.map +1 -1
  28. package/dist/layout/itemBuilders.js +17 -3
  29. package/dist/layout/itemBuilders.js.map +1 -1
  30. package/dist/layout/labelsRender.d.ts.map +1 -1
  31. package/dist/layout/labelsRender.js +15 -27
  32. package/dist/layout/labelsRender.js.map +1 -1
  33. package/dist/layout/legendRender.d.ts +18 -6
  34. package/dist/layout/legendRender.d.ts.map +1 -1
  35. package/dist/layout/legendRender.js +49 -79
  36. package/dist/layout/legendRender.js.map +1 -1
  37. package/dist/layout/metadata.d.ts.map +1 -1
  38. package/dist/layout/metadata.js +26 -5
  39. package/dist/layout/metadata.js.map +1 -1
  40. package/dist/layout/model.d.ts +4 -3
  41. package/dist/layout/model.d.ts.map +1 -1
  42. package/dist/layout/model.js +17 -57
  43. package/dist/layout/model.js.map +1 -1
  44. package/dist/layout/options.d.ts +1 -2
  45. package/dist/layout/options.d.ts.map +1 -1
  46. package/dist/layout/options.js.map +1 -1
  47. package/dist/layout/preflight.d.ts +32 -0
  48. package/dist/layout/preflight.d.ts.map +1 -0
  49. package/dist/layout/preflight.js +69 -0
  50. package/dist/layout/preflight.js.map +1 -0
  51. package/dist/layout/qrRender.d.ts.map +1 -1
  52. package/dist/layout/qrRender.js +11 -10
  53. package/dist/layout/qrRender.js.map +1 -1
  54. package/dist/layout/renderDrawing.d.ts +28 -17
  55. package/dist/layout/renderDrawing.d.ts.map +1 -1
  56. package/dist/layout/renderDrawing.js +107 -88
  57. package/dist/layout/renderDrawing.js.map +1 -1
  58. package/dist/layout/renderLayout.d.ts +25 -0
  59. package/dist/layout/renderLayout.d.ts.map +1 -0
  60. package/dist/layout/renderLayout.js +315 -0
  61. package/dist/layout/renderLayout.js.map +1 -0
  62. package/dist/layout/renderTemplateSheet.d.ts +99 -0
  63. package/dist/layout/renderTemplateSheet.d.ts.map +1 -0
  64. package/dist/layout/renderTemplateSheet.js +296 -0
  65. package/dist/layout/renderTemplateSheet.js.map +1 -0
  66. package/dist/layout/renderUtils.d.ts +19 -0
  67. package/dist/layout/renderUtils.d.ts.map +1 -1
  68. package/dist/layout/renderUtils.js +47 -4
  69. package/dist/layout/renderUtils.js.map +1 -1
  70. package/dist/layout/sectionDefs.d.ts +65 -0
  71. package/dist/layout/sectionDefs.d.ts.map +1 -0
  72. package/dist/layout/sectionDefs.js +88 -0
  73. package/dist/layout/sectionDefs.js.map +1 -0
  74. package/dist/layout/slabModelView.d.ts +34 -0
  75. package/dist/layout/slabModelView.d.ts.map +1 -0
  76. package/dist/layout/slabModelView.js +175 -0
  77. package/dist/layout/slabModelView.js.map +1 -0
  78. package/dist/layout/templateVariables.d.ts +1 -1
  79. package/dist/layout/templateVariables.d.ts.map +1 -1
  80. package/dist/layout/templateVariables.js +21 -9
  81. package/dist/layout/templateVariables.js.map +1 -1
  82. package/dist/layout/types.d.ts +164 -83
  83. package/dist/layout/types.d.ts.map +1 -1
  84. package/dist/layout/types.js.map +1 -1
  85. package/dist/layout/validation.d.ts.map +1 -1
  86. package/dist/layout/validation.js +78 -33
  87. package/dist/layout/validation.js.map +1 -1
  88. package/dist/layout/viewportPrep.d.ts +8 -6
  89. package/dist/layout/viewportPrep.d.ts.map +1 -1
  90. package/dist/layout/viewportPrep.js +35 -23
  91. package/dist/layout/viewportPrep.js.map +1 -1
  92. package/dist/layout/viewportRender.d.ts +9 -7
  93. package/dist/layout/viewportRender.d.ts.map +1 -1
  94. package/dist/layout/viewportRender.js +93 -126
  95. package/dist/layout/viewportRender.js.map +1 -1
  96. package/dist/layout/viewportUtils.d.ts +20 -0
  97. package/dist/layout/viewportUtils.d.ts.map +1 -1
  98. package/dist/layout/viewportUtils.js +107 -4
  99. package/dist/layout/viewportUtils.js.map +1 -1
  100. package/dist/printToPdf.d.ts +7 -1
  101. package/dist/printToPdf.d.ts.map +1 -1
  102. package/dist/printToPdf.js +81 -14
  103. package/dist/printToPdf.js.map +1 -1
  104. package/dist/printToSvg.d.ts.map +1 -1
  105. package/dist/printToSvg.js +7 -3
  106. package/dist/printToSvg.js.map +1 -1
  107. package/dist/schemaUtils.d.ts +9 -0
  108. package/dist/schemaUtils.d.ts.map +1 -0
  109. package/dist/schemaUtils.js +14 -0
  110. package/dist/schemaUtils.js.map +1 -0
  111. package/dist/templates/default-template.json +30 -8
  112. package/dist/types.d.ts +2 -1
  113. package/dist/types.d.ts.map +1 -1
  114. package/dist/types.js +10 -1
  115. package/dist/types.js.map +1 -1
  116. package/package.json +8 -3
  117. package/src/index.ts +4 -0
  118. package/src/layout/analyzeTemplate.ts +21 -19
  119. package/src/layout/classClosure.ts +142 -0
  120. package/src/layout/clipUtils.ts +65 -6
  121. package/src/layout/filters.ts +31 -3
  122. package/src/layout/index.ts +61 -571
  123. package/src/layout/itemBuilders.ts +20 -5
  124. package/src/layout/labelsRender.ts +23 -40
  125. package/src/layout/legendRender.ts +64 -84
  126. package/src/layout/metadata.ts +26 -5
  127. package/src/layout/model.ts +21 -66
  128. package/src/layout/options.ts +1 -2
  129. package/src/layout/preflight.ts +96 -0
  130. package/src/layout/qrRender.ts +11 -10
  131. package/src/layout/renderDrawing.ts +132 -110
  132. package/src/layout/renderLayout.ts +483 -0
  133. package/src/layout/renderTemplateSheet.ts +435 -0
  134. package/src/layout/renderUtils.ts +53 -4
  135. package/src/layout/sectionDefs.ts +131 -0
  136. package/src/layout/slabModelView.ts +196 -0
  137. package/src/layout/templateVariables.ts +25 -9
  138. package/src/layout/types.ts +160 -70
  139. package/src/layout/validation.ts +98 -32
  140. package/src/layout/viewportPrep.ts +47 -29
  141. package/src/layout/viewportRender.ts +114 -141
  142. package/src/layout/viewportUtils.ts +118 -5
  143. package/src/printToPdf.ts +84 -15
  144. package/src/printToSvg.ts +7 -3
  145. package/src/schemaUtils.ts +13 -0
  146. package/src/templates/default-template.json +30 -8
  147. package/src/types.ts +12 -2
  148. package/templates/default-a0-landscape.json +171 -66
  149. package/templates/default-a0-portrait.json +159 -60
  150. package/templates/default-a1-landscape.json +135 -48
  151. package/templates/default-a1-portrait.json +123 -42
  152. package/templates/default-a3-landscape.json +87 -24
  153. package/templates/default-a3-portrait.json +99 -30
  154. package/templates/default-a4-landscape.json +75 -18
  155. package/templates/default-a4-portrait.json +75 -18
  156. package/templates/llm-authoring-guide.md +247 -0
  157. package/templates/template-schema.json +295 -120
@@ -0,0 +1,483 @@
1
+ import { renderViewportElement } from './viewportRender';
2
+ import { prepareViewports } from './viewportPrep';
3
+ import { renderLabels, ViewportRenderInfo } from './labelsRender';
4
+ import { renderQrElements } from './qrRender';
5
+ import { applyLegendFilter, buildLegendGroup, type LegendColorAssignment, type LegendResult } from './legendRender';
6
+ import { RenderLayoutOptions } from './options';
7
+ import { resolvePresets } from './presets';
8
+ import { resolveIncludeClasses } from './metadata';
9
+ import { pushWarning } from './warnings';
10
+ import { escapeAttr, escapeText, interpolate, getPathValue, formatNumber } from './renderUtils';
11
+ import { uniquePositiveDenoms } from './utils';
12
+ import { validateLayoutTemplate } from './validation';
13
+ import { validateLayoutData } from './preflight';
14
+ import {
15
+ ELEMENT_KIND_VIEWPORT,
16
+ ELEMENT_KIND_TEXT,
17
+ ELEMENT_KIND_IMAGE,
18
+ ELEMENT_KIND_LINE,
19
+ ELEMENT_KIND_RECT,
20
+ ELEMENT_KIND_CIRCLE,
21
+ ELEMENT_KIND_POLYLINE,
22
+ ELEMENT_KIND_POLYGON,
23
+ ELEMENT_KIND_SCALE_BAR,
24
+ ELEMENT_KIND_QR,
25
+ ELEMENT_KIND_LABELS,
26
+ ELEMENT_KIND_LEGEND,
27
+ ELEMENT_KIND_GROUP
28
+ } from './elementKinds';
29
+ import {
30
+ ResourceSpec,
31
+ LayoutElement,
32
+ ViewportElement,
33
+ TextElement,
34
+ ImageElement,
35
+ LineElement,
36
+ RectElement,
37
+ CircleElement,
38
+ PolylineElement,
39
+ PolygonElement,
40
+ ScaleBarElement,
41
+ QrElement,
42
+ LabelsElement,
43
+ LegendElement,
44
+ GroupElement,
45
+ LayoutTemplate,
46
+ ModelContext,
47
+ LayoutData,
48
+ ValidationIssue,
49
+ ComputedViewportScale
50
+ } from './types';
51
+
52
+ const SVG_NS = 'http://www.w3.org/2000/svg';
53
+
54
+ /** Result of {@link renderLayout}: the final SVG markup, per-viewport scale info, and any warnings. */
55
+ export interface RenderLayoutResult {
56
+ /** Complete SVG markup string ready for embedding or PDF conversion. */
57
+ svg: string;
58
+ /** Per-viewport computed scale information (denominator, mm-per-model-unit, fit status). */
59
+ viewportScales: Record<string, ComputedViewportScale>;
60
+ /** Non-fatal issues encountered during rendering (missing resources, unresolved styles, etc.). */
61
+ warnings: string[];
62
+ }
63
+
64
+ const dashArray = (dash?: string): string => (dash ? ` stroke-dasharray="${escapeAttr(dash)}"` : '');
65
+
66
+ /**
67
+ * Render a layout template to SVG.
68
+ *
69
+ * This is the low-level rendering function. For most use cases, prefer {@link renderDrawing}
70
+ * which handles all data wiring automatically.
71
+ *
72
+ * @param template - Validated layout template (page, elements, styles, presets).
73
+ * @param data - Layout data with viewport items, template variables, and legend rows.
74
+ * @param models - Per-viewport SVG model views (geometry + transforms).
75
+ * @param options - Optional rendering settings (remote resources, scale overrides, metadata).
76
+ * @returns SVG markup string, computed viewport scales, and any warnings.
77
+ */
78
+ export async function renderLayout(
79
+ template: LayoutTemplate,
80
+ data: LayoutData,
81
+ models: ModelContext,
82
+ options?: RenderLayoutOptions
83
+ ): Promise<RenderLayoutResult> {
84
+ const presets = resolvePresets(template?.presets);
85
+
86
+ const validation = validateLayoutTemplate(template, {
87
+ allowRemoteResources: options?.allowRemoteResources,
88
+ allowDataUrls: options?.allowDataUrls,
89
+ maxViewports: options?.maxViewports
90
+ });
91
+ if (!validation.ok) {
92
+ throw new Error(
93
+ `Template validation failed: ${validation.errors.map((e: ValidationIssue) => `${e.path}: ${e.message}`).join('; ')}`
94
+ );
95
+ }
96
+
97
+ const preferredScales = uniquePositiveDenoms(options?.scaleDenominators);
98
+ const page = template.page;
99
+ const warnings: string[] = [];
100
+
101
+ // Flatten all elements to collect viewports, qrs, labels, legends regardless of grouping
102
+ const allElements: LayoutElement[] = [];
103
+ const collectElements = (elements: LayoutElement[]): void => {
104
+ elements.forEach((el) => {
105
+ allElements.push(el);
106
+ if (el.kind === ELEMENT_KIND_GROUP) {
107
+ collectElements((el as GroupElement).children);
108
+ }
109
+ });
110
+ };
111
+ collectElements(template.elements);
112
+
113
+ const viewports = allElements.filter((el) => el.kind === ELEMENT_KIND_VIEWPORT) as ViewportElement[];
114
+
115
+ const dataValidation = validateLayoutData(
116
+ data,
117
+ viewports.map((v) => v.id)
118
+ );
119
+ if (!dataValidation.ok) {
120
+ throw new Error(`Layout data validation failed: ${dataValidation.errors.join('; ')}`);
121
+ }
122
+
123
+ const {
124
+ viewportScales,
125
+ viewportClipPolygonsLocal,
126
+ viewportClipPolygonsWorld,
127
+ viewportClipBounds,
128
+ viewportClipLabels,
129
+ viewportClipStyles,
130
+ // viewportClipWhere intentionally omitted — not needed for rendering
131
+ viewportHighlight,
132
+ filteredModelsByViewport
133
+ } = prepareViewports(viewports, models, data, preferredScales, presets, {
134
+ metadataById: options?.metadataById,
135
+ warnings
136
+ });
137
+
138
+ const viewportData: Record<
139
+ string,
140
+ {
141
+ scaleDenom: number;
142
+ scaleText: string;
143
+ mmPerModelUnit: number;
144
+ fits: boolean;
145
+ }
146
+ > = {};
147
+
148
+ Object.entries(viewportScales).forEach(([id, scale]) => {
149
+ viewportData[id] = {
150
+ scaleDenom: scale.denom,
151
+ scaleText: `1:${scale.denom}`,
152
+ mmPerModelUnit: scale.mmPerModelUnit,
153
+ fits: scale.fits
154
+ };
155
+ });
156
+
157
+ const dataWithViewport = { ...data, viewport: viewportData };
158
+
159
+ const sorted = [...template.elements].sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
160
+
161
+ const parts: string[] = [];
162
+ parts.push(
163
+ `<svg xmlns="${SVG_NS}" width="${page.widthMm}mm" height="${page.heightMm}mm" viewBox="0 0 ${page.widthMm} ${page.heightMm}">`
164
+ );
165
+
166
+ if (page.backgroundColor) {
167
+ parts.push(
168
+ `<rect x="0" y="0" width="${page.widthMm}" height="${page.heightMm}" fill="${escapeAttr(page.backgroundColor)}"/>`
169
+ );
170
+ }
171
+
172
+ const resourceMap = new Map<string, ResourceSpec>();
173
+ (template.resources ?? []).forEach((resource) => resourceMap.set(resource.id, resource));
174
+
175
+ const qrElements = allElements.filter((el) => el.kind === ELEMENT_KIND_QR) as QrElement[];
176
+ const labelElements = allElements.filter((el) => el.kind === ELEMENT_KIND_LABELS) as LabelsElement[];
177
+ const viewportRenderInfo: Record<string, ViewportRenderInfo> = {};
178
+
179
+ // Compute every legend ONCE, before viewport rendering: fill: "legend"
180
+ // needs the color assignments first, and the drawn legend must be built
181
+ // from the exact same rows — computing twice is how fill colors drift
182
+ // from the legend swatches. applyLegendFilter works in world space, so no
183
+ // render-time state is needed here. Results are keyed by element
184
+ // reference (immune to duplicate ids) and reused by the render pass.
185
+ const legendColorMaps = new Map<string, LegendColorAssignment>(); // viewport ID -> color assignment
186
+ const legendResults = new Map<LegendElement, LegendResult | null>();
187
+ const legendElements = allElements.filter((el) => el.kind === ELEMENT_KIND_LEGEND) as LegendElement[];
188
+ legendElements.forEach((legend) => {
189
+ const legendItems = getPathValue(data, `viewports.${legend.viewport}.items`);
190
+ const rawLegendItems = Array.isArray(legendItems) ? legendItems : [];
191
+ const legendDataFiltered = applyLegendFilter(rawLegendItems, legend, viewportClipPolygonsWorld);
192
+ if (!legendDataFiltered.length) {
193
+ if (!rawLegendItems.length) {
194
+ pushWarning(warnings, `Legend '${legend.id}' has no items from viewport '${legend.viewport}'.`);
195
+ } else {
196
+ pushWarning(
197
+ warnings,
198
+ `Legend '${legend.id}' filter matched 0 of ${rawLegendItems.length} items (class=${JSON.stringify(legend.class)}, where=${JSON.stringify(legend.where)}).`
199
+ );
200
+ }
201
+ }
202
+ const vpEl = viewports.find((v) => v.id === legend.viewport);
203
+ // Swatch opacity mirrors the fill rule the legend actually drives
204
+ // (fill: "legend"), falling back to the first fill rule.
205
+ const fillRule = vpEl?.fills?.find((f) => f.fill === 'legend') ?? vpEl?.fills?.[0];
206
+ const fillOpacity = fillRule ? (fillRule.fillOpacity ?? 0.35) : undefined;
207
+ const result = buildLegendGroup(legend, template.styles, page, page.marginMm, legendDataFiltered, fillOpacity);
208
+ legendResults.set(legend, result);
209
+ if (result?.colors) {
210
+ if (legendColorMaps.has(legend.viewport)) {
211
+ pushWarning(
212
+ warnings,
213
+ `Multiple legends with assignColors target viewport '${legend.viewport}' — fill: "legend" uses the last one.`
214
+ );
215
+ }
216
+ legendColorMaps.set(legend.viewport, result.colors);
217
+ }
218
+ });
219
+
220
+ const renderElement = (el: LayoutElement): void => {
221
+ if (el.kind === ELEMENT_KIND_RECT) {
222
+ const r = el as RectElement;
223
+ parts.push(
224
+ `<rect x="${r.xMm}" y="${r.yMm}" width="${r.widthMm}" height="${r.heightMm}"` +
225
+ (r.stroke ? ` stroke="${escapeAttr(r.stroke)}"` : '') +
226
+ (r.strokeWidthMm ? ` stroke-width="${r.strokeWidthMm}"` : '') +
227
+ (r.fill ? ` fill="${escapeAttr(r.fill)}"` : ' fill="none"') +
228
+ dashArray(r.dash) +
229
+ ` />`
230
+ );
231
+ } else if (el.kind === ELEMENT_KIND_LINE) {
232
+ const l = el as LineElement;
233
+ parts.push(
234
+ `<line x1="${l.x1Mm}" y1="${l.y1Mm}" x2="${l.x2Mm}" y2="${l.y2Mm}"` +
235
+ (l.stroke ? ` stroke="${escapeAttr(l.stroke)}"` : '') +
236
+ (l.strokeWidthMm ? ` stroke-width="${l.strokeWidthMm}"` : '') +
237
+ dashArray(l.dash) +
238
+ ` />`
239
+ );
240
+ } else if (el.kind === ELEMENT_KIND_CIRCLE) {
241
+ const c = el as CircleElement;
242
+ parts.push(
243
+ `<circle cx="${c.cxMm}" cy="${c.cyMm}" r="${c.rMm}"` +
244
+ (c.stroke ? ` stroke="${escapeAttr(c.stroke)}"` : '') +
245
+ (c.strokeWidthMm ? ` stroke-width="${c.strokeWidthMm}"` : '') +
246
+ (c.fill ? ` fill="${escapeAttr(c.fill)}"` : ' fill="none"') +
247
+ dashArray(c.dash) +
248
+ ` />`
249
+ );
250
+ } else if (el.kind === ELEMENT_KIND_POLYLINE) {
251
+ const pl = el as PolylineElement;
252
+ const pointsStr = pl.points.map((p) => `${formatNumber(p.xMm)},${formatNumber(p.yMm)}`).join(' ');
253
+ parts.push(
254
+ `<polyline points="${pointsStr}" stroke="${escapeAttr(pl.stroke)}" stroke-width="${pl.strokeWidthMm}" fill="none"${dashArray(pl.dash)} />`
255
+ );
256
+ } else if (el.kind === ELEMENT_KIND_POLYGON) {
257
+ const pg = el as PolygonElement;
258
+ const pointsStr = pg.points.map((p) => `${formatNumber(p.xMm)},${formatNumber(p.yMm)}`).join(' ');
259
+ parts.push(
260
+ `<polygon points="${pointsStr}"` +
261
+ (pg.stroke ? ` stroke="${escapeAttr(pg.stroke)}"` : '') +
262
+ (pg.strokeWidthMm ? ` stroke-width="${pg.strokeWidthMm}"` : '') +
263
+ (pg.fill ? ` fill="${escapeAttr(pg.fill)}"` : ' fill="none"') +
264
+ dashArray(pg.dash) +
265
+ ` />`
266
+ );
267
+ } else if (el.kind === ELEMENT_KIND_TEXT) {
268
+ const tEl = el as TextElement;
269
+ const styleName = tEl.textStyle;
270
+ const style = styleName ? template.styles?.[styleName] : undefined;
271
+ if (!style) {
272
+ pushWarning(warnings, `Missing text style '${styleName ?? '(none)'}' for element '${tEl.id}'.`);
273
+ return;
274
+ }
275
+ const text = interpolate(tEl.text, dataWithViewport, warnings);
276
+ const anchor = tEl.align === 'center' ? 'middle' : tEl.align === 'right' ? 'end' : 'start';
277
+ parts.push(
278
+ `<text x="${tEl.xMm}" y="${tEl.yMm}" font-family="${escapeAttr(style.fontFamily)}" font-size="${style.fontSizeMm}"` +
279
+ (style.fontWeight ? ` font-weight="${escapeAttr(style.fontWeight)}"` : '') +
280
+ (style.color ? ` fill="${escapeAttr(style.color)}"` : '') +
281
+ // Escaped, not type-narrowed: validation only checks fontFamily and fontSizeMm,
282
+ // and these attributes legitimately accept a length with units ("0.5mm"), so
283
+ // forcing them to numbers would reject valid templates. Unescaped, a value of
284
+ // `0" onload="…` closed the attribute and injected a handler.
285
+ (style.letterSpacingMm !== undefined
286
+ ? ` letter-spacing="${escapeAttr(style.letterSpacingMm)}"`
287
+ : '') +
288
+ (style.lineHeightMm !== undefined ? ` line-height="${escapeAttr(style.lineHeightMm)}"` : '') +
289
+ ` text-anchor="${anchor}">${escapeText(text)}</text>`
290
+ );
291
+ } else if (el.kind === ELEMENT_KIND_IMAGE) {
292
+ const img = el as ImageElement;
293
+ const resource = resourceMap.get(img.resourceId);
294
+ if (!resource) {
295
+ pushWarning(warnings, `Missing resource '${img.resourceId}' for image '${img.id}'.`);
296
+ return;
297
+ }
298
+ if (resource.src) {
299
+ parts.push(
300
+ `<image href="${escapeAttr(resource.src)}" x="${img.xMm}" y="${img.yMm}" width="${img.widthMm}" height="${img.heightMm}"` +
301
+ (img.fit === 'cover'
302
+ ? ' preserveAspectRatio="xMidYMid slice"'
303
+ : img.fit === 'stretch'
304
+ ? ' preserveAspectRatio="none"'
305
+ : ' preserveAspectRatio="xMidYMid meet"') +
306
+ ` />`
307
+ );
308
+ } else if (resource.content) {
309
+ parts.push(
310
+ `<g transform="translate(${img.xMm} ${img.yMm}) scale(${img.widthMm} ${img.heightMm})">${resource.content}</g>`
311
+ );
312
+ }
313
+ } else if (el.kind === ELEMENT_KIND_SCALE_BAR) {
314
+ const sb = el as ScaleBarElement;
315
+ // Scale bar declares which viewport it binds to via `viewport`
316
+ const boundViewport = sb.viewport ?? viewports[0]?.id ?? null;
317
+ if (!boundViewport) {
318
+ pushWarning(warnings, `Scale bar '${sb.id}' is not bound to a viewport.`);
319
+ return;
320
+ }
321
+ const scaleInfo = viewportScales[boundViewport];
322
+ if (!scaleInfo) {
323
+ pushWarning(warnings, `Viewport '${boundViewport}' missing scale info for scale bar '${sb.id}'.`);
324
+ return;
325
+ }
326
+ const lengthMm = sb.maxLengthMm;
327
+ const segments = sb.segments ?? 10;
328
+ const segmentMm = lengthMm / segments;
329
+ const metersPerMm = 1 / scaleInfo.mmPerModelUnit;
330
+ const segmentMeters = segmentMm * metersPerMm;
331
+ const labelsEvery = Math.max(1, sb.labelEvery ?? 2);
332
+ const unitSuffix = sb.unit ? ` ${sb.unit}` : ' m';
333
+ const barParts: string[] = [];
334
+ const sbStroke = sb.stroke ?? '#000';
335
+ const sbStrokeWidth = sb.strokeWidthMm ?? 0.25;
336
+
337
+ for (let i = 0; i <= segments; i++) {
338
+ const x = sb.xMm + segmentMm * i;
339
+ const isLabel = i % labelsEvery === 0;
340
+ const height = isLabel ? 4 : 3;
341
+ barParts.push(
342
+ `<line x1="${x}" y1="${sb.yMm}" x2="${x}" y2="${sb.yMm - height}" stroke="${escapeAttr(
343
+ sbStroke
344
+ )}" stroke-width="${sbStrokeWidth}" />`
345
+ );
346
+ if (isLabel && sb.textStyle) {
347
+ const style = template.styles?.[sb.textStyle];
348
+ if (style) {
349
+ const labelValue = (segmentMeters * i).toFixed(2).replace(/\.?0+$/, '');
350
+ barParts.push(
351
+ `<text x="${x}" y="${sb.yMm + style.fontSizeMm + 1}" font-family="${escapeAttr(
352
+ style.fontFamily
353
+ )}" font-size="${style.fontSizeMm}" text-anchor="middle">${escapeText(labelValue + unitSuffix)}</text>`
354
+ );
355
+ }
356
+ }
357
+ }
358
+
359
+ barParts.push(
360
+ `<line x1="${sb.xMm}" y1="${sb.yMm}" x2="${sb.xMm + lengthMm}" y2="${sb.yMm}" stroke="${escapeAttr(
361
+ sbStroke
362
+ )}" stroke-width="${sbStrokeWidth}" />`
363
+ );
364
+
365
+ parts.push(barParts.join(''));
366
+ } else if (el.kind === ELEMENT_KIND_VIEWPORT) {
367
+ const vp = el as ViewportElement;
368
+ const model = filteredModelsByViewport[vp.id] ?? models[vp.id] ?? null;
369
+ if (!model) {
370
+ pushWarning(warnings, `Viewport '${vp.id}' missing model.`);
371
+ }
372
+ // Resolved once per viewport: the same includeClasses (incl. presets) that
373
+ // filters the line paths also gates generic fill rules in the renderer.
374
+ const vpIncludeFilters = resolveIncludeClasses(vp.includeClasses as unknown, presets.includeClasses ?? {});
375
+ const vpParts = renderViewportElement(
376
+ vp,
377
+ model,
378
+ dataWithViewport,
379
+ preferredScales,
380
+ presets.scales,
381
+ resourceMap,
382
+ viewportScales,
383
+ viewportClipPolygonsLocal,
384
+ viewportClipBounds,
385
+ viewportClipLabels,
386
+ viewportClipStyles,
387
+ viewportHighlight,
388
+ viewportRenderInfo,
389
+ warnings,
390
+ legendColorMaps,
391
+ undefined,
392
+ vpIncludeFilters
393
+ );
394
+ parts.push(...vpParts);
395
+ } else if (el.kind === ELEMENT_KIND_LEGEND) {
396
+ // Computed once in the pre-pass (same rows as the color maps).
397
+ const legendResult = legendResults.get(el as LegendElement);
398
+ if (legendResult) {
399
+ parts.push(legendResult.svg);
400
+ }
401
+ } else if (el.kind === ELEMENT_KIND_GROUP) {
402
+ const grp = el as GroupElement;
403
+ const children = Array.isArray(grp.children) ? grp.children : [];
404
+ if (!children.length) return;
405
+
406
+ const t = grp.transform;
407
+ const transforms: string[] = [];
408
+ if (t?.translateXMm || t?.translateYMm) {
409
+ transforms.push(`translate(${t.translateXMm ?? 0} ${t.translateYMm ?? 0})`);
410
+ }
411
+ if (t?.rotateDeg) transforms.push(`rotate(${t.rotateDeg})`);
412
+ if (t?.scaleX !== undefined || t?.scaleY !== undefined) {
413
+ transforms.push(`scale(${t.scaleX ?? 1} ${t.scaleY ?? 1})`);
414
+ }
415
+
416
+ let clipRef = '';
417
+ if (grp.clip && grp.clip.type === 'rect') {
418
+ // grp.id is template-authored and lands in both id="..." and url(#...); a quote in it
419
+ // would close the attribute and let the rest inject arbitrary ones. Every other
420
+ // template value in this function is escaped the same way.
421
+ const clipId = escapeAttr(`group-clip-${grp.id}-${parts.length}`);
422
+ parts.push(
423
+ `<defs><clipPath id="${clipId}"><rect x="${grp.clip.xMm}" y="${grp.clip.yMm}" width="${grp.clip.widthMm}" height="${grp.clip.heightMm}" /></clipPath></defs>`
424
+ );
425
+ clipRef = ` clip-path="url(#${clipId})"`;
426
+ }
427
+
428
+ parts.push(
429
+ `<g${transforms.length ? ` transform="${transforms.join(' ')}"` : ''}` +
430
+ (grp.opacity !== undefined ? ` opacity="${grp.opacity}"` : '') +
431
+ clipRef +
432
+ `>`
433
+ );
434
+ // Labels and QR are data-driven overlays rendered globally after
435
+ // the element pass (they need viewport render info / async QR
436
+ // encoding) — group transform/opacity/clip do not apply to them.
437
+ if (children.some((c) => c.kind === ELEMENT_KIND_LABELS || c.kind === ELEMENT_KIND_QR)) {
438
+ pushWarning(
439
+ warnings,
440
+ `Group '${grp.id}' contains labels/qr children — these render as page-level overlays; the group's transform/opacity/clip do not apply to them.`
441
+ );
442
+ }
443
+ [...children]
444
+ .filter((c) => c.kind !== ELEMENT_KIND_LABELS && c.kind !== ELEMENT_KIND_QR)
445
+ .sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0))
446
+ .forEach(renderElement);
447
+ parts.push('</g>');
448
+ }
449
+ };
450
+ sorted.forEach(renderElement);
451
+
452
+ if (labelElements.length) {
453
+ const labelParts = renderLabels(
454
+ labelElements,
455
+ data,
456
+ viewportRenderInfo,
457
+ viewportClipLabels,
458
+ viewportClipPolygonsLocal,
459
+ viewportClipPolygonsWorld,
460
+ template.styles ?? {},
461
+ dataWithViewport,
462
+ warnings
463
+ );
464
+ parts.push(...labelParts);
465
+ }
466
+
467
+ if (qrElements.length) {
468
+ const qrParts = await renderQrElements(
469
+ qrElements,
470
+ viewports,
471
+ viewportScales,
472
+ models,
473
+ { qrContext: options?.qrContext },
474
+ dataWithViewport,
475
+ warnings
476
+ );
477
+ parts.push(...qrParts);
478
+ }
479
+
480
+ parts.push('</svg>');
481
+
482
+ return { svg: parts.join(''), viewportScales, warnings };
483
+ }