fix the watermark issue
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -260,6 +260,8 @@ export interface ImageOverlayData {
|
||||
width: number;
|
||||
height: number;
|
||||
imageData: string;
|
||||
opacity?: number;
|
||||
rotation?: number;
|
||||
}
|
||||
|
||||
export interface HighlightQuadPoint {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user