Merge pull request 'azeem' (#77) from azeem into dev

Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/77
This commit is contained in:
furqan
2026-07-10 05:49:09 +00:00
14 changed files with 667 additions and 47 deletions
+1
View File
@@ -164,6 +164,7 @@ private:
std::expected<void, EngineError> applyOp_replaceText(const nlohmann::json& op, int pageIndex);
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_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
@@ -42,7 +42,7 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
}
static const std::set<std::string> kContentOps = {
"replace_text", "reflow_paragraph", "text_overlay", "add_text",
"replace_text", "reflow_paragraph", "text_overlay", "add_text", "stamp",
"underline", "strikeout", "squiggly", "redaction",
"image_overlay", "highlight", "free_text", "comment", "freehand"};
if (kContentOps.count(type)) markEdited(pageIndex);
@@ -54,6 +54,8 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
r = applyOp_reflow(op, pageIndex);
} else if (type == "text_overlay" || type == "add_text") {
r = applyOp_textOverlay(op, pageIndex);
} else if (type == "stamp") {
r = applyOp_stamp(op, pageIndex);
} else if (type == "underline" || type == "strikeout" || type == "squiggly") {
r = applyOp_decoration(op, pageIndex);
} else if (type == "redaction") {
@@ -1,4 +1,7 @@
#include "parser/pdfium_internal.hpp"
#include <chrono>
#include <iomanip>
#include <sstream>
namespace pdfengine::parser {
@@ -111,6 +114,101 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_textOverlay(const nlohm
#endif
}
std::expected<void, EngineError> PdfiumDocument::applyOp_stamp(const nlohmann::json& op, int pageIndex) {
#ifdef PDFENGINE_WITH_PDFIUM
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("stamp operation missing 'data' object");
return std::unexpected(EngineError::InvalidFormat);
}
auto data = op["data"];
std::string text = data.value("text", "");
double x = data.value("x", 0.0);
double y = data.value("y", 0.0);
double width = data.value("width", 100.0);
double height = data.value("height", 30.0);
double fontSize = data.value("fontSize", 18.0);
std::string textColor = data.value("textColor", "#000000");
std::string bgColor = data.value("backgroundColor", "#ffffff");
std::string borderColor = data.value("borderColor", "#000000");
bool includeDate = data.value("includeDate", false);
if (includeDate) {
auto now = std::chrono::system_clock::now();
auto in_time_t = std::chrono::system_clock::to_time_t(now);
std::stringstream ss;
ss << std::put_time(std::localtime(&in_time_t), "%Y-%m-%d %H:%M");
text += "\n" + ss.str();
}
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
if (!page) {
spdlog::error("Failed to load page index {} for stamp", pageIndex);
return std::unexpected(EngineError::Unknown);
}
// Background rect
FPDF_PAGEOBJECT bgRect = FPDFPageObj_CreateNewRect(
static_cast<float>(x), static_cast<float>(y),
static_cast<float>(width), static_cast<float>(height)
);
unsigned int r=0, g=0, b=0;
parseHexColor(bgColor, r, g, b);
FPDFPageObj_SetFillColor(bgRect, r, g, b, 255);
FPDFPath_SetDrawMode(bgRect, FPDF_FILLMODE_WINDING, 0);
FPDFPage_InsertObject(page, bgRect);
// Border rect
FPDF_PAGEOBJECT borderRect = FPDFPageObj_CreateNewRect(
static_cast<float>(x), static_cast<float>(y),
static_cast<float>(width), static_cast<float>(height)
);
parseHexColor(borderColor, r, g, b);
FPDFPageObj_SetStrokeColor(borderRect, r, g, b, 255);
FPDFPageObj_SetStrokeWidth(borderRect, 2.5f);
FPDFPath_SetDrawMode(borderRect, 0, 1);
FPDFPage_InsertObject(page, borderRect);
FPDF_FONT font = FPDFText_LoadStandardFont(doc_, "Helvetica-Bold");
std::vector<std::string> lines;
std::stringstream textStream(text);
std::string line;
while(std::getline(textStream, line, '\n')) {
lines.push_back(line);
}
float startY = static_cast<float>(y + height - fontSize * 1.1);
parseHexColor(textColor, r, g, b);
for (size_t i = 0; i < lines.size(); i++) {
float currentFontSize = static_cast<float>(i == 0 ? fontSize : fontSize * 0.5);
FPDF_PAGEOBJECT textObj = FPDFPageObj_CreateTextObj(doc_, font, currentFontSize);
FPDFPageObj_SetFillColor(textObj, r, g, b, 255);
auto utf16 = utf8_to_utf16le(lines[i]);
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 textX = static_cast<float>(x + width / 2.0 - textWidth / 2.0);
float textY = startY - (i * fontSize * 0.7f);
FPDFPageObj_Transform(textObj, 1.0, 0.0, 0.0, 1.0, textX, textY);
FPDFPage_InsertObject(page, textObj);
}
if (!FPDFPage_GenerateContent(page)) {
spdlog::error("Failed to generate page content after stamp");
}
FPDF_ClosePage(page);
return {};
#else
(void)op; (void)pageIndex;
return std::unexpected(EngineError::Unknown);
#endif
}
std::expected<void, EngineError> PdfiumDocument::applyOp_decoration(const nlohmann::json& op, int pageIndex) {
#ifdef PDFENGINE_WITH_PDFIUM
std::string type = op.value("type", "");
+83 -9
View File
@@ -5,6 +5,7 @@ import { Toolbar } from './components/Toolbar';
import { InspectorPanel } from './components/InspectorPanel';
import type { InspectorTab } from './components/InspectorPanel';
import { SignatureModal } from './components/SignatureModal';
import { RedactPagesModal } from './components/RedactPagesModal';
import { AboutModal } from './components/AboutModal';
import { ToastViewport } from './components/ui';
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
@@ -20,7 +21,7 @@ import { viewportRectToPdf } from './lib/coordinateMapping';
import type { Rect } from './lib/coordinateMapping';
import { toast } from './lib/toast';
import { DEFAULT_TOOL_SETTINGS, TOOL_SHORTCUTS } from './lib/tools';
import type { ToolId, ToolSettings } from './lib/tools';
import type { ToolId, ToolSettings, StampPreset } from './lib/tools';
const rid = (p: string) => `${p}_${Math.random().toString(36).substring(2, 11)}`;
@@ -65,9 +66,11 @@ function App() {
const [pendingSignature, setPendingSignature] = useState<{ url: string; aspect: number } | null>(null);
const [signatureModalOpen, setSignatureModalOpen] = useState(false);
const [redactPagesModalOpen, setRedactPagesModalOpen] = useState(false);
const [aboutModalOpen, setAboutModalOpen] = useState(false);
const [passwordPrompt, setPasswordPrompt] = useState<{ file: File; filename: string; error?: string } | null>(null);
const [activeStamp, setActiveStamp] = useState<{ label: string; color: string } | null>(null);
const [activeStamp, setActiveStamp] = useState<StampPreset | null>(null);
const [redactionMode, setRedactionMode] = useState<'area' | 'text'>('area');
const [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null);
const [searchQuery, setSearchQuery] = useState('');
@@ -332,15 +335,76 @@ function App() {
if (!activeStamp) return;
if (!can('canAnnotate')) { denyToast('Stamping'); return; }
const fontSize = 22;
const width = Math.max(60, activeStamp.label.length * fontSize * 0.62);
const height = fontSize * 1.5;
const padding = 12; // visual padding
const dateWidth = 16 * (fontSize * 0.5) * 0.7; // date string approx length
const labelWidth = activeStamp.label.length * fontSize * 0.7;
const contentWidth = Math.max(labelWidth, dateWidth);
const width = Math.max(80, contentWidth) + (padding * 2);
const height = fontSize * 1.5 + (padding * 2);
const pdf = viewportRectToPdf({ x: point.x, y: point.y, width, height }, 1, pageHeightPts(pageIndex));
applyOps([{
id: rid('stamp'), type: 'text_overlay', pageIndex,
data: { x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height, text: activeStamp.label, fontSize, fontFamily: 'Helvetica-Bold', color: activeStamp.color },
id: rid('stamp'), type: 'stamp', pageIndex,
data: {
x: pdf.x, y: pdf.y, width: pdf.width, height: pdf.height,
text: activeStamp.label,
textColor: activeStamp.textColor,
backgroundColor: activeStamp.backgroundColor,
borderColor: activeStamp.borderColor,
fontSize,
includeDate: true // We can toggle this later, true by default for now
},
}], `Stamp “${activeStamp.label}” placed`);
};
const handleRedactPages = (pagesString: string) => {
if (!activeDoc) return;
const ranges = pagesString.split(',').map(s => s.trim());
const pagesToRedact = new Set<number>();
for (const r of ranges) {
if (r.includes('-')) {
const parts = r.split('-');
if (parts.length === 2) {
const start = parseInt(parts[0], 10);
const end = parseInt(parts[1], 10);
if (!isNaN(start) && !isNaN(end)) {
for (let i = Math.min(start, end); i <= Math.max(start, end); i++) {
if (i >= 1 && i <= activeDoc.totalPages) pagesToRedact.add(i - 1);
}
}
}
} else {
const val = parseInt(r, 10);
if (!isNaN(val) && val >= 1 && val <= activeDoc.totalPages) {
pagesToRedact.add(val - 1);
}
}
}
const newRedactions: { id: string, pageIndex: number, bounds: Rect }[] = [];
pagesToRedact.forEach(pageIndex => {
const pInfo = activeDoc.pages?.[pageIndex];
if (!pInfo) return;
newRedactions.push({
id: rid('redmark'),
pageIndex,
bounds: {
x: 0,
y: 0,
width: pInfo.width,
height: pInfo.height
}
});
});
if (newRedactions.length > 0) {
setPendingRedactions(p => [...p, ...newRedactions]);
toast(`Marked ${newRedactions.length} page${newRedactions.length > 1 ? 's' : ''} for redaction`, 'success');
} else {
toast('No valid pages found in range', 'error');
}
setRedactPagesModalOpen(false);
};
const handlePlaceSignature = (pageIndex: number, pdfRect: { x: number; y: number; width: number; height: number }, _rotation: number) => {
if (!pendingSignature) return;
if (!can('canAnnotate')) { denyToast('Signing'); setActiveTool('select'); return; }
@@ -562,11 +626,14 @@ function App() {
onSettingsChange={(patch) => setToolSettings((s) => ({ ...s, ...patch }))}
onOpenSignature={() => setSignatureModalOpen(true)}
hasSignature={!!pendingSignature}
activeStamp={activeStamp?.label ?? null}
onSelectStamp={(label, color) => setActiveStamp({ label, color })}
activeStamp={activeStamp}
onSelectStamp={setActiveStamp}
redactionMode={redactionMode}
onRedactionModeChange={setRedactionMode}
pendingRedactionCount={pendingRedactions.length}
onApplyRedactions={handleApplyRedactions}
onClearRedactions={() => setPendingRedactions([])}
onRedactPages={() => setRedactPagesModalOpen(true)}
/>
<div className="relative min-h-0 flex-1">
@@ -586,10 +653,11 @@ function App() {
pagesInfo={activeDoc.pages}
activeTool={activeTool}
toolSettings={toolSettings}
redactionMode={redactionMode}
hasSignature={!!pendingSignature}
signatureImageUrl={pendingSignature?.url}
signatureAspect={pendingSignature?.aspect}
activeStamp={activeStamp?.label ?? null}
activeStamp={activeStamp}
annotations={annotations}
canCopy={can('canCopy')}
onFieldChange={(id, value, i) => {
@@ -688,6 +756,12 @@ function App() {
}}
/>
<RedactPagesModal
open={redactPagesModalOpen}
onClose={() => setRedactPagesModalOpen(false)}
onConfirm={handleRedactPages}
/>
<AboutModal
open={aboutModalOpen}
onClose={() => setAboutModalOpen(false)}
@@ -0,0 +1,52 @@
import React, { useState } from 'react';
import { Modal } from './ui';
import { CustomButton } from './custom/CustomButton';
interface RedactPagesModalProps {
open: boolean;
onClose: () => void;
onConfirm: (pagesString: string) => void;
}
export const RedactPagesModal: React.FC<RedactPagesModalProps> = ({ open, onClose, onConfirm }) => {
const [pagesString, setPagesString] = useState('');
return (
<Modal open={open} onClose={onClose} title="Redact Pages">
<div className="flex w-[400px] flex-col gap-4 p-5">
<p className="text-[13px] text-[#4b5563]">
Enter the pages or page ranges to mark for redaction (e.g. "1, 3-5"). This will mark the entire page area for redaction.
</p>
<div className="flex flex-col gap-1.5">
<label className="text-[11.5px] font-semibold text-[#18212e]">Pages</label>
<input
type="text"
className="h-[36px] w-full rounded-[8px] border border-[#d1d5db] bg-white px-3 text-[13.5px] shadow-sm outline-none transition-colors focus:border-[#2563eb] focus:ring-1 focus:ring-[#2563eb]"
placeholder="e.g. 1, 3-5"
value={pagesString}
onChange={(e) => setPagesString(e.target.value)}
autoFocus
/>
</div>
<div className="mt-4 flex justify-end gap-2 border-t border-[#ebedf0] pt-4">
<CustomButton variant="outline" onClick={onClose}>
Cancel
</CustomButton>
<CustomButton
variant="primary"
onClick={() => {
if (pagesString.trim()) {
onConfirm(pagesString.trim());
setPagesString('');
}
}}
disabled={!pagesString.trim()}
>
Mark for Redaction
</CustomButton>
</div>
</div>
</Modal>
);
};
+30 -8
View File
@@ -1,6 +1,6 @@
import { CustomButton } from './custom/CustomButton';
import React from 'react';
import type { ToolId, ToolSettings } from '../lib/tools';
import type { ToolId, ToolSettings, StampPreset } from '../lib/tools';
import { STAMP_PRESETS } from '../lib/tools';
import { ColorSwatches, Slider } from './ui';
import {
@@ -15,11 +15,14 @@ interface ToolbarProps {
onSettingsChange: (patch: Partial<ToolSettings>) => void;
onOpenSignature: () => void;
hasSignature: boolean;
activeStamp: string | null;
onSelectStamp: (label: string, color: string) => void;
activeStamp?: StampPreset | null;
onSelectStamp: (stamp: StampPreset) => void;
redactionMode?: 'area' | 'text';
onRedactionModeChange?: (mode: 'area' | 'text') => void;
pendingRedactionCount?: number;
onApplyRedactions?: () => void;
onClearRedactions?: () => void;
onRedactPages?: () => void;
}
const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
@@ -57,7 +60,8 @@ const Divider = () => <div className="mx-1 h-5 w-px shrink-0 bg-[#ebedf0]" />;
export const Toolbar: React.FC<ToolbarProps> = ({
activeTool, settings, onSettingsChange, onOpenSignature, hasSignature, activeStamp, onSelectStamp,
pendingRedactionCount = 0, onApplyRedactions, onClearRedactions,
redactionMode = 'area', onRedactionModeChange,
pendingRedactionCount = 0, onApplyRedactions, onClearRedactions, onRedactPages,
}) => {
const meta = TOOL_META[activeTool];
const isRedact = activeTool === 'redact';
@@ -154,9 +158,9 @@ export const Toolbar: React.FC<ToolbarProps> = ({
<>
<div className="flex items-center gap-2">
{STAMP_PRESETS.map((s) => (
<CustomButton variant="unstyled" key={s.label} onClick={() => onSelectStamp(s.label, s.color)}
className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-transform hover:scale-[1.04] ${activeStamp === s.label ? 'ring-2 ring-offset-1 ring-[#2563eb]' : ''}`}
style={{ color: s.color, borderColor: s.color, background: `color-mix(in srgb, ${s.color} 8%, white)` }}>
<CustomButton variant="unstyled" key={s.label} onClick={() => onSelectStamp(s)}
className={`shrink-0 rounded-[6px] border px-2.5 py-1 text-[10.5px] font-bold tracking-wide transition-transform hover:scale-[1.04] ${activeStamp?.label === s.label ? 'ring-2 ring-offset-1 ring-[#2563eb]' : ''}`}
style={{ color: s.textColor, borderColor: s.borderColor, background: s.backgroundColor }}>
{s.label}
</CustomButton>
))}
@@ -167,6 +171,24 @@ export const Toolbar: React.FC<ToolbarProps> = ({
{activeTool === 'redact' && (
<>
<div className="flex items-center gap-1.5 border-r border-[#ebedf0] pr-3">
<button
className={`rounded px-2 py-1 text-[11px] font-semibold transition-colors ${redactionMode === 'area' ? 'bg-[#2563eb] text-white' : 'text-[#4b5563] hover:bg-[#f3f4f6]'}`}
onClick={() => onRedactionModeChange?.('area')}
>
Area
</button>
<button
className={`rounded px-2 py-1 text-[11px] font-semibold transition-colors ${redactionMode === 'text' ? 'bg-[#2563eb] text-white' : 'text-[#4b5563] hover:bg-[#f3f4f6]'}`}
onClick={() => onRedactionModeChange?.('text')}
>
Text
</button>
</div>
<CustomButton variant="outline" size="sm" onClick={onRedactPages}>
Redact Pages
</CustomButton>
<Divider />
{pendingRedactionCount > 0 ? (
<>
<CustomButton variant="primary" size="sm" onClick={onApplyRedactions}>
@@ -177,7 +199,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
</CustomButton>
</>
) : (
<Hint tone="warn"> Drag a box to permanently remove content underneath.</Hint>
<Hint tone="warn"> Select text or drag a box to permanently remove content.</Hint>
)}
</>
)}
+15
View File
@@ -136,6 +136,20 @@ export interface TextOverlayData {
color: string;
}
export interface StampData {
text: string;
x: number;
y: number;
width: number;
height: number;
textColor: string;
backgroundColor: string;
borderColor: string;
fontSize: number;
includeDate: boolean;
timestamp?: string;
}
export interface RedactionData {
x: number;
y: number;
@@ -207,6 +221,7 @@ export interface PageReorderData {
export type EditOperationDataMap = {
text_overlay: TextOverlayData;
stamp: StampData;
redaction: RedactionData;
image_overlay: ImageOverlayData;
highlight: HighlightData;
+14 -7
View File
@@ -55,11 +55,18 @@ export const TOOL_SHORTCUTS: Record<string, ToolId> = {
q: 'stream_edit',
};
export const STAMP_PRESETS = [
{ label: 'APPROVED', color: '#16a34a' },
{ label: 'DRAFT', color: '#6b7280' },
{ label: 'CONFIDENTIAL', color: '#dc2626' },
{ label: 'REVIEWED', color: '#2563eb' },
{ label: 'FINAL', color: '#7c3aed' },
{ label: 'VOID', color: '#dc2626' },
export interface StampPreset {
label: string;
textColor: string;
backgroundColor: string;
borderColor: string;
}
export const STAMP_PRESETS: StampPreset[] = [
{ label: 'APPROVED', textColor: '#16a34a', backgroundColor: '#dcfce7', borderColor: '#16a34a' },
{ label: 'DRAFT', textColor: '#6b7280', backgroundColor: '#f3f4f6', borderColor: '#6b7280' },
{ label: 'CONFIDENTIAL', textColor: '#dc2626', backgroundColor: '#fee2e2', borderColor: '#dc2626' },
{ label: 'REVIEWED', textColor: '#2563eb', backgroundColor: '#dbeafe', borderColor: '#2563eb' },
{ label: 'FINAL', textColor: '#7c3aed', backgroundColor: '#ede9fe', borderColor: '#7c3aed' },
{ label: 'VOID', textColor: '#dc2626', backgroundColor: '#fee2e2', borderColor: '#dc2626' },
];
+206
View File
@@ -0,0 +1,206 @@
import React, { useState, useRef, useEffect } from 'react';
import type { Rect } from '../lib/coordinateMapping';
interface FloatingTextToolbarProps {
selection: { text: string; bbox: Rect; lines: Rect[] };
onAction: (action: 'copy' | 'comment' | 'highlight' | 'underline' | 'strikeout' | 'squiggly' | 'redact' | 'edit', overrideColor?: string) => void;
}
const COLORS = [
'#facc15', '#4ade80', '#2dd4bf', '#a78bfa', '#e879f9',
'#fb923c', '#60a5fa', '#f472b6', '#22d3ee', '#34d399',
'#16a34a', '#a855f7', '#2563eb', '#fef08a', '#ef4444',
'#ffffff', '#e5e5e5', '#a3a3a3', '#52525b', '#000000',
];
export const FloatingTextToolbar: React.FC<FloatingTextToolbarProps> = ({ selection, onAction }) => {
const { bbox } = selection;
// Calculate position: just above the bounding box
const top = bbox.y - 48; // 48px above
const left = bbox.x;
const [openDropdown, setOpenDropdown] = useState<'highlight' | 'underline' | 'strikeout' | null>(null);
const [toolColors, setToolColors] = useState({
highlight: '#facc15',
underline: '#f43f5e',
strikeout: '#ef4444',
});
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpenDropdown(null);
}
};
if (openDropdown) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [openDropdown]);
const handleAction = (action: 'highlight' | 'underline' | 'strikeout') => {
onAction(action, toolColors[action]);
setOpenDropdown(null);
};
const ColorPicker = ({ action }: { action: 'highlight' | 'underline' | 'strikeout' }) => (
<div className="absolute top-full left-0 mt-1 p-2 bg-[#262626] border border-[#3f3f46] rounded-md shadow-xl w-48 z-50 flex flex-col gap-2">
<div className="grid grid-cols-5 gap-1">
{COLORS.map((c) => (
<button
key={c}
className={`w-8 h-8 rounded-sm ${toolColors[action] === c ? 'ring-2 ring-white ring-offset-1 ring-offset-[#262626]' : ''}`}
style={{ backgroundColor: c }}
onClick={() => {
setToolColors(prev => ({ ...prev, [action]: c }));
onAction(action, c);
setOpenDropdown(null);
}}
/>
))}
</div>
<div className="text-xs text-[#a1a1aa] mt-1 cursor-pointer hover:text-white transition-colors">More colors</div>
<div className="flex items-center justify-between text-xs text-[#a1a1aa] border-t border-[#3f3f46] pt-2 mt-1">
<span>Opacity</span>
<div className="flex items-center gap-2">
<button className="hover:text-white"></button>
<span>100%</span>
<button className="hover:text-white">+</button>
</div>
</div>
</div>
);
return (
<div
ref={menuRef}
className="absolute z-50 flex items-center gap-1 bg-[#262626] rounded-[6px] shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1.5 border border-[#3f3f46]"
style={{ top, left, pointerEvents: 'auto' }}
onMouseDown={(e) => {
// Prevent clearing the selection layer when clicking the toolbar
e.stopPropagation();
}}
>
<button
onClick={() => onAction('copy')}
className="p-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Copy"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<button
onClick={() => onAction('comment')}
className="p-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Add Comment"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
<line x1="9" y1="12" x2="15" y2="12" />
<line x1="12" y1="9" x2="12" y2="15" />
</svg>
</button>
<div className="w-px h-4 bg-[#52525b] mx-1" />
<div className="relative flex items-center group">
<button
onClick={() => handleAction('highlight')}
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Highlight"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m9 11-6 6v3h9l3-3"/>
<path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/>
<path d="M12 21h10" strokeWidth="3" stroke={toolColors.highlight} />
</svg>
</button>
<button
onClick={() => setOpenDropdown(openDropdown === 'highlight' ? null : 'highlight')}
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
{openDropdown === 'highlight' && <ColorPicker action="highlight" />}
</div>
<div className="relative flex items-center group">
<button
onClick={() => handleAction('underline')}
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Underline"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 4v6a6 6 0 0 0 12 0V4"/>
<line x1="4" y1="20" x2="20" y2="20" stroke={toolColors.underline} strokeWidth="3" />
</svg>
</button>
<button
onClick={() => setOpenDropdown(openDropdown === 'underline' ? null : 'underline')}
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
{openDropdown === 'underline' && <ColorPicker action="underline" />}
</div>
<div className="relative flex items-center group">
<button
onClick={() => handleAction('strikeout')}
className="p-1.5 rounded-l hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors"
title="Strikeout"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 4H9a3 3 0 0 0-2.83 4"/>
<path d="M14 12a4 4 0 0 1 0 8H6"/>
<line x1="4" y1="12" x2="20" y2="12" stroke={toolColors.strikeout} strokeWidth="2.5" />
</svg>
</button>
<button
onClick={() => setOpenDropdown(openDropdown === 'strikeout' ? null : 'strikeout')}
className="p-1.5 rounded-r hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors border-l border-transparent group-hover:border-[#3f3f46]"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
{openDropdown === 'strikeout' && <ColorPicker action="strikeout" />}
</div>
<div className="w-px h-4 bg-[#52525b] mx-1" />
<button
onClick={() => onAction('redact')}
className="px-2 py-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
title="Redact Text"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 3h18v18H3z"/>
<path d="M3 3l18 18"/>
</svg>
Redact Text
</button>
<button
onClick={() => onAction('edit')}
className="px-2 py-1.5 rounded hover:bg-[#3f3f46] text-[#e4e4e7] flex items-center justify-center transition-colors gap-1.5 text-xs font-medium"
title="Edit Text"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/>
<path d="M15 5l4 4"/>
</svg>
Edit Text
</button>
</div>
);
};
+3 -2
View File
@@ -2,6 +2,7 @@ import { CustomButton } from '../components/custom/CustomButton';
import React, { useState, useRef } from 'react';
import type { Annotation } from './AnnotationLayer';
import type { Rect } from '../lib/coordinateMapping';
import type { StampPreset } from '../lib/tools';
interface OverlayLayerProps {
pageIndex: number;
@@ -14,7 +15,7 @@ interface OverlayLayerProps {
textColor: string;
fontSize: number;
hasSignature: boolean;
activeStamp: string | null;
activeStamp: StampPreset | null;
onAnnotationAdded?: (anno: Annotation) => void;
onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void;
onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
@@ -143,7 +144,7 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a sticky note</div>
)}
{activeTool === 'stamp' && activeStamp && (
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to place {activeStamp}</div>
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to place {activeStamp.label}</div>
)}
{activeTool === 'textbox' && !textBox && (
<div className="absolute top-[14px] left-1/2 -translate-x-1/2 bg-[#18212e] text-white text-[11px] font-semibold py-[5px] px-[12px] rounded-full shadow-[0_4px_12px_rgba(16,24,40,0.10)] whitespace-nowrap" style={{ pointerEvents: 'none' }}>Click to add a text box</div>
+120 -13
View File
@@ -11,13 +11,14 @@ import { SearchOverlayLayer } from './SearchOverlayLayer';
import type { Rect } from '../lib/coordinateMapping';
import { gatewayService } from '../lib/gatewayService';
import type { SearchResult, PageInfo, Glyph } from '../lib/gatewayService';
import type { ToolSettings } from '../lib/tools';
import type { ToolSettings, ToolId, StampPreset } from '../lib/tools';
import { toast } from '../lib/toast';
import { RedactionLayer } from './RedactionLayer';
import { StreamEditLayer } from './StreamEditLayer';
import { wasmFreeDocument } from '../lib/pdfiumEngine';
import { SignaturePlacementOverlay } from './SignaturePlacementOverlay';
import type { PlacementRect } from './SignaturePlacementOverlay';
import { FloatingTextToolbar } from './FloatingTextToolbar';
interface PDFViewerProps {
documentId: string;
@@ -26,13 +27,14 @@ interface PDFViewerProps {
pageHeight: number;
zoom: number;
pagesInfo?: PageInfo[];
activeTool: string;
activeTool: ToolId;
toolSettings: ToolSettings;
redactionMode?: 'area' | 'text';
hasSignature: boolean;
/** The data-URL of the pending signature image (for the placement preview) */
signatureImageUrl?: string;
signatureAspect?: number;
activeStamp: string | null;
activeStamp: StampPreset | null;
annotations: Annotation[];
searchQuery?: string;
searchResults?: SearchResult[];
@@ -78,6 +80,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
pagesInfo,
activeTool,
toolSettings,
redactionMode = 'area',
hasSignature,
signatureImageUrl,
signatureAspect = 3,
@@ -119,6 +122,13 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
rect: PlacementRect;
} | null>(null);
const [textSelection, setTextSelection] = useState<{
pageIndex: number;
text: string;
bbox: Rect;
lines: Rect[];
} | null>(null);
useEffect(() => {
setPageTexts({});
const prev = prevDocumentIdRef.current;
@@ -313,6 +323,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
}, [textToolActive, visiblePages, documentId, pageTexts]);
const handleTextSelection = (text: string, bbox: Rect, lines: Rect[], pageIndex: number) => {
// This is still called on Ctrl+C for select mode
if (activeTool === 'select') {
if (!canCopy) {
toast("Copying is not permitted by this document's restrictions", 'error');
@@ -353,7 +364,96 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
activeTool === 'strikeout' ? toolSettings.strikeoutColor :
toolSettings.squigglyColor;
onDecorateText?.(pageIndex, lines, activeTool, color);
return;
}
if (activeTool === 'redact') {
if (lines.length > 0) {
lines.forEach(line => {
onMarkRedaction?.(pageIndex, {
x: line.x / zoom,
y: line.y / zoom,
width: line.width / zoom,
height: line.height / zoom,
});
});
}
return;
}
};
const handleToolbarAction = (action: string, overrideColor?: string) => {
if (!textSelection) return;
const { pageIndex, text, bbox, lines } = textSelection;
switch (action) {
case 'copy':
if (!canCopy) {
toast("Copying is not permitted by this document's restrictions", 'error');
break;
}
navigator.clipboard?.writeText(text).then(
() => toast(`Copied ${text.length} character${text.length > 1 ? 's' : ''}`, 'success'),
() => toast('Copy failed — clipboard unavailable', 'error')
);
break;
case 'highlight': {
const newAnno: Annotation = {
id: generateUniqueId(),
type: 'highlight',
pageIndex,
bbox: {
x: bbox.x / zoom,
y: bbox.y / zoom,
width: bbox.width / zoom,
height: bbox.height / zoom,
},
color: overrideColor || toolSettings.highlightColor,
opacity: toolSettings.highlightOpacity,
author: 'Current User',
content: text,
};
onAnnotationAdded?.(newAnno);
break;
}
case 'underline':
onDecorateText?.(pageIndex, lines, 'underline', overrideColor || toolSettings.underlineColor);
break;
case 'strikeout':
onDecorateText?.(pageIndex, lines, 'strikeout', overrideColor || toolSettings.strikeoutColor);
break;
case 'squiggly':
onDecorateText?.(pageIndex, lines, 'squiggly', overrideColor || toolSettings.squigglyColor);
break;
case 'redact':
onMarkRedaction?.(pageIndex, {
x: bbox.x / zoom,
y: bbox.y / zoom,
width: bbox.width / zoom,
height: bbox.height / zoom
});
break;
case 'edit':
toast('Please select the Edit Text tool from the toolbar to edit text content', 'info');
break;
case 'comment': {
const newAnno: Annotation = {
id: generateUniqueId(),
type: 'comment',
pageIndex,
bbox: {
x: bbox.x / zoom,
y: bbox.y / zoom,
width: 24,
height: 24,
},
color: '#facc15',
author: 'Current User',
content: 'New Comment\n\n' + text,
};
onAnnotationAdded?.(newAnno);
break;
}
}
setTextSelection(null);
};
const [isPanning, setIsPanning] = useState(false);
@@ -471,29 +571,36 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
onFieldChange={onFieldChange}
/>
{(activeTool === 'select' || activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly') && (
{(activeTool === 'select' || activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly' || (activeTool === 'redact' && redactionMode === 'text')) && (
<SelectionLayer
pageIndex={page.index}
width={page.width}
height={page.height}
zoom={zoom}
glyphs={pageTexts[page.index] || []}
mode={(activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly') ? 'highlight' : 'select'}
mode={(activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly' || activeTool === 'redact') ? 'highlight' : 'select'}
onTextSelected={(text, bbox, lines) => handleTextSelection(text, bbox, lines, page.index)}
onSelectionChange={(sel) => setTextSelection(sel ? { ...sel, pageIndex: page.index } : null)}
/>
)}
{activeTool === 'redact' && (
<RedactionLayer
pageIndex={page.index}
width={page.width}
height={page.height}
onRedactionSelected={(bounds) => onMarkRedaction?.(page.index, bounds)}
pendingRedactions={pendingRedactions.filter(r => r.pageIndex === page.index)}
onRemoveRedaction={onRemoveRedaction}
{textSelection && textSelection.pageIndex === page.index && (
<FloatingTextToolbar
selection={textSelection}
onAction={(action, color) => handleToolbarAction(action as any, color)}
/>
)}
<RedactionLayer
pageIndex={page.index}
width={page.width}
height={page.height}
onRedactionSelected={(bounds) => onMarkRedaction?.(page.index, bounds)}
pendingRedactions={pendingRedactions.filter(r => r.pageIndex === page.index)}
onRemoveRedaction={onRemoveRedaction}
mode={(activeTool === 'redact' && redactionMode === 'area') ? 'area' : 'text'}
/>
<OverlayLayer
pageIndex={page.index}
width={page.width}
+5 -1
View File
@@ -8,6 +8,7 @@ interface RedactionLayerProps {
onRedactionSelected: (bounds: Rect) => void;
pendingRedactions?: { id: string, bounds: Rect }[];
onRemoveRedaction?: (id: string) => void;
mode?: 'area' | 'text';
}
export const RedactionLayer: React.FC<RedactionLayerProps> = ({
@@ -16,6 +17,7 @@ export const RedactionLayer: React.FC<RedactionLayerProps> = ({
onRedactionSelected,
pendingRedactions = [],
onRemoveRedaction,
mode = 'area',
}) => {
const [dragStart, setDragStart] = useState<Point | null>(null);
const [redactBox, setRedactBox] = useState<Rect | null>(null);
@@ -66,8 +68,9 @@ export const RedactionLayer: React.FC<RedactionLayerProps> = ({
left: 0,
width: `${width}px`,
height: `${height}px`,
cursor: 'crosshair',
cursor: mode === 'area' ? 'crosshair' : 'default',
zIndex: 25,
pointerEvents: mode === 'area' ? 'auto' : 'none',
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
@@ -104,6 +107,7 @@ export const RedactionLayer: React.FC<RedactionLayerProps> = ({
backgroundColor: 'rgba(239, 68, 68, 0.2)',
zIndex: 30,
cursor: 'pointer',
pointerEvents: 'auto',
}}
>
<div className="absolute inset-0 hidden bg-black group-hover:block" title="Click to remove" onClick={(e) => { e.stopPropagation(); onRemoveRedaction?.(redaction.id); }} />
+13 -4
View File
@@ -13,6 +13,7 @@ interface SelectionLayerProps {
glyphs: Glyph[];
mode?: 'select' | 'highlight';
onTextSelected?: (text: string, bbox: Rect, lines: Rect[]) => void;
onSelectionChange?: (sel: { text: string, bbox: Rect, lines: Rect[] } | null) => void;
}
const SEL_START_EVT = 'pdf-selection-start';
@@ -25,6 +26,7 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
glyphs,
mode = 'select',
onTextSelected,
onSelectionChange,
}) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const model = useMemo(() => new TextSelectionModel(glyphs), [glyphs]);
@@ -62,7 +64,8 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
setBox(null);
drag.current = null;
boxStart.current = null;
}, []);
onSelectionChange?.(null);
}, [onSelectionChange]);
useEffect(() => {
const onOther = (e: Event) => {
@@ -160,14 +163,20 @@ export const SelectionLayer: React.FC<SelectionLayerProps> = ({
const cur = selRef.current;
if (!cur || cur.start === cur.end) {
setSel(null);
onSelectionChange?.(null);
return;
}
const text = model.textOfRange(cur);
const u = model.unionRect(cur);
const lines = model.rectsOfRange(cur).map((q) => ({ x: q.x * zoom, y: q.y * zoom, width: q.w * zoom, height: q.h * zoom }));
if (mode === 'highlight') {
const text = model.textOfRange(cur);
const u = model.unionRect(cur);
const lines = model.rectsOfRange(cur).map((q) => ({ x: q.x * zoom, y: q.y * zoom, width: q.w * zoom, height: q.h * zoom }));
if (u) onTextSelected?.(text, { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines);
setSel(null);
onSelectionChange?.(null);
} else if (mode === 'select') {
if (u) onSelectionChange?.({ text, bbox: { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines });
}
};
+24 -2
View File
@@ -21,6 +21,19 @@ class TextOverlayData(BaseModel):
fontFamily: str
color: str
class StampData(BaseModel):
text: str
x: float
y: float
width: float
height: float
textColor: str
backgroundColor: str
borderColor: str
fontSize: float = Field(..., gt=0)
includeDate: bool = False
timestamp: str | None = None
class RedactionData(BaseModel):
x: float
@@ -96,6 +109,12 @@ class TextOverlayOperation(BaseModel):
pageIndex: int = Field(..., ge=0)
data: TextOverlayData
class StampOperation(BaseModel):
id: str
type: Literal["stamp"]
pageIndex: int = Field(..., ge=0)
data: StampData
class RedactionOperation(BaseModel):
id: str
@@ -299,6 +318,7 @@ class SignatureOperation(BaseModel):
EditOperation = Annotated[
TextOverlayOperation
| StampOperation
| RedactionOperation
| ImageOverlayOperation
| HighlightOperation
@@ -329,8 +349,8 @@ class EditsRequest(BaseModel):
_OP_PERMISSION = {
"highlight": "canAnnotate", "underline": "canAnnotate", "strikeout": "canAnnotate",
"squiggly": "canAnnotate", "comment": "canAnnotate", "freehand": "canAnnotate",
"free_text": "canAnnotate", "text_overlay": "canAnnotate", "image_overlay": "canAnnotate",
"delete_annotation": "canAnnotate", "update_annotation": "canAnnotate",
"free_text": "canAnnotate", "text_overlay": "canAnnotate", "stamp": "canAnnotate",
"image_overlay": "canAnnotate", "delete_annotation": "canAnnotate", "update_annotation": "canAnnotate",
"replace_text": "canModify", "reflow_paragraph": "canModify", "redaction": "canModify",
"update_field": "canFillForms",
"page_rotation": "canAssemble", "page_deletion": "canAssemble", "page_reorder": "canAssemble",
@@ -377,6 +397,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
if "," in img_data_str:
img_data_str = img_data_str.split(",", 1)[1]
img_data_str += "=" * ((4 - len(img_data_str) % 4) % 4)
raw_bytes = base64.b64decode(img_data_str)
img = Image.open(io.BytesIO(raw_bytes))
img_rgba = img.convert("RGBA")
@@ -387,6 +408,7 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
bgra_bytes = img_bgra.tobytes()
fd, temp_path = tempfile.mkstemp(suffix=".bin", prefix="pdf_pixel_")
temp_path = temp_path.replace("\\", "/")
created_temp_files.append(temp_path)
try:
with os.fdopen(fd, "wb") as tmp: