water mark don

This commit is contained in:
saqib mir
2026-08-12 18:33:34 +05:30
parent 5c5fe450ea
commit 59854f8f61
12 changed files with 679 additions and 10 deletions
+1
View File
@@ -169,6 +169,7 @@ private:
std::expected<void, EngineError> applyOp_reflow(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_textOverlay(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_stamp(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_watermark(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_decoration(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_redaction(const nlohmann::json& op, int pageIndex);
std::expected<void, EngineError> applyOp_updateField(const nlohmann::json& op, int pageIndex);
+3 -1
View File
@@ -44,7 +44,7 @@ std::expected<std::vector<InvalidatedRegion>, EngineError> PdfiumDocument::apply
}
static const std::set<std::string> 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<std::vector<InvalidatedRegion>, 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") {
@@ -676,4 +676,107 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_updateAnnotation(const
#endif
}
std::expected<void, EngineError> 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<float>(fontSize));
unsigned int r = 0, g = 0, b = 0;
parseHexColor(color, r, g, b);
unsigned int alpha = static_cast<unsigned int>(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<FPDF_WIDESTRING>(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<float>(matA), static_cast<float>(matB), static_cast<float>(matC), static_cast<float>(matD), static_cast<float>(matE), static_cast<float>(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
}
}
+99 -4
View File
@@ -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<StampPreset | null>(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}
/>
<WatermarkModal
open={watermarkModalOpen}
onClose={() => setWatermarkModalOpen(false)}
totalPages={(activeTool === 'create_pdf' || createPdfModalOpen) ? creatorPageCount : (activeDoc?.totalPages || 1)}
currentPage={currentPage}
onApplyWatermark={handleApplyWatermark}
/>
<CustomConfirmationModal state={confirmState} onClose={() => setConfirmState(null)} />
<PasswordModal
+14
View File
@@ -74,6 +74,20 @@ const TOOLS: (ToolDef | 'divider')[] = [
},
{ id: 'signature', label: 'Signature', shortcut: 'S', icon: <SignatureIcon /> },
{ id: 'stamp', label: 'Stamp', shortcut: 'M', icon: <StampIcon /> },
{
id: 'watermark',
label: 'Add Watermark',
shortLabel: 'Watermark',
shortcut: 'K',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="9" />
<path d="m9 12 2 2 4-4" />
<path d="M12 3v2" />
<path d="M12 19v2" />
</svg>
),
},
'divider',
{ id: 'redact', label: 'Redact', shortcut: 'R', icon: <RedactIcon />, danger: true },
];
+20 -1
View File
@@ -23,6 +23,7 @@ interface ToolbarProps {
onApplyRedactions?: () => void;
onClearRedactions?: () => void;
onRedactPages?: () => void;
onOpenWatermark?: () => void;
selectedAnnotation?: import('../viewer/AnnotationLayer').Annotation | null;
onUpdateAnnotation?: (patch: Partial<import('../viewer/AnnotationLayer').Annotation>) => void;
onDeleteAnnotation?: () => void;
@@ -76,6 +77,15 @@ const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
label: 'Whiteboard',
icon: <DrawIcon size={17} />
},
watermark: {
label: 'Watermark',
icon: (
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="9" />
<path d="m9 12 2 2 4-4" />
</svg>
),
},
};
const Hint: React.FC<{ children: React.ReactNode; tone?: 'normal' | 'warn' }> = ({ children, tone = 'normal' }) => (
@@ -89,7 +99,7 @@ const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-border-primary"
export const Toolbar: React.FC<ToolbarProps> = ({
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<ToolbarProps> = ({
</>
)}
{activeTool === 'watermark' && (
<>
<CustomButton variant="primary" size="sm" onClick={onOpenWatermark}>
Add Watermark
</CustomButton>
<Hint>Configure and apply text watermarks across PDF pages.</Hint>
</>
)}
{activeTool === 'redact' && (
<>
<div className="flex items-center gap-1.5 border-r border-border-primary pr-3">
+2 -3
View File
@@ -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<TopBarProps> = ({
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<HTMLInputElement>(null);
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
+346
View File
@@ -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<void>;
}
export const WATERMARK_PRESETS = ['CONFIDENTIAL', 'DRAFT', 'SAMPLE', 'PROPERTY OF COMPANY', 'FINAL', 'INTERNAL USE ONLY'];
export const WatermarkModal: React.FC<WatermarkModalProps> = ({
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<string | null>(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<number>();
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 (
<Modal
open={open}
onClose={() => {
if (!isSubmitting) onClose();
}}
title="Add Watermark"
width={480}
>
<div className="flex flex-col gap-4 text-[13px]">
{errorMessage && (
<div className="rounded-lg bg-red-50 border border-red-200 p-3 text-red-700 text-xs font-semibold flex items-center justify-between">
<span> {errorMessage}</span>
<button
type="button"
onClick={() => setErrorMessage(null)}
className="text-red-500 hover:text-red-800 font-bold ml-2 cursor-pointer"
>
</button>
</div>
)}
{/* Text & Presets */}
<div className="flex flex-col gap-1.5">
<label className="text-[12px] font-bold text-text-primary">
Watermark text:
</label>
<input
type="text"
value={text}
onChange={(e) => 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"
/>
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<span className="text-[11px] font-semibold text-text-tertiary">Presets:</span>
{WATERMARK_PRESETS.map((preset) => (
<button
key={preset}
type="button"
onClick={() => setText(preset)}
className={`rounded px-2 py-0.5 text-[10.5px] font-semibold border transition-all cursor-pointer ${
text === preset
? 'bg-brand-primary text-white border-brand-primary'
: 'bg-bg-secondary text-text-secondary border-border-primary hover:bg-bg-tertiary'
}`}
>
{preset}
</button>
))}
</div>
</div>
{/* Appearance Controls */}
<div className="rounded-xl border border-border-primary bg-bg-secondary/40 p-3.5 flex flex-col gap-3">
<span className="text-[11.5px] font-extrabold uppercase tracking-wide text-text-tertiary">
Appearance & Formatting
</span>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-text-secondary">Font Family:</label>
<select
value={fontFamily}
onChange={(e) => setFontFamily(e.target.value)}
disabled={isSubmitting}
className="h-8 rounded-md border border-border-primary bg-bg-primary px-2 text-[12px] font-medium text-text-primary outline-none"
>
<option value="Helvetica">Helvetica (Sans-Serif)</option>
<option value="Times-Roman">Times-Roman (Serif)</option>
<option value="Courier">Courier (Monospace)</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11px] font-semibold text-text-secondary">Font Weight:</label>
<select
value={fontWeight}
onChange={(e) => setFontWeight(e.target.value)}
disabled={isSubmitting}
className="h-8 rounded-md border border-border-primary bg-bg-primary px-2 text-[12px] font-medium text-text-primary outline-none"
>
<option value="bold">Bold</option>
<option value="normal">Normal</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3 items-center">
<Slider
label="Font Size"
min={12}
max={120}
step={2}
value={fontSize}
onChange={setFontSize}
suffix="pt"
width={100}
/>
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold text-text-secondary">Color:</span>
<ColorSwatches value={color} onChange={setColor} />
</div>
</div>
<div className="grid grid-cols-2 gap-3 items-center">
<Slider
label="Opacity"
min={5}
max={100}
step={5}
value={opacity}
onChange={setOpacity}
suffix="%"
width={100}
/>
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold text-text-secondary">Rotation:</span>
<select
value={rotation}
onChange={(e) => setRotation(Number(e.target.value))}
disabled={isSubmitting}
className="h-8 rounded-md border border-border-primary bg-bg-primary px-2 text-[12px] font-medium text-text-primary outline-none"
>
<option value={-45}>-45° Diagonal</option>
<option value={45}>45° Diagonal</option>
<option value={0}>0° Horizontal</option>
<option value={90}>90° Vertical</option>
</select>
</div>
</div>
</div>
{/* Position & Target Pages */}
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1">
<label className="text-[12px] font-bold text-text-primary">Position:</label>
<select
value={position}
onChange={(e) => setPosition(e.target.value as any)}
disabled={isSubmitting}
className="h-9 rounded-lg border border-border-primary bg-bg-primary px-2.5 text-[12.5px] font-medium text-text-primary outline-none"
>
<option value="center">Center</option>
<option value="top_center">Top Center</option>
<option value="top_left">Top Left</option>
<option value="top_right">Top Right</option>
<option value="center_left">Center Left</option>
<option value="center_right">Center Right</option>
<option value="bottom_center">Bottom Center</option>
<option value="bottom_left">Bottom Left</option>
<option value="bottom_right">Bottom Right</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[12px] font-bold text-text-primary">Apply to Pages:</label>
<select
value={targetPages}
onChange={(e) => setTargetPages(e.target.value as any)}
disabled={isSubmitting}
className="h-9 rounded-lg border border-border-primary bg-bg-primary px-2.5 text-[12.5px] font-medium text-text-primary outline-none"
>
<option value="all">All Pages ({totalPages})</option>
<option value="current">Current Page ({currentPage + 1})</option>
<option value="custom">Custom Page Range</option>
</select>
</div>
</div>
{targetPages === 'custom' && (
<div className="flex flex-col gap-1 rounded-lg bg-bg-secondary p-2.5 border border-border-primary">
<label className="text-[11.5px] font-semibold text-text-secondary">
Page Range (e.g. 1-3, 5, 8):
</label>
<input
type="text"
value={customRangeStr}
onChange={(e) => 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"
/>
</div>
)}
{/* Action Buttons */}
<div className="mt-2 flex items-center justify-end gap-2.5 pt-3 border-t border-border-primary">
<CustomButton variant="outline" onClick={onClose} disabled={isSubmitting}>
Cancel
</CustomButton>
<CustomButton variant="primary" onClick={handleApply} disabled={isSubmitting}>
{isSubmitting ? 'Applying Watermark...' : 'Apply Watermark'}
</CustomButton>
</div>
</div>
</Modal>
);
};
+15
View File
@@ -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;
+2 -1
View File
@@ -15,7 +15,8 @@ export type ToolId =
| 'squiggly'
| 'stream_edit'
| 'whiteboard'
| 'create_pdf';
| 'create_pdf'
| 'watermark';
export interface ToolSettings {
highlightColor: string;
+20
View File
@@ -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",
+54
View File
@@ -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