From 9635c398d6a81c8ca81b0c9c9af969d3be0b9d46 Mon Sep 17 00:00:00 2001 From: alam00000 Date: Sun, 16 Aug 2026 12:40:55 +0530 Subject: [PATCH] Add tiled watermark layout support with adjustable gaps and UI controls --- docs/tools/add-watermark.md | 9 + src/js/logic/add-watermark-page.ts | 256 +++++++++++++++- src/js/types/add-watermark-type.ts | 5 + src/js/utils/pdf-operations.ts | 144 +++++++-- src/js/workflow/nodes/watermark-node.ts | 19 ++ src/pages/add-watermark.html | 70 ++++- src/tests/watermark-tile.test.ts | 391 ++++++++++++++++++++++++ 7 files changed, 871 insertions(+), 23 deletions(-) create mode 100644 src/tests/watermark-tile.test.ts diff --git a/docs/tools/add-watermark.md b/docs/tools/add-watermark.md index ac0fe85c..faf44b3f 100644 --- a/docs/tools/add-watermark.md +++ b/docs/tools/add-watermark.md @@ -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. diff --git a/src/js/logic/add-watermark-page.ts b/src/js/logic/add-watermark-page.ts index 79c06c64..337b5129 100644 --- a/src/js/logic/add-watermark-page.ts +++ b/src/js/logic/add-watermark-page.ts @@ -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, }) ); } diff --git a/src/js/types/add-watermark-type.ts b/src/js/types/add-watermark-type.ts index 898d5c4e..f300d995 100644 --- a/src/js/types/add-watermark-type.ts +++ b/src/js/types/add-watermark-type.ts @@ -9,8 +9,13 @@ export interface AddWatermarkState { watermarkY: number; // 0–1, 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; diff --git a/src/js/utils/pdf-operations.ts b/src/js/utils/pdf-operations.ts index a048ee84..3892f67d 100644 --- a/src/js/utils/pdf-operations.ts +++ b/src/js/utils/pdf-operations.ts @@ -182,6 +182,82 @@ export function parseDeletePages(str: string, totalPages: number): Set { 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,17 +323,28 @@ 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 }]; - page.drawImage(image, { - x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH, - y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH, - width: imgWidth, - height: imgHeight, - opacity: options.opacity, - rotate: degrees(options.angle), - }); + 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, + width: imgWidth, + height: imgHeight, + opacity: options.opacity, + 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,17 +388,28 @@ 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 }]; - page.drawImage(image, { - x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH, - y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH, - width: imgWidth, - height: imgHeight, - opacity: options.opacity, - rotate: degrees(options.angle), - }); + 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, + width: imgWidth, + height: imgHeight, + opacity: options.opacity, + rotate: degrees(options.angle), + }); + } } return new Uint8Array(await pdfDoc.save()); diff --git a/src/js/workflow/nodes/watermark-node.ts b/src/js/workflow/nodes/watermark-node.ts index b0b97d69..d470f8d8 100644 --- a/src/js/workflow/nodes/watermark-node.ts +++ b/src/js/workflow/nodes/watermark-node.ts @@ -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) { diff --git a/src/pages/add-watermark.html b/src/pages/add-watermark.html index 00e0d74a..df59df57 100644 --- a/src/pages/add-watermark.html +++ b/src/pages/add-watermark.html @@ -212,7 +212,9 @@ - @@ -226,6 +228,10 @@ style="touch-action: none" > +
+
+ +
+ + +
+

+ Tiled repeats the watermark across the whole page. +

+
+
@@ -470,7 +499,44 @@
-
+ + +
diff --git a/src/tests/watermark-tile.test.ts b/src/tests/watermark-tile.test.ts new file mode 100644 index 00000000..7d877aea --- /dev/null +++ b/src/tests/watermark-tile.test.ts @@ -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 { + 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 { + 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 { + 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); + }); +});