diff --git a/engine/src/parser/pdfium_document.hpp b/engine/src/parser/pdfium_document.hpp index a4914e0..a09df26 100644 --- a/engine/src/parser/pdfium_document.hpp +++ b/engine/src/parser/pdfium_document.hpp @@ -169,6 +169,7 @@ private: std::expected applyOp_reflow(const nlohmann::json& op, int pageIndex); std::expected applyOp_textOverlay(const nlohmann::json& op, int pageIndex); std::expected applyOp_stamp(const nlohmann::json& op, int pageIndex); + std::expected applyOp_watermark(const nlohmann::json& op, int pageIndex); std::expected applyOp_decoration(const nlohmann::json& op, int pageIndex); std::expected applyOp_redaction(const nlohmann::json& op, int pageIndex); std::expected applyOp_updateField(const nlohmann::json& op, int pageIndex); diff --git a/engine/src/parser/pdfium_edit.cpp b/engine/src/parser/pdfium_edit.cpp index 2298924..ed481d8 100644 --- a/engine/src/parser/pdfium_edit.cpp +++ b/engine/src/parser/pdfium_edit.cpp @@ -44,7 +44,7 @@ std::expected, EngineError> PdfiumDocument::apply } static const std::set kContentOps = { - "replace_text", "reflow_paragraph", "text_overlay", "add_text", "stamp", + "replace_text", "reflow_paragraph", "text_overlay", "add_text", "stamp", "watermark", "add_watermark", "underline", "strikeout", "squiggly", "redaction", "image_overlay", "highlight", "free_text", "comment", "freehand"}; if (kContentOps.count(type)) markEdited(pageIndex); @@ -58,6 +58,8 @@ std::expected, EngineError> PdfiumDocument::apply r = applyOp_textOverlay(op, pageIndex); } else if (type == "stamp") { r = applyOp_stamp(op, pageIndex); + } else if (type == "watermark" || type == "add_watermark") { + r = applyOp_watermark(op, pageIndex); } else if (type == "underline" || type == "strikeout" || type == "squiggly") { r = applyOp_decoration(op, pageIndex); } else if (type == "redaction") { diff --git a/engine/src/parser/pdfium_edit_annotations.cpp b/engine/src/parser/pdfium_edit_annotations.cpp index 2537a19..670e836 100644 --- a/engine/src/parser/pdfium_edit_annotations.cpp +++ b/engine/src/parser/pdfium_edit_annotations.cpp @@ -676,4 +676,107 @@ std::expected PdfiumDocument::applyOp_updateAnnotation(const #endif } +std::expected PdfiumDocument::applyOp_watermark(const nlohmann::json& op, int pageIndex) { +#ifdef PDFENGINE_WITH_PDFIUM + if (!op.contains("data") || !op["data"].is_object()) { + spdlog::error("watermark operation missing 'data' object"); + return std::unexpected(EngineError::InvalidFormat); + } + auto data = op["data"]; + std::string text = data.value("text", "CONFIDENTIAL"); + if (text.empty()) return {}; + + std::string fontFamily = data.value("fontFamily", "Helvetica"); + std::string fontWeight = data.value("fontWeight", "normal"); + double fontSize = data.value("fontSize", 48.0); + std::string color = data.value("color", "#000000"); + double opacity = data.value("opacity", 0.25); + double rotation = data.value("rotation", -45.0); + std::string position = data.value("position", "center"); + double xOffset = data.value("xOffset", 0.0); + double yOffset = data.value("yOffset", 0.0); + + // Font resolution with logging + std::string stdFontName = "Helvetica"; + if (fontFamily.find("Times") != std::string::npos) { + stdFontName = (fontWeight == "bold" || fontWeight == "700") ? "Times-Bold" : "Times-Roman"; + } else if (fontFamily.find("Courier") != std::string::npos) { + stdFontName = (fontWeight == "bold" || fontWeight == "700") ? "Courier-Bold" : "Courier"; + } else { + stdFontName = (fontWeight == "bold" || fontWeight == "700") ? "Helvetica-Bold" : "Helvetica"; + } + spdlog::info("[WATERMARK_FONT] Requested: {} ({}) Resolved: {}", fontFamily, fontWeight, stdFontName); + + FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex); + if (!page) { + spdlog::error("Failed to load page index {} for watermark", pageIndex); + return std::unexpected(EngineError::Unknown); + } + + double pageWidth = FPDF_GetPageWidth(page); + double pageHeight = FPDF_GetPageHeight(page); + + FPDF_FONT font = FPDFText_LoadStandardFont(doc_, stdFontName.c_str()); + if (!font) { + font = FPDFText_LoadStandardFont(doc_, "Helvetica"); + } + + FPDF_PAGEOBJECT textObj = FPDFPageObj_CreateTextObj(doc_, font, static_cast(fontSize)); + unsigned int r = 0, g = 0, b = 0; + parseHexColor(color, r, g, b); + unsigned int alpha = static_cast(std::clamp(opacity, 0.0, 1.0) * 255.0); + FPDFPageObj_SetFillColor(textObj, r, g, b, alpha); + + auto utf16 = utf8_to_utf16le(text); + FPDFText_SetText(textObj, reinterpret_cast(utf16.data())); + + float left = 0, bottom = 0, right = 0, top = 0; + FPDFPageObj_GetBounds(textObj, &left, &bottom, &right, &top); + float textWidth = right - left; + float textHeight = top - bottom; + + double margin = 36.0; + double cx = pageWidth / 2.0; + double cy = pageHeight / 2.0; + + if (position == "top_left") { cx = margin + textWidth / 2.0; cy = pageHeight - margin - textHeight / 2.0; } + else if (position == "top_center") { cx = pageWidth / 2.0; cy = pageHeight - margin - textHeight / 2.0; } + else if (position == "top_right") { cx = pageWidth - margin - textWidth / 2.0; cy = pageHeight - margin - textHeight / 2.0; } + else if (position == "center_left") { cx = margin + textWidth / 2.0; cy = pageHeight / 2.0; } + else if (position == "center_right") { cx = pageWidth - margin - textWidth / 2.0; cy = pageHeight / 2.0; } + else if (position == "bottom_left") { cx = margin + textWidth / 2.0; cy = margin + textHeight / 2.0; } + else if (position == "bottom_center") { cx = pageWidth / 2.0; cy = margin + textHeight / 2.0; } + else if (position == "bottom_right") { cx = pageWidth - margin - textWidth / 2.0; cy = margin + textHeight / 2.0; } + + cx += xOffset; + cy += yOffset; + + spdlog::info("[WATERMARK_ENGINE] Page: {} Page size: {}x{} PDF position: x={}, y={} Drawing watermark: {}", + pageIndex, pageWidth, pageHeight, cx, cy, text); + + double rad = rotation * 3.14159265358979323846 / 180.0; + double cosA = std::cos(rad); + double sinA = std::sin(rad); + + double matA = cosA; + double matB = sinA; + double matC = -sinA; + double matD = cosA; + double matE = cx - (cosA * (textWidth / 2.0) - sinA * (textHeight / 2.0)); + double matF = cy - (sinA * (textWidth / 2.0) + cosA * (textHeight / 2.0)); + + FPDFPageObj_Transform(textObj, static_cast(matA), static_cast(matB), static_cast(matC), static_cast(matD), static_cast(matE), static_cast(matF)); + FPDFPage_InsertObject(page, textObj); + + if (!FPDFPage_GenerateContent(page)) { + spdlog::error("Failed to generate page content after watermark"); + } + FPDF_ClosePage(page); + return {}; +#else + (void)op; (void)pageIndex; + return std::unexpected(EngineError::Unknown); +#endif +} + } \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a7acf7d..0ab0a1a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { RedactPagesModal } from './components/RedactPagesModal'; import { AboutModal } from './components/AboutModal'; import { VersionHistoryModal } from './components/VersionHistoryModal'; import { ExportPDFModal } from './components/ExportPDFModal'; +import { WatermarkModal, type WatermarkConfig } from './components/WatermarkModal'; import { triggerPDFDownload } from './lib/pdfExport'; import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal'; @@ -100,6 +101,7 @@ function App() { const [aboutModalOpen, setAboutModalOpen] = useState(false); const [versionHistoryModalOpen, setVersionHistoryModalOpen] = useState(false); const [exportModalOpen, setExportModalOpen] = useState(false); + const [watermarkModalOpen, setWatermarkModalOpen] = useState(false); const [passwordPrompt, setPasswordPrompt] = useState<{ file: File; filename: string; error?: string } | null>(null); const [isInspectorExpanded, setIsInspectorExpanded] = useState(false); const [activeStamp, setActiveStamp] = useState(null); @@ -135,10 +137,12 @@ function App() { }; const openDocument = useCallback((id: string) => { + localStorage.removeItem('active_mode'); setCreatePdfModalOpen(false); setActiveTool((t) => (t === 'create_pdf' ? 'select' : t)); setHist({ stack: [id], index: 0 }); }, []); + const pushHistory = (id: string) => setHist((h) => ({ stack: [...h.stack.slice(0, h.index + 1), id], index: h.index + 1 })); const undo = () => { @@ -172,6 +176,15 @@ function App() { setIsLoading(true); const docs = await gatewayService.listDocuments(); setDocuments(docs); + + const savedMode = localStorage.getItem('active_mode'); + if (savedMode === 'create_pdf') { + setCreatePdfModalOpen(true); + setActiveTool('create_pdf'); + setIsLoading(false); + return; + } + if (docs.length > 0) { // Attempt to load from localStorage, otherwise fallback to the most recent document const savedHist = localStorage.getItem('pdf_hist'); @@ -186,8 +199,8 @@ function App() { console.error('Failed to parse history', e); } } - // Default to the most recent document (last in list) - openDocument(docs[docs.length - 1].id); + // Default to the most recent document (first in list) + openDocument(docs[0].id); } } catch (e) { console.error('Failed to load documents', e); @@ -553,6 +566,74 @@ function App() { }); }; + const handleApplyWatermark = async (config: WatermarkConfig, targetPageIndices: number[]) => { + const isCreatorActive = activeTool === 'create_pdf' || createPdfModalOpen; + if (isCreatorActive) { + if (!creatorActions?.generateBlob) { + alert('Document Creator is initializing.'); + return; + } + try { + setIsSaving(true); + const pdfBlob = await creatorActions.generateBlob(); + const file = new File([pdfBlob], 'New Blank Document.pdf', { type: 'application/pdf' }); + const newDoc = await gatewayService.uploadDocument(file); + setDocuments((prev) => [newDoc, ...prev]); + setCreatePdfModalOpen(false); + setActiveTool('select'); + openDocument(newDoc.id); + + const ops: EditOperation[] = targetPageIndices.map((p) => ({ + id: rid('watermark'), + type: 'add_watermark' as const, + pageIndex: p, + data: { + text: config.text, + fontFamily: config.fontFamily, + fontSize: config.fontSize, + fontWeight: config.fontWeight, + color: config.color, + opacity: config.opacity / 100.0, + rotation: config.rotation, + position: config.position, + }, + })); + + const result = await gatewayService.applyEdits(newDoc.id, ops); + if (result.success) { + adoptNewDocument(result.newDocumentId); + } + } catch (e) { + console.error('Failed to apply watermark in creator mode', e); + alert('Failed to apply watermark to new blank document.'); + } finally { + setIsSaving(false); + } + return; + } + + if (!selectedDocId) return; + if (!can('canAnnotate')) { denyToast('Watermark'); return; } + + const ops: EditOperation[] = targetPageIndices.map((p) => ({ + id: rid('watermark'), + type: 'add_watermark' as const, + pageIndex: p, + data: { + text: config.text, + fontFamily: config.fontFamily, + fontSize: config.fontSize, + fontWeight: config.fontWeight, + color: config.color, + opacity: config.opacity / 100.0, + rotation: config.rotation, + position: config.position, + }, + })); + + await applyOps(ops, 'Watermark applied'); + }; + const handleUpload = async (file: File, password = '') => { try { setIsLoading(true); @@ -575,6 +656,11 @@ function App() { }; const handleToolChange = async (tool: ToolId) => { + if (tool === 'watermark') { + setActiveTool('watermark'); + setWatermarkModalOpen(true); + return; + } if (tool === 'create_pdf') { setCreatePdfModalOpen(true); setActiveTool('create_pdf'); @@ -741,9 +827,8 @@ function App() { canPrint={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canPrint')} canExport={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canCopy')} canAssemble={can('canAssemble')} - canVersionHistory={!(activeTool === 'create_pdf' || createPdfModalOpen)} onUpload={handleUpload} - onNewBlankPDF={() => { setCreatePdfModalOpen(true); setActiveTool('create_pdf'); }} + onNewBlankPDF={() => { localStorage.setItem('active_mode', 'create_pdf'); setCreatePdfModalOpen(true); setActiveTool('create_pdf'); }} onShowVersionHistory={() => setVersionHistoryModalOpen(true)} isInspectorOpen={isInspectorOpen} onToggleInspector={toggleInspector} @@ -783,6 +868,7 @@ function App() { onApplyRedactions={handleApplyRedactions} onClearRedactions={() => setPendingRedactions([])} onRedactPages={() => setRedactPagesModalOpen(true)} + onOpenWatermark={() => setWatermarkModalOpen(true)} selectedAnnotation={annotations.find(a => a.id === selectedAnnotationId)} onUpdateAnnotation={(patch) => { const a = annotations.find(x => x.id === selectedAnnotationId); @@ -816,6 +902,7 @@ function App() { if (preset) setActiveStamp(preset); }} onClose={() => { + localStorage.removeItem('active_mode'); setCreatePdfModalOpen(false); if (activeTool === 'create_pdf') setActiveTool('select'); }} @@ -1037,6 +1124,14 @@ function App() { onConfirmExport={handleConfirmExport} /> + setWatermarkModalOpen(false)} + totalPages={(activeTool === 'create_pdf' || createPdfModalOpen) ? creatorPageCount : (activeDoc?.totalPages || 1)} + currentPage={currentPage} + onApplyWatermark={handleApplyWatermark} + /> + setConfirmState(null)} /> }, { id: 'stamp', label: 'Stamp', shortcut: 'M', icon: }, + { + id: 'watermark', + label: 'Add Watermark', + shortLabel: 'Watermark', + shortcut: 'K', + icon: ( + + + + + + + ), + }, 'divider', { id: 'redact', label: 'Redact', shortcut: 'R', icon: , danger: true }, ]; diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index b288fae..538d02c 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -23,6 +23,7 @@ interface ToolbarProps { onApplyRedactions?: () => void; onClearRedactions?: () => void; onRedactPages?: () => void; + onOpenWatermark?: () => void; selectedAnnotation?: import('../viewer/AnnotationLayer').Annotation | null; onUpdateAnnotation?: (patch: Partial) => void; onDeleteAnnotation?: () => void; @@ -76,6 +77,15 @@ const TOOL_META: Record = { label: 'Whiteboard', icon: }, + watermark: { + label: 'Watermark', + icon: ( + + + + + ), + }, }; const Hint: React.FC<{ children: React.ReactNode; tone?: 'normal' | 'warn' }> = ({ children, tone = 'normal' }) => ( @@ -89,7 +99,7 @@ const Divider = () =>
= ({ activeTool, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp, redactionMode = 'area', onRedactionModeChange, - pendingRedactionCount = 0, onApplyRedactions, onClearRedactions, onRedactPages, + pendingRedactionCount = 0, onApplyRedactions, onClearRedactions, onRedactPages, onOpenWatermark, selectedAnnotation, onUpdateAnnotation, onDeleteAnnotation, onDeselectAnnotation }) => { if (activeTool === 'whiteboard') return null; @@ -247,6 +257,15 @@ export const Toolbar: React.FC = ({ )} + {activeTool === 'watermark' && ( + <> + + Add Watermark… + + Configure and apply text watermarks across PDF pages. + + )} + {activeTool === 'redact' && ( <>
diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index 0d6bca2..8d939f3 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -33,7 +33,6 @@ interface TopBarProps { canPrint?: boolean; canExport?: boolean; canAssemble?: boolean; - canVersionHistory?: boolean; } const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]; @@ -41,8 +40,8 @@ const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]; export const TopBar: React.FC = ({ documentName, backendHealthy, engineReady, zoom, onZoomChange, onFitWidth, currentPage, totalPages, onGoToPage, canUndo, canRedo, onUndo, onRedo, - isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload, onNewBlankPDF, onShowVersionHistory, - canPrint = true, canExport = true, canAssemble = true, canVersionHistory = true, + isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload, onNewBlankPDF, + canPrint = true, canExport = true, canAssemble = true, }) => { const fileRef = useRef(null); const handleFile = (e: React.ChangeEvent) => { diff --git a/frontend/src/components/WatermarkModal.tsx b/frontend/src/components/WatermarkModal.tsx new file mode 100644 index 0000000..100cdb1 --- /dev/null +++ b/frontend/src/components/WatermarkModal.tsx @@ -0,0 +1,346 @@ +import React, { useState, useEffect } from 'react'; +import { Modal, ColorSwatches, Slider } from './ui'; +import { CustomButton } from './custom/CustomButton'; + +export interface WatermarkConfig { + text: string; + fontFamily: string; + fontSize: number; + fontWeight: string; + color: string; + opacity: number; // 0 to 100 + rotation: number; // e.g. -45, 0, 45, 90 + position: 'top_left' | 'top_center' | 'top_right' | 'center_left' | 'center' | 'center_right' | 'bottom_left' | 'bottom_center' | 'bottom_right'; + targetPages: 'all' | 'current' | 'custom'; + customRangeStr: string; +} + +interface WatermarkModalProps { + open: boolean; + onClose: () => void; + totalPages: number; + currentPage: number; + onApplyWatermark: (config: WatermarkConfig, targetPageIndices: number[]) => Promise; +} + +export const WATERMARK_PRESETS = ['CONFIDENTIAL', 'DRAFT', 'SAMPLE', 'PROPERTY OF COMPANY', 'FINAL', 'INTERNAL USE ONLY']; + +export const WatermarkModal: React.FC = ({ + open, + onClose, + totalPages, + currentPage, + onApplyWatermark, +}) => { + const [text, setText] = useState('CONFIDENTIAL'); + const [fontFamily, setFontFamily] = useState('Helvetica'); + const [fontSize, setFontSize] = useState(48); + const [fontWeight, setFontWeight] = useState('bold'); + const [color, setColor] = useState('#dc2626'); + const [opacity, setOpacity] = useState(25); + const [rotation, setRotation] = useState(-45); + const [position, setPosition] = useState<'top_left' | 'top_center' | 'top_right' | 'center_left' | 'center' | 'center_right' | 'bottom_left' | 'bottom_center' | 'bottom_right'>('center'); + const [targetPages, setTargetPages] = useState<'all' | 'current' | 'custom'>('all'); + const [customRangeStr, setCustomRangeStr] = useState(`1-${totalPages || 1}`); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + if (open) { + setErrorMessage(null); + setIsSubmitting(false); + setCustomRangeStr(`1-${totalPages || 1}`); + } + }, [open, totalPages]); + + const parsePageIndices = (): { indices: number[]; error?: string } => { + if (targetPages === 'all') { + return { indices: Array.from({ length: Math.max(1, totalPages) }, (_, i) => i) }; + } + if (targetPages === 'current') { + return { indices: [currentPage] }; + } + + const cleaned = customRangeStr.trim(); + if (!cleaned) return { indices: [], error: 'Please enter a valid page range.' }; + + const indicesSet = new Set(); + const parts = cleaned.split(','); + for (const part of parts) { + const p = part.trim(); + if (!p) continue; + if (p.includes('-')) { + const [startStr, endStr] = p.split('-'); + const start = parseInt(startStr, 10); + const end = parseInt(endStr, 10); + if (isNaN(start) || isNaN(end) || start > end || start < 1 || end > totalPages) { + return { indices: [], error: `Invalid page range "${p}". Page numbers must be between 1 and ${totalPages}.` }; + } + for (let i = start; i <= end; i++) { + indicesSet.add(i - 1); + } + } else { + const num = parseInt(p, 10); + if (isNaN(num) || num < 1 || num > totalPages) { + return { indices: [], error: `Invalid page number "${p}". Page numbers must be between 1 and ${totalPages}.` }; + } + indicesSet.add(num - 1); + } + } + + const result = Array.from(indicesSet).sort((a, b) => a - b); + if (result.length === 0) return { indices: [], error: 'No valid pages selected.' }; + return { indices: result }; + }; + + const handleApply = async () => { + if (isSubmitting) return; + + const trimmedText = text.trim(); + if (!trimmedText) { + setErrorMessage('Watermark text cannot be empty.'); + return; + } + + const { indices, error } = parsePageIndices(); + if (error || indices.length === 0) { + setErrorMessage(error || 'Invalid page range.'); + return; + } + + try { + setIsSubmitting(true); + setErrorMessage(null); + console.log(`[WATERMARK] Add requested. Text: ${trimmedText}, Pages: ${indices.length}, Font: ${fontFamily}, Size: ${fontSize}, Weight: ${fontWeight}, Opacity: ${opacity / 100}, Rotation: ${rotation}, Position: ${position}`); + + await onApplyWatermark( + { + text: trimmedText, + fontFamily, + fontSize, + fontWeight, + color, + opacity, + rotation, + position, + targetPages, + customRangeStr, + }, + indices + ); + + console.log(`[WATERMARK] Applied successfully. Pages affected: ${indices.length}`); + onClose(); + } catch (err: any) { + console.error('[WATERMARK] Failed:', err); + setErrorMessage(err.message || 'Failed to apply watermark. Please try again.'); + } finally { + setIsSubmitting(false); + } + }; + + if (!open) return null; + + return ( + { + if (!isSubmitting) onClose(); + }} + title="Add Watermark" + width={480} + > +
+ {errorMessage && ( +
+ ⚠️ {errorMessage} + +
+ )} + + {/* Text & Presets */} +
+ + setText(e.target.value)} + disabled={isSubmitting} + placeholder="CONFIDENTIAL" + className="h-9 w-full rounded-lg border border-border-primary bg-bg-primary px-3 text-[13px] text-text-primary outline-none transition-all focus:border-brand-primary focus:ring-1 focus:ring-brand-primary" + /> +
+ Presets: + {WATERMARK_PRESETS.map((preset) => ( + + ))} +
+
+ + {/* Appearance Controls */} +
+ + Appearance & Formatting + + +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ Color: + +
+
+ +
+ + +
+ Rotation: + +
+
+
+ + {/* Position & Target Pages */} +
+
+ + +
+ +
+ + +
+
+ + {targetPages === 'custom' && ( +
+ + setCustomRangeStr(e.target.value)} + placeholder={`1-${totalPages}`} + className="h-8 w-full rounded border border-border-primary bg-bg-primary px-2.5 text-[12px] text-text-primary outline-none" + /> +
+ )} + + {/* Action Buttons */} +
+ + Cancel + + + {isSubmitting ? 'Applying Watermark...' : 'Apply Watermark'} + +
+
+
+ ); +}; diff --git a/frontend/src/lib/gatewayService.ts b/frontend/src/lib/gatewayService.ts index b3d1e45..53f83f8 100644 --- a/frontend/src/lib/gatewayService.ts +++ b/frontend/src/lib/gatewayService.ts @@ -315,9 +315,24 @@ export interface PageReorderData { destPageIndex: number; } +export interface WatermarkOperationData { + text: string; + fontFamily: string; + fontSize: number; + fontWeight: string; + color: string; + opacity: number; + rotation: number; + position: string; + xOffset?: number; + yOffset?: number; +} + export type EditOperationDataMap = { text_overlay: TextOverlayData; stamp: StampData; + watermark: WatermarkOperationData; + add_watermark: WatermarkOperationData; redaction: RedactionData; image_overlay: ImageOverlayData; highlight: HighlightData; diff --git a/frontend/src/lib/tools.ts b/frontend/src/lib/tools.ts index 549c110..e935601 100644 --- a/frontend/src/lib/tools.ts +++ b/frontend/src/lib/tools.ts @@ -15,7 +15,8 @@ export type ToolId = | 'squiggly' | 'stream_edit' | 'whiteboard' - | 'create_pdf'; + | 'create_pdf' + | 'watermark'; export interface ToolSettings { highlightColor: string; diff --git a/gateway/app/routers/edits.py b/gateway/app/routers/edits.py index 57b7da5..05ded10 100644 --- a/gateway/app/routers/edits.py +++ b/gateway/app/routers/edits.py @@ -317,6 +317,24 @@ class SignatureOperation(BaseModel): pageIndex: int = Field(..., ge=0) data: SignatureData +class WatermarkData(BaseModel): + text: str + fontFamily: str = "Helvetica" + fontSize: float = Field(48.0, gt=0) + fontWeight: str = "normal" + color: str = "#000000" + opacity: float = Field(0.25, ge=0.0, le=1.0) + rotation: float = -45.0 + position: str = "center" + xOffset: float = 0.0 + yOffset: float = 0.0 + +class WatermarkOperation(BaseModel): + id: str + type: Literal["add_watermark", "watermark"] + pageIndex: int = Field(..., ge=0) + data: WatermarkData + EditOperation = Annotated[ TextOverlayOperation | StampOperation @@ -338,6 +356,7 @@ EditOperation = Annotated[ | StrikeoutOperation | SquigglyOperation | SignatureOperation + | WatermarkOperation , Field(discriminator="type"), ] @@ -351,6 +370,7 @@ _OP_PERMISSION = { "highlight": "canAnnotate", "underline": "canAnnotate", "strikeout": "canAnnotate", "squiggly": "canAnnotate", "comment": "canAnnotate", "freehand": "canAnnotate", "free_text": "canAnnotate", "text_overlay": "canAnnotate", "stamp": "canAnnotate", + "watermark": "canAnnotate", "add_watermark": "canAnnotate", "image_overlay": "canAnnotate", "delete_annotation": "canAnnotate", "update_annotation": "canAnnotate", "replace_text": "canModify", "reflow_paragraph": "canModify", "redaction": "canModify", "update_field": "canFillForms", diff --git a/gateway/tests/test_watermark.py b/gateway/tests/test_watermark.py new file mode 100644 index 0000000..1fb704a --- /dev/null +++ b/gateway/tests/test_watermark.py @@ -0,0 +1,54 @@ +import contextlib +from pathlib import Path +import pytest +from fastapi.testclient import TestClient +from app.services import engine + +has_pdfium = False +if engine.is_available(): + with contextlib.suppress(Exception): + has_pdfium = engine.require().engine_has_pdfium() + +pytestmark = pytest.mark.skipif( + not engine.is_available() or not has_pdfium, + reason="pdfengine pybind11 module is not compiled/available, or was compiled without PDFium support.", +) + +CORPUS_DIR = Path(__file__).parent.parent.parent / "corpus" +HELLO_WORLD_PDF = CORPUS_DIR / "basic" / "hello_world.pdf" + +def test_watermark_operation(client: TestClient): + assert HELLO_WORLD_PDF.exists(), f"Test corpus file not found at {HELLO_WORLD_PDF}" + + with open(HELLO_WORLD_PDF, "rb") as f: + upload_resp = client.post( + "/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")} + ) + assert upload_resp.status_code == 201 + doc_id = upload_resp.json()["id"] + + payload = { + "version": "1.0", + "operations": [ + { + "id": "wm_001", + "type": "add_watermark", + "pageIndex": 0, + "data": { + "text": "CONFIDENTIAL", + "fontFamily": "Helvetica", + "fontSize": 48.0, + "fontWeight": "bold", + "color": "#dc2626", + "opacity": 0.25, + "rotation": -45.0, + "position": "center" + } + } + ] + } + response = client.post(f"/documents/{doc_id}/edits", json=payload) + assert response.status_code == 200 + data = response.json() + assert data.get("success") is True + assert "newDocumentId" in data