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