Files
bentopdf/src/js/logic/add-watermark-page.ts
T

1290 lines
42 KiB
TypeScript

import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import {
downloadFile,
getPDFDocument,
hexToRgb,
formatBytes,
readFileAsArrayBuffer,
} from '../utils/helpers.js';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import {
addTextWatermark,
addImageWatermark,
parsePageRange,
computeTileCenters,
} from '../utils/pdf-operations.js';
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';
import '../utils/setup-pdf-worker.js';
import { trText, onPageReady } from '../i18n/i18n.js';
const pageState: AddWatermarkState = {
file: null,
pdfDoc: null,
pdfBytes: null,
previewCanvas: null,
watermarkX: 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 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;
let previewScale = 1;
let pdfPageWidth = 0;
let pdfPageHeight = 0;
let isResizing = false;
let resizeStartDistance = 0;
let resizeStartFontSize = 0;
let resizeStartImageScale = 0;
let currentPageNum = 1;
let totalPageCount = 1;
let cachedPdfjsDoc: pdfjsLib.PDFDocumentProxy | null = null;
const pageWatermarks: Map<number, PageWatermarkConfig> = new Map();
let applyToAllPages = true;
onPageReady(initializePage);
function initializePage() {
createIcons({ icons });
const fileInput = document.getElementById('file-input') as HTMLInputElement;
const dropZone = document.getElementById('drop-zone');
const backBtn = document.getElementById('back-to-tools');
const editorBackBtn = document.getElementById('editor-back-btn');
const processBtn = document.getElementById('process-btn');
if (fileInput) {
fileInput.addEventListener('change', handleFileUpload);
fileInput.addEventListener('click', () => {
fileInput.value = '';
});
}
if (dropZone) {
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('border-indigo-500');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('border-indigo-500');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('border-indigo-500');
if (e.dataTransfer?.files.length) handleFiles(e.dataTransfer.files);
});
}
if (backBtn)
backBtn.addEventListener('click', () => {
window.location.href = import.meta.env.BASE_URL;
});
if (editorBackBtn)
editorBackBtn.addEventListener('click', () => {
resetState();
document.getElementById('uploader')?.classList.remove('hidden');
document.getElementById('editor-panel')?.classList.add('hidden');
});
if (processBtn) processBtn.addEventListener('click', applyWatermark);
setupEditorControls();
setupPageNavigation();
}
function handleFileUpload(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files?.length) handleFiles(input.files);
}
async function handleFiles(files: FileList) {
const file = files[0];
if (!file || file.type !== 'application/pdf') {
showAlert('Invalid File', 'Please upload a valid PDF file.');
return;
}
try {
const result = await loadPdfWithPasswordPrompt(file);
if (!result) return;
showLoader('Loading PDF...');
const pdfBytes = new Uint8Array(result.bytes);
pageState.pdfDoc = await loadPdfDocument(pdfBytes);
pageState.file = result.file;
pageState.pdfBytes = pdfBytes;
cachedPdfjsDoc = result.pdf;
totalPageCount = cachedPdfjsDoc.numPages;
currentPageNum = 1;
pageWatermarks.clear();
updateFileDisplay();
document.getElementById('uploader')?.classList.add('hidden');
document.getElementById('editor-panel')?.classList.remove('hidden');
const editorFileInfo = document.getElementById('editor-file-info');
if (editorFileInfo) {
editorFileInfo.textContent = trText(
'{{name}} ({{size}}, {{totalPageCount}} pages)',
{ name: file.name, size: formatBytes(file.size), totalPageCount }
);
}
updatePageNavUI();
await renderPreview();
updateWatermarkOverlay();
} catch (error) {
console.error(error);
showAlert('Error', 'Failed to load PDF file.');
} finally {
hideLoader();
}
}
function updateFileDisplay() {
const fileDisplayArea = document.getElementById('file-display-area');
if (!fileDisplayArea || !pageState.file || !pageState.pdfDoc) return;
fileDisplayArea.innerHTML = '';
const fileDiv = document.createElement('div');
fileDiv.className =
'flex items-center justify-between bg-gray-700 p-3 rounded-lg';
const infoContainer = document.createElement('div');
infoContainer.className = 'flex flex-col flex-1 min-w-0';
const nameSpan = document.createElement('div');
nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1';
nameSpan.textContent = pageState.file.name;
const metaSpan = document.createElement('div');
metaSpan.className = 'text-xs text-gray-400';
metaSpan.textContent = trText('{{size}} • {{getPageCount}} pages', {
size: formatBytes(pageState.file.size),
getPageCount: pageState.pdfDoc.getPageCount(),
});
infoContainer.append(nameSpan, metaSpan);
const removeBtn = document.createElement('button');
removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0';
removeBtn.innerHTML = '<i data-lucide="trash-2" class="w-4 h-4"></i>';
removeBtn.onclick = resetState;
fileDiv.append(infoContainer, removeBtn);
fileDisplayArea.appendChild(fileDiv);
createIcons({ icons });
}
function resetState() {
pageState.file = null;
pageState.pdfDoc = null;
pageState.pdfBytes = null;
pageState.previewCanvas = null;
pageState.watermarkX = 0.5;
pageState.watermarkY = 0.5;
imageWatermarkDataUrl = null;
imageWatermarkFile = null;
imageWatermarkBitmap = null;
cachedPdfjsDoc = null;
currentPageNum = 1;
totalPageCount = 1;
pageWatermarks.clear();
const fileDisplayArea = document.getElementById('file-display-area');
if (fileDisplayArea) fileDisplayArea.innerHTML = '';
const fileInput = document.getElementById('file-input') as HTMLInputElement;
if (fileInput) fileInput.value = '';
}
function setupPageNavigation() {
const prevBtn = document.getElementById('prev-page-btn');
const nextBtn = document.getElementById('next-page-btn');
const pageInput = document.getElementById(
'page-num-input'
) as HTMLInputElement;
prevBtn?.addEventListener('click', () => changePage(currentPageNum - 1));
nextBtn?.addEventListener('click', () => changePage(currentPageNum + 1));
pageInput?.addEventListener('change', () => {
const val = parseInt(pageInput.value);
if (val >= 1 && val <= totalPageCount) {
changePage(val);
} else {
pageInput.value = String(currentPageNum);
}
});
pageInput?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
(e.target as HTMLInputElement).blur();
}
});
const applyAllCheckbox = document.getElementById(
'apply-all-pages'
) as HTMLInputElement;
const pageRangeSection = document.getElementById('page-range-section');
applyAllCheckbox?.addEventListener('change', () => {
const wasApplyAll = applyToAllPages;
applyToAllPages = applyAllCheckbox.checked;
if (pageRangeSection) {
pageRangeSection.style.display = applyToAllPages ? '' : 'none';
}
if (!applyToAllPages && wasApplyAll) {
const config = getCurrentConfig();
for (let i = 1; i <= totalPageCount; i++) {
if (!pageWatermarks.has(i)) {
pageWatermarks.set(i, { ...config });
}
}
}
});
}
function updatePageNavUI() {
const prevBtn = document.getElementById('prev-page-btn') as HTMLButtonElement;
const nextBtn = document.getElementById('next-page-btn') as HTMLButtonElement;
const pageInput = document.getElementById(
'page-num-input'
) as HTMLInputElement;
const totalSpan = document.getElementById('total-pages');
if (prevBtn) prevBtn.disabled = currentPageNum <= 1;
if (nextBtn) nextBtn.disabled = currentPageNum >= totalPageCount;
if (pageInput) {
pageInput.value = String(currentPageNum);
pageInput.max = String(totalPageCount);
}
if (totalSpan) totalSpan.textContent = String(totalPageCount);
}
async function changePage(newPageNum: number) {
if (newPageNum < 1 || newPageNum > totalPageCount) return;
if (newPageNum === currentPageNum) return;
saveCurrentPageConfig();
currentPageNum = newPageNum;
updatePageNavUI();
await renderPreview();
loadPageConfig(currentPageNum);
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: '',
fontSize: 72,
color: '#888888',
opacityText: 0.3,
angleText: -45,
imageDataUrl: null,
imageFile: null,
imageScale: 100,
opacityImage: 0.3,
angleImage: 0,
};
}
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:
(document.getElementById('watermark-text') as HTMLInputElement)?.value ||
'',
fontSize:
parseInt(
(document.getElementById('font-size') as HTMLInputElement)?.value
) || 72,
color:
(document.getElementById('text-color') as HTMLInputElement)?.value ||
'#888888',
opacityText:
parseFloat(
(document.getElementById('opacity-text') as HTMLInputElement)?.value
) || 0.3,
angleText:
parseInt(
(document.getElementById('angle-text') as HTMLInputElement)?.value
) || 0,
imageDataUrl: imageWatermarkDataUrl,
imageFile: imageWatermarkFile,
imageScale:
parseInt(
(document.getElementById('image-scale') as HTMLInputElement)?.value
) || 100,
opacityImage:
parseFloat(
(document.getElementById('opacity-image') as HTMLInputElement)?.value
) || 0.3,
angleImage:
parseInt(
(document.getElementById('angle-image') as HTMLInputElement)?.value
) || 0,
};
}
function saveCurrentPageConfig() {
pageWatermarks.set(currentPageNum, getCurrentConfig());
}
function loadPageConfig(pageNum: number) {
let config: PageWatermarkConfig;
if (applyToAllPages) {
config = pageWatermarks.get(1) || getCurrentConfig();
} else {
config = pageWatermarks.get(pageNum) || getDefaultConfig();
}
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');
const imageOptions = document.getElementById('image-watermark-options');
if (config.type === 'text') {
typeTextBtn!.className =
'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-indigo-600 text-white transition-colors';
typeImageBtn!.className =
'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors';
textOptions?.classList.remove('hidden');
imageOptions?.classList.add('hidden');
} else {
typeImageBtn!.className =
'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-indigo-600 text-white transition-colors';
typeTextBtn!.className =
'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors';
textOptions?.classList.add('hidden');
imageOptions?.classList.remove('hidden');
}
const watermarkText = document.getElementById(
'watermark-text'
) as HTMLInputElement;
const fontSize = document.getElementById('font-size') as HTMLInputElement;
const textColor = document.getElementById('text-color') as HTMLInputElement;
const opacityText = document.getElementById(
'opacity-text'
) as HTMLInputElement;
const angleText = document.getElementById('angle-text') as HTMLInputElement;
const opacityValueText = document.getElementById('opacity-value-text');
const angleValueText = document.getElementById('angle-value-text');
if (watermarkText) watermarkText.value = config.text;
if (fontSize) fontSize.value = String(config.fontSize);
if (textColor) textColor.value = config.color;
if (opacityText) opacityText.value = String(config.opacityText);
if (angleText) angleText.value = String(config.angleText);
if (opacityValueText)
opacityValueText.textContent = String(config.opacityText);
if (angleValueText) angleValueText.textContent = String(config.angleText);
const imageScale = document.getElementById('image-scale') as HTMLInputElement;
const opacityImage = document.getElementById(
'opacity-image'
) as HTMLInputElement;
const angleImage = document.getElementById('angle-image') as HTMLInputElement;
const imageScaleValue = document.getElementById('image-scale-value');
const opacityValueImage = document.getElementById('opacity-value-image');
const angleValueImage = document.getElementById('angle-value-image');
if (imageScale) imageScale.value = String(config.imageScale);
if (opacityImage) opacityImage.value = String(config.opacityImage);
if (angleImage) angleImage.value = String(config.angleImage);
if (imageScaleValue) imageScaleValue.textContent = String(config.imageScale);
if (opacityValueImage)
opacityValueImage.textContent = String(config.opacityImage);
if (angleValueImage) angleValueImage.textContent = String(config.angleImage);
updatePresetHighlight(config.x, config.y);
}
async function renderPreview() {
if (!pageState.pdfBytes || !cachedPdfjsDoc) return;
const page = await cachedPdfjsDoc.getPage(currentPageNum);
const container = document.getElementById('preview-container');
const wrapper = document.getElementById('preview-wrapper');
if (!container || !wrapper) return;
const isDesktop = window.innerWidth >= 1024;
const availableWidth = wrapper.clientWidth - 16;
let availableHeight: number;
if (isDesktop) {
const controlsCard = document.querySelector(
'.lg\\:w-80 > div'
) as HTMLElement;
if (controlsCard && controlsCard.offsetHeight > 100) {
const cardHeader = wrapper.parentElement?.querySelector(
'.flex.items-center.justify-between'
) as HTMLElement;
const headerH = cardHeader ? cardHeader.offsetHeight + 12 : 40;
const cardPadding = 32;
availableHeight = controlsCard.offsetHeight - headerH - cardPadding;
} else {
availableHeight =
wrapper.clientHeight > 100
? wrapper.clientHeight - 16
: window.innerHeight * 0.8;
}
} else {
availableHeight = Math.min(window.innerHeight * 0.55, 600);
}
const unscaledViewport = page.getViewport({ scale: 1 });
pdfPageWidth = unscaledViewport.width;
pdfPageHeight = unscaledViewport.height;
previewScale = Math.min(
availableWidth / pdfPageWidth,
availableHeight / pdfPageHeight
);
const displayWidth = Math.floor(pdfPageWidth * previewScale);
const displayHeight = Math.floor(pdfPageHeight * previewScale);
const dpr = 2;
const viewport = page.getViewport({ scale: previewScale * dpr });
const canvas = document.getElementById('preview-canvas') as HTMLCanvasElement;
if (!canvas) return;
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = displayWidth + 'px';
canvas.style.height = displayHeight + 'px';
container.style.width = displayWidth + 'px';
container.style.height = displayHeight + 'px';
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
await page.render({ canvasContext: ctx, canvas, viewport }).promise;
pageState.previewCanvas = canvas;
setupDragHandlers(container);
}
function setupEditorControls() {
const typeTextBtn = document.getElementById('type-text-btn');
const typeImageBtn = document.getElementById('type-image-btn');
const textOptions = document.getElementById('text-watermark-options');
const imageOptions = document.getElementById('image-watermark-options');
typeTextBtn?.addEventListener('click', () => {
watermarkType = 'text';
typeTextBtn.className =
'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-indigo-600 text-white transition-colors';
typeImageBtn!.className =
'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors';
textOptions?.classList.remove('hidden');
imageOptions?.classList.add('hidden');
updateWatermarkOverlay();
});
typeImageBtn?.addEventListener('click', () => {
watermarkType = 'image';
typeImageBtn.className =
'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-indigo-600 text-white transition-colors';
typeTextBtn!.className =
'flex-1 py-2 px-3 text-sm font-medium rounded-lg bg-gray-700 text-gray-300 hover:bg-gray-600 transition-colors';
textOptions?.classList.add('hidden');
imageOptions?.classList.remove('hidden');
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;
const fontSize = document.getElementById('font-size') as HTMLInputElement;
const textColor = document.getElementById('text-color') as HTMLInputElement;
const opacityText = document.getElementById(
'opacity-text'
) as HTMLInputElement;
const angleText = document.getElementById('angle-text') as HTMLInputElement;
const opacityValueText = document.getElementById('opacity-value-text');
const angleValueText = document.getElementById('angle-value-text');
watermarkText?.addEventListener('input', () => updateWatermarkOverlay());
fontSize?.addEventListener('input', () => updateWatermarkOverlay());
textColor?.addEventListener('input', () => updateWatermarkOverlay());
opacityText?.addEventListener('input', () => {
if (opacityValueText) opacityValueText.textContent = opacityText.value;
updateWatermarkOverlay();
});
angleText?.addEventListener('input', () => {
if (angleValueText) angleValueText.textContent = angleText.value;
updateWatermarkOverlay();
});
const opacityImage = document.getElementById(
'opacity-image'
) as HTMLInputElement;
const angleImage = document.getElementById('angle-image') as HTMLInputElement;
const imageScale = document.getElementById('image-scale') as HTMLInputElement;
const opacityValueImage = document.getElementById('opacity-value-image');
const angleValueImage = document.getElementById('angle-value-image');
const imageScaleValue = document.getElementById('image-scale-value');
opacityImage?.addEventListener('input', () => {
if (opacityValueImage) opacityValueImage.textContent = opacityImage.value;
updateWatermarkOverlay();
});
angleImage?.addEventListener('input', () => {
if (angleValueImage) angleValueImage.textContent = angleImage.value;
updateWatermarkOverlay();
});
imageScale?.addEventListener('input', () => {
if (imageScaleValue) imageScaleValue.textContent = imageScale.value;
updateWatermarkOverlay();
});
const imageInput = document.getElementById(
'image-watermark-input'
) as HTMLInputElement;
imageInput?.addEventListener('change', () => {
const file = imageInput.files?.[0];
if (!file) return;
imageWatermarkFile = file;
const reader = new FileReader();
reader.onload = () => {
imageWatermarkDataUrl = reader.result as string;
loadWatermarkBitmap(imageWatermarkDataUrl);
updateWatermarkOverlay();
};
reader.readAsDataURL(file);
});
document.querySelectorAll('.pos-preset-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const pos = (btn as HTMLElement).dataset.pos;
if (!pos) return;
const [x, y] = pos.split(',').map(Number);
pageState.watermarkX = x;
pageState.watermarkY = y;
updatePresetHighlight(x, y);
updateWatermarkOverlay();
});
});
}
function updatePresetHighlight(x: number, y: number) {
document.querySelectorAll('.pos-preset-btn').forEach((btn) => {
const pos = (btn as HTMLElement).dataset.pos;
if (!pos) return;
const [bx, by] = pos.split(',').map(Number);
if (Math.abs(bx - x) < 0.01 && Math.abs(by - y) < 0.01) {
btn.className =
'pos-preset-btn py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded-md transition-colors';
} else {
btn.className =
'pos-preset-btn py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-md transition-colors';
}
});
}
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(
'watermark-overlay'
) as HTMLElement;
const imageOverlay = document.getElementById(
'image-watermark-overlay'
) as HTMLImageElement;
if (!box || !textOverlay || !imageOverlay) return;
const container = document.getElementById('preview-container');
if (!container) return;
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');
textOverlay.classList.remove('hidden');
imageOverlay.classList.add('hidden');
const text =
(document.getElementById('watermark-text') as HTMLInputElement)?.value ||
'CONFIDENTIAL';
const fontSizePdf =
parseInt(
(document.getElementById('font-size') as HTMLInputElement)?.value
) || 72;
const color =
(document.getElementById('text-color') as HTMLInputElement)?.value ||
'#888888';
const opacity =
parseFloat(
(document.getElementById('opacity-text') as HTMLInputElement)?.value
) || 0.3;
const angle =
parseInt(
(document.getElementById('angle-text') as HTMLInputElement)?.value
) || 0;
const fontSizePreview = fontSizePdf * previewScale;
textOverlay.textContent = text;
textOverlay.style.fontSize = fontSizePreview + 'px';
textOverlay.style.color = color;
textOverlay.style.opacity = String(opacity);
textOverlay.style.fontFamily =
'"Noto Sans SC", "Noto Sans JP", "Noto Sans KR", "Noto Sans Arabic", sans-serif';
textOverlay.style.fontWeight = 'bold';
box.style.left = pageState.watermarkX * containerW + 'px';
box.style.top = pageState.watermarkY * containerH + 'px';
box.style.transform = `translate(-50%, -50%) rotate(${angle}deg)`;
} else {
textOverlay.classList.add('hidden');
if (imageWatermarkDataUrl) {
box.classList.remove('hidden');
imageOverlay.classList.remove('hidden');
imageOverlay.src = imageWatermarkDataUrl;
const scale =
parseInt(
(document.getElementById('image-scale') as HTMLInputElement)?.value
) || 100;
const opacity =
parseFloat(
(document.getElementById('opacity-image') as HTMLInputElement)?.value
) || 0.3;
const angle =
parseInt(
(document.getElementById('angle-image') as HTMLInputElement)?.value
) || 0;
imageOverlay.style.opacity = String(opacity);
imageOverlay.style.maxWidth = (scale / 100) * containerW * 0.5 + 'px';
box.style.left = pageState.watermarkX * containerW + 'px';
box.style.top = pageState.watermarkY * containerH + 'px';
box.style.transform = `translate(-50%, -50%) rotate(${angle}deg)`;
} else {
box.classList.add('hidden');
imageOverlay.classList.add('hidden');
}
}
}
function setupDragHandlers(container: HTMLElement) {
const box = document.getElementById('watermark-box')!;
function onPointerDown(e: PointerEvent) {
const target = e.target as HTMLElement;
if (target.classList.contains('resize-handle')) {
isResizing = true;
const containerRect = container.getBoundingClientRect();
const centerX = pageState.watermarkX * containerRect.width;
const centerY = pageState.watermarkY * containerRect.height;
const pointerX = e.clientX - containerRect.left;
const pointerY = e.clientY - containerRect.top;
resizeStartDistance = Math.max(
Math.hypot(pointerX - centerX, pointerY - centerY),
10
);
if (watermarkType === 'text') {
resizeStartFontSize =
parseInt(
(document.getElementById('font-size') as HTMLInputElement).value
) || 72;
} else {
resizeStartImageScale =
parseInt(
(document.getElementById('image-scale') as HTMLInputElement).value
) || 100;
}
container.setPointerCapture(e.pointerId);
e.preventDefault();
return;
}
if (!box.contains(target)) return;
isDragging = true;
const rect = box.getBoundingClientRect();
dragOffsetX = e.clientX - rect.left - rect.width / 2;
dragOffsetY = e.clientY - rect.top - rect.height / 2;
container.setPointerCapture(e.pointerId);
e.preventDefault();
}
function onPointerMove(e: PointerEvent) {
if (isResizing) {
const containerRect = container.getBoundingClientRect();
const centerX = pageState.watermarkX * containerRect.width;
const centerY = pageState.watermarkY * containerRect.height;
const pointerX = e.clientX - containerRect.left;
const pointerY = e.clientY - containerRect.top;
const currentDistance = Math.hypot(
pointerX - centerX,
pointerY - centerY
);
const ratio = currentDistance / resizeStartDistance;
if (watermarkType === 'text') {
const newSize = Math.max(
10,
Math.min(200, Math.round(resizeStartFontSize * ratio))
);
const fontSizeInput = document.getElementById(
'font-size'
) as HTMLInputElement;
fontSizeInput.value = String(newSize);
} else {
const newScale = Math.max(
10,
Math.min(200, Math.round(resizeStartImageScale * ratio))
);
const scaleInput = document.getElementById(
'image-scale'
) as HTMLInputElement;
scaleInput.value = String(newScale);
const scaleValue = document.getElementById('image-scale-value');
if (scaleValue) scaleValue.textContent = String(newScale);
}
updateWatermarkOverlay();
e.preventDefault();
return;
}
if (!isDragging) return;
const containerRect = container.getBoundingClientRect();
const x = e.clientX - containerRect.left - dragOffsetX;
const y = e.clientY - containerRect.top - dragOffsetY;
const clampedX = Math.max(0, Math.min(x, containerRect.width));
const clampedY = Math.max(0, Math.min(y, containerRect.height));
pageState.watermarkX = clampedX / containerRect.width;
pageState.watermarkY = clampedY / containerRect.height;
updatePresetHighlight(-1, -1);
updateWatermarkOverlay();
e.preventDefault();
}
function onPointerUp() {
isDragging = false;
isResizing = false;
}
container.addEventListener('pointerdown', onPointerDown);
container.addEventListener('pointermove', onPointerMove);
container.addEventListener('pointerup', onPointerUp);
container.addEventListener('pointercancel', onPointerUp);
}
async function applyWatermark() {
if (!pageState.pdfDoc || !pageState.pdfBytes) {
showAlert('Error', 'Please upload a PDF file first.');
return;
}
showLoader('Adding watermark...');
try {
saveCurrentPageConfig();
const pdfBytes = new Uint8Array(await pageState.pdfDoc.save());
let resultBytes = pdfBytes;
if (applyToAllPages) {
const config = getCurrentConfig();
const posY = 1 - config.y;
const pageRangeStr =
(
document.getElementById('page-range-input') as HTMLInputElement
)?.value.trim() || 'all';
const pageIndices =
pageRangeStr.toLowerCase() === 'all'
? undefined
: parsePageRange(pageRangeStr, pageState.pdfDoc!.getPageCount());
if (config.type === 'text') {
const text = config.text;
if (!text.trim())
throw new Error(trText('Please enter text for the watermark.'));
const textColor = hexToRgb(config.color);
resultBytes = new Uint8Array(
await addTextWatermark(resultBytes, {
text,
fontSize: config.fontSize,
color: textColor,
opacity: config.opacityText,
angle: -config.angleText,
x: config.x,
y: posY,
pageIndices,
tile: config.layout === 'tile',
tileGapX: config.tileGapX / 100,
tileGapY: config.tileGapY / 100,
})
);
} else {
const imageFile = config.imageFile;
if (!imageFile)
throw new Error(
trText('Please select an image file for the watermark.')
);
const imageBytes = await readFileAsArrayBuffer(imageFile);
let imageType: 'png' | 'jpg';
if (imageFile.type === 'image/png') {
imageType = 'png';
} else if (imageFile.type === 'image/jpeg') {
imageType = 'jpg';
} else {
throw new Error(
trText(
'Unsupported Image. Please use a PNG or JPG for the watermark.'
)
);
}
resultBytes = new Uint8Array(
await addImageWatermark(resultBytes, {
imageBytes: new Uint8Array(imageBytes as ArrayBuffer),
imageType,
opacity: config.opacityImage,
angle: -config.angleImage,
scale: config.imageScale / 100,
x: config.x,
y: posY,
pageIndices,
tile: config.layout === 'tile',
tileGapX: config.tileGapX / 100,
tileGapY: config.tileGapY / 100,
})
);
}
} else {
const configGroups: Map<
string,
{ config: PageWatermarkConfig; indices: number[] }
> = new Map();
for (let i = 1; i <= totalPageCount; i++) {
const config = pageWatermarks.get(i);
if (!config) continue;
const hasContent =
config.type === 'text'
? config.text.trim().length > 0
: config.imageFile !== null;
if (!hasContent) continue;
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,
fontSize: config.fontSize,
color: config.color,
opacityText: config.opacityText,
angleText: config.angleText,
imageScale: config.imageScale,
opacityImage: config.opacityImage,
angleImage: config.angleImage,
imageFileName: config.imageFile?.name || null,
});
if (!configGroups.has(key)) {
configGroups.set(key, { config, indices: [] });
}
configGroups.get(key)!.indices.push(i - 1);
}
for (const { config, indices } of configGroups.values()) {
const posY = 1 - config.y;
if (config.type === 'text') {
const textColor = hexToRgb(config.color);
resultBytes = new Uint8Array(
await addTextWatermark(resultBytes, {
text: config.text,
fontSize: config.fontSize,
color: textColor,
opacity: config.opacityText,
angle: -config.angleText,
x: config.x,
y: posY,
pageIndices: indices,
tile: config.layout === 'tile',
tileGapX: config.tileGapX / 100,
tileGapY: config.tileGapY / 100,
})
);
} else {
if (!config.imageFile) continue;
const imageBytes = await readFileAsArrayBuffer(config.imageFile);
let imageType: 'png' | 'jpg';
if (config.imageFile.type === 'image/png') {
imageType = 'png';
} else if (config.imageFile.type === 'image/jpeg') {
imageType = 'jpg';
} else {
continue;
}
resultBytes = new Uint8Array(
await addImageWatermark(resultBytes, {
imageBytes: new Uint8Array(imageBytes as ArrayBuffer),
imageType,
opacity: config.opacityImage,
angle: -config.angleImage,
scale: config.imageScale / 100,
x: config.x,
y: posY,
pageIndices: indices,
tile: config.layout === 'tile',
tileGapX: config.tileGapX / 100,
tileGapY: config.tileGapY / 100,
})
);
}
}
}
const flattenCheckbox = document.getElementById(
'flatten-watermark'
) as HTMLInputElement;
if (flattenCheckbox?.checked) {
const watermarkedPdf = await getPDFDocument({
data: resultBytes.slice(),
}).promise;
const flattenedDoc = await PDFLibDocument.create();
const totalPages = watermarkedPdf.numPages;
const renderScale = 2.5;
for (let i = 1; i <= totalPages; i++) {
showLoader(
trText('Flattening page {{i}} of {{totalPages}}...', {
i,
totalPages,
})
);
const page = await watermarkedPdf.getPage(i);
const unscaledVP = page.getViewport({ scale: 1 });
const viewport = page.getViewport({ scale: renderScale });
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({ canvasContext: ctx, canvas, viewport }).promise;
const jpegBytes = await new Promise<ArrayBuffer>((resolve) =>
canvas.toBlob(
(blob) => blob?.arrayBuffer().then(resolve),
'image/jpeg',
0.92
)
);
const image = await flattenedDoc.embedJpg(jpegBytes);
const newPage = flattenedDoc.addPage([
unscaledVP.width,
unscaledVP.height,
]);
newPage.drawImage(image, {
x: 0,
y: 0,
width: unscaledVP.width,
height: unscaledVP.height,
});
}
resultBytes = new Uint8Array(await flattenedDoc.save());
}
downloadFile(
new Blob([new Uint8Array(resultBytes)], { type: 'application/pdf' }),
pageState.file?.name || 'document.pdf'
);
showAlert('Success', 'Watermark added successfully!', 'success');
} catch (e: unknown) {
console.error(e);
showAlert(
'Error',
e instanceof Error ? e.message : 'Could not add the watermark.'
);
} finally {
hideLoader();
}
}