diff --git a/docs/design/195-copy-paste-image-verify-mode.md b/docs/design/195-copy-paste-image-verify-mode.md new file mode 100644 index 0000000..57e227e --- /dev/null +++ b/docs/design/195-copy-paste-image-verify-mode.md @@ -0,0 +1,404 @@ +# Design: Copy paste image in Verify mode + + + +- **Issue:** #195 +- **Title on issue:** [ENHANCEMENT] Copy paste image in Verify mode +- **Author:** Pier-Jean Malandrino +- **Date:** 2026-04-23 +- **Status:** Accepted +- **Target milestone:** 0.5.0 +- **Impacted layers:** · | shared | app> · · +- **Audit dimensions likely touched:** +- **ADR spawned?:** *(write an ADR when choosing a library, moving a boundary, or deciding **not** to do something — see `docs/architecture/adr-guide.md`)* + +--- + +## 1. Problem + + + +TODO: Why this issue exists. Link the user story, incident, or upstream discussion that motivates it. + +Today in Verify mode regarding image handling: TODO — describe the current baseline (upload-only? no paste target? no drag-drop?). + +## 2. Goals + + + +Users should be able to copy/paste images directly into Verify mode (e.g. from clipboard) instead of only via file upload. + +- [ ] Define paste source (OS clipboard, drag-drop, screenshot) +- [ ] Define target area in Verify mode UI +- [ ] Define supported image formats and size limits +- [ ] Size / type limits are env-var configurable, carry sane defaults, and are documented in `README.md` + `docs/deployment/*` (e.g. `MAX_PASTE_IMAGE_SIZE_MB`, `PASTE_ALLOWED_IMAGE_TYPES`) + +## 3. Non-goals + + + +- ... +- ... + +## 4. Context & constraints + + + +### SQLite & storage limits (must be enforced upstream of the DB) + +Pasted images follow the existing `documents` pattern: bytes land on disk +under `UPLOAD_DIR`, the row stores `storage_path: TEXT`. We do **not** +store base64 or BLOBs. Even so, app-level size guards must stay below +SQLite's structural limits so a malformed request can never wedge the +engine. + +Relevant SQLite defaults (see https://www.sqlite.org/limits.html): + +| SQLite limit | Default | Relevance | +|---|---|---| +| `SQLITE_MAX_LENGTH` | 1 GB (1 × 10⁹ bytes) | Max size of any single `TEXT` / `BLOB` cell. Irrelevant as long as we keep bytes off the DB. | +| `SQLITE_MAX_SQL_LENGTH` | 1 MB | Max length of an SQL statement incl. inlined literals. Always use parameter binding — never inline image bytes. | +| Page cache / WAL growth | n/a | Large writes bloat WAL until checkpoint; another reason to stay off-DB. | + +Our own app-level limits guard against ever reaching those ceilings. +All such limits **must**: (1) carry a sane default in `infra/settings.py`, +(2) be overridable via env var, (3) be documented in `README.md` and +`docs/deployment/*`. This is consistent with how `MAX_FILE_SIZE_MB` +(default 50) is handled today. + +## 5. Proposed design + + + +### 5.1 Domain + +### 5.2 Persistence + +### 5.3 Infra adapters + +Extend `document-parser/infra/settings.py` with paste-specific limits. +Follow the existing `MAX_FILE_SIZE_MB` pattern: typed field on the +settings dataclass, `os.environ.get(...)` with a string default, cast +at load time. + +| Setting | Env var | Default | Allowed | Notes | +|---|---|---|---|---| +| `max_paste_image_size_mb` | `MAX_PASTE_IMAGE_SIZE_MB` | `10` | positive int, `0` = unlimited | Must be ≤ `MAX_FILE_SIZE_MB`; upload validator rejects larger payloads before any DB write. | +| `paste_allowed_image_types` | `PASTE_ALLOWED_IMAGE_TYPES` | `image/png,image/jpeg,image/webp` | comma-separated MIME list | Enforced server-side; frontend uses the same list via `/api/health`. | + +Validation happens in the API layer (upload handler) **before** the +bytes reach persistence. Any future move to BLOB storage would still +rely on these guards — they are the contract that prevents us ever +approaching `SQLITE_MAX_LENGTH`. + +### 5.4 Services + +### 5.5 API + +### 5.6 Frontend — feature module + +### 5.7 Cross-cutting (feature flags, i18n, shared types) + +## 6. Alternatives considered + + + +### Alternative A — + +- **Summary:** +- **Why not:** + +### Alternative B — + +- **Summary:** +- **Why not:** + +## 7. API & data contract + + + +## 8. Risks & mitigations + + + +| Risk | Audit dimension | Likelihood | Impact | How we notice | Mitigation / rollback | +|------|-----------------|------------|--------|---------------|------------------------| +| | | | | | | + +## 9. Testing strategy + + + +## 10. Rollout & observability + + + +## 11. Open questions + + + +- ... +- ... + +## 12. References + + + +- **Issue:** https://github.com/scub-france/Docling-Studio/issues/195 +- **Related PRs / commits:** +- **ADRs:** +- **Project docs:** + - Architecture: `docs/architecture.md` + - Coding standards: `docs/architecture/coding-standards.md` + - ADR guide / template: `docs/architecture/adr-guide.md`, `docs/architecture/adr-template.md` + - Audit master: `docs/audit/master.md` + - E2E conventions: `e2e/CONVENTIONS.md` +- **External:** diff --git a/document-parser/api/schemas.py b/document-parser/api/schemas.py index 2b4a1b1..aae926c 100644 --- a/document-parser/api/schemas.py +++ b/document-parser/api/schemas.py @@ -34,6 +34,8 @@ class HealthResponse(_CamelModel): database: str max_page_count: int | None = None max_file_size_mb: int | None = None + max_paste_image_size_mb: int | None = None + paste_allowed_image_types: list[str] = Field(default_factory=list) ingestion_available: bool = False diff --git a/document-parser/infra/settings.py b/document-parser/infra/settings.py index b114045..6b02cd6 100644 --- a/document-parser/infra/settings.py +++ b/document-parser/infra/settings.py @@ -32,6 +32,10 @@ class Settings: embedding_dimension: int = 384 # Granite Embedding 30M / all-MiniLM-L6-v2 upload_dir: str = "./uploads" db_path: str = "./data/docling_studio.db" + max_paste_image_size_mb: int = 10 # clipboard-paste image limit in MB (0 = unlimited) + paste_allowed_image_types: list[str] = field( + default_factory=lambda: ["image/png", "image/jpeg", "image/webp"] + ) cors_origins: list[str] = field( default_factory=lambda: ["http://localhost:3000", "http://localhost:5173"] ) @@ -54,6 +58,12 @@ class Settings: errors.append(f"max_file_size must be >= 0 (got {self.max_file_size})") if self.max_file_size_mb < 0: errors.append(f"max_file_size_mb must be >= 0 (got {self.max_file_size_mb})") + if self.max_paste_image_size_mb < 0: + errors.append( + f"max_paste_image_size_mb must be >= 0 (got {self.max_paste_image_size_mb})" + ) + if not self.paste_allowed_image_types: + errors.append("paste_allowed_image_types must not be empty") if self.rate_limit_rpm < 0: errors.append(f"rate_limit_rpm must be >= 0 (got {self.rate_limit_rpm})") if self.batch_page_size < 0: @@ -87,6 +97,9 @@ class Settings: def from_env(cls) -> Settings: """Build a Settings instance from environment variables.""" cors_raw = os.environ.get("CORS_ORIGINS", "http://localhost:3000,http://localhost:5173") + paste_types_raw = os.environ.get( + "PASTE_ALLOWED_IMAGE_TYPES", "image/png,image/jpeg,image/webp" + ) return cls( app_version=os.environ.get("APP_VERSION", "dev"), conversion_engine=os.environ.get("CONVERSION_ENGINE", "local"), @@ -112,6 +125,8 @@ class Settings: embedding_dimension=int(os.environ.get("EMBEDDING_DIMENSION", "384")), upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"), db_path=os.environ.get("DB_PATH", "./data/docling_studio.db"), + max_paste_image_size_mb=int(os.environ.get("MAX_PASTE_IMAGE_SIZE_MB", "10")), + paste_allowed_image_types=[t.strip() for t in paste_types_raw.split(",") if t.strip()], cors_origins=[o.strip() for o in cors_raw.split(",")], ) diff --git a/document-parser/main.py b/document-parser/main.py index b12f832..dd698ad 100644 --- a/document-parser/main.py +++ b/document-parser/main.py @@ -236,5 +236,9 @@ async def health() -> HealthResponse: database=db_status, max_page_count=settings.max_page_count if settings.max_page_count > 0 else None, max_file_size_mb=settings.max_file_size_mb if settings.max_file_size_mb > 0 else None, + max_paste_image_size_mb=( + settings.max_paste_image_size_mb if settings.max_paste_image_size_mb > 0 else None + ), + paste_allowed_image_types=settings.paste_allowed_image_types, ingestion_available=getattr(app.state, "ingestion_service", None) is not None, ) diff --git a/document-parser/tests/test_settings.py b/document-parser/tests/test_settings.py index 4326790..9525c51 100644 --- a/document-parser/tests/test_settings.py +++ b/document-parser/tests/test_settings.py @@ -18,6 +18,8 @@ class TestSettingsDefaults: assert s.lock_timeout == 300 assert s.max_page_count == 0 assert s.max_file_size_mb == 50 + assert s.max_paste_image_size_mb == 10 + assert s.paste_allowed_image_types == ["image/png", "image/jpeg", "image/webp"] assert s.batch_page_size == 0 assert s.opensearch_default_limit == 1000 assert s.upload_dir == "./uploads" @@ -76,6 +78,18 @@ class TestSettingsValidation: with pytest.raises(ValueError, match="max_file_size_mb must be >= 0"): Settings(max_file_size_mb=-1) + def test_negative_max_paste_image_size_mb_rejected(self): + import pytest + + with pytest.raises(ValueError, match="max_paste_image_size_mb must be >= 0"): + Settings(max_paste_image_size_mb=-1) + + def test_empty_paste_allowed_image_types_rejected(self): + import pytest + + with pytest.raises(ValueError, match="paste_allowed_image_types must not be empty"): + Settings(paste_allowed_image_types=[]) + def test_zero_max_file_size_mb_accepted(self): s = Settings(max_file_size_mb=0) assert s.max_file_size_mb == 0