@cj-tech-master/excelts 9.0.0 → 9.1.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 (96) hide show
  1. package/dist/browser/index.browser.d.ts +2 -0
  2. package/dist/browser/index.browser.js +2 -0
  3. package/dist/browser/index.d.ts +2 -0
  4. package/dist/browser/index.js +2 -0
  5. package/dist/browser/modules/excel/image.d.ts +27 -2
  6. package/dist/browser/modules/excel/image.js +23 -1
  7. package/dist/browser/modules/excel/stream/worksheet-writer.d.ts +16 -1
  8. package/dist/browser/modules/excel/stream/worksheet-writer.js +68 -0
  9. package/dist/browser/modules/excel/types.d.ts +72 -0
  10. package/dist/browser/modules/excel/utils/drawing-utils.d.ts +4 -0
  11. package/dist/browser/modules/excel/utils/drawing-utils.js +5 -0
  12. package/dist/browser/modules/excel/utils/ooxml-paths.d.ts +4 -0
  13. package/dist/browser/modules/excel/utils/ooxml-paths.js +15 -0
  14. package/dist/browser/modules/excel/utils/watermark-image.d.ts +67 -0
  15. package/dist/browser/modules/excel/utils/watermark-image.js +383 -0
  16. package/dist/browser/modules/excel/worksheet.d.ts +39 -1
  17. package/dist/browser/modules/excel/worksheet.js +99 -0
  18. package/dist/browser/modules/excel/xlsx/xform/core/content-types-xform.js +3 -2
  19. package/dist/browser/modules/excel/xlsx/xform/drawing/base-cell-anchor-xform.js +6 -1
  20. package/dist/browser/modules/excel/xlsx/xform/drawing/blip-fill-xform.d.ts +2 -1
  21. package/dist/browser/modules/excel/xlsx/xform/drawing/blip-fill-xform.js +0 -1
  22. package/dist/browser/modules/excel/xlsx/xform/drawing/blip-xform.d.ts +3 -1
  23. package/dist/browser/modules/excel/xlsx/xform/drawing/blip-xform.js +22 -6
  24. package/dist/browser/modules/excel/xlsx/xform/drawing/pic-xform.d.ts +3 -0
  25. package/dist/browser/modules/excel/xlsx/xform/drawing/pic-xform.js +5 -1
  26. package/dist/browser/modules/excel/xlsx/xform/drawing/vml-drawing-xform.d.ts +19 -0
  27. package/dist/browser/modules/excel/xlsx/xform/drawing/vml-drawing-xform.js +103 -4
  28. package/dist/browser/modules/excel/xlsx/xform/sheet/worksheet-xform.js +135 -8
  29. package/dist/browser/modules/excel/xlsx/xlsx.browser.d.ts +1 -0
  30. package/dist/browser/modules/excel/xlsx/xlsx.browser.js +53 -1
  31. package/dist/browser/modules/pdf/core/pdf-writer.d.ts +1 -1
  32. package/dist/browser/modules/pdf/core/pdf-writer.js +2 -1
  33. package/dist/browser/modules/pdf/index.d.ts +1 -1
  34. package/dist/browser/modules/pdf/render/page-renderer.d.ts +29 -1
  35. package/dist/browser/modules/pdf/render/page-renderer.js +394 -25
  36. package/dist/browser/modules/pdf/render/pdf-exporter.js +84 -47
  37. package/dist/browser/modules/pdf/types.d.ts +235 -0
  38. package/dist/cjs/index.js +5 -2
  39. package/dist/cjs/modules/excel/image.js +23 -1
  40. package/dist/cjs/modules/excel/stream/worksheet-writer.js +68 -0
  41. package/dist/cjs/modules/excel/utils/drawing-utils.js +5 -0
  42. package/dist/cjs/modules/excel/utils/ooxml-paths.js +19 -0
  43. package/dist/cjs/modules/excel/utils/watermark-image.js +386 -0
  44. package/dist/cjs/modules/excel/worksheet.js +99 -0
  45. package/dist/cjs/modules/excel/xlsx/xform/core/content-types-xform.js +3 -2
  46. package/dist/cjs/modules/excel/xlsx/xform/drawing/base-cell-anchor-xform.js +6 -1
  47. package/dist/cjs/modules/excel/xlsx/xform/drawing/blip-fill-xform.js +0 -1
  48. package/dist/cjs/modules/excel/xlsx/xform/drawing/blip-xform.js +22 -6
  49. package/dist/cjs/modules/excel/xlsx/xform/drawing/pic-xform.js +5 -1
  50. package/dist/cjs/modules/excel/xlsx/xform/drawing/vml-drawing-xform.js +103 -4
  51. package/dist/cjs/modules/excel/xlsx/xform/sheet/worksheet-xform.js +134 -7
  52. package/dist/cjs/modules/excel/xlsx/xlsx.browser.js +52 -0
  53. package/dist/cjs/modules/pdf/core/pdf-writer.js +2 -1
  54. package/dist/cjs/modules/pdf/render/page-renderer.js +396 -25
  55. package/dist/cjs/modules/pdf/render/pdf-exporter.js +83 -46
  56. package/dist/esm/index.browser.js +2 -0
  57. package/dist/esm/index.js +2 -0
  58. package/dist/esm/modules/excel/image.js +23 -1
  59. package/dist/esm/modules/excel/stream/worksheet-writer.js +68 -0
  60. package/dist/esm/modules/excel/utils/drawing-utils.js +5 -0
  61. package/dist/esm/modules/excel/utils/ooxml-paths.js +15 -0
  62. package/dist/esm/modules/excel/utils/watermark-image.js +383 -0
  63. package/dist/esm/modules/excel/worksheet.js +99 -0
  64. package/dist/esm/modules/excel/xlsx/xform/core/content-types-xform.js +3 -2
  65. package/dist/esm/modules/excel/xlsx/xform/drawing/base-cell-anchor-xform.js +6 -1
  66. package/dist/esm/modules/excel/xlsx/xform/drawing/blip-fill-xform.js +0 -1
  67. package/dist/esm/modules/excel/xlsx/xform/drawing/blip-xform.js +22 -6
  68. package/dist/esm/modules/excel/xlsx/xform/drawing/pic-xform.js +5 -1
  69. package/dist/esm/modules/excel/xlsx/xform/drawing/vml-drawing-xform.js +103 -4
  70. package/dist/esm/modules/excel/xlsx/xform/sheet/worksheet-xform.js +135 -8
  71. package/dist/esm/modules/excel/xlsx/xlsx.browser.js +53 -1
  72. package/dist/esm/modules/pdf/core/pdf-writer.js +2 -1
  73. package/dist/esm/modules/pdf/render/page-renderer.js +394 -25
  74. package/dist/esm/modules/pdf/render/pdf-exporter.js +84 -47
  75. package/dist/iife/excelts.iife.js +2390 -469
  76. package/dist/iife/excelts.iife.js.map +1 -1
  77. package/dist/iife/excelts.iife.min.js +47 -47
  78. package/dist/types/index.browser.d.ts +2 -0
  79. package/dist/types/index.d.ts +2 -0
  80. package/dist/types/modules/excel/image.d.ts +27 -2
  81. package/dist/types/modules/excel/stream/worksheet-writer.d.ts +16 -1
  82. package/dist/types/modules/excel/types.d.ts +72 -0
  83. package/dist/types/modules/excel/utils/drawing-utils.d.ts +4 -0
  84. package/dist/types/modules/excel/utils/ooxml-paths.d.ts +4 -0
  85. package/dist/types/modules/excel/utils/watermark-image.d.ts +67 -0
  86. package/dist/types/modules/excel/worksheet.d.ts +39 -1
  87. package/dist/types/modules/excel/xlsx/xform/drawing/blip-fill-xform.d.ts +2 -1
  88. package/dist/types/modules/excel/xlsx/xform/drawing/blip-xform.d.ts +3 -1
  89. package/dist/types/modules/excel/xlsx/xform/drawing/pic-xform.d.ts +3 -0
  90. package/dist/types/modules/excel/xlsx/xform/drawing/vml-drawing-xform.d.ts +19 -0
  91. package/dist/types/modules/excel/xlsx/xlsx.browser.d.ts +1 -0
  92. package/dist/types/modules/pdf/core/pdf-writer.d.ts +1 -1
  93. package/dist/types/modules/pdf/index.d.ts +1 -1
  94. package/dist/types/modules/pdf/render/page-renderer.d.ts +29 -1
  95. package/dist/types/modules/pdf/types.d.ts +235 -0
  96. package/package.json +1 -1
