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
@@ -25,6 +25,7 @@ const image_utils_1 = require("../builder/image-utils.js");
25
25
  const pdf_stream_1 = require("../core/pdf-stream.js");
26
26
  const font_manager_1 = require("../font/font-manager.js");
27
27
  const constants_1 = require("./constants.js");
28
+ const style_converter_1 = require("./style-converter.js");
28
29
  function computeCellPadding(cell, scaleFactor = 1) {
29
30
  return {
30
31
  left: (constants_1.CELL_PADDING_H + cell.borderInsets.left) * scaleFactor,
@@ -113,6 +114,14 @@ function renderPage(page, options, fontManager, totalPages) {
113
114
  drawCellText(stream, cell, fontManager, alphaValues, sf);
114
115
  }
115
116
  }
117
+ // --- Step 5b: Draw printed row/column headings ---
118
+ if (options.showRowColHeaders) {
119
+ drawRowColHeadings(stream, page, options, fontManager);
120
+ }
121
+ // --- Step 5c: Draw comment boxes over the grid ---
122
+ if (page.commentBoxes?.length) {
123
+ drawCommentBoxes(stream, page, options, fontManager);
124
+ }
116
125
  // --- Step 5: Draw page header (sheet name) ---
117
126
  const excelHeader = selectHeaderFooter(page, "header");
118
127
  const excelFooter = selectHeaderFooter(page, "footer");
@@ -1355,7 +1364,11 @@ function drawHeaderFooterSection(stream, page, fontManager, runs, anchorX, basel
1355
1364
  : run.subscript
1356
1365
  ? -fontSize * HEADER_FOOTER_METRICS.SUBSCRIPT_DROP
1357
1366
  : 0;
1358
- const color = run.color ?? { r: 0, g: 0, b: 0 };
1367
+ // Header/footer runs carry their own `&K` colors, which bypass the cell
1368
+ // style pipeline — apply black-and-white here.
1369
+ const bw = page.options.blackAndWhite;
1370
+ const rawColor = run.color ?? { r: 0, g: 0, b: 0 };
1371
+ const color = bw ? (0, style_converter_1.toGrayscale)(rawColor) : rawColor;
1359
1372
  stream.save();
1360
1373
  stream.setFillColor(color);
1361
1374
  stream.setStrokeColor(color);
@@ -1404,6 +1417,169 @@ function resolveLineRuns(line, page, fontManager) {
1404
1417
  return { run, text, resourceName, fontSize, width };
1405
1418
  });
1406
1419
  }
