water mark don
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -15,7 +15,8 @@ export type ToolId =
|
||||
| 'squiggly'
|
||||
| 'stream_edit'
|
||||
| 'whiteboard'
|
||||
| 'create_pdf';
|
||||
| 'create_pdf'
|
||||
| 'watermark';
|
||||
|
||||
export interface ToolSettings {
|
||||
highlightColor: string;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user