h17-sspdf 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,931 @@
1
+ const { PDFCore, getStyleMarginsMm, getTextPaddingMm, applyTextTransform } = require("./pdf-core");
2
+ const { pxToMm, resolveLineHeightMm } = require("./units");
3
+ const { registerThemeFonts } = require("./font-registry");
4
+ const { getPlugin, hasPlugin } = require("./plugin-registry");
5
+ const { validateSource, validateTheme, validateSourceAgainstTheme } = require("./validate");
6
+
7
+ /**
8
+ * Render a document by executing labeled operations.
9
+ *
10
+ * pageTemplates format (inside source):
11
+ * {
12
+ * header: operations[],
13
+ * footer: operations[],
14
+ * headerHeightMm: number, // reserves body space
15
+ * footerHeightMm: number, // reserves body space
16
+ * headerBypassMargins: true // default true
17
+ * footerBypassMargins: true // default true
18
+ * }
19
+ *
20
+ * @param {object} input
21
+ * @param {object} input.source Source-of-truth JSON
22
+ * @param {object} input.theme Theme with `labels` style map
23
+ * @param {string} [input.outputPath]
24
+ * @returns {{ buffer: Buffer, operationsCount: number, core: PDFCore }}
25
+ */
26
+ function renderDocument(input) {
27
+ if (!input || typeof input !== "object") {
28
+ throw new Error("renderDocument: input is required");
29
+ }
30
+ if (!input.source) {
31
+ throw new Error("renderDocument: source is required");
32
+ }
33
+ if (!input.theme) {
34
+ throw new Error("renderDocument: theme is required");
35
+ }
36
+ if (input.validate) {
37
+ validateSource(input.source);
38
+ validateTheme(input.theme);
39
+ validateSourceAgainstTheme(input.source, input.theme);
40
+ }
41
+
42
+ const built = normalizeSourceModel(input.source);
43
+ const runtimeTheme = buildRuntimeTheme(input.theme, built.pageTemplates);
44
+
45
+ const core = new PDFCore(runtimeTheme);
46
+ registerThemeFonts(core, runtimeTheme);
47
+
48
+ installPageTemplates(core, runtimeTheme, built.pageTemplates);
49
+
50
+ executeOperations({
51
+ core,
52
+ theme: runtimeTheme,
53
+ operations: built.operations,
54
+ indexPrefix: "",
55
+ templateMode: false,
56
+ templateBypassMargins: false,
57
+ });
58
+
59
+ const buffer = core.toBuffer();
60
+ if (input.outputPath) {
61
+ core.saveToFile(input.outputPath);
62
+ }
63
+
64
+ return {
65
+ buffer,
66
+ operationsCount: countLeafOperations(built.operations),
67
+ core,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Normalize direct JSON contract into operations.
73
+ * Accepted root forms:
74
+ * - operations[]
75
+ * - { operations, pageTemplates? }
76
+ * - { content|items|sections|children, pageTemplates? } wrappers
77
+ *
78
+ * Node wrappers can nest.
79
+ * - `section` wrappers are normalized as parent `block` operations.
80
+ * - other wrappers are flattened while preserving order.
81
+ * A wrapper node is any object with content/items/sections/children array.
82
+ *
83
+ * "group" is accepted as alias for "block".
84
+ *
85
+ * @param {object|Array<object>} source
86
+ * @returns {{ operations: Array<object>, pageTemplates: object|null }}
87
+ */
88
+ function normalizeSourceModel(source) {
89
+ if (Array.isArray(source)) {
90
+ return { operations: normalizeNodes(source, "source"), pageTemplates: null };
91
+ }
92
+
93
+ if (!source || typeof source !== "object") {
94
+ throw new Error("renderDocument: source must be an object or operation array");
95
+ }
96
+
97
+ if (Array.isArray(source.operations)) {
98
+ return {
99
+ operations: normalizeNodes(source.operations, "source.operations"),
100
+ pageTemplates: source.pageTemplates || null,
101
+ };
102
+ }
103
+
104
+ const rootChildren = getNodeChildren(source);
105
+ if (rootChildren) {
106
+ return {
107
+ operations: normalizeNodes(rootChildren, "source"),
108
+ pageTemplates: source.pageTemplates || null,
109
+ };
110
+ }
111
+
112
+ throw new Error(
113
+ "renderDocument: source must provide operations[] or wrapper arrays (content/items/sections/children)"
114
+ );
115
+ }
116
+
117
+ function buildRuntimeTheme(theme, pageTemplates) {
118
+ const page = Object.assign({}, theme.page || {});
119
+ const hasHeader = pageTemplates && Array.isArray(pageTemplates.header) && pageTemplates.header.length > 0;
120
+ const hasFooter = pageTemplates && Array.isArray(pageTemplates.footer) && pageTemplates.footer.length > 0;
121
+
122
+ if (pageTemplates && pageTemplates.headerHeightMm !== undefined) {
123
+ page.headerHeightMm = Number(pageTemplates.headerHeightMm) || 0;
124
+ } else if (hasHeader && page.headerHeightMm === undefined) {
125
+ page.headerHeightMm = 12;
126
+ }
127
+
128
+ if (pageTemplates && pageTemplates.footerHeightMm !== undefined) {
129
+ page.footerHeightMm = Number(pageTemplates.footerHeightMm) || 0;
130
+ } else if (hasFooter && page.footerHeightMm === undefined) {
131
+ page.footerHeightMm = 10;
132
+ }
133
+
134
+ return Object.assign({}, theme, { page });
135
+ }
136
+
137
+ function installPageTemplates(core, theme, pageTemplates) {
138
+ if (!pageTemplates || typeof pageTemplates !== "object") {
139
+ return;
140
+ }
141
+
142
+ const headerOps = Array.isArray(pageTemplates.header) ? pageTemplates.header : [];
143
+ const footerOps = Array.isArray(pageTemplates.footer) ? pageTemplates.footer : [];
144
+ if (headerOps.length === 0 && footerOps.length === 0) {
145
+ return;
146
+ }
147
+
148
+ const headerBypassMargins = pageTemplates.headerBypassMargins !== false;
149
+ const footerBypassMargins = pageTemplates.footerBypassMargins !== false;
150
+
151
+ const renderTemplatesForCurrentPage = () => {
152
+ if (headerOps.length > 0) {
153
+ const startY = pageTemplates.headerStartMm !== undefined
154
+ ? Number(pageTemplates.headerStartMm) || 0
155
+ : 0;
156
+ renderTemplateRegion(core, theme, headerOps, startY, "header", headerBypassMargins);
157
+ }
158
+ if (footerOps.length > 0) {
159
+ const defaultFooterStart = core.pageHeight - (core.footerHeightMm || 0);
160
+ const startY = pageTemplates.footerStartMm !== undefined
161
+ ? Number(pageTemplates.footerStartMm) || 0
162
+ : defaultFooterStart;
163
+ renderTemplateRegion(core, theme, footerOps, startY, "footer", footerBypassMargins);
164
+ }
165
+ };
166
+
167
+ const originalAddPage = core.addPage.bind(core);
168
+ core.addPage = () => {
169
+ originalAddPage();
170
+ renderTemplatesForCurrentPage();
171
+ };
172
+
173
+ renderTemplatesForCurrentPage();
174
+ }
175
+
176
+ function renderTemplateRegion(core, theme, operations, startY, regionName, bypassMargins) {
177
+ const savedY = core.getCursorY();
178
+ core.setCursorY(startY);
179
+
180
+ executeOperations({
181
+ core,
182
+ theme,
183
+ operations,
184
+ indexPrefix: `template:${regionName}.`,
185
+ templateMode: true,
186
+ templateBypassMargins: bypassMargins,
187
+ });
188
+
189
+ core.setCursorY(savedY);
190
+ }
191
+
192
+ function executeOperations(ctx) {
193
+ const { core, theme, operations, indexPrefix, templateMode, templateBypassMargins, insideContainer } = ctx;
194
+ if (!Array.isArray(operations)) {
195
+ throw new Error("Operations must be an array");
196
+ }
197
+
198
+ for (let i = 0; i < operations.length; i += 1) {
199
+ const operation = operations[i];
200
+ const index = `${indexPrefix}${i}`;
201
+
202
+ if (!templateMode) {
203
+ const keepCount = normalizeKeepWithNext(operation && operation.keepWithNext);
204
+ if (keepCount > 0) {
205
+ const grouped = operations.slice(i, i + 1 + keepCount);
206
+ const groupedHeight = estimateOperationsHeight({
207
+ core,
208
+ theme,
209
+ operations: grouped,
210
+ indexPrefix: `${index}.keep.`,
211
+ });
212
+ core.ensureSpace(groupedHeight);
213
+ }
214
+ }
215
+
216
+ core.withDocumentState(() => {
217
+ executeOperation({
218
+ core,
219
+ theme,
220
+ operation,
221
+ index,
222
+ templateMode,
223
+ templateBypassMargins,
224
+ insideContainer,
225
+ });
226
+ });
227
+ }
228
+ }
229
+
230
+ function normalizeNodes(nodes, path) {
231
+ if (!Array.isArray(nodes)) {
232
+ throw new Error(`${path} must be an array`);
233
+ }
234
+
235
+ const operations = [];
236
+ nodes.forEach((node, i) => {
237
+ const nodePath = `${path}[${i}]`;
238
+ operations.push(...normalizeNode(node, nodePath));
239
+ });
240
+ return operations;
241
+ }
242
+
243
+ function normalizeNode(node, path) {
244
+ if (!node || typeof node !== "object") {
245
+ throw new Error(`${path} must be an object`);
246
+ }
247
+
248
+ if (node.type === "quote") {
249
+ if (!node.label) {
250
+ throw new Error(`${path} type "quote" requires label`);
251
+ }
252
+ const quoteText = node.content !== undefined ? node.content : node.text;
253
+ if (quoteText === undefined) {
254
+ throw new Error(`${path} type "quote" requires content or text`);
255
+ }
256
+ const attrLabel = node.attributionLabel || (node.label + ".attribution");
257
+ const attrText = node.author !== undefined ? node.author : node.attribution;
258
+ const children = [
259
+ { type: "text", label: node.label, text: quoteText, xMm: node.xMm, maxWidthMm: node.maxWidthMm },
260
+ ];
261
+ if (attrText) {
262
+ children.push({ type: "text", label: attrLabel, text: attrText, xMm: node.xMm, maxWidthMm: node.maxWidthMm });
263
+ }
264
+ return [{
265
+ type: "block",
266
+ label: node.label,
267
+ keepTogether: true,
268
+ xMm: node.xMm,
269
+ maxWidthMm: node.maxWidthMm,
270
+ children,
271
+ }];
272
+ }
273
+
274
+ if (isOperationType(node.type)) {
275
+ return expandOperationNode(node, path);
276
+ }
277
+
278
+ const children = getNodeChildren(node);
279
+ if (children) {
280
+ if (isParentContainerType(node.type)) {
281
+ const childrenOps = normalizeNodes(children, `${path}.children`);
282
+ return [toParentBlock(node, childrenOps)];
283
+ }
284
+ return normalizeNodes(children, `${path}.children`);
285
+ }
286
+
287
+ if (node.type === "group") {
288
+ throw new Error(`${path} type "group" requires children/content/items/sections array`);
289
+ }
290
+
291
+ if (node.type === "block") {
292
+ throw new Error(`${path} type "block" requires children/content/items/sections array`);
293
+ }
294
+ if (node.type === "section") {
295
+ throw new Error(`${path} type "section" requires children/content/items/sections array`);
296
+ }
297
+
298
+ if (node.label && (node.text !== undefined || node.value !== undefined)) {
299
+ const raw = node.text !== undefined ? node.text : node.value;
300
+ if (Array.isArray(raw)) {
301
+ return raw
302
+ .map((item) => String(item || "").trim())
303
+ .filter(Boolean)
304
+ .map((text) => ({
305
+ type: "text",
306
+ label: node.label,
307
+ text,
308
+ }));
309
+ }
310
+ return [
311
+ {
312
+ type: "text",
313
+ label: node.label,
314
+ text: raw,
315
+ },
316
+ ];
317
+ }
318
+
319
+ throw new Error(
320
+ `${path} is not a valid operation node. Provide operation.type or wrapper children arrays`
321
+ );
322
+ }
323
+
324
+ function isOperationType(type) {
325
+ return type === "text"
326
+ || type === "row"
327
+ || type === "bullet"
328
+ || type === "divider"
329
+ || type === "spacer"
330
+ || type === "hiddenText"
331
+ || hasPlugin(type);
332
+ }
333
+
334
+ function expandOperationNode(node, path) {
335
+ if (node.type === "bullet") {
336
+ const bulletList = getBulletArray(node);
337
+ if (bulletList) {
338
+ return bulletList
339
+ .map((item) => String(item || "").trim())
340
+ .filter(Boolean)
341
+ .map((text) => ({
342
+ type: "bullet",
343
+ label: node.label,
344
+ markerLabel: node.markerLabel,
345
+ marker: node.marker,
346
+ xMm: node.xMm,
347
+ textIndentMm: node.textIndentMm,
348
+ maxWidthMm: node.maxWidthMm,
349
+ keepWithNext: node.keepWithNext,
350
+ text,
351
+ }));
352
+ }
353
+ return [node];
354
+ }
355
+
356
+ if (node.type === "text" && Array.isArray(node.text)) {
357
+ return node.text
358
+ .map((item) => String(item || "").trim())
359
+ .filter(Boolean)
360
+ .map((text) => ({
361
+ type: "text",
362
+ label: node.label,
363
+ xMm: node.xMm,
364
+ maxWidthMm: node.maxWidthMm,
365
+ align: node.align,
366
+ wrap: node.wrap,
367
+ advance: node.advance,
368
+ keepWithNext: node.keepWithNext,
369
+ text,
370
+ }));
371
+ }
372
+
373
+ if ((node.type === "hiddenText") && Array.isArray(node.text)) {
374
+ return [
375
+ Object.assign({}, node, {
376
+ text: node.text.map((item) => String(item || "")).join(" "),
377
+ }),
378
+ ];
379
+ }
380
+
381
+ return [node];
382
+ }
383
+
384
+ function getBulletArray(node) {
385
+ if (Array.isArray(node.text)) {
386
+ return node.text;
387
+ }
388
+ if (Array.isArray(node.items)) {
389
+ return node.items;
390
+ }
391
+ if (Array.isArray(node.bullets)) {
392
+ return node.bullets;
393
+ }
394
+ return null;
395
+ }
396
+
397
+ function isParentContainerType(type) {
398
+ return type === "block" || type === "group" || type === "section";
399
+ }
400
+
401
+ function toParentBlock(node, childrenOps) {
402
+ const out = {};
403
+ Object.keys(node).forEach((key) => {
404
+ if (key === "children" || key === "content" || key === "items" || key === "sections") {
405
+ return;
406
+ }
407
+ out[key] = node[key];
408
+ });
409
+ out.type = "block";
410
+ out.children = childrenOps;
411
+
412
+ // `section` defines a parent boundary but should not force keepTogether unless explicit.
413
+ if (node.type === "section" && out.keepTogether === undefined) {
414
+ out.keepTogether = false;
415
+ }
416
+
417
+ return out;
418
+ }
419
+
420
+ function getNodeChildren(node) {
421
+ if (!node || typeof node !== "object") {
422
+ return null;
423
+ }
424
+ if (Array.isArray(node.children)) {
425
+ return node.children;
426
+ }
427
+ if (Array.isArray(node.content)) {
428
+ return node.content;
429
+ }
430
+ if (Array.isArray(node.items)) {
431
+ return node.items;
432
+ }
433
+ if (Array.isArray(node.sections)) {
434
+ return node.sections;
435
+ }
436
+ return null;
437
+ }
438
+
439
+ /**
440
+ * Execute one operation.
441
+ * Supported types:
442
+ * - text
443
+ * - row
444
+ * - bullet
445
+ * - divider
446
+ * - spacer
447
+ * - hiddenText
448
+ * - block
449
+ *
450
+ * block format:
451
+ * {
452
+ * type: "block",
453
+ * keepTogether: true,
454
+ * children: [ ...operations ],
455
+ * spaceAfterMm: 2
456
+ * }
457
+ *
458
+ * @param {object} ctx
459
+ */
460
+ function executeOperation(ctx) {
461
+ const { core, theme, operation, index, templateMode, templateBypassMargins, insideContainer } = ctx;
462
+ if (!operation || !operation.type) {
463
+ throw new Error(`Invalid operation at index ${index}`);
464
+ }
465
+
466
+ if (operation.type === "block") {
467
+ const children = Array.isArray(operation.children) ? operation.children : null;
468
+ if (!children) {
469
+ throw new Error(`Block operation at index ${index} must define children[]`);
470
+ }
471
+
472
+ const containerStyle = operation.label
473
+ ? resolveLabelStyle(theme, operation.label, operation, index, "label", true)
474
+ : null;
475
+ const hasContainer = containerStyle && (
476
+ Array.isArray(containerStyle.backgroundColor)
477
+ || (Number(containerStyle.borderWidthMm) > 0)
478
+ );
479
+
480
+ const childrenHeight = estimateOperationsHeight({
481
+ core,
482
+ theme,
483
+ operations: children,
484
+ indexPrefix: `${index}.block.`,
485
+ });
486
+
487
+ if (!templateMode && (hasContainer || operation.keepTogether !== false)) {
488
+ core.ensureSpace(childrenHeight);
489
+ }
490
+
491
+ if (hasContainer) {
492
+ const bounds = getHorizontalBounds(core, templateBypassMargins);
493
+ const x = operation.xMm !== undefined ? operation.xMm : bounds.left;
494
+ const width = operation.maxWidthMm !== undefined ? operation.maxWidthMm : (bounds.right - x);
495
+ const containerY = core.getCursorY();
496
+
497
+ core._drawTextContainer({ style: containerStyle, x, y: containerY, width, height: childrenHeight });
498
+ core._drawTextLeftBorder({ style: containerStyle, x, y: containerY, lineHeightMm: 0, blockHeight: childrenHeight });
499
+ }
500
+
501
+ executeOperations({
502
+ core,
503
+ theme,
504
+ operations: children,
505
+ indexPrefix: `${index}.`,
506
+ templateMode,
507
+ templateBypassMargins,
508
+ insideContainer: hasContainer || insideContainer,
509
+ });
510
+
511
+ if (operation.spaceAfterMm !== undefined) {
512
+ core.moveDown(Number(operation.spaceAfterMm) || 0);
513
+ } else if (operation.spaceAfterPx !== undefined) {
514
+ core.moveDown(pxToMm(operation.spaceAfterPx));
515
+ } else if (operation.spaceAfterLabel) {
516
+ const style = resolveLabelStyle(theme, operation.spaceAfterLabel, operation, index, "spaceAfterLabel");
517
+ moveFromSpacerStyle(core, style, index);
518
+ }
519
+ return;
520
+ }
521
+
522
+ if (operation.type === "text") {
523
+ const rawStyle = resolveLabelStyle(theme, operation.label, operation, index);
524
+ const style = insideContainer ? stripContainerProps(rawStyle) : rawStyle;
525
+ const bounds = getHorizontalBounds(core, templateBypassMargins);
526
+ const defaultX = bounds.left;
527
+ const x = operation.xMm !== undefined ? operation.xMm : defaultX;
528
+ validatePointInsideBounds("xMm", x, bounds, index, templateBypassMargins);
529
+ const maxWidth = operation.maxWidthMm !== undefined
530
+ ? operation.maxWidthMm
531
+ : bounds.right - x;
532
+ if (maxWidth <= 0) {
533
+ throw new Error(`Operation ${index} (${operation.type}) has non-positive width`);
534
+ }
535
+
536
+ core.drawText({
537
+ text: applyPageTokens(operation.text, core),
538
+ style,
539
+ x,
540
+ maxWidth,
541
+ align: operation.align,
542
+ wrap: operation.wrap,
543
+ advance: operation.advance,
544
+ allowPageBreak: !templateMode,
545
+ });
546
+ return;
547
+ }
548
+
549
+ if (operation.type === "row") {
550
+ const leftStyle = resolveLabelStyle(theme, operation.leftLabel, operation, index, "leftLabel");
551
+ const rightStyle = resolveLabelStyle(theme, operation.rightLabel, operation, index, "rightLabel");
552
+ const bounds = getHorizontalBounds(core, templateBypassMargins);
553
+ const defaultLeft = bounds.left;
554
+ const defaultRight = bounds.right;
555
+ const leftPadding = getTextPaddingMm(leftStyle);
556
+ const rightPadding = getTextPaddingMm(rightStyle);
557
+ const xLeft = (operation.xLeftMm !== undefined ? operation.xLeftMm : defaultLeft) + leftPadding.left;
558
+ const xRight = (operation.xRightMm !== undefined ? operation.xRightMm : defaultRight) - rightPadding.right;
559
+ validatePointInsideBounds("xLeftMm", xLeft, bounds, index, templateBypassMargins);
560
+ validatePointInsideBounds("xRightMm", xRight, bounds, index, templateBypassMargins);
561
+ if (xRight < xLeft) {
562
+ throw new Error(`Operation ${index} (${operation.type}) has xRightMm < xLeftMm`);
563
+ }
564
+
565
+ core.drawRow({
566
+ leftText: applyPageTokens(operation.leftText, core),
567
+ rightText: applyPageTokens(operation.rightText, core),
568
+ leftStyle,
569
+ rightStyle,
570
+ xLeft,
571
+ xRight,
572
+ allowPageBreak: !templateMode,
573
+ });
574
+ return;
575
+ }
576
+
577
+ if (operation.type === "bullet") {
578
+ const textStyle = resolveLabelStyle(theme, operation.label, operation, index);
579
+ const markerStyle = resolveLabelStyle(
580
+ theme,
581
+ operation.markerLabel || "bullet.marker",
582
+ operation,
583
+ index,
584
+ "markerLabel",
585
+ true
586
+ );
587
+
588
+ const bounds = getHorizontalBounds(core, templateBypassMargins);
589
+ const defaultX = bounds.left;
590
+ const x = operation.xMm !== undefined ? operation.xMm : defaultX;
591
+ validatePointInsideBounds("xMm", x, bounds, index, templateBypassMargins);
592
+ const textIndentMm = operation.textIndentMm !== undefined
593
+ ? operation.textIndentMm
594
+ : (theme.layout && Number(theme.layout.bulletIndentMm)) || 4;
595
+ const rightBoundary = bounds.right;
596
+ const maxWidth = operation.maxWidthMm !== undefined
597
+ ? operation.maxWidthMm
598
+ : rightBoundary - (x + textIndentMm);
599
+ if (maxWidth <= 0) {
600
+ throw new Error(`Operation ${index} (${operation.type}) has non-positive width`);
601
+ }
602
+
603
+ core.drawBullet({
604
+ text: applyPageTokens(operation.text, core),
605
+ textStyle,
606
+ markerStyle: markerStyle || {},
607
+ marker: operation.marker,
608
+ x,
609
+ textIndentMm,
610
+ maxWidth,
611
+ allowPageBreak: !templateMode,
612
+ });
613
+ return;
614
+ }
615
+
616
+ if (operation.type === "divider") {
617
+ const style = resolveLabelStyle(theme, operation.label, operation, index);
618
+ const bounds = getHorizontalBounds(core, templateBypassMargins);
619
+ const defaultX1 = bounds.left;
620
+ const defaultX2 = bounds.right;
621
+ const x1 = operation.x1Mm !== undefined ? operation.x1Mm : defaultX1;
622
+ const x2 = operation.x2Mm !== undefined ? operation.x2Mm : defaultX2;
623
+ validatePointInsideBounds("x1Mm", x1, bounds, index, templateBypassMargins);
624
+ validatePointInsideBounds("x2Mm", x2, bounds, index, templateBypassMargins);
625
+ if (x2 < x1) {
626
+ throw new Error(`Operation ${index} (${operation.type}) has x2Mm < x1Mm`);
627
+ }
628
+
629
+ core.drawDivider({
630
+ style,
631
+ x1,
632
+ x2,
633
+ allowPageBreak: !templateMode,
634
+ });
635
+ return;
636
+ }
637
+
638
+ if (operation.type === "spacer") {
639
+ if (operation.mm !== undefined) {
640
+ core.moveDown(Number(operation.mm) || 0);
641
+ return;
642
+ }
643
+ if (operation.px !== undefined) {
644
+ core.moveDown(pxToMm(operation.px));
645
+ return;
646
+ }
647
+ if (operation.label) {
648
+ const style = resolveLabelStyle(theme, operation.label, operation, index);
649
+ moveFromSpacerStyle(core, style, index);
650
+ return;
651
+ }
652
+ throw new Error(`Spacer operation at index ${index} must provide mm, px, or label with spaceMm/spacePx`);
653
+ }
654
+
655
+ if (operation.type === "hiddenText") {
656
+ const style = resolveLabelStyle(theme, operation.label, operation, index);
657
+ const bounds = getHorizontalBounds(core, templateBypassMargins);
658
+ const x = operation.xMm !== undefined
659
+ ? operation.xMm
660
+ : bounds.left;
661
+ core.drawHiddenText({
662
+ text: applyPageTokens(operation.text, core),
663
+ style,
664
+ x,
665
+ });
666
+ return;
667
+ }
668
+
669
+ const plugin = getPlugin(operation.type);
670
+ if (plugin) {
671
+ const bounds = getHorizontalBounds(core, templateBypassMargins);
672
+ if (typeof plugin.validate === "function") {
673
+ plugin.validate(operation);
674
+ }
675
+ plugin.render({ core, operation, theme, bounds, index, templateMode });
676
+ return;
677
+ }
678
+
679
+ throw new Error(`Unsupported operation type "${operation.type}" at index ${index}`);
680
+ }
681
+
682
+ function estimateOperationsHeight(ctx) {
683
+ const { core, theme, operations, indexPrefix, maxTextLines } = ctx;
684
+ let total = 0;
685
+ operations.forEach((operation, idx) => {
686
+ total += estimateOperationHeight({
687
+ core,
688
+ theme,
689
+ operation,
690
+ index: `${indexPrefix}${idx}`,
691
+ maxTextLines,
692
+ });
693
+ });
694
+ return total;
695
+ }
696
+
697
+ function estimateOperationHeight(ctx) {
698
+ const { core, theme, operation, index } = ctx;
699
+ if (!operation || !operation.type) {
700
+ throw new Error(`Invalid operation at index ${index}`);
701
+ }
702
+
703
+ if (operation.type === "block") {
704
+ const children = Array.isArray(operation.children) ? operation.children : null;
705
+ if (!children) {
706
+ throw new Error(`Block operation at index ${index} must define children[]`);
707
+ }
708
+ let total = estimateOperationsHeight({
709
+ core,
710
+ theme,
711
+ operations: children,
712
+ indexPrefix: `${index}.block.`,
713
+ });
714
+ if (operation.spaceAfterMm !== undefined) {
715
+ total += Number(operation.spaceAfterMm) || 0;
716
+ } else if (operation.spaceAfterPx !== undefined) {
717
+ total += pxToMm(operation.spaceAfterPx);
718
+ } else if (operation.spaceAfterLabel) {
719
+ const style = resolveLabelStyle(theme, operation.spaceAfterLabel, operation, index, "spaceAfterLabel");
720
+ total += estimateSpacerFromStyle(style, index);
721
+ }
722
+ return total;
723
+ }
724
+
725
+ if (operation.type === "text") {
726
+ const style = resolveLabelStyle(theme, operation.label, operation, index);
727
+ const lineHeightMm = style.lineHeightMm || resolveLineHeightMm(Number(style.fontSize) || 10, style.lineHeight);
728
+ const margins = getStyleMarginsMm(style);
729
+ const padding = getTextPaddingMm(style);
730
+
731
+ let lineCount;
732
+ if (ctx.maxTextLines > 0) {
733
+ lineCount = ctx.maxTextLines;
734
+ } else {
735
+ const x = operation.xMm !== undefined ? operation.xMm : core.marginLeftMm;
736
+ const maxWidth = operation.maxWidthMm !== undefined
737
+ ? operation.maxWidthMm
738
+ : core.pageWidth - core.marginRightMm - x;
739
+ const innerWidth = maxWidth - padding.left - padding.right;
740
+ if (innerWidth <= 0) {
741
+ throw new Error(`Operation ${index} (${operation.type}) has non-positive inner width after padding`);
742
+ }
743
+ const text = applyTextTransform(
744
+ String(operation.text !== undefined ? operation.text : ""),
745
+ style.textTransform
746
+ );
747
+ const lines = operation.wrap === false
748
+ ? [text]
749
+ : core.measureWrappedLines(text, innerWidth, style);
750
+ lineCount = Math.max(lines.length, 1);
751
+ }
752
+
753
+ return margins.top + padding.top + (lineCount * lineHeightMm) + padding.bottom + margins.bottom;
754
+ }
755
+
756
+ if (operation.type === "row") {
757
+ const leftStyle = resolveLabelStyle(theme, operation.leftLabel, operation, index, "leftLabel");
758
+ const rightStyle = resolveLabelStyle(theme, operation.rightLabel, operation, index, "rightLabel");
759
+ const leftMargins = getStyleMarginsMm(leftStyle);
760
+ const rightMargins = getStyleMarginsMm(rightStyle);
761
+ const top = Math.max(leftMargins.top, rightMargins.top);
762
+ const bottom = Math.max(leftMargins.bottom, rightMargins.bottom);
763
+ const leftHeight = resolveLineHeightMm(Number(leftStyle.fontSize) || 10, leftStyle.lineHeight);
764
+ const rightHeight = resolveLineHeightMm(Number(rightStyle.fontSize) || 10, rightStyle.lineHeight);
765
+ return top + Math.max(leftHeight, rightHeight) + bottom;
766
+ }
767
+
768
+ if (operation.type === "bullet") {
769
+ const style = resolveLabelStyle(theme, operation.label, operation, index);
770
+ const x = operation.xMm !== undefined ? operation.xMm : core.marginLeftMm;
771
+ const textIndentMm = operation.textIndentMm !== undefined
772
+ ? operation.textIndentMm
773
+ : (theme.layout && Number(theme.layout.bulletIndentMm)) || 4;
774
+ const maxWidth = operation.maxWidthMm !== undefined
775
+ ? operation.maxWidthMm
776
+ : core.pageWidth - core.marginRightMm - (x + textIndentMm);
777
+ const text = applyTextTransform(
778
+ String(operation.text !== undefined ? operation.text : ""),
779
+ style.textTransform
780
+ );
781
+ const lines = core.measureWrappedLines(text, maxWidth, style);
782
+ const lineCount = Math.max(lines.length, 1);
783
+ const lineHeightMm = style.lineHeightMm || resolveLineHeightMm(Number(style.fontSize) || 10, style.lineHeight);
784
+ const margins = getStyleMarginsMm(style);
785
+ return margins.top + (lineCount * lineHeightMm) + margins.bottom;
786
+ }
787
+
788
+ if (operation.type === "divider") {
789
+ const style = resolveLabelStyle(theme, operation.label, operation, index);
790
+ const margins = getStyleMarginsMm(style);
791
+ return margins.top + (Number(style.lineWidth) || 0.3) + margins.bottom;
792
+ }
793
+
794
+ if (operation.type === "spacer") {
795
+ if (operation.mm !== undefined) {
796
+ return Number(operation.mm) || 0;
797
+ }
798
+ if (operation.px !== undefined) {
799
+ return pxToMm(operation.px);
800
+ }
801
+ if (operation.label) {
802
+ const style = resolveLabelStyle(theme, operation.label, operation, index);
803
+ return estimateSpacerFromStyle(style, index);
804
+ }
805
+ throw new Error(`Spacer operation at index ${index} must provide mm, px, or label with spaceMm/spacePx`);
806
+ }
807
+
808
+ if (operation.type === "hiddenText") {
809
+ return 0;
810
+ }
811
+
812
+ const plugin = getPlugin(operation.type);
813
+ if (plugin) {
814
+ if (typeof plugin.estimateHeight === "function") {
815
+ return plugin.estimateHeight({ core, operation, theme, index });
816
+ }
817
+ return 20;
818
+ }
819
+
820
+ throw new Error(`Unsupported operation type "${operation.type}" at index ${index}`);
821
+ }
822
+
823
+ function moveFromSpacerStyle(core, style, index) {
824
+ if (style.spaceMm !== undefined) {
825
+ core.moveDown(Number(style.spaceMm) || 0);
826
+ return;
827
+ }
828
+ if (style.spacePx !== undefined) {
829
+ core.moveDown(pxToMm(style.spacePx));
830
+ return;
831
+ }
832
+ throw new Error(`Spacer style at index ${index} must contain spaceMm or spacePx`);
833
+ }
834
+
835
+ function estimateSpacerFromStyle(style, index) {
836
+ if (style.spaceMm !== undefined) {
837
+ return Number(style.spaceMm) || 0;
838
+ }
839
+ if (style.spacePx !== undefined) {
840
+ return pxToMm(style.spacePx);
841
+ }
842
+ throw new Error(`Spacer style at index ${index} must contain spaceMm or spacePx`);
843
+ }
844
+
845
+ function normalizeKeepWithNext(value) {
846
+ if (value === true) {
847
+ return 1;
848
+ }
849
+ const n = Number(value);
850
+ if (Number.isInteger(n) && n > 0) {
851
+ return n;
852
+ }
853
+ return 0;
854
+ }
855
+
856
+ function applyPageTokens(value, core) {
857
+ if (value === undefined || value === null) {
858
+ return "";
859
+ }
860
+ const page = core.doc.getNumberOfPages();
861
+ return String(value).replace(/\{\{page\}\}/g, String(page));
862
+ }
863
+
864
+ function getHorizontalBounds(core, bypassMargins) {
865
+ if (bypassMargins) {
866
+ return { left: 0, right: core.pageWidth };
867
+ }
868
+ return {
869
+ left: core.marginLeftMm,
870
+ right: core.pageWidth - core.marginRightMm,
871
+ };
872
+ }
873
+
874
+ function validatePointInsideBounds(fieldName, value, bounds, index, bypassMargins) {
875
+ if (bypassMargins) {
876
+ return;
877
+ }
878
+ if (value < bounds.left || value > bounds.right) {
879
+ throw new Error(
880
+ `Operation ${index} has ${fieldName}=${value} outside content bounds (${bounds.left}..${bounds.right})`
881
+ );
882
+ }
883
+ }
884
+
885
+ function countLeafOperations(operations) {
886
+ let count = 0;
887
+ operations.forEach((operation) => {
888
+ if (operation && operation.type === "block" && Array.isArray(operation.children)) {
889
+ count += countLeafOperations(operation.children);
890
+ return;
891
+ }
892
+ count += 1;
893
+ });
894
+ return count;
895
+ }
896
+
897
+ function resolveLabelStyle(theme, label, operation, index, fieldName, optional) {
898
+ if (!label) {
899
+ if (optional) {
900
+ return null;
901
+ }
902
+ const keyName = fieldName || "label";
903
+ throw new Error(`Operation ${index} (${operation.type}) is missing ${keyName}`);
904
+ }
905
+ if (!theme.labels || !theme.labels[label]) {
906
+ if (optional) {
907
+ return null;
908
+ }
909
+ throw new Error(`No theme style found for label "${label}" (operation ${index}, type ${operation.type})`);
910
+ }
911
+ return theme.labels[label];
912
+ }
913
+
914
+ /**
915
+ * Strip background/border properties from a style so drawText
916
+ * renders text only, without drawing its own container rect.
917
+ */
918
+ function stripContainerProps(style) {
919
+ const s = Object.assign({}, style);
920
+ delete s.backgroundColor;
921
+ delete s.borderWidthMm;
922
+ delete s.borderWidth;
923
+ delete s.borderColor;
924
+ delete s.borderRadiusMm;
925
+ delete s.leftBorder;
926
+ return s;
927
+ }
928
+
929
+ module.exports = {
930
+ renderDocument,
931
+ };