documonster 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/browser/modules/pdf/builder/image-utils.d.ts +1 -1
  2. package/dist/browser/modules/pdf/builder/image-utils.js +58 -9
  3. package/dist/browser/modules/pdf/core/pdf-writer.d.ts +8 -0
  4. package/dist/browser/modules/pdf/core/pdf-writer.js +3 -0
  5. package/dist/browser/modules/pdf/excel-bridge.js +126 -19
  6. package/dist/browser/modules/pdf/index.d.ts +1 -1
  7. package/dist/browser/modules/pdf/render/chart-surface.d.ts +6 -1
  8. package/dist/browser/modules/pdf/render/chart-surface.js +86 -47
  9. package/dist/browser/modules/pdf/render/constants.d.ts +69 -0
  10. package/dist/browser/modules/pdf/render/constants.js +45 -0
  11. package/dist/browser/modules/pdf/render/layout-engine.d.ts +5 -1
  12. package/dist/browser/modules/pdf/render/layout-engine.js +586 -205
  13. package/dist/browser/modules/pdf/render/page-renderer.js +180 -3
  14. package/dist/browser/modules/pdf/render/pdf-exporter.js +176 -35
  15. package/dist/browser/modules/pdf/render/style-converter.d.ts +11 -0
  16. package/dist/browser/modules/pdf/render/style-converter.js +24 -0
  17. package/dist/browser/modules/pdf/types.d.ts +284 -5
  18. package/dist/cjs/modules/pdf/builder/image-utils.js +58 -9
  19. package/dist/cjs/modules/pdf/core/pdf-writer.js +3 -0
  20. package/dist/cjs/modules/pdf/excel-bridge.js +125 -18
  21. package/dist/cjs/modules/pdf/render/chart-surface.js +86 -47
  22. package/dist/cjs/modules/pdf/render/constants.js +46 -1
  23. package/dist/cjs/modules/pdf/render/layout-engine.js +583 -202
  24. package/dist/cjs/modules/pdf/render/page-renderer.js +179 -2
  25. package/dist/cjs/modules/pdf/render/pdf-exporter.js +175 -34
  26. package/dist/cjs/modules/pdf/render/style-converter.js +26 -0
  27. package/dist/esm/modules/pdf/builder/image-utils.js +58 -9
  28. package/dist/esm/modules/pdf/core/pdf-writer.js +3 -0
  29. package/dist/esm/modules/pdf/excel-bridge.js +126 -19
  30. package/dist/esm/modules/pdf/render/chart-surface.js +86 -47
  31. package/dist/esm/modules/pdf/render/constants.js +45 -0
  32. package/dist/esm/modules/pdf/render/layout-engine.js +586 -205
  33. package/dist/esm/modules/pdf/render/page-renderer.js +180 -3
  34. package/dist/esm/modules/pdf/render/pdf-exporter.js +176 -35
  35. package/dist/esm/modules/pdf/render/style-converter.js +24 -0
  36. package/dist/iife/documonster.archive.iife.js +1 -1
  37. package/dist/iife/documonster.archive.iife.min.js +1 -1
  38. package/dist/iife/documonster.csv.iife.js +1 -1
  39. package/dist/iife/documonster.csv.iife.min.js +1 -1
  40. package/dist/iife/documonster.excel.iife.js +1 -1
  41. package/dist/iife/documonster.excel.iife.min.js +1 -1
  42. package/dist/iife/documonster.formula.iife.js +1 -1
  43. package/dist/iife/documonster.formula.iife.min.js +1 -1
  44. package/dist/iife/documonster.markdown.iife.js +1 -1
  45. package/dist/iife/documonster.markdown.iife.min.js +1 -1
  46. package/dist/iife/documonster.pdf.iife.js +1457 -664
  47. package/dist/iife/documonster.pdf.iife.js.map +1 -1
  48. package/dist/iife/documonster.pdf.iife.min.js +33 -32
  49. package/dist/iife/documonster.stream.iife.js +1 -1
  50. package/dist/iife/documonster.stream.iife.min.js +1 -1
  51. package/dist/iife/documonster.word.iife.js +1 -1
  52. package/dist/iife/documonster.word.iife.min.js +1 -1
  53. package/dist/iife/documonster.xml.iife.js +1 -1
  54. package/dist/iife/documonster.xml.iife.min.js +1 -1
  55. package/dist/types/modules/pdf/builder/image-utils.d.ts +1 -1
  56. package/dist/types/modules/pdf/core/pdf-writer.d.ts +8 -0
  57. package/dist/types/modules/pdf/index.d.ts +1 -1
  58. package/dist/types/modules/pdf/render/chart-surface.d.ts +6 -1
  59. package/dist/types/modules/pdf/render/constants.d.ts +69 -0
  60. package/dist/types/modules/pdf/render/layout-engine.d.ts +5 -1
  61. package/dist/types/modules/pdf/render/style-converter.d.ts +11 -0
  62. package/dist/types/modules/pdf/types.d.ts +284 -5
  63. package/package.json +1 -1
@@ -89,25 +89,39 @@ async function layoutSheet(sheet, options, fontManager) {
89
89
  }
90
90
  const layoutPages = [];
91
91
  const totalOutputPages = ctx.rowPages.length * ctx.colGroups.length;
92
- for (const rowPage of ctx.rowPages) {
93
- for (const colGroup of ctx.colGroups) {
92
+ // Excel's "Page order". `downThenOver` (Excel's default) walks each column
93
+ // band top to bottom before moving right; `overThenDown` walks each row band
94
+ // left to right before moving down. Only the nesting differs, so pick which
95
+ // axis is outer rather than materialising every pair.
96
+ const overThenDown = options.pageOrder === "overThenDown";
97
+ const outer = overThenDown ? ctx.rowPages : ctx.colGroups;
98
+ const inner = overThenDown ? ctx.colGroups : ctx.rowPages;
99
+ for (const outerTracks of outer) {
100
+ for (const innerTracks of inner) {
101
+ const rowPage = overThenDown ? outerTracks : innerTracks;
102
+ const colGroup = overThenDown ? innerTracks : outerTracks;
94
103
  layoutPages.push(buildPageLayout(ctx, rowPage, colGroup, layoutPages.length, sheet, options, fontManager));
95
104
  if (layoutPages.length < totalOutputPages) {
96
105
  await (0, utils_base_1.yieldToEventLoop)();
97
106
  }
98
107
  }
99
108
  }
100
- if (layoutPages.length > 0 && sheet.images) {
109
+ // Draft quality omits graphics, matching Excel's "Draft quality" option.
110
+ if (layoutPages.length > 0 && sheet.images && !options.draft) {
101
111
  assignImagesToPages(sheet.images, layoutPages, ctx.scaleFactor);
102
112
  }
103
- if (layoutPages.length > 0 && sheet.charts) {
113
+ if (layoutPages.length > 0 && sheet.charts && !options.draft) {
104
114
  assignChartsToPages(sheet.charts, layoutPages, ctx.scaleFactor);
105
115
  }
106
- const firstPageNumber = sheet.pageSetup?.firstPageNumber ?? 1;
116
+ // Excel's "Comments: at end of sheet" — appended after the grid so the page
117
+ // numbering below covers them too.
118
+ if (options.cellComments === "atEnd" && sheet.comments?.length) {
119
+ layoutPages.push(...buildCommentPages(sheet, sheet.comments, options, fontManager));
120
+ }
121
+ // Only the per-sheet index is settled here; `fixPageNumbers` in the exporter
122
+ // owns `pageNumber`, `sheetPageNumber` and the document-wide `sheetPageCount`.
107
123
  for (let i = 0; i < layoutPages.length; i++) {
108
- layoutPages[i].sheetPageNumber = firstPageNumber + i;
109
124
  layoutPages[i].sheetPageIndex = i + 1;
110
- layoutPages[i].sheetPageCount = layoutPages.length;
111
125
  }
112
126
  return layoutPages;
113
127
  }
@@ -137,14 +151,10 @@ function layoutChartsheet(sheet, documentOptions) {
137
151
  // affected when a single chartsheet flips to portrait.
138
152
  const orientation = sheet.orientation ?? documentOptions.orientation;
139
153
  const options = { ...documentOptions, orientation };
140
- let pageWidth = options.pageSize.width;
141
- let pageHeight = options.pageSize.height;
142
- if (options.orientation === "landscape") {
143
- [pageWidth, pageHeight] = [pageHeight, pageWidth];
144
- }
154
+ const { width: pageWidth, height: pageHeight } = pageDimensions(options);
145
155
  const margins = options.margins;
146
- const headerHeight = options.showSheetNames ? 20 : 0;
147
- const footerHeight = options.showPageNumbers ? 20 : 0;
156
+ const headerHeight = options.showSheetNames ? constants_1.SHEET_NAME_BAND_HEIGHT : 0;
157
+ const footerHeight = options.showPageNumbers ? constants_1.PAGE_NUMBER_BAND_HEIGHT : 0;
148
158
  const contentX = margins.left;
149
159
  const contentY = margins.bottom + footerHeight;
150
160
  const contentWidth = pageWidth - margins.left - margins.right;
@@ -159,7 +169,38 @@ function layoutChartsheet(sheet, documentOptions) {
159
169
  drawVector: sheet.chart.drawVector,
160
170
  raster: sheet.chart.raster
161
171
  };
162
- const page = {
172
+ return [
173
+ {
174
+ ...blankPage(sheet, options),
175
+ // Draft quality omits graphics. A chartsheet is nothing but a graphic, so
176
+ // the page is still emitted — Excel likewise prints a blank sheet rather
177
+ // than dropping it from the page count.
178
+ charts: options.draft ? [] : [chart]
179
+ }
180
+ ];
181
+ }
182
+ // =============================================================================
183
+ // Internal — Shared Layout Pipeline
184
+ // =============================================================================
185
+ /**
186
+ * Page dimensions for the resolved options, with landscape applied.
187
+ *
188
+ * `pageSize` is always stored portrait-wise, so every consumer has to swap the
189
+ * axes itself; centralised here so the four layout entry points cannot disagree.
190
+ */
191
+ function pageDimensions(options) {
192
+ const { width, height } = options.pageSize;
193
+ return options.orientation === "landscape" ? { width: height, height: width } : { width, height };
194
+ }
195
+ /**
196
+ * A `LayoutPage` with every field at its empty value.
197
+ *
198
+ * The shape has ~20 members and four construction sites; building them from one
199
+ * skeleton means a new field cannot be forgotten in three of them.
200
+ */
201
+ function blankPage(sheet, options) {
202
+ const { width, height } = pageDimensions(options);
203
+ return {
163
204
  pageNumber: 1,
164
205
  sheetPageNumber: sheet.pageSetup?.firstPageNumber ?? 1,
165
206
  sheetPageIndex: 1,
@@ -167,8 +208,8 @@ function layoutChartsheet(sheet, documentOptions) {
167
208
  firstPageNumber: sheet.pageSetup?.firstPageNumber,
168
209
  options,
169
210
  cells: [],
170
- width: pageWidth,
171
- height: pageHeight,
211
+ width,
212
+ height,
172
213
  sheetName: sheet.name,
173
214
  sheetCols: [],
174
215
  columnOffsets: [],
@@ -177,11 +218,10 @@ function layoutChartsheet(sheet, documentOptions) {
177
218
  rowYPositions: [],
178
219
  rowHeights: [],
179
220
  images: [],
180
- charts: [chart],
221
+ charts: [],
181
222
  scaleFactor: 1,
182
223
  headerFooter: options.includeHeadersFooters ? sheet.headerFooter : undefined
183
224
  };
184
- return [page];
185
225
  }
186
226
  /**
187
227
  * Steps 1–5: compute columns, scale, rows, merges, pagination.
@@ -189,45 +229,90 @@ function layoutChartsheet(sheet, documentOptions) {
189
229
  */
190
230
  function prepareLayout(sheet, options, fontManager) {
191
231
  const { margins } = options;
192
- let pageWidth = options.pageSize.width;
193
- let pageHeight = options.pageSize.height;
194
- if (options.orientation === "landscape") {
195
- [pageWidth, pageHeight] = [pageHeight, pageWidth];
196
- }
197
- const contentWidth = pageWidth - margins.left - margins.right;
198
- const contentHeight = pageHeight - margins.top - margins.bottom;
199
- const headerHeight = options.showSheetNames ? 20 : 0;
232
+ const { width: pageWidth, height: pageHeight } = pageDimensions(options);
233
+ const headerHeight = options.showSheetNames ? constants_1.SHEET_NAME_BAND_HEIGHT : 0;
200
234
  const printRange = getPrintRange(sheet, options);
201
- // --- Step 1: Visible columns and widths ---
202
- const { columnWidths, visibleCols } = computeColumnWidths(sheet, printRange);
235
+ // Excel's "Row and column headings". The bands are deliberately *not*
236
+ // scaled with the grid: they are a print aid rather than content, and a
237
+ // fixed size stays legible at small print scales while letting us reserve
238
+ // exactly the space we later draw into.
239
+ const headings = options.showRowColHeaders
240
+ ? computeHeadingMetrics(sheet, printRange, fontManager, options)
241
+ : undefined;
242
+ const gutterWidth = headings?.gutterWidth ?? 0;
243
+ const bandHeight = headings?.bandHeight ?? 0;
244
+ const contentWidth = pageWidth - margins.left - margins.right - gutterWidth;
245
+ const contentHeight = pageHeight - margins.top - margins.bottom - bandHeight;
246
+ // --- Step 1: Visible columns and widths (title columns lead) ---
247
+ const { columnWidths, visibleCols, repeatColIndices } = computeColumnWidths(sheet, printRange, options.repeatCols);
203
248
  if (visibleCols.length === 0) {
204
249
  return null;
205
250
  }
206
251
  // --- Step 2: Scale ---
252
+ // Rows are measured once, unscaled: heights are linear in the print scale, so
253
+ // the fit solver can probe by multiplying instead of re-measuring.
254
+ const natural = computeRowHeights(sheet, printRange, fontManager, options, options.repeatRows);
255
+ // Break sets are derived once: the fit solver paginates ~25 times per axis.
256
+ const rowBreaks = buildBreakSet(sheet.rowBreaks ?? [], natural.visibleRows);
257
+ const colBreaks = buildBreakSet(sheet.colBreaks ?? [], visibleCols);
207
258
  const totalTableWidth = columnWidths.reduce((sum, w) => sum + w, 0);
259
+ const footerHeight = options.showPageNumbers ? constants_1.PAGE_NUMBER_BAND_HEIGHT : 0;
260
+ const availableHeight = contentHeight - headerHeight - footerHeight;
208
261
  let scaleFactor = options.scale;
209
- if (options.fitToPage && totalTableWidth > 0) {
210
- const fitScale = contentWidth / totalTableWidth;
262
+ if (options.fitToWidth > 0 || options.fitToHeight > 0) {
263
+ // Excel's "Fit to N page(s) wide by M tall". Like Excel, this only ever
264
+ // shrinks — a grid smaller than the target is left at actual size.
265
+ //
266
+ // A total-size ratio alone does not deliver the promise: pagination packs
267
+ // indivisible columns/rows greedily, and repeated title bands consume space
268
+ // on every page after the first. Three columns at 60% of the page width fit
269
+ // "1.8 pages" by area yet still need three pages. So the ratio is only a
270
+ // starting upper bound, which we then tighten against the real packer.
271
+ let fit = 1;
272
+ // Excel's 10% floor applies to the *final* scale, so express it relative to
273
+ // the factor already in play.
274
+ const minFit = Math.min(1, constants_1.FIT_MIN_SCALE / scaleFactor);
275
+ if (options.fitToWidth > 0 && totalTableWidth > 0) {
276
+ fit = Math.min(fit, (contentWidth * options.fitToWidth) / (totalTableWidth * scaleFactor));
277
+ fit = tightenToPageCount(candidate => paginateTracks(columnWidths.map(w => w * scaleFactor * candidate), contentWidth, repeatColIndices, colBreaks, constants_1.COLUMN_FIT_EPSILON).length, options.fitToWidth, fit, minFit);
278
+ }
279
+ if (options.fitToHeight > 0 && availableHeight > 0) {
280
+ const totalTableHeight = natural.rowHeights.reduce((sum, h) => sum + h, 0);
281
+ if (totalTableHeight > 0) {
282
+ let heightFit = Math.min(fit, (availableHeight * options.fitToHeight) / (totalTableHeight * scaleFactor));
283
+ heightFit = tightenToPageCount(candidate => paginateTracks(natural.rowHeights.map(h => h * scaleFactor * candidate), availableHeight, natural.repeatRowIndices, rowBreaks).length, options.fitToHeight, heightFit, minFit);
284
+ fit = Math.min(fit, heightFit);
285
+ }
286
+ }
287
+ if (fit < 1) {
288
+ scaleFactor = Math.max(scaleFactor * fit, constants_1.FIT_MIN_SCALE);
289
+ }
290
+ }
291
+ else if (options.fitToPage && totalTableWidth > 0) {
292
+ // Same contract as `fitToWidth: 1`: it is the *final* width that must fit
293
+ // one page, so `scale` belongs inside the ratio rather than multiplied on
294
+ // top of it. Dividing by the running factor makes this `min(scale,
295
+ // contentWidth / totalTableWidth)`, which neither overflows when `scale`
296
+ // enlarges nor shrinks twice when it already reduces.
297
+ const fitScale = contentWidth / (totalTableWidth * scaleFactor);
211
298
  if (fitScale < 1) {
212
299
  scaleFactor *= fitScale;
213
300
  }
214
301
  }
215
302
  const scaledColumnWidths = columnWidths.map(w => w * scaleFactor);
216
- const footerHeight = options.showPageNumbers ? 20 : 0;
217
- const availableHeight = contentHeight - headerHeight - footerHeight;
218
- // --- Step 3: Visible rows and heights ---
219
- const { rowHeights, visibleRows } = computeRowHeights(sheet, scaleFactor, printRange, fontManager, options);
303
+ // --- Step 3: Apply the final scale to the measured heights ---
304
+ const rowHeights = natural.rowHeights.map(h => h * scaleFactor);
305
+ const { visibleRows, repeatRowIndices } = natural;
220
306
  // --- Step 4: Merge map ---
221
307
  const mergeMap = buildMergeMap(sheet);
222
308
  // --- Step 5: Paginate ---
223
- const repeatRowCount = typeof options.repeatRows === "number" ? options.repeatRows : 0;
224
- const rowBreakSet = buildRowBreakSet(sheet, visibleRows);
225
- const rowPages = paginateRows(rowHeights, availableHeight, repeatRowCount, rowBreakSet);
226
- const colGroups = paginateColumns(scaledColumnWidths, contentWidth, sheet, visibleCols);
309
+ const rowPages = paginateTracks(rowHeights, availableHeight, repeatRowIndices, rowBreaks);
310
+ const colGroups = paginateTracks(scaledColumnWidths, contentWidth, repeatColIndices, colBreaks, constants_1.COLUMN_FIT_EPSILON);
227
311
  return {
228
312
  pageWidth,
229
313
  pageHeight,
230
314
  contentWidth,
315
+ availableHeight,
231
316
  headerHeight,
232
317
  scaleFactor,
233
318
  scaledColumnWidths,
@@ -237,31 +322,179 @@ function prepareLayout(sheet, options, fontManager) {
237
322
  mergeMap,
238
323
  rowPages,
239
324
  colGroups,
240
- margins
325
+ margins,
326
+ headings
327
+ };
328
+ }
329
+ /**
330
+ * Size the row-number gutter and column-letter band for Excel's "Row and
331
+ * column headings" print option.
332
+ *
333
+ * The gutter is sized from the widest row label that can appear in the printed
334
+ * range, so the grid origin is stable across pages instead of jittering as row
335
+ * numbers gain digits.
336
+ */
337
+ function computeHeadingMetrics(sheet, printRange, fontManager, options) {
338
+ const fontSize = constants_1.HEADING_FONT_SIZE;
339
+ const resourceName = fontManager.hasEmbeddedFont()
340
+ ? fontManager.getEmbeddedResourceName()
341
+ : fontManager.ensureFont((0, font_manager_1.resolvePdfFontName)(options.defaultFontFamily, false, false));
342
+ const lastRow = printRange?.endRow ?? sheet.bounds.bottom;
343
+ const widestLabel = String(Math.max(1, lastRow));
344
+ fontManager.trackText(widestLabel);
345
+ const labelWidth = fontManager.measureText(widestLabel, resourceName, fontSize);
346
+ return {
347
+ gutterWidth: labelWidth + 2 * constants_1.HEADING_PADDING,
348
+ bandHeight: fontSize + 2 * constants_1.HEADING_PADDING,
349
+ fontSize
241
350
  };
242
351
  }
352
+ /**
353
+ * Largest scale ≤ `startScale` whose real pagination lands within `target`
354
+ * pages, found by bisection.
355
+ *
356
+ * The caller passes a closure that re-paginates at a candidate scale, so the
357
+ * answer respects indivisible columns/rows, manual breaks and repeated title
358
+ * bands instead of trusting a total-size ratio. Returns `startScale` untouched
359
+ * when it already fits, so nothing is shrunk needlessly.
360
+ *
361
+ * `minScale` is the floor the result may not go below. When the target is
362
+ * unreachable even there — manual page breaks alone can force more pages than
363
+ * requested — the floor is returned and the sheet simply spans more than
364
+ * `target` pages, which is preferable to shrinking it into illegibility.
365
+ */
366
+ function tightenToPageCount(pageCountAt, target, startScale, minScale) {
367
+ const start = Math.max(startScale, minScale);
368
+ if (pageCountAt(start) <= target || start <= minScale) {
369
+ return start;
370
+ }
371
+ let lo = minScale;
372
+ let hi = start;
373
+ if (pageCountAt(lo) > target) {
374
+ return lo;
375
+ }
376
+ // 24 halvings resolve the scale to ~6e-8 of the starting bound, far below one
377
+ // device pixel, and each probe is pure arithmetic over the track sizes.
378
+ for (let i = 0; i < 24; i++) {
379
+ const mid = (lo + hi) / 2;
380
+ if (pageCountAt(mid) <= target) {
381
+ lo = mid;
382
+ }
383
+ else {
384
+ hi = mid;
385
+ }
386
+ }
387
+ return lo;
388
+ }
389
+ /**
390
+ * Lay out Excel's "Comments at end of sheet" as extra pages.
391
+ *
392
+ * Each entry becomes an ordinary {@link LayoutCell} spanning the content width,
393
+ * so the existing page renderer draws them with no special casing — including
394
+ * word wrapping, which long comment bodies need. Pages are filled top to bottom
395
+ * and a new one starts when the next entry would overflow.
396
+ */
397
+ function buildCommentPages(sheet, comments, options, fontManager) {
398
+ const { width: pageWidth, height: pageHeight } = pageDimensions(options);
399
+ const { margins } = options;
400
+ const headerHeight = options.showSheetNames ? constants_1.SHEET_NAME_BAND_HEIGHT : 0;
401
+ const footerHeight = options.showPageNumbers ? constants_1.PAGE_NUMBER_BAND_HEIGHT : 0;
402
+ const contentWidth = pageWidth - margins.left - margins.right;
403
+ const top = pageHeight - margins.top - headerHeight;
404
+ const bottom = margins.bottom + footerHeight;
405
+ const fontSize = options.defaultFontSize;
406
+ const resourceName = fontManager.hasEmbeddedFont()
407
+ ? fontManager.getEmbeddedResourceName()
408
+ : fontManager.ensureFont((0, font_manager_1.resolvePdfFontName)(options.defaultFontFamily, false, false));
409
+ const measure = (text) => fontManager.measureText(text, resourceName, fontSize);
410
+ const lineHeight = fontSize * constants_1.LINE_HEIGHT_FACTOR;
411
+ const textColor = { r: 0, g: 0, b: 0 };
412
+ const pages = [];
413
+ let cells = [];
414
+ let cursor = top;
415
+ const flush = () => {
416
+ if (cells.length > 0) {
417
+ pages.push({ ...blankPage(sheet, options), cells });
418
+ cells = [];
419
+ cursor = top;
420
+ }
421
+ };
422
+ const push = (text, bold) => {
423
+ fontManager.trackText(text);
424
+ const lines = (0, page_renderer_1.wrapTextLines)(text, measure, Math.max(contentWidth - 2 * constants_1.CELL_PADDING_H, 1));
425
+ const height = Math.max(lines.length, 1) * lineHeight + 2 * constants_1.CELL_PADDING_V;
426
+ if (cursor - height < bottom) {
427
+ flush();
428
+ }
429
+ cells.push({
430
+ text,
431
+ rect: { x: margins.left, y: cursor - height, width: contentWidth, height },
432
+ fontFamily: options.defaultFontFamily,
433
+ fontSize,
434
+ bold,
435
+ italic: false,
436
+ strike: false,
437
+ underline: false,
438
+ textColor,
439
+ fillColor: null,
440
+ horizontalAlign: "left",
441
+ verticalAlign: "top",
442
+ wrapText: true,
443
+ borders: { top: null, right: null, bottom: null, left: null },
444
+ borderInsets: { top: 0, right: 0, bottom: 0, left: 0 },
445
+ colSpan: 1,
446
+ rowSpan: 1,
447
+ hyperlink: null,
448
+ richText: null,
449
+ textRotation: 0,
450
+ indent: 0,
451
+ textOverflowWidth: 0
452
+ });
453
+ cursor -= height;
454
+ };
455
+ push(`${sheet.name} — comments`, true);
456
+ for (const comment of comments) {
457
+ const author = comment.author ? ` (${comment.author})` : "";
458
+ push(`${comment.ref}${author}: ${comment.text}`, false);
459
+ }
460
+ flush();
461
+ return pages;
462
+ }
243
463
  /**
244
464
  * Build the LayoutPage for a single rowPage × colGroup combination.
245
465
  */
246
466
  function buildPageLayout(ctx, rowPage, colGroup, currentPageCount, sheet, options, fontManager) {
247
- const { scaledColumnWidths, rowHeights, visibleRows, visibleCols, mergeMap, pageWidth, pageHeight, contentWidth, headerHeight, scaleFactor, margins } = ctx;
467
+ const { scaledColumnWidths, rowHeights, visibleRows, visibleCols, mergeMap, pageWidth, pageHeight, contentWidth, availableHeight, headerHeight, scaleFactor, margins, headings } = ctx;
248
468
  const cells = [];
249
- // Compute column offsets for this column group
469
+ // The row-number gutter and column-letter band shift the grid origin right
470
+ // and down respectively; both are 0 when headings are not printed.
471
+ const gridLeft = margins.left + (headings?.gutterWidth ?? 0);
472
+ const gridTop = pageHeight - margins.top - headerHeight - (headings?.bandHeight ?? 0);
473
+ // Compute column offsets for this column group. Content starts at the left
474
+ // margin, matching Excel's print behaviour; it is only centered when the
475
+ // sheet (or the caller) asks for it via Excel's "Center on page →
476
+ // Horizontally" print option.
250
477
  const groupColWidths = colGroup.map(ci => scaledColumnWidths[ci]);
251
478
  const groupTotalWidth = groupColWidths.reduce((s, w) => s + w, 0);
252
479
  const groupColOffsets = [];
253
- let gx = margins.left;
254
- if (groupTotalWidth < contentWidth) {
255
- gx = margins.left + (contentWidth - groupTotalWidth) / 2;
480
+ let gx = gridLeft;
481
+ if (options.horizontalCentered && groupTotalWidth < contentWidth) {
482
+ gx = gridLeft + (contentWidth - groupTotalWidth) / 2;
256
483
  }
257
484
  for (const w of groupColWidths) {
258
485
  groupColOffsets.push(gx);
259
486
  gx += w;
260
487
  }
261
- // Row Y positions
488
+ // Row Y positions. Same rule as above for the vertical axis.
262
489
  const rowYPositions = [];
263
490
  const pageRowHeights = [];
264
- let currentY = pageHeight - margins.top - headerHeight;
491
+ let currentY = gridTop;
492
+ if (options.verticalCentered) {
493
+ const pageTotalHeight = rowPage.reduce((sum, rowIdx) => sum + (rowHeights[rowIdx] ?? DEFAULT_ROW_HEIGHT * scaleFactor), 0);
494
+ if (pageTotalHeight < availableHeight) {
495
+ currentY -= (availableHeight - pageTotalHeight) / 2;
496
+ }
497
+ }
265
498
  for (const rowIdx of rowPage) {
266
499
  const rowH = rowHeights[rowIdx] ?? DEFAULT_ROW_HEIGHT * scaleFactor;
267
500
  rowYPositions.push(currentY);
@@ -325,6 +558,12 @@ function buildPageLayout(ctx, rowPage, colGroup, currentPageCount, sheet, option
325
558
  // Propagate merged cell borders from boundary cells
326
559
  if (mergeInfo?.isMaster) {
327
560
  propagateMergeBorders(layoutCell, mergeInfo, wsRowNumber, wsColNumber, sheet);
561
+ // Propagation re-converts the boundary cell's border straight from the
562
+ // Excel style, bypassing the conversion in `buildLayoutCell`, so the
563
+ // black-and-white pass has to be reapplied to the result.
564
+ if (options.blackAndWhite) {
565
+ layoutCell.borders = (0, style_converter_1.grayscaleBorders)(layoutCell.borders);
566
+ }
328
567
  }
329
568
  cellGrid.set(`${ri}:${gci}`, layoutCell);
330
569
  }
@@ -354,37 +593,101 @@ function buildPageLayout(ctx, rowPage, colGroup, currentPageCount, sheet, option
354
593
  images: [],
355
594
  charts: [],
356
595
  scaleFactor,
596
+ headings,
597
+ commentBoxes: options.cellComments === "asDisplayed"
598
+ ? placeCommentBoxes(sheet, colGroup.map(ci => visibleCols[ci]), groupColOffsets, groupColWidths, rowPage.map(ri => visibleRows[ri]), rowYPositions, pageRowHeights, options, scaleFactor)
599
+ : undefined,
357
600
  headerFooter: options.includeHeadersFooters ? sheet.headerFooter : undefined
358
601
  };
359
602
  }
360
- function createEmptyPage(sheet, options) {
361
- let pageWidth = options.pageSize.width;
362
- let pageHeight = options.pageSize.height;
363
- if (options.orientation === "landscape") {
364
- [pageWidth, pageHeight] = [pageHeight, pageWidth];
603
+ /**
604
+ * Position each comment box on a page, for `cellComments: "asDisplayed"`.
605
+ *
606
+ * A comment is placed when its box overlaps the page's tracks. Fractional VML
607
+ * coordinates are interpolated against the page's own column offsets and row
608
+ * positions, so a box lands correctly even when the scale, hidden tracks or
609
+ * repeated title bands have moved the grid around. Comments whose box falls
610
+ * entirely outside the page are skipped, which keeps each one on a single page
611
+ * rather than slicing it across the seam.
612
+ */
613
+ function placeCommentBoxes(sheet, sheetCols, columnOffsets, columnWidths, sheetRows, rowYPositions, rowHeights, options, scaleFactor) {
614
+ const comments = sheet.comments;
615
+ if (!comments?.length || sheetCols.length === 0 || sheetRows.length === 0) {
616
+ return undefined;
365
617
  }
366
- return {
367
- pageNumber: 1,
368
- sheetPageNumber: sheet.pageSetup?.firstPageNumber ?? 1,
369
- sheetPageIndex: 1,
370
- sheetPageCount: 1,
371
- firstPageNumber: sheet.pageSetup?.firstPageNumber,
372
- options,
373
- cells: [],
374
- width: pageWidth,
375
- height: pageHeight,
376
- sheetName: sheet.name,
377
- sheetCols: [],
378
- columnOffsets: [],
379
- columnWidths: [],
380
- sheetRows: [],
381
- rowYPositions: [],
382
- rowHeights: [],
383
- images: [],
384
- charts: [],
385
- scaleFactor: 1,
386
- headerFooter: options.includeHeadersFooters ? sheet.headerFooter : undefined
618
+ // VML coordinates are 0-based; the page tracks are 1-based sheet numbers.
619
+ const xAt = (col) => {
620
+ const index = sheetCols.indexOf(Math.floor(col) + 1);
621
+ if (index < 0) {
622
+ return undefined;
623
+ }
624
+ return columnOffsets[index] + (col - Math.floor(col)) * columnWidths[index];
625
+ };
626
+ const yAt = (row) => {
627
+ const index = sheetRows.indexOf(Math.floor(row) + 1);
628
+ if (index < 0) {
629
+ return undefined;
630
+ }
631
+ return rowYPositions[index] - (row - Math.floor(row)) * rowHeights[index];
387
632
  };
633
+ const boxes = [];
634
+ for (const comment of comments) {
635
+ const anchor = comment.anchor ?? defaultCommentAnchor(comment.ref);
636
+ if (!anchor) {
637
+ continue;
638
+ }
639
+ const left = xAt(anchor.left);
640
+ const right = xAt(anchor.right);
641
+ const top = yAt(anchor.top);
642
+ const bottom = yAt(anchor.bottom);
643
+ if (left === undefined || right === undefined || top === undefined || bottom === undefined) {
644
+ continue;
645
+ }
646
+ const width = right - left;
647
+ const height = top - bottom;
648
+ if (width <= 0 || height <= 0) {
649
+ continue;
650
+ }
651
+ const cell = parseCellRef(comment.ref);
652
+ const markerCol = sheetCols.indexOf(cell.c + 1);
653
+ const markerRow = sheetRows.indexOf(cell.r + 1);
654
+ const author = comment.author ? `${comment.author}:\n` : "";
655
+ boxes.push({
656
+ rect: { x: left, y: bottom, width, height },
657
+ text: `${author}${comment.text}`,
658
+ fontSize: options.defaultFontSize * scaleFactor,
659
+ marker: markerCol >= 0 && markerRow >= 0
660
+ ? {
661
+ x: columnOffsets[markerCol] + columnWidths[markerCol],
662
+ y: rowYPositions[markerRow],
663
+ size: constants_1.COMMENT_MARKER_SIZE * scaleFactor
664
+ }
665
+ : undefined
666
+ });
667
+ }
668
+ return boxes.length > 0 ? boxes : undefined;
669
+ }
670
+ /**
671
+ * Excel's default comment placement, used when the note carries no VML anchor.
672
+ *
673
+ * Mirrors the geometry Excel writes for a fresh comment: the box starts at the
674
+ * commented cell's column, two rows above it, and spans two columns by four
675
+ * rows.
676
+ */
677
+ function defaultCommentAnchor(ref) {
678
+ let cell;
679
+ try {
680
+ cell = parseCellRef(ref);
681
+ }
682
+ catch {
683
+ return undefined;
684
+ }
685
+ const left = cell.c + 6 / 68;
686
+ const top = Math.max(cell.r - 2, 0) + 14 / 18;
687
+ return { left, top, right: left + 2, bottom: top + 4 };
688
+ }
689
+ function createEmptyPage(sheet, options) {
690
+ return blankPage(sheet, options);
388
691
  }
389
692
  /**
390
693
  * Parse a cell reference like "A1" into 0-indexed { c, r }.
@@ -448,46 +751,80 @@ function getPrintRange(sheet, options) {
448
751
  // =============================================================================
449
752
  // Column Width Computation
450
753
  // =============================================================================
451
- function computeColumnWidths(sheet, printRange) {
754
+ function computeColumnWidths(sheet, printRange, titleBand) {
452
755
  const bounds = sheet.bounds;
453
756
  const hasData = bounds.top > 0 && bounds.left > 0;
454
757
  if (!hasData) {
455
- return { columnWidths: [], visibleCols: [] };
758
+ return { columnWidths: [], visibleCols: [], repeatColIndices: [] };
456
759
  }
457
760
  const startCol = printRange?.startCol ?? bounds.left;
458
761
  const endCol = printRange?.endCol ?? bounds.right;
459
762
  const columnWidths = [];
460
763
  const visibleCols = [];
461
- for (let c = startCol; c <= endCol; c++) {
764
+ const emitted = new Set();
765
+ const push = (c) => {
766
+ if (emitted.has(c)) {
767
+ return;
768
+ }
462
769
  const col = sheet.columns.get(c);
463
770
  if (col?.hidden) {
464
- continue;
771
+ return;
465
772
  }
773
+ emitted.add(c);
466
774
  const excelWidth = col?.width ?? DEFAULT_COLUMN_WIDTH;
467
775
  const pixelWidth = (0, units_1.charWidthToPixel)(excelWidth, constants_1.MAX_DIGIT_WIDTH_PX);
468
776
  const pointWidth = Math.max(pixelWidth * constants_1.PX_TO_PT, MIN_COLUMN_WIDTH);
469
777
  columnWidths.push(pointWidth);
470
778
  visibleCols.push(c);
779
+ };
780
+ // Print titles are independent of the print area, so a band that is not
781
+ // fully inside it must be emitted first: it prints down the left of every
782
+ // page. A band wholly inside keeps the sheet's natural order, so the first
783
+ // page is not reshuffled — only later pages get the repeated prefix.
784
+ const titleFullyInside = titleBand && titleBand.first >= startCol && titleBand.last <= endCol;
785
+ if (titleBand && !titleFullyInside) {
786
+ for (let c = titleBand.first; c <= titleBand.last; c++) {
787
+ push(c);
788
+ }
471
789
  }
472
- return { columnWidths, visibleCols };
790
+ for (let c = startCol; c <= endCol; c++) {
791
+ push(c);
792
+ }
793
+ const repeatColIndices = titleBand
794
+ ? visibleCols.flatMap((c, i) => (c >= titleBand.first && c <= titleBand.last ? [i] : []))
795
+ : [];
796
+ return { columnWidths, visibleCols, repeatColIndices };
473
797
  }
474
798
  // =============================================================================
475
799
  // Row Height Computation
476
800
  // =============================================================================
477
- function computeRowHeights(sheet, scaleFactor, printRange, fontManager, options) {
801
+ /**
802
+ * Measure every printable row at 100% scale.
803
+ *
804
+ * Heights are deliberately unscaled: `countWrapLines` derives the wrapped line
805
+ * count from ratios that are independent of the print scale, so a scaled height
806
+ * is exactly `unscaled * scale`. Measuring once and multiplying avoids re-running
807
+ * the most expensive step of layout for every probe of the fit solver.
808
+ */
809
+ function computeRowHeights(sheet, printRange, fontManager, options, titleBand) {
478
810
  const bounds = sheet.bounds;
479
811
  if (bounds.top <= 0) {
480
- return { rowHeights: [], visibleRows: [] };
812
+ return { rowHeights: [], visibleRows: [], repeatRowIndices: [] };
481
813
  }
482
814
  const startRow = printRange?.startRow ?? bounds.top;
483
815
  const endRow = printRange?.endRow ?? bounds.bottom;
484
816
  const rowHeights = [];
485
817
  const visibleRows = [];
486
- for (let r = startRow; r <= endRow; r++) {
818
+ const emitted = new Set();
819
+ const push = (r) => {
820
+ if (emitted.has(r)) {
821
+ return;
822
+ }
487
823
  const row = sheet.rows.get(r);
488
824
  if (row?.hidden) {
489
- continue;
825
+ return;
490
826
  }
827
+ emitted.add(r);
491
828
  let height;
492
829
  if (row?.height && row.customHeight) {
493
830
  // Custom height explicitly set by user — use as-is
@@ -498,27 +835,41 @@ function computeRowHeights(sheet, scaleFactor, printRange, fontManager, options)
498
835
  // the row is tall enough for wrapped text. The stored height may be
499
836
  // stale when columns are narrower in the PDF layout or when the PDF
500
837
  // uses different font metrics than the original Excel file.
501
- height = Math.max(row.height, autoRowHeight(row, scaleFactor, sheet, fontManager, options));
838
+ height = Math.max(row.height, autoRowHeight(row, sheet, fontManager, options));
502
839
  }
503
840
  else {
504
841
  // No height info: auto-size based on cell content
505
- height = autoRowHeight(row, scaleFactor, sheet, fontManager, options);
842
+ height = autoRowHeight(row, sheet, fontManager, options);
506
843
  }
507
- rowHeights.push(height * scaleFactor);
844
+ rowHeights.push(height);
508
845
  visibleRows.push(r);
846
+ };
847
+ // Mirrors `computeColumnWidths`: a title band not fully inside the print area
848
+ // leads, otherwise the sheet's natural row order is preserved.
849
+ const titleFullyInside = titleBand && titleBand.first >= startRow && titleBand.last <= endRow;
850
+ if (titleBand && !titleFullyInside) {
851
+ for (let r = titleBand.first; r <= titleBand.last; r++) {
852
+ push(r);
853
+ }
509
854
  }
510
- return { rowHeights, visibleRows };
855
+ for (let r = startRow; r <= endRow; r++) {
856
+ push(r);
857
+ }
858
+ const repeatRowIndices = titleBand
859
+ ? visibleRows.flatMap((r, i) => (r >= titleBand.first && r <= titleBand.last ? [i] : []))
860
+ : [];
861
+ return { rowHeights, visibleRows, repeatRowIndices };
511
862
  }
512
863
  /**
513
864
  * Compute the minimum row height required to display wrapped cell content.
514
865
  * Returns at least `DEFAULT_ROW_HEIGHT`.
515
866
  */
516
- function autoRowHeight(row, scaleFactor, sheet, fontManager, options) {
867
+ function autoRowHeight(row, sheet, fontManager, options) {
517
868
  let height = DEFAULT_ROW_HEIGHT;
518
869
  if (row) {
519
870
  for (const cell of row.cells.values()) {
520
871
  const fontSize = getCellFontSize(cell);
521
- const wrapLineCount = countWrapLines(cell, fontSize, scaleFactor, sheet, fontManager, options);
872
+ const wrapLineCount = countWrapLines(cell, fontSize, sheet, fontManager, options);
522
873
  const lineHeight = fontSize * constants_1.LINE_HEIGHT_FACTOR;
523
874
  // Account for border width: half of each border extends inward
524
875
  const borderTop = cell.style?.border?.top?.style
@@ -560,7 +911,7 @@ function getCellFontSize(cell) {
560
911
  * Count the wrap-line count for a cell, using actual font measurements
561
912
  * so row heights match the page renderer exactly.
562
913
  */
563
- function countWrapLines(cell, fontSize, scaleFactor, sheet, fontManager, options) {
914
+ function countWrapLines(cell, fontSize, sheet, fontManager, options) {
564
915
  const text = typeof cell.text === "string" ? cell.text : String(cell.text ?? "");
565
916
  const lineCount = Math.max(1, (text.match(/\n/g) ?? []).length + 1);
566
917
  if (!cell.style?.alignment?.wrapText || text.length === 0) {
@@ -568,7 +919,7 @@ function countWrapLines(cell, fontSize, scaleFactor, sheet, fontManager, options
568
919
  }
569
920
  const col = sheet.columns.get(cell.col);
570
921
  const colWidth = col?.width ?? DEFAULT_COLUMN_WIDTH;
571
- const scaledColPts = (0, units_1.charWidthToPixel)(colWidth, constants_1.MAX_DIGIT_WIDTH_PX) * constants_1.PX_TO_PT * scaleFactor;
922
+ const colPts = (0, units_1.charWidthToPixel)(colWidth, constants_1.MAX_DIGIT_WIDTH_PX) * constants_1.PX_TO_PT;
572
923
  const indent = cell.style.alignment.indent ?? 0;
573
924
  const borderLeft = cell.style?.border?.left?.style
574
925
  ? (0, style_converter_1.borderStyleToLineWidth)(cell.style.border.left.style) / 2
@@ -576,26 +927,27 @@ function countWrapLines(cell, fontSize, scaleFactor, sheet, fontManager, options
576
927
  const borderRight = cell.style?.border?.right?.style
577
928
  ? (0, style_converter_1.borderStyleToLineWidth)(cell.style.border.right.style) / 2
578
929
  : 0;
930
+ // Width, padding and font size are all unscaled here, which is what makes the
931
+ // wrapped line count independent of the print scale.
579
932
  const padding = constants_1.CELL_PADDING_H + borderLeft + (constants_1.CELL_PADDING_H + borderRight) + indent * constants_1.INDENT_WIDTH;
580
- const effectiveWidth = Math.max(scaledColPts - padding, 1);
933
+ const effectiveWidth = Math.max(colPts - padding, 1);
581
934
  // For rich text cells, use per-run font size measurement to match rendering
582
935
  if (cell.type === types_1.PdfCellType.RichText) {
583
936
  const value = cell.value;
584
937
  if (value && typeof value === "object" && "richText" in value) {
585
938
  const runs = value.richText;
586
939
  if (runs.length > 0) {
587
- const wrappedCount = countRichTextWrapLines(text, runs, scaleFactor, effectiveWidth, fontManager, options, cell.style?.font);
940
+ const wrappedCount = countRichTextWrapLines(text, runs, effectiveWidth, fontManager, options, cell.style?.font);
588
941
  return Math.max(lineCount, wrappedCount);
589
942
  }
590
943
  }
591
944
  }
592
- const scaledFontSize = fontSize * scaleFactor;
593
945
  const fontProps = (0, style_converter_1.extractFontProperties)(cell.style.font, options.defaultFontFamily, options.defaultFontSize);
594
946
  const pdfFontName = (0, font_manager_1.resolvePdfFontName)(fontProps.fontFamily, fontProps.bold, fontProps.italic);
595
947
  const resourceName = fontManager.hasEmbeddedFont()
596
948
  ? fontManager.getEmbeddedResourceName()
597
949
  : fontManager.ensureFont(pdfFontName);
598
- const measure = (s) => fontManager.measureText(s, resourceName, scaledFontSize);
950
+ const measure = (s) => fontManager.measureText(s, resourceName, fontSize);
599
951
  const wrappedLines = (0, page_renderer_1.wrapTextLines)(text, measure, effectiveWidth);
600
952
  return Math.max(lineCount, wrappedLines.length);
601
953
  }
@@ -604,7 +956,7 @@ function countWrapLines(cell, fontSize, scaleFactor, sheet, fontManager, options
604
956
  * This mirrors the logic in wrapRichTextLines (page-renderer) so that
605
957
  * the row height calculation matches the actual rendering.
606
958
  */
607
- function countRichTextWrapLines(text, runs, scaleFactor, effectiveWidth, fontManager, options, cellFont) {
959
+ function countRichTextWrapLines(text, runs, effectiveWidth, fontManager, options, cellFont) {
608
960
  // Use cell-level font as fallback for runs without their own font
609
961
  const defaultFamily = cellFont?.name ?? options.defaultFontFamily;
610
962
  const defaultSize = cellFont?.size ?? options.defaultFontSize;
@@ -645,7 +997,7 @@ function countRichTextWrapLines(text, runs, scaleFactor, effectiveWidth, fontMan
645
997
  }
646
998
  : cellFont;
647
999
  const fontProps = (0, style_converter_1.extractFontProperties)(effectiveRunFont, defaultFamily, defaultSize);
648
- return fontProps.fontSize * scaleFactor;
1000
+ return fontProps.fontSize;
649
1001
  });
650
1002
  // Measure a range of fullText using per-character run font sizes
651
1003
  const measureRange = (start, end) => {
@@ -738,24 +1090,26 @@ function countRichTextWrapLines(text, runs, scaleFactor, effectiveWidth, fontMan
738
1090
  // Row Breaks
739
1091
  // =============================================================================
740
1092
  /**
741
- * Build a set of visible-row indices where manual page breaks occur.
1093
+ * Translate manual break positions from sheet track numbers into indices of the
1094
+ * printed track list.
1095
+ *
1096
+ * A break sits *after* its track, so the following index starts a new page.
1097
+ * Shared by both axes; computed once per sheet because the fit solver paginates
1098
+ * many times and must not rebuild the lookup on every probe.
742
1099
  */
743
- function buildRowBreakSet(sheet, visibleRows) {
1100
+ function buildBreakSet(breakTracks, visibleTracks) {
744
1101
  const breaks = new Set();
745
- const rowBreaks = sheet.rowBreaks ?? [];
746
- if (rowBreaks.length === 0) {
1102
+ if (breakTracks.length === 0) {
747
1103
  return breaks;
748
1104
  }
749
- // Map row numbers to visible-row indices
750
- const rowToIndex = new Map();
751
- for (let i = 0; i < visibleRows.length; i++) {
752
- rowToIndex.set(visibleRows[i], i);
1105
+ const trackToIndex = new Map();
1106
+ for (let i = 0; i < visibleTracks.length; i++) {
1107
+ trackToIndex.set(visibleTracks[i], i);
753
1108
  }
754
- for (const brk of rowBreaks) {
755
- const idx = rowToIndex.get(brk);
756
- if (idx !== undefined) {
757
- // Break AFTER this row, so the next row starts a new page
758
- breaks.add(idx + 1);
1109
+ for (const track of breakTracks) {
1110
+ const index = trackToIndex.get(track);
1111
+ if (index !== undefined) {
1112
+ breaks.add(index + 1);
759
1113
  }
760
1114
  }
761
1115
  return breaks;
@@ -793,115 +1147,100 @@ function buildMergeMap(sheet) {
793
1147
  // =============================================================================
794
1148
  // Pagination
795
1149
  // =============================================================================
796
- function paginateRows(rowHeights, availableHeight, repeatRowCount, rowBreaks) {
797
- if (rowHeights.length === 0) {
1150
+ /**
1151
+ * Split a track list (rows or columns) into pages.
1152
+ *
1153
+ * One implementation serves both axes: heights against the available page
1154
+ * height, widths against the content width. The axes differ only in the overflow
1155
+ * tolerance, so `epsilon` is the sole parameter that distinguishes them —
1156
+ * columns need a small slack because scaled point widths accumulate rounding.
1157
+ *
1158
+ * `repeatIndices` are the tracks of a print-title band. They are re-emitted at
1159
+ * the start of every page after the first, which is why they are absolute
1160
+ * indices rather than a count: a band may sit in the middle of the printed range
1161
+ * (Excel allows `printTitlesColumn = "C:D"`).
1162
+ *
1163
+ * Manual breaks are honoured via `breaks`, holding the index that must start a
1164
+ * new page.
1165
+ */
1166
+ function paginateTracks(sizes, available, repeatIndices, breaks, epsilon = 0) {
1167
+ if (sizes.length === 0) {
798
1168
  return [[]];
799
1169
  }
800
1170
  const pages = [];
801
- let currentPage = [];
802
- let currentPageHeight = 0;
1171
+ let current = [];
1172
+ let used = 0;
803
1173
  let isFirstPage = true;
804
1174
  let repeatedPrefixCount = 0;
805
- const addRepeatRows = () => {
1175
+ const repeatSet = new Set(repeatIndices);
1176
+ const addRepeats = () => {
806
1177
  repeatedPrefixCount = 0;
807
- for (let h = 0; h < repeatRowCount && h < rowHeights.length; h++) {
808
- if (currentPageHeight + rowHeights[h] > availableHeight && currentPage.length > 0) {
1178
+ for (const index of repeatIndices) {
1179
+ if (index >= sizes.length) {
1180
+ continue;
1181
+ }
1182
+ if (used + sizes[index] > available + epsilon && current.length > 0) {
809
1183
  break;
810
1184
  }
811
- currentPage.push(h);
812
- currentPageHeight += rowHeights[h];
1185
+ current.push(index);
1186
+ used += sizes[index];
813
1187
  repeatedPrefixCount++;
814
1188
  }
815
1189
  };
816
- for (let i = 0; i < rowHeights.length; i++) {
817
- const rowHeight = rowHeights[i];
818
- const pageAvailable = availableHeight;
819
- let skipRepeatedRow = false;
820
- while (true) {
821
- // Force page break at row break positions, or when content overflows
822
- const forceBreak = rowBreaks.has(i) && currentPage.length > 0;
823
- if ((forceBreak || currentPageHeight + rowHeight > pageAvailable) && currentPage.length > 0) {
824
- const pageHasOnlyRepeatRows = !forceBreak &&
825
- !isFirstPage &&
826
- currentPage.length > 0 &&
827
- currentPage.length === repeatedPrefixCount;
828
- if (pageHasOnlyRepeatRows) {
829
- currentPage = [];
830
- currentPageHeight = 0;
1190
+ for (let i = 0; i < sizes.length; i++) {
1191
+ const size = sizes[i];
1192
+ // A manual break at `i` must fire at most once. Without this latch the break
1193
+ // stays true after the repeated title tracks are re-added, so the loop keeps
1194
+ // flushing title-only pages forever (heap exhaustion).
1195
+ let breakConsumed = false;
1196
+ for (;;) {
1197
+ const forceBreak = !breakConsumed && breaks.has(i) && current.length > 0;
1198
+ if ((forceBreak || used + size > available + epsilon) && current.length > 0) {
1199
+ if (forceBreak) {
1200
+ breakConsumed = true;
1201
+ }
1202
+ // Never emit a page holding nothing but the repeated prefix.
1203
+ if (!forceBreak && !isFirstPage && current.length === repeatedPrefixCount) {
1204
+ current = [];
1205
+ used = 0;
831
1206
  repeatedPrefixCount = 0;
832
1207
  continue;
833
1208
  }
834
- pages.push(currentPage);
835
- currentPage = [];
836
- currentPageHeight = 0;
1209
+ pages.push(current);
1210
+ current = [];
1211
+ used = 0;
837
1212
  repeatedPrefixCount = 0;
838
1213
  isFirstPage = false;
839
- addRepeatRows();
1214
+ addRepeats();
840
1215
  continue;
841
1216
  }
842
- if (!isFirstPage && i < repeatRowCount && currentPage.includes(i)) {
843
- skipRepeatedRow = true;
844
- break;
1217
+ if (isFirstPage || !repeatSet.has(i) || !current.includes(i)) {
1218
+ current.push(i);
1219
+ used += size;
845
1220
  }
846
- currentPage.push(i);
847
- currentPageHeight += rowHeight;
848
1221
  break;
849
1222
  }
850
- if (skipRepeatedRow) {
851
- continue;
852
- }
853
1223
  }
854
- if (currentPage.length > 0) {
855
- pages.push(currentPage);
1224
+ if (current.length > 0) {
1225
+ pages.push(current);
856
1226
  }
857
- return pages.length > 0 ? pages : [[]];
1227
+ return pages;
858
1228
  }
859
1229
  /**
860
- * Split columns into groups for horizontal pagination.
1230
+ * Row pagination. Thin wrapper over {@link paginateTracks} that also accepts a
1231
+ * plain count, which reads better in the focused unit tests.
861
1232
  */
862
- function paginateColumns(columnWidths, contentWidth, sheet, visibleCols) {
863
- if (columnWidths.length === 0) {
864
- return [[]];
865
- }
866
- // Build col break set (indices into visibleCols)
867
- const colBreaks = new Set();
868
- const wsColBreaks = sheet.colBreaks ?? [];
869
- if (wsColBreaks.length > 0) {
870
- const colToIndex = new Map();
871
- for (let i = 0; i < visibleCols.length; i++) {
872
- colToIndex.set(visibleCols[i], i);
873
- }
874
- for (const brk of wsColBreaks) {
875
- const idx = colToIndex.get(brk);
876
- if (idx !== undefined) {
877
- colBreaks.add(idx + 1);
878
- }
879
- }
880
- }
881
- const groups = [];
882
- let currentGroup = [];
883
- let currentWidth = 0;
884
- for (let i = 0; i < columnWidths.length; i++) {
885
- const colWidth = columnWidths[i];
886
- const forceBreak = colBreaks.has(i) && currentGroup.length > 0;
887
- if ((forceBreak || currentWidth + colWidth > contentWidth + 0.01) && currentGroup.length > 0) {
888
- groups.push(currentGroup);
889
- currentGroup = [];
890
- currentWidth = 0;
891
- }
892
- currentGroup.push(i);
893
- currentWidth += colWidth;
894
- }
895
- if (currentGroup.length > 0) {
896
- groups.push(currentGroup);
897
- }
898
- return groups.length > 0 ? groups : [Array.from({ length: columnWidths.length }, (_, i) => i)];
1233
+ function paginateRows(rowHeights, availableHeight, repeatRows, rowBreaks) {
1234
+ const repeatIndices = Array.isArray(repeatRows)
1235
+ ? repeatRows
1236
+ : Array.from({ length: Math.max(0, repeatRows) }, (_, i) => i);
1237
+ return paginateTracks(rowHeights, availableHeight, repeatIndices, rowBreaks);
899
1238
  }
900
1239
  // =============================================================================
901
1240
  // Cell Layout
902
1241
  // =============================================================================
903
1242
  function buildLayoutCell(cell, x, y, width, height, colSpan, rowSpan, options, fontManager, scaleFactor) {
904
- const text = cell?.text ?? "";
1243
+ const text = resolveErrorText(cell, options.errors);
905
1244
  const style = cell?.style ?? {};
906
1245
  const fontProps = (0, style_converter_1.extractFontProperties)(style.font, options.defaultFontFamily, options.defaultFontSize);
907
1246
  // Scale font size proportionally when fitToPage shrinks the layout
@@ -920,7 +1259,9 @@ function buildLayoutCell(cell, x, y, width, height, colSpan, rowSpan, options, f
920
1259
  // their own font definition (e.g. the first run often has no font object
921
1260
  // and should inherit the cell's style font including bold/italic).
922
1261
  const richText = buildRichTextRuns(cell, options, fontManager, scaleFactor, style.font);
923
- const borders = (0, style_converter_1.excelBordersToPdf)(style.border);
1262
+ const rawBorders = (0, style_converter_1.excelBordersToPdf)(style.border);
1263
+ const borders = options.blackAndWhite ? (0, style_converter_1.grayscaleBorders)(rawBorders) : rawBorders;
1264
+ const rawFill = (0, style_converter_1.excelFillToPdfColor)(style.fill);
924
1265
  return {
925
1266
  text,
926
1267
  rect: { x, y, width, height },
@@ -930,8 +1271,8 @@ function buildLayoutCell(cell, x, y, width, height, colSpan, rowSpan, options, f
930
1271
  italic: fontProps.italic,
931
1272
  strike: fontProps.strike,
932
1273
  underline: fontProps.underline,
933
- textColor: fontProps.textColor,
934
- fillColor: (0, style_converter_1.excelFillToPdfColor)(style.fill),
1274
+ textColor: options.blackAndWhite ? (0, style_converter_1.toGrayscale)(fontProps.textColor) : fontProps.textColor,
1275
+ fillColor: options.blackAndWhite && rawFill !== null ? (0, style_converter_1.toGrayscale)(rawFill) : rawFill,
935
1276
  horizontalAlign: resolveHorizontalAlign(style.alignment, cell?.type, cell?.result),
936
1277
  verticalAlign: (0, style_converter_1.excelVAlignToPdf)(style.alignment),
937
1278
  wrapText: style.alignment?.wrapText ?? false,
@@ -1042,7 +1383,7 @@ function resolveSharedBorders(cellGrid, rowCount, colCount) {
1042
1383
  * - `br` — when present — locates the opposite corner, so the rect size is
1043
1384
  * the difference.
1044
1385
  * - `ext` — when present — overrides the size directly. Images use pixels
1045
- * (px × 0.75 = pt); charts use EMU (EMU / 9525 = pt). The `extUnit`
1386
+ * (px × 0.75 = pt); charts use EMU (EMU / 12700 = pt). The `extUnit`
1046
1387
  * field disambiguates. Historical callers that omit `extUnit` keep
1047
1388
  * the legacy px behaviour.
1048
1389
  *
@@ -1063,7 +1404,7 @@ function resolveAnchorRect(range, layoutPages, scaleFactor) {
1063
1404
  const baseY = targetPage.rowYPositions[pageRowIndex] ??
1064
1405
  targetPage.height -
1065
1406
  targetPage.options.margins.top -
1066
- (targetPage.options.showSheetNames ? 20 : 0);
1407
+ (targetPage.options.showSheetNames ? constants_1.SHEET_NAME_BAND_HEIGHT : 0);
1067
1408
  // Apply sub-cell offsets, scaled to match page layout.
1068
1409
  const tlColOff = ((0, units_1.emuToPt)(tl.nativeColOff ?? 0) || 0) * scaleFactor;
1069
1410
  const tlRowOff = ((0, units_1.emuToPt)(tl.nativeRowOff ?? 0) || 0) * scaleFactor;
@@ -1075,9 +1416,11 @@ function resolveAnchorRect(range, layoutPages, scaleFactor) {
1075
1416
  const extUnit = range.extUnit ?? "px";
1076
1417
  if (range.ext) {
1077
1418
  if (extUnit === "emu") {
1078
- // EMU → px (the Excel drawing ext.cx/cy convention at 96 DPI).
1079
- width = (0, units_1.emuToPx)(range.ext.width) * scaleFactor;
1080
- height = (0, units_1.emuToPx)(range.ext.height) * scaleFactor;
1419
+ // EMU → pt (÷12700). Using the px factor (÷9525) here rendered every
1420
+ // EMU-sized chart 4/3 too large: a 4in chart came out 384pt instead of
1421
+ // 288pt, overflowing the content area and skewing its aspect ratio.
1422
+ width = (0, units_1.emuToPt)(range.ext.width) * scaleFactor;
1423
+ height = (0, units_1.emuToPt)(range.ext.height) * scaleFactor;
1081
1424
  }
1082
1425
  else {
1083
1426
  // Legacy pixel → pt (0.75 factor = 72/96 dpi)
@@ -1234,13 +1577,20 @@ function assignChartsToPages(charts, layoutPages, scaleFactor) {
1234
1577
  // is near a page break: rather than clipping them to a sliver, we
1235
1578
  // push them onto the following page at full size.
1236
1579
  const tl = chart.range.tl;
1580
+ const tlCol = (tl.nativeCol ?? tl.col ?? 0) + 1;
1237
1581
  const tlRow = (tl.nativeRow ?? tl.row ?? 0) + 1;
1582
+ // Restrict the search to the column band that holds the anchor, then walk
1583
+ // it in row order. Filtering before stepping keeps the result independent
1584
+ // of `pageOrder`: that setting decides the array order, but "the next page
1585
+ // down" is always the next row band within the same column band.
1586
+ const band = layoutPages.filter(page => page.sheetCols.includes(tlCol));
1587
+ const ordered = (band.length > 0 ? band : [...layoutPages]).sort((a, b) => (a.sheetRows[0] ?? 0) - (b.sheetRows[0] ?? 0));
1238
1588
  let targetPage;
1239
- for (let pi = 0; pi < layoutPages.length; pi++) {
1240
- const page = layoutPages[pi];
1589
+ for (let pi = 0; pi < ordered.length; pi++) {
1590
+ const page = ordered[pi];
1241
1591
  const lastPageRow = page.sheetRows[page.sheetRows.length - 1] ?? 0;
1242
- if (lastPageRow >= tlRow - 1 && pi + 1 < layoutPages.length) {
1243
- targetPage = layoutPages[pi + 1];
1592
+ if (lastPageRow >= tlRow - 1 && pi + 1 < ordered.length) {
1593
+ targetPage = ordered[pi + 1];
1244
1594
  break;
1245
1595
  }
1246
1596
  if (lastPageRow >= tlRow) {
@@ -1249,11 +1599,11 @@ function assignChartsToPages(charts, layoutPages, scaleFactor) {
1249
1599
  }
1250
1600
  }
1251
1601
  if (!targetPage) {
1252
- targetPage = layoutPages[layoutPages.length - 1];
1602
+ targetPage = ordered[ordered.length - 1];
1253
1603
  }
1254
1604
  if (targetPage) {
1255
1605
  const margins = targetPage.options.margins;
1256
- const headerH = targetPage.options.showSheetNames ? 20 : 0;
1606
+ const headerH = targetPage.options.showSheetNames ? constants_1.SHEET_NAME_BAND_HEIGHT : 0;
1257
1607
  const contentX = margins.left;
1258
1608
  const contentY = margins.bottom;
1259
1609
  const contentW = targetPage.width - margins.left - margins.right;
@@ -1448,7 +1798,38 @@ function buildRichTextRuns(cell, options, fontManager, scaleFactor, cellFont) {
1448
1798
  italic: fontProps.italic,
1449
1799
  strike: fontProps.strike,
1450
1800
  underline: fontProps.underline,
1451
- textColor: fontProps.textColor
1801
+ textColor: options.blackAndWhite ? (0, style_converter_1.toGrayscale)(fontProps.textColor) : fontProps.textColor
1452
1802
  };
1453
1803
  });
1454
1804
  }
1805
+ /**
1806
+ * Apply Excel's "Cell errors as" print option to a cell's display text.
1807
+ *
1808
+ * Error values reach the PDF model in two shapes: a plain error cell
1809
+ * (`PdfCellType.Error`) and a formula whose computed result is an error
1810
+ * (`PdfCellType.Formula` with an `{ error }` result). Both must be substituted.
1811
+ */
1812
+ function resolveErrorText(cell, mode) {
1813
+ const text = cell?.text ?? "";
1814
+ if (mode === "displayed" || !cell) {
1815
+ return text;
1816
+ }
1817
+ const isError = cell.type === types_1.PdfCellType.Error ||
1818
+ (cell.type === types_1.PdfCellType.Formula &&
1819
+ typeof cell.result === "object" &&
1820
+ cell.result !== null &&
1821
+ "error" in cell.result);
1822
+ if (!isError) {
1823
+ return text;
1824
+ }
1825
+ switch (mode) {
1826
+ case "blank":
1827
+ return "";
1828
+ case "dash":
1829
+ return "--";
1830
+ case "NA":
1831
+ return "#N/A";
1832
+ default:
1833
+ return text;
1834
+ }
1835
+ }