Add tiled watermark layout support with adjustable gaps and UI controls

This commit is contained in:
alam00000
2026-08-16 12:40:55 +05:30
parent 5bf54f1dad
commit 9635c398d6
7 changed files with 871 additions and 23 deletions
+9
View File
@@ -33,6 +33,13 @@ Add text or image watermarks to your PDF pages using a visual editor. You can po
- **Opacity** -- Control image transparency.
- **Scale** -- Resize the image watermark.
**Layout settings:**
- **Single** -- One watermark per page, positioned by dragging or with the position presets.
- **Tiled** -- Repeats the watermark across the whole page in a diagonal grid that follows the rotation angle, so the mark covers the document from edge to edge.
- **Horizontal gap** -- Spacing between tiles along the text direction, as a percentage of the watermark width (tiled mode only).
- **Vertical gap** -- Spacing between tile rows, as a percentage of the watermark height (tiled mode only).
**General settings:**
- **Apply to all pages** -- When checked, the same watermark applies to every page. Uncheck to set individual watermarks per page.
@@ -43,6 +50,7 @@ Add text or image watermarks to your PDF pages using a visual editor. You can po
- Visual drag-and-drop positioning on a live PDF preview
- Resizable watermark with corner handles
- Text and image watermark modes
- Single or full-page tiled (repeating) layout with adjustable spacing
- Per-page or global watermark application
- Page-by-page navigation in the preview
- Opacity and rotation controls
@@ -59,6 +67,7 @@ Add text or image watermarks to your PDF pages using a visual editor. You can po
## Tips
- For diagonal watermarks across the page, set a rotation around 45 degrees and increase the font size.
- Tiled mode makes a document much harder to reuse after cropping, since removing one mark still leaves dozens behind. Lower the opacity when tiling so the underlying text stays readable.
- Image watermarks with transparent backgrounds (PNG format) blend more naturally with the document content.
- If you need different watermark text on different pages, uncheck "Apply to all pages" and navigate page by page.
+255 -1
View File
@@ -11,8 +11,13 @@ import {
addTextWatermark,
addImageWatermark,
parsePageRange,
computeTileCenters,
} from '../utils/pdf-operations.js';
import { AddWatermarkState, PageWatermarkConfig } from '@/types';
import {
AddWatermarkState,
PageWatermarkConfig,
WatermarkLayout,
} from '@/types';
import * as pdfjsLib from 'pdfjs-dist';
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
import { loadPdfDocument } from '../utils/load-pdf-document.js';
@@ -31,9 +36,15 @@ const pageState: AddWatermarkState = {
watermarkY: 0.5,
};
const WATERMARK_FONT_STACK =
'"Noto Sans SC", "Noto Sans JP", "Noto Sans KR", "Noto Sans Arabic", Arial, sans-serif';
let watermarkType: 'text' | 'image' = 'text';
let watermarkLayout: WatermarkLayout = 'single';
let imageWatermarkDataUrl: string | null = null;
let imageWatermarkFile: File | null = null;
let imageWatermarkBitmap: HTMLImageElement | null = null;
let measureCanvas: HTMLCanvasElement | null = null;
let isDragging = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
@@ -186,6 +197,7 @@ function resetState() {
pageState.watermarkY = 0.5;
imageWatermarkDataUrl = null;
imageWatermarkFile = null;
imageWatermarkBitmap = null;
cachedPdfjsDoc = null;
currentPageNum = 1;
totalPageCount = 1;
@@ -273,9 +285,19 @@ async function changePage(newPageNum: number) {
updateWatermarkOverlay();
}
function readNumberInput(id: string, fallback: number): number {
const input = document.getElementById(id) as HTMLInputElement | null;
if (!input) return fallback;
const value = parseFloat(input.value);
return Number.isFinite(value) ? value : fallback;
}
function getDefaultConfig(): PageWatermarkConfig {
return {
type: 'text',
layout: 'single',
tileGapX: 25,
tileGapY: 75,
x: 0.5,
y: 0.5,
text: '',
@@ -294,6 +316,9 @@ function getDefaultConfig(): PageWatermarkConfig {
function getCurrentConfig(): PageWatermarkConfig {
return {
type: watermarkType,
layout: watermarkLayout,
tileGapX: readNumberInput('tile-gap-x', 25),
tileGapY: readNumberInput('tile-gap-y', 75),
x: pageState.watermarkX,
y: pageState.watermarkY,
text:
@@ -345,11 +370,27 @@ function loadPageConfig(pageNum: number) {
}
watermarkType = config.type;
watermarkLayout = config.layout;
pageState.watermarkX = config.x;
pageState.watermarkY = config.y;
if (config.imageDataUrl !== imageWatermarkDataUrl) {
loadWatermarkBitmap(config.imageDataUrl);
}
imageWatermarkDataUrl = config.imageDataUrl;
imageWatermarkFile = config.imageFile;
const tileGapX = document.getElementById('tile-gap-x') as HTMLInputElement;
const tileGapY = document.getElementById('tile-gap-y') as HTMLInputElement;
const tileGapXValue = document.getElementById('tile-gap-x-value');
const tileGapYValue = document.getElementById('tile-gap-y-value');
if (tileGapX) tileGapX.value = String(config.tileGapX);
if (tileGapY) tileGapY.value = String(config.tileGapY);
if (tileGapXValue) tileGapXValue.textContent = String(config.tileGapX);
if (tileGapYValue) tileGapYValue.textContent = String(config.tileGapY);
applyLayoutUI();
const typeTextBtn = document.getElementById('type-text-btn');
const typeImageBtn = document.getElementById('type-image-btn');
const textOptions = document.getElementById('text-watermark-options');
@@ -511,6 +552,36 @@ function setupEditorControls() {
updateWatermarkOverlay();
});
const layoutSingleBtn = document.getElementById('layout-single-btn');
const layoutTileBtn = document.getElementById('layout-tile-btn');
layoutSingleBtn?.addEventListener('click', () => {
watermarkLayout = 'single';
applyLayoutUI();
updateWatermarkOverlay();
});
layoutTileBtn?.addEventListener('click', () => {
watermarkLayout = 'tile';
applyLayoutUI();
updateWatermarkOverlay();
});
const tileGapX = document.getElementById('tile-gap-x') as HTMLInputElement;
const tileGapY = document.getElementById('tile-gap-y') as HTMLInputElement;
const tileGapXValue = document.getElementById('tile-gap-x-value');
const tileGapYValue = document.getElementById('tile-gap-y-value');
tileGapX?.addEventListener('input', () => {
if (tileGapXValue) tileGapXValue.textContent = tileGapX.value;
updateWatermarkOverlay();
});
tileGapY?.addEventListener('input', () => {
if (tileGapYValue) tileGapYValue.textContent = tileGapY.value;
updateWatermarkOverlay();
});
const watermarkText = document.getElementById(
'watermark-text'
) as HTMLInputElement;
@@ -571,6 +642,7 @@ function setupEditorControls() {
const reader = new FileReader();
reader.onload = () => {
imageWatermarkDataUrl = reader.result as string;
loadWatermarkBitmap(imageWatermarkDataUrl);
updateWatermarkOverlay();
};
reader.readAsDataURL(file);
@@ -604,6 +676,161 @@ function updatePresetHighlight(x: number, y: number) {
});
}
function loadWatermarkBitmap(dataUrl: string | null) {
if (!dataUrl) {
imageWatermarkBitmap = null;
return;
}
const image = new Image();
image.onload = () => {
if (imageWatermarkDataUrl !== dataUrl) return;
imageWatermarkBitmap = image;
updateWatermarkOverlay();
};
image.src = dataUrl;
}
function applyLayoutUI() {
const singleBtn = document.getElementById('layout-single-btn');
const tileBtn = document.getElementById('layout-tile-btn');
const tileOptions = document.getElementById('tile-options');
const positionSection = document.getElementById('position-section');
const dragHint = document.getElementById('drag-hint');
const activeClass =
'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-indigo-600 text-white transition-colors';
const inactiveClass =
'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors';
const isTile = watermarkLayout === 'tile';
if (singleBtn) singleBtn.className = isTile ? inactiveClass : activeClass;
if (tileBtn) tileBtn.className = isTile ? activeClass : inactiveClass;
tileOptions?.classList.toggle('hidden', !isTile);
positionSection?.classList.toggle('hidden', isTile);
dragHint?.classList.toggle('sm:inline', !isTile);
}
function measureTextWidth(text: string, fontSize: number): number {
if (!measureCanvas) measureCanvas = document.createElement('canvas');
const ctx = measureCanvas.getContext('2d');
if (!ctx) return 0;
ctx.font = `bold ${fontSize}px ${WATERMARK_FONT_STACK}`;
return ctx.measureText(text).width + 2;
}
function renderTilePreview() {
const canvas = document.getElementById(
'tile-preview-canvas'
) as HTMLCanvasElement;
const container = document.getElementById('preview-container');
if (!canvas || !container) return;
const displayWidth = container.clientWidth;
const displayHeight = container.clientHeight;
if (!displayWidth || !displayHeight || !pdfPageWidth || !pdfPageHeight)
return;
const dpr = 2;
canvas.width = Math.round(displayWidth * dpr);
canvas.height = Math.round(displayHeight * dpr);
canvas.style.width = displayWidth + 'px';
canvas.style.height = displayHeight + 'px';
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.scale(dpr, dpr);
const gapX = readNumberInput('tile-gap-x', 25) / 100;
const gapY = readNumberInput('tile-gap-y', 75) / 100;
let itemWidth: number;
let itemHeight: number;
let uiAngle: number;
let opacity: number;
if (watermarkType === 'text') {
const text =
(document.getElementById('watermark-text') as HTMLInputElement)?.value ||
'CONFIDENTIAL';
const fontSize = readNumberInput('font-size', 72);
uiAngle = readNumberInput('angle-text', 0);
opacity = readNumberInput('opacity-text', 0.3);
itemWidth = measureTextWidth(text, fontSize);
itemHeight = fontSize * 1.4;
const centers = computeTileCenters({
pageWidth: pdfPageWidth,
pageHeight: pdfPageHeight,
itemWidth,
itemHeight,
angle: -uiAngle,
gapX,
gapY,
});
ctx.globalAlpha = opacity;
ctx.fillStyle =
(document.getElementById('text-color') as HTMLInputElement)?.value ||
'#888888';
ctx.font = `bold ${fontSize * previewScale}px ${WATERMARK_FONT_STACK}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const center of centers) {
ctx.save();
ctx.translate(
center.x * previewScale,
(pdfPageHeight - center.y) * previewScale
);
ctx.rotate((uiAngle * Math.PI) / 180);
ctx.fillText(text, 0, 0);
ctx.restore();
}
return;
}
const bitmap = imageWatermarkBitmap;
if (!bitmap || !bitmap.naturalWidth || !bitmap.naturalHeight) return;
const scale = readNumberInput('image-scale', 100) / 100;
uiAngle = readNumberInput('angle-image', 0);
opacity = readNumberInput('opacity-image', 0.3);
itemWidth = bitmap.naturalWidth * scale;
itemHeight = bitmap.naturalHeight * scale;
const centers = computeTileCenters({
pageWidth: pdfPageWidth,
pageHeight: pdfPageHeight,
itemWidth,
itemHeight,
angle: -uiAngle,
gapX,
gapY,
});
ctx.globalAlpha = opacity;
const drawWidth = itemWidth * previewScale;
const drawHeight = itemHeight * previewScale;
for (const center of centers) {
ctx.save();
ctx.translate(
center.x * previewScale,
(pdfPageHeight - center.y) * previewScale
);
ctx.rotate((uiAngle * Math.PI) / 180);
ctx.drawImage(
bitmap,
-drawWidth / 2,
-drawHeight / 2,
drawWidth,
drawHeight
);
ctx.restore();
}
}
function updateWatermarkOverlay() {
const box = document.getElementById('watermark-box') as HTMLElement;
const textOverlay = document.getElementById(
@@ -619,6 +846,18 @@ function updateWatermarkOverlay() {
const containerW = container.clientWidth;
const containerH = container.clientHeight;
const tileCanvas = document.getElementById('tile-preview-canvas');
if (watermarkLayout === 'tile') {
box.classList.add('hidden');
textOverlay.classList.add('hidden');
imageOverlay.classList.add('hidden');
tileCanvas?.classList.remove('hidden');
renderTilePreview();
return;
}
tileCanvas?.classList.add('hidden');
if (watermarkType === 'text') {
box.classList.remove('hidden');
@@ -846,6 +1085,9 @@ async function applyWatermark() {
x: config.x,
y: posY,
pageIndices,
tile: config.layout === 'tile',
tileGapX: config.tileGapX / 100,
tileGapY: config.tileGapY / 100,
})
);
} else {
@@ -875,6 +1117,9 @@ async function applyWatermark() {
x: config.x,
y: posY,
pageIndices,
tile: config.layout === 'tile',
tileGapX: config.tileGapX / 100,
tileGapY: config.tileGapY / 100,
})
);
}
@@ -896,6 +1141,9 @@ async function applyWatermark() {
const key = JSON.stringify({
type: config.type,
layout: config.layout,
tileGapX: config.tileGapX,
tileGapY: config.tileGapY,
x: config.x,
y: config.y,
text: config.text,
@@ -930,6 +1178,9 @@ async function applyWatermark() {
x: config.x,
y: posY,
pageIndices: indices,
tile: config.layout === 'tile',
tileGapX: config.tileGapX / 100,
tileGapY: config.tileGapY / 100,
})
);
} else {
@@ -955,6 +1206,9 @@ async function applyWatermark() {
x: config.x,
y: posY,
pageIndices: indices,
tile: config.layout === 'tile',
tileGapX: config.tileGapX / 100,
tileGapY: config.tileGapY / 100,
})
);
}
+5
View File
@@ -9,8 +9,13 @@ export interface AddWatermarkState {
watermarkY: number; // 01, percentage from top (flipped to bottom for PDF)
}
export type WatermarkLayout = 'single' | 'tile';
export interface PageWatermarkConfig {
type: 'text' | 'image';
layout: WatermarkLayout;
tileGapX: number;
tileGapY: number;
x: number;
y: number;
text: string;
+108 -4
View File
@@ -182,6 +182,82 @@ export function parseDeletePages(str: string, totalPages: number): Set<number> {
return pages;
}
export interface TileLayoutOptions {
pageWidth: number;
pageHeight: number;
itemWidth: number;
itemHeight: number;
angle: number;
gapX: number;
gapY: number;
}
export const DEFAULT_TILE_GAP_X = 0.25;
export const DEFAULT_TILE_GAP_Y = 0.75;
const MAX_TILES = 1500;
export function computeTileCenters(
options: TileLayoutOptions
): { x: number; y: number }[] {
const { pageWidth, pageHeight, itemWidth, itemHeight } = options;
if (
!(pageWidth > 0) ||
!(pageHeight > 0) ||
!(itemWidth > 0) ||
!(itemHeight > 0) ||
!Number.isFinite(options.angle)
) {
return [];
}
const rad = (options.angle * Math.PI) / 180;
const ux = Math.cos(rad);
const uy = Math.sin(rad);
const vx = -Math.sin(rad);
const vy = Math.cos(rad);
let stepX = Math.max(itemWidth * (1 + Math.max(0, options.gapX)), 1);
let stepY = Math.max(itemHeight * (1 + Math.max(0, options.gapY)), 1);
const density = (pageWidth * pageHeight) / (stepX * stepY);
if (density > MAX_TILES) {
const factor = Math.sqrt(density / MAX_TILES);
stepX *= factor;
stepY *= factor;
}
const centerX = pageWidth / 2;
const centerY = pageHeight / 2;
const reach = Math.hypot(pageWidth, pageHeight) / 2;
const radius = Math.hypot(itemWidth, itemHeight) / 2;
const halfExtentX =
(Math.abs(ux) * itemWidth + Math.abs(vx) * itemHeight) / 2;
const halfExtentY =
(Math.abs(uy) * itemWidth + Math.abs(vy) * itemHeight) / 2;
const iMax = Math.ceil((reach + radius) / stepX);
const jMax = Math.ceil((reach + radius) / stepY);
const centers: { x: number; y: number }[] = [];
for (let j = -jMax; j <= jMax; j++) {
for (let i = -iMax; i <= iMax; i++) {
const x = centerX + i * stepX * ux + j * stepY * vx;
const y = centerY + i * stepX * uy + j * stepY * vy;
if (
x + halfExtentX < 0 ||
x - halfExtentX > pageWidth ||
y + halfExtentY < 0 ||
y - halfExtentY > pageHeight
) {
continue;
}
centers.push({ x, y });
if (centers.length >= MAX_TILES * 2) return centers;
}
}
return centers;
}
export interface TextWatermarkOptions {
text: string;
fontSize: number;
@@ -191,6 +267,9 @@ export interface TextWatermarkOptions {
x?: number;
y?: number;
pageIndices?: number[];
tile?: boolean;
tileGapX?: number;
tileGapY?: number;
}
export async function addTextWatermark(
@@ -244,9 +323,19 @@ export async function addTextWatermark(
const page = pages[idx];
if (!page) continue;
const { width, height } = page.getSize();
const cx = posX * width;
const cy = posY * height;
const centers = options.tile
? computeTileCenters({
pageWidth: width,
pageHeight: height,
itemWidth: imgWidth,
itemHeight: imgHeight,
angle: options.angle,
gapX: options.tileGapX ?? DEFAULT_TILE_GAP_X,
gapY: options.tileGapY ?? DEFAULT_TILE_GAP_Y,
})
: [{ x: posX * width, y: posY * height }];
for (const { x: cx, y: cy } of centers) {
page.drawImage(image, {
x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH,
y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH,
@@ -256,6 +345,7 @@ export async function addTextWatermark(
rotate: degrees(options.angle),
});
}
}
return new Uint8Array(await pdfDoc.save());
}
@@ -269,6 +359,9 @@ export interface ImageWatermarkOptions {
x?: number;
y?: number;
pageIndices?: number[];
tile?: boolean;
tileGapX?: number;
tileGapY?: number;
}
export async function addImageWatermark(
@@ -295,9 +388,19 @@ export async function addImageWatermark(
const page = pages[idx];
if (!page) continue;
const { width, height } = page.getSize();
const cx = posX * width;
const cy = posY * height;
const centers = options.tile
? computeTileCenters({
pageWidth: width,
pageHeight: height,
itemWidth: imgWidth,
itemHeight: imgHeight,
angle: options.angle,
gapX: options.tileGapX ?? DEFAULT_TILE_GAP_X,
gapY: options.tileGapY ?? DEFAULT_TILE_GAP_Y,
})
: [{ x: posX * width, y: posY * height }];
for (const { x: cx, y: cy } of centers) {
page.drawImage(image, {
x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH,
y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH,
@@ -307,6 +410,7 @@ export async function addImageWatermark(
rotate: degrees(options.angle),
});
}
}
return new Uint8Array(await pdfDoc.save());
}
+19
View File
@@ -46,6 +46,18 @@ export class WatermarkNode extends BaseWorkflowNode {
'position',
new ClassicPreset.InputControl('text', { initial: 'center' })
);
this.addControl(
'tile',
new ClassicPreset.InputControl('text', { initial: 'no' })
);
this.addControl(
'tileGapX',
new ClassicPreset.InputControl('number', { initial: 25 })
);
this.addControl(
'tileGapY',
new ClassicPreset.InputControl('number', { initial: 75 })
);
this.addControl(
'pages',
new ClassicPreset.InputControl('text', { initial: 'all' })
@@ -96,6 +108,10 @@ export class WatermarkNode extends BaseWorkflowNode {
const posKey = getText('position', 'center').trim().toLowerCase();
const { x, y } = positionPresets[posKey] ?? positionPresets['center'];
const tile = getText('tile', 'no').trim().toLowerCase() === 'yes';
const tileGapX = Math.max(0, getNum('tileGapX', 25)) / 100;
const tileGapY = Math.max(0, getNum('tileGapY', 75)) / 100;
const pagesStr = getText('pages', 'all').trim().toLowerCase();
const shouldFlatten =
getText('flatten', 'no').trim().toLowerCase() === 'yes';
@@ -117,6 +133,9 @@ export class WatermarkNode extends BaseWorkflowNode {
x,
y: 1 - y,
pageIndices,
tile,
tileGapX,
tileGapY,
});
if (shouldFlatten) {
+67 -1
View File
@@ -212,7 +212,9 @@
<i data-lucide="chevron-right" class="w-4 h-4"></i>
</button>
</div>
<span class="text-xs text-gray-500 hidden sm:inline"
<span
id="drag-hint"
class="text-xs text-gray-500 hidden sm:inline"
>Drag watermark to position</span
>
</div>
@@ -226,6 +228,10 @@
style="touch-action: none"
>
<canvas id="preview-canvas"></canvas>
<canvas
id="tile-preview-canvas"
class="hidden absolute top-0 left-0 pointer-events-none"
></canvas>
<div
id="watermark-box"
class="absolute pointer-events-auto"
@@ -284,6 +290,29 @@
</button>
</div>
<div>
<label class="block mb-1.5 text-sm font-medium text-gray-300"
>Layout</label
>
<div class="flex gap-2">
<button
id="layout-single-btn"
class="flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-indigo-600 text-white transition-colors"
>
Single
</button>
<button
id="layout-tile-btn"
class="flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors"
>
Tiled
</button>
</div>
<p class="text-xs text-gray-500 mt-1.5">
Tiled repeats the watermark across the whole page.
</p>
</div>
<div
class="flex items-center gap-2 bg-gray-900 rounded-lg px-3 py-2"
>
@@ -470,7 +499,44 @@
</div>
</div>
<div id="tile-options" class="hidden space-y-4">
<div>
<label
for="tile-gap-x"
class="block mb-1.5 text-sm font-medium text-gray-300"
>
Horizontal gap: <span id="tile-gap-x-value">25</span>%
</label>
<input
type="range"
id="tile-gap-x"
min="0"
max="200"
step="5"
value="25"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
/>
</div>
<div>
<label
for="tile-gap-y"
class="block mb-1.5 text-sm font-medium text-gray-300"
>
Vertical gap: <span id="tile-gap-y-value">75</span>%
</label>
<input
type="range"
id="tile-gap-y"
min="0"
max="200"
step="5"
value="75"
class="w-full h-2 bg-gray-700 rounded-lg cursor-pointer accent-indigo-500"
/>
</div>
</div>
<div id="position-section">
<label class="block mb-1.5 text-sm font-medium text-gray-300"
>Position</label
>
+391
View File
@@ -0,0 +1,391 @@
import { describe, it, expect } from 'vitest';
import {
computeTileCenters,
addImageWatermark,
} from '../js/utils/pdf-operations';
import {
PDFArray,
PDFDocument,
PDFName,
PDFObject,
PDFRawStream,
PDFRef,
} from 'pdf-lib';
import { inflateSync } from 'node:zlib';
const PNG_1X1_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
function pngBytes(): Uint8Array {
return new Uint8Array(Buffer.from(PNG_1X1_BASE64, 'base64'));
}
async function createTestPdf(pageCount: number): Promise<Uint8Array> {
const doc = await PDFDocument.create();
for (let i = 0; i < pageCount; i++) {
doc.addPage([612, 792]);
}
return new Uint8Array(await doc.save());
}
function resolve(doc: PDFDocument, ref?: PDFObject): PDFObject | undefined {
if (ref instanceof PDFRef) {
return doc.context.lookup(ref) as PDFObject | undefined;
}
return ref;
}
function decodeStream(doc: PDFDocument, ref?: PDFObject): string {
const stream = resolve(doc, ref);
if (!(stream instanceof PDFRawStream)) return '';
const raw = Buffer.from(stream.contents);
const filter = stream.dict.get(PDFName.of('Filter'));
return filter?.toString().includes('FlateDecode')
? inflateSync(raw).toString('latin1')
: raw.toString('latin1');
}
async function countDrawOpsPerPage(bytes: Uint8Array): Promise<number[]> {
const doc = await PDFDocument.load(bytes);
return doc.getPages().map((page) => {
const contents = page.node.get(PDFName.of('Contents'));
const resolved = doc.context.lookup(contents);
const parts =
resolved instanceof PDFArray
? resolved.asArray().map((entry) => decodeStream(doc, entry))
: [decodeStream(doc, contents)];
return parts.join('\n').match(/\/[^\s/[\]<>()]+\s+Do\b/g)?.length ?? 0;
});
}
async function countDrawOps(bytes: Uint8Array): Promise<number> {
const perPage = await countDrawOpsPerPage(bytes);
return perPage.reduce((sum, n) => sum + n, 0);
}
describe('computeTileCenters', () => {
const page = { pageWidth: 612, pageHeight: 792 };
it('produces a single-row-and-column-aligned grid at angle 0', () => {
const centers = computeTileCenters({
...page,
itemWidth: 100,
itemHeight: 100,
angle: 0,
gapX: 0,
gapY: 0,
});
expect(centers.length).toBeGreaterThan(1);
const xs = [...new Set(centers.map((c) => Math.round(c.x)))].sort(
(a, b) => a - b
);
const ys = [...new Set(centers.map((c) => Math.round(c.y)))].sort(
(a, b) => a - b
);
for (let i = 1; i < xs.length; i++) expect(xs[i] - xs[i - 1]).toBe(100);
for (let i = 1; i < ys.length; i++) expect(ys[i] - ys[i - 1]).toBe(100);
expect(centers.length).toBe(xs.length * ys.length);
});
it('covers the whole page including all four corners', () => {
const centers = computeTileCenters({
...page,
itemWidth: 200,
itemHeight: 60,
angle: 45,
gapX: 0.2,
gapY: 0.5,
});
const radius = Math.hypot(200, 60) / 2;
const corners = [
{ x: 0, y: 0 },
{ x: 612, y: 0 },
{ x: 0, y: 792 },
{ x: 612, y: 792 },
];
for (const corner of corners) {
const nearest = Math.min(
...centers.map((c) => Math.hypot(c.x - corner.x, c.y - corner.y))
);
expect(nearest).toBeLessThanOrEqual(radius);
}
});
it('keeps every tile within reach of the page', () => {
const centers = computeTileCenters({
...page,
itemWidth: 150,
itemHeight: 50,
angle: -45,
gapX: 0.25,
gapY: 0.75,
});
const cos = Math.abs(Math.cos((-45 * Math.PI) / 180));
const sin = Math.abs(Math.sin((-45 * Math.PI) / 180));
const halfExtentX = (cos * 150 + sin * 50) / 2;
const halfExtentY = (sin * 150 + cos * 50) / 2;
for (const c of centers) {
expect(c.x).toBeGreaterThanOrEqual(-halfExtentX);
expect(c.x).toBeLessThanOrEqual(612 + halfExtentX);
expect(c.y).toBeGreaterThanOrEqual(-halfExtentY);
expect(c.y).toBeLessThanOrEqual(792 + halfExtentY);
}
});
it('is symmetric about the page center', () => {
const centers = computeTileCenters({
...page,
itemWidth: 120,
itemHeight: 40,
angle: 30,
gapX: 0.5,
gapY: 0.5,
});
const keys = new Set(
centers.map((c) => `${c.x.toFixed(3)}:${c.y.toFixed(3)}`)
);
for (const c of centers) {
const mirrorX = (612 - c.x).toFixed(3);
const mirrorY = (792 - c.y).toFixed(3);
expect(keys.has(`${mirrorX}:${mirrorY}`)).toBe(true);
}
});
it('spaces tiles further apart as the gap grows', () => {
const tight = computeTileCenters({
...page,
itemWidth: 100,
itemHeight: 40,
angle: -45,
gapX: 0,
gapY: 0,
});
const loose = computeTileCenters({
...page,
itemWidth: 100,
itemHeight: 40,
angle: -45,
gapX: 1,
gapY: 2,
});
expect(loose.length).toBeLessThan(tight.length);
expect(loose.length).toBeGreaterThan(0);
});
it('treats negative gaps as zero', () => {
const negative = computeTileCenters({
...page,
itemWidth: 100,
itemHeight: 40,
angle: 0,
gapX: -5,
gapY: -5,
});
const zero = computeTileCenters({
...page,
itemWidth: 100,
itemHeight: 40,
angle: 0,
gapX: 0,
gapY: 0,
});
expect(negative).toEqual(zero);
});
it('caps the tile count for tiny watermarks', () => {
const centers = computeTileCenters({
...page,
itemWidth: 1,
itemHeight: 1,
angle: 0,
gapX: 0,
gapY: 0,
});
expect(centers.length).toBeGreaterThan(0);
expect(centers.length).toBeLessThanOrEqual(3000);
});
it('stays bounded for extreme aspect ratios', () => {
const wide = computeTileCenters({
...page,
itemWidth: 5000,
itemHeight: 2,
angle: 17,
gapX: 0,
gapY: 0,
});
const tall = computeTileCenters({
...page,
itemWidth: 1,
itemHeight: 5000,
angle: 0,
gapX: 0,
gapY: 0,
});
for (const centers of [wide, tall]) {
expect(centers.length).toBeGreaterThan(0);
expect(centers.length).toBeLessThanOrEqual(3000);
expect(
centers.every((c) => Number.isFinite(c.x) && Number.isFinite(c.y))
).toBe(true);
}
});
it('ignores a non-finite angle', () => {
expect(
computeTileCenters({
...page,
itemWidth: 100,
itemHeight: 40,
angle: Number.NaN,
gapX: 0,
gapY: 0,
})
).toEqual([]);
});
it('returns nothing for degenerate input', () => {
expect(
computeTileCenters({
pageWidth: 0,
pageHeight: 792,
itemWidth: 10,
itemHeight: 10,
angle: 0,
gapX: 0,
gapY: 0,
})
).toEqual([]);
expect(
computeTileCenters({
...page,
itemWidth: 0,
itemHeight: 10,
angle: 0,
gapX: 0,
gapY: 0,
})
).toEqual([]);
expect(
computeTileCenters({
...page,
itemWidth: Number.NaN,
itemHeight: 10,
angle: 0,
gapX: 0,
gapY: 0,
})
).toEqual([]);
expect(
computeTileCenters({
...page,
itemWidth: -10,
itemHeight: 10,
angle: 0,
gapX: 0,
gapY: 0,
})
).toEqual([]);
});
});
describe('addImageWatermark tiling', () => {
it('draws the watermark once per page in single mode', async () => {
const result = await addImageWatermark(await createTestPdf(2), {
imageBytes: pngBytes(),
imageType: 'png',
opacity: 0.3,
angle: -45,
scale: 100,
});
expect(await countDrawOps(result)).toBe(2);
});
it('repeats the watermark across each page in tile mode', async () => {
const single = await addImageWatermark(await createTestPdf(1), {
imageBytes: pngBytes(),
imageType: 'png',
opacity: 0.3,
angle: -45,
scale: 100,
});
const tiled = await addImageWatermark(await createTestPdf(1), {
imageBytes: pngBytes(),
imageType: 'png',
opacity: 0.3,
angle: -45,
scale: 100,
tile: true,
tileGapX: 0.25,
tileGapY: 0.75,
});
expect(await countDrawOps(single)).toBe(1);
expect(await countDrawOps(tiled)).toBeGreaterThan(10);
});
it('only tiles the selected pages', async () => {
const tiled = await addImageWatermark(await createTestPdf(3), {
imageBytes: pngBytes(),
imageType: 'png',
opacity: 0.3,
angle: 0,
scale: 100,
pageIndices: [1],
tile: true,
tileGapX: 0.25,
tileGapY: 0.75,
});
const doc = await PDFDocument.load(tiled);
expect(doc.getPageCount()).toBe(3);
const perPage = await countDrawOpsPerPage(tiled);
expect(perPage[0]).toBe(0);
expect(perPage[1]).toBeGreaterThan(10);
expect(perPage[2]).toBe(0);
const untouched = await addImageWatermark(await createTestPdf(3), {
imageBytes: pngBytes(),
imageType: 'png',
opacity: 0.3,
angle: 0,
scale: 100,
pageIndices: [],
tile: true,
});
expect(await countDrawOps(untouched)).toBe(0);
});
it('produces a loadable PDF with a bounded size when tiling', async () => {
const tiled = await addImageWatermark(await createTestPdf(5), {
imageBytes: pngBytes(),
imageType: 'png',
opacity: 0.2,
angle: -45,
scale: 20,
tile: true,
tileGapX: 0,
tileGapY: 0,
});
const doc = await PDFDocument.load(tiled);
expect(doc.getPageCount()).toBe(5);
expect(tiled.byteLength).toBeLessThan(5 * 1024 * 1024);
});
});