@@ -108,22 +108,57 @@ function drawCellFill(stream, cell, alphaValues) {
108
108
  }
109
109
  }
110
110
  // =============================================================================
111
+ // Rotation Helpers
112
+ // =============================================================================
113
+ /**
114
+ * Convert Excel textRotation to standard signed degrees.
115
+ * Excel uses 1-90 for CCW and 91-180 for CW (where 91 = -1°, 180 = -90°).
116
+ * Returns 0 for non-numeric values (e.g. "vertical").
117
+ */
118
+ function excelRotationToDegrees(textRotation) {
119
+ if (typeof textRotation !== "number") {
120
+ return 0;
121
+ }
122
+ return textRotation <= 90 ? textRotation : -(textRotation - 90);
123
+ }
124
+ // =============================================================================
111
125
  // Cell Borders
112
126
  // =============================================================================
127
+ /**
128
+ * Compute the horizontal slant offset for parallelogram borders.
129
+ * For general rotation angles (not 0°/90°), Excel renders cell borders as a
130
+ * parallelogram whose left/right edges tilt to match the text rotation angle.
131
+ * Returns 0 for straight borders (no rotation, 90°, -90°, or vertical stacked).
132
+ */
133
+ function computeSlantOffset(textRotation, height) {
134
+ const degrees = excelRotationToDegrees(textRotation);
135
+ if (degrees === 0) {
136
+ return 0;
137
+ }
138
+ const absDeg = Math.abs(degrees);
139
+ if (absDeg < 0.01 || absDeg > 89.99) {
140
+ return 0;
141
+ }
142
+ const radians = (absDeg * Math.PI) / 180;
143
+ const offset = (height * Math.cos(radians)) / Math.sin(radians);
144
+ return degrees < 0 ? -offset : offset;
145
+ }
113
146
  function drawCellBorders(stream, cell) {
114
- const { rect, borders } = cell;
147
+ const { rect, borders, textRotation } = cell;
115
148
  const { x, y, width, height } = rect;
149
+ // Compute slant for parallelogram borders on general-angle rotated cells
150
+ const slant = computeSlantOffset(textRotation, height);
116
151
  if (borders.top) {
117
- drawBorderLine(stream, borders.top, x, y + height, x + width, y + height, true);
152
+ drawBorderLine(stream, borders.top, x + slant, y + height, x + width + slant, y + height, true);
118
153
  }
119
154
  if (borders.bottom) {
120
155
  drawBorderLine(stream, borders.bottom, x, y, x + width, y, true);
121
156
  }
122
157
  if (borders.left) {
123
- drawBorderLine(stream, borders.left, x, y, x, y + height, false);
158
+ drawBorderLine(stream, borders.left, x, y, x + slant, y + height, false);
124
159
  }
125
160
  if (borders.right) {
126
- drawBorderLine(stream, borders.right, x + width, y, x + width, y + height, false);
161
+ drawBorderLine(stream, borders.right, x + width, y, x + width + slant, y + height, false);
127
162
  }
128
163
  }
