feat: implemented base editing, underline and striking
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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øæ[pq5X‘3;Ý�|šš 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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
bool isFill = (type != "squiggly");
|
||||
bool isStroke = (type == "squiggly");
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
FPDFPath_SetDrawMode(pathObj, isFill ? FPDF_FILLMODE_ALTERNATE : FPDF_FILLMODE_NONE, isStroke);
|
||||
|
||||
double pageHeight = FPDF_GetPageHeightF(page);
|
||||
unsigned int r = 0, g = 0, b = 0;
|
||||
parseHexColor(color, r, g, b);
|
||||
parseHexColor(data.value("color", "#000000"), r, g, b);
|
||||
FPDFAnnot_SetColor(annot, FPDFANNOT_COLORTYPE_Color, r, g, b, 255);
|
||||
|
||||
if (isFill) {
|
||||
FPDFPageObj_SetFillColor(pathObj, r, g, b, 255);
|
||||
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 (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;
|
||||
}
|
||||
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()) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+64
-14
@@ -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) {
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 |
@@ -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())
|
||||
Reference in New Issue
Block a user