fix the watermark issue

This commit is contained in:
saqib mir
2026-08-13 19:05:14 +05:30
parent 6c22179699
commit e96d9fc10b
10 changed files with 846 additions and 169 deletions
+298
View File
@@ -0,0 +1,298 @@
# PDF Security / Unlock / Protect — Feature Audit Report
**Date of Audit:** August 13, 2026
**Audited Subsystems:** Frontend (`pdf/frontend`), Gateway API (`pdf/gateway`), C++ PDF Engine (`pdf/engine`), Pybind11 Bindings (`pdf/bindings`), Security Tests (`pdf/tests/security`)
**Audit Purpose:** Evaluate the exact current state of PDF security, password authentication, permission enforcement, encryption detection, password removal, and PDF protection capabilities in the existing codebase.
---
## 1. Executive Summary
The existing PDF editor codebase possesses a **robust reading, authenticating, and permission-enforcing pipeline** for password-protected PDFs, but **lacks all writing/creation capabilities for PDF encryption and protection**.
Key findings:
* **Reading & Authenticating Encrypted PDFs:** **FULLY IMPLEMENTED**. The system detects encrypted PDFs, prompts the user via a React modal, passes the password to PDFium in C++, validates credentials, returns helpful error messages on invalid passwords, and renders the document upon success.
* **Granular Permission Surfacing & Enforcement:** **FULLY IMPLEMENTED**. PDFium extracts security revision numbers, encryption types (RC4, AES-128, AES-256), and permission flags. The Python FastAPI Gateway exposes these flags in `PermissionsResponse` and enforces HTTP `403 Forbidden` errors if a user attempts forbidden edits (annotations, text replacements, page rotations) or unauthorized exports.
* **Password Removal / Unlocking:** **PARTIALLY IMPLEMENTED (Implicit)**. Opening a protected PDF with a valid password loads the decrypted document into memory. Exporting the document via `/documents/{id}/export` writes out an unencrypted PDF file. However, there is no explicit UI button or API endpoint dedicated to "Remove Password".
* **Protecting / Encrypting PDFs:** **NOT IMPLEMENTED / MISSING**. There is no functionality in the C++ engine (PDFium/QPDF), Pybind11 bindings, Gateway API, or Frontend UI to password-protect an unencrypted PDF, set user/owner passwords, or configure output permissions.
---
## 2. User-Facing Capability Summary
### CURRENTLY AVAILABLE
* **✓ Open Password-Protected PDFs:** Prompts for credentials when an encrypted PDF is uploaded.
* **✓ Password Validation & Error Feedback:** Rejects incorrect passwords with clear inline UI feedback and allows unlimited retries.
* **✓ Post-Authentication Rendering & Extraction:** Full page rendering, OCR, text extraction, font listing, layout analysis, and display list extraction work seamlessly after authentication.
* **✓ Encryption & Security Inspection:** Detects and displays encryption standards (RC4-40, RC4-128, AES-128, AES-256) and security revision level (2 through 6) in the Inspector panel.
* **✓ Permission Enforcement:** Gateway blocks unauthorized edits, annotations, page reordering, and exports with HTTP `403 Forbidden` responses if disallowed by the PDF's security settings.
* **✓ Unprotected Export:** Exporting an authenticated PDF generates an unencrypted PDF that can subsequently be opened without a password.
### NOT CURRENTLY AVAILABLE
* **✗ Explicit Password Removal UI/API:** No button or endpoint explicitly labeled "Unlock PDF" or "Remove Security".
* **✗ Password-Protect PDF / Lock PDF:** Cannot apply passwords to an unencrypted PDF.
* **✗ Configure Output Permissions:** Cannot set or modify permission flags for printing, copying, editing, or annotating.
* **✗ Separate Owner Password Prompting:** Prompts only with a generic "Document password" input; does not request owner password specifically when attempting restricted operations.
* **✗ Re-encrypting Edited PDFs:** Saved/exported PDFs are saved without encryption.
* **✗ Attempt Rate Limiting:** No rate limiting on password validation attempts at the API level.
---
## 3. Protected PDF Open Flow
The upload and document initialization flow is traced across the full stack:
```
User Selects Encrypted PDF
[Frontend] gatewayService.uploadDocument(file, password="")
▼ (POST /documents)
[Gateway API] upload_document() in crud.py
[Pybind11] PdfDocument.load_from_memory(bytes_data, "")
[C++ Engine] PdfDocument::loadFromMemory() -> FPDF_LoadMemDocument()
▼ (PDFium returns FPDF_ERR_PASSWORD)
[C++ Engine] mapPdfiumError() returns EngineError::PasswordRequired
[Pybind11] Throws ValueError("Password required to open this PDF")
[Gateway API] Catches ValueError -> Raises HTTP 401 ("Password required")
[Frontend] gatewayService catches 401 -> Throws PasswordError
[Frontend] App.tsx sets passwordPrompt state -> PasswordModal renders
```
### Exact Code Implementation Points
* **Encryption Detection & Password Loading (C++ Engine):**
* File: [`pdf/engine/src/parser/pdfium_document.cpp`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/engine/src/parser/pdfium_document.cpp#L42-L48)
* Function: `pdfengine::PdfDocument::loadFromMemory(const std::vector<uint8_t>& data, const std::string& password)`
* C++ API: `FPDF_LoadMemDocument(buffer_copy.data(), static_cast<int>(buffer_copy.size()), password.empty() ? nullptr : password.c_str())`
* **Error Mapping (C++ Engine):**
* File: [`pdf/engine/src/parser/pdfium_internal.cpp`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/engine/src/parser/pdfium_internal.cpp#L271-L285)
* Function: `pdfengine::parser::mapPdfiumError(unsigned long err, bool passwordProvided)`
* Logic: Maps `FPDF_ERR_PASSWORD` to `EngineError::PasswordRequired` (if `password` is empty) or `EngineError::InvalidPassword` (if `password` was provided).
* **Pybind11 Translation:**
* File: [`pdf/bindings/python/pdfengine_py.cpp`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/bindings/python/pdfengine_py.cpp#L18-L21)
* Function: `throw_on_error(pdfengine::EngineError err)`
* Logic: Maps `PasswordRequired` -> `PyExc_ValueError("Password required to open this PDF")` and `InvalidPassword` -> `PyExc_ValueError("Invalid password provided for this PDF")`.
* **Gateway Endpoint:**
* File: [`pdf/gateway/app/routers/documents/crud.py`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/gateway/app/routers/documents/crud.py#L142-L189)
* Endpoint: `POST /documents?password={password}` (`upload_document`)
* Logic: Catches `ValueError` from pybind11 and raises `HTTPException(status_code=401, detail="Password required")` or `HTTPException(status_code=401, detail="Invalid password")`.
* **Frontend Password Dialog & Resubmission:**
* Files: [`pdf/frontend/src/lib/gatewayService.ts`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/frontend/src/lib/gatewayService.ts#L471-L486), [`pdf/frontend/src/App.tsx`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/frontend/src/App.tsx#L638-L652), [`pdf/frontend/src/components/PasswordModal.tsx`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/frontend/src/components/PasswordModal.tsx#L15-L95)
* Logic: `gatewayService` throws `PasswordError`. `App.tsx` catches `PasswordError` and opens `PasswordModal`. User enters password, triggering resubmission to `uploadDocument(file, password)`.
---
## 4. Password Authentication & Validation Flow
| Stage | Implementation Status | Evidence / Location |
|---|---|---|
| **Password Reaches Backend** | **IMPLEMENTED** | `uploadDocument(file, password)` in `gatewayService.ts:L476` sends `POST /documents?password=...`. |
| **Backend Passes Password to C++** | **IMPLEMENTED** | `crud.py:L173` calls `doc = pdfengine.PdfDocument.load_from_memory(bytes_data, password)`. |
| **C++ Engine Validation** | **IMPLEMENTED** | `pdfium_document.cpp:L42` executes `FPDF_LoadMemDocument(..., password.c_str())`. |
| **Incorrect Password Rejected** | **IMPLEMENTED** | `pdfium_internal.cpp:L280` returns `EngineError::InvalidPassword` -> HTTP 401 `"Invalid password"` -> `App.tsx` shows red error message in modal. |
| **Correct Password Accepted** | **IMPLEMENTED** | `FPDF_LoadMemDocument` returns document pointer -> Gateway stores document info and returns HTTP 201 response. |
| **Password Retry Loop** | **IMPLEMENTED** | `App.tsx:L650` retains modal open on failure with updated error message, allowing infinite retry attempts. |
| **Rate Limiting / Attempt Limit** | **MISSING** | Neither Gateway nor C++ Engine tracks failed attempts or implements delays/lockouts. |
---
## 5. Rendering & Feature Support After Authentication
Once authenticated, all engine features operate on the unlocked in-memory PDF handle:
| Feature | Status | Evidence / Implementation Location |
|---|---|---|
| **Page Count** | **IMPLEMENTED** | [`pdfium_document.cpp:L101`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/engine/src/parser/pdfium_document.cpp#L101) (`FPDF_GetPageCount`) |
| **Document Metadata** | **IMPLEMENTED** | [`pdfium_document.cpp:L109`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/engine/src/parser/pdfium_document.cpp#L109) (`FPDF_GetMetaText`) |
| **Font Inventory** | **IMPLEMENTED** | [`pdfium_document.cpp:L280`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/engine/src/parser/pdfium_document.cpp#L280), [`fonts.py:L16`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/gateway/app/routers/documents/fonts.py#L16) |
| **Page Text Extraction** | **IMPLEMENTED** | [`pdfium_page.cpp:L100`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/engine/src/parser/pdfium_page.cpp#L100), [`render.py:L172`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/gateway/app/routers/render.py#L172) |
| **Page Image Rendering** | **IMPLEMENTED** | [`pdfium_page.cpp:L50`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/engine/src/parser/pdfium_page.cpp#L50), [`render.py:L19`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/gateway/app/routers/render.py#L19) |
| **Display List Extraction** | **IMPLEMENTED** | [`pdfium_page.cpp:L300`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/engine/src/parser/pdfium_page.cpp#L300), [`content.py:L133`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/gateway/app/routers/documents/content.py#L133) |
| **OCR Support** | **IMPLEMENTED** | [`ocr.py:L63`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/gateway/app/routers/ocr.py#L63) (Runs Tesseract on rendered page image) |
| **Layout Model Extraction** | **IMPLEMENTED** | [`layout.py:L120`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/gateway/app/routers/layout.py#L120) (`extract_document_model()`) |
| **Editing Operations** | **PARTIAL** | [`edits.py:L395`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/gateway/app/routers/edits.py#L395) checks `permissions`. Allowed edits modify stream in-memory. |
| **Exporting Document** | **IMPLEMENTED** | [`export.py:L17`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/gateway/app/routers/documents/export.py#L17) checks `canCopy` and calls `save_full_for_export()`. |
---
## 6. Unlock / Decrypt Capability
| Action | Status | Description |
|---|---|---|
| **Open Protected PDF** | **IMPLEMENTED** | Via `upload_document` with `password`. |
| **Authenticate** | **IMPLEMENTED** | Validated via `FPDF_LoadMemDocument`. |
| **In-Memory Decryption** | **IMPLEMENTED** | PDFium decrypts document structure in RAM for standard operations. |
| **Save/Export Unprotected Copy** | **IMPLEMENTED (Implicit)** | `doc.save_full_for_export()` calls PDFium's `FPDF_SaveWithVersion(doc_, &writer, 0, 14)`. Because PDFium does not attach an encryption handler during save, the output PDF is **unencrypted**. |
| **Reopen Exported Copy Without Password** | **IMPLEMENTED** | The exported PDF contains no `/Encrypt` dictionary; reopening requires no password. |
| **Explicit "Remove Password" Endpoint / UI** | **MISSING** | No dedicated route (e.g. `POST /documents/{id}/unlock`) or UI action exists. |
---
## 7. Protect / Encrypt Capability
A comprehensive search across C++ Engine (`pdf/engine`), Pybind11 (`pdf/bindings`), Gateway (`pdf/gateway`), and Frontend (`pdf/frontend`) reveals **NO code for creating encrypted PDFs**:
* **QPDF Encryption Writer:** Not implemented. [`qpdf_writer.cpp`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/engine/src/qpdf/qpdf_writer.cpp) contains stream replacement and appearance helpers, but no `QPDFWriter::setEncryption` calls.
* **PDFium Encryption Output:** PDFium's public writing API lacks native PDF encryption creation functions.
* **Python Encryption Libraries:** `pikepdf` is imported **only** in [`pdf/tests/security/test_permissions.py`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/tests/security/test_permissions.py#L30) to generate test fixtures. It is not present in Gateway production services.
* **Frontend Encryption Controls:** No modal, form, or state exists for password-protecting documents.
---
## 8. Password Types & Permission Management
### Password Types
* **User Password:** **IMPLEMENTED**. Used for opening documents.
* **Owner Password:** **PARTIALLY IMPLEMENTED**. When opened with an Owner password, PDFium elevates document permissions. The C++ engine detects this by comparing user vs doc permissions (`perms.ownerUnlocked = (p != up)` in [`pdfium_document.cpp:L168`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/engine/src/parser/pdfium_document.cpp#L168)) and displays an `"Owner"` badge in `InspectorPanel.tsx:L569`. However, there is no UI workflow to enter an owner password separately to unlock restricted actions.
### PDF Permissions Matrix
| Permission | Existing Support | Where Implemented |
|---|---|---|
| **Print** (`canPrint`) | Surfaced & Displayed | `pdfium_document.cpp:L171`, `store.py:L18`, `InspectorPanel.tsx:L573` |
| **Modify** (`canModify`) | Surfaced & Enforced | `pdfium_document.cpp:L172`, `edits.py:L375`, `edits.py:L395` (HTTP 403) |
| **Copy** (`canCopy`) | Surfaced & Enforced | `pdfium_document.cpp:L173`, `export.py:L30`, `export.py:L65` (HTTP 403 on Export) |
| **Extract** (`canCopy`) | Surfaced & Enforced | Same bit as Copy (`0x10`) in PDFium spec |
| **Annotate** (`canAnnotate`) | Surfaced & Enforced | `pdfium_document.cpp:L174`, `edits.py:L370-374`, `edits.py:L395` (HTTP 403) |
| **Fill Forms** (`canFillForms`) | Surfaced & Enforced | `pdfium_document.cpp:L175`, `edits.py:L376`, `edits.py:L395` |
| **Accessibility** (`canExtractForAccessibility`) | Surfaced & Displayed | `pdfium_document.cpp:L176`, `store.py:L24` |
| **Document Assembly** (`canAssemble`) | Surfaced & Enforced | `pdfium_document.cpp:L177`, `edits.py:L377` (HTTP 403 on rotation/deletion) |
| **High-Quality Print** (`canPrintHighRes`) | Surfaced & Displayed | `pdfium_document.cpp:L178`, `store.py:L19` |
| **Permission Configuration (Writing)** | **MISSING** | No engine or gateway code exists to modify permission flags. |
---
## 9. Encryption Algorithm Surfacing
The C++ engine inspects the PDF security handler revision via `FPDF_GetSecurityHandlerRevision(doc_)`:
```cpp
// pdfium_document.cpp (lines 147-164)
switch (rev) {
case 2: perms.encryption = "RC4-40"; break;
case 3: perms.encryption = "RC4-128"; break;
case 4: perms.encryption = "AES-128"; break;
case 5:
case 6: perms.encryption = "AES-256"; break;
default: perms.encryption = "Unknown"; break;
}
```
* **Surfacing:** Mapped to `PermissionsResponse.encryption` ([`document.py:L12`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/gateway/app/schemas/document.py#L12)) and rendered as a badge in the Inspector panel ([`InspectorPanel.tsx:L567`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/frontend/src/components/InspectorPanel.tsx#L567)).
* **Creation:** Encryption creation was not found in the codebase.
---
## 10. Frontend UI State
| Capability | Status | Front-End Evidence |
|---|---|---|
| **A. Unlock existing protected PDF** | **IMPLEMENTED** | `PasswordModal.tsx` renders when `passwordPrompt` state is non-null. |
| **B. Remove password / security** | **MISSING** | No UI button or option. |
| **C. Protect an unprotected PDF** | **MISSING** | No UI button or option. |
| **D. Set a password** | **MISSING** | No input fields for protecting PDFs. |
| **E. Configure permissions** | **MISSING** | No permissions toggle matrix in settings or export dialog. |
| **F. Export document** | **IMPLEMENTED** | TopBar export button triggers file download. |
---
## 11. Gateway / API Endpoints Audit
| Endpoint | Method | Purpose | Implemented Behavior | C++ Call |
|---|---|---|---|---|
| `/documents` | `POST` | Upload & open PDF | Accepts `password` query param. Passes password to engine. Returns HTTP 401 on missing/wrong password, HTTP 201 with permissions on success. | `PdfDocument::loadFromMemory` |
| `/documents/{id}/export` | `GET` | Export PDF | Verifies `permissions.canCopy`. Returns HTTP 403 if forbidden. Calls `save_full_for_export()`. | `PdfiumDocument::saveFullForExport` |
| `/documents/{id}/export-remote` | `POST` | Export to remote URL | Verifies `permissions.canCopy`. Streams file to target URL. | `PdfiumDocument::saveFullForExport` |
| `/edits` | `POST` | Apply PDF edits | Maps operation types to permissions (`canAnnotate`, `canModify`, `canFillForms`, `canAssemble`). Returns HTTP 403 if restricted. | Engine edit APIs |
| `/documents/{id}/unlock` | N/A | Dedicated unlock | **MISSING** | N/A |
| `/documents/{id}/protect` | N/A | Protect document | **MISSING** | N/A |
---
## 12. End-to-End Export & Reopen Verification Scenarios
### SCENARIO A: Unprotected PDF -> Protect with Password -> Export -> Reopen -> Prompted for Password
* **Status:** **NOT WORKING / IMPOSSIBLE TODAY**
* **Reason:** "Protect with password" is not implemented anywhere in the backend or engine.
### SCENARIO B: Protected PDF -> Enter Password -> Opened -> Export -> Reopen Exported PDF -> No Password Required
* **Status:** **FULLY WORKING TODAY (Implicitly)**
* **Reason:** PDFium loads the decrypted PDF structure into memory. Exporting via `GET /documents/{id}/export` writes the file without encryption. Reopening the exported file requires no password.
### SCENARIO C: Protected PDF -> Enter Wrong Password -> Rejected -> Enter Correct Password -> Opened
* **Status:** **FULLY WORKING TODAY**
* **Reason:** Invalid password returns HTTP 401 with `"Invalid password"`. The frontend displays `"Incorrect password — please try again."` and keeps the modal open. Re-submitting with the correct password opens the document cleanly.
---
## 13. Security Observations & Risks
* **CONFIRMED FROM CODE — Password Passed in Query String:**
In [`gatewayService.ts:L476`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/frontend/src/lib/gatewayService.ts#L476), the upload URL is constructed as `${this.baseUrl}/documents?password=${encodeURIComponent(password)}`. Transmitting passwords in GET/POST URL query parameters poses a security risk because query parameters may be recorded in server access logs or proxy logs.
* **CONFIRMED FROM CODE — Absence of API Rate Limiting:**
In [`crud.py:L142-L190`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/gateway/app/routers/documents/crud.py#L142-L189), there is no rate-limiting or lock-out mechanism for password validation requests, allowing automated brute-force attempts.
* **CONFIRMED FROM CODE — Implicit Decryption on Export:**
In [`export.py:L38`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/gateway/app/routers/documents/export.py#L38), exported PDFs are saved unencrypted. Users who upload a password-protected PDF and subsequently export it will receive an unencrypted file without explicit warning that password protection has been stripped.
* **CONFIRMED FROM CODE — Plaintext Password In-Memory Only:**
Passwords are passed directly to `load_from_memory` and are **not** persisted in `document_store` or written to disk.
---
## 14. Complete Feature Matrix
| Feature | Status | Existing Location | Evidence |
|---|---|---|---|
| **Detect encrypted PDF** | `IMPLEMENTED` | `pdfium_document.cpp:L142` | `perms.isEncrypted = (rev != -1)` |
| **Password popup** | `IMPLEMENTED` | `PasswordModal.tsx:L15` | `<PasswordModal state={passwordPrompt} ... />` |
| **Validate password** | `IMPLEMENTED` | `pdfium_document.cpp:L42`, `crud.py:L173` | `load_from_memory(bytes_data, password)` |
| **Wrong password handling** | `IMPLEMENTED` | `crud.py:L183`, `App.tsx:L650` | HTTP 401 `"Invalid password"` -> UI error |
| **Correct password handling** | `IMPLEMENTED` | `crud.py:L174`, `App.tsx:L646` | Returns `DocumentInfoResponse` -> Document opens |
| **Render protected PDF** | `IMPLEMENTED` | `pdfium_page.cpp:L50`, `render.py:L19` | Renders tiles/pages post-authentication |
| **OCR protected PDF** | `IMPLEMENTED` | `ocr.py:L63` | Executes Tesseract on authenticated doc pages |
| **Edit protected PDF** | `PARTIAL` | `edits.py:L395` | Enforces permissions, but doesn't re-encrypt |
| **Export protected PDF** | `PARTIAL` | `export.py:L17` | Enforces `canCopy`, but exports UNENCRYPTED |
| **Remove password** | `PARTIAL` | `export.py:L38` | Exporting strips password (implicit, no explicit API) |
| **Export unprotected PDF** | `IMPLEMENTED` | `export.py:L38` | `save_full_for_export()` outputs unencrypted PDF |
| **Reopen unprotected PDF** | `IMPLEMENTED` | `crud.py:L141` | Exported file reopens without password |
| **Protect PDF** | `MISSING` | N/A | No code exists to protect/encrypt PDF |
| **Set user password** | `MISSING` | N/A | No functionality to set user password |
| **Set owner password** | `MISSING` | N/A | No functionality to set owner password |
| **AES encryption (detection)** | `IMPLEMENTED` | `pdfium_document.cpp:L155-160` | Revision 4/5/6 mapped to `"AES-128"` / `"AES-256"` |
| **AES-256 (detection)** | `IMPLEMENTED` | `pdfium_document.cpp:L158` | Revision 5/6 mapped to `"AES-256"` |
| **RC4 (detection)** | `IMPLEMENTED` | `pdfium_document.cpp:L148-153` | Revision 2/3 mapped to `"RC4-40"` / `"RC4-128"` |
| **Print permission** | `IMPLEMENTED` | `pdfium_document.cpp:L171`, `store.py:L18` | Surfaced in permissions API |
| **Copy permission** | `IMPLEMENTED` | `pdfium_document.cpp:L173`, `export.py:L30` | Enforced on Export (returns HTTP 403) |
| **Modify permission** | `IMPLEMENTED` | `pdfium_document.cpp:L172`, `edits.py:L375` | Enforced on edits (returns HTTP 403) |
| **Annotation permission** | `IMPLEMENTED` | `pdfium_document.cpp:L174`, `edits.py:L370` | Enforced on annotations (returns HTTP 403) |
| **Form permission** | `IMPLEMENTED` | `pdfium_document.cpp:L175`, `edits.py:L376` | Enforced on form fills (allows if permitted) |
| **Extraction permission** | `IMPLEMENTED` | `pdfium_document.cpp:L173`, `store.py:L21` | Surfaced as `canCopy` |
| **Document assembly** | `IMPLEMENTED` | `pdfium_document.cpp:L177`, `edits.py:L377` | Enforced on page rotate/delete ops |
| **Accessibility permission** | `IMPLEMENTED` | `pdfium_document.cpp:L176`, `store.py:L24` | Surfaced as `canExtractForAccessibility` |
| **High-quality printing** | `IMPLEMENTED` | `pdfium_document.cpp:L178`, `store.py:L19` | Surfaced as `canPrintHighRes` |
| **Security UI** | `PARTIAL` | `PasswordModal.tsx`, `InspectorPanel.tsx` | Password prompt modal + Inspector security badge exist |
| **Security API** | `PARTIAL` | `crud.py`, `export.py`, `edits.py` | Upload & export handle passwords & perms |
| **C++ security implementation** | `PARTIAL` | `pdfium_document.cpp` | Document load & permission inspection implemented |
| **Pybind security bindings** | `PARTIAL` | `pdfengine_py.cpp:L252` | `DocumentPermissions` & `load_from_memory` bound |
---
## 15. Production Readiness Summary
* **Opening & Viewing Protected PDFs:** **PRODUCTION READY**. Robust, fully tested with unit tests ([`pdf/engine/tests/document_load_test.cpp`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/engine/tests/document_load_test.cpp#L27-L70)) and security integration tests ([`pdf/tests/security/test_permissions.py`](file:///c:/Users/Maskan/Desktop/pdf_editor/pdf/tests/security/test_permissions.py)).
* **Permission Enforcement:** **PRODUCTION READY**. Gateway correctly returns HTTP 403 Forbidden for restricted edits and exports.
* **Password Removal / Unlocking:** **NEEDS FEATURIZATION**. Works implicitly when exporting, but lacks dedicated API routes and UI buttons for explicit unlock workflows.
* **Protecting / Encrypting PDFs:** **NOT PRODUCTION READY (0% IMPLEMENTED)**. Creation of password-protected PDFs or custom permission dictionaries requires adding QPDF or pikepdf encryption writers to the engine/gateway layer.
+28 -1
View File
@@ -49,6 +49,14 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_imageOverlay(const nloh
return std::unexpected(EngineError::InvalidFormat);
}
double opacity = data.value("opacity", 1.0);
if (opacity < 1.0) {
float opFactor = static_cast<float>(std::clamp(opacity, 0.0, 1.0));
for (size_t i = 3; i < decodedBytes.size(); i += 4) {
decodedBytes[i] = static_cast<uint8_t>(std::round(decodedBytes[i] * opFactor));
}
}
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
if (!page) {
spdlog::error("Failed to load page index {} for image insertion", pageIndex);
@@ -81,7 +89,26 @@ std::expected<void, EngineError> PdfiumDocument::applyOp_imageOverlay(const nloh
return std::unexpected(EngineError::Unknown);
}
FPDFPageObj_Transform(imgObj, width, 0.0, 0.0, height, x, y);
double rotation = data.value("rotation", 0.0);
if (std::abs(rotation) > 0.001) {
double rad = rotation * 3.14159265358979323846 / 180.0;
double cosA = std::cos(rad);
double sinA = std::sin(rad);
double cx = x + width / 2.0;
double cy = y + height / 2.0;
double a = width * cosA;
double b = width * sinA;
double c = -height * sinA;
double d = height * cosA;
double e = cx - 0.5 * (a + c);
double f = cy - 0.5 * (b + d);
FPDFPageObj_Transform(imgObj, a, b, c, d, e, f);
} else {
FPDFPageObj_Transform(imgObj, width, 0.0, 0.0, height, x, y);
}
FPDFPage_InsertObject(page, imgObj);
+100 -35
View File
@@ -60,6 +60,14 @@ function App() {
const [currentPage, setCurrentPage] = useState(0);
const [isInspectorOpen, setIsInspectorOpen] = useState(true);
const [createPdfModalOpen, setCreatePdfModalOpen] = useState(false);
const [createPdfKey, setCreatePdfKey] = useState(0);
const startNewBlankPDF = useCallback(() => {
localStorage.setItem('active_mode', 'create_pdf');
setCreatePdfModalOpen(true);
setActiveTool('create_pdf');
setCreatePdfKey((k) => k + 1);
}, []);
const [creatorActions, setCreatorActions] = useState<{
canUndo: boolean;
canRedo: boolean;
@@ -568,6 +576,80 @@ function App() {
};
const handleApplyWatermark = async (config: WatermarkConfig, targetPageIndices: number[]) => {
const buildOps = (docWidthFallback = 612, docHeightFallback = 792): EditOperation[] => {
const isImage = config.type === 'image' && config.imageDataUrl;
return targetPageIndices.map((p) => {
if (isImage) {
const pageW = activeDoc?.pages?.[p]?.width ?? activeDoc?.pageWidth ?? docWidthFallback;
const pageH = activeDoc?.pages?.[p]?.height ?? activeDoc?.pageHeight ?? docHeightFallback;
const imgNativeW = config.imageWidth || 150;
const imgNativeH = config.imageHeight || 150;
const scaleFactor = config.scale || 0.5;
let targetW = imgNativeW * scaleFactor;
let targetH = imgNativeH * scaleFactor;
if (targetW > pageW * 0.85) {
const r = (pageW * 0.85) / targetW;
targetW *= r;
targetH *= r;
}
if (targetH > pageH * 0.85) {
const r = (pageH * 0.85) / targetH;
targetW *= r;
targetH *= r;
}
const margin = 36.0;
let cx = pageW / 2.0;
let cy = pageH / 2.0;
if (config.position === 'top_left') { cx = margin + targetW / 2.0; cy = pageH - margin - targetH / 2.0; }
else if (config.position === 'top_center') { cx = pageW / 2.0; cy = pageH - margin - targetH / 2.0; }
else if (config.position === 'top_right') { cx = pageW - margin - targetW / 2.0; cy = pageH - margin - targetH / 2.0; }
else if (config.position === 'center_left') { cx = margin + targetW / 2.0; cy = pageH / 2.0; }
else if (config.position === 'center_right') { cx = pageW - margin - targetW / 2.0; cy = pageH / 2.0; }
else if (config.position === 'bottom_left') { cx = margin + targetW / 2.0; cy = margin + targetH / 2.0; }
else if (config.position === 'bottom_center') { cx = pageW / 2.0; cy = margin + targetH / 2.0; }
else if (config.position === 'bottom_right') { cx = pageW - margin - targetW / 2.0; cy = margin + targetH / 2.0; }
const x = cx - targetW / 2.0;
const y = cy - targetH / 2.0;
return {
id: rid('img_wm'),
type: 'image_overlay' as const,
pageIndex: p,
data: {
imageData: config.imageDataUrl!,
x,
y,
width: targetW,
height: targetH,
opacity: config.opacity / 100.0,
rotation: config.rotation,
},
};
}
return {
id: rid('watermark'),
type: 'add_watermark' as const,
pageIndex: p,
data: {
text: config.text,
fontFamily: config.fontFamily,
fontSize: config.fontSize,
fontWeight: config.fontWeight,
color: config.color,
opacity: config.opacity / 100.0,
rotation: config.rotation,
position: config.position,
},
};
});
};
const isCreatorActive = activeTool === 'create_pdf' || createPdfModalOpen;
if (isCreatorActive) {
if (!creatorActions?.generateBlob) {
@@ -584,21 +666,7 @@ function App() {
setActiveTool('select');
openDocument(newDoc.id);
const ops: EditOperation[] = targetPageIndices.map((p) => ({
id: rid('watermark'),
type: 'add_watermark' as const,
pageIndex: p,
data: {
text: config.text,
fontFamily: config.fontFamily,
fontSize: config.fontSize,
fontWeight: config.fontWeight,
color: config.color,
opacity: config.opacity / 100.0,
rotation: config.rotation,
position: config.position,
},
}));
const ops = buildOps(newDoc.pageWidth || 612, newDoc.pageHeight || 792);
const result = await gatewayService.applyEdits(newDoc.id, ops);
if (result.success) {
@@ -609,6 +677,7 @@ function App() {
alert('Failed to apply watermark to new blank document.');
} finally {
setIsSaving(false);
setWatermarkPreview(null);
}
return;
}
@@ -616,23 +685,13 @@ function App() {
if (!selectedDocId) return;
if (!can('canAnnotate')) { denyToast('Watermark'); return; }
const ops: EditOperation[] = targetPageIndices.map((p) => ({
id: rid('watermark'),
type: 'add_watermark' as const,
pageIndex: p,
data: {
text: config.text,
fontFamily: config.fontFamily,
fontSize: config.fontSize,
fontWeight: config.fontWeight,
color: config.color,
opacity: config.opacity / 100.0,
rotation: config.rotation,
position: config.position,
},
}));
const ops = buildOps();
await applyOps(ops, 'Watermark applied');
try {
await applyOps(ops, 'Watermark applied');
} finally {
setWatermarkPreview(null);
}
};
const handleUpload = async (file: File, password = '') => {
@@ -657,6 +716,9 @@ function App() {
};
const handleToolChange = async (tool: ToolId) => {
if (tool !== 'watermark') {
setWatermarkPreview(null);
}
if (tool === 'watermark') {
setActiveTool('watermark');
setInspectorTab('watermark');
@@ -665,8 +727,7 @@ function App() {
return;
}
if (tool === 'create_pdf') {
setCreatePdfModalOpen(true);
setActiveTool('create_pdf');
startNewBlankPDF();
return;
}
@@ -831,7 +892,7 @@ function App() {
canExport={(activeTool === 'create_pdf' || createPdfModalOpen) ? true : can('canCopy')}
canAssemble={can('canAssemble')}
onUpload={handleUpload}
onNewBlankPDF={() => { localStorage.setItem('active_mode', 'create_pdf'); setCreatePdfModalOpen(true); setActiveTool('create_pdf'); }}
onNewBlankPDF={startNewBlankPDF}
onShowVersionHistory={() => setVersionHistoryModalOpen(true)}
isInspectorOpen={isInspectorOpen}
onToggleInspector={toggleInspector}
@@ -897,6 +958,7 @@ function App() {
<WhiteboardView />
) : activeTool === 'create_pdf' || createPdfModalOpen ? (
<CreatePDFModal
key={createPdfKey}
isOpen={true}
activeTool={activeTool}
drawColor={toolSettings.inkColor}
@@ -1034,7 +1096,10 @@ function App() {
{isInspectorOpen && (
<InspectorPanel
activeTab={inspectorTab}
onTabChange={setInspectorTab}
onTabChange={(t) => {
if (t !== 'watermark') setWatermarkPreview(null);
setInspectorTab(t);
}}
isExpanded={isInspectorExpanded}
onExpandedChange={setIsInspectorExpanded}
documents={documents}
+301 -128
View File
@@ -717,6 +717,12 @@ const WatermarkTab: React.FC<{
const [targetPages, setTargetPages] = React.useState<'all' | 'current' | 'custom'>('all');
const [customRangeStr, setCustomRangeStr] = React.useState(`1-${totalPages || 1}`);
// Image watermark state
const [imageFile, setImageFile] = React.useState<File | null>(null);
const [imageDataUrl, setImageDataUrl] = React.useState<string | null>(null);
const [imageDims, setImageDims] = React.useState<{ width: number; height: number } | null>(null);
const [scale, setScale] = React.useState<number>(0.5); // 50% default scale
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
const [successMessage, setSuccessMessage] = React.useState<string | null>(null);
@@ -725,23 +731,68 @@ const WatermarkTab: React.FC<{
setCustomRangeStr(`1-${totalPages || 1}`);
}, [totalPages]);
const processImageFile = (file: File) => {
if (!file.type.startsWith('image/')) {
setErrorMessage('Please select a valid image file (PNG, JPG, WebP, SVG).');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
const img = new Image();
img.onload = () => {
setImageFile(file);
setImageDataUrl(dataUrl);
setImageDims({ width: img.naturalWidth, height: img.naturalHeight });
setErrorMessage(null);
};
img.src = dataUrl;
};
reader.readAsDataURL(file);
};
React.useEffect(() => {
onPreviewChange?.({
text,
fontFamily,
fontSize,
fontWeight,
color,
opacity,
rotation,
position,
targetPages,
customRangeStr,
});
if (tab === 'image') {
if (imageDataUrl && imageDims) {
onPreviewChange?.({
type: 'image',
text: '',
imageDataUrl,
imageWidth: imageDims.width,
imageHeight: imageDims.height,
scale,
fontFamily: 'Helvetica',
fontSize: 48,
fontWeight: 'normal',
color: '#000000',
opacity,
rotation,
position,
targetPages,
customRangeStr,
});
} else {
onPreviewChange?.(null);
}
} else {
onPreviewChange?.({
type: 'text',
text,
fontFamily,
fontSize,
fontWeight,
color,
opacity,
rotation,
position,
targetPages,
customRangeStr,
});
}
return () => {
onPreviewChange?.(null);
};
}, [text, fontFamily, fontSize, fontWeight, color, opacity, rotation, position, targetPages, customRangeStr, onPreviewChange]);
}, [tab, text, imageDataUrl, imageDims, scale, fontFamily, fontSize, fontWeight, color, opacity, rotation, position, targetPages, customRangeStr, onPreviewChange]);
const parsePageIndices = (): { indices: number[]; error?: string } => {
if (targetPages === 'all') {
@@ -782,11 +833,20 @@ const WatermarkTab: React.FC<{
const handleApply = async () => {
if (isSubmitting || !onApplyWatermark) return;
const trimmedText = text.trim();
if (!trimmedText) {
setErrorMessage('Watermark text cannot be empty.');
return;
if (tab === 'text') {
const trimmedText = text.trim();
if (!trimmedText) {
setErrorMessage('Watermark text cannot be empty.');
return;
}
} else {
if (!imageDataUrl || !imageDims) {
setErrorMessage('Please upload an image file for the watermark.');
return;
}
}
const { indices, error } = parsePageIndices();
if (error || indices.length === 0) {
setErrorMessage(error || 'Invalid page range.');
@@ -796,21 +856,39 @@ const WatermarkTab: React.FC<{
setIsSubmitting(true);
setErrorMessage(null);
setSuccessMessage(null);
await onApplyWatermark(
{
text: trimmedText,
fontFamily,
fontSize,
fontWeight,
color,
opacity,
rotation,
position,
targetPages,
customRangeStr,
},
indices
);
const config: WatermarkConfig = tab === 'text' ? {
type: 'text',
text: text.trim(),
fontFamily,
fontSize,
fontWeight,
color,
opacity,
rotation,
position,
targetPages,
customRangeStr,
} : {
type: 'image',
text: '',
imageDataUrl,
imageWidth: imageDims?.width,
imageHeight: imageDims?.height,
scale,
fontFamily: 'Helvetica',
fontSize: 48,
fontWeight: 'normal',
color: '#000000',
opacity,
rotation,
position,
targetPages,
customRangeStr,
};
await onApplyWatermark(config, indices);
onPreviewChange?.(null);
setSuccessMessage(`Watermark added to ${indices.length} page(s)!`);
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err: any) {
@@ -839,7 +917,7 @@ const WatermarkTab: React.FC<{
<button
type="button"
onClick={() => setTab('text')}
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[12px] font-bold transition-all ${
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[12px] font-bold transition-all cursor-pointer ${
tab === 'text' ? 'bg-white text-brand-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
@@ -848,7 +926,7 @@ const WatermarkTab: React.FC<{
<button
type="button"
onClick={() => setTab('image')}
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[12px] font-bold transition-all ${
className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-[12px] font-bold transition-all cursor-pointer ${
tab === 'image' ? 'bg-white text-brand-primary shadow-sm' : 'text-text-secondary hover:text-text-primary'
}`}
>
@@ -953,104 +1031,199 @@ const WatermarkTab: React.FC<{
</div>
</div>
</div>
{/* Position (3x3 Grid) */}
<div className="flex flex-col gap-1.5">
<label className="text-[11.5px] font-bold text-text-primary">Position</label>
<div className="grid grid-cols-3 gap-1.5 w-full max-w-[210px] mx-auto p-2 bg-bg-secondary rounded-xl border border-border-primary">
{posGrid.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setPosition(p.id)}
className={`h-10 rounded-lg flex items-center justify-center transition-all cursor-pointer ${
position === p.id
? 'bg-brand-primary text-white shadow-md ring-2 ring-brand-primary/30'
: 'bg-white text-text-secondary hover:bg-bg-tertiary border border-border-primary'
}`}
>
<span className="h-2.5 w-2.5 rounded-full bg-current" />
</button>
))}
</div>
</div>
{/* Rotation & Transparency */}
<div className="grid grid-cols-2 gap-3 items-center pt-1">
<div className="flex flex-col gap-1">
<label className="text-[11.5px] font-bold text-text-primary">Rotation</label>
<select
value={rotation}
onChange={(e) => setRotation(Number(e.target.value))}
disabled={isSubmitting}
className="h-9 rounded-lg border border-border-primary bg-bg-primary px-2 text-[12px] font-medium text-text-primary outline-none transition-all focus:border-brand-primary focus:ring-1 focus:ring-brand-primary"
>
<option value={0}>Do not rotate</option>
<option value={45}>45 degrees</option>
<option value={90}>90 degrees</option>
<option value={180}>180 degrees</option>
<option value={270}>270 degrees</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11.5px] font-bold text-text-primary">Transparency</label>
<select
value={opacity}
onChange={(e) => setOpacity(Number(e.target.value))}
disabled={isSubmitting}
className="h-9 rounded-lg border border-border-primary bg-bg-primary px-2 text-[12px] font-medium text-text-primary outline-none transition-all focus:border-brand-primary focus:ring-1 focus:ring-brand-primary"
>
<option value={100}>No transparency</option>
<option value={75}>75%</option>
<option value={50}>50%</option>
<option value={25}>25%</option>
</select>
</div>
</div>
{/* Target Pages */}
<div className="flex flex-col gap-1 pt-1">
<label className="text-[11.5px] font-bold text-text-primary">Apply to Pages</label>
<select
value={targetPages}
onChange={(e) => setTargetPages(e.target.value as any)}
disabled={isSubmitting}
className="h-9 rounded-lg border border-border-primary bg-bg-primary px-2.5 text-[12px] font-medium text-text-primary outline-none"
>
<option value="all">All Pages ({totalPages})</option>
<option value="current">Current Page ({currentPage + 1})</option>
<option value="custom">Custom Page Range</option>
</select>
</div>
{targetPages === 'custom' && (
<input
type="text"
value={customRangeStr}
onChange={(e) => setCustomRangeStr(e.target.value)}
placeholder={`1-${totalPages}`}
className="h-8 w-full rounded-lg border border-border-primary bg-bg-primary px-2.5 text-[12px] text-text-primary outline-none"
/>
)}
{/* Primary Action Button */}
<button
type="button"
onClick={handleApply}
disabled={isSubmitting}
className="mt-3 flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-brand-primary text-white text-[13.5px] font-bold shadow-md hover:bg-brand-primary/95 active:scale-[0.99] transition-all cursor-pointer disabled:opacity-50"
>
{isSubmitting ? 'Adding Watermark...' : 'Add Watermark →'}
</button>
</>
) : (
<div className="flex flex-col items-center justify-center p-6 text-center text-text-tertiary">
<span className="text-3xl mb-2">🖼</span>
<p className="font-bold text-[13px] text-text-primary mb-1">Image Watermark</p>
<p className="text-[11.5px]">Upload an image logo or stamp to place across PDF pages.</p>
/* Image Watermark Controls */
<div className="flex flex-col gap-3">
{!imageDataUrl ? (
<label
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files?.[0];
if (file) processImageFile(file);
}}
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border-secondary rounded-xl bg-bg-secondary/50 hover:bg-bg-secondary hover:border-brand-primary cursor-pointer transition-all text-center gap-2"
>
<div className="h-10 w-10 rounded-full bg-brand-secondary/40 text-brand-primary flex items-center justify-center">
<ImageIcon size={22} />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[12.5px] font-bold text-text-primary">Upload Watermark Image</span>
<span className="text-[11px] text-text-tertiary">Click or drag & drop (PNG, JPG, WebP, SVG)</span>
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) processImageFile(f);
e.target.value = '';
}}
/>
</label>
) : (
<div className="flex items-center gap-3 p-3 bg-bg-secondary rounded-xl border border-border-primary">
<div className="h-14 w-14 shrink-0 rounded-lg border border-border-primary bg-white flex items-center justify-center p-1 overflow-hidden">
<img src={imageDataUrl} alt="Watermark logo" className="max-h-full max-w-full object-contain" />
</div>
<div className="flex flex-col flex-1 min-w-0 gap-1">
<span className="text-[12px] font-bold text-text-primary truncate">{imageFile?.name || 'Uploaded Image'}</span>
<span className="text-[11px] text-text-tertiary">
{imageDims ? `${imageDims.width} × ${imageDims.height} px` : ''}
</span>
<div className="flex items-center gap-2 pt-0.5">
<label className="text-[11px] font-semibold text-brand-primary hover:underline cursor-pointer">
Change
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) processImageFile(f);
e.target.value = '';
}}
/>
</label>
<span className="text-text-tertiary text-[10px]">|</span>
<button
type="button"
onClick={() => {
setImageFile(null);
setImageDataUrl(null);
setImageDims(null);
}}
className="text-[11px] font-semibold text-red-600 hover:underline cursor-pointer"
>
Remove
</button>
</div>
</div>
</div>
)}
{/* Image Scale Slider */}
<div className="flex flex-col gap-1.5 rounded-xl border border-border-primary bg-bg-secondary/40 p-3">
<div className="flex items-center justify-between">
<label className="text-[11.5px] font-bold text-text-primary">Image Scale</label>
<span className="text-[11px] font-bold text-brand-primary">{Math.round(scale * 100)}%</span>
</div>
<input
type="range"
min="0.1"
max="2.0"
step="0.05"
value={scale}
onChange={(e) => setScale(parseFloat(e.target.value))}
disabled={isSubmitting}
className="w-full h-1.5 bg-bg-tertiary rounded-lg appearance-none cursor-pointer accent-brand-primary"
/>
<div className="flex items-center justify-between text-[10px] text-text-tertiary">
<span>10%</span>
<span>50%</span>
<span>100%</span>
<span>200%</span>
</div>
</div>
</div>
)}
{/* Position (3x3 Grid) */}
<div className="flex flex-col gap-1.5">
<label className="text-[11.5px] font-bold text-text-primary">Position</label>
<div className="grid grid-cols-3 gap-1.5 w-full max-w-[210px] mx-auto p-2 bg-bg-secondary rounded-xl border border-border-primary">
{posGrid.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setPosition(p.id)}
className={`h-10 rounded-lg flex items-center justify-center transition-all cursor-pointer ${
position === p.id
? 'bg-brand-primary text-white shadow-md ring-2 ring-brand-primary/30'
: 'bg-white text-text-secondary hover:bg-bg-tertiary border border-border-primary'
}`}
>
<span className="h-2.5 w-2.5 rounded-full bg-current" />
</button>
))}
</div>
</div>
{/* Rotation & Transparency */}
<div className="grid grid-cols-2 gap-3 items-center pt-1">
<div className="flex flex-col gap-1">
<label className="text-[11.5px] font-bold text-text-primary">Rotation</label>
<select
value={rotation}
onChange={(e) => setRotation(Number(e.target.value))}
disabled={isSubmitting}
className="h-9 rounded-lg border border-border-primary bg-bg-primary px-2 text-[12px] font-medium text-text-primary outline-none transition-all focus:border-brand-primary focus:ring-1 focus:ring-brand-primary"
>
<option value={0}>Do not rotate (0°)</option>
<option value={-45}>-45 degrees (Diagonal)</option>
<option value={45}>45 degrees</option>
<option value={90}>90 degrees</option>
<option value={180}>180 degrees</option>
<option value={270}>270 degrees</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[11.5px] font-bold text-text-primary">Transparency</label>
<select
value={opacity}
onChange={(e) => setOpacity(Number(e.target.value))}
disabled={isSubmitting}
className="h-9 rounded-lg border border-border-primary bg-bg-primary px-2 text-[12px] font-medium text-text-primary outline-none transition-all focus:border-brand-primary focus:ring-1 focus:ring-brand-primary"
>
<option value={100}>No transparency (100%)</option>
<option value={75}>75%</option>
<option value={50}>50%</option>
<option value={25}>25%</option>
</select>
</div>
</div>
{/* Target Pages */}
<div className="flex flex-col gap-1 pt-1">
<label className="text-[11.5px] font-bold text-text-primary">Apply to Pages</label>
<select
value={targetPages}
onChange={(e) => setTargetPages(e.target.value as any)}
disabled={isSubmitting}
className="h-9 rounded-lg border border-border-primary bg-bg-primary px-2.5 text-[12px] font-medium text-text-primary outline-none"
>
<option value="all">All Pages ({totalPages})</option>
<option value="current">Current Page ({currentPage + 1})</option>
<option value="custom">Custom Page Range</option>
</select>
</div>
{targetPages === 'custom' && (
<input
type="text"
value={customRangeStr}
onChange={(e) => setCustomRangeStr(e.target.value)}
placeholder={`1-${totalPages}`}
className="h-8 w-full rounded-lg border border-border-primary bg-bg-primary px-2.5 text-[12px] text-text-primary outline-none"
/>
)}
{/* Primary Action Button */}
<button
type="button"
onClick={handleApply}
disabled={isSubmitting || (tab === 'image' && !imageDataUrl)}
className="mt-3 flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-brand-primary text-white text-[13.5px] font-bold shadow-md hover:bg-brand-primary/95 active:scale-[0.99] transition-all cursor-pointer disabled:opacity-50"
>
{isSubmitting
? 'Adding Watermark...'
: tab === 'image'
? 'Add Image Watermark →'
: 'Add Text Watermark →'}
</button>
</div>
);
};
+7 -1
View File
@@ -3,7 +3,12 @@ import { Modal, ColorSwatches, Slider } from './ui';
import { CustomButton } from './custom/CustomButton';
export interface WatermarkConfig {
type?: 'text' | 'image';
text: string;
imageDataUrl?: string | null;
imageWidth?: number;
imageHeight?: number;
scale?: number; // scale factor e.g. 0.5 (50%) or 1.0 (100%)
fontFamily: string;
fontSize: number;
fontWeight: string;
@@ -260,7 +265,8 @@ export const WatermarkModal: React.FC<WatermarkModalProps> = ({
disabled={isSubmitting}
className="h-8 rounded-md border border-border-primary bg-bg-primary px-2 text-[12px] font-medium text-text-primary outline-none"
>
<option value={0}>Do not rotate</option>
<option value={0}>Do not rotate (0°)</option>
<option value={-45}>-45 degrees (Diagonal)</option>
<option value={45}>45 degrees</option>
<option value={90}>90 degrees</option>
<option value={180}>180 degrees</option>
@@ -232,7 +232,15 @@ const EditableImageBlock: React.FC<{
{/* Floating Quick Action Toolbar */}
{isSelected && (
<div className="absolute -top-10 left-1/2 -translate-x-1/2 flex items-center gap-1 rounded-md bg-slate-900 px-2 py-1 text-white shadow-lg z-20 text-[11px]">
<div
className={`absolute -top-10 flex items-center gap-1 rounded-md bg-slate-900 px-2 py-1 text-white shadow-xl z-30 text-[11px] whitespace-nowrap max-w-none ${
block.alignment === 'right'
? 'right-0'
: block.alignment === 'left'
? 'left-0'
: 'left-1/2 -translate-x-1/2'
}`}
>
<button
type="button"
onClick={(e) => {
@@ -832,7 +840,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{/* Page Content Blocks */}
<div
className="flex flex-col gap-2 flex-1 overflow-hidden"
className="flex flex-col gap-2 flex-1 overflow-visible relative"
onClick={(e) => {
// Only trigger if the click landed directly on this container (the empty area below blocks)
if (e.target === e.currentTarget) {
+2
View File
@@ -260,6 +260,8 @@ export interface ImageOverlayData {
width: number;
height: number;
imageData: string;
opacity?: number;
rotation?: number;
}
export interface HighlightQuadPoint {
+42 -2
View File
@@ -756,6 +756,8 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(
)}
{(() => {
const isEditMode = activeTool === 'select' || activeTool === 'edit_text';
if (!isEditMode) return null;
const pageLayout = layoutDataByPage[page.index];
if (!pageLayout) return null;
const pageBlocks = pageLayout.blocks.length > 0
@@ -1105,7 +1107,12 @@ const WatermarkPreviewOverlay: React.FC<{
height: number;
zoom: number;
}> = ({ preview, width, height, zoom }) => {
if (!preview.text.trim()) return null;
const isImageMode = preview.type === 'image';
if (isImageMode) {
if (!preview.imageDataUrl) return null;
} else {
if (!preview.text.trim()) return null;
}
let justifyContent = 'center';
let alignItems = 'center';
@@ -1121,9 +1128,42 @@ const WatermarkPreviewOverlay: React.FC<{
padding: `${margin}px`,
};
const scaledFontSize = preview.fontSize * zoom;
const opacityVal = preview.opacity / 100.0;
if (isImageMode && preview.imageDataUrl) {
const scaleFactor = preview.scale ?? 0.5;
const baseW = (preview.imageWidth || 150) * scaleFactor * zoom;
const baseH = (preview.imageHeight || 150) * scaleFactor * zoom;
return (
<div
className="absolute inset-0 pointer-events-none z-30 flex overflow-hidden select-none"
style={{
width: `${width * zoom}px`,
height: `${height * zoom}px`,
justifyContent,
alignItems,
...style,
}}
>
<img
src={preview.imageDataUrl}
alt="Watermark preview"
className="transition-all duration-150 transform-gpu object-contain"
style={{
width: `${baseW}px`,
height: `${baseH}px`,
opacity: opacityVal,
transform: `rotate(${preview.rotation}deg)`,
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.2))',
}}
/>
</div>
);
}
const scaledFontSize = preview.fontSize * zoom;
return (
<div
className="absolute inset-0 pointer-events-none z-30 flex overflow-hidden select-none"
+13
View File
@@ -49,6 +49,8 @@ class ImageOverlayData(BaseModel):
width: float
height: float
imageData: str
opacity: float = 1.0
rotation: float = 0.0
class HighlightQuadPoint(BaseModel):
@@ -423,8 +425,15 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
img = Image.open(io.BytesIO(raw_bytes))
img_rgba = img.convert("RGBA")
opacity = float(op["data"].get("opacity", 1.0))
if opacity < 1.0:
r, g, b, a = img_rgba.split()
a = a.point(lambda p: int(round(p * opacity)))
img_rgba.putalpha(a)
r, g, b, a = img_rgba.split()
img_bgra = Image.merge("RGBA", (b, g, r, a))
op["data"]["opacity"] = 1.0
bgra_bytes = img_bgra.tobytes()
@@ -445,6 +454,10 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
if "imageData" in op["data"]:
del op["data"]["imageData"]
if op.get("type") in ("add_watermark", "watermark"):
if "data" in op and "rotation" in op["data"]:
op["data"]["rotation"] = -float(op["data"]["rotation"])
edits_json = json.dumps(req_dict)
doc_copy = pdfengine.PdfDocument.load_from_memory(doc_info["bytes_data"])
invalidated_regions = doc_copy.apply_edits(edits_json)
+45
View File
@@ -52,3 +52,48 @@ def test_watermark_operation(client: TestClient):
data = response.json()
assert data.get("success") is True
assert "newDocumentId" in data
def test_image_watermark_transparency_operation(client: TestClient):
import base64
import io
from PIL import Image
assert HELLO_WORLD_PDF.exists()
with open(HELLO_WORLD_PDF, "rb") as f:
upload_resp = client.post(
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
)
doc_id = upload_resp.json()["id"]
img = Image.new("RGBA", (10, 10), (255, 0, 0, 128))
buf = io.BytesIO()
img.save(buf, format="PNG")
png_b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
payload = {
"version": "1.0",
"operations": [
{
"id": "img_wm_001",
"type": "image_overlay",
"pageIndex": 0,
"data": {
"imageData": f"data:image/png;base64,{png_b64}",
"x": 50.0,
"y": 50.0,
"width": 100.0,
"height": 100.0,
"opacity": 0.5,
"rotation": 15.0,
},
}
],
}
response = client.post(f"/documents/{doc_id}/edits", json=payload)
assert response.status_code == 200
data = response.json()
assert data.get("success") is True
assert "newDocumentId" in data