129
164
  function drawBorderLine(stream, border, x1, y1, x2, y2, isHorizontal) {
@@ -161,9 +196,21 @@ function drawCellText(stream, cell, fontManager, alphaValues, scaleFactor = 1) {
161
196
  }
162
197
  const indentPts = cell.indent * INDENT_WIDTH * scaleFactor;
163
198
  // Clip to cell bounds (extend for text overflow into adjacent empty cells)
199
+ // For rotated text with slanted borders, use a parallelogram clip path
164
200
  const clipWidth = rect.width + (cell.textOverflowWidth || 0);
165
201
  stream.save();
166
- stream.rect(rect.x, rect.y, clipWidth, rect.height);
202
+ const slantClip = computeSlantOffset(cell.textRotation, rect.height);
203
+ if (slantClip !== 0) {
204
+ // Parallelogram clip: bottom-left, bottom-right, top-right (shifted), top-left (shifted)
205
+ stream.moveTo(rect.x, rect.y);
206
+ stream.lineTo(rect.x + clipWidth, rect.y);
207
+ stream.lineTo(rect.x + clipWidth + slantClip, rect.y + rect.height);
208
+ stream.lineTo(rect.x + slantClip, rect.y + rect.height);
209
+ stream.closePath();
210
+ }
211
+ else {
212
+ stream.rect(rect.x, rect.y, clipWidth, rect.height);
213
+ }
167
214
  stream.clip();
168
215
  stream.endPath();
169
216
  // Apply text color alpha if needed
@@ -392,14 +439,7 @@ function drawRotatedText(stream, cell, fontManager, indentPts, scaleFactor = 1)
392
439
  ? fontManager.getEmbeddedResourceName()
393
440
  : fontManager.ensureFont(resolvePdfFontName(cell.fontFamily, cell.bold, cell.italic));
394
441
  // Convert Excel rotation to degrees
395
- // 1-90: counterclockwise, 91-180: clockwise (value-90 degrees)
396
- let degrees;
397
- if (typeof cell.textRotation === "number") {
398
- degrees = cell.textRotation <= 90 ? cell.textRotation : -(cell.textRotation - 90);
399
- }
400
- else {
401
- degrees = 0;
402
- }
442
+ const degrees = excelRotationToDegrees(cell.textRotation);
403
443
  const radians = (degrees * Math.PI) / 180;
404
444
  const cos = Math.cos(radians);
405
445
  const sin = Math.sin(radians);
@@ -469,31 +509,35 @@ function drawRotatedText(stream, cell, fontManager, indentPts, scaleFactor = 1)
469
509
  function drawRotated90(stream, cell, lines, fontManager, resourceName, fontSize, lineHeight, ascent, padH, padV) {
470
510
  const { rect, horizontalAlign, verticalAlign } = cell;
471
511
  const totalColumnsWidth = lines.length * lineHeight;
472
- // Horizontal centering of line columns
512
+ // horizontalAlign controls X placement of line columns (same visual axis)
473
513
  let startX;
474
- if (horizontalAlign === "center" || lines.length === 1) {
514
+ if (horizontalAlign === "center") {
475
515
  startX = rect.x + rect.width / 2 - totalColumnsWidth / 2 + ascent;
476
516
  }
477
517
  else if (horizontalAlign === "right") {
478
518
  startX = rect.x + rect.width - padH - totalColumnsWidth + ascent;
479
519
  }
480
520
  else {
521
+ // left (default)
481
522
  startX = rect.x + padH + ascent;
482
523
  }
483
524
  for (let i = 0; i < lines.length; i++) {
484
525
  const line = lines[i];
485
526
  const lineWidth = fontManager.measureText(line, resourceName, fontSize);
486
527
  const colX = startX + i * lineHeight;
487
- // Vertical positioning: text flows upward from bottom
528
+ // verticalAlign controls Y placement (text flows upward from ty)
529
+ // In PDF coords: higher y = top of cell
488
530
  let ty;
489
531
  if (verticalAlign === "top") {
490
- ty = rect.y + padV;
532
+ // text at top → text end near top → ty starts at bottom so text reaches top
533
+ ty = rect.y + rect.height - padV - lineWidth;
491
534
  }
492
535
  else if (verticalAlign === "middle") {
493
536
  ty = rect.y + (rect.height - lineWidth) / 2;
494
537
  }
495
538
  else {
496
- ty = rect.y + rect.height - padV - lineWidth;
539
+ // bottom (default) text at bottom ty near bottom
540
+ ty = rect.y + padV;
497
541
  }
498
542
  ty = Math.max(ty, rect.y + padV);
499
543
  stream.beginText();
@@ -507,28 +551,34 @@ function drawRotated90(stream, cell, lines, fontManager, resourceName, fontSize,
507
551
  function drawRotatedMinus90(stream, cell, lines, fontManager, resourceName, fontSize, lineHeight, ascent, padH, padV) {
508
552
  const { rect, horizontalAlign, verticalAlign } = cell;
509
553
  const totalColumnsWidth = lines.length * lineHeight;
554
+ // horizontalAlign controls X placement: lines stack right-to-left
510
555
  let startX;
511
- if (horizontalAlign === "center" || lines.length === 1) {
556
+ if (horizontalAlign === "center") {
512
557
  startX = rect.x + rect.width / 2 + totalColumnsWidth / 2 - lineHeight + ascent;
513
558
  }
514
559
  else if (horizontalAlign === "right") {
515
560
  startX = rect.x + rect.width - padH - lineHeight + ascent;
516
561
  }
517
562
  else {
563
+ // left (default)
518
564
  startX = rect.x + padH + totalColumnsWidth - lineHeight + ascent;
519
565
  }
520
566
  for (let i = 0; i < lines.length; i++) {
521
567
  const line = lines[i];
522
568
  const lineWidth = fontManager.measureText(line, resourceName, fontSize);
523
569
  const colX = startX - i * lineHeight;
570
+ // verticalAlign controls Y placement (text flows downward from ty)
571
+ // In PDF coords: higher y = top of cell; text drawn downward = toward lower y
524
572
  let ty;
525
573
  if (verticalAlign === "top") {
574
+ // text at top → ty near top (high PDF y)
526
575
  ty = rect.y + rect.height - padV;
527
576
  }
528
577
  else if (verticalAlign === "middle") {
529
578
  ty = rect.y + (rect.height + lineWidth) / 2;
530
579
  }
531
580
  else {
581
+ // bottom (default) → text at bottom → ty so text ends at bottom
532
582
  ty = rect.y + padV + lineWidth;
533
583
  }
534
584
  ty = Math.min(ty, rect.y + rect.height - padV);
@@ -541,10 +591,53 @@ function drawRotatedMinus90(stream, cell, lines, fontManager, resourceName, font
541
591
  }
542
592
  /** General rotation — center a multi-line text block in the cell. */
543
593
  function drawRotatedGeneral(stream, cell, lines, fontManager, resourceName, fontSize, lineHeight, ascent, cos, sin, indentPts) {
544
- const { rect, horizontalAlign } = cell;
594
+ const { rect, horizontalAlign, verticalAlign } = cell;
595
+ const padH = CELL_PADDING_H;
596
+ const padV = CELL_PADDING_V;
597
+ // Compute the rotated bounding box of the text block
598
+ let maxLineWidth = 0;
599
+ for (const line of lines) {
600
+ const w = fontManager.measureText(line, resourceName, fontSize);
601
+ if (w > maxLineWidth) {
602
+ maxLineWidth = w;
603
+ }
604
+ }
605
+ const totalTextHeight = lines.length * lineHeight;
606
+ const absSin = Math.abs(sin);
607
+ const absCos = Math.abs(cos);
608
+ const rotatedWidth = maxLineWidth * absCos + totalTextHeight * absSin;
609
+ const rotatedHeight = maxLineWidth * absSin + totalTextHeight * absCos;
610
+ // Compute slant offset to match parallelogram border shape
611
+ const slantShift = computeSlantOffset(cell.textRotation, rect.height) / 2;
612
+ // Determine vertical position first, then horizontal (because slant depends on Y position)
545
613
  const indentOffset = horizontalAlign === "left" ? indentPts / 2 : horizontalAlign === "right" ? -indentPts / 2 : 0;
546
- const cx = rect.x + rect.width / 2 + indentOffset;
547
- const cy = rect.y + rect.height / 2;
614
+ let cy;
615
+ if (verticalAlign === "top") {
616
+ cy = rect.y + rect.height - padV - rotatedHeight / 2;
617
+ }
618
+ else if (verticalAlign === "bottom") {
619
+ cy = rect.y + padV + rotatedHeight / 2;
620
+ }
621
+ else {
622
+ // middle (default)
623
+ cy = rect.y + rect.height / 2;
624
+ }
625
+ // For slanted parallelogram, the horizontal offset depends on the vertical position
626
+ // At bottom (y), left edge is at x; at top (y+height), left edge is at x+slantOffset
627
+ // At cy, the horizontal shift is proportional: slantOffset * (cy - y) / height
628
+ const verticalRatio = rect.height > 0 ? (cy - rect.y) / rect.height : 0.5;
629
+ const slantAtCy = slantShift * 2 * verticalRatio; // slantShift*2 = full slantOffset
630
+ let cx;
631
+ if (horizontalAlign === "right") {
632
+ cx = rect.x + rect.width - padH - rotatedWidth / 2 + indentOffset + slantAtCy;
633
+ }
634
+ else if (horizontalAlign === "left") {
635
+ cx = rect.x + padH + rotatedWidth / 2 + indentOffset + slantAtCy;
636
+ }
637
+ else {
638
+ // center (default for rotated)
639
+ cx = rect.x + rect.width / 2 + indentOffset + slantAtCy;
640
+ }
548
641
  for (let i = 0; i < lines.length; i++) {
549
642
  const line = lines[i];
550
643
  const lineWidth = fontManager.measureText(line, resourceName, fontSize);
@@ -575,7 +668,8 @@ function emitText(stream, fontManager, text, resourceName) {
575
668
  * Newlines (\n) start a new column to the right.
576
669
  */
577
670
  function drawVerticalStackedText(stream, cell, fontManager, _indentPts, scaleFactor = 1) {
578
- const { rect, text, fontSize } = cell;
671
+ const { rect, text, fontSize, horizontalAlign, verticalAlign } = cell;
672
+ const padH = CELL_PADDING_H * scaleFactor;
579
673
  const padV = CELL_PADDING_V * scaleFactor;
580
674
  const isEmbedded = fontManager.hasEmbeddedFont();
581
675
  const resourceName = isEmbedded
@@ -587,12 +681,35 @@ function drawVerticalStackedText(stream, cell, fontManager, _indentPts, scaleFac
587
681
  const columns = text.split(/\r?\n/);
588
682
  const columnWidth = fontSize * 1.4;
589
683
  const totalColumnsWidth = columns.length * columnWidth;
590
- const startX = rect.x + rect.width / 2 - totalColumnsWidth / 2 + columnWidth / 2;
684
+ // Horizontal alignment controls column X positioning
685
+ let startX;
686
+ if (horizontalAlign === "center") {
687
+ startX = rect.x + rect.width / 2 - totalColumnsWidth / 2 + columnWidth / 2;
688
+ }
689
+ else if (horizontalAlign === "right") {
690
+ startX = rect.x + rect.width - padH - totalColumnsWidth + columnWidth / 2;
691
+ }
692
+ else {
693
+ // left (default)
694
+ startX = rect.x + padH + columnWidth / 2;
695
+ }
591
696
  stream.setFillColor(cell.textColor);
592
697
  for (let colIdx = 0; colIdx < columns.length; colIdx++) {
593
698
  const colText = columns[colIdx];
594
699
  const colX = startX + colIdx * columnWidth;
595
- let currentY = rect.y + rect.height - padV - ascent;
700
+ const totalTextHeight = colText.length * charHeight;
701
+ // Vertical alignment controls starting Y position (PDF y-axis: higher = top of cell)
702
+ let currentY;
703
+ if (verticalAlign === "middle") {
704
+ currentY = rect.y + rect.height / 2 + totalTextHeight / 2 - ascent;
705
+ }
706
+ else if (verticalAlign === "bottom") {
707
+ currentY = rect.y + padV + totalTextHeight - ascent;
708
+ }
709
+ else {
710
+ // top (default)
711
+ currentY = rect.y + rect.height - padV - ascent;
712
+ }
596
713
  for (const ch of colText) {
597
714
  if (currentY < rect.y + padV) {
598
715
  break;
@@ -773,3 +890,255 @@ function drawPageFooter(stream, page, options, fontManager, totalPages) {
773
890
  stream.endText();
774
891
  stream.restore();
775
892
  }
893
+ // =============================================================================
894
+ // Watermark Rendering
895
+ // =============================================================================
896
+ /** Default values for text watermarks. */
897
+ const TEXT_WM_DEFAULTS = {
898
+ fontSize: 54,
899
+ color: { r: 0.75, g: 0.75, b: 0.75 },
900
+ opacity: 0.15,
901
+ rotation: -45,
902
+ fontFamily: "Helvetica",
903
+ bold: false,
904
+ italic: false,
905
+ repeatSpacingX: 200,
906
+ repeatSpacingY: 200
907
+ };
908
+ /** Default values for image watermarks. */
909
+ const IMAGE_WM_DEFAULTS = {
910
+ opacity: 0.15,
911
+ rotation: 0,
912
+ scale: 0.5,
913
+ repeatSpacingX: 200,
914
+ repeatSpacingY: 200
915
+ };
916
+ /** Minimum allowed spacing for repeat patterns (prevents infinite loops). */
917
+ const MIN_REPEAT_SPACING = 10;
918
+ /**
919
+ * Render a watermark onto a PDF content stream.
920
+ * This should be called BEFORE the cell/grid content is rendered so the
921
+ * watermark sits behind everything (under-content).
922
+ */
923
+ export function renderWatermark(stream, page, watermark, fontManager) {
924
+ if (watermark.type === "text") {
925
+ return renderTextWatermark(stream, page, normalizeTextWatermark(watermark), fontManager);
926
+ }
927
+ return renderImageWatermark(stream, page, normalizeImageWatermark(watermark));
928
+ }
929
+ /** Clamp/normalize text watermark options to safe ranges. */
930
+ function normalizeTextWatermark(wm) {
931
+ return {
932
+ ...wm,
933
+ opacity: clamp01(wm.opacity ?? TEXT_WM_DEFAULTS.opacity),
934
+ fontSize: Math.max(1, wm.fontSize ?? TEXT_WM_DEFAULTS.fontSize),
935
+ repeatSpacingX: Math.max(MIN_REPEAT_SPACING, wm.repeatSpacingX ?? TEXT_WM_DEFAULTS.repeatSpacingX),
936
+ repeatSpacingY: Math.max(MIN_REPEAT_SPACING, wm.repeatSpacingY ?? TEXT_WM_DEFAULTS.repeatSpacingY)
937
+ };
938
+ }
939
+ /** Clamp/normalize image watermark options to safe ranges. */
940
+ function normalizeImageWatermark(wm) {
941
+ return {
942
+ ...wm,
943
+ opacity: clamp01(wm.opacity ?? IMAGE_WM_DEFAULTS.opacity),
944
+ scale: Math.max(0.01, wm.scale ?? IMAGE_WM_DEFAULTS.scale),
945
+ width: wm.width !== undefined ? Math.max(1, wm.width) : undefined,
946
+ height: wm.height !== undefined ? Math.max(1, wm.height) : undefined,
947
+ repeatSpacingX: Math.max(MIN_REPEAT_SPACING, wm.repeatSpacingX ?? IMAGE_WM_DEFAULTS.repeatSpacingX),
948
+ repeatSpacingY: Math.max(MIN_REPEAT_SPACING, wm.repeatSpacingY ?? IMAGE_WM_DEFAULTS.repeatSpacingY)
949
+ };
950
+ }
951
+ /** Clamp a number to the 0..1 range. */
952
+ function clamp01(v) {
953
+ return Math.max(0, Math.min(1, v));
954
+ }
955
+ /**
956
+ * Render a text watermark on a single page.
957
+ */
958
+ function renderTextWatermark(stream, page, watermark, fontManager) {
959
+ const fontSize = watermark.fontSize ?? TEXT_WM_DEFAULTS.fontSize;
960
+ const color = watermark.color ?? TEXT_WM_DEFAULTS.color;
961
+ const opacity = watermark.opacity ?? TEXT_WM_DEFAULTS.opacity;
962
+ const rotation = watermark.rotation ?? TEXT_WM_DEFAULTS.rotation;
963
+ const fontFamily = watermark.fontFamily ?? TEXT_WM_DEFAULTS.fontFamily;
964
+ const bold = watermark.bold ?? TEXT_WM_DEFAULTS.bold;
965
+ const italic = watermark.italic ?? TEXT_WM_DEFAULTS.italic;
966
+ const isEmbedded = fontManager.hasEmbeddedFont();
967
+ const resourceName = isEmbedded
968
+ ? fontManager.getEmbeddedResourceName()
969
+ : fontManager.ensureFont(resolvePdfFontName(fontFamily, bold, italic));
970
+ const textWidth = fontManager.measureText(watermark.text, resourceName, fontSize);
971
+ // Approximate text height using ascent (roughly 0.7 * fontSize for most fonts)
972
+ const textHeight = fontSize * 0.7;
973
+ const radians = (rotation * Math.PI) / 180;
974
+ const cos = Math.cos(radians);
975
+ const sin = Math.sin(radians);
976
+ const needsAlpha = opacity < 1;
977
+ const gsName = needsAlpha ? alphaGsName(opacity) : "";
978
+ const drawSingleWatermark = (cx, cy) => {
979
+ // Center the text at (cx, cy), compensating for both width and ascent height
980
+ const halfW = textWidth / 2;
981
+ const halfH = textHeight / 2;
982
+ const tx = cx - halfW * cos + halfH * sin;
983
+ const ty = cy - halfW * sin - halfH * cos;
984
+ stream.save();
985
+ if (needsAlpha) {
986
+ stream.setGraphicsState(gsName);
987
+ }
988
+ stream.setFillColor(color);
989
+ stream.beginText();
990
+ stream.setFont(resourceName, fontSize);
991
+ stream.setTextMatrix(cos, sin, -sin, cos, tx, ty);
992
+ const hex = fontManager.encodeText(watermark.text, resourceName);
993
+ if (hex) {
994
+ stream.showTextHex(hex);
995
+ }
996
+ else {
997
+ stream.showText(watermark.text);
998
+ }
999
+ stream.endText();
1000
+ stream.restore();
1001
+ };
1002
+ if (watermark.repeat) {
1003
+ const spacingX = watermark.repeatSpacingX ?? TEXT_WM_DEFAULTS.repeatSpacingX;
1004
+ const spacingY = watermark.repeatSpacingY ?? TEXT_WM_DEFAULTS.repeatSpacingY;
1005
+ renderRepeatedPattern(page.width, page.height, spacingX, spacingY, drawSingleWatermark);
1006
+ }
1007
+ else {
1008
+ const { cx, cy } = resolveWatermarkCenter(page, watermark.position);
1009
+ drawSingleWatermark(cx, cy);
1010
+ }
1011
+ return { alphaValues: needsAlpha ? [opacity] : [], imageXObjects: [] };
1012
+ }
1013
+ /**
1014
+ * Render an image watermark on a single page.
1015
+ */
1016
+ function renderImageWatermark(stream, page, watermark) {
1017
+ const opacity = watermark.opacity ?? IMAGE_WM_DEFAULTS.opacity;
1018
+ const rotation = watermark.rotation ?? IMAGE_WM_DEFAULTS.rotation;
1019
+ const scale = watermark.scale ?? IMAGE_WM_DEFAULTS.scale;
1020
+ const needsAlpha = opacity < 1;
1021
+ // Determine image dimensions — use explicit width/height if provided,
1022
+ // otherwise parse actual dimensions from image data and scale proportionally
1023
+ let imgWidth;
1024
+ let imgHeight;
1025
+ if (watermark.width !== undefined && watermark.height !== undefined) {
1026
+ imgWidth = watermark.width;
1027
+ imgHeight = watermark.height;
1028
+ }
1029
+ else {
1030
+ const dims = parseImageDimensions(watermark.data, watermark.format);
1031
+ const minDim = Math.min(page.width, page.height);
1032
+ const targetSize = minDim * scale;
1033
+ const maxDim = Math.max(dims.width, dims.height);
1034
+ const ratio = maxDim > 0 ? targetSize / maxDim : 1;
1035
+ imgWidth = dims.width * ratio;
1036
+ imgHeight = dims.height * ratio;
1037
+ }
1038
+ const radians = (rotation * Math.PI) / 180;
1039
+ const cos = Math.cos(radians);
1040
+ const sin = Math.sin(radians);
1041
+ const gsName = needsAlpha ? alphaGsName(opacity) : "";
1042
+ const imgName = "WmImg";
1043
+ const drawSingleWatermark = (cx, cy) => {
1044
+ stream.save();
1045
+ if (needsAlpha) {
1046
+ stream.setGraphicsState(gsName);
1047
+ }
1048
+ const halfW = imgWidth / 2;
1049
+ const halfH = imgHeight / 2;
1050
+ const tx = cx - halfW * cos + halfH * sin;
1051
+ const ty = cy - halfW * sin - halfH * cos;
1052
+ stream.concat(imgWidth * cos, imgWidth * sin, -imgHeight * sin, imgHeight * cos, tx, ty);
1053
+ stream.doXObject(imgName);
1054
+ stream.restore();
1055
+ };
1056
+ if (watermark.repeat) {
1057
+ const spacingX = watermark.repeatSpacingX ?? IMAGE_WM_DEFAULTS.repeatSpacingX;
1058
+ const spacingY = watermark.repeatSpacingY ?? IMAGE_WM_DEFAULTS.repeatSpacingY;
1059
+ renderRepeatedPattern(page.width, page.height, spacingX, spacingY, drawSingleWatermark);
1060
+ }
1061
+ else {
1062
+ const { cx, cy } = resolveWatermarkCenter(page, watermark.position);
1063
+ drawSingleWatermark(cx, cy);
1064
+ }
1065
+ return {
1066
+ alphaValues: needsAlpha ? [opacity] : [],
1067
+ imageXObjects: [{ name: imgName, data: watermark.data, format: watermark.format }]
1068
+ };
1069
+ }
1070
+ /**
1071
+ * Parse image dimensions from raw JPEG or PNG data without a full decode.
1072
+ */
1073
+ export function parseImageDimensions(data, format) {
1074
+ if (format === "png") {
1075
+ return parsePngDimensions(data);
1076
+ }
1077
+ return parseJpegDimensions(data);
1078
+ }
1079
+ /** Read width/height from a PNG IHDR chunk (bytes 16-23). */
1080
+ function parsePngDimensions(data) {
1081
+ // PNG header: 8 byte signature, then IHDR chunk: 4 byte length, 4 byte type, 4 byte width, 4 byte height
1082
+ if (data.length >= 24 &&
1083
+ data[12] === 0x49 &&
1084
+ data[13] === 0x48 &&
1085
+ data[14] === 0x44 &&
1086
+ data[15] === 0x52) {
1087
+ const width = (data[16] << 24) | (data[17] << 16) | (data[18] << 8) | data[19];
1088
+ const height = (data[20] << 24) | (data[21] << 16) | (data[22] << 8) | data[23];
1089
+ return { width, height };
1090
+ }
1091
+ return { width: 1, height: 1 };
1092
+ }
1093
+ /** Read width/height from JPEG SOF marker. */
1094
+ function parseJpegDimensions(data) {
1095
+ let offset = 2; // skip SOI marker
1096
+ while (offset < data.length - 1) {
1097
+ while (offset < data.length && data[offset] === 0xff && data[offset + 1] === 0xff) {
1098
+ offset++;
1099
+ }
1100
+ if (offset >= data.length - 1 || data[offset] !== 0xff) {
1101
+ break;
1102
+ }
1103
+ const marker = data[offset + 1];
1104
+ const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
1105
+ if (isSof && offset + 8 < data.length) {
1106
+ return {
1107
+ width: (data[offset + 7] << 8) | data[offset + 8],
1108
+ height: (data[offset + 5] << 8) | data[offset + 6]
1109
+ };
1110
+ }
1111
+ if (offset + 3 >= data.length) {
1112
+ break;
1113
+ }
1114
+ const segLen = (data[offset + 2] << 8) | data[offset + 3];
1115
+ offset += 2 + segLen;
1116
+ }
1117
+ return { width: 1, height: 1 };
1118
+ }
1119
+ /**
1120
+ * Resolve the center position for a watermark on a given page.
1121
+ */
1122
+ function resolveWatermarkCenter(page, position) {
1123
+ if (!position || position === "center") {
1124
+ return { cx: page.width / 2, cy: page.height / 2 };
1125
+ }
1126
+ return { cx: position.x, cy: position.y };
1127
+ }
1128
+ /**
1129
+ * Render a repeated pattern of watermarks across the entire page.
1130
+ * Uses a staggered grid for a natural diagonal tiling effect.
1131
+ */
1132
+ function renderRepeatedPattern(pageWidth, pageHeight, spacingX, spacingY, drawFn) {
1133
+ // Start from beyond the page edges to ensure full coverage with rotation
1134
+ const margin = Math.max(pageWidth, pageHeight) * 0.5;
1135
+ let rowIndex = 0;
1136
+ for (let y = -margin; y < pageHeight + margin; y += spacingY) {
1137
+ // Stagger every other row by half the horizontal spacing
1138
+ const offsetX = rowIndex % 2 === 1 ? spacingX / 2 : 0;
1139
+ for (let x = -margin; x < pageWidth + margin; x += spacingX) {
1140
+ drawFn(x + offsetX, y);
1141
+ }
1142
+ rowIndex++;
1143
+ }
1144
+ }