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
@@ -12,7 +12,8 @@
12
12
  import { parseImageDimensions } from "../builder/image-utils.js";
13
13
  import { PdfContentStream } from "../core/pdf-stream.js";
14
14
  import { resolvePdfFontName } from "../font/font-manager.js";
15
- import { CELL_PADDING_H, CELL_PADDING_V, LINE_HEIGHT_FACTOR, INDENT_WIDTH } from "./constants.js";
15
+ import { CELL_PADDING_H, CELL_PADDING_V, LINE_HEIGHT_FACTOR, INDENT_WIDTH, HEADING_FILL, HEADING_RULE, HEADING_TEXT, COMMENT_FILL, COMMENT_BORDER, COMMENT_MARKER_COLOR } from "./constants.js";
16
+ import { toGrayscale } from "./style-converter.js";
16
17
  function computeCellPadding(cell, scaleFactor = 1) {
17
18
  return {
18
19
  left: (CELL_PADDING_H + cell.borderInsets.left) * scaleFactor,
@@ -101,6 +102,14 @@ export function renderPage(page, options, fontManager, totalPages) {
101
102
  drawCellText(stream, cell, fontManager, alphaValues, sf);
102
103
  }
103
104
  }
105
+ // --- Step 5b: Draw printed row/column headings ---
106
+ if (options.showRowColHeaders) {
107
+ drawRowColHeadings(stream, page, options, fontManager);
108
+ }
109
+ // --- Step 5c: Draw comment boxes over the grid ---
110
+ if (page.commentBoxes?.length) {
111
+ drawCommentBoxes(stream, page, options, fontManager);
112
+ }
104
113
  // --- Step 5: Draw page header (sheet name) ---
105
114
  const excelHeader = selectHeaderFooter(page, "header");
106
115
  const excelFooter = selectHeaderFooter(page, "footer");
@@ -1343,7 +1352,11 @@ function drawHeaderFooterSection(stream, page, fontManager, runs, anchorX, basel
1343
1352
  : run.subscript
1344
1353
  ? -fontSize * HEADER_FOOTER_METRICS.SUBSCRIPT_DROP
1345
1354
  : 0;
1346
- const color = run.color ?? { r: 0, g: 0, b: 0 };
1355
+ // Header/footer runs carry their own `&K` colors, which bypass the cell
1356
+ // style pipeline — apply black-and-white here.
1357
+ const bw = page.options.blackAndWhite;
1358
+ const rawColor = run.color ?? { r: 0, g: 0, b: 0 };
1359
+ const color = bw ? toGrayscale(rawColor) : rawColor;
1347
1360
  stream.save();
1348
1361
  stream.setFillColor(color);
1349
1362
  stream.setStrokeColor(color);
@@ -1392,6 +1405,169 @@ function resolveLineRuns(line, page, fontManager) {
1392
1405
  return { run, text, resourceName, fontSize, width };
1393
1406
  });
1394
1407
  }
