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..99b5e452b 100644 --- a/examples/react-mui/src/application.tsx +++ b/examples/react-mui/src/application.tsx @@ -37,9 +37,11 @@ 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 { useMemo, useRef } from 'react'; +import BrandingWatermarkOutlinedIcon from '@mui/icons-material/BrandingWatermarkOutlined'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { PageControls } from './components/page-controls'; import { Search } from './components/search'; @@ -49,8 +51,10 @@ 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(); +const WATERMARK_TAKEOVER_PLACEMENT_THRESHOLD = 500; function App() { const isDev = useMemo( @@ -60,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( () => [ @@ -98,6 +143,7 @@ function App() { width: 120, paddingY: 10, }), + createPluginRegistration(WatermarkPluginPackage), ], [], ); @@ -189,6 +235,18 @@ function App() { position: 'left', props: { documentId: activeDocumentId }, }, + { + id: 'watermark', + component: WatermarkPanel, + icon: BrandingWatermarkOutlinedIcon, + label: 'Watermark', + position: 'right', + props: { + documentId: activeDocumentId, + takeoverPlacementThreshold: WATERMARK_TAKEOVER_PLACEMENT_THRESHOLD, + onApplyStateChange: handleWatermarkApplyStateChange, + }, + }, ]; return ( @@ -328,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/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..cae1aa8b4 --- /dev/null +++ b/examples/react-mui/src/components/watermark-panel/index.tsx @@ -0,0 +1,562 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import { + Alert, + Box, + Button, + CircularProgress, + 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'; +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' }, + { 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 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, + 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, + 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'); + 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 [verticalPosition, setVerticalPosition] = useState('center'); + const [horizontalPosition, setHorizontalPosition] = + useState('center'); + 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 [appliedWatermarksByDocument, setAppliedWatermarksByDocument] = useState< + Record + >({}); + const appliedWatermarks = useMemo( + () => appliedWatermarksByDocument[documentId] ?? [], + [appliedWatermarksByDocument, documentId], + ); + + 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 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, + DEFAULT_PAGE_SIZE, + { width, height }, + ); + + const input = + watermarkType === 'text' + ? { + type: 'text' as const, + textOptions: { text, fontSize, fontFamily, colour }, + position: alignedOrigin, + alignment: { vertical: verticalPosition, horizontal: horizontalPosition }, + size: { width, height }, + opacity, + rotation, + repeat, + repeatSpacing: { x: repeatSpacingX, y: repeatSpacingY }, + pageRange: 'all' as const, + readOnly: true, + printable: true, + } + : { + type: 'image' as const, + imageOptions: { data: imageData!, mimeType: imageMimeType }, + position: alignedOrigin, + alignment: { vertical: verticalPosition, horizontal: horizontalPosition }, + size: { width, height }, + opacity, + rotation, + repeat, + repeatSpacing: { x: repeatSpacingX, y: repeatSpacingY }, + pageRange: 'all' as const, + readOnly: true, + printable: true, + }; + + watermarkCapability.addWatermark(input).wait( + (id: string) => { + if (settled) return; + settled = true; + const baseLabel = watermarkType === 'text' ? `Text: "${text}"` : `Image: ${imageName}`; + const label = repeat === 'none' ? baseLabel : `${baseLabel} (${repeat})`; + setAppliedWatermarksByDocument((prev) => ({ + ...prev, + [documentId]: [...(prev[documentId] ?? []), { id, label }], + })); + updateApplyState(false, '', false); + }, + (error: { type: string; reason: { message: string } }) => { + if (settled) return; + settled = true; + setApplyError(error.reason.message || 'Failed to apply watermark.'); + updateApplyState(false, '', false); + }, + ); + + }, [ + watermarkCapability, + watermarkType, + text, + fontSize, + fontFamily, + colour, + opacity, + verticalPosition, + horizontalPosition, + width, + height, + rotation, + repeat, + repeatSpacingX, + repeatSpacingY, + imageData, + imageName, + imageMimeType, + documentId, + documentManager, + updateApplyState, + takeoverPlacementThreshold, + ]); + + 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 + + + + Vertical + + + + Horizontal + + + + + + Size (PDF points) + + + setWidth(Number(e.target.value))} + size="small" + /> + setHeight(Number(e.target.value))} + 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 && ( + <> + + + Applied Watermarks + + + {appliedWatermarks.map((w) => ( + + + {w.label} + + + + ))} + + + )} + + ); +}; + + + diff --git a/packages/engines/src/lib/orchestrator/pdf-engine.ts b/packages/engines/src/lib/orchestrator/pdf-engine.ts index 2b7720bed..1f6bd6336 100644 --- a/packages/engines/src/lib/orchestrator/pdf-engine.ts +++ b/packages/engines/src/lib/orchestrator/pdf-engine.ts @@ -1023,6 +1023,38 @@ 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 }, + ); + } + + 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 93290543d..2a2912bd4 100644 --- a/packages/engines/src/lib/orchestrator/remote-executor.ts +++ b/packages/engines/src/lib/orchestrator/remote-executor.ts @@ -117,6 +117,8 @@ type MessageType = | 'applyRedaction' | 'applyAllRedactions' | 'flattenAnnotation' + | 'flattenAnnotationBehind' + | 'tileAppearanceXObjectBehind' | 'exportAnnotationAppearanceAsPdf' | 'exportAnnotationsAppearanceAsPdf' | 'getTextSlices' @@ -607,6 +609,22 @@ 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]); + } + + 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 d4b82d39a..14eb405b4 100644 --- a/packages/engines/src/lib/pdfium/engine.ts +++ b/packages/engines/src/lib/pdfium/engine.ts @@ -7208,6 +7208,280 @@ 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); + } + + /** + * 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 16bcbd6d4..39d78ac6e 100644 --- a/packages/engines/src/lib/webworker/engine.ts +++ b/packages/engines/src/lib/webworker/engine.ts @@ -1134,6 +1134,56 @@ 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.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 7138df03f..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', @@ -447,6 +476,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..79b9894a2 100644 --- a/packages/models/src/pdf.ts +++ b/packages/models/src/pdf.ts @@ -3796,6 +3796,28 @@ 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; + /** + * 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 @@ -4127,6 +4149,16 @@ export interface IPdfiumExecutor { page: PdfPageObject, annotation: PdfAnnotationObject, ): PdfTask; + flattenAnnotationBehind( + doc: PdfDocumentObject, + 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 new file mode 100644 index 000000000..169645cc3 --- /dev/null +++ b/packages/plugin-watermark/README.md @@ -0,0 +1,175 @@ +# @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** — 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 +- **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 + +```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 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. + +```typescript +watermark.removeWatermark(watermarkId); +``` + +### Clearing watermarks from a document + +```typescript +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` + +| Method | Description | +|--------|-------------| +| `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)` | Clear placement tracking for that document (flattened content remains) | +| `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 | + +### `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: + +- 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/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..3a4474d41 --- /dev/null +++ b/packages/plugin-watermark/src/lib/actions.ts @@ -0,0 +1,80 @@ +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 const CLEAR_DOCUMENT = 'WATERMARK/CLEAR_DOCUMENT'; + +export interface AddWatermarkAction extends Action { + type: typeof ADD_WATERMARK; + payload: { + documentId: string; + definition: WatermarkDefinition; + }; +} + +export interface RemoveWatermarkAction extends Action { + type: typeof REMOVE_WATERMARK; + payload: { + documentId: string; + watermarkId: string; + }; +} + +export interface AddPlacementsAction extends Action { + type: typeof ADD_PLACEMENTS; + payload: { + documentId: string; + watermarkId: string; + placements: WatermarkPlacement[]; + }; +} + +export interface ClearPlacementsAction extends Action { + type: typeof CLEAR_PLACEMENTS; + payload: { + documentId: string; + watermarkId: string; + }; +} + +export interface ClearDocumentAction extends Action { + type: typeof CLEAR_DOCUMENT; + payload: { + documentId: string; + }; +} + +export type WatermarkAction = + | AddWatermarkAction + | RemoveWatermarkAction + | AddPlacementsAction + | ClearPlacementsAction + | ClearDocumentAction; + +export function addWatermark(documentId: string, definition: WatermarkDefinition): AddWatermarkAction { + return { type: ADD_WATERMARK, payload: { documentId, definition } }; +} + +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: { documentId, watermarkId, placements } }; +} + +export function clearPlacements(documentId: string, watermarkId: string): ClearPlacementsAction { + return { type: CLEAR_PLACEMENTS, payload: { documentId, watermarkId } }; +} + +export function clearDocument(documentId: string): ClearDocumentAction { + return { type: CLEAR_DOCUMENT, payload: { 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..f20b17373 --- /dev/null +++ b/packages/plugin-watermark/src/lib/reducer.ts @@ -0,0 +1,102 @@ +import { Reducer } from '@embedpdf/core'; +import { + WatermarkAction, + ADD_WATERMARK, + REMOVE_WATERMARK, + ADD_PLACEMENTS, + CLEAR_PLACEMENTS, + CLEAR_DOCUMENT, +} from './actions'; +import { WatermarkState } from './types'; + +export const initialState: WatermarkState = { + watermarkIdsByDocument: {}, + placementsByDocument: {}, +}; + +export const watermarkReducer: Reducer = ( + state = initialState, + action, +) => { + switch (action.type) { + case ADD_WATERMARK: { + const { documentId, definition } = action.payload; + const currentIds = state.watermarkIdsByDocument[documentId] ?? []; + const currentPlacements = state.placementsByDocument[documentId] ?? {}; + return { + ...state, + watermarkIdsByDocument: { + ...state.watermarkIdsByDocument, + [documentId]: [...currentIds, definition.id], + }, + placementsByDocument: { + ...state.placementsByDocument, + [documentId]: { ...currentPlacements, [definition.id]: [] }, + }, + }; + } + + case REMOVE_WATERMARK: { + const { documentId, watermarkId } = action.payload; + const currentIds = state.watermarkIdsByDocument[documentId] ?? []; + const currentPlacements = state.placementsByDocument[documentId] ?? {}; + return { + ...state, + 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 { documentId, watermarkId, placements } = action.payload; + const docPlacements = state.placementsByDocument[documentId] ?? {}; + const existing = docPlacements[watermarkId] ?? []; + return { + ...state, + placementsByDocument: { + ...state.placementsByDocument, + [documentId]: { + ...docPlacements, + [watermarkId]: [...existing, ...placements], + }, + }, + }; + } + + case CLEAR_PLACEMENTS: { + const { watermarkId, documentId } = action.payload; + 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, + watermarkIdsByDocument: remainingIds, + placementsByDocument: remainingPlacements, + }; + } + + 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..352c809b8 --- /dev/null +++ b/packages/plugin-watermark/src/lib/types.ts @@ -0,0 +1,219 @@ +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; +} + +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. + */ +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; + /** 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 */ + 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 seeded per document and optionally auto-applied when 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 { + /** Maps document ID -> watermark definition IDs registered for that document */ + watermarkIdsByDocument: Record; + /** Maps document ID -> watermark definition ID -> placed instances */ + placementsByDocument: 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. + * 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 from the active document. + * Note: already-flattened watermarks are permanent in the PDF and + * cannot be visually removed. + */ + removeWatermark(id: string): PdfTask; + + /** + * Get watermark definitions for the active document. + */ + getWatermarks(): WatermarkDefinition[]; + + /** + * Apply all watermarks registered for 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..fdfbb9baf --- /dev/null +++ b/packages/plugin-watermark/src/lib/watermark-plugin.ts @@ -0,0 +1,1265 @@ +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, + clearDocument, + 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 definitionsByDocument = 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 { + // Watermarks are scoped per document and seeded when each document loads. + } + + 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 { + this.seedConfiguredWatermarksForDocument(documentId); + + if (this.config.autoApply !== false && this.getDocumentDefinitions(documentId).size > 0) { + this.applyToDocument(documentId); + } + } + + protected override onDocumentClosed(documentId: string): void { + this.definitionsByDocument.delete(documentId); + this.dispatch(clearDocument(documentId)); + this.emitChange(); + } + + // ───────────────────────────────────────────────────────── + // Public capability methods + // ───────────────────────────────────────────────────────── + + 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); + const documentDefinitions = this.getOrCreateDocumentDefinitions(activeDocId); + documentDefinitions.set(def.id, def); + this.dispatch(addWatermark(activeDocId, def)); + this.emitChange(); + + 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 = documentDefinitions.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. + documentDefinitions.delete(id); + this.dispatch(removeWatermark(activeDocId, id)); + this.emitChange(); + task.resolve(); + + return task; + } + + private getWatermarks(): WatermarkDefinition[] { + const activeDocId = this.getActiveDocumentIdOrNull(); + if (!activeDocId) { + return []; + } + return Array.from(this.getDocumentDefinitions(activeDocId).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.getDocumentDefinitions(documentId).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.reject(error.reason); + } + }, + ); + } + + 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.getDocumentDefinitions(documentId).values()) { + this.dispatch(clearPlacements(documentId, def.id)); + } + + 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< + number, + { index: number; size: { width: number; height: number }; rotation: number } + > = + new Map( + docState.document.pages.map( + (page: { index: number; size: { width: number; height: number }; rotation: number }) => + [page.index, page] as const, + ), + ); + + if (def.type === 'text') { + this.applyTextWatermarkOptimized( + def, + documentId, + pageIndices, + pageMap, + task, + ); + } else { + this.applyImageWatermarkOptimized( + def, + documentId, + pageIndices, + pageMap, + task, + ); + } + + 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(documentId, 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. + */ + private applyTextWatermark( + def: WatermarkDefinition, + documentId: string, + pageIndices: number[], + pageMap: Map< + number, + { index: number; size: { width: number; height: number }; rotation: number } + >, + 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(documentId, 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< + number, + { index: number; size: { width: number; height: number }; rotation: number } + >, + 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(documentId, 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< + number, + { index: number; size: { width: number; height: number }; rotation: number } + >, + 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 baseOrigin = this.resolveBaseOrigin(def, page, baseRect); + + const xOrigins = this.expandAxisOrigins( + baseOrigin.x, + baseRect.size.width, + page.size.width, + mode === 'horizontal' || mode === 'both', + spacingX, + ); + const yOrigins = this.expandAxisOrigins( + baseOrigin.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 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, + 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(), + }; + } + + 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. + */ + private emitChange(): void { + this.watermarkChange$.emit({ watermarks: this.getWatermarks() }); + } + + override destroy(): void { + this.watermarkChange$.clear(); + this.definitionsByDocument.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)