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.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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