1420
+ // =============================================================================
1421
+ // Row / Column Headings
1422
+ // =============================================================================
1423
+ /** Convert a 1-based column number to its Excel letters (1 → "A", 27 → "AA"). */
1424
+ function columnNumberToLetters(col) {
1425
+ let n = col;
1426
+ let out = "";
1427
+ while (n > 0) {
1428
+ const rem = (n - 1) % 26;
1429
+ out = String.fromCharCode(65 + rem) + out;
1430
+ n = Math.floor((n - 1) / 26);
1431
+ }
1432
+ return out;
1433
+ }
1434
+ /**
1435
+ * Draw Excel's printed row numbers and column letters around the grid.
1436
+ *
1437
+ * The bands occupy space reserved by the layout engine (see
1438
+ * `computeHeadingMetrics`), so the grid origin already accounts for them and
1439
+ * nothing here can push content off the page.
1440
+ */
1441
+ function drawRowColHeadings(stream, page, options, fontManager) {
1442
+ const headings = page.headings;
1443
+ if (!headings || page.columnWidths.length === 0 || page.rowHeights.length === 0) {
1444
+ return;
1445
+ }
1446
+ const { gutterWidth, bandHeight, fontSize } = headings;
1447
+ const resourceName = fontManager.hasEmbeddedFont()
1448
+ ? fontManager.getEmbeddedResourceName()
1449
+ : fontManager.ensureFont((0, font_manager_1.resolvePdfFontName)(options.defaultFontFamily, false, false));
1450
+ // The heading palette is already neutral gray, so black-and-white needs no
1451
+ // conversion here.
1452
+ const fill = constants_1.HEADING_FILL;
1453
+ const rule = constants_1.HEADING_RULE;
1454
+ const textColor = constants_1.HEADING_TEXT;
1455
+ const gridLeft = page.columnOffsets[0];
1456
+ const lastCol = page.columnWidths.length - 1;
1457
+ const gridRight = page.columnOffsets[lastCol] + page.columnWidths[lastCol];
1458
+ const gridTop = page.rowYPositions[0];
1459
+ const lastRow = page.rowHeights.length - 1;
1460
+ const gridBottom = page.rowYPositions[lastRow] - page.rowHeights[lastRow];
1461
+ stream.save();
1462
+ // Band backgrounds: column letters above the grid, row numbers to its left,
1463
+ // plus the corner square where the two meet.
1464
+ stream.fillRect(gridLeft, gridTop, gridRight - gridLeft, bandHeight, fill);
1465
+ stream.fillRect(gridLeft - gutterWidth, gridBottom, gutterWidth, gridTop - gridBottom, fill);
1466
+ stream.fillRect(gridLeft - gutterWidth, gridTop, gutterWidth, bandHeight, fill);
1467
+ // Rules: one per column boundary through the top band, one per row boundary
1468
+ // through the gutter, and the two outer edges.
1469
+ stream.setStrokeColor(rule);
1470
+ stream.setLineWidth(0.25);
1471
+ for (let i = 0; i <= page.columnWidths.length; i++) {
1472
+ const x = i < page.columnWidths.length
1473
+ ? page.columnOffsets[i]
1474
+ : page.columnOffsets[i - 1] + page.columnWidths[i - 1];
1475
+ stream.moveTo(x, gridTop);
1476
+ stream.lineTo(x, gridTop + bandHeight);
1477
+ }
1478
+ for (let i = 0; i <= page.rowYPositions.length; i++) {
1479
+ const y = i < page.rowYPositions.length
1480
+ ? page.rowYPositions[i]
1481
+ : page.rowYPositions[i - 1] - page.rowHeights[i - 1];
1482
+ stream.moveTo(gridLeft - gutterWidth, y);
1483
+ stream.lineTo(gridLeft, y);
1484
+ }
1485
+ stream.moveTo(gridLeft - gutterWidth, gridTop + bandHeight);
1486
+ stream.lineTo(gridRight, gridTop + bandHeight);
1487
+ stream.moveTo(gridLeft - gutterWidth, gridBottom);
1488
+ stream.lineTo(gridLeft - gutterWidth, gridTop + bandHeight);
1489
+ stream.stroke();
1490
+ // Labels, centered in their band cell.
1491
+ stream.setFillColor(textColor);
1492
+ const baselineOffset = (bandHeight - fontSize) / 2 + fontSize * 0.2;
1493
+ for (let i = 0; i < page.sheetCols.length; i++) {
1494
+ const label = columnNumberToLetters(page.sheetCols[i]);
1495
+ fontManager.trackText(label);
1496
+ const w = fontManager.measureText(label, resourceName, fontSize);
1497
+ const x = page.columnOffsets[i] + (page.columnWidths[i] - w) / 2;
1498
+ emitTextWithMatrix(stream, fontManager, {
1499
+ text: label,
1500
+ matrix: [1, 0, 0, 1, x, gridTop + baselineOffset],
1501
+ resourceName,
1502
+ fontSize
1503
+ });
1504
+ }
1505
+ for (let i = 0; i < page.sheetRows.length; i++) {
1506
+ const label = String(page.sheetRows[i]);
1507
+ fontManager.trackText(label);
1508
+ const w = fontManager.measureText(label, resourceName, fontSize);
1509
+ const rowH = page.rowHeights[i];
1510
+ const x = gridLeft - gutterWidth + (gutterWidth - w) / 2;
1511
+ const y = page.rowYPositions[i] - rowH + (rowH - fontSize) / 2 + fontSize * 0.2;
1512
+ emitTextWithMatrix(stream, fontManager, {
1513
+ text: label,
1514
+ matrix: [1, 0, 0, 1, x, y],
1515
+ resourceName,
1516
+ fontSize
1517
+ });
1518
+ }
1519
+ stream.restore();
1520
+ }
1521
+ /**
1522
+ * Draw Excel's on-sheet comment boxes and their cell corner markers.
1523
+ *
1524
+ * Positioned by the layout engine, so this only has to paint: a pale note-yellow
1525
+ * box with a thin border, the wrapped body inside it, and the small red triangle
1526
+ * Excel puts in the commented cell's top-right corner.
1527
+ */
1528
+ function drawCommentBoxes(stream, page, options, fontManager) {
1529
+ const boxes = page.commentBoxes;
1530
+ if (!boxes?.length) {
1531
+ return;
1532
+ }
1533
+ const bw = options.blackAndWhite;
1534
+ const fill = bw ? (0, style_converter_1.toGrayscale)(constants_1.COMMENT_FILL) : constants_1.COMMENT_FILL;
1535
+ const border = bw ? (0, style_converter_1.toGrayscale)(constants_1.COMMENT_BORDER) : constants_1.COMMENT_BORDER;
1536
+ const markerColor = bw ? (0, style_converter_1.toGrayscale)(constants_1.COMMENT_MARKER_COLOR) : constants_1.COMMENT_MARKER_COLOR;
1537
+ const resourceName = fontManager.hasEmbeddedFont()
1538
+ ? fontManager.getEmbeddedResourceName()
1539
+ : fontManager.ensureFont((0, font_manager_1.resolvePdfFontName)(options.defaultFontFamily, false, false));
1540
+ for (const box of boxes) {
1541
+ const { rect } = box;
1542
+ stream.save();
1543
+ stream.fillRect(rect.x, rect.y, rect.width, rect.height, fill);
1544
+ stream.setStrokeColor(border);
1545
+ stream.setLineWidth(0.5);
1546
+ stream.rect(rect.x, rect.y, rect.width, rect.height);
1547
+ stream.stroke();
1548
+ // Body text, wrapped to the box and clipped by simply stopping once the
1549
+ // remaining height is used up.
1550
+ const lineHeight = box.fontSize * constants_1.LINE_HEIGHT_FACTOR;
1551
+ const innerWidth = Math.max(rect.width - 2 * constants_1.CELL_PADDING_H, 1);
1552
+ const measure = (t) => fontManager.measureText(t, resourceName, box.fontSize);
1553
+ const lines = box.text.split("\n").flatMap(part => wrapTextLines(part, measure, innerWidth));
1554
+ stream.setFillColor(bw ? (0, style_converter_1.toGrayscale)({ r: 0, g: 0, b: 0 }) : { r: 0, g: 0, b: 0 });
1555
+ let baseline = rect.y + rect.height - constants_1.CELL_PADDING_V - box.fontSize;
1556
+ for (const line of lines) {
1557
+ if (baseline < rect.y + constants_1.CELL_PADDING_V) {
1558
+ break;
1559
+ }
1560
+ fontManager.trackText(line);
1561
+ emitTextWithMatrix(stream, fontManager, {
1562
+ text: line,
1563
+ matrix: [1, 0, 0, 1, rect.x + constants_1.CELL_PADDING_H, baseline],
1564
+ resourceName,
1565
+ fontSize: box.fontSize
1566
+ });
1567
+ baseline -= lineHeight;
1568
+ }
1569
+ stream.restore();
1570
+ if (box.marker) {
1571
+ const { x, y, size } = box.marker;
1572
+ stream.save();
1573
+ stream.setFillColor(markerColor);
1574
+ stream.moveTo(x - size, y);
1575
+ stream.lineTo(x, y);
1576
+ stream.lineTo(x, y - size);
1577
+ stream.closePath();
1578
+ stream.fill();
1579
+ stream.restore();
1580
+ }
1581
+ }
1582
+ }
1407
1583
  function drawPageHeader(stream, page, options, fontManager) {
1408
1584
  const headerFontSize = 10;
1409
1585
  const headerText = page.sheetName;
@@ -1507,7 +1683,8 @@ function clamp01(v) {
1507
1683
  */
1508
1684
  function renderTextWatermark(stream, page, watermark, fontManager) {
1509
1685
  const fontSize = watermark.fontSize ?? TEXT_WM_DEFAULTS.fontSize;
1510
- const color = watermark.color ?? TEXT_WM_DEFAULTS.color;
1686
+ const rawColor = watermark.color ?? TEXT_WM_DEFAULTS.color;
1687
+ const color = page.options.blackAndWhite ? (0, style_converter_1.toGrayscale)(rawColor) : rawColor;
1511
1688
  const opacity = watermark.opacity ?? TEXT_WM_DEFAULTS.opacity;
1512
1689
  const rotation = watermark.rotation ?? TEXT_WM_DEFAULTS.rotation;
1513
1690
  const fontFamily = watermark.fontFamily ?? TEXT_WM_DEFAULTS.fontFamily;
@@ -148,7 +148,9 @@ function preflightCharts(allPages, fontManager) {
148
148
  }
149
149
  const stream = new pdf_stream_1.PdfContentStream();
150
150
  const alphaValues = new Set();
151
- const surface = (0, chart_surface_1.createChartSurface)(stream, fontManager, alphaValues);
151
+ // Black-and-white is a per-sheet print option, and each page carries the
152
+ // options of the sheet that produced it.
153
+ const surface = (0, chart_surface_1.createChartSurface)(stream, fontManager, alphaValues, page.options.blackAndWhite);
152
154
  chart.drawVector(surface, constrainChartAspectRatio(chart.rect));
153
155
  cache.set(chart, { stream, alphaValues });
154
156
  }
@@ -270,6 +272,10 @@ function renderSinglePage(page, fontManager, writer, fontObjectMap, chartCache,
270
272
  const { stream: contentStream, alphaValues } = (0, page_renderer_1.renderPage)(page, page.options, fontManager, totalPages);
271
273
  // Handle images: create XObject Image entries and draw them
272
274
  const imageXObjects = new Map();
275
+ // Raster payloads are converted when the XObject is written — a real pixel
276
+ // conversion for PNG, a `/DeviceN` luma space for JPEG — so no overlay is
277
+ // painted and transparency is preserved.
278
+ const grayscaleRaster = page.options.blackAndWhite;
273
279
  let headerImageContentObjNum;
274
280
  const headerContent = (0, page_renderer_1.selectHeaderFooter)(page, "header");
275
281
  const footerContent = (0, page_renderer_1.selectHeaderFooter)(page, "footer");
@@ -285,23 +291,28 @@ function renderSinglePage(page, fontManager, writer, fontObjectMap, chartCache,
285
291
  const rightEdge = alignWithMargins
286
292
  ? page.width - page.options.margins.right
287
293
  : page.width - 18;
294
+ // `scaleWithDoc` governs the whole header/footer, not just its text, so a
295
+ // logo has to shrink with the grid the same way the runs do.
296
+ const hfScale = page.headerFooter?.scaleWithDoc === false ? 1 : page.scaleFactor;
288
297
  for (let index = 0; index < headerImages.length; index++) {
289
298
  const headerImage = headerImages[index];
290
299
  if (!activePositions.has(headerImage.position)) {
291
300
  continue;
292
301
  }
293
302
  const imageName = `HfIm${index + 1}`;
294
- imageXObjects.set(imageName, getOrWriteImageObject(writer, imageObjectCache, headerImage.data, headerImage.format));
303
+ imageXObjects.set(imageName, getOrWriteImageObject(writer, imageObjectCache, headerImage.data, headerImage.format, grayscaleRaster));
304
+ const drawWidth = headerImage.width * hfScale;
305
+ const drawHeight = headerImage.height * hfScale;
295
306
  const placement = headerFooterImagePlacement(headerImage.position);
296
307
  const imageX = placement.section === "left"
297
308
  ? leftEdge
298
309
  : placement.section === "right"
299
- ? rightEdge - headerImage.width
300
- : (page.width - headerImage.width) / 2;
310
+ ? rightEdge - drawWidth
311
+ : (page.width - drawWidth) / 2;
301
312
  const imageY = placement.kind === "header"
302
- ? page.height - page.options.headerMargin - headerImage.height
313
+ ? page.height - page.options.headerMargin - drawHeight
303
314
  : page.options.footerMargin;
304
- imageStream.drawImage(imageName, imageX, imageY, headerImage.width, headerImage.height);
315
+ imageStream.drawImage(imageName, imageX, imageY, drawWidth, drawHeight);
305
316
  }
306
317
  headerImageContentObjNum = writer.allocObject();
307
318
  writer.addStreamObject(headerImageContentObjNum, new pdf_object_1.PdfDict(), imageStream);
@@ -310,7 +321,7 @@ function renderSinglePage(page, fontManager, writer, fontObjectMap, chartCache,
310
321
  for (let imgIdx = 0; imgIdx < page.images.length; imgIdx++) {
311
322
  const img = page.images[imgIdx];
312
323
  const imgName = `Im${imgIdx + 1}`;
313
- const imgObjNum = getOrWriteImageObject(writer, imageObjectCache, img.data, img.format);
324
+ const imgObjNum = getOrWriteImageObject(writer, imageObjectCache, img.data, img.format, grayscaleRaster);
314
325
  imageXObjects.set(imgName, imgObjNum);
315
326
  contentStream.drawImage(imgName, img.rect.x, img.rect.y, img.rect.width, img.rect.height);
316
327
  }
@@ -339,7 +350,7 @@ function renderSinglePage(page, fontManager, writer, fontObjectMap, chartCache,
339
350
  }
340
351
  if (chart.raster) {
341
352
  const imgName = `Im${++rasterCounter}`;
342
- const imgObjNum = getOrWriteImageObject(writer, imageObjectCache, chart.raster.data, chart.raster.format);
353
+ const imgObjNum = getOrWriteImageObject(writer, imageObjectCache, chart.raster.data, chart.raster.format, grayscaleRaster);
343
354
  imageXObjects.set(imgName, imgObjNum);
344
355
  contentStream.drawImage(imgName, chart.rect.x, chart.rect.y, chart.rect.width, chart.rect.height);
345
356
  }
@@ -359,7 +370,7 @@ function renderSinglePage(page, fontManager, writer, fontObjectMap, chartCache,
359
370
  }
360
371
  // Register watermark image XObjects
361
372
  for (const wmImg of wmResult.imageXObjects) {
362
- const imgObjNum = getOrWriteImageObject(writer, imageObjectCache, wmImg.data, wmImg.format);
373
+ const imgObjNum = getOrWriteImageObject(writer, imageObjectCache, wmImg.data, wmImg.format, grayscaleRaster);
363
374
  imageXObjects.set(wmImg.name, imgObjNum);
364
375
  }
365
376
  // Write watermark content stream object
@@ -440,6 +451,8 @@ function renderSinglePage(page, fontManager, writer, fontObjectMap, chartCache,
440
451
  height: page.height,
441
452
  contentsRef: contentsRef,
442
453
  resourcesRef: resourcesObjNum,
454
+ // Constant alpha needs a defined backdrop.
455
+ transparencyGroup: alphaValues.size > 0,
443
456
  annotRefs: annotRefs.length > 0 ? annotRefs : undefined
444
457
  });
445
458
  pageObjNums.push(pageObjNum);
@@ -453,13 +466,18 @@ function renderSinglePage(page, fontManager, writer, fontObjectMap, chartCache,
453
466
  });
454
467
  }
455
468
  }
456
- function getOrWriteImageObject(writer, cache, data, format) {
457
- const existing = cache.get(data);
469
+ function getOrWriteImageObject(writer, cache, data, format, grayscale = false) {
470
+ let variants = cache.get(data);
471
+ if (variants === undefined) {
472
+ variants = new Map();
473
+ cache.set(data, variants);
474
+ }
475
+ const existing = variants.get(grayscale);
458
476
  if (existing !== undefined) {
459
477
  return existing;
460
478
  }
461
- const objectNumber = (0, image_utils_1.writeImageXObject)(writer, data, format);
462
- cache.set(data, objectNumber);
479
+ const objectNumber = (0, image_utils_1.writeImageXObject)(writer, data, format, grayscale);
480
+ variants.set(grayscale, objectNumber);
463
481
  return objectNumber;
464
482
  }
465
483
  function headerFooterImagePositions(content, kind) {
@@ -543,6 +561,110 @@ function selectSheets(workbook, sheets) {
543
561
  // =============================================================================
544
562
  // Options Resolution
545
563
  // =============================================================================
564
+ /**
565
+ * Parse an Excel "Columns to repeat at left" reference (`printTitlesColumn`)
566
+ * into an absolute column band, e.g. `"A:B"` → `{ first: 1, last: 2 }`.
567
+ *
568
+ * Returns `null` for anything that is not a column range.
569
+ */
570
+ function parseTitlesColumnBand(ref) {
571
+ const match = ref
572
+ .replace(/\$/g, "")
573
+ .toUpperCase()
574
+ .match(/^([A-Z]+)(?::([A-Z]+))?$/);
575
+ if (!match) {
576
+ return null;
577
+ }
578
+ const first = columnLettersToNumber(match[1]);
579
+ const last = match[2] ? columnLettersToNumber(match[2]) : first;
580
+ if (last < first) {
581
+ return null;
582
+ }
583
+ return { first, last };
584
+ }
585
+ /**
586
+ * Parse an Excel "Rows to repeat at top" reference (`printTitlesRow`) into an
587
+ * absolute row band, e.g. `"1:3"` → `{ first: 1, last: 3 }`.
588
+ */
589
+ function parseTitlesRowBand(ref) {
590
+ const match = ref.replace(/\$/g, "").match(/^(\d+)(?::(\d+))?$/);
591
+ if (!match) {
592
+ return null;
593
+ }
594
+ const first = parseInt(match[1], 10);
595
+ const last = match[2] ? parseInt(match[2], 10) : first;
596
+ if (first < 1 || last < first) {
597
+ return null;
598
+ }
599
+ return { first, last };
600
+ }
601
+ function columnLettersToNumber(letters) {
602
+ let n = 0;
603
+ for (let i = 0; i < letters.length; i++) {
604
+ n = n * 26 + (letters.charCodeAt(i) - 64);
605
+ }
606
+ return n;
607
+ }
608
+ function normalizePageOrder(value) {
609
+ return value === "overThenDown" ? "overThenDown" : "downThenOver";
610
+ }
611
+ /** Excel writes `None` / `asDisplayed` / `atEnd`. */
612
+ function normalizeCommentMode(value) {
613
+ if (value === "atEnd" || value === "asDisplayed") {
614
+ return value;
615
+ }
616
+ return "none";
617
+ }
618
+ function normalizeErrorMode(value) {
619
+ switch (value) {
620
+ case "blank":
621
+ case "dash":
622
+ case "NA":
623
+ return value;
624
+ default:
625
+ return "displayed";
626
+ }
627
+ }
628
+ /**
629
+ * Reconcile documonster's scaling options with Excel's two mutually exclusive
630
+ * scaling modes (Page Setup → Scaling):
631
+ *
632
+ * - **"Adjust to N %"** — `pageSetup.scale`, with `pageSetup.fitToPage` off.
633
+ * - **"Fit to N pages wide by M tall"** — `pageSetup.fitToWidth` /
634
+ * `fitToHeight`, with `pageSetup.fitToPage` on. Excel ignores `scale` here.
635
+ *
636
+ * Neither mode ever enlarges: Excel caps fit-to-page scaling at 100%.
637
+ *
638
+ * `PdfExportOptions.fitToPage` is documonster's own convenience default
639
+ * ("shrink to one page wide"). It only applies when neither the caller nor the
640
+ * sheet expresses a scaling intent, so a sheet that asks for 80% is not
641
+ * shrunk twice.
642
+ */
643
+ function resolveScaling(options, ps) {
644
+ const clampPages = (n) => n === undefined || !Number.isFinite(n) || n <= 0 ? 0 : Math.floor(n);
645
+ const callerFit = options?.fitToWidth !== undefined || options?.fitToHeight !== undefined;
646
+ const sheetFitMode = ps?.fitToPage === true;
647
+ let fitToWidth = 0;
648
+ let fitToHeight = 0;
649
+ if (callerFit) {
650
+ fitToWidth = clampPages(options?.fitToWidth);
651
+ fitToHeight = clampPages(options?.fitToHeight);
652
+ }
653
+ else if (sheetFitMode && options?.scale === undefined && options?.fitToPage !== false) {
654
+ // `fitToPage: false` is the caller saying "do not auto-scale"; it has to
655
+ // beat the sheet's own fit-to-N mode, or an explicit option would lose to
656
+ // the workbook it is meant to override.
657
+ fitToWidth = clampPages(ps?.fitToWidth);
658
+ fitToHeight = clampPages(ps?.fitToHeight);
659
+ }
660
+ // A sheet in "Adjust to N %" mode with a non-default percentage.
661
+ const sheetScalePercent = !sheetFitMode && ps?.scale !== undefined && ps.scale !== 100 ? ps.scale / 100 : undefined;
662
+ // Excel's Page Setup allows 10–400%, so the ceiling is 4.0 rather than 3.0.
663
+ const scale = Math.max(0.1, Math.min(4.0, options?.scale ?? sheetScalePercent ?? 1.0));
664
+ const hasFitConstraint = fitToWidth > 0 || fitToHeight > 0;
665
+ const fitToPage = options?.fitToPage ?? !(hasFitConstraint || sheetScalePercent !== undefined || callerFit);
666
+ return { fitToPage, scale, fitToWidth, fitToHeight };
667
+ }
546
668
  /**
547
669
  * Resolve user options with defaults.
548
670
  */
@@ -563,40 +685,59 @@ function resolveOptions(options, sheet) {
563
685
  chartsheetOrientation ??
564
686
  (ps?.orientation === "landscape" ? "landscape" : "portrait");
565
687
  const margins = resolveMargins(options?.margins, ps?.margins);
688
+ const blackAndWhite = options?.blackAndWhite ?? ps?.blackAndWhite ?? false;
566
689
  const gridLineColorStr = options?.gridLineColor ?? "FFD0D0D0";
567
- const gridLineColor = (0, style_converter_1.argbToPdfColor)(gridLineColorStr) ?? {
690
+ const gridLineColorRaw = (0, style_converter_1.argbToPdfColor)(gridLineColorStr) ?? {
568
691
  r: 0.816,
569
692
  g: 0.816,
570
693
  b: 0.816
571
694
  };
572
- // Use sheet's printTitlesRow as fallback for repeatRows. Only
573
- // PdfSheetData carries `printTitlesRow` — chartsheets never have
574
- // repeated header rows.
575
- let repeatRows = options?.repeatRows ?? false;
576
- if (repeatRows === false && sheet && !(0, types_1.isPdfChartsheet)(sheet) && ps?.printTitlesRow) {
577
- // printTitlesRow format: "1:3" (repeat rows 1-3) or "1" (repeat row 1)
578
- const match = ps.printTitlesRow.match(/^(\d+)(?::(\d+))?$/);
579
- if (match) {
580
- repeatRows = parseInt(match[2] ?? match[1], 10);
581
- }
582
- }
695
+ const gridLineColor = blackAndWhite ? (0, style_converter_1.toGrayscale)(gridLineColorRaw) : gridLineColorRaw;
696
+ // Repeated title rows/columns fall back to the sheet's print titles. Only
697
+ // PdfSheetData carries them — chartsheets have no repeated bands. An
698
+ // explicitly passed `false` suppresses the sheet setting, so "not provided"
699
+ // (undefined) and "provided as off" must stay distinguishable.
700
+ const isWorksheet = Boolean(sheet) && !(0, types_1.isPdfChartsheet)(sheet);
701
+ // A caller-supplied count means "the first N rows/columns of the sheet"; the
702
+ // sheet's own print titles are absolute ranges that may sit outside the print
703
+ // area, so both normalise to an absolute band.
704
+ const countToBand = (n) => typeof n === "number" && n > 0 ? { first: 1, last: n } : false;
705
+ let repeatRows = false;
706
+ if (options?.repeatRows !== undefined) {
707
+ repeatRows = countToBand(options.repeatRows);
708
+ }
709
+ else if (isWorksheet && ps?.printTitlesRow) {
710
+ repeatRows = parseTitlesRowBand(ps.printTitlesRow) ?? false;
711
+ }
712
+ let repeatCols = false;
713
+ if (options?.repeatCols !== undefined) {
714
+ repeatCols = countToBand(options.repeatCols);
715
+ }
716
+ else if (isWorksheet && ps?.printTitlesColumn) {
717
+ repeatCols = parseTitlesColumnBand(ps.printTitlesColumn) ?? false;
718
+ }
719
+ const { fitToPage, scale, fitToWidth, fitToHeight } = resolveScaling(options, ps);
583
720
  return {
584
721
  pageSize,
585
722
  orientation,
586
723
  margins,
587
724
  ignorePrintArea: options?.ignorePrintArea ?? false,
588
- fitToPage: options?.fitToPage !== undefined ? options.fitToPage : true,
589
- scale: Math.max(0.1, Math.min(3.0, options?.scale ??
590
- // When fitToPage is active (default), ignore sheet's pageSetup.scale
591
- // to avoid double-scaling. Only apply sheet scale when fitToPage is off.
592
- ((options?.fitToPage !== undefined ? options.fitToPage : true)
593
- ? 1.0
594
- : ps?.scale
595
- ? ps.scale / 100
596
- : 1.0))),
725
+ fitToPage,
726
+ scale,
727
+ fitToWidth,
728
+ fitToHeight,
597
729
  showGridLines: options?.showGridLines ?? ps?.showGridLines ?? false,
598
730
  gridLineColor,
731
+ showRowColHeaders: options?.showRowColHeaders ?? ps?.showRowColHeaders ?? false,
732
+ horizontalCentered: options?.horizontalCentered ?? ps?.horizontalCentered ?? false,
733
+ verticalCentered: options?.verticalCentered ?? ps?.verticalCentered ?? false,
734
+ pageOrder: options?.pageOrder ?? normalizePageOrder(ps?.pageOrder),
735
+ blackAndWhite,
736
+ draft: options?.draft ?? ps?.draft ?? false,
737
+ errors: options?.errors ?? normalizeErrorMode(ps?.errors),
738
+ cellComments: options?.cellComments ?? normalizeCommentMode(ps?.cellComments),
599
739
  repeatRows,
740
+ repeatCols,
600
741
  defaultFontFamily: options?.defaultFontFamily ?? "Helvetica",
601
742
  defaultFontSize: options?.defaultFontSize ?? 11,
602
743
  showSheetNames: options?.showSheetNames ?? false,
@@ -13,6 +13,8 @@ exports.DEFAULT_COLORS = void 0;
13
13
  exports.argbToPdfColor = argbToPdfColor;
14
14
  exports.excelColorToPdf = excelColorToPdf;
15
15
  exports.applyTint = applyTint;
16
+ exports.toGrayscale = toGrayscale;
17
+ exports.grayscaleBorders = grayscaleBorders;
16
18
  exports.extractFontProperties = extractFontProperties;
17
19
  exports.excelFillToPdfColor = excelFillToPdfColor;
18
20
  exports.borderStyleToLineWidth = borderStyleToLineWidth;
@@ -177,6 +179,30 @@ function indexedColorToPdf(index) {
177
179
  function applyTint(color, tint) {
178
180
  return (0, theme_colors_1.applyTintRgb01)(color, tint);
179
181
  }
182
+ /**
183
+ * Convert a color to its luminance-preserving grayscale equivalent, using the
184
+ * Rec. 601 luma weights. Backs Excel's "Black and white" print option: hues are
185
+ * discarded while relative lightness — and therefore contrast — is kept.
186
+ *
187
+ * Opacity is carried through untouched; dropping it would make transparent
188
+ * chart fills opaque.
189
+ */
190
+ function toGrayscale(color) {
191
+ const luma = 0.299 * color.r + 0.587 * color.g + 0.114 * color.b;
192
+ return color.a === undefined
193
+ ? { r: luma, g: luma, b: luma }
194
+ : { r: luma, g: luma, b: luma, a: color.a };
195
+ }
196
+ /** Grayscale every edge of a border set, preserving `null` edges. */
197
+ function grayscaleBorders(borders) {
198
+ const convert = (b) => b === null ? null : { ...b, color: toGrayscale(b.color) };
199
+ return {
200
+ top: convert(borders.top),
201
+ right: convert(borders.right),
202
+ bottom: convert(borders.bottom),
203
+ left: convert(borders.left)
204
+ };
205
+ }
180
206
  /**
181
207
  * Default colors used in PDF rendering.
182
208
  */
@@ -76,41 +76,90 @@ export function parseJpegDimensions(data) {
76
76
  * Write an image XObject (JPEG or PNG) to the writer.
77
77
  * Returns the allocated object number.
78
78
  */
79
- export function writeImageXObject(writer, data, format) {
79
+ export function writeImageXObject(writer, data, format, grayscale = false) {
80
80
  if (format === "png") {
81
- return writePngImageXObject(writer, data);
81
+ return writePngImageXObject(writer, data, grayscale);
82
82
  }
83
- return writeJpegImageXObject(writer, data);
83
+ return writeJpegImageXObject(writer, data, grayscale);
84
84
  }
85
85
  /**
86
- * Write a JPEG image using DCTDecode (raw JPEG data embedded directly).
86
+ * Write the PostScript calculator function that converts an RGB triple to its
87
+ * Rec. 601 luma, and return its object number.
88
+ *
89
+ * Used as the tint transform of a `/DeviceN` space so a JPEG can be recoloured
90
+ * without decoding it: the samples stay untouched and the consumer applies the
91
+ * transform per pixel. Unlike painting a blend on top, this cannot darken
92
+ * transparent areas, because it *is* the color space rather than an overlay.
87
93
  */
88
- function writeJpegImageXObject(writer, data) {
94
+ function writeLumaTintTransform(writer) {
89
95
  const objNum = writer.allocObject();
96
+ const dict = new PdfDict()
97
+ .set("FunctionType", "4")
98
+ .set("Domain", "[0 1 0 1 0 1]")
99
+ .set("Range", "[0 1]");
100
+ // Stack on entry is R G B (B on top):
101
+ // 0.114 mul -> R G 0.114B
102
+ // exch -> R 0.114B G
103
+ // 0.587 mul -> R 0.114B 0.587G
104
+ // add -> R (0.114B + 0.587G)
105
+ // exch -> (0.114B + 0.587G) R
106
+ // 0.299 mul -> (0.114B + 0.587G) 0.299R
107
+ // add -> luma
108
+ const program = "{ 0.114 mul exch 0.587 mul add exch 0.299 mul add }";
109
+ writer.addStreamObject(objNum, dict, new TextEncoder().encode(program));
110
+ return objNum;
111
+ }
112
+ /**
113
+ * Write a JPEG image using DCTDecode (raw JPEG data embedded directly).
114
+ */
115
+ function writeJpegImageXObject(writer, data, grayscale = false) {
90
116
  const dims = parseJpegDimensions(data);
117
+ // Grayscale without a JPEG decoder: keep the DCTDecode samples and reinterpret
118
+ // the three components through a `/DeviceN` space whose tint transform is the
119
+ // luma formula. Component count still matches the JPEG, as PDF requires.
120
+ const tintRef = grayscale ? writeLumaTintTransform(writer) : undefined;
121
+ const objNum = writer.allocObject();
91
122
  const dict = new PdfDict()
92
123
  .set("Type", "/XObject")
93
124
  .set("Subtype", "/Image")
94
125
  .set("Width", pdfNumber(dims.width))
95
126
  .set("Height", pdfNumber(dims.height))
96
- .set("ColorSpace", "/DeviceRGB")
127
+ .set("ColorSpace", tintRef === undefined
128
+ ? "/DeviceRGB"
129
+ : `[/DeviceN [/C1 /C2 /C3] /DeviceGray ${pdfRef(tintRef)}]`)
97
130
  .set("BitsPerComponent", "8")
98
131
  .set("Filter", "/DCTDecode");
99
132
  writer.addStreamObject(objNum, dict, data);
100
133
  return objNum;
101
134
  }
135
+ /** Rec. 601 luma of an 8-bit RGB triple. */
136
+ function lumaByte(r, g, b) {
137
+ return Math.round(0.299 * r + 0.587 * g + 0.114 * b);
138
+ }
102
139
  /**
103
140
  * Write a PNG image: decode to raw RGB, create SMask for alpha if needed.
104
141
  */
105
- function writePngImageXObject(writer, data) {
142
+ function writePngImageXObject(writer, data, grayscale = false) {
106
143
  const png = decodePng(data);
107
144
  const objNum = writer.allocObject();
145
+ // PNG is already decoded to raw samples here, so grayscale is a real pixel
146
+ // conversion: collapse RGB to one luma component and switch to DeviceGray.
147
+ // The alpha channel lives in a separate SMask and is untouched, so transparent
148
+ // regions stay transparent.
149
+ let pixels = png.pixels;
150
+ if (grayscale) {
151
+ const gray = new Uint8Array(png.width * png.height);
152
+ for (let i = 0, p = 0; i < gray.length; i++, p += 3) {
153
+ gray[i] = lumaByte(pixels[p], pixels[p + 1], pixels[p + 2]);
154
+ }
155
+ pixels = gray;
156
+ }
108
157
  const dict = new PdfDict()
109
158
  .set("Type", "/XObject")
110
159
  .set("Subtype", "/Image")
111
160
  .set("Width", pdfNumber(png.width))
112
161
  .set("Height", pdfNumber(png.height))
113
- .set("ColorSpace", "/DeviceRGB")
162
+ .set("ColorSpace", grayscale ? "/DeviceGray" : "/DeviceRGB")
114
163
  .set("BitsPerComponent", pdfNumber(png.bitsPerComponent));
115
164
  if (png.alpha) {
116
165
  const smaskObjNum = writer.allocObject();
@@ -124,6 +173,6 @@ function writePngImageXObject(writer, data) {
124
173
  writer.addStreamObject(smaskObjNum, smaskDict, png.alpha);
125
174
  dict.set("SMask", pdfRef(smaskObjNum));
126
175
  }
127
- writer.addStreamObject(objNum, dict, png.pixels);
176
+ writer.addStreamObject(objNum, dict, pixels);
128
177
  return objNum;
129
178
  }