1408
+ // =============================================================================
1409
+ // Row / Column Headings
1410
+ // =============================================================================
1411
+ /** Convert a 1-based column number to its Excel letters (1 → "A", 27 → "AA"). */
1412
+ function columnNumberToLetters(col) {
1413
+ let n = col;
1414
+ let out = "";
1415
+ while (n > 0) {
1416
+ const rem = (n - 1) % 26;
1417
+ out = String.fromCharCode(65 + rem) + out;
1418
+ n = Math.floor((n - 1) / 26);
1419
+ }
1420
+ return out;
1421
+ }
1422
+ /**
1423
+ * Draw Excel's printed row numbers and column letters around the grid.
1424
+ *
1425
+ * The bands occupy space reserved by the layout engine (see
1426
+ * `computeHeadingMetrics`), so the grid origin already accounts for them and
1427
+ * nothing here can push content off the page.
1428
+ */
1429
+ function drawRowColHeadings(stream, page, options, fontManager) {
1430
+ const headings = page.headings;
1431
+ if (!headings || page.columnWidths.length === 0 || page.rowHeights.length === 0) {
1432
+ return;
1433
+ }
1434
+ const { gutterWidth, bandHeight, fontSize } = headings;
1435
+ const resourceName = fontManager.hasEmbeddedFont()
1436
+ ? fontManager.getEmbeddedResourceName()
1437
+ : fontManager.ensureFont(resolvePdfFontName(options.defaultFontFamily, false, false));
1438
+ // The heading palette is already neutral gray, so black-and-white needs no
1439
+ // conversion here.
1440
+ const fill = HEADING_FILL;
1441
+ const rule = HEADING_RULE;
1442
+ const textColor = HEADING_TEXT;
1443
+ const gridLeft = page.columnOffsets[0];
1444
+ const lastCol = page.columnWidths.length - 1;
1445
+ const gridRight = page.columnOffsets[lastCol] + page.columnWidths[lastCol];
1446
+ const gridTop = page.rowYPositions[0];
1447
+ const lastRow = page.rowHeights.length - 1;
1448
+ const gridBottom = page.rowYPositions[lastRow] - page.rowHeights[lastRow];
1449
+ stream.save();
1450
+ // Band backgrounds: column letters above the grid, row numbers to its left,
1451
+ // plus the corner square where the two meet.
1452
+ stream.fillRect(gridLeft, gridTop, gridRight - gridLeft, bandHeight, fill);
1453
+ stream.fillRect(gridLeft - gutterWidth, gridBottom, gutterWidth, gridTop - gridBottom, fill);
1454
+ stream.fillRect(gridLeft - gutterWidth, gridTop, gutterWidth, bandHeight, fill);
1455
+ // Rules: one per column boundary through the top band, one per row boundary
1456
+ // through the gutter, and the two outer edges.
1457
+ stream.setStrokeColor(rule);
1458
+ stream.setLineWidth(0.25);
1459
+ for (let i = 0; i <= page.columnWidths.length; i++) {
1460
+ const x = i < page.columnWidths.length
1461
+ ? page.columnOffsets[i]
1462
+ : page.columnOffsets[i - 1] + page.columnWidths[i - 1];
1463
+ stream.moveTo(x, gridTop);
1464
+ stream.lineTo(x, gridTop + bandHeight);
1465
+ }
1466
+ for (let i = 0; i <= page.rowYPositions.length; i++) {
1467
+ const y = i < page.rowYPositions.length
1468
+ ? page.rowYPositions[i]
1469
+ : page.rowYPositions[i - 1] - page.rowHeights[i - 1];
1470
+ stream.moveTo(gridLeft - gutterWidth, y);
1471
+ stream.lineTo(gridLeft, y);
1472
+ }
1473
+ stream.moveTo(gridLeft - gutterWidth, gridTop + bandHeight);
1474
+ stream.lineTo(gridRight, gridTop + bandHeight);
1475
+ stream.moveTo(gridLeft - gutterWidth, gridBottom);
1476
+ stream.lineTo(gridLeft - gutterWidth, gridTop + bandHeight);
1477
+ stream.stroke();
1478
+ // Labels, centered in their band cell.
1479
+ stream.setFillColor(textColor);
1480
+ const baselineOffset = (bandHeight - fontSize) / 2 + fontSize * 0.2;
1481
+ for (let i = 0; i < page.sheetCols.length; i++) {
1482
+ const label = columnNumberToLetters(page.sheetCols[i]);
1483
+ fontManager.trackText(label);
1484
+ const w = fontManager.measureText(label, resourceName, fontSize);
1485
+ const x = page.columnOffsets[i] + (page.columnWidths[i] - w) / 2;
1486
+ emitTextWithMatrix(stream, fontManager, {
1487
+ text: label,
1488
+ matrix: [1, 0, 0, 1, x, gridTop + baselineOffset],
1489
+ resourceName,
1490
+ fontSize
1491
+ });
1492
+ }
1493
+ for (let i = 0; i < page.sheetRows.length; i++) {
1494
+ const label = String(page.sheetRows[i]);
1495
+ fontManager.trackText(label);
1496
+ const w = fontManager.measureText(label, resourceName, fontSize);
1497
+ const rowH = page.rowHeights[i];
1498
+ const x = gridLeft - gutterWidth + (gutterWidth - w) / 2;
1499
+ const y = page.rowYPositions[i] - rowH + (rowH - fontSize) / 2 + fontSize * 0.2;
1500
+ emitTextWithMatrix(stream, fontManager, {
1501
+ text: label,
1502
+ matrix: [1, 0, 0, 1, x, y],
1503
+ resourceName,
1504
+ fontSize
1505
+ });
1506
+ }
1507
+ stream.restore();
1508
+ }
1509
+ /**
1510
+ * Draw Excel's on-sheet comment boxes and their cell corner markers.
1511
+ *
1512
+ * Positioned by the layout engine, so this only has to paint: a pale note-yellow
1513
+ * box with a thin border, the wrapped body inside it, and the small red triangle
1514
+ * Excel puts in the commented cell's top-right corner.
1515
+ */
1516
+ function drawCommentBoxes(stream, page, options, fontManager) {
1517
+ const boxes = page.commentBoxes;
1518
+ if (!boxes?.length) {
1519
+ return;
1520
+ }
1521
+ const bw = options.blackAndWhite;
1522
+ const fill = bw ? toGrayscale(COMMENT_FILL) : COMMENT_FILL;
1523
+ const border = bw ? toGrayscale(COMMENT_BORDER) : COMMENT_BORDER;
1524
+ const markerColor = bw ? toGrayscale(COMMENT_MARKER_COLOR) : COMMENT_MARKER_COLOR;
1525
+ const resourceName = fontManager.hasEmbeddedFont()
1526
+ ? fontManager.getEmbeddedResourceName()
1527
+ : fontManager.ensureFont(resolvePdfFontName(options.defaultFontFamily, false, false));
1528
+ for (const box of boxes) {
1529
+ const { rect } = box;
1530
+ stream.save();
1531
+ stream.fillRect(rect.x, rect.y, rect.width, rect.height, fill);
1532
+ stream.setStrokeColor(border);
1533
+ stream.setLineWidth(0.5);
1534
+ stream.rect(rect.x, rect.y, rect.width, rect.height);
1535
+ stream.stroke();
1536
+ // Body text, wrapped to the box and clipped by simply stopping once the
1537
+ // remaining height is used up.
1538
+ const lineHeight = box.fontSize * LINE_HEIGHT_FACTOR;
1539
+ const innerWidth = Math.max(rect.width - 2 * CELL_PADDING_H, 1);
1540
+ const measure = (t) => fontManager.measureText(t, resourceName, box.fontSize);
1541
+ const lines = box.text.split("\n").flatMap(part => wrapTextLines(part, measure, innerWidth));
1542
+ stream.setFillColor(bw ? toGrayscale({ r: 0, g: 0, b: 0 }) : { r: 0, g: 0, b: 0 });
1543
+ let baseline = rect.y + rect.height - CELL_PADDING_V - box.fontSize;
1544
+ for (const line of lines) {
1545
+ if (baseline < rect.y + CELL_PADDING_V) {
1546
+ break;
1547
+ }
1548
+ fontManager.trackText(line);
1549
+ emitTextWithMatrix(stream, fontManager, {
1550
+ text: line,
1551
+ matrix: [1, 0, 0, 1, rect.x + CELL_PADDING_H, baseline],
1552
+ resourceName,
1553
+ fontSize: box.fontSize
1554
+ });
1555
+ baseline -= lineHeight;
1556
+ }
1557
+ stream.restore();
1558
+ if (box.marker) {
1559
+ const { x, y, size } = box.marker;
1560
+ stream.save();
1561
+ stream.setFillColor(markerColor);
1562
+ stream.moveTo(x - size, y);
1563
+ stream.lineTo(x, y);
1564
+ stream.lineTo(x, y - size);
1565
+ stream.closePath();
1566
+ stream.fill();
1567
+ stream.restore();
1568
+ }
1569
+ }
1570
+ }
1395
1571
  function drawPageHeader(stream, page, options, fontManager) {
1396
1572
  const headerFontSize = 10;
1397
1573
  const headerText = page.sheetName;
@@ -1495,7 +1671,8 @@ function clamp01(v) {
1495
1671
  */
1496
1672
  function renderTextWatermark(stream, page, watermark, fontManager) {
1497
1673
  const fontSize = watermark.fontSize ?? TEXT_WM_DEFAULTS.fontSize;
1498
- const color = watermark.color ?? TEXT_WM_DEFAULTS.color;
1674
+ const rawColor = watermark.color ?? TEXT_WM_DEFAULTS.color;
1675
+ const color = page.options.blackAndWhite ? toGrayscale(rawColor) : rawColor;
1499
1676
  const opacity = watermark.opacity ?? TEXT_WM_DEFAULTS.opacity;
1500
1677
  const rotation = watermark.rotation ?? TEXT_WM_DEFAULTS.rotation;
1501
1678
  const fontFamily = watermark.fontFamily ?? TEXT_WM_DEFAULTS.fontFamily;
@@ -19,7 +19,7 @@ import { parseTtf } from "../font/ttf-parser.js";
19
19
  import { createChartSurface } from "./chart-surface.js";
20
20
  import { layoutChartsheet, layoutSheet } from "./layout-engine.js";
21
21
  import { renderPage, alphaGsName, renderWatermark, resolveHeaderFooterRunText, selectHeaderFooter } from "./page-renderer.js";
22
- import { argbToPdfColor } from "./style-converter.js";
22
+ import { argbToPdfColor, toGrayscale } from "./style-converter.js";
23
23
  import { PageSizes, PdfCellType, isPdfChartsheet } from "../types.js";
24
24
  import { yieldToEventLoop } from "../../../utils/utils.base.js";
25
25
  // =============================================================================
@@ -145,7 +145,9 @@ function preflightCharts(allPages, fontManager) {
145
145
  }
146
146
  const stream = new PdfContentStream();
147
147
  const alphaValues = new Set();
148
- const surface = createChartSurface(stream, fontManager, alphaValues);
148
+ // Black-and-white is a per-sheet print option, and each page carries the
149
+ // options of the sheet that produced it.
150
+ const surface = createChartSurface(stream, fontManager, alphaValues, page.options.blackAndWhite);
149
151
  chart.drawVector(surface, constrainChartAspectRatio(chart.rect));
150
152
  cache.set(chart, { stream, alphaValues });
151
153
  }
@@ -267,6 +269,10 @@ function renderSinglePage(page, fontManager, writer, fontObjectMap, chartCache,
267
269
  const { stream: contentStream, alphaValues } = renderPage(page, page.options, fontManager, totalPages);
268
270
  // Handle images: create XObject Image entries and draw them
269
271
  const imageXObjects = new Map();
272
+ // Raster payloads are converted when the XObject is written — a real pixel
273
+ // conversion for PNG, a `/DeviceN` luma space for JPEG — so no overlay is
274
+ // painted and transparency is preserved.
275
+ const grayscaleRaster = page.options.blackAndWhite;
270
276
  let headerImageContentObjNum;
271
277
  const headerContent = selectHeaderFooter(page, "header");
272
278
  const footerContent = selectHeaderFooter(page, "footer");
@@ -282,23 +288,28 @@ function renderSinglePage(page, fontManager, writer, fontObjectMap, chartCache,
282
288
  const rightEdge = alignWithMargins
283
289
  ? page.width - page.options.margins.right
284
290
  : page.width - 18;
291
+ // `scaleWithDoc` governs the whole header/footer, not just its text, so a
292
+ // logo has to shrink with the grid the same way the runs do.
293
+ const hfScale = page.headerFooter?.scaleWithDoc === false ? 1 : page.scaleFactor;
285
294
  for (let index = 0; index < headerImages.length; index++) {
286
295
  const headerImage = headerImages[index];
287
296
  if (!activePositions.has(headerImage.position)) {
288
297
  continue;
289
298
  }
290
299
  const imageName = `HfIm${index + 1}`;
291
- imageXObjects.set(imageName, getOrWriteImageObject(writer, imageObjectCache, headerImage.data, headerImage.format));
300
+ imageXObjects.set(imageName, getOrWriteImageObject(writer, imageObjectCache, headerImage.data, headerImage.format, grayscaleRaster));
301
+ const drawWidth = headerImage.width * hfScale;
302
+ const drawHeight = headerImage.height * hfScale;
292
303
  const placement = headerFooterImagePlacement(headerImage.position);
293
304
  const imageX = placement.section === "left"
294
305
  ? leftEdge
295
306
  : placement.section === "right"
296
- ? rightEdge - headerImage.width
297
- : (page.width - headerImage.width) / 2;
307
+ ? rightEdge - drawWidth
308
+ : (page.width - drawWidth) / 2;
298
309
  const imageY = placement.kind === "header"
299
- ? page.height - page.options.headerMargin - headerImage.height
310
+ ? page.height - page.options.headerMargin - drawHeight
300
311
  : page.options.footerMargin;
301
- imageStream.drawImage(imageName, imageX, imageY, headerImage.width, headerImage.height);
312
+ imageStream.drawImage(imageName, imageX, imageY, drawWidth, drawHeight);
302
313
  }
303
314
  headerImageContentObjNum = writer.allocObject();
304
315
  writer.addStreamObject(headerImageContentObjNum, new PdfDict(), imageStream);
@@ -307,7 +318,7 @@ function renderSinglePage(page, fontManager, writer, fontObjectMap, chartCache,
307
318
  for (let imgIdx = 0; imgIdx < page.images.length; imgIdx++) {
308
319
  const img = page.images[imgIdx];
309
320
  const imgName = `Im${imgIdx + 1}`;
310
- const imgObjNum = getOrWriteImageObject(writer, imageObjectCache, img.data, img.format);
321
+ const imgObjNum = getOrWriteImageObject(writer, imageObjectCache, img.data, img.format, grayscaleRaster);
311
322
  imageXObjects.set(imgName, imgObjNum);
312
323
  contentStream.drawImage(imgName, img.rect.x, img.rect.y, img.rect.width, img.rect.height);
313
324
  }
@@ -336,7 +347,7 @@ function renderSinglePage(page, fontManager, writer, fontObjectMap, chartCache,
336
347
  }
337
348
  if (chart.raster) {
338
349
  const imgName = `Im${++rasterCounter}`;
339
- const imgObjNum = getOrWriteImageObject(writer, imageObjectCache, chart.raster.data, chart.raster.format);
350
+ const imgObjNum = getOrWriteImageObject(writer, imageObjectCache, chart.raster.data, chart.raster.format, grayscaleRaster);
340
351
  imageXObjects.set(imgName, imgObjNum);
341
352
  contentStream.drawImage(imgName, chart.rect.x, chart.rect.y, chart.rect.width, chart.rect.height);
342
353
  }
@@ -356,7 +367,7 @@ function renderSinglePage(page, fontManager, writer, fontObjectMap, chartCache,
356
367
  }
357
368
  // Register watermark image XObjects
358
369
  for (const wmImg of wmResult.imageXObjects) {
359
- const imgObjNum = getOrWriteImageObject(writer, imageObjectCache, wmImg.data, wmImg.format);
370
+ const imgObjNum = getOrWriteImageObject(writer, imageObjectCache, wmImg.data, wmImg.format, grayscaleRaster);
360
371
  imageXObjects.set(wmImg.name, imgObjNum);
361
372
  }
362
373
  // Write watermark content stream object
@@ -437,6 +448,8 @@ function renderSinglePage(page, fontManager, writer, fontObjectMap, chartCache,
437
448
  height: page.height,
438
449
  contentsRef: contentsRef,
439
450
  resourcesRef: resourcesObjNum,
451
+ // Constant alpha needs a defined backdrop.
452
+ transparencyGroup: alphaValues.size > 0,
440
453
  annotRefs: annotRefs.length > 0 ? annotRefs : undefined
441
454
  });
442
455
  pageObjNums.push(pageObjNum);
@@ -450,13 +463,18 @@ function renderSinglePage(page, fontManager, writer, fontObjectMap, chartCache,
450
463
  });
451
464
  }
452
465
  }
453
- function getOrWriteImageObject(writer, cache, data, format) {
454
- const existing = cache.get(data);
466
+ function getOrWriteImageObject(writer, cache, data, format, grayscale = false) {
467
+ let variants = cache.get(data);
468
+ if (variants === undefined) {
469
+ variants = new Map();
470
+ cache.set(data, variants);
471
+ }
472
+ const existing = variants.get(grayscale);
455
473
  if (existing !== undefined) {
456
474
  return existing;
457
475
  }
458
- const objectNumber = writeImageXObject(writer, data, format);
459
- cache.set(data, objectNumber);
476
+ const objectNumber = writeImageXObject(writer, data, format, grayscale);
477
+ variants.set(grayscale, objectNumber);
460
478
  return objectNumber;
461
479
  }
462
480
  function headerFooterImagePositions(content, kind) {
@@ -540,6 +558,110 @@ function selectSheets(workbook, sheets) {
540
558
  // =============================================================================
541
559
  // Options Resolution
542
560
  // =============================================================================
561
+ /**
562
+ * Parse an Excel "Columns to repeat at left" reference (`printTitlesColumn`)
563
+ * into an absolute column band, e.g. `"A:B"` → `{ first: 1, last: 2 }`.
564
+ *
565
+ * Returns `null` for anything that is not a column range.
566
+ */
567
+ function parseTitlesColumnBand(ref) {
568
+ const match = ref
569
+ .replace(/\$/g, "")
570
+ .toUpperCase()
571
+ .match(/^([A-Z]+)(?::([A-Z]+))?$/);
572
+ if (!match) {
573
+ return null;
574
+ }
575
+ const first = columnLettersToNumber(match[1]);
576
+ const last = match[2] ? columnLettersToNumber(match[2]) : first;
577
+ if (last < first) {
578
+ return null;
579
+ }
580
+ return { first, last };
581
+ }
582
+ /**
583
+ * Parse an Excel "Rows to repeat at top" reference (`printTitlesRow`) into an
584
+ * absolute row band, e.g. `"1:3"` → `{ first: 1, last: 3 }`.
585
+ */
586
+ function parseTitlesRowBand(ref) {
587
+ const match = ref.replace(/\$/g, "").match(/^(\d+)(?::(\d+))?$/);
588
+ if (!match) {
589
+ return null;
590
+ }
591
+ const first = parseInt(match[1], 10);
592
+ const last = match[2] ? parseInt(match[2], 10) : first;
593
+ if (first < 1 || last < first) {
594
+ return null;
595
+ }
596
+ return { first, last };
597
+ }
598
+ function columnLettersToNumber(letters) {
599
+ let n = 0;
600
+ for (let i = 0; i < letters.length; i++) {
601
+ n = n * 26 + (letters.charCodeAt(i) - 64);
602
+ }
603
+ return n;
604
+ }
605
+ function normalizePageOrder(value) {
606
+ return value === "overThenDown" ? "overThenDown" : "downThenOver";
607
+ }
608
+ /** Excel writes `None` / `asDisplayed` / `atEnd`. */
609
+ function normalizeCommentMode(value) {
610
+ if (value === "atEnd" || value === "asDisplayed") {
611
+ return value;
612
+ }
613
+ return "none";
614
+ }
615
+ function normalizeErrorMode(value) {
616
+ switch (value) {
617
+ case "blank":
618
+ case "dash":
619
+ case "NA":
620
+ return value;
621
+ default:
622
+ return "displayed";
623
+ }
624
+ }
625
+ /**
626
+ * Reconcile documonster's scaling options with Excel's two mutually exclusive
627
+ * scaling modes (Page Setup → Scaling):
628
+ *
629
+ * - **"Adjust to N %"** — `pageSetup.scale`, with `pageSetup.fitToPage` off.
630
+ * - **"Fit to N pages wide by M tall"** — `pageSetup.fitToWidth` /
631
+ * `fitToHeight`, with `pageSetup.fitToPage` on. Excel ignores `scale` here.
632
+ *
633
+ * Neither mode ever enlarges: Excel caps fit-to-page scaling at 100%.
634
+ *
635
+ * `PdfExportOptions.fitToPage` is documonster's own convenience default
636
+ * ("shrink to one page wide"). It only applies when neither the caller nor the
637
+ * sheet expresses a scaling intent, so a sheet that asks for 80% is not
638
+ * shrunk twice.
639
+ */
640
+ function resolveScaling(options, ps) {
641
+ const clampPages = (n) => n === undefined || !Number.isFinite(n) || n <= 0 ? 0 : Math.floor(n);
642
+ const callerFit = options?.fitToWidth !== undefined || options?.fitToHeight !== undefined;
643
+ const sheetFitMode = ps?.fitToPage === true;
644
+ let fitToWidth = 0;
645
+ let fitToHeight = 0;
646
+ if (callerFit) {
647
+ fitToWidth = clampPages(options?.fitToWidth);
648
+ fitToHeight = clampPages(options?.fitToHeight);
649
+ }
650
+ else if (sheetFitMode && options?.scale === undefined && options?.fitToPage !== false) {
651
+ // `fitToPage: false` is the caller saying "do not auto-scale"; it has to
652
+ // beat the sheet's own fit-to-N mode, or an explicit option would lose to
653
+ // the workbook it is meant to override.
654
+ fitToWidth = clampPages(ps?.fitToWidth);
655
+ fitToHeight = clampPages(ps?.fitToHeight);
656
+ }
657
+ // A sheet in "Adjust to N %" mode with a non-default percentage.
658
+ const sheetScalePercent = !sheetFitMode && ps?.scale !== undefined && ps.scale !== 100 ? ps.scale / 100 : undefined;
659
+ // Excel's Page Setup allows 10–400%, so the ceiling is 4.0 rather than 3.0.
660
+ const scale = Math.max(0.1, Math.min(4.0, options?.scale ?? sheetScalePercent ?? 1.0));
661
+ const hasFitConstraint = fitToWidth > 0 || fitToHeight > 0;
662
+ const fitToPage = options?.fitToPage ?? !(hasFitConstraint || sheetScalePercent !== undefined || callerFit);
663
+ return { fitToPage, scale, fitToWidth, fitToHeight };
664
+ }
543
665
  /**
544
666
  * Resolve user options with defaults.
545
667
  */
@@ -560,40 +682,59 @@ function resolveOptions(options, sheet) {
560
682
  chartsheetOrientation ??
561
683
  (ps?.orientation === "landscape" ? "landscape" : "portrait");
562
684
  const margins = resolveMargins(options?.margins, ps?.margins);
685
+ const blackAndWhite = options?.blackAndWhite ?? ps?.blackAndWhite ?? false;
563
686
  const gridLineColorStr = options?.gridLineColor ?? "FFD0D0D0";
564
- const gridLineColor = argbToPdfColor(gridLineColorStr) ?? {
687
+ const gridLineColorRaw = argbToPdfColor(gridLineColorStr) ?? {
565
688
  r: 0.816,
566
689
  g: 0.816,
567
690
  b: 0.816
568
691
  };
569
- // Use sheet's printTitlesRow as fallback for repeatRows. Only
570
- // PdfSheetData carries `printTitlesRow` — chartsheets never have
571
- // repeated header rows.
572
- let repeatRows = options?.repeatRows ?? false;
573
- if (repeatRows === false && sheet && !isPdfChartsheet(sheet) && ps?.printTitlesRow) {
574
- // printTitlesRow format: "1:3" (repeat rows 1-3) or "1" (repeat row 1)
575
- const match = ps.printTitlesRow.match(/^(\d+)(?::(\d+))?$/);
576
- if (match) {
577
- repeatRows = parseInt(match[2] ?? match[1], 10);
578
- }
579
- }
692
+ const gridLineColor = blackAndWhite ? toGrayscale(gridLineColorRaw) : gridLineColorRaw;
693
+ // Repeated title rows/columns fall back to the sheet's print titles. Only
694
+ // PdfSheetData carries them — chartsheets have no repeated bands. An
695
+ // explicitly passed `false` suppresses the sheet setting, so "not provided"
696
+ // (undefined) and "provided as off" must stay distinguishable.
697
+ const isWorksheet = Boolean(sheet) && !isPdfChartsheet(sheet);
698
+ // A caller-supplied count means "the first N rows/columns of the sheet"; the
699
+ // sheet's own print titles are absolute ranges that may sit outside the print
700
+ // area, so both normalise to an absolute band.
701
+ const countToBand = (n) => typeof n === "number" && n > 0 ? { first: 1, last: n } : false;
702
+ let repeatRows = false;
703
+ if (options?.repeatRows !== undefined) {
704
+ repeatRows = countToBand(options.repeatRows);
705
+ }
706
+ else if (isWorksheet && ps?.printTitlesRow) {
707
+ repeatRows = parseTitlesRowBand(ps.printTitlesRow) ?? false;
708
+ }
709
+ let repeatCols = false;
710
+ if (options?.repeatCols !== undefined) {
711
+ repeatCols = countToBand(options.repeatCols);
712
+ }
713
+ else if (isWorksheet && ps?.printTitlesColumn) {
714
+ repeatCols = parseTitlesColumnBand(ps.printTitlesColumn) ?? false;
715
+ }
716
+ const { fitToPage, scale, fitToWidth, fitToHeight } = resolveScaling(options, ps);
580
717
  return {
581
718
  pageSize,
582
719
  orientation,
583
720
  margins,
584
721
  ignorePrintArea: options?.ignorePrintArea ?? false,
585
- fitToPage: options?.fitToPage !== undefined ? options.fitToPage : true,
586
- scale: Math.max(0.1, Math.min(3.0, options?.scale ??
587
- // When fitToPage is active (default), ignore sheet's pageSetup.scale
588
- // to avoid double-scaling. Only apply sheet scale when fitToPage is off.
589
- ((options?.fitToPage !== undefined ? options.fitToPage : true)
590
- ? 1.0
591
- : ps?.scale
592
- ? ps.scale / 100
593
- : 1.0))),
722
+ fitToPage,
723
+ scale,
724
+ fitToWidth,
725
+ fitToHeight,
594
726
  showGridLines: options?.showGridLines ?? ps?.showGridLines ?? false,
595
727
  gridLineColor,
728
+ showRowColHeaders: options?.showRowColHeaders ?? ps?.showRowColHeaders ?? false,
729
+ horizontalCentered: options?.horizontalCentered ?? ps?.horizontalCentered ?? false,
730
+ verticalCentered: options?.verticalCentered ?? ps?.verticalCentered ?? false,
731
+ pageOrder: options?.pageOrder ?? normalizePageOrder(ps?.pageOrder),
732
+ blackAndWhite,
733
+ draft: options?.draft ?? ps?.draft ?? false,
734
+ errors: options?.errors ?? normalizeErrorMode(ps?.errors),
735
+ cellComments: options?.cellComments ?? normalizeCommentMode(ps?.cellComments),
596
736
  repeatRows,
737
+ repeatCols,
597
738
  defaultFontFamily: options?.defaultFontFamily ?? "Helvetica",
598
739
  defaultFontSize: options?.defaultFontSize ?? 11,
599
740
  showSheetNames: options?.showSheetNames ?? false,
@@ -27,6 +27,17 @@ export declare function excelColorToPdf(color: Partial<PdfColorData> | undefined
27
27
  * @see OOXML §18.8.19 - tint formula
28
28
  */
29
29
  export declare function applyTint(color: PdfColor, tint: number): PdfColor;
30
+ /**
31
+ * Convert a color to its luminance-preserving grayscale equivalent, using the
32
+ * Rec. 601 luma weights. Backs Excel's "Black and white" print option: hues are
33
+ * discarded while relative lightness — and therefore contrast — is kept.
34
+ *
35
+ * Opacity is carried through untouched; dropping it would make transparent
36
+ * chart fills opaque.
37
+ */
38
+ export declare function toGrayscale(color: PdfColor): PdfColor;
39
+ /** Grayscale every edge of a border set, preserving `null` edges. */
40
+ export declare function grayscaleBorders(borders: LayoutBorders): LayoutBorders;
30
41
  /**
31
42
  * Default colors used in PDF rendering.
32
43
  */
@@ -165,6 +165,30 @@ function indexedColorToPdf(index) {
165
165
  export function applyTint(color, tint) {
166
166
  return applyTintRgb01(color, tint);
167
167
  }
168
+ /**
169
+ * Convert a color to its luminance-preserving grayscale equivalent, using the
170
+ * Rec. 601 luma weights. Backs Excel's "Black and white" print option: hues are
171
+ * discarded while relative lightness — and therefore contrast — is kept.
172
+ *
173
+ * Opacity is carried through untouched; dropping it would make transparent
174
+ * chart fills opaque.
175
+ */
176
+ export function toGrayscale(color) {
177
+ const luma = 0.299 * color.r + 0.587 * color.g + 0.114 * color.b;
178
+ return color.a === undefined
179
+ ? { r: luma, g: luma, b: luma }
180
+ : { r: luma, g: luma, b: luma, a: color.a };
181
+ }
182
+ /** Grayscale every edge of a border set, preserving `null` edges. */
183
+ export function grayscaleBorders(borders) {
184
+ const convert = (b) => b === null ? null : { ...b, color: toGrayscale(b.color) };
185
+ return {
186
+ top: convert(borders.top),
187
+ right: convert(borders.right),
188
+ bottom: convert(borders.bottom),
189
+ left: convert(borders.left)
190
+ };
191
+ }
168
192
  /**
169
193
  * Default colors used in PDF rendering.
170
194
  */