@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.
- package/dist/browser/index.browser.d.ts +2 -0
- package/dist/browser/index.browser.js +2 -0
- package/dist/browser/index.d.ts +2 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/modules/excel/image.d.ts +27 -2
- package/dist/browser/modules/excel/image.js +23 -1
- package/dist/browser/modules/excel/stream/worksheet-writer.d.ts +16 -1
- package/dist/browser/modules/excel/stream/worksheet-writer.js +68 -0
- package/dist/browser/modules/excel/types.d.ts +72 -0
- package/dist/browser/modules/excel/utils/drawing-utils.d.ts +4 -0
- package/dist/browser/modules/excel/utils/drawing-utils.js +5 -0
- package/dist/browser/modules/excel/utils/ooxml-paths.d.ts +4 -0
- package/dist/browser/modules/excel/utils/ooxml-paths.js +15 -0
- package/dist/browser/modules/excel/utils/watermark-image.d.ts +67 -0
- package/dist/browser/modules/excel/utils/watermark-image.js +383 -0
- package/dist/browser/modules/excel/worksheet.d.ts +39 -1
- package/dist/browser/modules/excel/worksheet.js +99 -0
- package/dist/browser/modules/excel/xlsx/xform/core/content-types-xform.js +3 -2
- package/dist/browser/modules/excel/xlsx/xform/drawing/base-cell-anchor-xform.js +6 -1
- package/dist/browser/modules/excel/xlsx/xform/drawing/blip-fill-xform.d.ts +2 -1
- package/dist/browser/modules/excel/xlsx/xform/drawing/blip-fill-xform.js +0 -1
- package/dist/browser/modules/excel/xlsx/xform/drawing/blip-xform.d.ts +3 -1
- package/dist/browser/modules/excel/xlsx/xform/drawing/blip-xform.js +22 -6
- package/dist/browser/modules/excel/xlsx/xform/drawing/pic-xform.d.ts +3 -0
- package/dist/browser/modules/excel/xlsx/xform/drawing/pic-xform.js +5 -1
- package/dist/browser/modules/excel/xlsx/xform/drawing/vml-drawing-xform.d.ts +19 -0
- package/dist/browser/modules/excel/xlsx/xform/drawing/vml-drawing-xform.js +103 -4
- package/dist/browser/modules/excel/xlsx/xform/sheet/worksheet-xform.js +135 -8
- package/dist/browser/modules/excel/xlsx/xlsx.browser.d.ts +1 -0
- package/dist/browser/modules/excel/xlsx/xlsx.browser.js +53 -1
- package/dist/browser/modules/pdf/core/pdf-writer.d.ts +1 -1
- package/dist/browser/modules/pdf/core/pdf-writer.js +2 -1
- package/dist/browser/modules/pdf/index.d.ts +1 -1
- package/dist/browser/modules/pdf/render/page-renderer.d.ts +29 -1
- package/dist/browser/modules/pdf/render/page-renderer.js +394 -25
- package/dist/browser/modules/pdf/render/pdf-exporter.js +84 -47
- package/dist/browser/modules/pdf/types.d.ts +235 -0
- package/dist/cjs/index.js +5 -2
- package/dist/cjs/modules/excel/image.js +23 -1
- package/dist/cjs/modules/excel/stream/worksheet-writer.js +68 -0
- package/dist/cjs/modules/excel/utils/drawing-utils.js +5 -0
- package/dist/cjs/modules/excel/utils/ooxml-paths.js +19 -0
- package/dist/cjs/modules/excel/utils/watermark-image.js +386 -0
- package/dist/cjs/modules/excel/worksheet.js +99 -0
- package/dist/cjs/modules/excel/xlsx/xform/core/content-types-xform.js +3 -2
- package/dist/cjs/modules/excel/xlsx/xform/drawing/base-cell-anchor-xform.js +6 -1
- package/dist/cjs/modules/excel/xlsx/xform/drawing/blip-fill-xform.js +0 -1
- package/dist/cjs/modules/excel/xlsx/xform/drawing/blip-xform.js +22 -6
- package/dist/cjs/modules/excel/xlsx/xform/drawing/pic-xform.js +5 -1
- package/dist/cjs/modules/excel/xlsx/xform/drawing/vml-drawing-xform.js +103 -4
- package/dist/cjs/modules/excel/xlsx/xform/sheet/worksheet-xform.js +134 -7
- package/dist/cjs/modules/excel/xlsx/xlsx.browser.js +52 -0
- package/dist/cjs/modules/pdf/core/pdf-writer.js +2 -1
- package/dist/cjs/modules/pdf/render/page-renderer.js +396 -25
- package/dist/cjs/modules/pdf/render/pdf-exporter.js +83 -46
- package/dist/esm/index.browser.js +2 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/modules/excel/image.js +23 -1
- package/dist/esm/modules/excel/stream/worksheet-writer.js +68 -0
- package/dist/esm/modules/excel/utils/drawing-utils.js +5 -0
- package/dist/esm/modules/excel/utils/ooxml-paths.js +15 -0
- package/dist/esm/modules/excel/utils/watermark-image.js +383 -0
- package/dist/esm/modules/excel/worksheet.js +99 -0
- package/dist/esm/modules/excel/xlsx/xform/core/content-types-xform.js +3 -2
- package/dist/esm/modules/excel/xlsx/xform/drawing/base-cell-anchor-xform.js +6 -1
- package/dist/esm/modules/excel/xlsx/xform/drawing/blip-fill-xform.js +0 -1
- package/dist/esm/modules/excel/xlsx/xform/drawing/blip-xform.js +22 -6
- package/dist/esm/modules/excel/xlsx/xform/drawing/pic-xform.js +5 -1
- package/dist/esm/modules/excel/xlsx/xform/drawing/vml-drawing-xform.js +103 -4
- package/dist/esm/modules/excel/xlsx/xform/sheet/worksheet-xform.js +135 -8
- package/dist/esm/modules/excel/xlsx/xlsx.browser.js +53 -1
- package/dist/esm/modules/pdf/core/pdf-writer.js +2 -1
- package/dist/esm/modules/pdf/render/page-renderer.js +394 -25
- package/dist/esm/modules/pdf/render/pdf-exporter.js +84 -47
- package/dist/iife/excelts.iife.js +2390 -469
- package/dist/iife/excelts.iife.js.map +1 -1
- package/dist/iife/excelts.iife.min.js +47 -47
- package/dist/types/index.browser.d.ts +2 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/modules/excel/image.d.ts +27 -2
- package/dist/types/modules/excel/stream/worksheet-writer.d.ts +16 -1
- package/dist/types/modules/excel/types.d.ts +72 -0
- package/dist/types/modules/excel/utils/drawing-utils.d.ts +4 -0
- package/dist/types/modules/excel/utils/ooxml-paths.d.ts +4 -0
- package/dist/types/modules/excel/utils/watermark-image.d.ts +67 -0
- package/dist/types/modules/excel/worksheet.d.ts +39 -1
- package/dist/types/modules/excel/xlsx/xform/drawing/blip-fill-xform.d.ts +2 -1
- package/dist/types/modules/excel/xlsx/xform/drawing/blip-xform.d.ts +3 -1
- package/dist/types/modules/excel/xlsx/xform/drawing/pic-xform.d.ts +3 -0
- package/dist/types/modules/excel/xlsx/xform/drawing/vml-drawing-xform.d.ts +19 -0
- package/dist/types/modules/excel/xlsx/xlsx.browser.d.ts +1 -0
- package/dist/types/modules/pdf/core/pdf-writer.d.ts +1 -1
- package/dist/types/modules/pdf/index.d.ts +1 -1
- package/dist/types/modules/pdf/render/page-renderer.d.ts +29 -1
- package/dist/types/modules/pdf/types.d.ts +235 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
512
|
+
// horizontalAlign controls X placement of line columns (same visual axis)
|
|
473
513
|
let startX;
|
|
474
|
-
if (horizontalAlign === "center"
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
547
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|