diff --git a/.env.example b/.env.example index 2cce17b..3b1655d 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,7 @@ VITE_CORS_PROXY_SECRET= # WASM Module URLs # Pre-configured defaults enable advanced PDF features out of the box. # For air-gapped / offline deployments, point these to your internal server (e.g., /wasm/pymupdf/). -VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.14/ +VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/ VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/ VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/ diff --git a/README.md b/README.md index f74ca79..4dd1787 100644 --- a/README.md +++ b/README.md @@ -458,7 +458,7 @@ Advanced PDF features (PyMuPDF, Ghostscript, CoherentPDF) are pre-configured to The default URLs are set in `.env.production`: ```bash -VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.14/ +VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/ VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/ VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/ ``` diff --git a/cloudflare/WASM-PROXY.md b/cloudflare/WASM-PROXY.md index c7ee907..fa6963d 100644 --- a/cloudflare/WASM-PROXY.md +++ b/cloudflare/WASM-PROXY.md @@ -28,7 +28,7 @@ npx wrangler secret put CPDF_SOURCE -c wasm-wrangler.toml **Recommended Source URLs:** -- PYMUPDF_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.14/` +- PYMUPDF_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/` - GS_SOURCE: `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/` - CPDF_SOURCE: `https://cdn.jsdelivr.net/npm/coherentpdf/dist/` diff --git a/docs/self-hosting/docker.md b/docs/self-hosting/docker.md index 2ce4f7f..4e9c1ac 100644 --- a/docs/self-hosting/docker.md +++ b/docs/self-hosting/docker.md @@ -92,7 +92,7 @@ docker run -d -p 3000:8080 bentopdf:custom | ----------------------- | ------------------------------- | -------------------------------------------------------------- | | `SIMPLE_MODE` | Build without LibreOffice tools | `false` | | `BASE_URL` | Deploy to subdirectory | `/` | -| `VITE_WASM_PYMUPDF_URL` | PyMuPDF WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.14/` | +| `VITE_WASM_PYMUPDF_URL` | PyMuPDF WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/` | | `VITE_WASM_GS_URL` | Ghostscript WASM module URL | `https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/` | | `VITE_WASM_CPDF_URL` | CoherentPDF WASM module URL | `https://cdn.jsdelivr.net/npm/coherentpdf/dist/` | | `VITE_DEFAULT_LANGUAGE` | Default UI language | `en` | diff --git a/docs/self-hosting/index.md b/docs/self-hosting/index.md index a08c18c..d1ac849 100644 --- a/docs/self-hosting/index.md +++ b/docs/self-hosting/index.md @@ -167,7 +167,7 @@ As of v2.0.0, WASM modules are pre-configured to load from jsDelivr CDN via envi These are set in `.env.production` and baked into the build: ```bash -VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.14/ +VITE_WASM_PYMUPDF_URL=https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/ VITE_WASM_GS_URL=https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/ VITE_WASM_CPDF_URL=https://cdn.jsdelivr.net/npm/coherentpdf/dist/ ``` diff --git a/public/locales/ar/tools.json b/public/locales/ar/tools.json index 86edbc0..18a6f04 100644 --- a/public/locales/ar/tools.json +++ b/public/locales/ar/tools.json @@ -92,7 +92,8 @@ }, "addWatermark": { "name": "إضافة علامة مائية", - "subtitle": "ختم نص أو صورة على صفحات PDF الخاصة بك." + "subtitle": "ختم نص أو صورة على صفحات PDF الخاصة بك.", + "applyToAllPages": "تطبيق على جميع الصفحات" }, "headerFooter": { "name": "رأس وتذييل", diff --git a/public/locales/be/tools.json b/public/locales/be/tools.json index 3383baf..7026407 100644 --- a/public/locales/be/tools.json +++ b/public/locales/be/tools.json @@ -92,7 +92,8 @@ }, "addWatermark": { "name": "Дадаць вадзяны знак", - "subtitle": "Накласці на старонкі PDF тэкст або відарыс." + "subtitle": "Накласці на старонкі PDF тэкст або відарыс.", + "applyToAllPages": "Прымяніць да ўсіх старонак" }, "headerFooter": { "name": "Верхні і ніжні калантытул", diff --git a/public/locales/da/tools.json b/public/locales/da/tools.json index c04d4aa..ffaa16b 100644 --- a/public/locales/da/tools.json +++ b/public/locales/da/tools.json @@ -92,7 +92,8 @@ }, "addWatermark": { "name": "Tilføj vandmærke", - "subtitle": "Placer tekst eller et billede oven på dine PDF-sider." + "subtitle": "Placer tekst eller et billede oven på dine PDF-sider.", + "applyToAllPages": "Anvend på alle sider" }, "headerFooter": { "name": "Sidehoved og sidefod", diff --git a/public/locales/de/tools.json b/public/locales/de/tools.json index 9455c0c..8c5335f 100644 --- a/public/locales/de/tools.json +++ b/public/locales/de/tools.json @@ -92,7 +92,8 @@ }, "addWatermark": { "name": "Wasserzeichen hinzufügen", - "subtitle": "Text oder ein Bild über Ihre PDF-Seiten stempeln." + "subtitle": "Text oder ein Bild über Ihre PDF-Seiten stempeln.", + "applyToAllPages": "Auf alle Seiten anwenden" }, "headerFooter": { "name": "Kopf- & Fußzeile", diff --git a/public/locales/en/tools.json b/public/locales/en/tools.json index 514ff3d..8c99513 100644 --- a/public/locales/en/tools.json +++ b/public/locales/en/tools.json @@ -92,7 +92,8 @@ }, "addWatermark": { "name": "Add Watermark", - "subtitle": "Stamp text or an image over your PDF pages." + "subtitle": "Stamp text or an image over your PDF pages.", + "applyToAllPages": "Apply to all pages" }, "headerFooter": { "name": "Header & Footer", diff --git a/public/locales/es/tools.json b/public/locales/es/tools.json index d1e37cf..be3ec61 100644 --- a/public/locales/es/tools.json +++ b/public/locales/es/tools.json @@ -92,7 +92,8 @@ }, "addWatermark": { "name": "Agregar Marca de Agua", - "subtitle": "Estampa texto o una imagen sobre tus páginas PDF." + "subtitle": "Estampa texto o una imagen sobre tus páginas PDF.", + "applyToAllPages": "Aplicar a todas las páginas" }, "headerFooter": { "name": "Encabezado y Pie de Página", diff --git a/public/locales/fr/tools.json b/public/locales/fr/tools.json index f969063..8223229 100644 --- a/public/locales/fr/tools.json +++ b/public/locales/fr/tools.json @@ -92,7 +92,8 @@ }, "addWatermark": { "name": "Ajouter un filigrane", - "subtitle": "Apposer un texte ou une image sur les pages du PDF." + "subtitle": "Apposer un texte ou une image sur les pages du PDF.", + "applyToAllPages": "Appliquer à toutes les pages" }, "headerFooter": { "name": "En-tête et pied de page", diff --git a/public/locales/id/tools.json b/public/locales/id/tools.json index c10b9f1..96f55a6 100644 --- a/public/locales/id/tools.json +++ b/public/locales/id/tools.json @@ -92,7 +92,8 @@ }, "addWatermark": { "name": "Tambah Watermark", - "subtitle": "Cap teks atau gambar di atas halaman PDF Anda." + "subtitle": "Cap teks atau gambar di atas halaman PDF Anda.", + "applyToAllPages": "Terapkan ke semua halaman" }, "headerFooter": { "name": "Header & Footer", diff --git a/public/locales/it/tools.json b/public/locales/it/tools.json index 7b08637..3bb29a1 100644 --- a/public/locales/it/tools.json +++ b/public/locales/it/tools.json @@ -92,7 +92,8 @@ }, "addWatermark": { "name": "Aggiungi Filigrana", - "subtitle": "Applica testo o un'immagine sulle pagine del tuo PDF." + "subtitle": "Applica testo o un'immagine sulle pagine del tuo PDF.", + "applyToAllPages": "Applica a tutte le pagine" }, "headerFooter": { "name": "Intestazione e Piè di Pagina", diff --git a/public/locales/nl/tools.json b/public/locales/nl/tools.json index 0175307..2a8cf28 100644 --- a/public/locales/nl/tools.json +++ b/public/locales/nl/tools.json @@ -92,7 +92,8 @@ }, "addWatermark": { "name": "Watermerk toevoegen", - "subtitle": "Tekst of een afbeelding over de pagina's van je PDF stempelen." + "subtitle": "Tekst of een afbeelding over de pagina's van je PDF stempelen.", + "applyToAllPages": "Toepassen op alle pagina's" }, "headerFooter": { "name": "Koptekst & Voettekst", diff --git a/public/locales/pt/tools.json b/public/locales/pt/tools.json index 69617ea..c3608ec 100644 --- a/public/locales/pt/tools.json +++ b/public/locales/pt/tools.json @@ -70,7 +70,8 @@ }, "addWatermark": { "name": "Adicionar Marca d'Água", - "subtitle": "Carimbe texto ou uma imagem sobre as páginas do seu PDF." + "subtitle": "Carimbe texto ou uma imagem sobre as páginas do seu PDF.", + "applyToAllPages": "Aplicar a todas as páginas" }, "headerFooter": { "name": "Cabeçalho e Rodapé", diff --git a/public/locales/tr/tools.json b/public/locales/tr/tools.json index 2516427..17a7bc8 100644 --- a/public/locales/tr/tools.json +++ b/public/locales/tr/tools.json @@ -70,7 +70,8 @@ }, "addWatermark": { "name": "Filigran Ekle", - "subtitle": "PDF sayfalarınızın üzerine metin veya görsel damgası ekleyin." + "subtitle": "PDF sayfalarınızın üzerine metin veya görsel damgası ekleyin.", + "applyToAllPages": "Tüm sayfalara uygula" }, "headerFooter": { "name": "Üst Bilgi & Alt Bilgi", diff --git a/public/locales/vi/tools.json b/public/locales/vi/tools.json index ff57044..c415c69 100644 --- a/public/locales/vi/tools.json +++ b/public/locales/vi/tools.json @@ -92,7 +92,8 @@ }, "addWatermark": { "name": "Thêm Watermark", - "subtitle": "Đóng dấu văn bản hoặc hình ảnh lên các trang PDF của bạn." + "subtitle": "Đóng dấu văn bản hoặc hình ảnh lên các trang PDF của bạn.", + "applyToAllPages": "Áp dụng cho tất cả các trang" }, "headerFooter": { "name": "Đầu trang & Chân trang", diff --git a/public/locales/zh-TW/tools.json b/public/locales/zh-TW/tools.json index 12f2a55..016486f 100644 --- a/public/locales/zh-TW/tools.json +++ b/public/locales/zh-TW/tools.json @@ -70,7 +70,8 @@ }, "addWatermark": { "name": "添加浮水印", - "subtitle": "在你的 PDF 頁面上壓印文字或圖片。" + "subtitle": "在你的 PDF 頁面上壓印文字或圖片。", + "applyToAllPages": "套用至所有頁面" }, "headerFooter": { "name": "頁首與頁尾", diff --git a/public/locales/zh/tools.json b/public/locales/zh/tools.json index 13feb21..ef863db 100644 --- a/public/locales/zh/tools.json +++ b/public/locales/zh/tools.json @@ -92,7 +92,8 @@ }, "addWatermark": { "name": "添加水印", - "subtitle": "在您的 PDF 页面上添加文字或图片水印。" + "subtitle": "在您的 PDF 页面上添加文字或图片水印。", + "applyToAllPages": "应用到所有页面" }, "headerFooter": { "name": "页眉和页脚", diff --git a/src/css/styles.css b/src/css/styles.css index 11abd82..304a523 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -701,6 +701,33 @@ button:disabled, pointer-events: none; } +/* Color picker */ +input[type='color'] { + width: 2.5rem; + height: 2.5rem; + padding: 0; + border: 2px solid #4b5563; + border-radius: 9999px; + background: transparent; + cursor: pointer; + appearance: none; + -webkit-appearance: none; +} + +input[type='color']::-webkit-color-swatch-wrapper { + padding: 0; +} + +input[type='color']::-webkit-color-swatch { + border: none; + border-radius: 9999px; +} + +input[type='color']::-moz-color-swatch { + border: none; + border-radius: 9999px; +} + /* Hide spin buttons for number inputs */ input[type='number'] { appearance: none; diff --git a/src/js/const/cdn-version.ts b/src/js/const/cdn-version.ts index 01ec32e..3b8fe31 100644 --- a/src/js/const/cdn-version.ts +++ b/src/js/const/cdn-version.ts @@ -1,4 +1,4 @@ export const PACKAGE_VERSIONS = { ghostscript: '0.1.1', - pymupdf: '0.11.14', + pymupdf: '0.11.16', } as const; diff --git a/src/js/logic/add-watermark-page.ts b/src/js/logic/add-watermark-page.ts index 569e58c..eb6431f 100644 --- a/src/js/logic/add-watermark-page.ts +++ b/src/js/logic/add-watermark-page.ts @@ -10,10 +10,60 @@ import { PDFDocument as PDFLibDocument } from 'pdf-lib'; import { addTextWatermark, addImageWatermark, + parsePageRange, } from '../utils/pdf-operations.js'; import { AddWatermarkState } from '@/types'; +import * as pdfjsLib from 'pdfjs-dist'; -const pageState: AddWatermarkState = { file: null, pdfDoc: null }; +pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url +).toString(); + +interface PageWatermarkConfig { + type: 'text' | 'image'; + x: number; + y: number; + text: string; + fontSize: number; + color: string; + opacityText: number; + angleText: number; + imageDataUrl: string | null; + imageFile: File | null; + imageScale: number; + opacityImage: number; + angleImage: number; +} + +const pageState: AddWatermarkState = { + file: null, + pdfDoc: null, + pdfBytes: null, + previewCanvas: null, + watermarkX: 0.5, + watermarkY: 0.5, +}; + +let watermarkType: 'text' | 'image' = 'text'; +let imageWatermarkDataUrl: string | null = null; +let imageWatermarkFile: File | 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 = new Map(); +let applyToAllPages = true; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializePage); @@ -27,6 +77,7 @@ function initializePage() { 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) { @@ -55,9 +106,18 @@ function initializePage() { backBtn.addEventListener('click', () => { window.location.href = import.meta.env.BASE_URL; }); - if (processBtn) processBtn.addEventListener('click', addWatermark); - setupWatermarkUI(); + 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) { @@ -74,10 +134,30 @@ async function handleFiles(files: FileList) { showLoader('Loading PDF...'); try { const arrayBuffer = await file.arrayBuffer(); - pageState.pdfDoc = await PDFLibDocument.load(arrayBuffer); + const pdfBytes = new Uint8Array(arrayBuffer); + pageState.pdfDoc = await PDFLibDocument.load(pdfBytes); pageState.file = file; + pageState.pdfBytes = pdfBytes; + + cachedPdfjsDoc = await pdfjsLib.getDocument({ data: pdfBytes.slice() }) + .promise; + totalPageCount = cachedPdfjsDoc.numPages; + currentPageNum = 1; + pageWatermarks.clear(); + updateFileDisplay(); - document.getElementById('options-panel')?.classList.remove('hidden'); + + document.getElementById('uploader')?.classList.add('hidden'); + document.getElementById('editor-panel')?.classList.remove('hidden'); + + const editorFileInfo = document.getElementById('editor-file-info'); + if (editorFileInfo) { + editorFileInfo.textContent = `${file.name} (${formatBytes(file.size)}, ${totalPageCount} pages)`; + } + + updatePageNavUI(); + await renderPreview(); + updateWatermarkOverlay(); } catch (error) { console.error(error); showAlert('Error', 'Failed to load PDF file.'); @@ -114,160 +194,839 @@ function updateFileDisplay() { 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; + cachedPdfjsDoc = null; + currentPageNum = 1; + totalPageCount = 1; + pageWatermarks.clear(); const fileDisplayArea = document.getElementById('file-display-area'); if (fileDisplayArea) fileDisplayArea.innerHTML = ''; - document.getElementById('options-panel')?.classList.add('hidden'); const fileInput = document.getElementById('file-input') as HTMLInputElement; if (fileInput) fileInput.value = ''; } -function setupWatermarkUI() { - const watermarkTypeRadios = document.querySelectorAll( - 'input[name="watermark-type"]' - ); - const textOptions = document.getElementById('text-watermark-options'); - const imageOptions = document.getElementById('image-watermark-options'); +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; - watermarkTypeRadios.forEach((radio) => { - radio.addEventListener('change', (e) => { - const target = e.target as HTMLInputElement; - if (target.value === 'text') { - textOptions?.classList.remove('hidden'); - imageOptions?.classList.add('hidden'); - } else { - textOptions?.classList.add('hidden'); - imageOptions?.classList.remove('hidden'); + 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 }); + } } - }); - }); - - const opacitySliderText = document.getElementById( - 'opacity-text' - ) as HTMLInputElement; - const opacityValueText = document.getElementById('opacity-value-text'); - const angleSliderText = document.getElementById( - 'angle-text' - ) as HTMLInputElement; - const angleValueText = document.getElementById('angle-value-text'); - - opacitySliderText?.addEventListener('input', () => { - if (opacityValueText) - opacityValueText.textContent = opacitySliderText.value; - }); - angleSliderText?.addEventListener('input', () => { - if (angleValueText) angleValueText.textContent = angleSliderText.value; - }); - - const opacitySliderImage = document.getElementById( - 'opacity-image' - ) as HTMLInputElement; - const opacityValueImage = document.getElementById('opacity-value-image'); - const angleSliderImage = document.getElementById( - 'angle-image' - ) as HTMLInputElement; - const angleValueImage = document.getElementById('angle-value-image'); - - opacitySliderImage?.addEventListener('input', () => { - if (opacityValueImage) - opacityValueImage.textContent = opacitySliderImage.value; - }); - angleSliderImage?.addEventListener('input', () => { - if (angleValueImage) angleValueImage.textContent = angleSliderImage.value; + } }); } -async function addWatermark() { - if (!pageState.pdfDoc) { +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 getDefaultConfig(): PageWatermarkConfig { + return { + type: 'text', + 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, + 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; + pageState.watermarkX = config.x; + pageState.watermarkY = config.y; + imageWatermarkDataUrl = config.imageDataUrl; + imageWatermarkFile = config.imageFile; + + 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 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; + 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 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; + + 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; } - const watermarkType = - ( - document.querySelector( - 'input[name="watermark-type"]:checked' - ) as HTMLInputElement - )?.value || 'text'; showLoader('Adding watermark...'); try { + saveCurrentPageConfig(); const pdfBytes = new Uint8Array(await pageState.pdfDoc.save()); - let resultBytes: Uint8Array; + let resultBytes = pdfBytes; - if (watermarkType === 'text') { - const text = ( - document.getElementById('watermark-text') as HTMLInputElement - ).value; - if (!text.trim()) throw new Error('Please enter text for the watermark.'); - const fontSize = - parseInt( - (document.getElementById('font-size') as HTMLInputElement).value - ) || 72; - const angle = - parseInt( - (document.getElementById('angle-text') as HTMLInputElement).value - ) || 0; - const opacity = - parseFloat( - (document.getElementById('opacity-text') as HTMLInputElement).value - ) || 0.3; - const colorHex = ( - document.getElementById('text-color') as HTMLInputElement - ).value; - const textColor = hexToRgb(colorHex); + if (applyToAllPages) { + const config = getCurrentConfig(); + const posY = 1 - config.y; - resultBytes = await addTextWatermark(pdfBytes, { - text, - fontSize, - color: textColor, - opacity, - angle, - }); - } else { - const imageFile = ( - document.getElementById('image-watermark-input') as HTMLInputElement - ).files?.[0]; - if (!imageFile) - throw new Error('Please select an image file for the watermark.'); - const imageBytes = await readFileAsArrayBuffer(imageFile); - const angle = - parseInt( - (document.getElementById('angle-image') as HTMLInputElement).value - ) || 0; - const opacity = - parseFloat( - (document.getElementById('opacity-image') as HTMLInputElement).value - ) || 0.3; + const pageRangeStr = + ( + document.getElementById('page-range-input') as HTMLInputElement + )?.value.trim() || 'all'; + const pageIndices = + pageRangeStr.toLowerCase() === 'all' + ? undefined + : parsePageRange(pageRangeStr, pageState.pdfDoc!.getPageCount()); - let imageType: 'png' | 'jpg'; - if (imageFile.type === 'image/png') { - imageType = 'png'; - } else if (imageFile.type === 'image/jpeg') { - imageType = 'jpg'; + if (config.type === 'text') { + const text = config.text; + if (!text.trim()) + throw new Error('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, + }) + ); } else { - throw new Error( - 'Unsupported Image. Please use a PNG or JPG for the watermark.' + const imageFile = config.imageFile; + if (!imageFile) + throw new Error('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( + '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, + }) ); } + } else { + const configGroups: Map< + string, + { config: PageWatermarkConfig; indices: number[] } + > = new Map(); - resultBytes = await addImageWatermark(pdfBytes, { - imageBytes: new Uint8Array(imageBytes as ArrayBuffer), - imageType, - opacity, - angle, - scale: 1.0, - }); + 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, + 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, + }) + ); + } 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, + }) + ); + } + } + } + + const flattenCheckbox = document.getElementById( + 'flatten-watermark' + ) as HTMLInputElement; + if (flattenCheckbox?.checked) { + const watermarkedPdf = await pdfjsLib.getDocument({ + 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(`Flattening page ${i} of ${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((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([resultBytes.buffer as ArrayBuffer], { - type: 'application/pdf', - }), + new Blob([new Uint8Array(resultBytes)], { type: 'application/pdf' }), 'watermarked.pdf' ); - showAlert('Success', 'Watermark added successfully!', 'success', () => { - resetState(); - }); + showAlert('Success', 'Watermark added successfully!', 'success'); } catch (e: any) { console.error(e); showAlert('Error', e.message || 'Could not add the watermark.'); diff --git a/src/js/logic/bookmark-pdf.ts b/src/js/logic/bookmark-pdf.ts index 2c40d0e..98bfe56 100644 --- a/src/js/logic/bookmark-pdf.ts +++ b/src/js/logic/bookmark-pdf.ts @@ -253,7 +253,7 @@ placeholder="${field.placeholder || ''}" /> ) .join('')} - ${field.name === 'color' ? '' : ''} + ${field.name === 'color' ? '' : ''} `; } else if (field.type === 'destination') { diff --git a/src/js/logic/form-creator.ts b/src/js/logic/form-creator.ts index 98854b7..b914b69 100644 --- a/src/js/logic/form-creator.ts +++ b/src/js/logic/form-creator.ts @@ -931,7 +931,7 @@ function showProperties(field: FormField): void {
- +
@@ -1169,7 +1169,7 @@ function showProperties(field: FormField): void {
- +
diff --git a/src/js/types/add-watermark-type.ts b/src/js/types/add-watermark-type.ts index 583d112..0fef71d 100644 --- a/src/js/types/add-watermark-type.ts +++ b/src/js/types/add-watermark-type.ts @@ -1,6 +1,10 @@ import { PDFDocument as PDFLibDocument } from 'pdf-lib'; export interface AddWatermarkState { - file: File | null; - pdfDoc: PDFLibDocument | null; -} \ No newline at end of file + file: File | null; + pdfDoc: PDFLibDocument | null; + pdfBytes: Uint8Array | null; + previewCanvas: HTMLCanvasElement | null; + watermarkX: number; // 0–1, percentage from left + watermarkY: number; // 0–1, percentage from top (flipped to bottom for PDF) +} diff --git a/src/js/utils/pdf-operations.ts b/src/js/utils/pdf-operations.ts index 047bc37..a4fd290 100644 --- a/src/js/utils/pdf-operations.ts +++ b/src/js/utils/pdf-operations.ts @@ -187,6 +187,9 @@ export interface TextWatermarkOptions { color: { r: number; g: number; b: number }; opacity: number; angle: number; + x?: number; + y?: number; + pageIndices?: number[]; } export async function addTextWatermark( @@ -194,19 +197,60 @@ export async function addTextWatermark( options: TextWatermarkOptions ): Promise { const pdfDoc = await PDFDocument.load(pdfBytes); - const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Failed to create canvas context'); + + const dpr = 2; + const colorR = Math.round(options.color.r * 255); + const colorG = Math.round(options.color.g * 255); + const colorB = Math.round(options.color.b * 255); + const fontStr = `bold ${options.fontSize * dpr}px "Noto Sans SC", "Noto Sans JP", "Noto Sans KR", "Noto Sans Arabic", Arial, sans-serif`; + + ctx.font = fontStr; + const metrics = ctx.measureText(options.text); + + canvas.width = Math.ceil(metrics.width) + 4; + canvas.height = Math.ceil(options.fontSize * dpr * 1.4); + + ctx.font = fontStr; + ctx.fillStyle = `rgb(${colorR}, ${colorG}, ${colorB})`; + ctx.textBaseline = 'middle'; + ctx.fillText(options.text, 2, canvas.height / 2); + + const blob = await new Promise((resolve, reject) => { + canvas.toBlob( + (b) => (b ? resolve(b) : reject(new Error('Canvas toBlob failed'))), + 'image/png' + ); + }); + const imageBytes = new Uint8Array(await blob.arrayBuffer()); + + const image = await pdfDoc.embedPng(imageBytes); const pages = pdfDoc.getPages(); + const posX = options.x ?? 0.5; + const posY = options.y ?? 0.5; + const imgWidth = image.width / dpr; + const imgHeight = image.height / dpr; - for (const page of pages) { + const rad = (options.angle * Math.PI) / 180; + const halfW = imgWidth / 2; + const halfH = imgHeight / 2; + + const targetIndices = options.pageIndices ?? pages.map((_, i) => i); + for (const idx of targetIndices) { + const page = pages[idx]; + if (!page) continue; const { width, height } = page.getSize(); - const textWidth = font.widthOfTextAtSize(options.text, options.fontSize); + const cx = posX * width; + const cy = posY * height; - page.drawText(options.text, { - x: (width - textWidth) / 2, - y: height / 2, - font, - size: options.fontSize, - color: rgb(options.color.r, options.color.g, options.color.b), + page.drawImage(image, { + x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH, + y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH, + width: imgWidth, + height: imgHeight, opacity: options.opacity, rotate: degrees(options.angle), }); @@ -221,6 +265,9 @@ export interface ImageWatermarkOptions { opacity: number; angle: number; scale: number; + x?: number; + y?: number; + pageIndices?: number[]; } export async function addImageWatermark( @@ -233,15 +280,26 @@ export async function addImageWatermark( ? await pdfDoc.embedPng(options.imageBytes) : await pdfDoc.embedJpg(options.imageBytes); const pages = pdfDoc.getPages(); + const posX = options.x ?? 0.5; + const posY = options.y ?? 0.5; - for (const page of pages) { + const imgWidth = image.width * options.scale; + const imgHeight = image.height * options.scale; + const rad = (options.angle * Math.PI) / 180; + const halfW = imgWidth / 2; + const halfH = imgHeight / 2; + + const targetIndices = options.pageIndices ?? pages.map((_, i) => i); + for (const idx of targetIndices) { + const page = pages[idx]; + if (!page) continue; const { width, height } = page.getSize(); - const imgWidth = image.width * options.scale; - const imgHeight = image.height * options.scale; + const cx = posX * width; + const cy = posY * height; page.drawImage(image, { - x: (width - imgWidth) / 2, - y: (height - imgHeight) / 2, + x: cx - Math.cos(rad) * halfW + Math.sin(rad) * halfH, + y: cy - Math.sin(rad) * halfW - Math.cos(rad) * halfH, width: imgWidth, height: imgHeight, opacity: options.opacity, diff --git a/src/js/utils/wasm-provider.ts b/src/js/utils/wasm-provider.ts index c17415a..a6bb1c5 100644 --- a/src/js/utils/wasm-provider.ts +++ b/src/js/utils/wasm-provider.ts @@ -9,7 +9,7 @@ interface WasmProviderConfig { const STORAGE_KEY = 'bentopdf:wasm-providers'; const CDN_DEFAULTS: Record = { - pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.14/', + pymupdf: 'https://cdn.jsdelivr.net/npm/@bentopdf/pymupdf-wasm@0.11.16/', ghostscript: 'https://cdn.jsdelivr.net/npm/@bentopdf/gs-wasm/assets/', cpdf: 'https://cdn.jsdelivr.net/npm/coherentpdf/dist/', }; diff --git a/src/js/workflow/nodes/watermark-node.ts b/src/js/workflow/nodes/watermark-node.ts index 8610874..d16eed1 100644 --- a/src/js/workflow/nodes/watermark-node.ts +++ b/src/js/workflow/nodes/watermark-node.ts @@ -3,9 +3,10 @@ import { BaseWorkflowNode } from './base-node'; import { pdfSocket } from '../sockets'; import type { SocketData } from '../types'; import { requirePdfInput, processBatch } from '../types'; -import { addTextWatermark } from '../../utils/pdf-operations'; +import { addTextWatermark, parsePageRange } from '../../utils/pdf-operations'; import { PDFDocument } from 'pdf-lib'; import { hexToRgb } from '../../utils/helpers.js'; +import * as pdfjsLib from 'pdfjs-dist'; export class WatermarkNode extends BaseWorkflowNode { readonly category = 'Edit & Annotate' as const; @@ -39,6 +40,18 @@ export class WatermarkNode extends BaseWorkflowNode { 'angle', new ClassicPreset.InputControl('number', { initial: -45 }) ); + this.addControl( + 'position', + new ClassicPreset.InputControl('text', { initial: 'center' }) + ); + this.addControl( + 'pages', + new ClassicPreset.InputControl('text', { initial: 'all' }) + ); + this.addControl( + 'flatten', + new ClassicPreset.InputControl('text', { initial: 'no' }) + ); } async data( @@ -67,16 +80,89 @@ export class WatermarkNode extends BaseWorkflowNode { const opacity = getNum('opacity', 30) / 100; const angle = getNum('angle', -45); + const positionPresets: Record = { + 'top-left': { x: 0.15, y: 0.15 }, + top: { x: 0.5, y: 0.15 }, + 'top-right': { x: 0.85, y: 0.15 }, + left: { x: 0.15, y: 0.5 }, + center: { x: 0.5, y: 0.5 }, + right: { x: 0.85, y: 0.5 }, + 'bottom-left': { x: 0.15, y: 0.85 }, + bottom: { x: 0.5, y: 0.85 }, + 'bottom-right': { x: 0.85, y: 0.85 }, + }; + const posKey = getText('position', 'center').trim().toLowerCase(); + const { x, y } = positionPresets[posKey] ?? positionPresets['center']; + + const pagesStr = getText('pages', 'all').trim().toLowerCase(); + const shouldFlatten = + getText('flatten', 'no').trim().toLowerCase() === 'yes'; + return { pdf: await processBatch(pdfInputs, async (input) => { - const resultBytes = await addTextWatermark(input.bytes, { + const srcDoc = await PDFDocument.load(input.bytes); + const totalPages = srcDoc.getPageCount(); + + const pageIndices = + pagesStr === 'all' ? undefined : parsePageRange(pagesStr, totalPages); + + let resultBytes = await addTextWatermark(input.bytes, { text: watermarkText, fontSize, color: { r: c.r, g: c.g, b: c.b }, opacity, angle, + x, + y: 1 - y, + pageIndices, }); + if (shouldFlatten) { + const watermarkedPdf = await pdfjsLib.getDocument({ + data: resultBytes.slice(), + }).promise; + const flattenedDoc = await PDFDocument.create(); + const renderScale = 2.5; + + for (let i = 1; i <= watermarkedPdf.numPages; i++) { + const page = await watermarkedPdf.getPage(i); + const unscaledVP = page.getViewport({ scale: 1 }); + const viewport = page.getViewport({ scale: renderScale }); + + const canvas = document.createElement('canvas'); + canvas.width = viewport.width; + canvas.height = viewport.height; + const ctx = canvas.getContext('2d')!; + await page.render({ canvasContext: ctx, canvas, viewport }).promise; + + const jpegBytes = await new Promise( + (resolve, reject) => + canvas.toBlob( + (blob) => + blob + ? blob.arrayBuffer().then(resolve) + : reject(new Error(`Failed to rasterize page ${i}`)), + '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()); + } + const resultDoc = await PDFDocument.load(resultBytes); return { diff --git a/src/pages/add-watermark.html b/src/pages/add-watermark.html index 2bcc63a..77eb7b4 100644 --- a/src/pages/add-watermark.html +++ b/src/pages/add-watermark.html @@ -158,171 +158,406 @@
+ + -
- +
diff --git a/src/pages/digital-sign-pdf.html b/src/pages/digital-sign-pdf.html index a972dfa..c1ac4cd 100644 --- a/src/pages/digital-sign-pdf.html +++ b/src/pages/digital-sign-pdf.html @@ -477,12 +477,7 @@ class="block text-sm font-medium text-gray-300 mb-2" >Text Color - +
- + Color for margins/padding diff --git a/src/pages/header-footer.html b/src/pages/header-footer.html index f2f868e..1cbbeb4 100644 --- a/src/pages/header-footer.html +++ b/src/pages/header-footer.html @@ -202,12 +202,7 @@ class="block mb-2 text-sm font-medium text-gray-300" >Font Color - +
diff --git a/src/pages/n-up-pdf.html b/src/pages/n-up-pdf.html index b34dfb5..4b0fa55 100644 --- a/src/pages/n-up-pdf.html +++ b/src/pages/n-up-pdf.html @@ -253,12 +253,7 @@ class="block mb-2 text-sm font-medium text-gray-300" >Border Color - + diff --git a/src/pages/page-numbers.html b/src/pages/page-numbers.html index f869aac..261f98d 100644 --- a/src/pages/page-numbers.html +++ b/src/pages/page-numbers.html @@ -219,12 +219,7 @@ class="block mb-2 text-sm font-medium text-gray-300" >Color - + diff --git a/src/pages/text-color.html b/src/pages/text-color.html index f28c0f6..24c218f 100644 --- a/src/pages/text-color.html +++ b/src/pages/text-color.html @@ -169,12 +169,7 @@ class="block mb-2 text-sm font-medium text-gray-300" >New Text Color - +