@flowaccount/pdfmake 1.0.0 → 1.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowaccount/pdfmake",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Client/server side PDF printing in pure JavaScript",
5
5
  "main": "src/printer.js",
6
6
  "browser": "build/pdfmake.js",
@@ -68,7 +68,7 @@
68
68
  "format:check": "prettier --check .",
69
69
  "publish:local": "cp .npmrc.local .npmrc && npm version 0.2.20-local.$(date +%s) --no-git-tag-version && npm publish --registry=http://localhost:4872",
70
70
  "publish:staging": "npm run build && npm run test && npm version prerelease --preid=staging --no-git-tag-version && npm publish --tag staging --access public",
71
- "publish:production": "npm run build && npm run test && npm publish --access public",
71
+ "publish:production": "npm run build && npm run test && npm publish",
72
72
  "version:patch": "npm version patch",
73
73
  "version:minor": "npm version minor",
74
74
  "version:major": "npm version major"
@@ -75,7 +75,7 @@ DocumentContext.prototype.resetMarginXTopParent = function () {
75
75
  this.marginXTopParent = null;
76
76
  };
77
77
 
78
- DocumentContext.prototype.beginColumn = function (width, offset, endingCell) {
78
+ DocumentContext.prototype.beginColumn = function (width, offset, endingCell, heightOffset) {
79
79
  var saved = this.snapshots[this.snapshots.length - 1];
80
80
 
81
81
  this.calculateBottomMost(saved, endingCell);
@@ -84,7 +84,7 @@ DocumentContext.prototype.beginColumn = function (width, offset, endingCell) {
84
84
  this.x = this.x + this.lastColumnWidth + (offset || 0);
85
85
  this.y = saved.y;
86
86
  this.availableWidth = width; //saved.availableWidth - offset;
87
- this.availableHeight = saved.availableHeight;
87
+ this.availableHeight = saved.availableHeight - (heightOffset || 0);
88
88
 
89
89
  this.lastColumnWidth = width;
90
90
  };
@@ -231,12 +231,14 @@ LayoutBuilder.prototype.applyFooterGapOption = function(opt) {
231
231
  if (!opt) return;
232
232
 
233
233
  if (typeof opt !== 'object') {
234
- this._footerGapOption = { enabled: true };
234
+ this._footerGapOption = { enabled: true, forcePageBreakForAllRows: false };
235
235
  return;
236
236
  }
237
237
 
238
238
  this._footerGapOption = {
239
239
  enabled: opt.enabled !== false,
240
+ minRowHeight: typeof opt.minRowHeight === 'number' ? opt.minRowHeight : undefined, // Optional fallback - system auto-calculates from cell content if not provided
241
+ forcePageBreakForAllRows: opt.forcePageBreakForAllRows === true, // Force page break for all rows (not just inline images)
240
242
  columns: opt.columns ? {
241
243
  widths: Array.isArray(opt.columns.widths) ? opt.columns.widths.slice() : undefined,
242
244
  widthLength: opt.columns.widths.length || 0,
@@ -818,6 +820,154 @@ LayoutBuilder.prototype._colLeftOffset = function (i, gaps) {
818
820
  return 0;
819
821
  };
820
822
 
823
+ /**
824
+ * Checks if a cell or node contains an inline image.
825
+ *
826
+ * @param {object} node - The node to check for inline images.
827
+ * @returns {boolean} True if the node contains an inline image; otherwise, false.
828
+ */
829
+ LayoutBuilder.prototype._containsInlineImage = function (node) {
830
+ if (!node) {
831
+ return false;
832
+ }
833
+
834
+ // Direct image node
835
+ if (node.image) {
836
+ return true;
837
+ }
838
+
839
+
840
+ // Check in table
841
+ if (node.table && isArray(node.table.body)) {
842
+ for (var r = 0; r < node.table.body.length; r++) {
843
+ if (isArray(node.table.body[r])) {
844
+ for (var c = 0; c < node.table.body[r].length; c++) {
845
+ if (this._containsInlineImage(node.table.body[r][c])) {
846
+ return true;
847
+ }
848
+ }
849
+ }
850
+ }
851
+ }
852
+
853
+ return false;
854
+ };
855
+
856
+
857
+ /**
858
+ * Gets the maximum image height from cells in a row.
859
+ *
860
+ * @param {Array<object>} cells - Array of cell objects in a row.
861
+ * @returns {number} The maximum image height found in the cells.
862
+ */
863
+ LayoutBuilder.prototype._getMaxImageHeight = function (cells) {
864
+ var maxHeight = 0;
865
+
866
+ for (var i = 0; i < cells.length; i++) {
867
+ var cellHeight = this._getImageHeightFromNode(cells[i]);
868
+ if (cellHeight > maxHeight) {
869
+ maxHeight = cellHeight;
870
+ }
871
+ }
872
+
873
+ return maxHeight;
874
+ };
875
+
876
+ /**
877
+ * Gets the maximum estimated height from cells in a row.
878
+ * Checks for measured heights (_height property) and content-based heights.
879
+ *
880
+ * @param {Array<object>} cells - Array of cell objects in a row.
881
+ * @returns {number} The maximum estimated height found in the cells, or 0 if cannot estimate.
882
+ */
883
+ LayoutBuilder.prototype._getMaxCellHeight = function (cells) {
884
+ var maxHeight = 0;
885
+
886
+ for (var i = 0; i < cells.length; i++) {
887
+ var cell = cells[i];
888
+ if (!cell || cell._span) {
889
+ continue; // Skip null cells and span placeholders
890
+ }
891
+
892
+ var cellHeight = 0;
893
+
894
+ // Check if cell has measured height from docMeasure phase
895
+ if (cell._height) {
896
+ cellHeight = cell._height;
897
+ }
898
+ // Check for image content
899
+ else if (cell.image && cell._maxHeight) {
900
+ cellHeight = cell._maxHeight;
901
+ }
902
+ // Check for nested content with height
903
+ else {
904
+ cellHeight = this._getImageHeightFromNode(cell);
905
+ }
906
+
907
+ if (cellHeight > maxHeight) {
908
+ maxHeight = cellHeight;
909
+ }
910
+ }
911
+
912
+ return maxHeight;
913
+ };
914
+
915
+ /**
916
+ * Recursively gets image height from a node.
917
+ *
918
+ * @param {object} node - The node to extract image height from.
919
+ * @returns {number} The image height if found; otherwise, 0.
920
+ */
921
+ LayoutBuilder.prototype._getImageHeightFromNode = function (node) {
922
+ if (!node) {
923
+ return 0;
924
+ }
925
+
926
+ // Direct image node with height
927
+ if (node.image && node._height) {
928
+ return node._height;
929
+ }
930
+
931
+ var maxHeight = 0;
932
+
933
+ // Check in stack
934
+ if (isArray(node.stack)) {
935
+ for (var i = 0; i < node.stack.length; i++) {
936
+ var h = this._getImageHeightFromNode(node.stack[i]);
937
+ if (h > maxHeight) {
938
+ maxHeight = h;
939
+ }
940
+ }
941
+ }
942
+
943
+ // Check in columns
944
+ if (isArray(node.columns)) {
945
+ for (var j = 0; j < node.columns.length; j++) {
946
+ var h2 = this._getImageHeightFromNode(node.columns[j]);
947
+ if (h2 > maxHeight) {
948
+ maxHeight = h2;
949
+ }
950
+ }
951
+ }
952
+
953
+ // Check in table
954
+ if (node.table && isArray(node.table.body)) {
955
+ for (var r = 0; r < node.table.body.length; r++) {
956
+ if (isArray(node.table.body[r])) {
957
+ for (var c = 0; c < node.table.body[r].length; c++) {
958
+ var h3 = this._getImageHeightFromNode(node.table.body[r][c]);
959
+ if (h3 > maxHeight) {
960
+ maxHeight = h3;
961
+ }
962
+ }
963
+ }
964
+ }
965
+ }
966
+
967
+ return maxHeight;
968
+ };
969
+
970
+
821
971
  /**
822
972
  * Retrieves the ending cell for a row span in case it exists in a specified table column.
823
973
  *
@@ -840,7 +990,7 @@ LayoutBuilder.prototype._getRowSpanEndingCell = function (tableBody, rowIndex, c
840
990
  return null;
841
991
  };
842
992
 
843
- LayoutBuilder.prototype.processRow = function ({ marginX = [0, 0], dontBreakRows = false, rowsWithoutPageBreak = 0, cells, widths, gaps, tableNode, tableBody, rowIndex, height }) {
993
+ LayoutBuilder.prototype.processRow = function ({ marginX = [0, 0], dontBreakRows = false, rowsWithoutPageBreak = 0, cells, widths, gaps, tableNode, tableBody, rowIndex, height, heightOffset = 0 }) {
844
994
  var self = this;
845
995
  var isUnbreakableRow = dontBreakRows || rowIndex <= rowsWithoutPageBreak - 1;
846
996
  var pageBreaks = [];
@@ -848,8 +998,108 @@ LayoutBuilder.prototype.processRow = function ({ marginX = [0, 0], dontBreakRows
848
998
  var positions = [];
849
999
  var willBreakByHeight = false;
850
1000
  var columnAlignIndexes = {};
1001
+ var hasInlineImage = false;
851
1002
  widths = widths || cells;
852
1003
 
1004
+ // Check if row contains inline images
1005
+ if (!isUnbreakableRow) {
1006
+ for (var cellIdx = 0; cellIdx < cells.length; cellIdx++) {
1007
+ if (self._containsInlineImage(cells[cellIdx])) {
1008
+ hasInlineImage = true;
1009
+ break;
1010
+ }
1011
+ }
1012
+ }
1013
+
1014
+ // Check if row would cause page break and force move to next page first
1015
+ // This keeps the entire row together on the new page
1016
+ // Apply when: forcePageBreakForAllRows is enabled OR row has inline images
1017
+
1018
+ // Priority for forcePageBreakForAllRows setting:
1019
+ // 1. Table-specific layout.forcePageBreakForAllRows
1020
+ // 2. Tables with footerGapCollect: 'product-items' (auto-enabled)
1021
+ // 3. Global footerGapOption.forcePageBreakForAllRows
1022
+ var tableLayout = tableNode && tableNode._layout;
1023
+ var footerGapOpt = self.writer.context()._footerGapOption;
1024
+ var shouldForcePageBreak = false;
1025
+
1026
+ if (tableLayout && tableLayout.forcePageBreakForAllRows !== undefined) {
1027
+ // Table-specific setting takes precedence
1028
+ shouldForcePageBreak = tableLayout.forcePageBreakForAllRows === true;
1029
+ }
1030
+
1031
+ if (!isUnbreakableRow && (shouldForcePageBreak || hasInlineImage)) {
1032
+ var availableHeight = self.writer.context().availableHeight;
1033
+
1034
+ // Calculate estimated height from actual cell content
1035
+ var estimatedHeight = height; // Use provided height if available
1036
+
1037
+ if (!estimatedHeight) {
1038
+ // Try to get maximum cell height from measured content
1039
+ var maxCellHeight = self._getMaxCellHeight(cells);
1040
+
1041
+ if (maxCellHeight > 0) {
1042
+ // Add padding for table borders and cell padding (approximate)
1043
+ // Using smaller padding to avoid overly conservative page break detection
1044
+ var tablePadding = 10; // Account for row padding and borders
1045
+ estimatedHeight = maxCellHeight + tablePadding;
1046
+ } else {
1047
+ // Fallback: use minRowHeight from table layout or global config if provided
1048
+ // Priority: table-specific layout > global footerGapOption > default 80
1049
+ // Using higher default (80px) to handle text rows with wrapping and multiple lines
1050
+ // This is conservative but prevents text rows from being split across pages
1051
+ var minRowHeight = (tableLayout && tableLayout.minRowHeight) || (footerGapOpt && footerGapOpt.minRowHeight) || 80;
1052
+ estimatedHeight = minRowHeight;
1053
+ }
1054
+ }
1055
+
1056
+ // Apply heightOffset from table definition to adjust page break calculation
1057
+ // This allows fine-tuning of page break detection for specific tables
1058
+ // heightOffset is passed as parameter from processTable
1059
+ if (heightOffset) {
1060
+ estimatedHeight = (estimatedHeight || 0) + heightOffset;
1061
+ }
1062
+
1063
+ // Check if row won't fit on current page
1064
+ // Strategy: Force break if row won't fit AND we're not too close to page boundary
1065
+ // "Too close" means availableHeight is very small (< 5px) - at that point forcing
1066
+ // a break would create a nearly-blank page
1067
+ var minSpaceThreshold = 5; // Only skip forced break if < 5px space left
1068
+
1069
+ if (estimatedHeight > availableHeight && availableHeight > minSpaceThreshold) {
1070
+ var currentPage = self.writer.context().page;
1071
+ var currentY = self.writer.context().y;
1072
+
1073
+ // Draw vertical lines to fill the gap from current position to page break
1074
+ // This ensures vertical lines extend all the way to the bottom of the page
1075
+ if (tableNode && tableNode._tableProcessor && rowIndex > 0) {
1076
+ tableNode._tableProcessor.drawVerticalLinesForForcedPageBreak(
1077
+ rowIndex,
1078
+ self.writer,
1079
+ currentY,
1080
+ currentY + availableHeight
1081
+ );
1082
+ }
1083
+
1084
+ // Move to next page before processing row
1085
+ self.writer.context().moveDown(availableHeight);
1086
+ self.writer.moveToNextPage();
1087
+
1088
+ // Track this page break so tableProcessor can draw borders correctly
1089
+ pageBreaks.push({
1090
+ prevPage: currentPage,
1091
+ prevY: currentY + availableHeight,
1092
+ y: self.writer.context().y,
1093
+ page: self.writer.context().page,
1094
+ forced: true // Mark as forced page break
1095
+ });
1096
+
1097
+ // Mark that this row should not break anymore
1098
+ isUnbreakableRow = true;
1099
+ dontBreakRows = true;
1100
+ }
1101
+ }
1102
+
853
1103
  // Check if row should break by height
854
1104
  if (!isUnbreakableRow && height > self.writer.context().availableHeight) {
855
1105
  willBreakByHeight = true;
@@ -898,7 +1148,7 @@ LayoutBuilder.prototype.processRow = function ({ marginX = [0, 0], dontBreakRows
898
1148
  }
899
1149
 
900
1150
  // We pass the endingSpanCell reference to store the context just after processing rowspan cell
901
- self.writer.context().beginColumn(width, leftOffset, endOfRowSpanCell);
1151
+ self.writer.context().beginColumn(width, leftOffset, endOfRowSpanCell, heightOffset);
902
1152
 
903
1153
  if (!cell._span) {
904
1154
  self.processNode(cell);
@@ -1053,6 +1303,9 @@ LayoutBuilder.prototype.processTable = function (tableNode) {
1053
1303
  this.nestedLevel++;
1054
1304
  var processor = new TableProcessor(tableNode);
1055
1305
 
1306
+ // Store processor reference for forced page break vertical line drawing
1307
+ tableNode._tableProcessor = processor;
1308
+
1056
1309
  processor.beginTable(this.writer);
1057
1310
 
1058
1311
  var rowHeights = tableNode.table.heights;
@@ -1082,6 +1335,8 @@ LayoutBuilder.prototype.processTable = function (tableNode) {
1082
1335
  height = undefined;
1083
1336
  }
1084
1337
 
1338
+ var heightOffset = tableNode.heightOffset != undefined ? tableNode.heightOffset : 0;
1339
+
1085
1340
  var pageBeforeProcessing = this.writer.context().page;
1086
1341
 
1087
1342
  var result = this.processRow({
@@ -1094,7 +1349,8 @@ LayoutBuilder.prototype.processTable = function (tableNode) {
1094
1349
  tableBody: tableNode.table.body,
1095
1350
  tableNode,
1096
1351
  rowIndex: i,
1097
- height
1352
+ height,
1353
+ heightOffset
1098
1354
  });
1099
1355
  addAll(tableNode.positions, result.positions);
1100
1356
 
@@ -1107,7 +1363,10 @@ LayoutBuilder.prototype.processTable = function (tableNode) {
1107
1363
  }
1108
1364
  }
1109
1365
 
1110
- processor.endRow(i, this.writer, result.pageBreaks);
1366
+ // Get next row cells for look-ahead page break detection
1367
+ var nextRowCells = (i + 1 < tableNode.table.body.length) ? tableNode.table.body[i + 1] : null;
1368
+
1369
+ processor.endRow(i, this.writer, result.pageBreaks, nextRowCells, this);
1111
1370
  }
1112
1371
 
1113
1372
  processor.endTable(this.writer);
package/src/printer.js CHANGED
@@ -45,6 +45,9 @@ var findFont = function (fonts, requiredFonts, defaultFont) {
45
45
  * @class Creates an instance of a PdfPrinter which turns document definition into a pdf
46
46
  *
47
47
  * @param {Object} fontDescriptors font definition dictionary
48
+ * @param {Object} [options] optional configuration
49
+ * @param {Number} [options.maxImageCacheSize] maximum number of images to cache (default: 100)
50
+ * @param {Number} [options.imageCacheTTL] cache time-to-live in milliseconds (default: 3600000 = 1 hour)
48
51
  *
49
52
  * @example
50
53
  * var fontDescriptors = {
@@ -56,10 +59,22 @@ var findFont = function (fonts, requiredFonts, defaultFont) {
56
59
  * }
57
60
  * };
58
61
  *
59
- * var printer = new PdfPrinter(fontDescriptors);
62
+ * var printer = new PdfPrinter(fontDescriptors, {
63
+ * maxImageCacheSize: 50,
64
+ * imageCacheTTL: 30 * 60 * 1000 // 30 minutes
65
+ * });
60
66
  */
61
- function PdfPrinter(fontDescriptors) {
67
+ function PdfPrinter(fontDescriptors, options) {
62
68
  this.fontDescriptors = fontDescriptors;
69
+ options = options || {};
70
+
71
+ // Cache configuration with memory leak prevention
72
+ this._cacheConfig = {
73
+ maxSize: isNumber(options.maxImageCacheSize) ? options.maxImageCacheSize : 100,
74
+ ttl: isNumber(options.imageCacheTTL) ? options.imageCacheTTL : 3600000 // 1 hour default
75
+ };
76
+
77
+ // LRU cache: Map maintains insertion order, so we can implement LRU easily
63
78
  this._remoteImageCache = new Map();
64
79
  }
65
80
 
@@ -115,6 +130,10 @@ function PdfPrinter(fontDescriptors) {
115
130
  * @return {Object} a pdfKit document object which can be saved or encode to data-url
116
131
  */
117
132
  PdfPrinter.prototype.createPdfKitDocument = function (docDefinition, options) {
133
+ if (!docDefinition || typeof docDefinition !== 'object') {
134
+ throw new Error('docDefinition parameter is required and must be an object');
135
+ }
136
+
118
137
  options = options || {};
119
138
 
120
139
  docDefinition.version = docDefinition.version || '1.3';
@@ -196,7 +215,67 @@ PdfPrinter.prototype.resolveRemoteImages = function (docDefinition, timeoutMs) {
196
215
  return resolveRemoteImages.call(this, docDefinition, timeoutMs);
197
216
  };
198
217
 
218
+ /**
219
+ * Clears the remote image cache
220
+ * Useful for freeing memory or forcing fresh image fetches
221
+ */
222
+ PdfPrinter.prototype.clearImageCache = function () {
223
+ this._remoteImageCache.clear();
224
+ };
225
+
226
+ /**
227
+ * Gets cache statistics for monitoring
228
+ * @return {Object} Cache statistics
229
+ */
230
+ PdfPrinter.prototype.getImageCacheStats = function () {
231
+ var now = Date.now();
232
+ var cache = this._remoteImageCache;
233
+ var expired = 0;
234
+ var valid = 0;
235
+
236
+ cache.forEach(function (entry) {
237
+ if (now - entry.timestamp > this._cacheConfig.ttl) {
238
+ expired++;
239
+ } else {
240
+ valid++;
241
+ }
242
+ }, this);
243
+
244
+ return {
245
+ size: cache.size,
246
+ maxSize: this._cacheConfig.maxSize,
247
+ ttl: this._cacheConfig.ttl,
248
+ validEntries: valid,
249
+ expiredEntries: expired
250
+ };
251
+ };
252
+
253
+ /**
254
+ * Removes expired entries from cache
255
+ */
256
+ PdfPrinter.prototype.cleanExpiredCache = function () {
257
+ var now = Date.now();
258
+ var cache = this._remoteImageCache;
259
+ var keysToDelete = [];
260
+
261
+ cache.forEach(function (entry, key) {
262
+ if (now - entry.timestamp > this._cacheConfig.ttl) {
263
+ keysToDelete.push(key);
264
+ }
265
+ }, this);
266
+
267
+ keysToDelete.forEach(function (key) {
268
+ cache.delete(key);
269
+ });
270
+
271
+ return keysToDelete.length;
272
+ };
273
+
199
274
  PdfPrinter.prototype.createPdfKitDocumentAsync = function (docDefinition, options) {
275
+ if (!docDefinition || typeof docDefinition !== 'object') {
276
+ return Promise.reject(new Error('docDefinition parameter is required and must be an object'));
277
+ }
278
+
200
279
  var createOptions = options ? Object.assign({}, options) : {};
201
280
  var timeout;
202
281
  if (Object.prototype.hasOwnProperty.call(createOptions, 'remoteImageTimeout')) {
@@ -800,11 +879,12 @@ function resolveRemoteImages(docDefinition, timeoutMs) {
800
879
 
801
880
  var timeout = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : undefined;
802
881
  var tasks = [];
882
+ var cacheConfig = this._cacheConfig;
803
883
 
804
884
  remoteTargets.forEach(function (descriptor, key) {
805
885
  var cacheKey = createCacheKey(descriptor.url, descriptor.headers);
806
886
  tasks.push(
807
- ensureRemoteBuffer(descriptor.url, descriptor.headers, cacheKey, cache, timeout)
887
+ ensureRemoteBuffer(descriptor.url, descriptor.headers, cacheKey, cache, timeout, cacheConfig)
808
888
  .then(function (buffer) {
809
889
  images[key] = buffer;
810
890
  })
@@ -906,14 +986,39 @@ function registerInlineImage(node, images) {
906
986
  return null;
907
987
  }
908
988
 
909
- function ensureRemoteBuffer(url, headers, cacheKey, cache, timeout) {
910
- var existing = cache.get(cacheKey);
911
- if (existing) {
912
- return Promise.resolve(existing);
989
+ function ensureRemoteBuffer(url, headers, cacheKey, cache, timeout, cacheConfig) {
990
+ var now = Date.now();
991
+
992
+ // Check if image is in cache and not expired
993
+ var cached = cache.get(cacheKey);
994
+ if (cached) {
995
+ var age = now - cached.timestamp;
996
+ if (age < cacheConfig.ttl) {
997
+ // Move to end (LRU: mark as recently used)
998
+ cache.delete(cacheKey);
999
+ cache.set(cacheKey, cached);
1000
+ return Promise.resolve(cached.buffer);
1001
+ } else {
1002
+ // Expired - remove from cache
1003
+ cache.delete(cacheKey);
1004
+ }
913
1005
  }
914
1006
 
1007
+ // Fetch remote image and cache the result
915
1008
  return fetchRemote(url, headers, timeout).then(function (buffer) {
916
- cache.set(cacheKey, buffer);
1009
+ // Implement LRU eviction if cache is full
1010
+ if (cache.size >= cacheConfig.maxSize) {
1011
+ // Remove oldest entry (first entry in Map)
1012
+ var firstKey = cache.keys().next().value;
1013
+ cache.delete(firstKey);
1014
+ }
1015
+
1016
+ // Store with timestamp for TTL
1017
+ cache.set(cacheKey, {
1018
+ buffer: buffer,
1019
+ timestamp: now
1020
+ });
1021
+
917
1022
  return buffer;
918
1023
  });
919
1024
  }