pdfmake 0.3.4 → 0.3.6

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/js/DocMeasure.js CHANGED
@@ -86,9 +86,11 @@ class DocMeasure {
86
86
  node._width = node._minWidth = node._maxWidth = node.cover.width;
87
87
  node._height = node._minHeight = node._maxHeight = node.cover.height;
88
88
  } else {
89
+ let nodeWidth = (0, _variableType.isNumber)(node.width) ? node.width : undefined;
90
+ let nodeHeight = (0, _variableType.isNumber)(node.height) ? node.height : undefined;
89
91
  let ratio = dimensions.width / dimensions.height;
90
- node._width = node._minWidth = node._maxWidth = node.width || (node.height ? node.height * ratio : dimensions.width);
91
- node._height = node.height || (node.width ? node.width / ratio : dimensions.height);
92
+ node._width = node._minWidth = node._maxWidth = nodeWidth || (nodeHeight ? nodeHeight * ratio : dimensions.width);
93
+ node._height = nodeHeight || (nodeWidth ? nodeWidth / ratio : dimensions.height);
92
94
  if ((0, _variableType.isNumber)(node.maxWidth) && node.maxWidth < node._width) {
93
95
  node._width = node._minWidth = node._maxWidth = node.maxWidth;
94
96
  node._height = node._width * dimensions.height / dimensions.width;
@@ -204,6 +206,9 @@ class DocMeasure {
204
206
  style: lineNumberStyle,
205
207
  margin: [0, lineMargin[1], 0, lineMargin[3]]
206
208
  }]);
209
+ if (node.toc.outlines) {
210
+ item._textNodeRef.outline = item._textNodeRef.outline || true;
211
+ }
207
212
  }
