From 38713dc852111cf14dfe8ae89c756b52ec53f210 Mon Sep 17 00:00:00 2001 From: Anton Samper Rivaya Date: Wed, 27 May 2026 10:22:10 +0100 Subject: [PATCH 1/6] initial watermark plugin --- examples/react-mui/package.json | 1 + examples/react-mui/src/application.tsx | 12 + .../src/components/toolbar/index.tsx | 1 + .../src/components/watermark-panel/index.tsx | 342 +++++++ examples/vue-vuetify/components.d.ts | 2 +- .../src/lib/orchestrator/pdf-engine.ts | 14 + .../src/lib/orchestrator/remote-executor.ts | 9 + packages/engines/src/lib/pdfium/engine.ts | 92 ++ packages/engines/src/lib/webworker/engine.ts | 20 + packages/engines/src/lib/webworker/runner.ts | 3 + packages/models/src/pdf.ts | 18 + packages/plugin-watermark/README.md | 136 +++ packages/plugin-watermark/package.json | 52 ++ packages/plugin-watermark/src/index.ts | 2 + packages/plugin-watermark/src/lib/actions.ts | 59 ++ packages/plugin-watermark/src/lib/index.ts | 25 + packages/plugin-watermark/src/lib/manifest.ts | 17 + packages/plugin-watermark/src/lib/reducer.ts | 69 ++ packages/plugin-watermark/src/lib/types.ts | 203 +++++ .../src/lib/watermark-plugin.ts | 859 ++++++++++++++++++ packages/plugin-watermark/tsconfig.json | 16 + packages/plugin-watermark/vite.config.ts | 3 + pnpm-lock.yaml | 281 +++++- 23 files changed, 2196 insertions(+), 40 deletions(-) create mode 100644 examples/react-mui/src/components/watermark-panel/index.tsx create mode 100644 packages/plugin-watermark/README.md create mode 100644 packages/plugin-watermark/package.json create mode 100644 packages/plugin-watermark/src/index.ts create mode 100644 packages/plugin-watermark/src/lib/actions.ts create mode 100644 packages/plugin-watermark/src/lib/index.ts create mode 100644 packages/plugin-watermark/src/lib/manifest.ts create mode 100644 packages/plugin-watermark/src/lib/reducer.ts create mode 100644 packages/plugin-watermark/src/lib/types.ts create mode 100644 packages/plugin-watermark/src/lib/watermark-plugin.ts create mode 100644 packages/plugin-watermark/tsconfig.json create mode 100644 packages/plugin-watermark/vite.config.ts diff --git a/examples/react-mui/package.json b/examples/react-mui/package.json index add504068..6995e2b34 100644 --- a/examples/react-mui/package.json +++ b/examples/react-mui/package.json @@ -32,6 +32,7 @@ "@embedpdf/plugin-history": "workspace:*", "@embedpdf/plugin-annotation": "workspace:*", "@embedpdf/plugin-redaction": "workspace:*", + "@embedpdf/plugin-watermark": "workspace:*", "@embedpdf/utils": "workspace:*", "@embedpdf/models": "workspace:*", "@embedpdf/pdfium": "workspace:*", diff --git a/examples/react-mui/src/application.tsx b/examples/react-mui/src/application.tsx index 61e1fdf70..f9fec5aa2 100644 --- a/examples/react-mui/src/application.tsx +++ b/examples/react-mui/src/application.tsx @@ -37,8 +37,10 @@ import { AnnotationPluginPackage, AnnotationTool, } from '@embedpdf/plugin-annotation/react'; +import { WatermarkPluginPackage } from '@embedpdf/plugin-watermark'; import { CircularProgress, Box, Alert } from '@mui/material'; import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined'; +import BrandingWatermarkOutlinedIcon from '@mui/icons-material/BrandingWatermarkOutlined'; import { useMemo, useRef } from 'react'; import { PageControls } from './components/page-controls'; @@ -49,6 +51,7 @@ import { Toolbar } from './components/toolbar'; import { ViewSidebarReverseIcon } from './icons'; import { AnnotationSelectionMenu } from './components/annotation-selection-menu'; import { RedactionSelectionMenu } from './components/redaction-selection-menu'; +import { WatermarkPanel } from './components/watermark-panel'; const consoleLogger = new ConsoleLogger(); @@ -98,6 +101,7 @@ function App() { width: 120, paddingY: 10, }), + createPluginRegistration(WatermarkPluginPackage), ], [], ); @@ -189,6 +193,14 @@ function App() { position: 'left', props: { documentId: activeDocumentId }, }, + { + id: 'watermark', + component: WatermarkPanel, + icon: BrandingWatermarkOutlinedIcon, + label: 'Watermark', + position: 'right', + props: { documentId: activeDocumentId }, + }, ]; return ( diff --git a/examples/react-mui/src/components/toolbar/index.tsx b/examples/react-mui/src/components/toolbar/index.tsx index 2f31aafd6..14c9bef24 100644 --- a/examples/react-mui/src/components/toolbar/index.tsx +++ b/examples/react-mui/src/components/toolbar/index.tsx @@ -303,6 +303,7 @@ export const Toolbar = ({ documentId }: ToolbarProps) => { + diff --git a/examples/react-mui/src/components/watermark-panel/index.tsx b/examples/react-mui/src/components/watermark-panel/index.tsx new file mode 100644 index 000000000..e3e26d969 --- /dev/null +++ b/examples/react-mui/src/components/watermark-panel/index.tsx @@ -0,0 +1,342 @@ +import { useCallback, useRef, useState } from 'react'; +import { + Box, + Button, + Divider, + FormControl, + FormControlLabel, + InputLabel, + MenuItem, + Radio, + RadioGroup, + Select, + Slider, + Stack, + TextField, + Typography, +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import { useCapability } from '@embedpdf/core/react'; +import { WatermarkPlugin } from '@embedpdf/plugin-watermark'; + +interface WatermarkPanelProps { + documentId: string; +} + +const FONT_OPTIONS = ['Helvetica', 'Times-Roman', 'Courier']; + +export const WatermarkPanel = (_props: WatermarkPanelProps) => { + const { provides: watermarkCapability } = useCapability(WatermarkPlugin.id); + + // Form state + const [watermarkType, setWatermarkType] = useState<'text' | 'image'>('text'); + const [text, setText] = useState('CONFIDENTIAL'); + const [fontSize, setFontSize] = useState(48); + const [fontFamily, setFontFamily] = useState('Helvetica'); + const [colour, setColour] = useState('#FF0000'); + const [opacity, setOpacity] = useState(0.3); + const [posX, setPosX] = useState(100); + const [posY, setPosY] = useState(400); + const [width, setWidth] = useState(400); + const [height, setHeight] = useState(80); + const [rotation, setRotation] = useState(-45); + const [imageData, setImageData] = useState(null); + const [imageName, setImageName] = useState(''); + const [imageMimeType, setImageMimeType] = useState<'image/png' | 'image/jpeg'>('image/png'); + + // Applied watermarks + const [appliedWatermarks, setAppliedWatermarks] = useState< + { id: string; label: string }[] + >([]); + + const fileInputRef = useRef(null); + + const handleImageUpload = useCallback((event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = () => { + setImageData(reader.result as ArrayBuffer); + setImageName(file.name); + setImageMimeType( + file.type === 'image/jpeg' ? 'image/jpeg' : 'image/png', + ); + }; + reader.readAsArrayBuffer(file); + }, []); + + const handleApplyWatermark = useCallback(() => { + if (!watermarkCapability) return; + + if (watermarkType === 'text' && !text.trim()) return; + if (watermarkType === 'image' && !imageData) return; + + const input = + watermarkType === 'text' + ? { + type: 'text' as const, + textOptions: { text, fontSize, fontFamily, colour }, + position: { x: posX, y: posY }, + size: { width, height }, + opacity, + rotation, + pageRange: 'all' as const, + readOnly: true, + printable: true, + } + : { + type: 'image' as const, + imageOptions: { data: imageData!, mimeType: imageMimeType }, + position: { x: posX, y: posY }, + size: { width, height }, + opacity, + rotation, + pageRange: 'all' as const, + readOnly: true, + printable: true, + }; + + watermarkCapability.addWatermark(input).wait( + (id) => { + const label = + watermarkType === 'text' ? `Text: "${text}"` : `Image: ${imageName}`; + setAppliedWatermarks((prev) => [...prev, { id, label }]); + }, + () => { + // Failed silently + }, + ); + }, [ + watermarkCapability, + watermarkType, + text, + fontSize, + fontFamily, + colour, + opacity, + posX, + posY, + width, + height, + rotation, + imageData, + imageName, + imageMimeType, + ]); + + return ( + + + Watermark + + + {/* Watermark type selection */} + + setWatermarkType(e.target.value as 'text' | 'image')} + > + } label="Text" /> + } label="Image" /> + + + + {/* Text-specific options */} + {watermarkType === 'text' && ( + + setText(e.target.value)} + size="small" + fullWidth + /> + + Font + + + setFontSize(Number(e.target.value))} + size="small" + fullWidth + slotProps={{ htmlInput: { min: 8, max: 200 } }} + /> + + + Colour + + setColour(e.target.value)} + style={{ width: '100%', height: 32, border: 'none', cursor: 'pointer' }} + /> + + + )} + + {/* Image-specific options */} + {watermarkType === 'image' && ( + + + + {imageName && ( + + {imageName} + + )} + + )} + + + + {/* Common settings */} + + + + Opacity: {Math.round(opacity * 100)}% + + setOpacity(v as number)} + min={0.05} + max={1} + step={0.05} + size="small" + /> + + + + + Rotation: {rotation}° + + setRotation(v as number)} + min={-180} + max={180} + step={5} + size="small" + /> + + + + Position (PDF points) + + + setPosX(Number(e.target.value))} + size="small" + /> + setPosY(Number(e.target.value))} + size="small" + /> + + + + Size (PDF points) + + + setWidth(Number(e.target.value))} + size="small" + /> + setHeight(Number(e.target.value))} + size="small" + /> + + + + + + {/* Applied watermarks list */} + {appliedWatermarks.length > 0 && ( + <> + + + Applied Watermarks + + + {appliedWatermarks.map((w) => ( + + + {w.label} + + + + ))} + + + )} + + ); +}; + + + diff --git a/examples/vue-vuetify/components.d.ts b/examples/vue-vuetify/components.d.ts index cf3362671..69a555899 100644 --- a/examples/vue-vuetify/components.d.ts +++ b/examples/vue-vuetify/components.d.ts @@ -6,7 +6,7 @@ // Generated by unplugin-vue-components // Read more: https://github.com/vuejs/core/pull/3399 -export {}; +export {} /* prettier-ignore */ declare module 'vue' { diff --git a/packages/engines/src/lib/orchestrator/pdf-engine.ts b/packages/engines/src/lib/orchestrator/pdf-engine.ts index 2b7720bed..7491c398c 100644 --- a/packages/engines/src/lib/orchestrator/pdf-engine.ts +++ b/packages/engines/src/lib/orchestrator/pdf-engine.ts @@ -1023,6 +1023,20 @@ export class PdfEngine implements IPdfEngine { ); } + flattenAnnotationBehind( + doc: PdfDocumentObject, + page: PdfPageObject, + annotation: PdfAnnotationObject, + ): PdfTask { + return this.workerQueue.enqueue( + { + execute: () => this.executor.flattenAnnotationBehind(doc, page, annotation), + meta: { docId: doc.id, pageIndex: page.index, operation: 'flattenAnnotationBehind' }, + }, + { priority: Priority.MEDIUM }, + ); + } + exportAnnotationAppearanceAsPdf( doc: PdfDocumentObject, page: PdfPageObject, diff --git a/packages/engines/src/lib/orchestrator/remote-executor.ts b/packages/engines/src/lib/orchestrator/remote-executor.ts index 93290543d..636102ed8 100644 --- a/packages/engines/src/lib/orchestrator/remote-executor.ts +++ b/packages/engines/src/lib/orchestrator/remote-executor.ts @@ -117,6 +117,7 @@ type MessageType = | 'applyRedaction' | 'applyAllRedactions' | 'flattenAnnotation' + | 'flattenAnnotationBehind' | 'exportAnnotationAppearanceAsPdf' | 'exportAnnotationsAppearanceAsPdf' | 'getTextSlices' @@ -607,6 +608,14 @@ export class RemoteExecutor implements IPdfiumExecutor { return this.send('flattenAnnotation', [doc, page, annotation]); } + flattenAnnotationBehind( + doc: PdfDocumentObject, + page: PdfPageObject, + annotation: PdfAnnotationObject, + ): PdfTask { + return this.send('flattenAnnotationBehind', [doc, page, annotation]); + } + exportAnnotationAppearanceAsPdf( doc: PdfDocumentObject, page: PdfPageObject, diff --git a/packages/engines/src/lib/pdfium/engine.ts b/packages/engines/src/lib/pdfium/engine.ts index d4b82d39a..6a28a15bc 100644 --- a/packages/engines/src/lib/pdfium/engine.ts +++ b/packages/engines/src/lib/pdfium/engine.ts @@ -7208,6 +7208,98 @@ export class PdfiumNative implements IPdfiumExecutor { return PdfTaskHelper.resolve(!!ok); } + /** + * Flatten an annotation's appearance to page content, inserting it BEHIND + * all existing page objects (at z-index 0). This is useful for watermarks + * that should appear underneath the document's text and images. + * + * Approach: count existing objects, flatten (appends at end), then move + * the newly created objects to index 0. + * + * @param doc - document object + * @param page - page object + * @param annotation - the annotation to flatten behind content + * @returns true if successful + */ + public flattenAnnotationBehind( + doc: PdfDocumentObject, + page: PdfPageObject, + annotation: PdfAnnotationObject, + ): PdfTask { + this.logger.debug( + LOG_SOURCE, + LOG_CATEGORY, + 'flattenAnnotationBehind', + doc.id, + page.index, + annotation.id, + ); + const label = 'FlattenAnnotationBehind'; + this.logger.perf(LOG_SOURCE, LOG_CATEGORY, label, 'Begin', `${doc.id}-${page.index}`); + + const ctx = this.cache.getContext(doc.id); + if (!ctx) { + this.logger.perf(LOG_SOURCE, LOG_CATEGORY, label, 'End', `${doc.id}-${page.index}`); + return PdfTaskHelper.reject({ + code: PdfErrorCode.DocNotOpen, + message: 'document does not open', + }); + } + + const pageCtx = ctx.acquirePage(page.index); + const annotPtr = this.getAnnotationByName(pageCtx.pagePtr, annotation.id); + if (!annotPtr) { + pageCtx.release(); + this.logger.perf(LOG_SOURCE, LOG_CATEGORY, label, 'End', `${doc.id}-${page.index}`); + return PdfTaskHelper.reject({ + code: PdfErrorCode.NotFound, + message: 'annotation not found', + }); + } + + const beforeCount = this.pdfiumModule.FPDFPage_CountObjects(pageCtx.pagePtr); + + // Place watermark beneath existing text, but not necessarily beneath every + // object (some PDFs have a background fill/image at index 0). + let insertionIndex = beforeCount; + for (let i = 0; i < beforeCount; i++) { + const objectPtr = this.pdfiumModule.FPDFPage_GetObject(pageCtx.pagePtr, i); + if (!objectPtr) continue; + const type = this.pdfiumModule.FPDFPageObj_GetType(objectPtr) as PdfPageObjectType; + if (type === PdfPageObjectType.TEXT) { + insertionIndex = i; + break; + } + } + + // Flatten annotation to page content (same robust primitive as flattenAnnotation). + const ok = this.pdfiumModule.EPDFAnnot_Flatten(pageCtx.pagePtr, annotPtr); + this.pdfiumModule.FPDFPage_CloseAnnot(annotPtr); + + if (ok) { + const afterCount = this.pdfiumModule.FPDFPage_CountObjects(pageCtx.pagePtr); + const addedCount = Math.max(0, afterCount - beforeCount); + + // Move newly appended objects to the insertion index so watermark renders + // behind text while remaining above potential background objects. + // Move from tail to head so relative order of added objects is preserved. + for (let moved = 0; moved < addedCount; moved++) { + const sourceIndex = afterCount - 1 - moved; + const objectPtr = this.pdfiumModule.FPDFPage_GetObject(pageCtx.pagePtr, sourceIndex); + if (!objectPtr) continue; + this.pdfiumModule.FPDFPage_RemoveObject(pageCtx.pagePtr, objectPtr); + this.pdfiumModule.FPDFPage_InsertObjectAtIndex(pageCtx.pagePtr, objectPtr, insertionIndex); + } + + this.pdfiumModule.FPDFPage_GenerateContent(pageCtx.pagePtr); + } + + pageCtx.disposeImmediate(); + this.logger.perf(LOG_SOURCE, LOG_CATEGORY, label, 'End', `${doc.id}-${page.index}`); + + return PdfTaskHelper.resolve(!!ok); + } + /** * Export an annotation's appearance as a standalone single-page PDF. * diff --git a/packages/engines/src/lib/webworker/engine.ts b/packages/engines/src/lib/webworker/engine.ts index 16bcbd6d4..8f04ab969 100644 --- a/packages/engines/src/lib/webworker/engine.ts +++ b/packages/engines/src/lib/webworker/engine.ts @@ -1134,6 +1134,26 @@ export class WebWorkerEngine implements PdfEngine { return task; } + /** + * {@inheritDoc @embedpdf/models!PdfEngine.flattenAnnotationBehind} + * + * @public + */ + flattenAnnotationBehind(doc: PdfDocumentObject, page: PdfPageObject, annotation: PdfAnnotationObject) { + this.logger.debug(LOG_SOURCE, LOG_CATEGORY, 'flattenAnnotationBehind', doc, page, annotation); + const requestId = this.generateRequestId(doc.id); + const task = new WorkerTask(this.worker, requestId); + + const request: ExecuteRequest = createRequest(requestId, 'flattenAnnotationBehind', [ + doc, + page, + annotation, + ]); + this.proxy(task, request); + + return task; + } + /** * {@inheritDoc @embedpdf/models!PdfEngine.exportAnnotationAppearanceAsPdf} * diff --git a/packages/engines/src/lib/webworker/runner.ts b/packages/engines/src/lib/webworker/runner.ts index 7138df03f..5136f1c19 100644 --- a/packages/engines/src/lib/webworker/runner.ts +++ b/packages/engines/src/lib/webworker/runner.ts @@ -447,6 +447,9 @@ export class EngineRunner { case 'flattenAnnotation': this.handleTask(request.id, engine.flattenAnnotation!(...args)); return; + case 'flattenAnnotationBehind': + this.handleTask(request.id, engine.flattenAnnotationBehind!(...args)); + return; case 'exportAnnotationAppearanceAsPdf': this.handleTask(request.id, engine.exportAnnotationAppearanceAsPdf!(...args)); return; diff --git a/packages/models/src/pdf.ts b/packages/models/src/pdf.ts index 77c0f6a5a..2bbc4cc29 100644 --- a/packages/models/src/pdf.ts +++ b/packages/models/src/pdf.ts @@ -3796,6 +3796,19 @@ export interface PdfEngine { page: PdfPageObject, annotation: PdfAnnotationObject, ) => PdfTask; + /** + * Flatten an annotation's appearance behind existing page content (at z-index 0). + * The annotation is removed after flattening. + * @param doc - pdf document + * @param page - pdf page + * @param annotation - the annotation to flatten behind content + * @returns task contains the result + */ + flattenAnnotationBehind: ( + doc: PdfDocumentObject, + page: PdfPageObject, + annotation: PdfAnnotationObject, + ) => PdfTask; /** * Export an annotation's appearance as a standalone PDF document * @param doc - pdf document @@ -4127,6 +4140,11 @@ export interface IPdfiumExecutor { page: PdfPageObject, annotation: PdfAnnotationObject, ): PdfTask; + flattenAnnotationBehind( + doc: PdfDocumentObject, + page: PdfPageObject, + annotation: PdfAnnotationObject, + ): PdfTask; exportAnnotationAppearanceAsPdf( doc: PdfDocumentObject, page: PdfPageObject, diff --git a/packages/plugin-watermark/README.md b/packages/plugin-watermark/README.md new file mode 100644 index 000000000..563edf9b9 --- /dev/null +++ b/packages/plugin-watermark/README.md @@ -0,0 +1,136 @@ +# @embedpdf/plugin-watermark + +A plugin for [EmbedPDF](https://www.embedpdf.com) that allows embedding text or image watermarks into PDF pages at specific coordinates. + +## Features + +- **Text watermarks** — customisable font, size, colour, and opacity +- **Image watermarks** — supports PNG and JPEG data +- **Image rotation** — rotation is applied consistently for image watermarks +- **Precise positioning** — place watermarks at exact PDF coordinates +- **Repeat tiling** — repeat watermarks horizontally, vertically, or both +- **Page range control** — apply to all pages or specific page indices +- **Configurable flags** — read-only, printable, rotation support +- **Auto-apply** — optionally apply watermarks automatically when documents load + +## Installation + +```bash +pnpm add @embedpdf/plugin-watermark +``` + +## Usage + +```typescript +import { WatermarkPluginPackage } from '@embedpdf/plugin-watermark'; + +// Register with your viewer +const viewer = createViewer({ + plugins: [ + // ... other plugins + [WatermarkPluginPackage, { + autoApply: true, + watermarks: [ + { + type: 'text', + textOptions: { + text: 'CONFIDENTIAL', + fontSize: 60, + colour: '#FF0000', + }, + position: { x: 100, y: 400 }, + size: { width: 400, height: 80 }, + opacity: 0.3, + rotation: -45, + repeat: 'both', + repeatSpacing: { x: 40, y: 80 }, + pageRange: 'all', + readOnly: true, + printable: true, + }, + ], + }], + ], +}); +``` + +### Adding watermarks programmatically + +```typescript +const watermark = viewer.getCapability('watermark'); + +// Text watermark +watermark.addWatermark({ + type: 'text', + textOptions: { text: 'DRAFT', fontSize: 48, colour: '#888888' }, + position: { x: 150, y: 300 }, + size: { width: 300, height: 60 }, + opacity: 0.2, + rotation: -30, + repeat: 'horizontal', + repeatSpacing: { x: 60 }, + pageRange: 'all', +}); + +// Image watermark +watermark.addWatermark({ + type: 'image', + imageOptions: { data: logoArrayBuffer, mimeType: 'image/png' }, + position: { x: 50, y: 700 }, + size: { width: 100, height: 100 }, + opacity: 0.4, + rotation: -25, + repeat: 'vertical', + repeatSpacing: { y: 40 }, + pageRange: [0, 1, 2], // first three pages only +}); +``` + +### Removing watermarks + +`removeWatermark(id)` removes the **definition** so it no longer applies to future loads/applies. + +Because this plugin flattens watermarks into page content, already-applied watermark visuals are permanent in the modified PDF and cannot be removed in-place. + +```typescript +watermark.removeWatermark(watermarkId); +``` + +### Clearing watermarks from a document + +```typescript +watermark.clearFromDocument(documentId); +``` + +## API + +### `WatermarkCapability` + +| Method | Description | +|--------|-------------| +| `addWatermark(input)` | Add and apply a watermark; returns the generated ID | +| `removeWatermark(id)` | Remove a watermark definition (already-flattened content remains) | +| `getWatermarks()` | Get all registered watermark definitions | +| `applyToDocument(documentId)` | Apply all watermarks to a specific document | +| `clearFromDocument(documentId)` | Remove all watermark annotations from a document | +| `onWatermarkChange` | Event hook for watermark definition changes | + +## Configuration + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `watermarks` | `WatermarkInput[]` | `[]` | Watermarks to register on initialisation | +| `autoApply` | `boolean` | `true` | Auto-apply watermarks when a document loads | + +### `WatermarkInput` repeat options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `repeat` | `'none' \| 'horizontal' \| 'vertical' \| 'both'` | `'none'` | Repeat the watermark across each target page | +| `repeatSpacing` | `{ x?: number; y?: number }` | `{ x: 0, y: 0 }` | Spacing (PDF points) between repeated instances | + +## Licence + +MIT + + diff --git a/packages/plugin-watermark/package.json b/packages/plugin-watermark/package.json new file mode 100644 index 000000000..8caa76aa2 --- /dev/null +++ b/packages/plugin-watermark/package.json @@ -0,0 +1,52 @@ +{ + "name": "@embedpdf/plugin-watermark", + "version": "1.0.0", + "type": "module", + "license": "MIT", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "pnpm run clean && vite build --mode base", + "clean": "rimraf dist", + "lint": "eslint src --color", + "lint:fix": "eslint src --color --fix" + }, + "dependencies": { + "@embedpdf/models": "workspace:*" + }, + "devDependencies": { + "@embedpdf/build": "workspace:*", + "@embedpdf/core": "workspace:*", + "@embedpdf/plugin-annotation": "workspace:*", + "typescript": "^5.0.0" + }, + "peerDependencies": { + "@embedpdf/core": "workspace:*", + "@embedpdf/plugin-annotation": "workspace:*" + }, + "files": [ + "dist", + "README.md" + ], + "repository": { + "type": "git", + "url": "https://github.com/embedpdf/embed-pdf-viewer", + "directory": "packages/plugin-watermark" + }, + "homepage": "https://www.embedpdf.com/docs", + "bugs": { + "url": "https://github.com/embedpdf/embed-pdf-viewer/issues" + }, + "publishConfig": { + "access": "public" + } +} + diff --git a/packages/plugin-watermark/src/index.ts b/packages/plugin-watermark/src/index.ts new file mode 100644 index 000000000..52ead47f4 --- /dev/null +++ b/packages/plugin-watermark/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib'; + diff --git a/packages/plugin-watermark/src/lib/actions.ts b/packages/plugin-watermark/src/lib/actions.ts new file mode 100644 index 000000000..c6372de9d --- /dev/null +++ b/packages/plugin-watermark/src/lib/actions.ts @@ -0,0 +1,59 @@ +import { Action } from '@embedpdf/core'; +import { WatermarkDefinition, WatermarkPlacement } from './types'; + +export const ADD_WATERMARK = 'WATERMARK/ADD'; +export const REMOVE_WATERMARK = 'WATERMARK/REMOVE'; +export const ADD_PLACEMENTS = 'WATERMARK/ADD_PLACEMENTS'; +export const CLEAR_PLACEMENTS = 'WATERMARK/CLEAR_PLACEMENTS'; + +export interface AddWatermarkAction extends Action { + type: typeof ADD_WATERMARK; + payload: WatermarkDefinition; +} + +export interface RemoveWatermarkAction extends Action { + type: typeof REMOVE_WATERMARK; + payload: string; // watermark ID +} + +export interface AddPlacementsAction extends Action { + type: typeof ADD_PLACEMENTS; + payload: { + watermarkId: string; + placements: WatermarkPlacement[]; + }; +} + +export interface ClearPlacementsAction extends Action { + type: typeof CLEAR_PLACEMENTS; + payload: { + watermarkId: string; + documentId?: string; // if provided, only clear placements for this doc + }; +} + +export type WatermarkAction = + | AddWatermarkAction + | RemoveWatermarkAction + | AddPlacementsAction + | ClearPlacementsAction; + +export function addWatermark(definition: WatermarkDefinition): AddWatermarkAction { + return { type: ADD_WATERMARK, payload: definition }; +} + +export function removeWatermark(id: string): RemoveWatermarkAction { + return { type: REMOVE_WATERMARK, payload: id }; +} + +export function addPlacements( + watermarkId: string, + placements: WatermarkPlacement[], +): AddPlacementsAction { + return { type: ADD_PLACEMENTS, payload: { watermarkId, placements } }; +} + +export function clearPlacements(watermarkId: string, documentId?: string): ClearPlacementsAction { + return { type: CLEAR_PLACEMENTS, payload: { watermarkId, documentId } }; +} + diff --git a/packages/plugin-watermark/src/lib/index.ts b/packages/plugin-watermark/src/lib/index.ts new file mode 100644 index 000000000..25f25238e --- /dev/null +++ b/packages/plugin-watermark/src/lib/index.ts @@ -0,0 +1,25 @@ +import { PluginPackage } from '@embedpdf/core'; +import { manifest, WATERMARK_PLUGIN_ID } from './manifest'; +import { WatermarkPluginConfig, WatermarkState } from './types'; +import { WatermarkPlugin } from './watermark-plugin'; +import { watermarkReducer, initialState } from './reducer'; +import { WatermarkAction } from './actions'; + +export const WatermarkPluginPackage: PluginPackage< + WatermarkPlugin, + WatermarkPluginConfig, + WatermarkState, + WatermarkAction +> = { + manifest, + create: (registry, config) => new WatermarkPlugin(WATERMARK_PLUGIN_ID, registry, config), + reducer: watermarkReducer, + initialState, +}; + +export * from './watermark-plugin'; +export * from './types'; +export * from './manifest'; +export * from './actions'; +export * from './reducer'; + diff --git a/packages/plugin-watermark/src/lib/manifest.ts b/packages/plugin-watermark/src/lib/manifest.ts new file mode 100644 index 000000000..8468178ea --- /dev/null +++ b/packages/plugin-watermark/src/lib/manifest.ts @@ -0,0 +1,17 @@ +import { PluginManifest } from '@embedpdf/core'; +import { WatermarkPluginConfig } from './types'; + +export const WATERMARK_PLUGIN_ID = 'watermark'; + +export const manifest: PluginManifest = { + id: WATERMARK_PLUGIN_ID, + name: 'Watermark Plugin', + version: '1.0.0', + provides: ['watermark'], + requires: ['annotation'], + optional: [], + defaultConfig: { + autoApply: true, + }, +}; + diff --git a/packages/plugin-watermark/src/lib/reducer.ts b/packages/plugin-watermark/src/lib/reducer.ts new file mode 100644 index 000000000..7f0d96735 --- /dev/null +++ b/packages/plugin-watermark/src/lib/reducer.ts @@ -0,0 +1,69 @@ +import { Reducer } from '@embedpdf/core'; +import { + WatermarkAction, + ADD_WATERMARK, + REMOVE_WATERMARK, + ADD_PLACEMENTS, + CLEAR_PLACEMENTS, +} from './actions'; +import { WatermarkState } from './types'; + +export const initialState: WatermarkState = { + watermarkIds: [], + placements: {}, +}; + +export const watermarkReducer: Reducer = ( + state = initialState, + action, +) => { + switch (action.type) { + case ADD_WATERMARK: + return { + ...state, + watermarkIds: [...state.watermarkIds, action.payload.id], + placements: { ...state.placements, [action.payload.id]: [] }, + }; + + case REMOVE_WATERMARK: + return { + ...state, + watermarkIds: state.watermarkIds.filter((id) => id !== action.payload), + placements: Object.fromEntries( + Object.entries(state.placements).filter(([key]) => key !== action.payload), + ), + }; + + case ADD_PLACEMENTS: { + const existing = state.placements[action.payload.watermarkId] ?? []; + return { + ...state, + placements: { + ...state.placements, + [action.payload.watermarkId]: [...existing, ...action.payload.placements], + }, + }; + } + + case CLEAR_PLACEMENTS: { + const { watermarkId, documentId } = action.payload; + if (!documentId) { + return { + ...state, + placements: { ...state.placements, [watermarkId]: [] }, + }; + } + const filtered = (state.placements[watermarkId] ?? []).filter( + (p) => p.documentId !== documentId, + ); + return { + ...state, + placements: { ...state.placements, [watermarkId]: filtered }, + }; + } + + default: + return state; + } +}; + diff --git a/packages/plugin-watermark/src/lib/types.ts b/packages/plugin-watermark/src/lib/types.ts new file mode 100644 index 000000000..48a205d82 --- /dev/null +++ b/packages/plugin-watermark/src/lib/types.ts @@ -0,0 +1,203 @@ +import { BasePluginConfig, EventHook } from '@embedpdf/core'; +import { PdfAnnotationFlagName, PdfTask } from '@embedpdf/models'; + +/** + * The type of watermark content. + */ +export type WatermarkType = 'text' | 'image'; + +/** + * Position within a PDF page (in PDF coordinate space, origin bottom-left). + */ +export interface WatermarkPosition { + x: number; + y: number; +} + +/** + * Size in PDF points. + */ +export interface WatermarkSize { + width: number; + height: number; +} + +/** + * Repeat mode for tiling watermarks across a page. + */ +export type WatermarkRepeatMode = 'none' | 'horizontal' | 'vertical' | 'both'; + +/** + * Optional spacing (in PDF points) between repeated watermarks. + */ +export interface WatermarkRepeatSpacing { + x?: number; + y?: number; +} + +/** + * Text-specific styling options for a watermark. + */ +export interface WatermarkTextOptions { + /** The text content to display */ + text: string; + /** Font size in PDF points (default: 48) */ + fontSize?: number; + /** Font family (default: 'Helvetica') */ + fontFamily?: string; + /** Text colour as a CSS-style hex string e.g. '#FF0000' (default: '#000000') */ + colour?: string; +} + +/** + * Image-specific options for a watermark. + */ +export interface WatermarkImageOptions { + /** Raw image data (PNG or JPEG) */ + data: ArrayBuffer; + /** MIME type of the image data */ + mimeType: 'image/png' | 'image/jpeg'; +} + +/** + * Defines which pages a watermark should be applied to. + * - `'all'`: apply to every page in the document + * - `number[]`: apply to specific page indices (0-based) + */ +export type WatermarkPageRange = 'all' | number[]; + +/** + * A complete watermark definition describing what to render and where. + */ +export interface WatermarkDefinition { + /** Unique identifier for this watermark */ + id: string; + /** Whether this is a text or image watermark */ + type: WatermarkType; + /** Text options (required when type is 'text') */ + textOptions?: WatermarkTextOptions; + /** Image options (required when type is 'image') */ + imageOptions?: WatermarkImageOptions; + /** Position of the watermark's bottom-left corner in PDF points */ + position: WatermarkPosition; + /** Size of the watermark bounding box in PDF points */ + size: WatermarkSize; + /** Opacity from 0.0 (fully transparent) to 1.0 (fully opaque). Default: 0.5 */ + opacity?: number; + /** Rotation angle in degrees (clockwise). Default: 0 */ + rotation?: number; + /** Repeat mode. Default: 'none' */ + repeat?: WatermarkRepeatMode; + /** Spacing between repeated instances. Default: { x: 0, y: 0 } */ + repeatSpacing?: WatermarkRepeatSpacing; + /** Which pages to apply the watermark to. Default: 'all' */ + pageRange?: WatermarkPageRange; + /** Whether the watermark annotation is read-only. Default: true */ + readOnly?: boolean; + /** Whether the watermark should appear when printing. Default: true */ + printable?: boolean; +} + +/** + * Input for creating a new watermark (id is auto-generated). + */ +export type WatermarkInput = Omit; + +/** + * Configuration for the watermark plugin. + */ +export interface WatermarkPluginConfig extends BasePluginConfig { + /** Watermarks to apply automatically when a document is loaded */ + watermarks?: WatermarkInput[]; + /** Whether to auto-apply configured watermarks on document load. Default: true */ + autoApply?: boolean; +} + +/** + * Internal state tracked by the watermark plugin's reducer. + */ +export interface WatermarkState { + /** IDs of all registered watermark definitions */ + watermarkIds: string[]; + /** Maps watermark definition ID → array of placed annotation IDs per document */ + placements: Record; +} + +/** + * Tracks a single placed watermark instance (flattened into page content). + */ +export interface WatermarkPlacement { + /** The document ID where this was placed */ + documentId: string; + /** The page index where this was flattened */ + pageIndex: number; + /** The original annotation ID (no longer exists after flatten) */ + annotationId: string; +} + +/** + * Event emitted when watermarks change. + */ +export interface WatermarkChangeEvent { + watermarks: WatermarkDefinition[]; +} + +/** + * The public capability interface exposed by the watermark plugin. + */ +export interface WatermarkCapability { + /** + * Add a watermark definition and immediately apply it to the active document. + * @returns The generated watermark ID. + */ + addWatermark(input: WatermarkInput): PdfTask; + + /** + * Remove a watermark definition. Note: already-flattened watermarks + * are permanent in the PDF and cannot be visually removed. + */ + removeWatermark(id: string): PdfTask; + + /** + * Get all registered watermark definitions. + */ + getWatermarks(): WatermarkDefinition[]; + + /** + * Apply all registered watermarks to a specific document. + * Each watermark is flattened into the page content stream. + */ + applyToDocument(documentId: string): PdfTask; + + /** + * Clear placement tracking records for a document. + * Does NOT remove the flattened watermark content from pages. + */ + clearFromDocument(documentId: string): PdfTask; + + /** Subscribe to watermark definition changes */ + onWatermarkChange: EventHook; +} + +/** + * Compute the annotation flags for a watermark. + */ +export function computeWatermarkFlags(def: WatermarkDefinition): PdfAnnotationFlagName[] { + const flags: PdfAnnotationFlagName[] = []; + + if (def.printable !== false) { + flags.push('print'); + } + + if (def.readOnly !== false) { + flags.push('readOnly'); + flags.push('locked'); + } + + return flags; +} + + + + + diff --git a/packages/plugin-watermark/src/lib/watermark-plugin.ts b/packages/plugin-watermark/src/lib/watermark-plugin.ts new file mode 100644 index 000000000..54424fd6a --- /dev/null +++ b/packages/plugin-watermark/src/lib/watermark-plugin.ts @@ -0,0 +1,859 @@ +import { BasePlugin, createEmitter, PluginRegistry, refreshPages } from '@embedpdf/core'; +import { + PdfAnnotationObject, + PdfAnnotationSubtype, + PdfErrorCode, + PdfErrorReason, + PdfStampAnnoObject, + Task, + uuidV4, +} from '@embedpdf/models'; +import { AnnotationCapability, AnnotationPlugin } from '@embedpdf/plugin-annotation'; +import { + WatermarkCapability, + WatermarkChangeEvent, + WatermarkDefinition, + WatermarkInput, + WatermarkPlacement, + WatermarkPluginConfig, + WatermarkState, + computeWatermarkFlags, +} from './types'; +import { + addWatermark, + removeWatermark, + addPlacements, + clearPlacements, + WatermarkAction, +} from './actions'; +import { WATERMARK_PLUGIN_ID } from './manifest'; + +type WatermarkRect = { origin: { x: number; y: number }; size: { width: number; height: number } }; +type PlacementTarget = { pageIndex: number; rect: WatermarkRect }; + +export class WatermarkPlugin extends BasePlugin< + WatermarkPluginConfig, + WatermarkCapability, + WatermarkState, + WatermarkAction +> { + static readonly id = WATERMARK_PLUGIN_ID; + + private readonly definitions = new Map(); + private readonly watermarkChange$ = createEmitter(); + private annotation: AnnotationCapability | null = null; + private config!: WatermarkPluginConfig; + + constructor(id: string, registry: PluginRegistry, config: WatermarkPluginConfig) { + super(id, registry); + this.config = config; + this.annotation = + registry.getPlugin('annotation')?.provides() ?? null; + } + + async initialize(): Promise { + if (this.config.watermarks) { + for (const input of this.config.watermarks) { + const def = this.createDefinition(input); + this.definitions.set(def.id, def); + this.dispatch(addWatermark(def)); + } + this.emitChange(); + } + } + + protected buildCapability(): WatermarkCapability { + return { + addWatermark: (input) => this.addWatermarkPublic(input), + removeWatermark: (id) => this.removeWatermarkPublic(id), + getWatermarks: () => this.getWatermarks(), + applyToDocument: (documentId) => this.applyToDocument(documentId), + clearFromDocument: (documentId) => this.clearFromDocument(documentId), + onWatermarkChange: this.watermarkChange$.on, + }; + } + + // ───────────────────────────────────────────────────────── + // Lifecycle hooks + // ───────────────────────────────────────────────────────── + + protected override onDocumentLoaded(documentId: string): void { + if (this.config.autoApply !== false && this.definitions.size > 0) { + this.applyToDocument(documentId); + } + } + + protected override onDocumentClosed(documentId: string): void { + for (const def of this.definitions.values()) { + this.dispatch(clearPlacements(def.id, documentId)); + } + } + + // ───────────────────────────────────────────────────────── + // Public capability methods + // ───────────────────────────────────────────────────────── + + private addWatermarkPublic(input: WatermarkInput): Task { + const task = new Task(); + + const def = this.createDefinition(input); + this.definitions.set(def.id, def); + this.dispatch(addWatermark(def)); + this.emitChange(); + + const activeDocId = this.getActiveDocumentIdOrNull(); + if (activeDocId) { + this.applyWatermarkToDocument(def, activeDocId).wait( + () => task.resolve(def.id), + (error: { type: string; reason: PdfErrorReason }) => { + this.logger.warn( + 'WatermarkPlugin', + 'AddWatermark', + `Watermark registered but failed to apply: ${error.reason.message}`, + ); + task.resolve(def.id); + }, + ); + } else { + task.resolve(def.id); + } + + return task; + } + + private removeWatermarkPublic(id: string): Task { + const task = new Task(); + + const def = this.definitions.get(id); + if (!def) { + task.reject({ + code: PdfErrorCode.NotFound, + message: `Watermark not found: ${id}`, + }); + return task; + } + + // Flattened watermarks are permanent — just remove the definition. + // The watermark content is already part of the page and cannot be reversed. + this.definitions.delete(id); + this.dispatch(removeWatermark(id)); + this.emitChange(); + task.resolve(); + + return task; + } + + private getWatermarks(): WatermarkDefinition[] { + return Array.from(this.definitions.values()); + } + + /** + * Apply all registered watermarks to a given document. + */ + applyToDocument(documentId: string): Task { + const task = new Task(); + + if (!this.annotation) { + task.reject({ + code: PdfErrorCode.NotSupport, + message: 'Annotation plugin is not available', + }); + return task; + } + + const docState = this.getCoreDocument(documentId); + if (!docState?.document) { + task.reject({ code: PdfErrorCode.DocNotOpen, message: 'Document is not open' }); + return task; + } + + const definitions = Array.from(this.definitions.values()); + let pending = definitions.length; + + if (pending === 0) { + task.resolve(); + return task; + } + + let hasError = false; + + for (const def of definitions) { + this.applyWatermarkToDocument(def, documentId).wait( + () => { + pending--; + if (pending === 0 && !hasError) { + task.resolve(); + } + }, + (error: { type: string; reason: PdfErrorReason }) => { + if (!hasError) { + hasError = true; + this.logger.error( + 'WatermarkPlugin', + 'ApplyToDocument', + `Failed to apply watermark ${def.id}`, + error, + ); + task.fail(error); + } + }, + ); + } + + return task; + } + + /** + * Flattened watermarks cannot be removed from the document. + * This only clears placement tracking records. + */ + private clearFromDocument(documentId: string): Task { + const task = new Task(); + + for (const def of this.definitions.values()) { + this.dispatch(clearPlacements(def.id, documentId)); + } + + task.resolve(); + return task; + } + + // ───────────────────────────────────────────────────────── + // Internal helpers + // ───────────────────────────────────────────────────────── + + /** + * Apply a single watermark definition to all target pages. + */ + private applyWatermarkToDocument( + def: WatermarkDefinition, + documentId: string, + ): Task { + const task = new Task(); + + const docState = this.getCoreDocument(documentId); + if (!docState?.document) { + task.reject({ code: PdfErrorCode.DocNotOpen, message: 'Document is not open' }); + return task; + } + + const pageCount = docState.document.pageCount; + const pageIndices = this.resolvePageRange(def.pageRange, pageCount); + const pageMap: Map = + new Map( + docState.document.pages.map( + (page: { index: number; size: { width: number; height: number } }) => + [page.index, page] as const, + ), + ); + + if (def.type === 'text') { + this.applyTextWatermark(def, documentId, pageIndices, pageMap, task); + } else { + this.applyImageWatermark(def, documentId, pageIndices, pageMap, task); + } + + return task; + } + + /** + * Apply a text watermark by creating stamp annotations sequentially, + * waiting for each to be committed, then flattening into page content. + */ + private applyTextWatermark( + def: WatermarkDefinition, + documentId: string, + pageIndices: number[], + pageMap: Map, + task: Task, + ): void { + if (!this.annotation) { + task.reject({ code: PdfErrorCode.NotSupport, message: 'Annotation plugin unavailable' }); + return; + } + + const { pdfString, rect } = this.generateTextAppearance(def); + const placementTargets = this.buildPlacementTargets(def, pageIndices, pageMap, rect); + const placements: WatermarkPlacement[] = []; + const annotationScope = this.annotation.forDocument(documentId); + + const createNext = (index: number) => { + if (index >= placementTargets.length) { + this.dispatch(addPlacements(def.id, placements)); + task.resolve(); + return; + } + + const target = placementTargets[index]; + const pageIndex = target.pageIndex; + const annotationId = uuidV4(); + + const annotation: PdfStampAnnoObject = { + id: annotationId, + type: PdfAnnotationSubtype.STAMP, + pageIndex, + rect: target.rect, + flags: computeWatermarkFlags(def), + subject: 'Watermark', + }; + + const appearanceCopy = new TextEncoder().encode(pdfString).buffer; + + annotationScope.createAnnotation(pageIndex, annotation, { + appearance: appearanceCopy, + }); + + // Explicitly commit so this works even when annotation autoCommit is disabled. + annotationScope.commit().wait( + () => { + this.flattenAndRefresh(documentId, pageIndex, annotation).wait( + () => { + placements.push({ documentId, pageIndex, annotationId }); + createNext(index + 1); + }, + (error: { type: string; reason: PdfErrorReason }) => { + this.logger.warn( + 'WatermarkPlugin', + 'FlattenFailed', + `Flatten failed for page ${pageIndex}: ${error.reason.message}`, + ); + placements.push({ documentId, pageIndex, annotationId }); + createNext(index + 1); + }, + ); + }, + (error: { type: string; reason: PdfErrorReason }) => { + this.logger.warn( + 'WatermarkPlugin', + 'CommitFailed', + `Commit failed for page ${pageIndex}: ${error.reason.message}`, + ); + createNext(index + 1); + }, + ); + }; + + createNext(0); + } + + /** + * Apply an image watermark by creating stamp annotations sequentially, + * waiting for each to be committed, then flattening into page content. + * Opacity is baked into the image pixel data via OffscreenCanvas. + */ + private applyImageWatermark( + def: WatermarkDefinition, + documentId: string, + pageIndices: number[], + pageMap: Map, + task: Task, + ): void { + if (!this.annotation) { + task.reject({ code: PdfErrorCode.NotSupport, message: 'Annotation plugin unavailable' }); + return; + } + + if (!def.imageOptions) { + task.reject({ + code: PdfErrorCode.NotFound, + message: 'Image options are required for image watermarks', + }); + return; + } + + const opacity = def.opacity ?? 0.5; + const rotation = def.rotation ?? 0; + const baseRect = this.computePlacementRect(def.position, def.size, rotation); + const placementTargets = this.buildPlacementTargets(def, pageIndices, pageMap, baseRect); + + // Pre-process image to bake in opacity/rotation, then proceed. + this.applyOpacityAndRotationToImage(def.imageOptions.data, opacity, rotation).then( + (processedData) => { + this.createImageAnnotationsSequentially( + def, + documentId, + placementTargets, + processedData, + task, + ); + }, + () => { + // Fallback: use original data without opacity + this.createImageAnnotationsSequentially( + def, + documentId, + placementTargets, + def.imageOptions!.data, + task, + ); + }, + ); + } + + /** + * Create image stamp annotations sequentially (after image processing). + */ + private createImageAnnotationsSequentially( + def: WatermarkDefinition, + documentId: string, + placementTargets: PlacementTarget[], + imageData: ArrayBuffer, + task: Task, + ): void { + const annotationScope = this.annotation!.forDocument(documentId); + const placements: WatermarkPlacement[] = []; + + const createNext = (index: number) => { + if (index >= placementTargets.length) { + this.dispatch(addPlacements(def.id, placements)); + task.resolve(); + return; + } + + const target = placementTargets[index]; + const pageIndex = target.pageIndex; + const annotationId = uuidV4(); + + const annotation: PdfStampAnnoObject = { + id: annotationId, + type: PdfAnnotationSubtype.STAMP, + pageIndex, + rect: target.rect, + flags: computeWatermarkFlags(def), + subject: 'Watermark', + }; + + const dataCopy = imageData.slice(0); + + annotationScope.createAnnotation(pageIndex, annotation, { + data: dataCopy, + mimeType: 'image/png', + }); + + // Explicitly commit so this works even when annotation autoCommit is disabled. + annotationScope.commit().wait( + () => { + this.flattenAndRefresh(documentId, pageIndex, annotation).wait( + () => { + placements.push({ documentId, pageIndex, annotationId }); + createNext(index + 1); + }, + (error: { type: string; reason: PdfErrorReason }) => { + this.logger.warn( + 'WatermarkPlugin', + 'FlattenFailed', + `Flatten failed for page ${pageIndex}: ${error.reason.message}`, + ); + placements.push({ documentId, pageIndex, annotationId }); + createNext(index + 1); + }, + ); + }, + (error: { type: string; reason: PdfErrorReason }) => { + this.logger.warn( + 'WatermarkPlugin', + 'CommitFailed', + `Commit failed for page ${pageIndex}: ${error.reason.message}`, + ); + createNext(index + 1); + }, + ); + }; + + createNext(0); + } + + /** + * Apply opacity to an image by drawing it on an OffscreenCanvas with + * globalAlpha, then exporting as PNG. Returns the processed ArrayBuffer. + */ + private async applyOpacityAndRotationToImage( + data: ArrayBuffer, + opacity: number, + rotation: number, + ): Promise { + const blob = new Blob([data]); + const bitmap = await createImageBitmap(blob); + const radians = (rotation * Math.PI) / 180; + const absCos = Math.abs(Math.cos(radians)); + const absSin = Math.abs(Math.sin(radians)); + const outWidth = Math.max(1, Math.ceil(bitmap.width * absCos + bitmap.height * absSin)); + const outHeight = Math.max(1, Math.ceil(bitmap.width * absSin + bitmap.height * absCos)); + + const canvas = new OffscreenCanvas(outWidth, outHeight); + const ctx = canvas.getContext('2d')!; + ctx.globalAlpha = opacity; + ctx.translate(outWidth / 2, outHeight / 2); + if (rotation !== 0) { + ctx.rotate(radians); + } + ctx.drawImage(bitmap, -bitmap.width / 2, -bitmap.height / 2); + bitmap.close(); + const resultBlob = await canvas.convertToBlob({ type: 'image/png' }); + return resultBlob.arrayBuffer(); + } + + private computePlacementRect( + position: { x: number; y: number }, + size: { width: number; height: number }, + rotation: number, + ): WatermarkRect { + const radians = (rotation * Math.PI) / 180; + const absCos = Math.abs(Math.cos(radians)); + const absSin = Math.abs(Math.sin(radians)); + const rotatedWidth = size.width * absCos + size.height * absSin; + const rotatedHeight = size.width * absSin + size.height * absCos; + const centreX = position.x + size.width / 2; + const centreY = position.y + size.height / 2; + + return { + origin: { + x: centreX - rotatedWidth / 2, + y: centreY - rotatedHeight / 2, + }, + size: { width: rotatedWidth, height: rotatedHeight }, + }; + } + + private buildPlacementTargets( + def: WatermarkDefinition, + pageIndices: number[], + pageMap: Map, + baseRect: WatermarkRect, + ): PlacementTarget[] { + const targets: PlacementTarget[] = []; + const mode = def.repeat ?? 'none'; + const spacingX = Math.max(0, def.repeatSpacing?.x ?? 0); + const spacingY = Math.max(0, def.repeatSpacing?.y ?? 0); + + for (const pageIndex of pageIndices) { + const page = pageMap.get(pageIndex); + if (!page) continue; + + const xOrigins = this.expandAxisOrigins( + baseRect.origin.x, + baseRect.size.width, + page.size.width, + mode === 'horizontal' || mode === 'both', + spacingX, + ); + const yOrigins = this.expandAxisOrigins( + baseRect.origin.y, + baseRect.size.height, + page.size.height, + mode === 'vertical' || mode === 'both', + spacingY, + ); + + for (const y of yOrigins) { + for (const x of xOrigins) { + targets.push({ + pageIndex, + rect: { + origin: { x, y }, + size: { width: baseRect.size.width, height: baseRect.size.height }, + }, + }); + } + } + } + + return targets; + } + + private expandAxisOrigins( + start: number, + itemSize: number, + pageSize: number, + repeatEnabled: boolean, + spacing: number, + ): number[] { + if (!repeatEnabled) { + return [start]; + } + + const step = itemSize + spacing; + if (step <= 0) { + return [start]; + } + + const result: number[] = []; + let first = start; + + // Extend backwards while still intersecting the page. + while (first - step + itemSize > 0) { + first -= step; + } + + // Extend forwards while still intersecting the page. + for (let cursor = first; cursor < pageSize; cursor += step) { + if (cursor + itemSize <= 0) continue; + result.push(cursor); + } + + if (result.length === 0) { + return [start]; + } + + return result; + } + + /** + * Flatten an annotation into the page content stream, then trigger + * a page re-render via refreshPages. + */ + private flattenAndRefresh( + documentId: string, + pageIndex: number, + annotation: PdfAnnotationObject, + ): Task { + const task = new Task(); + + const docState = this.getCoreDocument(documentId); + if (!docState?.document) { + task.reject({ code: PdfErrorCode.DocNotOpen, message: 'Document is not open' }); + return task; + } + + const page = docState.document.pages.find((p: { index: number }) => p.index === pageIndex); + if (!page) { + task.reject({ code: PdfErrorCode.NotFound, message: `Page ${pageIndex} not found` }); + return task; + } + + this.engine.flattenAnnotationBehind(docState.document, page, annotation).wait( + (flattened: boolean) => { + if (!flattened) { + task.reject({ + code: PdfErrorCode.Unknown, + message: `Failed to flatten watermark annotation ${annotation.id}`, + }); + return; + } + // Trigger tile re-render for this page + this.dispatchCoreAction(refreshPages(documentId, [pageIndex])); + task.resolve(); + }, + (error: { type: string; reason: PdfErrorReason }) => { + task.reject(error.reason); + }, + ); + + return task; + } + + /** + * Generate the text appearance PDF and the expanded annotation rect + * that accounts for rotation. The bounding box is expanded to encompass + * the fully rotated content, preventing clipping. + */ + private generateTextAppearance(def: WatermarkDefinition): { + pdfString: string; + rect: { origin: { x: number; y: number }; size: { width: number; height: number } }; + } { + const text = def.textOptions?.text ?? ''; + const fontSize = def.textOptions?.fontSize ?? 48; + const fontFamily = def.textOptions?.fontFamily ?? 'Helvetica'; + const colourHex = def.textOptions?.colour ?? '#000000'; + const opacity = def.opacity ?? 0.5; + const rotation = def.rotation ?? 0; + const { width, height } = def.size; + + // Calculate the rotated bounding box dimensions + const radians = (rotation * Math.PI) / 180; + const absCos = Math.abs(Math.cos(radians)); + const absSin = Math.abs(Math.sin(radians)); + const rotatedWidth = width * absCos + height * absSin; + const rotatedHeight = width * absSin + height * absCos; + + // Centre of the user-specified position + const centreX = def.position.x + width / 2; + const centreY = def.position.y + height / 2; + + // Expanded rect origin (keep same centre) + const originX = centreX - rotatedWidth / 2; + const originY = centreY - rotatedHeight / 2; + + // Parse hex colour to RGB components (0–1 range) + const r = parseInt(colourHex.slice(1, 3), 16) / 255; + const g = parseInt(colourHex.slice(3, 5), 16) / 255; + const b = parseInt(colourHex.slice(5, 7), 16) / 255; + + // Estimate text width (rough: Helvetica averages ~0.52 of fontSize per char) + const avgCharWidth = fontSize * 0.52; + const estimatedTextWidth = text.length * avgCharWidth; + + // Centre the text in the ORIGINAL (pre-rotation) bounding box + const textX = (width - estimatedTextWidth) / 2; + const textY = (height - fontSize) / 2; + + // Build rotation transform: translate to centre of expanded box, + // rotate, then offset to draw the original box centred + const cos = Math.cos(radians); + const sin = Math.sin(radians); + const cx = rotatedWidth / 2; + const cy = rotatedHeight / 2; + + // The transform: move origin to centre, rotate, then offset by -width/2, -height/2 + // so the original content box is centred within the expanded box. + // Combined matrix: translate(cx, cy) * rotate(θ) * translate(-width/2, -height/2) + const tx = cx - (width / 2) * cos + (height / 2) * sin; + const ty = cy - (width / 2) * sin - (height / 2) * cos; + + // Build content stream + const contentLines = [ + 'q', + '/GS0 gs', + `${r.toFixed(4)} ${g.toFixed(4)} ${b.toFixed(4)} rg`, + `${cos.toFixed(6)} ${sin.toFixed(6)} ${(-sin).toFixed(6)} ${cos.toFixed(6)} ${tx.toFixed(4)} ${ty.toFixed(4)} cm`, + 'BT', + `/F1 ${fontSize} Tf`, + `${textX.toFixed(2)} ${textY.toFixed(2)} Td`, + `(${this.escapePdfString(text)}) Tj`, + 'ET', + 'Q', + ]; + const contentStream = contentLines.join('\n'); + + const pdfString = this.buildMinimalPdf(rotatedWidth, rotatedHeight, contentStream, fontFamily, opacity); + + return { + pdfString, + rect: { + origin: { x: originX, y: originY }, + size: { width: rotatedWidth, height: rotatedHeight }, + }, + }; + } + + /** + * Build a minimal but valid PDF document with a single page containing + * the given content stream. + */ + private buildMinimalPdf( + width: number, + height: number, + contentStream: string, + fontName: string, + opacity: number, + ): string { + const streamBytes = new TextEncoder().encode(contentStream); + const streamLength = streamBytes.length; + + const objects: string[] = []; + + // 1: Catalog + objects.push('1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj'); + + // 2: Pages + objects.push('2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj'); + + // 3: Page — Resources reference /F1 for font and /GS0 for graphics state + objects.push( + `3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${width} ${height}] ` + + `/Contents 4 0 R /Resources << /Font << /F1 5 0 R >> ` + + `/ExtGState << /GS0 << /Type /ExtGState /ca ${opacity} /CA ${opacity} >> >> >> >>\nendobj`, + ); + + // 4: Content stream + objects.push( + `4 0 obj\n<< /Length ${streamLength} >>\nstream\n${contentStream}\nendstream\nendobj`, + ); + + // 5: Font + objects.push( + `5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /${fontName} >>\nendobj`, + ); + + // Build the PDF file + const header = '%PDF-1.4\n'; + let body = ''; + const xrefOffsets: number[] = []; + + let offset = header.length; + for (const obj of objects) { + xrefOffsets.push(offset); + const line = obj + '\n'; + body += line; + offset += new TextEncoder().encode(line).length; + } + + const xrefStart = offset; + let xref = `xref\n0 ${objects.length + 1}\n`; + xref += '0000000000 65535 f \n'; + for (const o of xrefOffsets) { + xref += `${o.toString().padStart(10, '0')} 00000 n \n`; + } + + const trailer = + `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n` + + `startxref\n${xrefStart}\n%%EOF`; + + return header + body + xref + trailer; + } + + /** + * Escape special characters in a PDF string literal. + */ + private escapePdfString(str: string): string { + return str + .replace(/\\/g, '\\\\') + .replace(/\(/g, '\\(') + .replace(/\)/g, '\\)') + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n'); + } + + /** + * Resolve a page range into concrete page indices. + */ + private resolvePageRange( + range: WatermarkDefinition['pageRange'], + pageCount: number, + ): number[] { + if (!range || range === 'all') { + return Array.from({ length: pageCount }, (_, i) => i); + } + return range.filter((i) => i >= 0 && i < pageCount); + } + + /** + * Create a full WatermarkDefinition from user input, generating an ID. + */ + private createDefinition(input: WatermarkInput): WatermarkDefinition { + return { + ...input, + id: uuidV4(), + }; + } + + /** + * Emit the watermark change event to subscribers. + */ + private emitChange(): void { + this.watermarkChange$.emit({ watermarks: this.getWatermarks() }); + } + + override destroy(): void { + this.watermarkChange$.clear(); + this.definitions.clear(); + super.destroy(); + } +} + + + + + + + + + + + + + + + + + diff --git a/packages/plugin-watermark/tsconfig.json b/packages/plugin-watermark/tsconfig.json new file mode 100644 index 000000000..5b2dde7eb --- /dev/null +++ b/packages/plugin-watermark/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} + diff --git a/packages/plugin-watermark/vite.config.ts b/packages/plugin-watermark/vite.config.ts new file mode 100644 index 000000000..fddfa57e8 --- /dev/null +++ b/packages/plugin-watermark/vite.config.ts @@ -0,0 +1,3 @@ +import { defineLibrary } from '@embedpdf/build/vite'; +export default defineLibrary(); + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da07e65c7..01fc440a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,10 +30,10 @@ importers: version: 22.19.11 '@typescript-eslint/eslint-plugin': specifier: ^8.32.1 - version: 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.2(jiti@2.6.1))(typescript@6.0.3) '@typescript-eslint/parser': specifier: ^8.32.1 - version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@6.0.3) concurrently: specifier: ^9.2.0 version: 9.2.1 @@ -48,7 +48,7 @@ importers: version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-import: specifier: ^2.31.0 - version: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + version: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-prettier: specifier: ^5.4.0 version: 5.5.5(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))(prettier@3.8.1) @@ -75,13 +75,13 @@ importers: version: 6.1.3 turbo: specifier: latest - version: 2.8.20 + version: 2.9.14 turbowatch: specifier: ^2.29.4 version: 2.30.0 typescript: specifier: latest - version: 5.9.3 + version: 6.0.3 vite: specifier: ^6.3.5 version: 6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) @@ -157,6 +157,9 @@ importers: '@embedpdf/plugin-viewport': specifier: workspace:* version: link:../../packages/plugin-viewport + '@embedpdf/plugin-watermark': + specifier: workspace:* + version: link:../../packages/plugin-watermark '@embedpdf/plugin-zoom': specifier: workspace:* version: link:../../packages/plugin-zoom @@ -815,7 +818,7 @@ importers: version: 6.2.4(svelte@5.50.1)(vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitejs/plugin-vue': specifier: ^6.0.3 - version: 6.0.4(vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.28(typescript@5.9.3)) + version: 6.0.4(vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.28(typescript@6.0.3)) glob: specifier: ^13.0.0 version: 13.0.2 @@ -824,10 +827,10 @@ importers: version: 5.50.1 svelte2tsx: specifier: ~0.7.33 - version: 0.7.47(svelte@5.50.1)(typescript@5.9.3) + version: 0.7.47(svelte@5.50.1)(typescript@6.0.3) unplugin-dts: specifier: 1.0.0-beta.6 - version: 1.0.0-beta.6(@microsoft/api-extractor@7.56.3(@types/node@22.19.11))(@vue/language-core@3.2.4)(esbuild@0.27.3)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.1(esbuild@0.27.3)) + version: 1.0.0-beta.6(@microsoft/api-extractor@7.56.3(@types/node@22.19.11))(@vue/language-core@3.2.4)(esbuild@0.27.3)(rollup@4.57.1)(typescript@6.0.3)(vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.1(esbuild@0.27.3)) vite: specifier: ^6.3.5 version: 6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) @@ -2253,6 +2256,25 @@ importers: specifier: ^5.0.0 version: 5.9.3 + packages/plugin-watermark: + dependencies: + '@embedpdf/models': + specifier: workspace:* + version: link:../models + devDependencies: + '@embedpdf/build': + specifier: workspace:* + version: link:../build + '@embedpdf/core': + specifier: workspace:* + version: link:../core + '@embedpdf/plugin-annotation': + specifier: workspace:* + version: link:../plugin-annotation + typescript: + specifier: ^5.0.0 + version: 5.9.3 + packages/plugin-zoom: dependencies: '@embedpdf/models': @@ -2356,7 +2378,7 @@ importers: version: 5.9.3 unplugin-dts: specifier: 1.0.0-beta.6 - version: 1.0.0-beta.6(@microsoft/api-extractor@7.56.3(@types/node@22.19.11))(@vue/language-core@3.2.4)(esbuild@0.27.3)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.1(esbuild@0.27.3)) + version: 1.0.0-beta.6(@microsoft/api-extractor@7.56.3(@types/node@22.19.11))(esbuild@0.27.3)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.1(esbuild@0.27.3)) vite: specifier: ^6.3.5 version: 6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) @@ -2593,7 +2615,7 @@ importers: version: 5.9.3 unplugin-dts: specifier: 1.0.0-beta.6 - version: 1.0.0-beta.6(@microsoft/api-extractor@7.56.3(@types/node@22.19.11))(@vue/language-core@3.2.4)(esbuild@0.27.3)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.1(esbuild@0.27.3)) + version: 1.0.0-beta.6(@microsoft/api-extractor@7.56.3(@types/node@22.19.11))(esbuild@0.27.3)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.1(esbuild@0.27.3)) vite: specifier: ^6.3.5 version: 6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) @@ -5440,33 +5462,33 @@ packages: '@tsconfig/node22@22.0.5': resolution: {integrity: sha512-hLf2ld+sYN/BtOJjHUWOk568dvjFQkHnLNa6zce25GIH+vxKfvTgm3qpaH6ToF5tu/NN0IH66s+Bb5wElHrLcw==} - '@turbo/darwin-64@2.8.20': - resolution: {integrity: sha512-FQ9EX1xMU5nbwjxXxM3yU88AQQ6Sqc6S44exPRroMcx9XZHqqppl5ymJF0Ig/z3nvQNwDmz1Gsnvxubo+nXWjQ==} + '@turbo/darwin-64@2.9.14': + resolution: {integrity: sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A==} cpu: [x64] os: [darwin] - '@turbo/darwin-arm64@2.8.20': - resolution: {integrity: sha512-Gpyh9ATFGThD6/s9L95YWY54cizg/VRWl2B67h0yofG8BpHf67DFAh9nuJVKG7bY0+SBJDAo5cMur+wOl9YOYw==} + '@turbo/darwin-arm64@2.9.14': + resolution: {integrity: sha512-d23147mC9BsCPA9mJ0h/ubcpbRgcJBXbcG3+Vq7YLhjz3IXuvQsJ1UXH8f4MD76ZjJ4m/E4aRdJV+MW88CDfbw==} cpu: [arm64] os: [darwin] - '@turbo/linux-64@2.8.20': - resolution: {integrity: sha512-p2QxWUYyYUgUFG0b0kR+pPi8t7c9uaVlRtjTTI1AbCvVqkpjUfCcReBn6DgG/Hu8xrWdKLuyQFaLYFzQskZbcA==} + '@turbo/linux-64@2.9.14': + resolution: {integrity: sha512-P3ZKB5tuUDdDQWuAsACGUR1qv9W7BNWxdxqVJ0kZNuNNPRaVYTPPikLcp79+GiEcW3npsR+KyP38lnQiBc5aSA==} cpu: [x64] os: [linux] - '@turbo/linux-arm64@2.8.20': - resolution: {integrity: sha512-Gn5yjlZGLRZWarLWqdQzv0wMqyBNIdq1QLi48F1oY5Lo9kiohuf7BPQWtWxeNVS2NgJ1+nb/DzK1JduYC4AWOA==} + '@turbo/linux-arm64@2.9.14': + resolution: {integrity: sha512-ZRTlzcUMrrPv9ZuDzRF9n60Ym13bKeG9jDB8WjxyLhWNzV+AJQN+zdpIk3NJYf2zQsGUm1mNar2P0elRzLw25g==} cpu: [arm64] os: [linux] - '@turbo/windows-64@2.8.20': - resolution: {integrity: sha512-vyaDpYk/8T6Qz5V/X+ihKvKFEZFUoC0oxYpC1sZanK6gaESJlmV3cMRT3Qhcg4D2VxvtC2Jjs9IRkrZGL+exLw==} + '@turbo/windows-64@2.9.14': + resolution: {integrity: sha512-exanwN6sIduZwykYeiTQj8kCmOhazP5WOz3bvXMcYtjhL6Z3iRWLewKrXCBq0bqwSP3iBMb/AerRCnHI4lx46A==} cpu: [x64] os: [win32] - '@turbo/windows-arm64@2.8.20': - resolution: {integrity: sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw==} + '@turbo/windows-arm64@2.9.14': + resolution: {integrity: sha512-fVdCsnmYoKICsycbWuuGp6Jvi51/3G/UluFWuAUCvR8PIW5IJkAk5BM9UF8PSm0Q2IphWHFZjYEgjHsh3B9y/g==} cpu: [arm64] os: [win32] @@ -8705,11 +8727,13 @@ packages: lucide-svelte@0.555.0: resolution: {integrity: sha512-2kWIcstKGgQObLGpYXmcYwm/Y83Am+AQcP7MnybrmaRU1+QUJ/WSEpv/wAbwaSkrWUiN1TX/9FuWQKLeEeWCrQ==} + deprecated: Package deprecated. Please use @lucide/svelte instead. peerDependencies: svelte: ^3 || ^4 || ^5.0.0-next.42 lucide-vue-next@0.562.0: resolution: {integrity: sha512-LN0BLGKMFulv0lnfK29r14DcngRUhIqdcaL0zXTt2o0oS9odlrjCGaU3/X9hIihOjjN8l8e+Y9G/famcNYaI7Q==} + deprecated: Package deprecated. Please use @lucide/vue instead. peerDependencies: vue: '>=3.0.1' @@ -10664,6 +10688,7 @@ packages: tar@7.5.7: resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} @@ -10813,8 +10838,8 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo@2.8.20: - resolution: {integrity: sha512-Rb4qk5YT8RUwwdXtkLpkVhNEe/lor6+WV7S5tTlLpxSz6MjV5Qi8jGNn4gS6NAvrYGA/rNrE6YUQM85sCZUDbQ==} + turbo@2.9.14: + resolution: {integrity: sha512-BQqXRr4UoWI3UPFrtznCLykYHxwxWh53iCB57x092jPMjIlW1wnm3N895g5irpiXmnxUhREBB0n6+y8BHhs4nw==} hasBin: true turbowatch@2.30.0: @@ -10887,6 +10912,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} @@ -14359,22 +14389,22 @@ snapshots: '@tsconfig/node22@22.0.5': {} - '@turbo/darwin-64@2.8.20': + '@turbo/darwin-64@2.9.14': optional: true - '@turbo/darwin-arm64@2.8.20': + '@turbo/darwin-arm64@2.9.14': optional: true - '@turbo/linux-64@2.8.20': + '@turbo/linux-64@2.9.14': optional: true - '@turbo/linux-arm64@2.8.20': + '@turbo/linux-arm64@2.9.14': optional: true - '@turbo/windows-64@2.8.20': + '@turbo/windows-64@2.9.14': optional: true - '@turbo/windows-arm64@2.8.20': + '@turbo/windows-arm64@2.9.14': optional: true '@tybys/wasm-util@0.10.1': @@ -14678,6 +14708,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.2(jiti@2.6.1))(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.55.0 + eslint: 9.39.2(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.55.0 @@ -14690,6 +14736,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.55.0 + debug: 4.4.3 + eslint: 9.39.2(jiti@2.6.1) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/project-service@8.55.0(typescript@5.9.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) @@ -14699,6 +14757,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.55.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@6.0.3) + '@typescript-eslint/types': 8.55.0 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.55.0': dependencies: '@typescript-eslint/types': 8.55.0 @@ -14708,6 +14775,10 @@ snapshots: dependencies: typescript: 5.9.3 + '@typescript-eslint/tsconfig-utils@8.55.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + '@typescript-eslint/type-utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.55.0 @@ -14720,6 +14791,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@6.0.3) + debug: 4.4.3 + eslint: 9.39.2(jiti@2.6.1) + ts-api-utils: 2.4.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/types@8.55.0': {} '@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)': @@ -14737,6 +14820,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.55.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.55.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@6.0.3) + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/visitor-keys': 8.55.0 + debug: 4.4.3 + minimatch: 9.0.5 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) @@ -14748,6 +14846,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.55.0 + '@typescript-eslint/types': 8.55.0 + '@typescript-eslint/typescript-estree': 8.55.0(typescript@6.0.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.55.0': dependencies: '@typescript-eslint/types': 8.55.0 @@ -14858,6 +14967,12 @@ snapshots: vite: 6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) vue: 3.5.28(typescript@5.9.3) + '@vitejs/plugin-vue@6.0.4(vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.28(typescript@6.0.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.2 + vite: 6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vue: 3.5.28(typescript@6.0.3) + '@volar/language-core@2.4.27': dependencies: '@volar/source-map': 2.4.27 @@ -14962,6 +15077,12 @@ snapshots: '@vue/shared': 3.5.28 vue: 3.5.28(typescript@5.9.3) + '@vue/server-renderer@3.5.28(vue@3.5.28(typescript@6.0.3))': + dependencies: + '@vue/compiler-ssr': 3.5.28 + '@vue/shared': 3.5.28 + vue: 3.5.28(typescript@6.0.3) + '@vue/shared@3.5.28': {} '@vue/tsconfig@0.8.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3))': @@ -16429,6 +16550,16 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@6.0.3) + eslint: 9.39.2(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + transitivePeerDependencies: + - supports-color + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 @@ -16458,6 +16589,35 @@ snapshots: - eslint-import-resolver-webpack - supports-color + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@6.0.3))(eslint@9.39.2(jiti@2.6.1)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.2(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.2(jiti@2.6.1)) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@6.0.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.6.1)): dependencies: aria-query: 5.3.2 @@ -20869,6 +21029,13 @@ snapshots: svelte: 5.50.1 typescript: 5.9.3 + svelte2tsx@0.7.47(svelte@5.50.1)(typescript@6.0.3): + dependencies: + dedent-js: 1.0.1 + scule: 1.3.0 + svelte: 5.50.1 + typescript: 6.0.3 + svelte@5.50.1: dependencies: '@jridgewell/remapping': 2.3.5 @@ -21000,6 +21167,10 @@ snapshots: dependencies: typescript: 5.9.3 + ts-api-utils@2.4.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + ts-custom-error@3.3.1: {} ts-dedent@2.2.0: {} @@ -21065,14 +21236,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo@2.8.20: + turbo@2.9.14: optionalDependencies: - '@turbo/darwin-64': 2.8.20 - '@turbo/darwin-arm64': 2.8.20 - '@turbo/linux-64': 2.8.20 - '@turbo/linux-arm64': 2.8.20 - '@turbo/windows-64': 2.8.20 - '@turbo/windows-arm64': 2.8.20 + '@turbo/darwin-64': 2.9.14 + '@turbo/darwin-arm64': 2.9.14 + '@turbo/linux-64': 2.9.14 + '@turbo/linux-arm64': 2.9.14 + '@turbo/windows-64': 2.9.14 + '@turbo/windows-arm64': 2.9.14 turbowatch@2.30.0: dependencies: @@ -21164,6 +21335,8 @@ snapshots: typescript@5.9.3: {} + typescript@6.0.3: {} + ufo@1.6.3: {} uglify-js@3.19.3: @@ -21283,7 +21456,7 @@ snapshots: universalify@2.0.1: {} - unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.56.3(@types/node@22.19.11))(@vue/language-core@3.2.4)(esbuild@0.27.3)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.1(esbuild@0.27.3)): + unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.56.3(@types/node@22.19.11))(@vue/language-core@3.2.4)(esbuild@0.27.3)(rollup@4.57.1)(typescript@6.0.3)(vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.1(esbuild@0.27.3)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.57.1) '@volar/typescript': 2.4.28 @@ -21292,7 +21465,7 @@ snapshots: kolorist: 1.8.0 local-pkg: 1.1.2 magic-string: 0.30.21 - typescript: 5.9.3 + typescript: 6.0.3 unplugin: 2.3.11 optionalDependencies: '@microsoft/api-extractor': 7.56.3(@types/node@22.19.11) @@ -21304,6 +21477,26 @@ snapshots: transitivePeerDependencies: - supports-color + unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.56.3(@types/node@22.19.11))(esbuild@0.27.3)(rollup@4.57.1)(typescript@5.9.3)(vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.1(esbuild@0.27.3)): + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.57.1) + '@volar/typescript': 2.4.28 + compare-versions: 6.1.1 + debug: 4.4.3 + kolorist: 1.8.0 + local-pkg: 1.1.2 + magic-string: 0.30.21 + typescript: 5.9.3 + unplugin: 2.3.11 + optionalDependencies: + '@microsoft/api-extractor': 7.56.3(@types/node@22.19.11) + esbuild: 0.27.3 + rollup: 4.57.1 + vite: 6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + webpack: 5.105.1(esbuild@0.27.3) + transitivePeerDependencies: + - supports-color + unplugin-fonts@1.4.0(vite@6.4.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.30.2)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: fast-glob: 3.3.3 @@ -21564,6 +21757,16 @@ snapshots: optionalDependencies: typescript: 5.9.3 + vue@3.5.28(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.5.28 + '@vue/compiler-sfc': 3.5.28 + '@vue/runtime-dom': 3.5.28 + '@vue/server-renderer': 3.5.28(vue@3.5.28(typescript@6.0.3)) + '@vue/shared': 3.5.28 + optionalDependencies: + typescript: 6.0.3 + vuetify@3.11.8(typescript@5.9.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.28(typescript@5.9.3)): dependencies: vue: 3.5.28(typescript@5.9.3) From d85611669b882302f7adb1e92104527c2bb89d27 Mon Sep 17 00:00:00 2001 From: Anton Samper Rivaya Date: Thu, 28 May 2026 10:53:28 +0100 Subject: [PATCH 2/6] feat: add vertical and horizontal alignment options for watermark positioning --- .../src/components/watermark-panel/index.tsx | 107 ++++++++-- packages/plugin-watermark/src/lib/types.ts | 14 ++ .../src/lib/watermark-plugin.ts | 193 +++++++++++++++++- .../content/docs/snippet/getting-started.mdx | 2 +- .../docs/snippet/plugins/plugin-spread.mdx | 1 + .../docs/snippet/plugins/plugin-zoom.mdx | 1 + 6 files changed, 284 insertions(+), 34 deletions(-) diff --git a/examples/react-mui/src/components/watermark-panel/index.tsx b/examples/react-mui/src/components/watermark-panel/index.tsx index e3e26d969..0d3227517 100644 --- a/examples/react-mui/src/components/watermark-panel/index.tsx +++ b/examples/react-mui/src/components/watermark-panel/index.tsx @@ -25,8 +25,45 @@ interface WatermarkPanelProps { } const FONT_OPTIONS = ['Helvetica', 'Times-Roman', 'Courier']; +type WatermarkVerticalAlignment = 'top' | 'center' | 'bottom'; +type WatermarkHorizontalAlignment = 'left' | 'center' | 'right'; +const VERTICAL_POSITION_OPTIONS: { value: WatermarkVerticalAlignment; label: string }[] = [ + { value: 'top', label: 'Top' }, + { value: 'center', label: 'Centre' }, + { value: 'bottom', label: 'Bottom' }, +]; +const HORIZONTAL_POSITION_OPTIONS: { value: WatermarkHorizontalAlignment; label: string }[] = [ + { value: 'left', label: 'Left' }, + { value: 'center', label: 'Centre' }, + { value: 'right', label: 'Right' }, +]; +const DEFAULT_PAGE_SIZE = { width: 595, height: 842 }; -export const WatermarkPanel = (_props: WatermarkPanelProps) => { +function getAlignedOrigin( + horizontal: WatermarkHorizontalAlignment, + vertical: WatermarkVerticalAlignment, + pageSize: { width: number; height: number }, + watermarkSize: { width: number; height: number }, +): { x: number; y: number } { + const x = + horizontal === 'left' + ? 0 + : horizontal === 'center' + ? (pageSize.width - watermarkSize.width) / 2 + : pageSize.width - watermarkSize.width; + + const y = + vertical === 'top' + ? 0 + : vertical === 'center' + ? (pageSize.height - watermarkSize.height) / 2 + : pageSize.height - watermarkSize.height; + + return { x, y }; +} + +export const WatermarkPanel = ({ documentId }: WatermarkPanelProps) => { + void documentId; const { provides: watermarkCapability } = useCapability(WatermarkPlugin.id); // Form state @@ -36,8 +73,9 @@ export const WatermarkPanel = (_props: WatermarkPanelProps) => { const [fontFamily, setFontFamily] = useState('Helvetica'); const [colour, setColour] = useState('#FF0000'); const [opacity, setOpacity] = useState(0.3); - const [posX, setPosX] = useState(100); - const [posY, setPosY] = useState(400); + const [verticalPosition, setVerticalPosition] = useState('center'); + const [horizontalPosition, setHorizontalPosition] = + useState('center'); const [width, setWidth] = useState(400); const [height, setHeight] = useState(80); const [rotation, setRotation] = useState(-45); @@ -73,12 +111,20 @@ export const WatermarkPanel = (_props: WatermarkPanelProps) => { if (watermarkType === 'text' && !text.trim()) return; if (watermarkType === 'image' && !imageData) return; + const alignedOrigin = getAlignedOrigin( + horizontalPosition, + verticalPosition, + DEFAULT_PAGE_SIZE, + { width, height }, + ); + const input = watermarkType === 'text' ? { type: 'text' as const, textOptions: { text, fontSize, fontFamily, colour }, - position: { x: posX, y: posY }, + position: alignedOrigin, + alignment: { vertical: verticalPosition, horizontal: horizontalPosition }, size: { width, height }, opacity, rotation, @@ -89,7 +135,8 @@ export const WatermarkPanel = (_props: WatermarkPanelProps) => { : { type: 'image' as const, imageOptions: { data: imageData!, mimeType: imageMimeType }, - position: { x: posX, y: posY }, + position: alignedOrigin, + alignment: { vertical: verticalPosition, horizontal: horizontalPosition }, size: { width, height }, opacity, rotation, @@ -116,8 +163,8 @@ export const WatermarkPanel = (_props: WatermarkPanelProps) => { fontFamily, colour, opacity, - posX, - posY, + verticalPosition, + horizontalPosition, width, height, rotation, @@ -249,23 +296,39 @@ export const WatermarkPanel = (_props: WatermarkPanelProps) => { - Position (PDF points) + Position - setPosX(Number(e.target.value))} - size="small" - /> - setPosY(Number(e.target.value))} - size="small" - /> + + Vertical + + + + Horizontal + + diff --git a/packages/plugin-watermark/src/lib/types.ts b/packages/plugin-watermark/src/lib/types.ts index 48a205d82..64eff7b6c 100644 --- a/packages/plugin-watermark/src/lib/types.ts +++ b/packages/plugin-watermark/src/lib/types.ts @@ -14,6 +14,18 @@ export interface WatermarkPosition { y: number; } +export type WatermarkVerticalAlignment = 'top' | 'center' | 'bottom'; + +export type WatermarkHorizontalAlignment = 'left' | 'center' | 'right'; + +/** + * Optional semantic page alignment for a watermark bounding box. + */ +export interface WatermarkAlignment { + vertical: WatermarkVerticalAlignment; + horizontal: WatermarkHorizontalAlignment; +} + /** * Size in PDF points. */ @@ -80,6 +92,8 @@ export interface WatermarkDefinition { imageOptions?: WatermarkImageOptions; /** Position of the watermark's bottom-left corner in PDF points */ position: WatermarkPosition; + /** Optional semantic page alignment for placement */ + alignment?: WatermarkAlignment; /** Size of the watermark bounding box in PDF points */ size: WatermarkSize; /** Opacity from 0.0 (fully transparent) to 1.0 (fully opaque). Default: 0.5 */ diff --git a/packages/plugin-watermark/src/lib/watermark-plugin.ts b/packages/plugin-watermark/src/lib/watermark-plugin.ts index 54424fd6a..b0b4b05c6 100644 --- a/packages/plugin-watermark/src/lib/watermark-plugin.ts +++ b/packages/plugin-watermark/src/lib/watermark-plugin.ts @@ -239,18 +239,33 @@ export class WatermarkPlugin extends BasePlugin< const pageCount = docState.document.pageCount; const pageIndices = this.resolvePageRange(def.pageRange, pageCount); - const pageMap: Map = + const pageMap: Map< + number, + { index: number; size: { width: number; height: number }; rotation: number } + > = new Map( docState.document.pages.map( - (page: { index: number; size: { width: number; height: number } }) => + (page: { index: number; size: { width: number; height: number }; rotation: number }) => [page.index, page] as const, ), ); if (def.type === 'text') { - this.applyTextWatermark(def, documentId, pageIndices, pageMap, task); + this.applyTextWatermark( + def, + documentId, + pageIndices, + pageMap, + task, + ); } else { - this.applyImageWatermark(def, documentId, pageIndices, pageMap, task); + this.applyImageWatermark( + def, + documentId, + pageIndices, + pageMap, + task, + ); } return task; @@ -264,7 +279,10 @@ export class WatermarkPlugin extends BasePlugin< def: WatermarkDefinition, documentId: string, pageIndices: number[], - pageMap: Map, + pageMap: Map< + number, + { index: number; size: { width: number; height: number }; rotation: number } + >, task: Task, ): void { if (!this.annotation) { @@ -273,7 +291,12 @@ export class WatermarkPlugin extends BasePlugin< } const { pdfString, rect } = this.generateTextAppearance(def); - const placementTargets = this.buildPlacementTargets(def, pageIndices, pageMap, rect); + const placementTargets = this.buildPlacementTargets( + def, + pageIndices, + pageMap, + rect, + ); const placements: WatermarkPlacement[] = []; const annotationScope = this.annotation.forDocument(documentId); @@ -345,7 +368,10 @@ export class WatermarkPlugin extends BasePlugin< def: WatermarkDefinition, documentId: string, pageIndices: number[], - pageMap: Map, + pageMap: Map< + number, + { index: number; size: { width: number; height: number }; rotation: number } + >, task: Task, ): void { if (!this.annotation) { @@ -364,7 +390,12 @@ export class WatermarkPlugin extends BasePlugin< const opacity = def.opacity ?? 0.5; const rotation = def.rotation ?? 0; const baseRect = this.computePlacementRect(def.position, def.size, rotation); - const placementTargets = this.buildPlacementTargets(def, pageIndices, pageMap, baseRect); + const placementTargets = this.buildPlacementTargets( + def, + pageIndices, + pageMap, + baseRect, + ); // Pre-process image to bake in opacity/rotation, then proceed. this.applyOpacityAndRotationToImage(def.imageOptions.data, opacity, rotation).then( @@ -518,7 +549,10 @@ export class WatermarkPlugin extends BasePlugin< private buildPlacementTargets( def: WatermarkDefinition, pageIndices: number[], - pageMap: Map, + pageMap: Map< + number, + { index: number; size: { width: number; height: number }; rotation: number } + >, baseRect: WatermarkRect, ): PlacementTarget[] { const targets: PlacementTarget[] = []; @@ -530,15 +564,17 @@ export class WatermarkPlugin extends BasePlugin< const page = pageMap.get(pageIndex); if (!page) continue; + const baseOrigin = this.resolveBaseOrigin(def, page, baseRect); + const xOrigins = this.expandAxisOrigins( - baseRect.origin.x, + baseOrigin.x, baseRect.size.width, page.size.width, mode === 'horizontal' || mode === 'both', spacingX, ); const yOrigins = this.expandAxisOrigins( - baseRect.origin.y, + baseOrigin.y, baseRect.size.height, page.size.height, mode === 'vertical' || mode === 'both', @@ -561,6 +597,141 @@ export class WatermarkPlugin extends BasePlugin< return targets; } + private resolveBaseOrigin( + def: WatermarkDefinition, + page: { size: { width: number; height: number }; rotation: number }, + baseRect: WatermarkRect, + ): { x: number; y: number } { + if (!def.alignment) { + return baseRect.origin; + } + + const rotation = page.rotation & 3; + const { horizontal: horizontalAlignment, vertical: verticalAlignment } = + this.resolveAlignmentForPageRotation(def.alignment.horizontal, def.alignment.vertical, rotation); + + const x = this.resolveHorizontalAxisOrigin( + horizontalAlignment, + page.size.width, + baseRect.size.width, + ); + const y = this.resolveVerticalAxisOrigin( + verticalAlignment, + page.size.height, + baseRect.size.height, + ); + + return { x, y }; + } + + private resolveAlignmentForPageRotation( + horizontal: 'left' | 'center' | 'right', + vertical: 'top' | 'center' | 'bottom', + rotation: number, + ): { horizontal: 'left' | 'center' | 'right'; vertical: 'top' | 'center' | 'bottom' } { + if (rotation === 0) { + return { horizontal, vertical }; + } + + if (rotation === 1) { + return { + horizontal: this.rotateVerticalIntoHorizontal(vertical, true), + vertical: this.rotateHorizontalIntoVertical(horizontal, true), + }; + } + + if (rotation === 2) { + return { + horizontal: this.flipHorizontal(horizontal), + vertical: this.flipVertical(vertical), + }; + } + + // 270° pages swap semantic axes; map vertical -> page-x and horizontal -> page-y. + return { + horizontal: this.rotateVerticalIntoHorizontal(vertical, false), + vertical: this.rotateHorizontalIntoVertical(horizontal, false), + }; + } + + private rotateVerticalIntoHorizontal( + vertical: 'top' | 'center' | 'bottom', + clockwise: boolean, + ): 'left' | 'center' | 'right' { + if (vertical === 'center') { + return 'center'; + } + + if (clockwise) { + return vertical === 'top' ? 'left' : 'right'; + } + + return vertical === 'top' ? 'right' : 'left'; + } + + private rotateHorizontalIntoVertical( + horizontal: 'left' | 'center' | 'right', + clockwise: boolean, + ): 'top' | 'center' | 'bottom' { + if (horizontal === 'center') { + return 'center'; + } + + if (clockwise) { + return horizontal === 'left' ? 'bottom' : 'top'; + } + + return horizontal === 'left' ? 'top' : 'bottom'; + } + + private flipHorizontal(alignment: 'left' | 'center' | 'right'): 'left' | 'center' | 'right' { + if (alignment === 'center') { + return 'center'; + } + + return alignment === 'left' ? 'right' : 'left'; + } + + private flipVertical(alignment: 'top' | 'center' | 'bottom'): 'top' | 'center' | 'bottom' { + if (alignment === 'center') { + return 'center'; + } + + return alignment === 'top' ? 'bottom' : 'top'; + } + + private resolveHorizontalAxisOrigin( + alignment: 'left' | 'center' | 'right', + pageSize: number, + itemSize: number, + ): number { + if (alignment === 'left') { + return 0; + } + + if (alignment === 'center') { + return (pageSize - itemSize) / 2; + } + + return pageSize - itemSize; + } + + private resolveVerticalAxisOrigin( + alignment: 'top' | 'center' | 'bottom', + pageSize: number, + itemSize: number, + ): number { + if (alignment === 'top') { + return 0; + } + + if (alignment === 'center') { + return (pageSize - itemSize) / 2; + } + + return pageSize - itemSize; + } + private expandAxisOrigins( start: number, itemSize: number, diff --git a/website/src/content/docs/snippet/getting-started.mdx b/website/src/content/docs/snippet/getting-started.mdx index 425574fb2..e648a1988 100644 --- a/website/src/content/docs/snippet/getting-started.mdx +++ b/website/src/content/docs/snippet/getting-started.mdx @@ -2,7 +2,7 @@ title: Getting Started with Snippet description: Add a full-featured PDF viewer to your application in minutes using the EmbedPDF Snippet. --- -{/* @embedpdf-version 2.0.0-next.2 */} +{/* @embedpdf-version 2.14.3 */} import { Tabs } from '@/components/tabs' import { FrameworkCards } from '@/components/framework-cards' diff --git a/website/src/content/docs/snippet/plugins/plugin-spread.mdx b/website/src/content/docs/snippet/plugins/plugin-spread.mdx index 4a52e9627..06abc6015 100644 --- a/website/src/content/docs/snippet/plugins/plugin-spread.mdx +++ b/website/src/content/docs/snippet/plugins/plugin-spread.mdx @@ -2,6 +2,7 @@ title: Spread Layouts description: Display pages individually or in a two-page, book-like spread layout. --- +{/* @embedpdf-version 2.14.3 */} import SpreadExample from '../code-examples/spread-example'; import { ExampleWrapper } from '@/components/example-wrapper'; diff --git a/website/src/content/docs/snippet/plugins/plugin-zoom.mdx b/website/src/content/docs/snippet/plugins/plugin-zoom.mdx index 06759afab..8218cfe97 100644 --- a/website/src/content/docs/snippet/plugins/plugin-zoom.mdx +++ b/website/src/content/docs/snippet/plugins/plugin-zoom.mdx @@ -2,6 +2,7 @@ title: Zoom description: Configure initial zoom levels and control zooming programmatically. --- +{/* @embedpdf-version 2.14.3 */} import ZoomExample from '../code-examples/zoom-example'; import { ExampleWrapper } from '@/components/example-wrapper'; From a40d5284dddd8e9c60950a17d0d8e22ea334ebfd Mon Sep 17 00:00:00 2001 From: Anton Samper Rivaya Date: Fri, 29 May 2026 15:55:03 +0100 Subject: [PATCH 3/6] feat: implement optimized tiling for watermark appearances using XObject references --- examples/react-mui/src/application.tsx | 96 ++++++++- .../src/components/watermark-panel/index.tsx | 166 +++++++++++++- .../src/lib/orchestrator/pdf-engine.ts | 18 ++ .../src/lib/orchestrator/remote-executor.ts | 9 + packages/engines/src/lib/pdfium/engine.ts | 182 ++++++++++++++++ packages/engines/src/lib/webworker/engine.ts | 30 +++ packages/engines/src/lib/webworker/runner.ts | 29 +++ packages/models/src/pdf.ts | 14 ++ packages/plugin-watermark/README.md | 27 +++ .../src/lib/watermark-plugin.ts | 203 +++++++++++++++++- 10 files changed, 760 insertions(+), 14 deletions(-) diff --git a/examples/react-mui/src/application.tsx b/examples/react-mui/src/application.tsx index f9fec5aa2..99b5e452b 100644 --- a/examples/react-mui/src/application.tsx +++ b/examples/react-mui/src/application.tsx @@ -41,7 +41,7 @@ import { WatermarkPluginPackage } from '@embedpdf/plugin-watermark'; import { CircularProgress, Box, Alert } from '@mui/material'; import SearchOutlinedIcon from '@mui/icons-material/SearchOutlined'; import BrandingWatermarkOutlinedIcon from '@mui/icons-material/BrandingWatermarkOutlined'; -import { useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { PageControls } from './components/page-controls'; import { Search } from './components/search'; @@ -54,6 +54,7 @@ import { RedactionSelectionMenu } from './components/redaction-selection-menu'; import { WatermarkPanel } from './components/watermark-panel'; const consoleLogger = new ConsoleLogger(); +const WATERMARK_TAKEOVER_PLACEMENT_THRESHOLD = 500; function App() { const isDev = useMemo( @@ -63,6 +64,47 @@ function App() { const { engine, isLoading, error } = usePdfiumEngine(isDev ? { logger: consoleLogger } : {}); const popperContainerRef = useRef(null); + const [watermarkBusy, setWatermarkBusy] = useState<{ + isApplying: boolean; + status: string; + fullPageTakeover: boolean; + startedAt: number; + }>({ + isApplying: false, + status: '', + fullPageTakeover: false, + startedAt: 0, + }); + const [watermarkBusyElapsedSeconds, setWatermarkBusyElapsedSeconds] = useState(0); + + const handleWatermarkApplyStateChange = useCallback( + (state: { isApplying: boolean; status: string; fullPageTakeover: boolean }) => { + setWatermarkBusy((prev) => ({ + isApplying: state.isApplying, + status: state.status, + fullPageTakeover: state.fullPageTakeover, + startedAt: state.isApplying ? (prev.isApplying ? prev.startedAt : Date.now()) : 0, + })); + }, + [], + ); + + useEffect(() => { + if (!watermarkBusy.isApplying || watermarkBusy.startedAt === 0) { + setWatermarkBusyElapsedSeconds(0); + return; + } + + const tick = () => { + setWatermarkBusyElapsedSeconds( + Math.max(0, Math.floor((Date.now() - watermarkBusy.startedAt) / 1000)), + ); + }; + + tick(); + const intervalId = globalThis.setInterval(tick, 1000); + return () => globalThis.clearInterval(intervalId); + }, [watermarkBusy.isApplying, watermarkBusy.startedAt]); const plugins = useMemo( () => [ @@ -199,7 +241,11 @@ function App() { icon: BrandingWatermarkOutlinedIcon, label: 'Watermark', position: 'right', - props: { documentId: activeDocumentId }, + props: { + documentId: activeDocumentId, + takeoverPlacementThreshold: WATERMARK_TAKEOVER_PLACEMENT_THRESHOLD, + onApplyStateChange: handleWatermarkApplyStateChange, + }, }, ]; @@ -340,6 +386,52 @@ function App() { {/* Right Sidebar */} + + {watermarkBusy.isApplying && watermarkBusy.fullPageTakeover && ( + + + + + {watermarkBusy.status || 'Applying watermark across pages...'} + + + + Viewer interactions are paused while watermark processing is in progress. + + {watermarkBusyElapsedSeconds >= 15 && ( + + Still running ({watermarkBusyElapsedSeconds}s). Large documents can take a while. + + )} + + + + )} ); diff --git a/examples/react-mui/src/components/watermark-panel/index.tsx b/examples/react-mui/src/components/watermark-panel/index.tsx index 0d3227517..5cc911007 100644 --- a/examples/react-mui/src/components/watermark-panel/index.tsx +++ b/examples/react-mui/src/components/watermark-panel/index.tsx @@ -1,7 +1,9 @@ import { useCallback, useRef, useState } from 'react'; import { + Alert, Box, Button, + CircularProgress, Divider, FormControl, FormControlLabel, @@ -19,14 +21,22 @@ import AddIcon from '@mui/icons-material/Add'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; import { useCapability } from '@embedpdf/core/react'; import { WatermarkPlugin } from '@embedpdf/plugin-watermark'; +import { DocumentManagerPlugin } from '@embedpdf/plugin-document-manager'; interface WatermarkPanelProps { documentId: string; + takeoverPlacementThreshold?: number; + onApplyStateChange?: (state: { + isApplying: boolean; + status: string; + fullPageTakeover: boolean; + }) => void; } const FONT_OPTIONS = ['Helvetica', 'Times-Roman', 'Courier']; type WatermarkVerticalAlignment = 'top' | 'center' | 'bottom'; type WatermarkHorizontalAlignment = 'left' | 'center' | 'right'; +type WatermarkRepeatMode = 'none' | 'horizontal' | 'vertical' | 'both'; const VERTICAL_POSITION_OPTIONS: { value: WatermarkVerticalAlignment; label: string }[] = [ { value: 'top', label: 'Top' }, { value: 'center', label: 'Centre' }, @@ -37,7 +47,26 @@ const HORIZONTAL_POSITION_OPTIONS: { value: WatermarkHorizontalAlignment; label: { value: 'center', label: 'Centre' }, { value: 'right', label: 'Right' }, ]; +const REPEAT_MODE_OPTIONS: { value: WatermarkRepeatMode; label: string }[] = [ + { value: 'none', label: 'None' }, + { value: 'horizontal', label: 'Horizontal' }, + { value: 'vertical', label: 'Vertical' }, + { value: 'both', label: 'Both (full tiling)' }, +]; const DEFAULT_PAGE_SIZE = { width: 595, height: 842 }; +const DEFAULT_TAKEOVER_PLACEMENT_THRESHOLD = 500; + +function estimateAxisCount( + pageSize: number, + itemSize: number, + repeatEnabled: boolean, + spacing: number, +): number { + if (!repeatEnabled) return 1; + const step = itemSize + Math.max(0, spacing); + if (step <= 0) return 1; + return Math.max(1, Math.ceil((pageSize + itemSize) / step)); +} function getAlignedOrigin( horizontal: WatermarkHorizontalAlignment, @@ -62,9 +91,13 @@ function getAlignedOrigin( return { x, y }; } -export const WatermarkPanel = ({ documentId }: WatermarkPanelProps) => { - void documentId; +export const WatermarkPanel = ({ + documentId, + takeoverPlacementThreshold = DEFAULT_TAKEOVER_PLACEMENT_THRESHOLD, + onApplyStateChange, +}: WatermarkPanelProps) => { const { provides: watermarkCapability } = useCapability(WatermarkPlugin.id); + const { provides: documentManager } = useCapability('document-manager'); // Form state const [watermarkType, setWatermarkType] = useState<'text' | 'image'>('text'); @@ -79,9 +112,25 @@ export const WatermarkPanel = ({ documentId }: WatermarkPanelProps) => { const [width, setWidth] = useState(400); const [height, setHeight] = useState(80); const [rotation, setRotation] = useState(-45); + const [repeat, setRepeat] = useState('none'); + const [repeatSpacingX, setRepeatSpacingX] = useState(40); + const [repeatSpacingY, setRepeatSpacingY] = useState(80); const [imageData, setImageData] = useState(null); const [imageName, setImageName] = useState(''); const [imageMimeType, setImageMimeType] = useState<'image/png' | 'image/jpeg'>('image/png'); + const [isApplying, setIsApplying] = useState(false); + const [applyError, setApplyError] = useState(null); + const [applyStatus, setApplyStatus] = useState(''); + const [lastEstimatedPlacements, setLastEstimatedPlacements] = useState(null); + + const updateApplyState = useCallback( + (isApplying: boolean, status: string, fullPageTakeover: boolean) => { + setIsApplying(isApplying); + setApplyStatus(status); + onApplyStateChange?.({ isApplying, status, fullPageTakeover }); + }, + [onApplyStateChange], + ); // Applied watermarks const [appliedWatermarks, setAppliedWatermarks] = useState< @@ -111,6 +160,21 @@ export const WatermarkPanel = ({ documentId }: WatermarkPanelProps) => { if (watermarkType === 'text' && !text.trim()) return; if (watermarkType === 'image' && !imageData) return; + const totalPages = documentManager?.getDocument(documentId)?.pageCount ?? 1; + const repeatX = repeat === 'horizontal' || repeat === 'both'; + const repeatY = repeat === 'vertical' || repeat === 'both'; + const estimatedPlacements = + totalPages * + estimateAxisCount(DEFAULT_PAGE_SIZE.width, width, repeatX, repeatSpacingX) * + estimateAxisCount(DEFAULT_PAGE_SIZE.height, height, repeatY, repeatSpacingY); + const fullPageTakeover = estimatedPlacements > takeoverPlacementThreshold; + setLastEstimatedPlacements(estimatedPlacements); + + setApplyError(null); + updateApplyState(true, 'Applying watermark across pages...', fullPageTakeover); + + let settled = false; + const alignedOrigin = getAlignedOrigin( horizontalPosition, verticalPosition, @@ -128,6 +192,8 @@ export const WatermarkPanel = ({ documentId }: WatermarkPanelProps) => { size: { width, height }, opacity, rotation, + repeat, + repeatSpacing: { x: repeatSpacingX, y: repeatSpacingY }, pageRange: 'all' as const, readOnly: true, printable: true, @@ -140,21 +206,30 @@ export const WatermarkPanel = ({ documentId }: WatermarkPanelProps) => { size: { width, height }, opacity, rotation, + repeat, + repeatSpacing: { x: repeatSpacingX, y: repeatSpacingY }, pageRange: 'all' as const, readOnly: true, printable: true, }; watermarkCapability.addWatermark(input).wait( - (id) => { - const label = - watermarkType === 'text' ? `Text: "${text}"` : `Image: ${imageName}`; + (id: string) => { + if (settled) return; + settled = true; + const baseLabel = watermarkType === 'text' ? `Text: "${text}"` : `Image: ${imageName}`; + const label = repeat === 'none' ? baseLabel : `${baseLabel} (${repeat})`; setAppliedWatermarks((prev) => [...prev, { id, label }]); + updateApplyState(false, '', false); }, - () => { - // Failed silently + (error: { type: string; reason: { message: string } }) => { + if (settled) return; + settled = true; + setApplyError(error.reason.message || 'Failed to apply watermark.'); + updateApplyState(false, '', false); }, ); + }, [ watermarkCapability, watermarkType, @@ -168,9 +243,16 @@ export const WatermarkPanel = ({ documentId }: WatermarkPanelProps) => { width, height, rotation, + repeat, + repeatSpacingX, + repeatSpacingY, imageData, imageName, imageMimeType, + documentManager, + updateApplyState, + documentId, + takeoverPlacementThreshold, ]); return ( @@ -230,6 +312,7 @@ export const WatermarkPanel = ({ documentId }: WatermarkPanelProps) => { setColour(e.target.value)} style={{ width: '100%', height: 32, border: 'none', cursor: 'pointer' }} @@ -244,6 +327,7 @@ export const WatermarkPanel = ({ documentId }: WatermarkPanelProps) => { { size="small" /> + + + Repeat + + + Mode + + + + {repeat !== 'none' && ( + + setRepeatSpacingX(Math.max(0, Number(e.target.value)))} + size="small" + slotProps={{ htmlInput: { min: 0 } }} + /> + setRepeatSpacingY(Math.max(0, Number(e.target.value)))} + size="small" + slotProps={{ htmlInput: { min: 0 } }} + /> + + )} + + + Sandbox preflight runs on the full document before apply. + + {lastEstimatedPlacements !== null && !isApplying && !applyError && ( + + Estimated placements: {lastEstimatedPlacements.toLocaleString()} (full-page takeover above{' '} + {takeoverPlacementThreshold.toLocaleString()}) + + )} + + {isApplying && ( + + + + {applyStatus || 'Applying watermark across pages. Large documents may take longer.'} + + + )} + + {applyError && ( + + {applyError} + + )} + {/* Applied watermarks list */} {appliedWatermarks.length > 0 && ( <> diff --git a/packages/engines/src/lib/orchestrator/pdf-engine.ts b/packages/engines/src/lib/orchestrator/pdf-engine.ts index 7491c398c..1f6bd6336 100644 --- a/packages/engines/src/lib/orchestrator/pdf-engine.ts +++ b/packages/engines/src/lib/orchestrator/pdf-engine.ts @@ -1037,6 +1037,24 @@ export class PdfEngine implements IPdfEngine { ); } + tileAppearanceXObjectBehind( + doc: PdfDocumentObject, + placements: Array<{ pageIndex: number; rect: Rect }>, + appearancePdf: ArrayBuffer, + ): PdfTask { + return this.workerQueue.enqueue( + { + execute: () => this.executor.tileAppearanceXObjectBehind(doc, placements, appearancePdf), + meta: { + docId: doc.id, + pageIndex: placements[0]?.pageIndex, + operation: 'tileAppearanceXObjectBehind', + }, + }, + { priority: Priority.MEDIUM }, + ); + } + exportAnnotationAppearanceAsPdf( doc: PdfDocumentObject, page: PdfPageObject, diff --git a/packages/engines/src/lib/orchestrator/remote-executor.ts b/packages/engines/src/lib/orchestrator/remote-executor.ts index 636102ed8..2a2912bd4 100644 --- a/packages/engines/src/lib/orchestrator/remote-executor.ts +++ b/packages/engines/src/lib/orchestrator/remote-executor.ts @@ -118,6 +118,7 @@ type MessageType = | 'applyAllRedactions' | 'flattenAnnotation' | 'flattenAnnotationBehind' + | 'tileAppearanceXObjectBehind' | 'exportAnnotationAppearanceAsPdf' | 'exportAnnotationsAppearanceAsPdf' | 'getTextSlices' @@ -616,6 +617,14 @@ export class RemoteExecutor implements IPdfiumExecutor { return this.send('flattenAnnotationBehind', [doc, page, annotation]); } + tileAppearanceXObjectBehind( + doc: PdfDocumentObject, + placements: Array<{ pageIndex: number; rect: Rect }>, + appearancePdf: ArrayBuffer, + ): PdfTask { + return this.send('tileAppearanceXObjectBehind', [doc, placements, appearancePdf]); + } + exportAnnotationAppearanceAsPdf( doc: PdfDocumentObject, page: PdfPageObject, diff --git a/packages/engines/src/lib/pdfium/engine.ts b/packages/engines/src/lib/pdfium/engine.ts index 6a28a15bc..14eb405b4 100644 --- a/packages/engines/src/lib/pdfium/engine.ts +++ b/packages/engines/src/lib/pdfium/engine.ts @@ -7300,6 +7300,188 @@ export class PdfiumNative implements IPdfiumExecutor { return PdfTaskHelper.resolve(!!ok); } + /** + * Place one appearance PDF many times as form XObject references. + * This keeps the source appearance shared, reducing output size. + */ + public tileAppearanceXObjectBehind( + doc: PdfDocumentObject, + placements: Array<{ pageIndex: number; rect: Rect }>, + appearancePdf: ArrayBuffer, + ): PdfTask { + this.logger.debug( + LOG_SOURCE, + LOG_CATEGORY, + 'tileAppearanceXObjectBehind', + doc.id, + placements.length, + ); + const label = 'TileAppearanceXObjectBehind'; + this.logger.perf(LOG_SOURCE, LOG_CATEGORY, label, 'Begin', doc.id); + + const ctx = this.cache.getContext(doc.id); + if (!ctx) { + this.logger.perf(LOG_SOURCE, LOG_CATEGORY, label, 'End', doc.id); + return PdfTaskHelper.reject({ + code: PdfErrorCode.DocNotOpen, + message: 'document does not open', + }); + } + + if (placements.length === 0) { + this.logger.perf(LOG_SOURCE, LOG_CATEGORY, label, 'End', doc.id); + return PdfTaskHelper.resolve(true); + } + + const data = new Uint8Array(appearancePdf); + const filePtr = this.memoryManager.malloc(data.byteLength); + if (!filePtr) { + this.logger.perf(LOG_SOURCE, LOG_CATEGORY, label, 'End', doc.id); + return PdfTaskHelper.reject({ + code: PdfErrorCode.CantCreateNewDoc, + message: 'failed to allocate appearance buffer', + }); + } + this.pdfiumModule.pdfium.HEAPU8.set(data, filePtr); + + const appearanceDocPtr = this.pdfiumModule.FPDF_LoadMemDocument(filePtr, data.byteLength, ''); + if (!appearanceDocPtr) { + this.memoryManager.free(filePtr); + this.logger.perf(LOG_SOURCE, LOG_CATEGORY, label, 'End', doc.id); + return PdfTaskHelper.reject({ + code: PdfErrorCode.CantCreateNewDoc, + message: 'failed to load appearance PDF', + }); + } + + const sourcePagePtr = this.pdfiumModule.FPDF_LoadPage(appearanceDocPtr, 0); + if (!sourcePagePtr) { + this.pdfiumModule.FPDF_CloseDocument(appearanceDocPtr); + this.memoryManager.free(filePtr); + this.logger.perf(LOG_SOURCE, LOG_CATEGORY, label, 'End', doc.id); + return PdfTaskHelper.reject({ + code: PdfErrorCode.CantCreateNewDoc, + message: 'appearance PDF has no first page', + }); + } + + const sourceWidth = this.pdfiumModule.FPDF_GetPageWidthF(sourcePagePtr); + const sourceHeight = this.pdfiumModule.FPDF_GetPageHeightF(sourcePagePtr); + this.pdfiumModule.FPDF_ClosePage(sourcePagePtr); + + if (sourceWidth <= 0 || sourceHeight <= 0) { + this.pdfiumModule.FPDF_CloseDocument(appearanceDocPtr); + this.memoryManager.free(filePtr); + this.logger.perf(LOG_SOURCE, LOG_CATEGORY, label, 'End', doc.id); + return PdfTaskHelper.reject({ + code: PdfErrorCode.CantCreateNewDoc, + message: 'appearance PDF has invalid page size', + }); + } + + const xObjectPtr = this.pdfiumModule.FPDF_NewXObjectFromPage(ctx.docPtr, appearanceDocPtr, 0); + if (!xObjectPtr) { + this.pdfiumModule.FPDF_CloseDocument(appearanceDocPtr); + this.memoryManager.free(filePtr); + this.logger.perf(LOG_SOURCE, LOG_CATEGORY, label, 'End', doc.id); + return PdfTaskHelper.reject({ + code: PdfErrorCode.CantCreateNewDoc, + message: 'failed to create watermark XObject', + }); + } + + const grouped = new Map(); + for (const placement of placements) { + const pageRects = grouped.get(placement.pageIndex); + if (pageRects) { + pageRects.push(placement.rect); + } else { + grouped.set(placement.pageIndex, [placement.rect]); + } + } + + const pageByIndex = new Map(doc.pages.map((p) => [p.index, p] as const)); + + let totalInserted = 0; + try { + for (const [pageIndex, rects] of grouped.entries()) { + const page = pageByIndex.get(pageIndex); + if (!page || rects.length === 0) continue; + + ctx.borrowPage(pageIndex, (pageCtx) => { + const beforeCount = this.pdfiumModule.FPDFPage_CountObjects(pageCtx.pagePtr); + let insertionIndex = beforeCount; + for (let i = 0; i < beforeCount; i++) { + const objectPtr = this.pdfiumModule.FPDFPage_GetObject(pageCtx.pagePtr, i); + if (!objectPtr) continue; + const type = this.pdfiumModule.FPDFPageObj_GetType(objectPtr) as PdfPageObjectType; + if (type === PdfPageObjectType.TEXT) { + insertionIndex = i; + break; + } + } + + let inserted = 0; + for (const rect of rects) { + const formObjectPtr = this.pdfiumModule.FPDF_NewFormObjectFromXObject(xObjectPtr); + if (!formObjectPtr) continue; + + const matrixPtr = this.memoryManager.malloc(6 * 4); + if (!matrixPtr) { + this.pdfiumModule.FPDFPageObj_Destroy(formObjectPtr); + continue; + } + + this.pdfiumModule.pdfium.setValue(matrixPtr, rect.size.width / sourceWidth, 'float'); + this.pdfiumModule.pdfium.setValue(matrixPtr + 4, 0, 'float'); + this.pdfiumModule.pdfium.setValue(matrixPtr + 8, 0, 'float'); + this.pdfiumModule.pdfium.setValue(matrixPtr + 12, rect.size.height / sourceHeight, 'float'); + this.pdfiumModule.pdfium.setValue(matrixPtr + 16, 0, 'float'); + this.pdfiumModule.pdfium.setValue(matrixPtr + 20, 0, 'float'); + + if (!this.pdfiumModule.FPDFPageObj_SetMatrix(formObjectPtr, matrixPtr)) { + this.memoryManager.free(matrixPtr); + this.pdfiumModule.FPDFPageObj_Destroy(formObjectPtr); + continue; + } + this.memoryManager.free(matrixPtr); + + const pagePos = this.convertDevicePointToPagePoint(doc, page, { + x: rect.origin.x, + y: rect.origin.y + rect.size.height, + }); + this.pdfiumModule.FPDFPageObj_Transform(formObjectPtr, 1, 0, 0, 1, pagePos.x, pagePos.y); + + this.pdfiumModule.FPDFPage_InsertObjectAtIndex( + pageCtx.pagePtr, + formObjectPtr, + insertionIndex, + ); + insertionIndex++; + inserted++; + } + + if (inserted > 0) { + this.pdfiumModule.FPDFPage_GenerateContent(pageCtx.pagePtr); + totalInserted += inserted; + } + }); + } + } catch { + this.pdfiumModule.FPDF_CloseXObject(xObjectPtr); + this.pdfiumModule.FPDF_CloseDocument(appearanceDocPtr); + this.memoryManager.free(filePtr); + this.logger.perf(LOG_SOURCE, LOG_CATEGORY, label, 'End', doc.id); + return PdfTaskHelper.resolve(false); + } + + this.pdfiumModule.FPDF_CloseXObject(xObjectPtr); + this.pdfiumModule.FPDF_CloseDocument(appearanceDocPtr); + this.memoryManager.free(filePtr); + this.logger.perf(LOG_SOURCE, LOG_CATEGORY, label, 'End', doc.id); + return PdfTaskHelper.resolve(totalInserted > 0); + } + /** * Export an annotation's appearance as a standalone single-page PDF. * diff --git a/packages/engines/src/lib/webworker/engine.ts b/packages/engines/src/lib/webworker/engine.ts index 8f04ab969..39d78ac6e 100644 --- a/packages/engines/src/lib/webworker/engine.ts +++ b/packages/engines/src/lib/webworker/engine.ts @@ -1154,6 +1154,36 @@ export class WebWorkerEngine implements PdfEngine { return task; } + /** + * {@inheritDoc @embedpdf/models!PdfEngine.tileAppearanceXObjectBehind} + * + * @public + */ + tileAppearanceXObjectBehind( + doc: PdfDocumentObject, + placements: Array<{ pageIndex: number; rect: Rect }>, + appearancePdf: ArrayBuffer, + ) { + this.logger.debug( + LOG_SOURCE, + LOG_CATEGORY, + 'tileAppearanceXObjectBehind', + doc, + placements.length, + ); + const requestId = this.generateRequestId(doc.id); + const task = new WorkerTask(this.worker, requestId); + + const request: ExecuteRequest = createRequest(requestId, 'tileAppearanceXObjectBehind', [ + doc, + placements, + appearancePdf, + ]); + this.proxy(task, request); + + return task; + } + /** * {@inheritDoc @embedpdf/models!PdfEngine.exportAnnotationAppearanceAsPdf} * diff --git a/packages/engines/src/lib/webworker/runner.ts b/packages/engines/src/lib/webworker/runner.ts index 5136f1c19..e5a3f8b61 100644 --- a/packages/engines/src/lib/webworker/runner.ts +++ b/packages/engines/src/lib/webworker/runner.ts @@ -273,6 +273,35 @@ export class EngineRunner { const engine = this.engine; const { name, args } = request.data; + + // Temporary dynamic branch so worker can serve new methods before all type unions refresh. + if (name === 'tileAppearanceXObjectBehind') { + const tileFn = (engine as PdfEngine & { + tileAppearanceXObjectBehind?: (...a: unknown[]) => PdfEngineMethodReturnType; + }).tileAppearanceXObjectBehind; + if (!tileFn) { + const error: PdfEngineError = { + type: 'reject', + reason: { + code: PdfErrorCode.NotSupport, + message: `engine method ${name} is not supported yet`, + }, + }; + const response: ExecuteResponse = { + id: request.id, + type: 'ExecuteResponse', + data: { + type: 'error', + value: error, + }, + }; + this.respond(response); + return; + } + this.handleTask(request.id, tileFn(...(args as unknown[]))); + return; + } + if (!engine[name]) { const error: PdfEngineError = { type: 'reject', diff --git a/packages/models/src/pdf.ts b/packages/models/src/pdf.ts index 2bbc4cc29..79b9894a2 100644 --- a/packages/models/src/pdf.ts +++ b/packages/models/src/pdf.ts @@ -3809,6 +3809,15 @@ export interface PdfEngine { page: PdfPageObject, annotation: PdfAnnotationObject, ) => PdfTask; + /** + * Place a single appearance PDF many times using XObject form references. + * This reuses one source object across all placements to minimise output size. + */ + tileAppearanceXObjectBehind: ( + doc: PdfDocumentObject, + placements: Array<{ pageIndex: number; rect: Rect }>, + appearancePdf: ArrayBuffer, + ) => PdfTask; /** * Export an annotation's appearance as a standalone PDF document * @param doc - pdf document @@ -4145,6 +4154,11 @@ export interface IPdfiumExecutor { page: PdfPageObject, annotation: PdfAnnotationObject, ): PdfTask; + tileAppearanceXObjectBehind( + doc: PdfDocumentObject, + placements: Array<{ pageIndex: number; rect: Rect }>, + appearancePdf: ArrayBuffer, + ): PdfTask; exportAnnotationAppearanceAsPdf( doc: PdfDocumentObject, page: PdfPageObject, diff --git a/packages/plugin-watermark/README.md b/packages/plugin-watermark/README.md index 563edf9b9..f33b38f35 100644 --- a/packages/plugin-watermark/README.md +++ b/packages/plugin-watermark/README.md @@ -9,6 +9,7 @@ A plugin for [EmbedPDF](https://www.embedpdf.com) that allows embedding text or - **Image rotation** — rotation is applied consistently for image watermarks - **Precise positioning** — place watermarks at exact PDF coordinates - **Repeat tiling** — repeat watermarks horizontally, vertically, or both +- **Optimised tiling** — one watermark appearance object is reused across all tiles via XObject references - **Page range control** — apply to all pages or specific page indices - **Configurable flags** — read-only, printable, rotation support - **Auto-apply** — optionally apply watermarks automatically when documents load @@ -129,6 +130,32 @@ watermark.clearFromDocument(documentId); | `repeat` | `'none' \| 'horizontal' \| 'vertical' \| 'both'` | `'none'` | Repeat the watermark across each target page | | `repeatSpacing` | `{ x?: number; y?: number }` | `{ x: 0, y: 0 }` | Spacing (PDF points) between repeated instances | +## Tiling strategy + +The plugin uses an XObject tiling path first: + +- A single watermark appearance (text or image) is created once. +- All repeated placements reference that same appearance object. +- This reduces per-tile work and keeps saved PDFs smaller. + +If the XObject path cannot be applied safely for a document/page, the plugin falls back to the existing sequential stamp+flatten path. + +## Acceptance checks + +Validate each row in the matrix below in both viewer preview and exported/saved output: + +| Repeat mode | Text watermark | Image watermark | Preview vs export parity | +|-------------|----------------|-----------------|--------------------------| +| `none` | ✅ | ✅ | visually identical | +| `horizontal` | ✅ | ✅ | visually identical | +| `vertical` | ✅ | ✅ | visually identical | +| `both` | ✅ | ✅ | visually identical | + +Additional checks: + +- Inspect the saved PDF: repeated instances reuse one appearance object (XObject references), rather than embedding full per-tile duplicates. +- Compare file size against a sequential-only baseline; XObject path should be smaller for repeated watermarks. + ## Licence MIT diff --git a/packages/plugin-watermark/src/lib/watermark-plugin.ts b/packages/plugin-watermark/src/lib/watermark-plugin.ts index b0b4b05c6..d1e94af42 100644 --- a/packages/plugin-watermark/src/lib/watermark-plugin.ts +++ b/packages/plugin-watermark/src/lib/watermark-plugin.ts @@ -111,7 +111,7 @@ export class WatermarkPlugin extends BasePlugin< 'AddWatermark', `Watermark registered but failed to apply: ${error.reason.message}`, ); - task.resolve(def.id); + task.reject(error.reason); }, ); } else { @@ -194,7 +194,7 @@ export class WatermarkPlugin extends BasePlugin< `Failed to apply watermark ${def.id}`, error, ); - task.fail(error); + task.reject(error.reason); } }, ); @@ -251,7 +251,7 @@ export class WatermarkPlugin extends BasePlugin< ); if (def.type === 'text') { - this.applyTextWatermark( + this.applyTextWatermarkOptimized( def, documentId, pageIndices, @@ -259,7 +259,7 @@ export class WatermarkPlugin extends BasePlugin< task, ); } else { - this.applyImageWatermark( + this.applyImageWatermarkOptimized( def, documentId, pageIndices, @@ -271,6 +271,201 @@ export class WatermarkPlugin extends BasePlugin< return task; } + private applyTextWatermarkOptimized( + def: WatermarkDefinition, + documentId: string, + pageIndices: number[], + pageMap: Map< + number, + { index: number; size: { width: number; height: number }; rotation: number } + >, + task: Task, + ): void { + const { pdfString, rect } = this.generateTextAppearance(def); + const placementTargets = this.buildPlacementTargets(def, pageIndices, pageMap, rect); + if (placementTargets.length === 0) { + task.resolve(); + return; + } + + const appearancePdf = new TextEncoder().encode(pdfString).buffer; + this.applyAppearanceWithXObjectTiling(def, documentId, placementTargets, appearancePdf, task, () => + this.applyTextWatermark(def, documentId, pageIndices, pageMap, task), + ); + } + + private applyImageWatermarkOptimized( + def: WatermarkDefinition, + documentId: string, + pageIndices: number[], + pageMap: Map< + number, + { index: number; size: { width: number; height: number }; rotation: number } + >, + task: Task, + ): void { + if (!def.imageOptions) { + task.reject({ + code: PdfErrorCode.NotFound, + message: 'Image options are required for image watermarks', + }); + return; + } + + const opacity = def.opacity ?? 0.5; + const rotation = def.rotation ?? 0; + const baseRect = this.computePlacementRect(def.position, def.size, rotation); + const placementTargets = this.buildPlacementTargets(def, pageIndices, pageMap, baseRect); + if (placementTargets.length === 0) { + task.resolve(); + return; + } + + const fallbackSequential = () => { + this.applyImageWatermark(def, documentId, pageIndices, pageMap, task); + }; + + const tryXObjectWithData = (imageData: ArrayBuffer, mimeType: 'image/png' | 'image/jpeg') => { + this.createImageAppearancePdfFromStamp(documentId, placementTargets[0], imageData, mimeType).wait( + (appearancePdf: ArrayBuffer) => { + this.applyAppearanceWithXObjectTiling( + def, + documentId, + placementTargets, + appearancePdf, + task, + fallbackSequential, + ); + }, + fallbackSequential, + ); + }; + + this.applyOpacityAndRotationToImage(def.imageOptions.data, opacity, rotation).then( + (processedData) => { + tryXObjectWithData(processedData, 'image/png'); + }, + () => { + tryXObjectWithData(def.imageOptions!.data, def.imageOptions!.mimeType); + }, + ); + } + + private applyAppearanceWithXObjectTiling( + def: WatermarkDefinition, + documentId: string, + placementTargets: PlacementTarget[], + appearancePdf: ArrayBuffer, + task: Task, + fallback: () => void, + ): void { + const docState = this.getCoreDocument(documentId); + if (!docState?.document) { + fallback(); + return; + } + + const placements: Array<{ pageIndex: number; rect: WatermarkRect }> = placementTargets.map((target) => ({ + pageIndex: target.pageIndex, + rect: target.rect, + })); + + this.engine.tileAppearanceXObjectBehind(docState.document, placements, appearancePdf).wait( + (ok: boolean) => { + if (!ok) { + this.logger.warn( + 'WatermarkPlugin', + 'XObjectTiling', + `XObject tiling returned false for watermark ${def.id}, falling back to sequential mode`, + ); + fallback(); + return; + } + + const placementRecords: WatermarkPlacement[] = placementTargets.map((target, index) => ({ + documentId, + pageIndex: target.pageIndex, + annotationId: `xobj-${def.id}-${index}`, + })); + + this.dispatch(addPlacements(def.id, placementRecords)); + const pagesToRefresh = Array.from(new Set(placementTargets.map((target) => target.pageIndex))); + this.dispatchCoreAction(refreshPages(documentId, pagesToRefresh)); + task.resolve(); + }, + () => fallback(), + ); + } + + private createImageAppearancePdfFromStamp( + documentId: string, + sourceTarget: PlacementTarget, + imageData: ArrayBuffer, + mimeType: 'image/png' | 'image/jpeg', + ): Task { + const task = new Task(); + + if (!this.annotation) { + task.reject({ code: PdfErrorCode.NotSupport, message: 'Annotation plugin unavailable' }); + return task; + } + + const docState = this.getCoreDocument(documentId); + const doc = docState?.document; + if (!doc) { + task.reject({ code: PdfErrorCode.DocNotOpen, message: 'Document is not open' }); + return task; + } + + const page = doc.pages.find((p: { index: number }) => p.index === sourceTarget.pageIndex); + if (!page) { + task.reject({ code: PdfErrorCode.NotFound, message: `Page ${sourceTarget.pageIndex} not found` }); + return task; + } + + const annotationScope = this.annotation.forDocument(documentId); + const tempAnnotationId = uuidV4(); + const tempAnnotation: PdfStampAnnoObject = { + id: tempAnnotationId, + type: PdfAnnotationSubtype.STAMP, + pageIndex: sourceTarget.pageIndex, + rect: sourceTarget.rect, + flags: ['hidden', 'noView'], + subject: 'Watermark-Source', + }; + + const cleanupAndContinue = (cb: () => void) => { + annotationScope.deleteAnnotation(sourceTarget.pageIndex, tempAnnotationId); + annotationScope.commit().wait( + () => cb(), + () => cb(), + ); + }; + + annotationScope.createAnnotation(sourceTarget.pageIndex, tempAnnotation, { + data: imageData, + mimeType, + }); + + annotationScope.commit().wait( + () => { + this.engine.exportAnnotationAppearanceAsPdf(doc, page, tempAnnotation).wait( + (appearancePdf: ArrayBuffer) => { + cleanupAndContinue(() => task.resolve(appearancePdf)); + }, + (error: { type: string; reason: PdfErrorReason }) => { + cleanupAndContinue(() => task.reject(error.reason)); + }, + ); + }, + (error: { type: string; reason: PdfErrorReason }) => { + task.reject(error.reason); + }, + ); + + return task; + } + /** * Apply a text watermark by creating stamp annotations sequentially, * waiting for each to be committed, then flattening into page content. From ad00182752793e42bb4ea66284a83829e231396b Mon Sep 17 00:00:00 2001 From: Anton Samper Rivaya Date: Fri, 29 May 2026 16:26:25 +0100 Subject: [PATCH 4/6] feat: enhance watermark management with per-document scoping and actions --- .../src/components/watermark-panel/index.tsx | 19 ++- packages/plugin-watermark/README.md | 11 +- packages/plugin-watermark/src/lib/actions.ts | 43 ++++-- packages/plugin-watermark/src/lib/reducer.ts | 81 ++++++++---- packages/plugin-watermark/src/lib/types.ts | 20 +-- .../src/lib/watermark-plugin.ts | 124 ++++++++++++------ 6 files changed, 201 insertions(+), 97 deletions(-) diff --git a/examples/react-mui/src/components/watermark-panel/index.tsx b/examples/react-mui/src/components/watermark-panel/index.tsx index 5cc911007..cae1aa8b4 100644 --- a/examples/react-mui/src/components/watermark-panel/index.tsx +++ b/examples/react-mui/src/components/watermark-panel/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { Alert, Box, @@ -133,9 +133,13 @@ export const WatermarkPanel = ({ ); // Applied watermarks - const [appliedWatermarks, setAppliedWatermarks] = useState< - { id: string; label: string }[] - >([]); + const [appliedWatermarksByDocument, setAppliedWatermarksByDocument] = useState< + Record + >({}); + const appliedWatermarks = useMemo( + () => appliedWatermarksByDocument[documentId] ?? [], + [appliedWatermarksByDocument, documentId], + ); const fileInputRef = useRef(null); @@ -219,7 +223,10 @@ export const WatermarkPanel = ({ settled = true; const baseLabel = watermarkType === 'text' ? `Text: "${text}"` : `Image: ${imageName}`; const label = repeat === 'none' ? baseLabel : `${baseLabel} (${repeat})`; - setAppliedWatermarks((prev) => [...prev, { id, label }]); + setAppliedWatermarksByDocument((prev) => ({ + ...prev, + [documentId]: [...(prev[documentId] ?? []), { id, label }], + })); updateApplyState(false, '', false); }, (error: { type: string; reason: { message: string } }) => { @@ -249,9 +256,9 @@ export const WatermarkPanel = ({ imageData, imageName, imageMimeType, + documentId, documentManager, updateApplyState, - documentId, takeoverPlacementThreshold, ]); diff --git a/packages/plugin-watermark/README.md b/packages/plugin-watermark/README.md index f33b38f35..7761d3636 100644 --- a/packages/plugin-watermark/README.md +++ b/packages/plugin-watermark/README.md @@ -13,6 +13,7 @@ A plugin for [EmbedPDF](https://www.embedpdf.com) that allows embedding text or - **Page range control** — apply to all pages or specific page indices - **Configurable flags** — read-only, printable, rotation support - **Auto-apply** — optionally apply watermarks automatically when documents load +- **Per-document scope** — watermarks added in one document are not applied to other open documents ## Installation @@ -89,7 +90,7 @@ watermark.addWatermark({ ### Removing watermarks -`removeWatermark(id)` removes the **definition** so it no longer applies to future loads/applies. +`removeWatermark(id)` removes the **definition from the active document only**. Because this plugin flattens watermarks into page content, already-applied watermark visuals are permanent in the modified PDF and cannot be removed in-place. @@ -109,10 +110,10 @@ watermark.clearFromDocument(documentId); | Method | Description | |--------|-------------| -| `addWatermark(input)` | Add and apply a watermark; returns the generated ID | -| `removeWatermark(id)` | Remove a watermark definition (already-flattened content remains) | -| `getWatermarks()` | Get all registered watermark definitions | -| `applyToDocument(documentId)` | Apply all watermarks to a specific document | +| `addWatermark(input)` | Add and apply a watermark to the active document; returns the generated ID | +| `removeWatermark(id)` | Remove a watermark definition from the active document (already-flattened content remains) | +| `getWatermarks()` | Get watermark definitions for the active document | +| `applyToDocument(documentId)` | Apply all watermarks registered for that document | | `clearFromDocument(documentId)` | Remove all watermark annotations from a document | | `onWatermarkChange` | Event hook for watermark definition changes | diff --git a/packages/plugin-watermark/src/lib/actions.ts b/packages/plugin-watermark/src/lib/actions.ts index c6372de9d..3a4474d41 100644 --- a/packages/plugin-watermark/src/lib/actions.ts +++ b/packages/plugin-watermark/src/lib/actions.ts @@ -5,20 +5,28 @@ export const ADD_WATERMARK = 'WATERMARK/ADD'; export const REMOVE_WATERMARK = 'WATERMARK/REMOVE'; export const ADD_PLACEMENTS = 'WATERMARK/ADD_PLACEMENTS'; export const CLEAR_PLACEMENTS = 'WATERMARK/CLEAR_PLACEMENTS'; +export const CLEAR_DOCUMENT = 'WATERMARK/CLEAR_DOCUMENT'; export interface AddWatermarkAction extends Action { type: typeof ADD_WATERMARK; - payload: WatermarkDefinition; + payload: { + documentId: string; + definition: WatermarkDefinition; + }; } export interface RemoveWatermarkAction extends Action { type: typeof REMOVE_WATERMARK; - payload: string; // watermark ID + payload: { + documentId: string; + watermarkId: string; + }; } export interface AddPlacementsAction extends Action { type: typeof ADD_PLACEMENTS; payload: { + documentId: string; watermarkId: string; placements: WatermarkPlacement[]; }; @@ -27,8 +35,15 @@ export interface AddPlacementsAction extends Action { export interface ClearPlacementsAction extends Action { type: typeof CLEAR_PLACEMENTS; payload: { + documentId: string; watermarkId: string; - documentId?: string; // if provided, only clear placements for this doc + }; +} + +export interface ClearDocumentAction extends Action { + type: typeof CLEAR_DOCUMENT; + payload: { + documentId: string; }; } @@ -36,24 +51,30 @@ export type WatermarkAction = | AddWatermarkAction | RemoveWatermarkAction | AddPlacementsAction - | ClearPlacementsAction; + | ClearPlacementsAction + | ClearDocumentAction; -export function addWatermark(definition: WatermarkDefinition): AddWatermarkAction { - return { type: ADD_WATERMARK, payload: definition }; +export function addWatermark(documentId: string, definition: WatermarkDefinition): AddWatermarkAction { + return { type: ADD_WATERMARK, payload: { documentId, definition } }; } -export function removeWatermark(id: string): RemoveWatermarkAction { - return { type: REMOVE_WATERMARK, payload: id }; +export function removeWatermark(documentId: string, watermarkId: string): RemoveWatermarkAction { + return { type: REMOVE_WATERMARK, payload: { documentId, watermarkId } }; } export function addPlacements( + documentId: string, watermarkId: string, placements: WatermarkPlacement[], ): AddPlacementsAction { - return { type: ADD_PLACEMENTS, payload: { watermarkId, placements } }; + return { type: ADD_PLACEMENTS, payload: { documentId, watermarkId, placements } }; +} + +export function clearPlacements(documentId: string, watermarkId: string): ClearPlacementsAction { + return { type: CLEAR_PLACEMENTS, payload: { documentId, watermarkId } }; } -export function clearPlacements(watermarkId: string, documentId?: string): ClearPlacementsAction { - return { type: CLEAR_PLACEMENTS, payload: { watermarkId, documentId } }; +export function clearDocument(documentId: string): ClearDocumentAction { + return { type: CLEAR_DOCUMENT, payload: { documentId } }; } diff --git a/packages/plugin-watermark/src/lib/reducer.ts b/packages/plugin-watermark/src/lib/reducer.ts index 7f0d96735..f20b17373 100644 --- a/packages/plugin-watermark/src/lib/reducer.ts +++ b/packages/plugin-watermark/src/lib/reducer.ts @@ -5,12 +5,13 @@ import { REMOVE_WATERMARK, ADD_PLACEMENTS, CLEAR_PLACEMENTS, + CLEAR_DOCUMENT, } from './actions'; import { WatermarkState } from './types'; export const initialState: WatermarkState = { - watermarkIds: [], - placements: {}, + watermarkIdsByDocument: {}, + placementsByDocument: {}, }; export const watermarkReducer: Reducer = ( @@ -18,47 +19,79 @@ export const watermarkReducer: Reducer = ( action, ) => { switch (action.type) { - case ADD_WATERMARK: + case ADD_WATERMARK: { + const { documentId, definition } = action.payload; + const currentIds = state.watermarkIdsByDocument[documentId] ?? []; + const currentPlacements = state.placementsByDocument[documentId] ?? {}; return { ...state, - watermarkIds: [...state.watermarkIds, action.payload.id], - placements: { ...state.placements, [action.payload.id]: [] }, + watermarkIdsByDocument: { + ...state.watermarkIdsByDocument, + [documentId]: [...currentIds, definition.id], + }, + placementsByDocument: { + ...state.placementsByDocument, + [documentId]: { ...currentPlacements, [definition.id]: [] }, + }, }; + } - case REMOVE_WATERMARK: + case REMOVE_WATERMARK: { + const { documentId, watermarkId } = action.payload; + const currentIds = state.watermarkIdsByDocument[documentId] ?? []; + const currentPlacements = state.placementsByDocument[documentId] ?? {}; return { ...state, - watermarkIds: state.watermarkIds.filter((id) => id !== action.payload), - placements: Object.fromEntries( - Object.entries(state.placements).filter(([key]) => key !== action.payload), - ), + watermarkIdsByDocument: { + ...state.watermarkIdsByDocument, + [documentId]: currentIds.filter((id) => id !== watermarkId), + }, + placementsByDocument: { + ...state.placementsByDocument, + [documentId]: Object.fromEntries( + Object.entries(currentPlacements).filter(([key]) => key !== watermarkId), + ), + }, }; + } case ADD_PLACEMENTS: { - const existing = state.placements[action.payload.watermarkId] ?? []; + const { documentId, watermarkId, placements } = action.payload; + const docPlacements = state.placementsByDocument[documentId] ?? {}; + const existing = docPlacements[watermarkId] ?? []; return { ...state, - placements: { - ...state.placements, - [action.payload.watermarkId]: [...existing, ...action.payload.placements], + placementsByDocument: { + ...state.placementsByDocument, + [documentId]: { + ...docPlacements, + [watermarkId]: [...existing, ...placements], + }, }, }; } case CLEAR_PLACEMENTS: { const { watermarkId, documentId } = action.payload; - if (!documentId) { - return { - ...state, - placements: { ...state.placements, [watermarkId]: [] }, - }; - } - const filtered = (state.placements[watermarkId] ?? []).filter( - (p) => p.documentId !== documentId, - ); + const docPlacements = state.placementsByDocument[documentId] ?? {}; + return { + ...state, + placementsByDocument: { + ...state.placementsByDocument, + [documentId]: { ...docPlacements, [watermarkId]: [] }, + }, + }; + } + + case CLEAR_DOCUMENT: { + const { documentId } = action.payload; + const { [documentId]: _removedIds, ...remainingIds } = state.watermarkIdsByDocument; + const { [documentId]: _removedPlacements, ...remainingPlacements } = + state.placementsByDocument; return { ...state, - placements: { ...state.placements, [watermarkId]: filtered }, + watermarkIdsByDocument: remainingIds, + placementsByDocument: remainingPlacements, }; } diff --git a/packages/plugin-watermark/src/lib/types.ts b/packages/plugin-watermark/src/lib/types.ts index 64eff7b6c..352c809b8 100644 --- a/packages/plugin-watermark/src/lib/types.ts +++ b/packages/plugin-watermark/src/lib/types.ts @@ -121,7 +121,7 @@ export type WatermarkInput = Omit; * Configuration for the watermark plugin. */ export interface WatermarkPluginConfig extends BasePluginConfig { - /** Watermarks to apply automatically when a document is loaded */ + /** Watermarks seeded per document and optionally auto-applied when loaded */ watermarks?: WatermarkInput[]; /** Whether to auto-apply configured watermarks on document load. Default: true */ autoApply?: boolean; @@ -131,10 +131,10 @@ export interface WatermarkPluginConfig extends BasePluginConfig { * Internal state tracked by the watermark plugin's reducer. */ export interface WatermarkState { - /** IDs of all registered watermark definitions */ - watermarkIds: string[]; - /** Maps watermark definition ID → array of placed annotation IDs per document */ - placements: Record; + /** Maps document ID -> watermark definition IDs registered for that document */ + watermarkIdsByDocument: Record; + /** Maps document ID -> watermark definition ID -> placed instances */ + placementsByDocument: Record>; } /** @@ -162,23 +162,25 @@ export interface WatermarkChangeEvent { export interface WatermarkCapability { /** * Add a watermark definition and immediately apply it to the active document. + * Watermarks are scoped per document and do not affect other open documents. * @returns The generated watermark ID. */ addWatermark(input: WatermarkInput): PdfTask; /** - * Remove a watermark definition. Note: already-flattened watermarks - * are permanent in the PDF and cannot be visually removed. + * Remove a watermark definition from the active document. + * Note: already-flattened watermarks are permanent in the PDF and + * cannot be visually removed. */ removeWatermark(id: string): PdfTask; /** - * Get all registered watermark definitions. + * Get watermark definitions for the active document. */ getWatermarks(): WatermarkDefinition[]; /** - * Apply all registered watermarks to a specific document. + * Apply all watermarks registered for a specific document. * Each watermark is flattened into the page content stream. */ applyToDocument(documentId: string): PdfTask; diff --git a/packages/plugin-watermark/src/lib/watermark-plugin.ts b/packages/plugin-watermark/src/lib/watermark-plugin.ts index d1e94af42..fdfbb9baf 100644 --- a/packages/plugin-watermark/src/lib/watermark-plugin.ts +++ b/packages/plugin-watermark/src/lib/watermark-plugin.ts @@ -24,6 +24,7 @@ import { removeWatermark, addPlacements, clearPlacements, + clearDocument, WatermarkAction, } from './actions'; import { WATERMARK_PLUGIN_ID } from './manifest'; @@ -39,7 +40,7 @@ export class WatermarkPlugin extends BasePlugin< > { static readonly id = WATERMARK_PLUGIN_ID; - private readonly definitions = new Map(); + private readonly definitionsByDocument = new Map>(); private readonly watermarkChange$ = createEmitter(); private annotation: AnnotationCapability | null = null; private config!: WatermarkPluginConfig; @@ -52,14 +53,7 @@ export class WatermarkPlugin extends BasePlugin< } async initialize(): Promise { - if (this.config.watermarks) { - for (const input of this.config.watermarks) { - const def = this.createDefinition(input); - this.definitions.set(def.id, def); - this.dispatch(addWatermark(def)); - } - this.emitChange(); - } + // Watermarks are scoped per document and seeded when each document loads. } protected buildCapability(): WatermarkCapability { @@ -78,15 +72,17 @@ export class WatermarkPlugin extends BasePlugin< // ───────────────────────────────────────────────────────── protected override onDocumentLoaded(documentId: string): void { - if (this.config.autoApply !== false && this.definitions.size > 0) { + this.seedConfiguredWatermarksForDocument(documentId); + + if (this.config.autoApply !== false && this.getDocumentDefinitions(documentId).size > 0) { this.applyToDocument(documentId); } } protected override onDocumentClosed(documentId: string): void { - for (const def of this.definitions.values()) { - this.dispatch(clearPlacements(def.id, documentId)); - } + this.definitionsByDocument.delete(documentId); + this.dispatch(clearDocument(documentId)); + this.emitChange(); } // ───────────────────────────────────────────────────────── @@ -95,36 +91,47 @@ export class WatermarkPlugin extends BasePlugin< private addWatermarkPublic(input: WatermarkInput): Task { const task = new Task(); + const activeDocId = this.getActiveDocumentIdOrNull(); + if (!activeDocId) { + task.reject({ + code: PdfErrorCode.DocNotOpen, + message: 'No active document to apply watermark', + }); + return task; + } const def = this.createDefinition(input); - this.definitions.set(def.id, def); - this.dispatch(addWatermark(def)); + const documentDefinitions = this.getOrCreateDocumentDefinitions(activeDocId); + documentDefinitions.set(def.id, def); + this.dispatch(addWatermark(activeDocId, def)); this.emitChange(); - const activeDocId = this.getActiveDocumentIdOrNull(); - if (activeDocId) { - this.applyWatermarkToDocument(def, activeDocId).wait( - () => task.resolve(def.id), - (error: { type: string; reason: PdfErrorReason }) => { - this.logger.warn( - 'WatermarkPlugin', - 'AddWatermark', - `Watermark registered but failed to apply: ${error.reason.message}`, - ); - task.reject(error.reason); - }, - ); - } else { - task.resolve(def.id); - } + this.applyWatermarkToDocument(def, activeDocId).wait( + () => task.resolve(def.id), + (error: { type: string; reason: PdfErrorReason }) => { + this.logger.warn( + 'WatermarkPlugin', + 'AddWatermark', + `Watermark registered but failed to apply: ${error.reason.message}`, + ); + task.reject(error.reason); + }, + ); return task; } private removeWatermarkPublic(id: string): Task { const task = new Task(); + const activeDocId = this.getActiveDocumentIdOrNull(); + if (!activeDocId) { + task.reject({ code: PdfErrorCode.DocNotOpen, message: 'No active document' }); + return task; + } + + const documentDefinitions = this.getDocumentDefinitions(activeDocId); - const def = this.definitions.get(id); + const def = documentDefinitions.get(id); if (!def) { task.reject({ code: PdfErrorCode.NotFound, @@ -135,8 +142,8 @@ export class WatermarkPlugin extends BasePlugin< // Flattened watermarks are permanent — just remove the definition. // The watermark content is already part of the page and cannot be reversed. - this.definitions.delete(id); - this.dispatch(removeWatermark(id)); + documentDefinitions.delete(id); + this.dispatch(removeWatermark(activeDocId, id)); this.emitChange(); task.resolve(); @@ -144,7 +151,11 @@ export class WatermarkPlugin extends BasePlugin< } private getWatermarks(): WatermarkDefinition[] { - return Array.from(this.definitions.values()); + const activeDocId = this.getActiveDocumentIdOrNull(); + if (!activeDocId) { + return []; + } + return Array.from(this.getDocumentDefinitions(activeDocId).values()); } /** @@ -167,7 +178,7 @@ export class WatermarkPlugin extends BasePlugin< return task; } - const definitions = Array.from(this.definitions.values()); + const definitions = Array.from(this.getDocumentDefinitions(documentId).values()); let pending = definitions.length; if (pending === 0) { @@ -210,8 +221,8 @@ export class WatermarkPlugin extends BasePlugin< private clearFromDocument(documentId: string): Task { const task = new Task(); - for (const def of this.definitions.values()) { - this.dispatch(clearPlacements(def.id, documentId)); + for (const def of this.getDocumentDefinitions(documentId).values()) { + this.dispatch(clearPlacements(documentId, def.id)); } task.resolve(); @@ -388,7 +399,7 @@ export class WatermarkPlugin extends BasePlugin< annotationId: `xobj-${def.id}-${index}`, })); - this.dispatch(addPlacements(def.id, placementRecords)); + this.dispatch(addPlacements(documentId, def.id, placementRecords)); const pagesToRefresh = Array.from(new Set(placementTargets.map((target) => target.pageIndex))); this.dispatchCoreAction(refreshPages(documentId, pagesToRefresh)); task.resolve(); @@ -497,7 +508,7 @@ export class WatermarkPlugin extends BasePlugin< const createNext = (index: number) => { if (index >= placementTargets.length) { - this.dispatch(addPlacements(def.id, placements)); + this.dispatch(addPlacements(documentId, def.id, placements)); task.resolve(); return; } @@ -631,7 +642,7 @@ export class WatermarkPlugin extends BasePlugin< const createNext = (index: number) => { if (index >= placementTargets.length) { - this.dispatch(addPlacements(def.id, placements)); + this.dispatch(addPlacements(documentId, def.id, placements)); task.resolve(); return; } @@ -1193,6 +1204,35 @@ export class WatermarkPlugin extends BasePlugin< }; } + private getDocumentDefinitions(documentId: string): Map { + return this.definitionsByDocument.get(documentId) ?? new Map(); + } + + private getOrCreateDocumentDefinitions(documentId: string): Map { + const existing = this.definitionsByDocument.get(documentId); + if (existing) { + return existing; + } + const created = new Map(); + this.definitionsByDocument.set(documentId, created); + return created; + } + + private seedConfiguredWatermarksForDocument(documentId: string): void { + if (!this.config.watermarks || this.getDocumentDefinitions(documentId).size > 0) { + return; + } + + const documentDefinitions = this.getOrCreateDocumentDefinitions(documentId); + for (const input of this.config.watermarks) { + const def = this.createDefinition(input); + documentDefinitions.set(def.id, def); + this.dispatch(addWatermark(documentId, def)); + } + + this.emitChange(); + } + /** * Emit the watermark change event to subscribers. */ @@ -1202,7 +1242,7 @@ export class WatermarkPlugin extends BasePlugin< override destroy(): void { this.watermarkChange$.clear(); - this.definitions.clear(); + this.definitionsByDocument.clear(); super.destroy(); } } From 37e3a043f3627e02757b10b9c43a23e76197462a Mon Sep 17 00:00:00 2001 From: Anton Samper Rivaya Date: Fri, 29 May 2026 22:18:22 +0100 Subject: [PATCH 5/6] chore: update embedpdf version in documentation files --- examples/vue-vuetify/components.d.ts | 2 +- website/src/content/docs/snippet/getting-started.mdx | 2 +- website/src/content/docs/snippet/plugins/plugin-spread.mdx | 1 - website/src/content/docs/snippet/plugins/plugin-zoom.mdx | 1 - 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/vue-vuetify/components.d.ts b/examples/vue-vuetify/components.d.ts index 69a555899..cf3362671 100644 --- a/examples/vue-vuetify/components.d.ts +++ b/examples/vue-vuetify/components.d.ts @@ -6,7 +6,7 @@ // Generated by unplugin-vue-components // Read more: https://github.com/vuejs/core/pull/3399 -export {} +export {}; /* prettier-ignore */ declare module 'vue' { diff --git a/website/src/content/docs/snippet/getting-started.mdx b/website/src/content/docs/snippet/getting-started.mdx index e648a1988..425574fb2 100644 --- a/website/src/content/docs/snippet/getting-started.mdx +++ b/website/src/content/docs/snippet/getting-started.mdx @@ -2,7 +2,7 @@ title: Getting Started with Snippet description: Add a full-featured PDF viewer to your application in minutes using the EmbedPDF Snippet. --- -{/* @embedpdf-version 2.14.3 */} +{/* @embedpdf-version 2.0.0-next.2 */} import { Tabs } from '@/components/tabs' import { FrameworkCards } from '@/components/framework-cards' diff --git a/website/src/content/docs/snippet/plugins/plugin-spread.mdx b/website/src/content/docs/snippet/plugins/plugin-spread.mdx index 06abc6015..4a52e9627 100644 --- a/website/src/content/docs/snippet/plugins/plugin-spread.mdx +++ b/website/src/content/docs/snippet/plugins/plugin-spread.mdx @@ -2,7 +2,6 @@ title: Spread Layouts description: Display pages individually or in a two-page, book-like spread layout. --- -{/* @embedpdf-version 2.14.3 */} import SpreadExample from '../code-examples/spread-example'; import { ExampleWrapper } from '@/components/example-wrapper'; diff --git a/website/src/content/docs/snippet/plugins/plugin-zoom.mdx b/website/src/content/docs/snippet/plugins/plugin-zoom.mdx index 8218cfe97..06759afab 100644 --- a/website/src/content/docs/snippet/plugins/plugin-zoom.mdx +++ b/website/src/content/docs/snippet/plugins/plugin-zoom.mdx @@ -2,7 +2,6 @@ title: Zoom description: Configure initial zoom levels and control zooming programmatically. --- -{/* @embedpdf-version 2.14.3 */} import ZoomExample from '../code-examples/zoom-example'; import { ExampleWrapper } from '@/components/example-wrapper'; From b7fdad3d1e3d0c62d936fbd01afaf17df9bb764a Mon Sep 17 00:00:00 2001 From: Anton Samper Rivaya Date: Fri, 29 May 2026 22:36:26 +0100 Subject: [PATCH 6/6] docs: update README to clarify watermark API and add alignment options --- packages/plugin-watermark/README.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/plugin-watermark/README.md b/packages/plugin-watermark/README.md index 7761d3636..169645cc3 100644 --- a/packages/plugin-watermark/README.md +++ b/packages/plugin-watermark/README.md @@ -5,9 +5,10 @@ A plugin for [EmbedPDF](https://www.embedpdf.com) that allows embedding text or ## Features - **Text watermarks** — customisable font, size, colour, and opacity -- **Image watermarks** — supports PNG and JPEG data +- **Image watermarks** — accepts PNG and JPEG input data - **Image rotation** — rotation is applied consistently for image watermarks - **Precise positioning** — place watermarks at exact PDF coordinates +- **Alignment-aware placement** — optional top/centre/bottom + left/centre/right alignment, including rotated pages - **Repeat tiling** — repeat watermarks horizontally, vertically, or both - **Optimised tiling** — one watermark appearance object is reused across all tiles via XObject references - **Page range control** — apply to all pages or specific page indices @@ -104,6 +105,9 @@ watermark.removeWatermark(watermarkId); watermark.clearFromDocument(documentId); ``` +`clearFromDocument(documentId)` clears internal placement tracking for that document. +It does **not** remove already-flattened watermark visuals from page content. + ## API ### `WatermarkCapability` @@ -114,7 +118,7 @@ watermark.clearFromDocument(documentId); | `removeWatermark(id)` | Remove a watermark definition from the active document (already-flattened content remains) | | `getWatermarks()` | Get watermark definitions for the active document | | `applyToDocument(documentId)` | Apply all watermarks registered for that document | -| `clearFromDocument(documentId)` | Remove all watermark annotations from a document | +| `clearFromDocument(documentId)` | Clear placement tracking for that document (flattened content remains) | | `onWatermarkChange` | Event hook for watermark definition changes | ## Configuration @@ -131,6 +135,15 @@ watermark.clearFromDocument(documentId); | `repeat` | `'none' \| 'horizontal' \| 'vertical' \| 'both'` | `'none'` | Repeat the watermark across each target page | | `repeatSpacing` | `{ x?: number; y?: number }` | `{ x: 0, y: 0 }` | Spacing (PDF points) between repeated instances | +### `WatermarkInput` alignment options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `alignment.horizontal` | `'left' \| 'center' \| 'right'` | _unset_ | Horizontal anchor within each page | +| `alignment.vertical` | `'top' \| 'center' \| 'bottom'` | _unset_ | Vertical anchor within each page | + +When `alignment` is set, it determines the base origin before repeat expansion. If `alignment` is omitted, `position` is used directly. + ## Tiling strategy The plugin uses an XObject tiling path first: @@ -160,5 +173,3 @@ Additional checks: ## Licence MIT - -