feat: implemented base editing, underline and striking

This commit is contained in:
Furqan-14
2026-06-12 15:04:09 +05:30
parent 6678f55e46
commit d47895c990
18 changed files with 730 additions and 91 deletions
+15
View File
@@ -80,6 +80,20 @@ PYBIND11_MODULE(pdfengine, m) {
return "DocumentMetadata(title='" + self.title + "', author='" + self.author + "')";
});
py::class_<pdfengine::DocumentPermissions>(m, "DocumentPermissions")
.def_readonly("is_encrypted", &pdfengine::DocumentPermissions::isEncrypted)
.def_readonly("encryption", &pdfengine::DocumentPermissions::encryption)
.def_readonly("security_revision", &pdfengine::DocumentPermissions::securityRevision)
.def_readonly("owner_unlocked", &pdfengine::DocumentPermissions::ownerUnlocked)
.def_readonly("can_print", &pdfengine::DocumentPermissions::canPrint)
.def_readonly("can_print_high_res", &pdfengine::DocumentPermissions::canPrintHighRes)
.def_readonly("can_modify", &pdfengine::DocumentPermissions::canModify)
.def_readonly("can_copy", &pdfengine::DocumentPermissions::canCopy)
.def_readonly("can_annotate", &pdfengine::DocumentPermissions::canAnnotate)
.def_readonly("can_fill_forms", &pdfengine::DocumentPermissions::canFillForms)
.def_readonly("can_extract_for_accessibility", &pdfengine::DocumentPermissions::canExtractForAccessibility)
.def_readonly("can_assemble", &pdfengine::DocumentPermissions::canAssemble);
py::class_<pdfengine::PageImage>(m, "PageImage")
.def_readonly("width", &pdfengine::PageImage::width)
.def_readonly("height", &pdfengine::PageImage::height)
@@ -270,6 +284,7 @@ PYBIND11_MODULE(pdfengine, m) {
}, py::arg("data"), py::arg("password") = "")
.def_property_readonly("page_count", &pdfengine::PdfDocument::pageCount)
.def_property_readonly("metadata", &pdfengine::PdfDocument::metadata)
.def_property_readonly("permissions", &pdfengine::PdfDocument::permissions)
.def("extract_outline", [](const pdfengine::PdfDocument& self) {
auto res = get_or_throw(self.extractOutline());
py::list out;
+41
View File
@@ -0,0 +1,41 @@
%PDF-1.7
%¿÷¢þ
1 0 obj
<< /Extensions << /ADBE << /BaseVersion /1.7 /ExtensionLevel 8 >> >> /Pages 2 0 R /Type /Catalog >>
endobj
2 0 obj
<< /Count 1 /Kids [ 3 0 R ] /Type /Pages >>
endobj
3 0 obj
<< /Contents 4 0 R /MediaBox [ 0 0 612 792 ] /Parent 2 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> /Type /Page >>
endobj
4 0 obj
<< /Length 352 /Filter /FlateDecode >>
stream
¥Æçÿ¥yÀL4Wˆ®{©ÈÒ
뫉û\ÐòòÆÓ‚˼`ÁS¢Éñ8¦1¾4â€v×än˳?ÁD<¨ž¤#“Õ6îçJµÔ?àšýÇÝ 3¥­Å g&eökûóA¹ŽŒpÉ@…“¶QÂÅþÊîšÔÊ>ȃ'«úœˆ l•¡:ùšÛ*ÚáS@5xw^Úuž…¦€ëv‰véúY\Bæ&Ť.õÖÕ¼ó©f(+ô£KˆN.ÁÎIÀ•èUÔæxu&²¡ÑTtàó¸®7©ÿÝÐ<c>þ"ˆþâóÈÀeêMq²U]æ¿—ø¥Ê¸õ=%dÉ®å y’†Mçƒíº7Üî‚¢øv_ݧã»çÿX POç\'f_ áYúof7}/tõaCÉàÿInhì ôòÜ\V®wRøæ[pq5X3;Ý|šš sŒt·JÕ 
endstream
endobj
5 0 obj
<< /BaseFont /Helvetica /Encoding /WinAnsiEncoding /Subtype /Type1 /Type /Font >>
endobj
6 0 obj
<< /BaseFont /Times-Roman /Encoding /WinAnsiEncoding /Subtype /Type1 /Type /Font >>
endobj
7 0 obj
<< /CF << /StdCF << /AuthEvent /DocOpen /CFM /AESV3 /Length 32 >> >> /Filter /Standard /Length 256 /O <eed09d7bae817b88ec68c4bba71e4688bad49a26f13d5f1b558fd8d6246663774b7e74b08734ad7a57473b33ec19e47d> /OE <33dc073711e1735085e2efd64928fa75af518dcf25ec7ff7d3ae22976ab8ded4> /P -3136 /Perms <b360a35c81e6b6380ef770702952140a> /R 6 /StmF /StdCF /StrF /StdCF /U <0d6d86441425cdaee0cda2eec22acd2391b314ff4c0bb60c90e9955907889824f87b528709af1246ab59dd722a89cb2a> /UE <2da7189a149f6fcbdf38688a565f280f8e6a4b727a3f528dbb9e014a88ebd88f> /V 5 >>
endobj
xref
0 8
0000000000 65535 f
0000000015 00000 n
0000000130 00000 n
0000000189 00000 n
0000000327 00000 n
0000000751 00000 n
0000000848 00000 n
0000000947 00000 n
trailer << /Root 1 0 R /Size 8 /ID [<f341436d4fd6835a35fb5f4313bdd156><f341436d4fd6835a35fb5f4313bdd156>] /Encrypt 7 0 R >>
startxref
1497
%%EOF
+20
View File
@@ -28,6 +28,25 @@ struct DocumentMetadata {
std::string modificationDate;
};
// Encryption + permission state of a loaded document. Booleans are the EFFECTIVE
// permissions for how the doc was opened (an owner-unlocked doc reports all true).
// The engine only surfaces these — enforcement happens in the gateway/app layers.
struct DocumentPermissions {
bool isEncrypted = false;
std::string encryption = "None"; // "None", "RC4-40", "RC4-128", "AES-128", "AES-256"
int securityRevision = -1; // FPDF_GetSecurityHandlerRevision (-1 if unencrypted)
bool ownerUnlocked = false; // encrypted but opened with full (owner) access
bool canPrint = true;
bool canPrintHighRes = true;
bool canModify = true;
bool canCopy = true; // extract text / graphics
bool canAnnotate = true; // add/modify annotations
bool canFillForms = true;
bool canExtractForAccessibility = true;
bool canAssemble = true; // insert / rotate / delete pages
};
struct PageImage {
int width;
int height;
@@ -214,6 +233,7 @@ public:
[[nodiscard]] virtual int pageCount() const noexcept = 0;
[[nodiscard]] virtual DocumentMetadata metadata() const noexcept = 0;
[[nodiscard]] virtual DocumentPermissions permissions() const noexcept = 0;
// A single entry in the document outline (bookmarks), flattened with a depth level.
struct OutlineItem {
+100 -52
View File
@@ -1299,6 +1299,10 @@ std::expected<std::vector<PdfPage::AnnotationInfo>, EngineError> PdfiumPage::ext
info.type = "ink";
} else if (subtype == FPDF_ANNOT_STRIKEOUT) {
info.type = "strikeout";
} else if (subtype == FPDF_ANNOT_UNDERLINE) {
info.type = "underline";
} else if (subtype == FPDF_ANNOT_SQUIGGLY) {
info.type = "squiggly";
} else if (subtype == FPDF_ANNOT_WIDGET) {
info.type = "widget";
@@ -1605,6 +1609,49 @@ DocumentMetadata PdfiumDocument::metadata() const noexcept {
return meta;
}
DocumentPermissions PdfiumDocument::permissions() const noexcept {
DocumentPermissions perms; // defaults: unencrypted, everything allowed
#ifdef PDFENGINE_WITH_PDFIUM
if (!doc_) return perms;
const int rev = FPDF_GetSecurityHandlerRevision(doc_);
perms.securityRevision = rev;
perms.isEncrypted = (rev != -1);
if (!perms.isEncrypted) {
return perms; // "None", all true
}
switch (rev) {
case 2: perms.encryption = "RC4-40"; break;
case 3: perms.encryption = "RC4-128"; break;
case 4: perms.encryption = "AES-128"; break; // R4 may be RC4-128; PDFium hides /CFM
case 5:
case 6: perms.encryption = "AES-256"; break;
default: perms.encryption = "Unknown"; break;
}
// FPDF_GetDocPermissions returns the EFFECTIVE /P flags for how the doc was
// opened; FPDF_GetDocUserPermissions always returns the user-level flags. When
// they differ, the doc was unlocked with the owner password (full access).
// (PDFium grants full effective perms without always returning exactly
// 0xFFFFFFFF, so a direct equality check is unreliable.)
const unsigned long p = FPDF_GetDocPermissions(doc_);
const unsigned long up = FPDF_GetDocUserPermissions(doc_);
perms.ownerUnlocked = (p != up);
auto allowed = [p](unsigned long bit) { return (p & bit) != 0ul; };
perms.canPrint = allowed(0x4); // bit 3
perms.canModify = allowed(0x8); // bit 4
perms.canCopy = allowed(0x10); // bit 5
perms.canAnnotate = allowed(0x20); // bit 6
perms.canFillForms = allowed(0x100); // bit 9
perms.canExtractForAccessibility = allowed(0x200);// bit 10
perms.canAssemble = allowed(0x400); // bit 11
perms.canPrintHighRes = allowed(0x800); // bit 12
#endif
return perms;
}
#ifdef PDFENGINE_WITH_PDFIUM
namespace {
// Depth-first flatten of the bookmark tree into OutlineItems with a depth level.
@@ -2269,13 +2316,6 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
return std::unexpected(EngineError::InvalidFormat);
}
auto data = op["data"];
double x = data.value("x", 0.0);
double y = data.value("y", 0.0);
double width = data.value("width", 0.0);
double thickness = data.value("thickness", 1.0);
std::string color = data.value("color", "#000000");
spdlog::info("Parsed {} operation: x={}, y={}, width={}", type, x, y, width);
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
if (!page) {
@@ -2283,59 +2323,67 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
return std::unexpected(EngineError::Unknown);
}
pdfengine::Path path;
if (type == "underline") {
path = DecorationBuilder::buildUnderline(x, y, width, thickness);
} else if (type == "strikeout") {
path = DecorationBuilder::buildStrikeout(x, y, width, thickness);
} else if (type == "squiggly") {
path = DecorationBuilder::buildSquiggly(x, y, width);
// Adobe-grade: create a real text-markup annotation (editable, deletable,
// round-trips, and positioned within the text quad by PDFium's appearance
// generator) instead of baking a fixed-offset path. Mirrors the highlight handler.
int subtype = FPDF_ANNOT_UNDERLINE;
if (type == "strikeout") subtype = FPDF_ANNOT_STRIKEOUT;
else if (type == "squiggly") subtype = FPDF_ANNOT_SQUIGGLY;
FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, subtype);
if (!annot) {
spdlog::error("Failed to create {} annotation", type);
FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown);
}
if (!path.empty()) {
const auto& segments = path.segments();
float startX = segments[0].points[0].x;
float startY = segments[0].points[0].y;
FPDF_PAGEOBJECT pathObj = FPDFPageObj_CreateNewPath(startX, startY);
double pageHeight = FPDF_GetPageHeightF(page);
unsigned int r = 0, g = 0, b = 0;
parseHexColor(data.value("color", "#000000"), r, g, b);
FPDFAnnot_SetColor(annot, FPDFANNOT_COLORTYPE_Color, r, g, b, 255);
bool isFill = (type != "squiggly");
bool isStroke = (type == "squiggly");
if (data.contains("quadPoints") && data["quadPoints"].is_array()) {
FS_RECTF boundingBox;
boundingBox.left = 99999.0f;
boundingBox.right = -99999.0f;
boundingBox.top = -99999.0f;
boundingBox.bottom = 99999.0f;
for (size_t i = 1; i < segments.size(); ++i) { // Start from 1 to skip first MoveTo
const auto& seg = segments[i];
if (seg.verb == Path::Verb::MoveTo) {
FPDFPath_MoveTo(pathObj, seg.points[0].x, seg.points[0].y);
} else if (seg.verb == Path::Verb::LineTo) {
FPDFPath_LineTo(pathObj, seg.points[0].x, seg.points[0].y);
} else if (seg.verb == Path::Verb::CubicBezierTo) {
FPDFPath_BezierTo(pathObj, seg.points[0].x, seg.points[0].y,
seg.points[1].x, seg.points[1].y,
seg.points[2].x, seg.points[2].y);
} else if (seg.verb == Path::Verb::Close) {
FPDFPath_Close(pathObj);
}
for (const auto& quad : data["quadPoints"]) {
FS_QUADPOINTSF points;
// Frontend is top-down; backend is bottom-up.
points.x1 = static_cast<float>(quad.value("x1", 0.0));
points.y1 = static_cast<float>(pageHeight - quad.value("y1", 0.0));
points.x2 = static_cast<float>(quad.value("x2", 0.0));
points.y2 = static_cast<float>(pageHeight - quad.value("y2", 0.0));
points.x3 = static_cast<float>(quad.value("x3", 0.0));
points.y3 = static_cast<float>(pageHeight - quad.value("y3", 0.0));
points.x4 = static_cast<float>(quad.value("x4", 0.0));
points.y4 = static_cast<float>(pageHeight - quad.value("y4", 0.0));
FPDFAnnot_AppendAttachmentPoints(annot, &points);
float minX = (std::min)({points.x1, points.x2, points.x3, points.x4});
float maxX = (std::max)({points.x1, points.x2, points.x3, points.x4});
float minY = (std::min)({points.y1, points.y2, points.y3, points.y4});
float maxY = (std::max)({points.y1, points.y2, points.y3, points.y4});
if (minX < boundingBox.left) boundingBox.left = minX;
if (maxX > boundingBox.right) boundingBox.right = maxX;
if (minY < boundingBox.bottom) boundingBox.bottom = minY;
if (maxY > boundingBox.top) boundingBox.top = maxY;
}
FPDFPath_SetDrawMode(pathObj, isFill ? FPDF_FILLMODE_ALTERNATE : FPDF_FILLMODE_NONE, isStroke);
unsigned int r = 0, g = 0, b = 0;
parseHexColor(color, r, g, b);
if (isFill) {
FPDFPageObj_SetFillColor(pathObj, r, g, b, 255);
}
if (isStroke) {
FPDFPageObj_SetStrokeColor(pathObj, r, g, b, 255);
FPDFPageObj_SetStrokeWidth(pathObj, thickness);
}
FPDFPage_InsertObject(page, pathObj);
if (!FPDFPage_GenerateContent(page)) {
spdlog::error("Failed to generate page content after adding decoration");
if (boundingBox.left <= boundingBox.right) {
FPDFAnnot_SetRect(annot, &boundingBox);
}
}
std::string author = data.value("author", "");
if (!author.empty()) {
auto utf16 = utf8_to_utf16le(author);
FPDFAnnot_SetStringValue(annot, "T", reinterpret_cast<FPDF_WIDESTRING>(utf16.data()));
}
FPDFPage_CloseAnnot(annot);
FPDF_ClosePage(page);
} else if (type == "redaction") {
if (!op.contains("data") || !op["data"].is_object()) {
+1
View File
@@ -85,6 +85,7 @@ public:
int pageCount() const noexcept override;
DocumentMetadata metadata() const noexcept override;
DocumentPermissions permissions() const noexcept override;
std::expected<std::vector<OutlineItem>, EngineError> extractOutline() const override;
std::expected<std::shared_ptr<PdfPage>, EngineError> getPage(int pageIndex) override;
+66 -16
View File
@@ -13,8 +13,9 @@ import { PDFViewer } from './viewer/PDFViewer';
import type { PDFViewerRef } from './viewer/PDFViewer';
import type { Annotation } from './viewer/AnnotationLayer';
import type { EditableRun } from './viewer/TextEditLayer';
import { gatewayService } from './lib/gatewayService';
import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem } from './lib/gatewayService';
import { gatewayService, PasswordError } from './lib/gatewayService';
import { PasswordModal } from './components/PasswordModal';
import type { DocumentInfo, SearchResult, EditOperation, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from './lib/gatewayService';
import { viewportRectToPdf } from './lib/coordinateMapping';
import type { Rect } from './lib/coordinateMapping';
import { wasmLoader } from './lib/wasmLoader';
@@ -37,6 +38,20 @@ function App() {
const canRedo = hist.index < hist.stack.length - 1;
const preservePageRef = useRef(false);
// Effective PDF permissions for the active document (carried forward across edits).
// `can` defaults to allowed when permissions are unknown / unencrypted.
const permissions = activeDoc?.permissions ?? null;
const can = (flag: keyof PDFPermissions) => !permissions || permissions[flag] !== false;
const denyToast = (label: string) =>
toast(`${label} is not permitted by this document's restrictions`, 'error');
// Tools the active document's permissions forbid (greyed out in the rail).
const disabledTools = new Set<ToolId>();
if (!can('canAnnotate'))
(['highlight', 'underline', 'strikeout', 'squiggly', 'draw', 'comment', 'textbox', 'stamp', 'signature'] as ToolId[]).forEach((t) => disabledTools.add(t));
if (!can('canModify')) (['edit_text', 'redact'] as ToolId[]).forEach((t) => disabledTools.add(t));
const disabledToolsRef = useRef(disabledTools);
disabledToolsRef.current = disabledTools;
// View / tools
const [zoom, setZoom] = useState(1.0);
const [activeTool, setActiveTool] = useState<ToolId>('select');
@@ -59,6 +74,7 @@ function App() {
const [pendingSignature, setPendingSignature] = useState<{ url: string; aspect: number } | null>(null);
const [signatureModalOpen, setSignatureModalOpen] = 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 [confirmState, setConfirmState] = useState<(CustomConfirmationOptions & { onConfirm: () => void }) | null>(null);
@@ -200,7 +216,11 @@ function App() {
if (typing || mod) return;
const tool = TOOL_SHORTCUTS[e.key.toLowerCase()];
if (tool) { setActiveTool(tool); if (tool === 'signature' && !pendingSignature) setSignatureModalOpen(true); }
if (tool) {
if (disabledToolsRef.current.has(tool)) { denyToast('This tool'); return; }
setActiveTool(tool);
if (tool === 'signature' && !pendingSignature) setSignatureModalOpen(true);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
@@ -231,6 +251,7 @@ function App() {
/* -------------------------------------- annotation creation (optimistic) */
const handleAnnotationAdded = (a: Annotation) => {
if (!can('canAnnotate')) { denyToast('Annotations'); return; }
setAnnotations((prev) => [...prev, a]);
if (inspectorTab !== 'notes') setInspectorTab('notes');
const page = a.pageIndex ?? currentPage;
@@ -264,15 +285,15 @@ function App() {
/* ---------------------------------------------- new overlay placements */
const handleDecorateText = (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => {
const ops: EditOperation[] = lines.map(line => {
const pdf = viewportRectToPdf(line, zoom, pageHeightPts(pageIndex));
return {
id: rid('decor'), type, pageIndex,
data: { x: pdf.x, y: pdf.y, width: pdf.width, thickness: 1.5, color }
};
if (!can('canAnnotate')) { denyToast('Text decorations'); return; }
// One markup annotation covering all selected lines. quadPoints are in top-left
// points (line px ÷ zoom); the engine flips to bottom-up, mirroring highlight.
const quadPoints = lines.map((line) => {
const lx = line.x / zoom, ly = line.y / zoom, lw = line.width / zoom, lh = line.height / zoom;
return { x1: lx, y1: ly + lh, x2: lx + lw, y2: ly + lh, x3: lx + lw, y3: ly, x4: lx, y4: ly };
});
// Optimistically show local overlays
// Optimistically show local overlays until the edit round-trips.
const newAnnos = lines.map(line => ({
id: rid('locdec'),
type,
@@ -281,12 +302,13 @@ function App() {
color,
author: 'Current User',
} as Annotation));
setAnnotations(prev => [...prev, ...newAnnos]);
applyOps(ops);
applyOps([{ id: rid('decor'), type, pageIndex, data: { quadPoints, color, author: 'Current User' } }]);
};
const handlePlaceText = (pageIndex: number, rectPts: Rect, text: string) => {
if (!can('canAnnotate')) { denyToast('Adding text'); setActiveTool('select'); return; }
const pdf = viewportRectToPdf(rectPts, 1, pageHeightPts(pageIndex));
applyOps([{
id: rid('txt'), type: 'text_overlay', pageIndex,
@@ -298,6 +320,7 @@ function App() {
// Rewrite existing page text in place via replace_text, which targets stable
// page-object indices (no coordinates) and reflows the rest of the line.
const handleEditText = (pageIndex: number, run: EditableRun, newText: string) => {
if (!can('canModify')) { denyToast('Editing text'); setActiveTool('select'); return; }
applyOps([{
id: rid('edit'), type: 'replace_text', pageIndex,
data: {
@@ -312,6 +335,7 @@ function App() {
const handlePlaceStamp = (pageIndex: number, point: { x: number; y: number }) => {
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;
@@ -324,6 +348,7 @@ function App() {
const handlePlaceSignature = (pageIndex: number, point: { x: number; y: number }) => {
if (!pendingSignature) return;
if (!can('canAnnotate')) { denyToast('Signing'); setActiveTool('select'); return; }
const width = 160;
const height = width / (pendingSignature.aspect || 3);
const pdf = viewportRectToPdf({ x: point.x, y: point.y, width, height }, 1, pageHeightPts(pageIndex));
@@ -337,11 +362,13 @@ function App() {
/* ---------------------------------------------------------- page ops */
const handleRotate = () => {
if (!activeDoc) return;
if (!can('canAssemble')) { denyToast('Rotating pages'); return; }
applyOps([{ id: rid('rot'), type: 'page_rotation', pageIndex: currentPage, data: { rotation: 90 } }], 'Page rotated');
};
const handleDeletePage = (pageIndex: number) => {
if (!activeDoc) return;
if (!can('canAssemble')) { denyToast('Deleting pages'); return; }
if (activeDoc.totalPages <= 1) { toast('Cannot delete the only page', 'error'); return; }
setConfirmState({
title: 'Delete page?',
@@ -356,12 +383,14 @@ function App() {
const handleReorderPage = (from: number, to: number) => {
if (!activeDoc || to < 0 || to >= activeDoc.totalPages) return;
if (!can('canAssemble')) { denyToast('Reordering pages'); return; }
applyOps([{ id: rid('reorder'), type: 'page_reorder', pageIndex: from, data: { destPageIndex: to } }], 'Page moved');
setCurrentPage(to);
};
const handleRedactArea = (pageIndex: number, bounds: Rect) => {
if (!activeDoc) return;
if (!can('canModify')) { denyToast('Redaction'); return; }
const pdf = viewportRectToPdf(bounds, zoom, pageHeightPts(pageIndex));
setConfirmState({
title: 'Redact area?',
@@ -378,16 +407,22 @@ function App() {
};
/* ----------------------------------------------------------- doc-level */
const handleUpload = async (file: File) => {
const handleUpload = async (file: File, password = '') => {
try {
setIsLoading(true);
const newDoc = await gatewayService.uploadDocument(file);
const newDoc = await gatewayService.uploadDocument(file, password);
setDocuments((prev) => [newDoc, ...prev]);
openDocument(newDoc.id);
toast(`Opened ${newDoc.filename}`, 'success');
setPasswordPrompt(null);
} catch (e) {
console.error('Upload failed', e);
toast('Upload failed', 'error');
if (e instanceof PasswordError) {
// Needs a password (missing or wrong) — prompt and retry.
setPasswordPrompt({ file, filename: file.name, error: password ? 'Incorrect password — please try again.' : undefined });
} else {
console.error('Upload failed', e);
toast('Upload failed', 'error');
}
} finally {
setIsLoading(false);
}
@@ -395,6 +430,7 @@ function App() {
const handleExport = async () => {
if (!activeDoc) return;
if (!can('canCopy')) { denyToast('Exporting'); return; }
try {
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
toast('Exported', 'success');
@@ -406,6 +442,7 @@ function App() {
const handlePrint = async () => {
if (!activeDoc) return;
if (!can('canPrint')) { denyToast('Printing'); return; }
try {
toast('Preparing print...', 'info');
const bytes = await gatewayService.fetchDocumentBytes(selectedDocId);
@@ -504,6 +541,9 @@ function App() {
onRotate={handleRotate}
onExport={handleExport}
onPrint={handlePrint}
canPrint={can('canPrint')}
canExport={can('canCopy')}
canAssemble={can('canAssemble')}
onUpload={handleUpload}
isInspectorOpen={isInspectorOpen}
onToggleInspector={toggleInspector}
@@ -516,6 +556,7 @@ function App() {
hasSignature={!!pendingSignature}
onOpenSignature={() => setSignatureModalOpen(true)}
onOpenAbout={() => setAboutModalOpen(true)}
disabledTools={disabledTools}
/>
<div className="flex min-w-0 flex-1 flex-col">
@@ -549,7 +590,9 @@ function App() {
hasSignature={!!pendingSignature}
activeStamp={activeStamp?.label ?? null}
annotations={annotations}
canCopy={can('canCopy')}
onFieldChange={(id, value, i) => {
if (!can('canFillForms')) { denyToast('Filling form fields'); return; }
applyOps([{
id,
type: 'update_field',
@@ -623,6 +666,7 @@ function App() {
searchCurrentMatch={searchCurrentMatch}
onSelectSearchMatch={selectSearchMatch}
metadata={metadata}
permissions={permissions ?? undefined}
fonts={fonts}
/>
)}
@@ -645,6 +689,12 @@ function App() {
/>
<CustomConfirmationModal state={confirmState} onClose={() => setConfirmState(null)} />
<PasswordModal
state={passwordPrompt}
onSubmit={(pw) => { if (passwordPrompt) handleUpload(passwordPrompt.file, pw); }}
onClose={() => setPasswordPrompt(null)}
/>
<ToastViewport />
</div>
);
+43 -4
View File
@@ -1,6 +1,6 @@
import { CustomButton } from './custom/CustomButton';
import React from 'react';
import type { DocumentInfo, SearchResult, DocumentMetadata, FontInfo, OutlineItem } from '../lib/gatewayService';
import type { DocumentInfo, SearchResult, DocumentMetadata, FontInfo, OutlineItem, PDFPermissions } from '../lib/gatewayService';
import type { Annotation } from '../viewer/AnnotationLayer';
import { Thumbnail } from './Thumbnail';
import { EmptyState, Popover } from './ui';
@@ -46,6 +46,7 @@ interface InspectorPanelProps {
onSelectSearchMatch: (i: number) => void;
metadata: DocumentMetadata | null;
permissions?: PDFPermissions;
fonts: FontInfo[];
}
@@ -140,7 +141,7 @@ export const InspectorPanel: React.FC<InspectorPanelProps> = (p) => {
{p.activeTab === 'pages' && <PagesTab {...p} />}
{p.activeTab === 'notes' && <NotesTab annotations={p.annotations.filter((a) => a.type !== 'widget')} onNavigate={p.onNavigateAnnotation} onDelete={p.onDeleteAnnotation} onUpdate={p.onUpdateAnnotation} />}
{p.activeTab === 'search' && <SearchTab {...p} />}
{p.activeTab === 'properties' && <PropertiesTab metadata={p.metadata} sizeBytes={p.sizeBytes} totalPages={p.totalPages} filename={selectedDoc?.filename} />}
{p.activeTab === 'properties' && <PropertiesTab metadata={p.metadata} permissions={p.permissions} sizeBytes={p.sizeBytes} totalPages={p.totalPages} filename={selectedDoc?.filename} />}
{p.activeTab === 'fonts' && <FontsTab fonts={p.fonts} />}
{p.activeTab === 'outline' && <OutlineTab outline={p.outline} onNavigate={p.onNavigateOutline} />}
{p.activeTab === 'forms' && <FormsTab fields={p.annotations.filter((a) => a.type === 'widget')} onNavigate={p.onNavigateAnnotation} />}
@@ -363,7 +364,16 @@ const SearchTab: React.FC<InspectorPanelProps> = (p) => {
);
};
const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; sizeBytes?: number; totalPages: number; filename?: string }> = ({ metadata, sizeBytes, totalPages, filename }) => {
const PERM_LABELS: [keyof PDFPermissions, string][] = [
['canPrint', 'Print'],
['canCopy', 'Copy / extract'],
['canModify', 'Modify content'],
['canAnnotate', 'Annotate'],
['canFillForms', 'Fill forms'],
['canAssemble', 'Assemble pages'],
];
const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; permissions?: PDFPermissions; sizeBytes?: number; totalPages: number; filename?: string }> = ({ metadata, permissions, sizeBytes, totalPages, filename }) => {
const rows: [string, string | undefined][] = [
['File name', filename],
['Title', metadata?.title],
@@ -385,7 +395,36 @@ const PropertiesTab: React.FC<{ metadata: DocumentMetadata | null; sizeBytes?: n
<span className="break-words text-[12.5px] text-[#18212e]">{v && v.trim() ? v : <span className="text-[#98a1ad]"></span>}</span>
</div>
))}
<p className="pt-2 text-[10.5px] text-[#98a1ad]">Document properties are read-only.</p>
{/* Security / permissions */}
<div className="mt-3 border-t border-[#ebedf0] pt-3">
<div className="mb-2 flex items-center gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wide text-[#98a1ad]">Security</span>
{permissions?.isEncrypted
? <span className="rounded bg-[#fdecec] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#dc2626]">{permissions.encryption}</span>
: <span className="rounded bg-[#edeff2] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#98a1ad]">Unencrypted</span>}
{permissions?.ownerUnlocked && <span className="rounded bg-[#eef4ff] px-1.5 py-0.5 text-[9px] font-bold uppercase text-[#2563eb]">Owner</span>}
</div>
{permissions?.isEncrypted && !permissions.ownerUnlocked ? (
<div className="flex flex-col gap-1">
{PERM_LABELS.map(([key, label]) => {
const allowed = permissions[key] !== false;
return (
<div key={key} className="flex items-center justify-between text-[12px]">
<span className="text-[#5b6573]">{label}</span>
<span className={allowed ? 'font-semibold text-[#16a34a]' : 'font-semibold text-[#dc2626]'}>
{allowed ? 'Allowed' : 'Restricted'}
</span>
</div>
);
})}
</div>
) : (
<p className="text-[11px] text-[#98a1ad]">No usage restrictions.</p>
)}
</div>
<p className="pt-3 text-[10.5px] text-[#98a1ad]">Document properties are read-only.</p>
</div>
);
};
+96
View File
@@ -0,0 +1,96 @@
import React, { useEffect, useRef, useState } from 'react';
import { CustomButton } from './custom/CustomButton';
export interface PasswordPromptState {
filename: string;
error?: string; // shown on a failed attempt ("Incorrect password…")
}
interface PasswordModalProps {
state: PasswordPromptState | null;
onSubmit: (password: string) => void;
onClose: () => void;
}
export const PasswordModal: React.FC<PasswordModalProps> = ({ state, onSubmit, onClose }) => {
const [value, setValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
// Reset the field and focus whenever the prompt (re)opens.
useEffect(() => {
if (!state) return;
setValue('');
const t = setTimeout(() => inputRef.current?.focus(), 30);
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
return () => { clearTimeout(t); document.removeEventListener('keydown', onKey); };
}, [state, onClose]);
if (!state) return null;
const submit = () => { if (value) onSubmit(value); };
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4" onMouseDown={onClose}>
<div className="absolute inset-0 bg-[#0f172a]/30 backdrop-blur-[2px]" style={{ animation: 'toastIn 0.2s ease-out' }} />
<div
style={{ width: 440, animation: 'slideUp 0.25s cubic-bezier(0.16, 1, 0.3, 1)' }}
className="relative flex max-h-[90vh] flex-col overflow-hidden rounded-[16px] bg-[#ffffff] shadow-[0_24px_48px_rgba(16,24,40,0.18)] ring-1 ring-[#ebedf0]"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex flex-col gap-2.5 p-7 pb-5">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#eef4ff] text-[#2563eb]">
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<rect x="5" y="11" width="14" height="10" rx="2" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
</div>
<div className="min-w-0">
<h2 className="text-[17.5px] font-bold tracking-tight text-[#18212e]">Password required</h2>
<p className="truncate text-[12.5px] text-[#98a1ad]" title={state.filename}>{state.filename}</p>
</div>
</div>
<p className="pl-[52px] text-[13.5px] leading-relaxed text-[#5b6573]">
This document is protected. Enter the password to open it.
</p>
<form
className="pl-[52px] pt-1"
onSubmit={(e) => { e.preventDefault(); submit(); }}
>
<input
ref={inputRef}
type="password"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Document password"
autoComplete="off"
className={`w-full rounded-[8px] border bg-white px-3 py-2 text-[13.5px] outline-none transition-colors focus:ring-2 ${
state.error
? 'border-[#dc2626] focus:border-[#dc2626] focus:ring-[#fdecec]'
: 'border-[#d6dae0] focus:border-[#2563eb] focus:ring-[#eef4ff]'
}`}
/>
{state.error && <p className="mt-1.5 text-[12px] font-medium text-[#dc2626]">{state.error}</p>}
</form>
</div>
<div className="flex items-center justify-end gap-3 border-t border-[#ebedf0] bg-[#f6f7f9] px-7 py-5">
<CustomButton variant="ghost" onClick={onClose} className="rounded-[8px] px-4 font-semibold text-[#5b6573]">
Cancel
</CustomButton>
<CustomButton
variant="primary"
onClick={submit}
disabled={!value}
className="rounded-[8px] bg-[#2563eb] px-5 font-semibold text-white shadow-sm transition-colors hover:bg-[#1d4ed8] disabled:opacity-50"
>
Open
</CustomButton>
</div>
</div>
</div>
);
};
+15 -6
View File
@@ -1,6 +1,7 @@
import { CustomButton } from './custom/CustomButton';
import React from 'react';
import type { ToolId } from '../lib/tools';
import { toast } from '../lib/toast';
import { Popover } from './ui';
import {
SelectIcon, PanIcon, HighlightIcon, DrawIcon, CommentIcon, TextBoxIcon,
@@ -44,28 +45,36 @@ interface ToolRailProps {
hasSignature: boolean;
onOpenSignature: () => void;
onOpenAbout: () => void;
disabledTools?: Set<ToolId>;
}
const RailButton: React.FC<{ t: ToolDef; active: boolean; onClick: () => void }> = ({ t, active, onClick }) => (
const RailButton: React.FC<{ t: ToolDef; active: boolean; disabled?: boolean; onClick: () => void }> = ({ t, active, disabled, onClick }) => (
<CustomButton variant="unstyled"
title={`${t.label} · ${t.shortcut}`}
title={disabled ? `${t.label} — not permitted by this document` : `${t.label} · ${t.shortcut}`}
aria-label={t.label}
aria-pressed={active}
aria-disabled={disabled}
onClick={onClick}
className={`relative flex h-11 w-11 items-center justify-center rounded-[10px] transition-colors ${
active
disabled
? 'cursor-not-allowed text-[#c5cad1]'
: active
? t.danger ? 'bg-[#fdecec] text-[#dc2626]' : 'bg-[#eef4ff] text-[#2563eb]'
: t.danger ? 'text-[#5b6573] hover:bg-[#fdecec] hover:text-[#dc2626]'
: 'text-[#5b6573] hover:bg-[#edeff2] hover:text-[#18212e]'
}`}
>
{active && <span className="absolute left-[-10px] h-5 w-[3px] rounded-full" style={{ background: t.danger ? '#dc2626' : '#2563eb' }} />}
{active && !disabled && <span className="absolute left-[-10px] h-5 w-[3px] rounded-full" style={{ background: t.danger ? '#dc2626' : '#2563eb' }} />}
{React.isValidElement(t.icon) ? React.cloneElement(t.icon as React.ReactElement<{ size?: number }>, { size: 22 }) : t.icon}
</CustomButton>
);
export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature, onOpenAbout }) => {
export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, hasSignature, onOpenSignature, onOpenAbout, disabledTools }) => {
const pickTool = (id: ToolId) => {
if (disabledTools?.has(id)) {
toast("This tool is not permitted by this document's restrictions", 'error');
return;
}
onToolChange(id);
if (id === 'signature' && !hasSignature) onOpenSignature();
};
@@ -75,7 +84,7 @@ export const ToolRail: React.FC<ToolRailProps> = ({ activeTool, onToolChange, ha
{TOOLS.map((t, i) =>
t === 'divider'
? <div key={`d${i}`} className="my-0.5 h-px w-6 bg-[#ebedf0]" />
: <RailButton key={t.id} t={t} active={activeTool === t.id} onClick={() => pickTool(t.id)} />,
: <RailButton key={t.id} t={t} active={activeTool === t.id} disabled={disabledTools?.has(t.id)} onClick={() => pickTool(t.id)} />,
)}
<div className="flex-1" />
+8 -4
View File
@@ -28,6 +28,9 @@ interface TopBarProps {
onUpload: (file: File) => void;
isInspectorOpen: boolean;
onToggleInspector: () => void;
canPrint?: boolean;
canExport?: boolean;
canAssemble?: boolean;
}
const ZOOM_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
@@ -37,6 +40,7 @@ export const TopBar: React.FC<TopBarProps> = ({
currentPage, totalPages, onGoToPage, canUndo, canRedo, onUndo, onRedo,
isSaving, isDirtySaved, onRotate, onExport, onPrint, onUpload,
isInspectorOpen, onToggleInspector,
canPrint = true, canExport = true, canAssemble = true,
}) => {
const fileRef = useRef<HTMLInputElement>(null);
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -72,8 +76,8 @@ export const TopBar: React.FC<TopBarProps> = ({
>
<div className="flex flex-col text-[13px]">
<MenuItem icon={<UploadIcon size={16} />} onClick={() => fileRef.current?.click()}>Open PDF</MenuItem>
<MenuItem icon={<DownloadIcon size={16} />} onClick={onExport} disabled={!documentName}>Export / Download</MenuItem>
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg>} onClick={onPrint} disabled={!documentName}>Print</MenuItem>
<MenuItem icon={<DownloadIcon size={16} />} onClick={onExport} disabled={!documentName || !canExport}>Export / Download</MenuItem>
<MenuItem icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 6 2 18 2 18 9"></polyline><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path><rect x="6" y="14" width="12" height="8"></rect></svg>} onClick={onPrint} disabled={!documentName || !canPrint}>Print</MenuItem>
</div>
</Popover>
@@ -119,7 +123,7 @@ export const TopBar: React.FC<TopBarProps> = ({
<CustomButton variant="icon" label="Zoom in" size={30} onClick={() => onZoomChange(Math.min(5, zoom + 0.1))}><ZoomInIcon size={17} /></CustomButton>
</div>
<CustomButton variant="icon" label="Rotate page 90°" size={34} onClick={onRotate} disabled={!documentName}><RotateIcon size={18} /></CustomButton>
<CustomButton variant="icon" label="Rotate page 90°" size={34} onClick={onRotate} disabled={!documentName || !canAssemble}><RotateIcon size={18} /></CustomButton>
{documentName && (
<div className="flex items-center gap-1 text-[12px] font-semibold text-[#5b6573]">
@@ -143,7 +147,7 @@ export const TopBar: React.FC<TopBarProps> = ({
<div className="flex shrink-0 items-center gap-2">
<SaveState isSaving={isSaving} saved={isDirtySaved} />
<HealthChip healthy={backendHealthy} engineReady={!!engineReady} />
<CustomButton variant="primary" size="sm" onClick={onExport} disabled={!documentName}><DownloadIcon size={15} /> Export</CustomButton>
<CustomButton variant="primary" size="sm" onClick={onExport} disabled={!documentName || !canExport}><DownloadIcon size={15} /> Export</CustomButton>
<CustomButton variant="icon"
label={isInspectorOpen ? "Hide panel" : "Show panel"}
size={34}
+50 -2
View File
@@ -4,6 +4,31 @@ export interface PageInfo {
height: number;
}
// Thrown by uploadDocument when the PDF needs a password (missing or wrong).
export class PasswordError extends Error {
detail: string;
constructor(detail: string) {
super(detail);
this.name = 'PasswordError';
this.detail = detail;
}
}
export interface PDFPermissions {
isEncrypted: boolean;
encryption: string; // "None" | "RC4-40" | "RC4-128" | "AES-128" | "AES-256"
securityRevision: number;
ownerUnlocked: boolean;
canPrint: boolean;
canPrintHighRes: boolean;
canModify: boolean;
canCopy: boolean;
canAnnotate: boolean;
canFillForms: boolean;
canExtractForAccessibility: boolean;
canAssemble: boolean;
}
export interface DocumentInfo {
id: string;
filename: string;
@@ -14,6 +39,7 @@ export interface DocumentInfo {
uploadedAt: string;
status: 'processing' | 'ready' | 'error';
pages?: PageInfo[];
permissions?: PDFPermissions;
}
export interface RenderParams {
@@ -194,8 +220,20 @@ export type EditOperationDataMap = {
delete_annotation: DeleteAnnotationData;
update_annotation: UpdateAnnotationData;
replace_text: ReplaceTextData;
underline: DecorationData;
strikeout: DecorationData;
squiggly: DecorationData;
};
// Text decoration (underline / strikeout / squiggly) as a markup annotation,
// positioned by quadpoints over the text lines (mirrors HighlightData).
export interface DecorationData {
quadPoints: HighlightQuadPoint[];
color: string;
author?: string;
content?: string;
}
// In-place rewrite of existing page text. Targets stable page-object indices
// (from getPageModel's run.object_indices) — no coordinates, so no Y-flip and no
// bbox ambiguity. The engine reflows subsequent same-line text by the width delta.
@@ -273,15 +311,25 @@ class GatewayService {
}
}
async uploadDocument(file: File): Promise<DocumentInfo> {
async uploadDocument(file: File, password = ''): Promise<DocumentInfo> {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${this.baseUrl}/documents`, {
const url = password
? `${this.baseUrl}/documents?password=${encodeURIComponent(password)}`
: `${this.baseUrl}/documents`;
const response = await fetch(url, {
method: 'POST',
body: formData,
});
// 401 = the PDF needs a password (missing or wrong) — surface a typed error
// so the UI can prompt and retry.
if (response.status === 401) {
const body = await response.json().catch(() => ({ detail: 'Password required' }));
throw new PasswordError(body.detail || 'Password required');
}
if (response.status === 501) {
// Simulate upload for Phase 0 scaffolding
return new Promise((resolve) => {
+6
View File
@@ -40,6 +40,7 @@ interface PDFViewerProps {
onPlaceSignature?: (pageIndex: number, pointPts: { x: number; y: number }) => void;
onDecorateText?: (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => void;
onFieldChange?: (id: string, value: string | boolean, pageIndex: number) => void;
canCopy?: boolean;
}
interface PageLayout {
@@ -81,6 +82,7 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
onPlaceSignature,
onDecorateText,
onFieldChange,
canCopy = true,
}, ref) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const [scrollPosition, setScrollPosition] = useState({ scrollLeft: 0, scrollTop: 0 });
@@ -293,6 +295,10 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
const handleTextSelection = (text: string, bbox: Rect, lines: Rect[], pageIndex: number) => {
if (activeTool === 'select') {
if (!canCopy) {
toast("Copying is not permitted by this document's restrictions", 'error');
return;
}
if (text.trim()) {
navigator.clipboard?.writeText(text).then(
() => toast(`Copied ${text.length} character${text.length > 1 ? 's' : ''}`, 'success'),
+26
View File
@@ -13,6 +13,21 @@ class PageInfoResponse(BaseModel):
height: float
class PermissionsResponse(BaseModel):
isEncrypted: bool = False
encryption: str = "None"
securityRevision: int = -1
ownerUnlocked: bool = False
canPrint: bool = True
canPrintHighRes: bool = True
canModify: bool = True
canCopy: bool = True
canAnnotate: bool = True
canFillForms: bool = True
canExtractForAccessibility: bool = True
canAssemble: bool = True
class DocumentInfoResponse(BaseModel):
id: str
filename: str
@@ -23,6 +38,7 @@ class DocumentInfoResponse(BaseModel):
uploadedAt: str
status: str
pages: list[PageInfoResponse] = []
permissions: PermissionsResponse = PermissionsResponse()
def make_document_response(d: dict) -> DocumentInfoResponse:
@@ -35,6 +51,7 @@ def make_document_response(d: dict) -> DocumentInfoResponse:
pages_list.append(PageInfoResponse(index=i, width=page.width, height=page.height))
except Exception:
pass
perms = d.get("permissions")
return DocumentInfoResponse(
id=d["id"],
filename=d["filename"],
@@ -45,6 +62,7 @@ def make_document_response(d: dict) -> DocumentInfoResponse:
uploadedAt=d["uploadedAt"],
status=d["status"],
pages=pages_list,
permissions=PermissionsResponse(**perms) if perms else PermissionsResponse(),
)
@@ -598,6 +616,14 @@ def export_document(document_id: str):
if not d:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
# Exporting a decrypted copy is a form of content extraction — gate on copy permission.
perms = d.get("permissions") or {}
if perms.get("canCopy", True) is False:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Exporting is not permitted by this document's restrictions (canCopy).",
)
try:
doc = d["doc_instance"]
bytes_data = doc.save_full()
+34 -6
View File
@@ -224,11 +224,12 @@ class ReplaceTextOperation(BaseModel):
class DecorationData(BaseModel):
x: float
y: float
width: float
thickness: float | None = None
color: str | None = None
# Text-markup annotation (underline/strikeout/squiggly) over one or more text
# lines — quadpoints in PDF top-down space, mirroring HighlightData.
quadPoints: list[HighlightQuadPoint]
color: str = "#000000"
author: str = "User"
content: str | None = None
class UnderlineOperation(BaseModel):
id: str
@@ -275,6 +276,19 @@ class EditsRequest(BaseModel):
operations: list[EditOperation]
# Which PDF permission each edit operation requires. Unencrypted / owner-unlocked
# docs report every flag True (in the engine), so this never blocks them.
_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",
"replace_text": "canModify", "redaction": "canModify",
"update_field": "canFillForms",
"page_rotation": "canAssemble", "page_deletion": "canAssemble", "page_reorder": "canAssemble",
}
def apply_edits_impl(document_id: str, request: EditsRequest):
if not engine.is_available():
raise HTTPException(
@@ -286,6 +300,17 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
if not doc_info:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
# Enforce document permissions (defense-in-depth; the UI also gates these).
# Done before the try so the 403 isn't rewritten to 400 by the broad handler.
perms = doc_info.get("permissions") or {}
for op in request.operations:
required = _OP_PERMISSION.get(op.type)
if required and perms.get(required, True) is False:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Operation '{op.type}' is not permitted by this document's restrictions ({required}).",
)
created_temp_files = []
try:
pdfengine = engine.require()
@@ -347,8 +372,11 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
new_doc = pdfengine.PdfDocument.load_from_memory(new_bytes)
# Carry the original permissions forward — the saved bytes are decrypted, so
# a fresh load would report full access and defeat enforcement.
new_info = document_store.add_document(
filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc
filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc,
permissions=doc_info.get("permissions"),
)
return {"success": True, "newDocumentId": new_info["id"]}
+37 -1
View File
@@ -4,12 +4,41 @@ from datetime import UTC, datetime
from typing import Any
def extract_permissions(doc_instance: Any) -> dict[str, Any] | None:
"""Read the engine's DocumentPermissions off a doc into a plain dict, or None
if unavailable (treated downstream as unrestricted)."""
try:
p = doc_instance.permissions
return {
"isEncrypted": p.is_encrypted,
"encryption": p.encryption,
"securityRevision": p.security_revision,
"ownerUnlocked": p.owner_unlocked,
"canPrint": p.can_print,
"canPrintHighRes": p.can_print_high_res,
"canModify": p.can_modify,
"canCopy": p.can_copy,
"canAnnotate": p.can_annotate,
"canFillForms": p.can_fill_forms,
"canExtractForAccessibility": p.can_extract_for_accessibility,
"canAssemble": p.can_assemble,
}
except Exception:
return None
class DocumentStore:
def __init__(self):
self._lock = threading.Lock()
self._documents: dict[str, dict[str, Any]] = {}
def add_document(self, filename: str, bytes_data: bytes, doc_instance: Any) -> dict[str, Any]:
def add_document(
self,
filename: str,
bytes_data: bytes,
doc_instance: Any,
permissions: dict[str, Any] | None = None,
) -> dict[str, Any]:
doc_id = str(uuid.uuid4())
uploaded_at = datetime.now(UTC).isoformat().replace("+00:00", "Z")
page_width = 612.0
@@ -22,6 +51,12 @@ class DocumentStore:
except Exception:
pass
# Permissions are computed from the freshly-loaded doc at upload; on derived
# docs (post-edit, which are saved decrypted) the caller passes the original's
# permissions forward so enforcement stays consistent.
if permissions is None:
permissions = extract_permissions(doc_instance)
info = {
"id": doc_id,
"filename": filename,
@@ -33,6 +68,7 @@ class DocumentStore:
"status": "ready",
"doc_instance": doc_instance,
"bytes_data": bytes_data,
"permissions": permissions,
}
with self._lock:
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Text-markup decoration tests (underline / strikeout / squiggly).
Decorations are real PDF text-markup annotations (not baked paths): they round-trip,
are extractable with the correct subtype + geometry, and are deletable like any
annotation. PDFium positions them within the text quad.
Run: gateway/.venv/Scripts/python.exe tests/edits/test_decorations.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "gateway"))
import pdfengine # noqa: E402
CORPUS = ROOT / "corpus" / "fonts" / "utf-8.pdf"
def _load():
return pdfengine.PdfDocument.load_from_memory(CORPUS.read_bytes(), "")
def _quad(run, page_height):
top, bot = page_height - (run.y + run.h), page_height - run.y
return {"x1": run.x, "y1": top, "x2": run.x + run.w, "y2": top,
"x3": run.x + run.w, "y3": bot, "x4": run.x, "y4": bot}
def _op(deco, run, page_height):
return {"version": "1.0", "operations": [{
"id": deco, "type": deco, "pageIndex": 0,
"data": {"quadPoints": [_quad(run, page_height)], "color": "#ff0000", "author": "Me"}}]}
def test_decorations_are_annotations_and_deletable():
for deco in ("underline", "strikeout", "squiggly"):
d = _load()
m = d.get_page(0).extract_document_model()
run = m.paragraphs[0].lines[0].runs[0]
d.apply_edits(json.dumps(_op(deco, run, m.height)))
doc2 = pdfengine.PdfDocument.load_from_memory(d.save_full(), "")
annots = doc2.get_page(0).extract_annotations()
match = [a for a in annots if a.type == deco]
assert match, f"{deco}: expected a '{deco}' annotation, got {[a.type for a in annots]}"
a = match[0]
# geometry roughly covers the run (top-left frame)
assert abs(a.x - run.x) < 3 and a.width > 10, f"{deco}: bbox off ({a.x},{a.width})"
# deletable like any annotation
doc2.apply_edits(json.dumps({"version": "1.0", "operations": [
{"id": "del", "type": "delete_annotation", "pageIndex": 0, "data": {"annotationId": a.id}}]}))
after = [x.type for x in pdfengine.PdfDocument.load_from_memory(doc2.save_full(), "").get_page(0).extract_annotations()]
assert deco not in after, f"{deco}: still present after delete ({after})"
print(f" ok {deco}: round-trips as annotation (bbox x={a.x:.0f} w={a.width:.0f}) and deletes cleanly")
def main() -> int:
try:
test_decorations_are_annotations_and_deletable()
except AssertionError as exc:
print(f" FAIL: {exc}")
return 1
print("\n1/1 passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Permission surfacing + enforcement tests for encrypted PDFs.
- Engine surfaces encryption + permission flags (FPDF_GetDocPermissions / revision).
- Gateway exposes them on the document response and ENFORCES forbidden edit ops (403).
Run: gateway/.venv/Scripts/python.exe tests/security/test_permissions.py
Requires the restricted fixture corpus/edge-cases/restricted.pdf (generated below if missing).
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "gateway"))
import pdfengine # noqa: E402
RESTRICTED = ROOT / "corpus" / "edge-cases" / "restricted.pdf"
NORMAL = ROOT / "corpus" / "fonts" / "utf-8.pdf"
def _ensure_fixture():
if RESTRICTED.exists():
return
import pikepdf # noqa: PLC0415
src = pikepdf.open(NORMAL)
perm = pikepdf.Permissions(extract=False, print_highres=False, print_lowres=False,
modify_other=False, modify_annotation=False)
src.save(RESTRICTED, encryption=pikepdf.Encryption(owner="owner", user="", R=6, allow=perm))
def test_engine_surfaces_permissions():
p = pdfengine.PdfDocument.load_from_memory(RESTRICTED.read_bytes(), "").permissions
assert p.is_encrypted and p.encryption == "AES-256" and p.security_revision == 6
assert not p.owner_unlocked
assert p.can_copy is False and p.can_print is False and p.can_modify is False and p.can_annotate is False
assert p.can_fill_forms is True # not denied in the fixture
n = pdfengine.PdfDocument.load_from_memory(NORMAL.read_bytes(), "").permissions
assert not n.is_encrypted and n.encryption == "None"
assert n.can_copy and n.can_print and n.can_modify and n.can_annotate
print(" ok engine surfaces AES-256 + correct flags (restricted) and all-true (normal)")
def test_gateway_surface_and_enforce():
from app.services.store import document_store # noqa: PLC0415
from app.routers.documents import make_document_response # noqa: PLC0415
from app.routers.edits import apply_edits_impl, EditsRequest # noqa: PLC0415
from fastapi import HTTPException # noqa: PLC0415
data = RESTRICTED.read_bytes()
doc = pdfengine.PdfDocument.load_from_memory(data, "")
info = document_store.add_document("restricted.pdf", data, doc)
# surface
resp = make_document_response(info)
assert resp.permissions.isEncrypted and resp.permissions.encryption == "AES-256"
assert resp.permissions.canModify is False and resp.permissions.canAnnotate is False
def apply(op):
apply_edits_impl(info["id"], EditsRequest.model_validate({"version": "1.0", "operations": [op]}))
# forbidden -> 403
forbidden = {"id": "a", "type": "replace_text", "pageIndex": 0,
"data": {"objectIndices": [0], "text": "x", "internalFontId": "F", "fontSize": 12.0}}
try:
apply(forbidden)
raise AssertionError("forbidden replace_text should have been rejected")
except HTTPException as ex:
assert ex.status_code == 403, f"expected 403, got {ex.status_code}"
# allowed (fill forms) -> not a 403
allowed = {"id": "b", "type": "update_field", "pageIndex": 0,
"data": {"value": "hi", "annotationId": "missing"}}
try:
apply(allowed)
except HTTPException as ex:
assert ex.status_code != 403, "fill-forms is allowed; should not be 403"
print(" ok gateway surfaces permissions + enforces 403 on forbidden op, allows permitted op")
def main() -> int:
_ensure_fixture()
tests = [test_engine_surfaces_permissions, test_gateway_surface_and_enforce]
failed = 0
for t in tests:
try:
t()
except AssertionError as exc:
print(f" FAIL {t.__name__}: {exc}")
failed += 1
print(f"\n{len(tests) - failed}/{len(tests)} passed.")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())