208
213
  node.toc._table = {
209
214
  table: {
@@ -20,7 +20,7 @@ class DocumentContext extends _events.EventEmitter {
20
20
  this.snapshots = [];
21
21
  this.backgroundLength = [];
22
22
  }
23
- beginColumnGroup(marginXTopParent, bottomByPage = {}) {
23
+ beginColumnGroup(marginXTopParent, bottomByPage = {}, snakingColumns = false, columnGap = 0, columnWidths = null) {
24
24
  this.snapshots.push({
25
25
  x: this.x,
26
26
  y: this.y,
@@ -35,7 +35,10 @@ class DocumentContext extends _events.EventEmitter {
35
35
  availableWidth: this.availableWidth,
36
36
  page: this.page
37
37
  },
38
- lastColumnWidth: this.lastColumnWidth
38
+ lastColumnWidth: this.lastColumnWidth,
39
+ snakingColumns: snakingColumns,
40
+ gap: columnGap,
41
+ columnWidths: columnWidths
39
42
  });
40
43
  this.lastColumnWidth = 0;
41
44
  if (marginXTopParent) {
@@ -44,18 +47,71 @@ class DocumentContext extends _events.EventEmitter {
44
47
  }
45
48
  updateBottomByPage() {
46
49
  const lastSnapshot = this.snapshots[this.snapshots.length - 1];
50
+ if (!lastSnapshot) {
51
+ return;
52
+ }
47
53
  const lastPage = this.page;
48
54
  let previousBottom = -Number.MIN_VALUE;
49
- if (lastSnapshot.bottomByPage[lastPage]) {
55
+ if (lastSnapshot.bottomByPage && lastSnapshot.bottomByPage[lastPage]) {
50
56
  previousBottom = lastSnapshot.bottomByPage[lastPage];
51
57
  }
52
- lastSnapshot.bottomByPage[lastPage] = Math.max(previousBottom, this.y);
58
+ if (lastSnapshot.bottomByPage) {
59
+ lastSnapshot.bottomByPage[lastPage] = Math.max(previousBottom, this.y);
60
+ }
53
61
  }
54
62
  resetMarginXTopParent() {
55
63
  this.marginXTopParent = null;
56
64
  }
65
+
66
+ /**
67
+ * Find the most recent (deepest) snaking column group snapshot.
68
+ * @returns {object|null}
69
+ */
70
+ getSnakingSnapshot() {
71
+ for (let i = this.snapshots.length - 1; i >= 0; i--) {
72
+ if (this.snapshots[i].snakingColumns) {
73
+ return this.snapshots[i];
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+ inSnakingColumns() {
79
+ return !!this.getSnakingSnapshot();
80
+ }
81
+
82
+ /**
83
+ * Check if we're inside a nested non-snaking column group (e.g., a table row)
84
+ * within an outer snaking column group. This is used to prevent snaking-specific
85
+ * breaks inside table cells — the table's own page break mechanism should handle
86
+ * row breaks, and column breaks should happen between rows.
87
+ * @returns {boolean}
88
+ */
89
+ isInNestedNonSnakingGroup() {
90
+ for (let i = this.snapshots.length - 1; i >= 0; i--) {
91
+ let snap = this.snapshots[i];
92
+ if (snap.snakingColumns) {
93
+ return false; // Reached snaking snapshot without finding inner group
94
+ }
95
+ if (!snap.overflowed) {
96
+ return true; // Found non-snaking, non-overflowed inner group
97
+ }
98
+ }
99
+ return false;
100
+ }
57
101
  beginColumn(width, offset, endingCell) {
102
+ // Find the correct snapshot for this column group.
103
+ // When a snaking column break (moveToNextColumn) occurs during inner column
104
+ // processing, overflowed snapshots may sit above this column group's snapshot.
105
+ // We need to skip past those to find the one from our beginColumnGroup call.
58
106
  let saved = this.snapshots[this.snapshots.length - 1];
107
+ if (saved && saved.overflowed) {
108
+ for (let i = this.snapshots.length - 1; i >= 0; i--) {
109
+ if (!this.snapshots[i].overflowed) {
110
+ saved = this.snapshots[i];
111
+ break;
112
+ }
113
+ }
114
+ }
59
115
  this.calculateBottomMost(saved, endingCell);
60
116
  this.page = saved.page;
61
117
  this.x = this.x + this.lastColumnWidth + (offset || 0);
@@ -91,6 +147,40 @@ class DocumentContext extends _events.EventEmitter {
91
147
  }
92
148
  completeColumnGroup(height, endingCell) {
93
149
  let saved = this.snapshots.pop();
150
+
151
+ // Track the maximum bottom position across all columns (including overflowed).
152
+ // Critical for snaking: content after columns must appear below the tallest column.
153
+ let maxBottomY = this.y;
154
+ let maxBottomPage = this.page;
155
+ let maxBottomAvailableHeight = this.availableHeight;
156
+
157
+ // Pop overflowed snapshots created by moveToNextColumn (snaking columns).
158
+ // Merge their bottomMost values to find the true maximum.
159
+ while (saved && saved.overflowed) {
160
+ let bm = bottomMostContext({
161
+ page: maxBottomPage,
162
+ y: maxBottomY,
163
+ availableHeight: maxBottomAvailableHeight
164
+ }, saved.bottomMost || {});
165
+ maxBottomPage = bm.page;
166
+ maxBottomY = bm.y;
167
+ maxBottomAvailableHeight = bm.availableHeight;
168
+ saved = this.snapshots.pop();
169
+ }
170
+ if (!saved) {
171
+ return {};
172
+ }
173
+
174
+ // Apply the max bottom from all overflowed columns to this base snapshot
175
+ if (maxBottomPage > saved.bottomMost.page || maxBottomPage === saved.bottomMost.page && maxBottomY > saved.bottomMost.y) {
176
+ saved.bottomMost = {
177
+ x: saved.x,
178
+ y: maxBottomY,
179
+ page: maxBottomPage,
180
+ availableHeight: maxBottomAvailableHeight,
181
+ availableWidth: saved.availableWidth
182
+ };
183
+ }
94
184
  this.calculateBottomMost(saved, endingCell);
95
185
  this.x = saved.x;
96
186
  let y = saved.bottomMost.y;
@@ -118,6 +208,140 @@ class DocumentContext extends _events.EventEmitter {
118
208
  this.lastColumnWidth = saved.lastColumnWidth;
119
209
  return saved.bottomByPage;
120
210
  }
211
+
212
+ /**
213
+ * Move to the next column in a column group (snaking columns).
214
+ * Creates an overflowed snapshot to track that we've moved to the next column.
215
+ * @returns {object} Position info for the new column
216
+ */
217
+ moveToNextColumn() {
218
+ let prevY = this.y;
219
+ let snakingSnapshot = this.getSnakingSnapshot();
220
+ if (!snakingSnapshot) {
221
+ return {
222
+ prevY: prevY,
223
+ y: this.y
224
+ };
225
+ }
226
+
227
+ // Update snaking snapshot's bottomMost with current position BEFORE resetting.
228
+ // This captures where content reached in the current column (overflow point).
229
+ this.calculateBottomMost(snakingSnapshot, null);
230
+
231
+ // Calculate new X position: move right by current column width + gap
232
+ let overflowCount = 0;
233
+ for (let i = this.snapshots.length - 1; i >= 0; i--) {
234
+ if (this.snapshots[i].overflowed) {
235
+ overflowCount++;
236
+ } else {
237
+ break;
238
+ }
239
+ }
240
+ let currentColumnWidth = snakingSnapshot.columnWidths && snakingSnapshot.columnWidths[overflowCount] || this.lastColumnWidth || this.availableWidth;
241
+ let nextColumnWidth = snakingSnapshot.columnWidths && snakingSnapshot.columnWidths[overflowCount + 1] || currentColumnWidth;
242
+ this.lastColumnWidth = nextColumnWidth;
243
+ let newX = this.x + (currentColumnWidth || 0) + (snakingSnapshot.gap || 0);
244
+ let newY = snakingSnapshot.y;
245
+ this.snapshots.push({
246
+ x: newX,
247
+ y: newY,
248
+ availableHeight: snakingSnapshot.availableHeight,
249
+ availableWidth: nextColumnWidth,
250
+ page: this.page,
251
+ overflowed: true,
252
+ bottomMost: {
253
+ x: newX,
254
+ y: newY,
255
+ availableHeight: snakingSnapshot.availableHeight,
256
+ availableWidth: nextColumnWidth,
257
+ page: this.page
258
+ },
259
+ lastColumnWidth: nextColumnWidth,
260
+ snakingColumns: true,
261
+ gap: snakingSnapshot.gap,
262
+ columnWidths: snakingSnapshot.columnWidths
263
+ });
264
+ this.x = newX;
265
+ this.y = newY;
266
+ this.availableHeight = snakingSnapshot.availableHeight;
267
+ this.availableWidth = nextColumnWidth;
268
+
269
+ // Sync non-overflowed inner snapshots (e.g. inner column groups for
270
+ // product/price rows) with the new snaking column position.
271
+ // Without this, inner beginColumn would read stale y/page/x values.
272
+ for (let i = this.snapshots.length - 2; i >= 0; i--) {
273
+ let snapshot = this.snapshots[i];
274
+ if (snapshot.overflowed || snapshot.snakingColumns) {
275
+ break; // Stop at first overflowed or snaking snapshot
276
+ }
277
+ snapshot.x = newX;
278
+ snapshot.y = newY;
279
+ snapshot.page = this.page;
280
+ snapshot.availableHeight = snakingSnapshot.availableHeight;
281
+ if (snapshot.bottomMost) {
282
+ snapshot.bottomMost.x = newX;
283
+ snapshot.bottomMost.y = newY;
284
+ snapshot.bottomMost.page = this.page;
285
+ snapshot.bottomMost.availableHeight = snakingSnapshot.availableHeight;
286
+ }
287
+ }
288
+ return {
289
+ prevY: prevY,
290
+ y: this.y
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Reset snaking column state when moving to a new page.
296
+ * Clears overflowed snapshots, resets X to left margin, sets width to first column,
297
+ * and syncs all snapshots to new page coordinates.
298
+ */
299
+ resetSnakingColumnsForNewPage() {
300
+ let snakingSnapshot = this.getSnakingSnapshot();
301
+ if (!snakingSnapshot) {
302
+ return;
303
+ }
304
+ let pageTopY = this.pageMargins.top;
305
+ let pageInnerHeight = this.getCurrentPage().pageSize.height - this.pageMargins.top - this.pageMargins.bottom;
306
+
307
+ // When moving to new page, start at first column.
308
+ // Reset width to FIRST column width, not last column from previous page.
309
+ let firstColumnWidth = snakingSnapshot.columnWidths ? snakingSnapshot.columnWidths[0] : this.lastColumnWidth || this.availableWidth;
310
+
311
+ // Clean up overflowed snapshots
312
+ while (this.snapshots.length > 1 && this.snapshots[this.snapshots.length - 1].overflowed) {
313
+ this.snapshots.pop();
314
+ }
315
+
316
+ // Reset X to start of first column (left margin)
317
+ if (this.marginXTopParent) {
318
+ this.x = this.pageMargins.left + this.marginXTopParent[0];
319
+ } else {
320
+ this.x = this.pageMargins.left;
321
+ }
322
+ this.availableWidth = firstColumnWidth;
323
+ this.lastColumnWidth = firstColumnWidth;
324
+
325
+ // Sync all snapshots to new page state.
326
+ // When page break occurs within snaking columns, update ALL snapshots
327
+ // (not just snaking column snapshots) to reflect new page coordinates.
328
+ // This ensures nested structures (like inner product/price columns)
329
+ // don't retain stale values that would cause layout corruption.
330
+ for (let i = 0; i < this.snapshots.length; i++) {
331
+ let snapshot = this.snapshots[i];
332
+ let isSnakingSnapshot = !!snapshot.snakingColumns;
333
+ snapshot.x = this.x;
334
+ snapshot.y = isSnakingSnapshot ? pageTopY : this.y;
335
+ snapshot.availableHeight = isSnakingSnapshot ? pageInnerHeight : this.availableHeight;
336
+ snapshot.page = this.page;
337
+ if (snapshot.bottomMost) {
338
+ snapshot.bottomMost.x = this.x;
339
+ snapshot.bottomMost.y = isSnakingSnapshot ? pageTopY : this.y;
340
+ snapshot.bottomMost.availableHeight = isSnakingSnapshot ? pageInnerHeight : this.availableHeight;
341
+ snapshot.bottomMost.page = this.page;
342
+ }
343
+ }
344
+ }
121
345
  addMargin(left, right) {
122
346
  this.x += left;
123
347
  this.availableWidth -= left + (right || 0);
@@ -356,7 +356,11 @@ class LayoutBuilder {
356
356
  if (availableHeight - margin[1] < 0) {
357
357
  // Consume the whole available space
358
358
  this.writer.context().moveDown(availableHeight);
359
- this.writer.moveToNextPage(node.pageOrientation);
359
+ if (this.writer.context().inSnakingColumns() && !this.writer.context().isInNestedNonSnakingGroup()) {
360
+ this.snakingAwarePageBreak(node.pageOrientation);
361
+ } else {
362
+ this.writer.moveToNextPage(node.pageOrientation);
363
+ }
360
364
  /**
361
365
  * TODO - Something to consider:
362
366
  * Right now the node starts at the top of next page (after header)
@@ -378,7 +382,11 @@ class LayoutBuilder {
378
382
  // Necessary for nodes inside tables
379
383
  if (availableHeight - margin[3] < 0) {
380
384
  this.writer.context().moveDown(availableHeight);
381
- this.writer.moveToNextPage(node.pageOrientation);
385
+ if (this.writer.context().inSnakingColumns() && !this.writer.context().isInNestedNonSnakingGroup()) {
386
+ this.snakingAwarePageBreak(node.pageOrientation);
387
+ } else {
388
+ this.writer.moveToNextPage(node.pageOrientation);
389
+ }
382
390
  /**
383
391
  * TODO - Something to consider:
384
392
  * Right now next node starts at the top of next page (after header)
@@ -477,6 +485,37 @@ class LayoutBuilder {
477
485
  }
478
486
  }
479
487
 
488
+ /**
489
+ * Helper for page breaks that respects snaking column context.
490
+ * When in snaking columns, first tries moving to next column.
491
+ * If no columns available, moves to next page and resets x to left margin.
492
+ * @param {string} pageOrientation - Optional page orientation for the new page
493
+ */
494
+ snakingAwarePageBreak(pageOrientation) {
495
+ let ctx = this.writer.context();
496
+ let snakingSnapshot = ctx.getSnakingSnapshot();
497
+ if (!snakingSnapshot) {
498
+ return;
499
+ }
500
+
501
+ // Try flowing to next column first
502
+ if (this.writer.canMoveToNextColumn()) {
503
+ this.writer.moveToNextColumn();
504
+ return;
505
+ }
506
+
507
+ // No more columns available, move to new page
508
+ this.writer.moveToNextPage(pageOrientation);
509
+
510
+ // Reset snaking column state for the new page
511
+ // Save lastColumnWidth before reset — if we're inside a nested
512
+ // column group (e.g. product/price row), the reset would overwrite
513
+ // it with the snaking column width, corrupting inner column layout.
514
+ let savedLastColumnWidth = ctx.lastColumnWidth;
515
+ ctx.resetSnakingColumnsForNewPage();
516
+ ctx.lastColumnWidth = savedLastColumnWidth;
517
+ }
518
+
480
519
  // vertical container
481
520
  processVerticalContainer(node) {
482
521
  node.stack.forEach(item => {
@@ -557,7 +596,8 @@ class LayoutBuilder {
557
596
  marginX: columnNode._margin ? [columnNode._margin[0], columnNode._margin[2]] : [0, 0],
558
597
  cells: columns,
559
598
  widths: columns,
560
- gaps
599
+ gaps,
600
+ snakingColumns: columnNode.snakingColumns
561
601
  });
562
602
  addAll(columnNode.positions, result.positions);
563
603
  this.nestedLevel--;
@@ -743,7 +783,8 @@ class LayoutBuilder {
743
783
  tableNode,
744
784
  tableBody,
745
785
  rowIndex,
746
- height
786
+ height,
787
+ snakingColumns = false
747
788
  }) {
748
789
  const isUnbreakableRow = dontBreakRows || rowIndex <= rowsWithoutPageBreak - 1;
749
790
  let pageBreaks = [];
@@ -761,7 +802,18 @@ class LayoutBuilder {
761
802
  // Use the marginX if we are in a top level table/column (not nested)
762
803
  const marginXParent = this.nestedLevel === 1 ? marginX : null;
763
804
  const _bottomByPage = tableNode ? tableNode._bottomByPage : null;
764
- this.writer.context().beginColumnGroup(marginXParent, _bottomByPage);
805
+ // Pass column gap and widths to context snapshot for snaking columns
806
+ // to advance correctly and reset to first-column width on new pages.
807
+ const columnGapForGroup = gaps && gaps.length > 1 ? gaps[1] : 0;
808
+ const columnWidthsForContext = widths.map(w => w._calcWidth);
809
+ this.writer.context().beginColumnGroup(marginXParent, _bottomByPage, snakingColumns, columnGapForGroup, columnWidthsForContext);
810
+
811
+ // IMPORTANT: We iterate ALL columns even when snakingColumns is enabled.
812
+ // This is intentional — beginColumn() must be called for each column to set up
813
+ // proper geometry (widths, offsets) and rowspan/colspan tracking. The
814
+ // completeColumnGroup() call at the end depends on this bookkeeping to compute
815
+ // heights correctly. Content processing is skipped for columns > 0 via
816
+ // skipForSnaking below, but the column structure must still be established.
765
817
  for (let i = 0, l = cells.length; i < l; i++) {
766
818
  let cell = cells[i];
767
819
  let cellIndexBegin = i;
@@ -814,7 +866,13 @@ class LayoutBuilder {
814
866
 
815
867
  // We pass the endingSpanCell reference to store the context just after processing rowspan cell
816
868
  this.writer.context().beginColumn(width, leftOffset, endOfRowSpanCell);
817
- if (!cell._span) {
869
+
870
+ // When snaking, only process content from the first column (i === 0).
871
+ // Content overflows into subsequent columns via moveToNextColumn().
872
+ // We skip content processing here but NOT the beginColumn() call above —
873
+ // the column geometry setup is still needed for proper layout bookkeeping.
874
+ const skipForSnaking = snakingColumns && i > 0;
875
+ if (!cell._span && !skipForSnaking) {
818
876
  this.processNode(cell, true);
819
877
  this.writer.context().updateBottomByPage();
820
878
  if (cell.verticalAlignment) {
@@ -867,7 +925,11 @@ class LayoutBuilder {
867
925
  // If content did not break page, check if we should break by height
868
926
  if (willBreakByHeight && !isUnbreakableRow && pageBreaks.length === 0) {
869
927
  this.writer.context().moveDown(this.writer.context().availableHeight);
870
- this.writer.moveToNextPage();
928
+ if (snakingColumns) {
929
+ this.snakingAwarePageBreak();
930
+ } else {
931
+ this.writer.moveToNextPage();
932
+ }
871
933
  }
872
934
  const bottomByPage = this.writer.context().completeColumnGroup(height, endingSpanCell);
873
935
  if (tableNode) {
@@ -957,7 +1019,27 @@ class LayoutBuilder {
957
1019
  let processor = new _TableProcessor.default(tableNode);
958
1020
  processor.beginTable(this.writer);
959
1021
  let rowHeights = tableNode.table.heights;
1022
+ let lastRowHeight = 0;
960
1023
  for (let i = 0, l = tableNode.table.body.length; i < l; i++) {
1024
+ // Between table rows: check if we should move to the next snaking column.
1025
+ // This must happen AFTER the previous row's endRow (borders drawn) and
1026
+ // BEFORE this row's beginRow. At this point, the table row column group
1027
+ // has been completed, so canMoveToNextColumn() works correctly.
1028
+ if (i > 0 && this.writer.context().inSnakingColumns()) {
1029
+ // Estimate minimum space for next row: use last row's height as heuristic,
1030
+ // or fall back to a minimum of padding + line height + border
1031
+ let minRowHeight = lastRowHeight > 0 ? lastRowHeight : processor.rowPaddingTop + 14 + processor.rowPaddingBottom + processor.bottomLineWidth + processor.topLineWidth;
1032
+ if (this.writer.context().availableHeight < minRowHeight) {
1033
+ this.snakingAwarePageBreak();
1034
+
1035
+ // Skip border when headerRows present (header repeat includes it)
1036
+ if (processor.layout.hLineWhenBroken !== false && !processor.headerRows) {
1037
+ processor.drawHorizontalLine(i, this.writer);
1038
+ }
1039
+ }
1040
+ }
1041
+ let rowYBefore = this.writer.context().y;
1042
+
961
1043
  // if dontBreakRows and row starts a rowspan
962
1044
  // we store the 'y' of the beginning of each rowSpan
963
1045
  if (processor.dontBreakRows) {
@@ -1002,6 +1084,12 @@ class LayoutBuilder {
1002
1084
  }
1003
1085
  }
1004
1086
  processor.endRow(i, this.writer, result.pageBreaks);
1087
+
1088
+ // Track the height of the completed row for the next row's estimate
1089
+ let rowYAfter = this.writer.context().y;
1090
+ if (this.writer.context().page === pageBeforeProcessing) {
1091
+ lastRowHeight = rowYAfter - rowYBefore;
1092
+ }
1005
1093
  }
1006
1094
  processor.endTable(this.writer);
1007
1095
  this.nestedLevel--;
@@ -1024,6 +1112,26 @@ class LayoutBuilder {
1024
1112
  line.id = nodeId;
1025
1113
  }
1026
1114
  }
1115
+ if (node.outline) {
1116
+ line._outline = {
1117
+ id: node.id,
1118
+ parentId: node.outlineParentId,
1119
+ text: node.outlineText || node.text,
1120
+ expanded: node.outlineExpanded || false
1121
+ };
1122
+ } else if (Array.isArray(node.text)) {
1123
+ for (let i = 0, l = node.text.length; i < l; i++) {
1124
+ let item = node.text[i];
1125
+ if (item.outline) {
1126
+ line._outline = {
1127
+ id: item.id,
1128
+ parentId: item.outlineParentId,
1129
+ text: item.outlineText || item.text,
1130
+ expanded: item.outlineExpanded || false
1131
+ };
1132
+ }
1133
+ }
1134
+ }
1027
1135
  if (node._tocItemRef) {
1028
1136
  line._pageNodeRef = node._tocItemRef;
1029
1137
  }
@@ -1041,6 +1149,27 @@ class LayoutBuilder {
1041
1149
  }
1042
1150
  }
1043
1151
  while (line && (maxHeight === -1 || currentHeight < maxHeight)) {
1152
+ // Check if line fits vertically in current context
1153
+ if (line.getHeight() > this.writer.context().availableHeight && this.writer.context().y > this.writer.context().pageMargins.top) {
1154
+ // Line doesn't fit, forced move to next page/column
1155
+ // Only do snaking-specific break if we're in snaking columns AND NOT inside
1156
+ // a nested non-snaking group (like a table row). Table cells should use
1157
+ // standard page breaks — column breaks happen between table rows instead.
1158
+ if (this.writer.context().inSnakingColumns() && !this.writer.context().isInNestedNonSnakingGroup()) {
1159
+ this.snakingAwarePageBreak(node.pageOrientation);
1160
+
1161
+ // Always reflow text after a snaking break (column or page).
1162
+ // This ensures text adapts to the new column width, whether it's narrower or wider.
1163
+ if (line.inlines && line.inlines.length > 0) {
1164
+ node._inlines.unshift(...line.inlines);
1165
+ }
1166
+ // Rebuild line with new width
1167
+ line = this.buildNextLine(node);
1168
+ continue;
1169
+ } else {
1170
+ this.writer.moveToNextPage(node.pageOrientation);
1171
+ }
1172
+ }
1044
1173
  let positions = this.writer.addLine(line);
1045
1174
  node.positions.push(positions);
1046
1175
  line = this.buildNextLine(node);
@@ -1094,7 +1223,6 @@ class LayoutBuilder {
1094
1223
  while (textNode._inlines && textNode._inlines.length > 0 && (line.hasEnoughSpaceForInline(textNode._inlines[0], textNode._inlines.slice(1)) || isForceContinue)) {
1095
1224
  let isHardWrap = false;
1096
1225
  let inline = textNode._inlines.shift();
1097
- isForceContinue = false;
1098
1226
  if (!inline.noWrap && inline.text.length > 1 && inline.width > line.getAvailableWidth()) {
1099
1227
  let maxChars = findMaxFitLength(inline.text, line.getAvailableWidth(), txt => textInlines.widthOfText(txt, inline));
1100
1228
  if (maxChars < inline.text.length) {
package/js/PDFDocument.js CHANGED
@@ -95,7 +95,9 @@ class PDFDocument extends _pdfkit.default {
95
95
  throw new Error('No image');
96
96
  }
97
97
  } catch (error) {
98
- throw new Error(`Invalid image: ${error.toString()}\nImages dictionary should contain dataURL entries (or local file paths in node.js)`);
98
+ throw new Error(`Invalid image: ${error.toString()}\nImages dictionary should contain dataURL entries (or local file paths in node.js)`, {
99
+ cause: error
100
+ });
99
101
  }
100
102
  image.embed(this);
101
103
  this._imageRegistry[src] = image;
@@ -149,11 +149,118 @@ class PageElementWriter extends _ElementWriter.default {
149
149
  popFromRepeatables() {
150
150
  this.repeatables.pop();
151
151
  }
152
+
153
+ /**
154
+ * Move to the next column in a column group (snaking columns).
155
+ * Handles repeatables and emits columnChanged event.
156
+ */
157
+ moveToNextColumn() {
158
+ let nextColumn = this.context().moveToNextColumn();
159
+
160
+ // Handle repeatables (like table headers) for the new column
161
+ this.repeatables.forEach(function (rep) {
162
+ // In snaking columns, we WANT headers to repeat.
163
+ // However, in Standard Page Breaks, headers are drawn using useBlockXOffset=true (original absolute X).
164
+ // This works for page breaks because margins are consistent.
165
+ // In Snaking Columns, the X position changes for each column.
166
+ // If we use true, the header is drawn at the *original* X position (Col 1), overlapping/invisible.
167
+ // We MUST use false to force drawing relative to the CURRENT context X (new column start).
168
+ this.addFragment(rep, false);
169
+ }, this);
170
+ this.emit('columnChanged', {
171
+ prevY: nextColumn.prevY,
172
+ y: this.context().y
173
+ });
174
+ }
175
+
176
+ /**
177
+ * Check if currently in a column group that can move to next column.
178
+ * Only returns true if snakingColumns is enabled for the column group.
179
+ * @returns {boolean}
180
+ */
181
+ canMoveToNextColumn() {
182
+ let ctx = this.context();
183
+ let snakingSnapshot = ctx.getSnakingSnapshot();
184
+ if (snakingSnapshot) {
185
+ // Check if we're inside a nested (non-snaking) column group.
186
+ // If so, don't allow a snaking column move — it would corrupt
187
+ // the inner row's layout (e.g. product name in col 1, price in col 2).
188
+ // The inner row should complete via normal page break instead.
189
+ for (let i = ctx.snapshots.length - 1; i >= 0; i--) {
190
+ let snap = ctx.snapshots[i];
191
+ if (snap.snakingColumns) {
192
+ break; // Reached the snaking snapshot, no inner groups found
193
+ }
194
+ if (!snap.overflowed) {
195
+ return false; // Found a non-snaking, non-overflowed inner group
196
+ }
197
+ }
198
+ let overflowCount = 0;
199
+ for (let i = ctx.snapshots.length - 1; i >= 0; i--) {
200
+ if (ctx.snapshots[i].overflowed) {
201
+ overflowCount++;
202
+ } else {
203
+ break;
204
+ }
205
+ }
206
+ if (snakingSnapshot.columnWidths && overflowCount >= snakingSnapshot.columnWidths.length - 1) {
207
+ return false;
208
+ }
209
+ let currentColumnWidth = ctx.availableWidth || ctx.lastColumnWidth || 0;
210
+ let nextColumnWidth = snakingSnapshot.columnWidths ? snakingSnapshot.columnWidths[overflowCount + 1] : currentColumnWidth;
211
+ let nextX = ctx.x + currentColumnWidth + (snakingSnapshot.gap || 0);
212
+ let page = ctx.getCurrentPage();
213
+ let pageWidth = page.pageSize.width;
214
+ let rightMargin = page.pageMargins ? page.pageMargins.right : 0;
215
+ let parentRightMargin = ctx.marginXTopParent ? ctx.marginXTopParent[1] : 0;
216
+ let rightBoundary = pageWidth - rightMargin - parentRightMargin;
217
+ return nextX + nextColumnWidth <= rightBoundary + 1;
218
+ }
219
+ return false;
220
+ }
152
221
  _fitOnPage(addFct) {
153
222
  let position = addFct();
154
223
  if (!position) {
155
- this.moveToNextPage();
156
- position = addFct();
224
+ if (this.canMoveToNextColumn()) {
225
+ this.moveToNextColumn();
226
+ position = addFct();
227
+ }
228
+ if (!position) {
229
+ let ctx = this.context();
230
+ let snakingSnapshot = ctx.getSnakingSnapshot();
231
+ if (snakingSnapshot) {
232
+ if (ctx.isInNestedNonSnakingGroup()) {
233
+ // Inside a table cell within snaking columns — use standard page break.
234
+ // Don't reset snaking state; the table handles its own breaks.
235
+ // Column breaks happen between rows in processTable instead.
236
+ this.moveToNextPage();
237
+ } else {
238
+ this.moveToNextPage();
239
+
240
+ // Save lastColumnWidth before reset — if we're inside a nested
241
+ // column group (e.g. product/price row), the reset would overwrite
242
+ // it with the snaking column width, corrupting inner column layout.
243
+ let savedLastColumnWidth = ctx.lastColumnWidth;
244
+ ctx.resetSnakingColumnsForNewPage();
245
+ ctx.lastColumnWidth = savedLastColumnWidth;
246
+ }
247
+ position = addFct();
248
+ } else {
249
+ while (ctx.snapshots.length > 0 && ctx.snapshots[ctx.snapshots.length - 1].overflowed) {
250
+ let popped = ctx.snapshots.pop();
251
+ let prevSnapshot = ctx.snapshots[ctx.snapshots.length - 1];
252
+ if (prevSnapshot) {
253
+ ctx.x = prevSnapshot.x;
254
+ ctx.y = prevSnapshot.y;
255
+ ctx.availableHeight = prevSnapshot.availableHeight;
256
+ ctx.availableWidth = popped.availableWidth;
257
+ ctx.lastColumnWidth = prevSnapshot.lastColumnWidth;
258
+ }
259
+ }
260
+ this.moveToNextPage();
261
+ position = addFct();
262
+ }
263
+ }
157
264
  }
158
265
  return position;
159
266
  }