mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-08-17 21:25:47 +02:00
Add tiled watermark layout support with adjustable gaps and UI controls
This commit is contained in:
@@ -33,6 +33,13 @@ Add text or image watermarks to your PDF pages using a visual editor. You can po
|
|||||||
- **Opacity** -- Control image transparency.
|
- **Opacity** -- Control image transparency.
|
||||||
- **Scale** -- Resize the image watermark.
|
- **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:**
|
**General settings:**
|
||||||
|
|
||||||
- **Apply to all pages** -- When checked, the same watermark applies to every page. Uncheck to set individual watermarks per page.
|
- **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
|
- Visual drag-and-drop positioning on a live PDF preview
|
||||||
- Resizable watermark with corner handles
|
- Resizable watermark with corner handles
|
||||||
- Text and image watermark modes
|
- Text and image watermark modes
|
||||||
|
- Single or full-page tiled (repeating) layout with adjustable spacing
|
||||||
- Per-page or global watermark application
|
- Per-page or global watermark application
|
||||||
- Page-by-page navigation in the preview
|
- Page-by-page navigation in the preview
|
||||||
- Opacity and rotation controls
|
- 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
|
## Tips
|
||||||
|
|
||||||
- For diagonal watermarks across the page, set a rotation around 45 degrees and increase the font size.
|
- 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.
|
- 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.
|
- If you need different watermark text on different pages, uncheck "Apply to all pages" and navigate page by page.
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,13 @@ import {
|
|||||||
addTextWatermark,
|
addTextWatermark,
|
||||||
addImageWatermark,
|
addImageWatermark,
|
||||||
parsePageRange,
|
parsePageRange,
|
||||||
|
computeTileCenters,
|
||||||
} from '../utils/pdf-operations.js';
|
} from '../utils/pdf-operations.js';
|
||||||
import { AddWatermarkState, PageWatermarkConfig } from '@/types';
|
import {
|
||||||
|
AddWatermarkState,
|
||||||
|
PageWatermarkConfig,
|
||||||
|
WatermarkLayout,
|
||||||
|
} from '@/types';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js';
|
||||||
import { loadPdfDocument } from '../utils/load-pdf-document.js';
|
import { loadPdfDocument } from '../utils/load-pdf-document.js';
|
||||||
@@ -31,9 +36,15 @@ const pageState: AddWatermarkState = {
|
|||||||
watermarkY: 0.5,
|
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 watermarkType: 'text' | 'image' = 'text';
|
||||||
|
let watermarkLayout: WatermarkLayout = 'single';
|
||||||
let imageWatermarkDataUrl: string | null = null;
|
let imageWatermarkDataUrl: string | null = null;
|
||||||
let imageWatermarkFile: File | null = null;
|
let imageWatermarkFile: File | null = null;
|
||||||
|
let imageWatermarkBitmap: HTMLImageElement | null = null;
|
||||||
|
let measureCanvas: HTMLCanvasElement | null = null;
|
||||||
let isDragging = false;
|
let isDragging = false;
|
||||||
let dragOffsetX = 0;
|
let dragOffsetX = 0;
|
||||||
let dragOffsetY = 0;
|
let dragOffsetY = 0;
|
||||||
@@ -186,6 +197,7 @@ function resetState() {
|
|||||||
pageState.watermarkY = 0.5;
|
pageState.watermarkY = 0.5;
|
||||||
imageWatermarkDataUrl = null;
|
imageWatermarkDataUrl = null;
|
||||||
imageWatermarkFile = null;
|
imageWatermarkFile = null;
|
||||||
|
imageWatermarkBitmap = null;
|
||||||
cachedPdfjsDoc = null;
|
cachedPdfjsDoc = null;
|
||||||
currentPageNum = 1;
|
currentPageNum = 1;
|
||||||
totalPageCount = 1;
|
totalPageCount = 1;
|
||||||
@@ -273,9 +285,19 @@ async function changePage(newPageNum: number) {
|
|||||||
updateWatermarkOverlay();
|
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 {
|
function getDefaultConfig(): PageWatermarkConfig {
|
||||||
return {
|
return {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
layout: 'single',
|
||||||
|
tileGapX: 25,
|
||||||
|
tileGapY: 75,
|
||||||
x: 0.5,
|
x: 0.5,
|
||||||
y: 0.5,
|
y: 0.5,
|
||||||
text: '',
|
text: '',
|
||||||
@@ -294,6 +316,9 @@ function getDefaultConfig(): PageWatermarkConfig {
|
|||||||
function getCurrentConfig(): PageWatermarkConfig {
|
function getCurrentConfig(): PageWatermarkConfig {
|
||||||
return {
|
return {
|
||||||
type: watermarkType,
|
type: watermarkType,
|
||||||
|
layout: watermarkLayout,
|
||||||
|
tileGapX: readNumberInput('tile-gap-x', 25),
|
||||||
|
tileGapY: readNumberInput('tile-gap-y', 75),
|
||||||
x: pageState.watermarkX,
|
x: pageState.watermarkX,
|
||||||
y: pageState.watermarkY,
|
y: pageState.watermarkY,
|
||||||
text:
|
text:
|
||||||
@@ -345,11 +370,27 @@ function loadPageConfig(pageNum: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watermarkType = config.type;
|
watermarkType = config.type;
|
||||||
|
watermarkLayout = config.layout;
|
||||||
pageState.watermarkX = config.x;
|
pageState.watermarkX = config.x;
|
||||||
pageState.watermarkY = config.y;
|
pageState.watermarkY = config.y;
|
||||||
|
if (config.imageDataUrl !== imageWatermarkDataUrl) {
|
||||||
|
loadWatermarkBitmap(config.imageDataUrl);
|
||||||
|
}
|
||||||
imageWatermarkDataUrl = config.imageDataUrl;
|
imageWatermarkDataUrl = config.imageDataUrl;
|
||||||
imageWatermarkFile = config.imageFile;
|
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 typeTextBtn = document.getElementById('type-text-btn');
|
||||||
const typeImageBtn = document.getElementById('type-image-btn');
|
const typeImageBtn = document.getElementById('type-image-btn');
|
||||||
const textOptions = document.getElementById('text-watermark-options');
|
const textOptions = document.getElementById('text-watermark-options');
|
||||||
@@ -511,6 +552,36 @@ function setupEditorControls() {
|
|||||||
updateWatermarkOverlay();
|
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(
|
const watermarkText = document.getElementById(
|
||||||
'watermark-text'
|
'watermark-text'
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
@@ -571,6 +642,7 @@ function setupEditorControls() {
|
|||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
imageWatermarkDataUrl = reader.result as string;
|
imageWatermarkDataUrl = reader.result as string;
|
||||||
|
loadWatermarkBitmap(imageWatermarkDataUrl);
|
||||||
updateWatermarkOverlay();
|
updateWatermarkOverlay();
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
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() {
|
function updateWatermarkOverlay() {
|
||||||
const box = document.getElementById('watermark-box') as HTMLElement;
|
const box = document.getElementById('watermark-box') as HTMLElement;
|
||||||
const textOverlay = document.getElementById(
|
const textOverlay = document.getElementById(
|
||||||
@@ -619,6 +846,18 @@ function updateWatermarkOverlay() {
|
|||||||
|
|
||||||
const containerW = container.clientWidth;
|
const containerW = container.clientWidth;
|
||||||
const containerH = container.clientHeight;
|
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') {
|
if (watermarkType === 'text') {
|
||||||
box.classList.remove('hidden');
|
box.classList.remove('hidden');
|
||||||
@@ -846,6 +1085,9 @@ async function applyWatermark() {
|
|||||||
x: config.x,
|
x: config.x,
|
||||||
y: posY,
|
y: posY,
|
||||||
pageIndices,
|
pageIndices,
|
||||||
|
tile: config.layout === 'tile',
|
||||||
|
tileGapX: config.tileGapX / 100,
|
||||||
|
tileGapY: config.tileGapY / 100,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -875,6 +1117,9 @@ async function applyWatermark() {
|
|||||||
x: config.x,
|
x: config.x,
|
||||||
y: posY,
|
y: posY,
|
||||||
pageIndices,
|
pageIndices,
|
||||||
|
tile: config.layout === 'tile',
|
||||||
|
tileGapX: config.tileGapX / 100,
|
||||||
|
tileGapY: config.tileGapY / 100,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -896,6 +1141,9 @@ async function applyWatermark() {
|
|||||||
|
|
||||||
const key = JSON.stringify({
|
const key = JSON.stringify({
|
||||||
type: config.type,
|
type: config.type,
|
||||||
|
layout: config.layout,
|
||||||
|
tileGapX: config.tileGapX,
|
||||||
|
tileGapY: config.tileGapY,
|
||||||
x: config.x,
|
x: config.x,
|
||||||
y: config.y,
|
y: config.y,
|
||||||
text: config.text,
|
text: config.text,
|
||||||
@@ -930,6 +1178,9 @@ async function applyWatermark() {
|
|||||||
x: config.x,
|
x: config.x,
|
||||||
y: posY,
|
y: posY,
|
||||||
pageIndices: indices,
|
pageIndices: indices,
|
||||||
|
tile: config.layout === 'tile',
|
||||||
|
tileGapX: config.tileGapX / 100,
|
||||||
|
tileGapY: config.tileGapY / 100,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -955,6 +1206,9 @@ async function applyWatermark() {
|
|||||||
x: config.x,
|
x: config.x,
|
||||||
y: posY,
|
y: posY,
|
||||||
pageIndices: indices,
|
pageIndices: indices,
|
||||||
|
tile: config.layout === 'tile',
|
||||||
|
tileGapX: config.tileGapX / 100,
|
||||||
|
tileGapY: config.tileGapY / 100,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ export interface AddWatermarkState {
|
|||||||
watermarkY: number; // 0–1, percentage from top (flipped to bottom for PDF)
|
watermarkY: number; // 0–1, percentage from top (flipped to bottom for PDF)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WatermarkLayout = 'single' | 'tile';
|
||||||
|
|
||||||
export interface PageWatermarkConfig {
|
export interface PageWatermarkConfig {
|
||||||
type: 'text' | 'image';
|
type: 'text' | 'image';
|
||||||
|
layout: WatermarkLayout;
|
||||||
|
tileGapX: number;
|
||||||
|
tileGapY: number;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
text: string;
|
text: string;
|
||||||
|
|||||||
+124
-20
@@ -182,6 +182,82 @@ export function parseDeletePages(str: string, totalPages: number): Set<number> {
|
|||||||
return pages;
|
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 {
|
export interface TextWatermarkOptions {
|
||||||
text: string;
|
text: string;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
@@ -191,6 +267,9 @@ export interface TextWatermarkOptions {
|
|||||||
x?: number;
|
x?: number;
|
||||||
y?: number;
|
y?: number;
|
||||||
pageIndices?: number[];
|
pageIndices?: number[];
|
||||||
|
tile?: boolean;
|
||||||
|
tileGapX?: number;
|
||||||
|
tileGapY?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addTextWatermark(
|
export async function addTextWatermark(
|
||||||
@@ -244,17 +323,28 @@ export async function addTextWatermark(
|
|||||||
const page = pages[idx];
|
const page = pages[idx];
|
||||||
if (!page) continue;
|
if (!page) continue;
|
||||||
const { width, height } = page.getSize();
|
const { width, height } = page.getSize();
|
||||||
const cx = posX * width;
|
const centers = options.tile
|
||||||
const cy = posY * height;
|
? 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, {
|
for (const { x: cx, y: cy } of centers) {
|
||||||
x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH,
|
page.drawImage(image, {
|
||||||
y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH,
|
x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH,
|
||||||
width: imgWidth,
|
y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH,
|
||||||
height: imgHeight,
|
width: imgWidth,
|
||||||
opacity: options.opacity,
|
height: imgHeight,
|
||||||
rotate: degrees(options.angle),
|
opacity: options.opacity,
|
||||||
});
|
rotate: degrees(options.angle),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Uint8Array(await pdfDoc.save());
|
return new Uint8Array(await pdfDoc.save());
|
||||||
@@ -269,6 +359,9 @@ export interface ImageWatermarkOptions {
|
|||||||
x?: number;
|
x?: number;
|
||||||
y?: number;
|
y?: number;
|
||||||
pageIndices?: number[];
|
pageIndices?: number[];
|
||||||
|
tile?: boolean;
|
||||||
|
tileGapX?: number;
|
||||||
|
tileGapY?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addImageWatermark(
|
export async function addImageWatermark(
|
||||||
@@ -295,17 +388,28 @@ export async function addImageWatermark(
|
|||||||
const page = pages[idx];
|
const page = pages[idx];
|
||||||
if (!page) continue;
|
if (!page) continue;
|
||||||
const { width, height } = page.getSize();
|
const { width, height } = page.getSize();
|
||||||
const cx = posX * width;
|
const centers = options.tile
|
||||||
const cy = posY * height;
|
? 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, {
|
for (const { x: cx, y: cy } of centers) {
|
||||||
x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH,
|
page.drawImage(image, {
|
||||||
y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH,
|
x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH,
|
||||||
width: imgWidth,
|
y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH,
|
||||||
height: imgHeight,
|
width: imgWidth,
|
||||||
opacity: options.opacity,
|
height: imgHeight,
|
||||||
rotate: degrees(options.angle),
|
opacity: options.opacity,
|
||||||
});
|
rotate: degrees(options.angle),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Uint8Array(await pdfDoc.save());
|
return new Uint8Array(await pdfDoc.save());
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ export class WatermarkNode extends BaseWorkflowNode {
|
|||||||
'position',
|
'position',
|
||||||
new ClassicPreset.InputControl('text', { initial: 'center' })
|
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(
|
this.addControl(
|
||||||
'pages',
|
'pages',
|
||||||
new ClassicPreset.InputControl('text', { initial: 'all' })
|
new ClassicPreset.InputControl('text', { initial: 'all' })
|
||||||
@@ -96,6 +108,10 @@ export class WatermarkNode extends BaseWorkflowNode {
|
|||||||
const posKey = getText('position', 'center').trim().toLowerCase();
|
const posKey = getText('position', 'center').trim().toLowerCase();
|
||||||
const { x, y } = positionPresets[posKey] ?? positionPresets['center'];
|
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 pagesStr = getText('pages', 'all').trim().toLowerCase();
|
||||||
const shouldFlatten =
|
const shouldFlatten =
|
||||||
getText('flatten', 'no').trim().toLowerCase() === 'yes';
|
getText('flatten', 'no').trim().toLowerCase() === 'yes';
|
||||||
@@ -117,6 +133,9 @@ export class WatermarkNode extends BaseWorkflowNode {
|
|||||||
x,
|
x,
|
||||||
y: 1 - y,
|
y: 1 - y,
|
||||||
pageIndices,
|
pageIndices,
|
||||||
|
tile,
|
||||||
|
tileGapX,
|
||||||
|
tileGapY,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (shouldFlatten) {
|
if (shouldFlatten) {
|
||||||
|
|||||||
@@ -212,7 +212,9 @@
|
|||||||
<i data-lucide="chevron-right" class="w-4 h-4"></i>
|
<i data-lucide="chevron-right" class="w-4 h-4"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
>Drag watermark to position</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,6 +228,10 @@
|
|||||||
style="touch-action: none"
|
style="touch-action: none"
|
||||||
>
|
>
|
||||||
<canvas id="preview-canvas"></canvas>
|
<canvas id="preview-canvas"></canvas>
|
||||||
|
<canvas
|
||||||
|
id="tile-preview-canvas"
|
||||||
|
class="hidden absolute top-0 left-0 pointer-events-none"
|
||||||
|
></canvas>
|
||||||
<div
|
<div
|
||||||
id="watermark-box"
|
id="watermark-box"
|
||||||
class="absolute pointer-events-auto"
|
class="absolute pointer-events-auto"
|
||||||
@@ -284,6 +290,29 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<div
|
||||||
class="flex items-center gap-2 bg-gray-900 rounded-lg px-3 py-2"
|
class="flex items-center gap-2 bg-gray-900 rounded-lg px-3 py-2"
|
||||||
>
|
>
|
||||||
@@ -470,7 +499,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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"
|
<label class="block mb-1.5 text-sm font-medium text-gray-300"
|
||||||
>Position</label
|
>Position</label
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user