From e1636e710d852253f57f333051bf360e33562c74 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Fri, 3 Apr 2026 13:00:43 +0200 Subject: [PATCH 01/10] Fix ServeConverter not returning document_json for chunking The remote converter never set document_json in ConversionResult, silently preventing chunking from working in remote mode. --- document-parser/infra/serve_converter.py | 3 +++ document-parser/tests/test_serve_converter.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/document-parser/infra/serve_converter.py b/document-parser/infra/serve_converter.py index 410e497..9f8f9bd 100644 --- a/document-parser/infra/serve_converter.py +++ b/document-parser/infra/serve_converter.py @@ -152,11 +152,14 @@ def _parse_response(data: dict) -> ConversionResult: page_count = len(pages) if pages else 1 + document_json = json.dumps(json_content) if json_content else None + return ConversionResult( page_count=page_count, content_markdown=content_md, content_html=content_html, pages=pages, + document_json=document_json, ) diff --git a/document-parser/tests/test_serve_converter.py b/document-parser/tests/test_serve_converter.py index dd4d4ab..4e8aef6 100644 --- a/document-parser/tests/test_serve_converter.py +++ b/document-parser/tests/test_serve_converter.py @@ -74,6 +74,7 @@ class TestParseResponse: assert result.content_html == "

Hello

" assert result.page_count == 1 assert result.pages[0].width == 612.0 + assert result.document_json is not None def test_response_with_elements(self): data = { @@ -159,6 +160,7 @@ class TestParseResponse: assert result.content_markdown == "text" assert result.pages == [] assert result.page_count == 1 + assert result.document_json is None def test_json_content_as_string(self): json_doc = { From 2cbef859e7740074d8708149148b48984cf12479 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Fri, 3 Apr 2026 13:01:50 +0200 Subject: [PATCH 02/10] Fix generate_page_images option silently dropped in remote mode The form data builder was not forwarding generate_page_images to Docling Serve, so page image generation was silently ignored. --- document-parser/infra/serve_converter.py | 1 + document-parser/tests/test_serve_converter.py | 1 + 2 files changed, 2 insertions(+) diff --git a/document-parser/infra/serve_converter.py b/document-parser/infra/serve_converter.py index 9f8f9bd..137a0a9 100644 --- a/document-parser/infra/serve_converter.py +++ b/document-parser/infra/serve_converter.py @@ -126,6 +126,7 @@ def _build_form_data(options: ConversionOptions) -> dict[str, str | list[str]]: "do_picture_classification": str(options.do_picture_classification).lower(), "do_picture_description": str(options.do_picture_description).lower(), "include_images": str(options.generate_picture_images).lower(), + "generate_page_images": str(options.generate_page_images).lower(), "images_scale": str(options.images_scale), } diff --git a/document-parser/tests/test_serve_converter.py b/document-parser/tests/test_serve_converter.py index 4e8aef6..6b0b9cb 100644 --- a/document-parser/tests/test_serve_converter.py +++ b/document-parser/tests/test_serve_converter.py @@ -32,6 +32,7 @@ class TestBuildFormData: assert data["do_picture_classification"] == "false" assert data["do_picture_description"] == "false" assert data["include_images"] == "false" + assert data["generate_page_images"] == "false" assert data["images_scale"] == "1.0" assert set(data["to_formats"]) == {"md", "html", "json"} From ac4a98e2046489203cb7c7b782a026c1aeb7c518 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Fri, 3 Apr 2026 13:02:07 +0200 Subject: [PATCH 03/10] Fix GHA cache collision between remote and local release builds Both matrix targets were sharing the same cache scope, causing overwrites and cache misses. Scope caches per target. --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a50c6ed..b58235a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,5 +55,5 @@ jobs: build-args: APP_VERSION=${{ steps.version.outputs.value }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=${{ matrix.target }} + cache-to: type=gha,mode=max,scope=${{ matrix.target }} From c28f734e7170de59e02ba677ffa813789c4e1947 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Fri, 3 Apr 2026 13:02:24 +0200 Subject: [PATCH 04/10] Add missing security headers to docker-compose nginx config Align frontend/nginx.conf with the root nginx.conf by adding X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, and Referrer-Policy headers. --- frontend/nginx.conf | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 515b230..e6bc5de 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,6 +4,12 @@ server { root /usr/share/nginx/html; index index.html; + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + location / { try_files $uri $uri/ /index.html; } From 8856daf3c96b57f834d4f191eec4b1090f2861ba Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Fri, 3 Apr 2026 13:03:21 +0200 Subject: [PATCH 05/10] Force .pdf extension on uploaded files regardless of user input The file content is already validated as PDF, so the user-supplied extension should not be trusted or written to disk. --- document-parser/services/document_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/document-parser/services/document_service.py b/document-parser/services/document_service.py index cba3d5c..bee24fb 100644 --- a/document-parser/services/document_service.py +++ b/document-parser/services/document_service.py @@ -32,7 +32,7 @@ async def upload(filename: str, content_type: str, file_content: bytes) -> Docum os.makedirs(UPLOAD_DIR, exist_ok=True) - ext = os.path.splitext(filename)[1] or ".pdf" + ext = ".pdf" # Content already validated as PDF safe_name = f"{uuid.uuid4()}{ext}" file_path = os.path.join(UPLOAD_DIR, safe_name) From 87269b9393b955f3249f1e0e790eb250dabc4703 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Fri, 3 Apr 2026 13:03:48 +0200 Subject: [PATCH 06/10] Add PDF type validation to file picker upload The drop handler validated PDF type but the file picker did not, allowing non-PDF files to bypass client-side validation. --- frontend/src/features/document/ui/DocumentUpload.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/document/ui/DocumentUpload.vue b/frontend/src/features/document/ui/DocumentUpload.vue index 26b664c..49613c7 100644 --- a/frontend/src/features/document/ui/DocumentUpload.vue +++ b/frontend/src/features/document/ui/DocumentUpload.vue @@ -42,7 +42,10 @@ function openFilePicker() { async function onFileSelect(e: Event) { const target = e.target as HTMLInputElement const file = target.files?.[0] - if (file) { + if ( + file && + (file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')) + ) { try { store.clearError() const doc = await store.upload(file) From dbcd9dfa4cfb1bc7535da0769e80e7ef2d30917f Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Fri, 3 Apr 2026 13:04:44 +0200 Subject: [PATCH 07/10] Fix naive datetime fallbacks in repository layer Use UTC-aware datetimes consistently to prevent TypeError when comparing aware and naive datetime objects. --- document-parser/persistence/analysis_repo.py | 4 ++-- document-parser/persistence/document_repo.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/document-parser/persistence/analysis_repo.py b/document-parser/persistence/analysis_repo.py index 0652e5b..9d6a7a7 100644 --- a/document-parser/persistence/analysis_repo.py +++ b/document-parser/persistence/analysis_repo.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import UTC, datetime from domain.models import AnalysisJob, AnalysisStatus from persistence.database import get_connection @@ -29,7 +29,7 @@ def _row_to_job(row) -> AnalysisJob: error_message=row["error_message"], started_at=_parse_dt(row["started_at"]), completed_at=_parse_dt(row["completed_at"]), - created_at=_parse_dt(row["created_at"]) or datetime.now(), + created_at=_parse_dt(row["created_at"]) or datetime.now(UTC), document_filename=row["filename"] if "filename" in keys else None, ) diff --git a/document-parser/persistence/document_repo.py b/document-parser/persistence/document_repo.py index 623e36a..04ac473 100644 --- a/document-parser/persistence/document_repo.py +++ b/document-parser/persistence/document_repo.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import UTC, datetime from domain.models import Document from persistence.database import get_connection @@ -12,6 +12,8 @@ def _row_to_document(row) -> Document: created = row["created_at"] if isinstance(created, str): created = datetime.fromisoformat(created) + if created.tzinfo is None: + created = created.replace(tzinfo=UTC) return Document( id=row["id"], filename=row["filename"], From afc9b2e9f2042b42b540f8401867845aff37d436 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Fri, 3 Apr 2026 13:05:00 +0200 Subject: [PATCH 08/10] Document CONVERSION_MODE build variable in .env.example Clarify the difference between CONVERSION_MODE (docker-compose build target) and CONVERSION_ENGINE (runtime variable set automatically by the Docker target). --- .env.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.env.example b/.env.example index 0821ea4..1e0a95d 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,8 @@ +# Docker Compose build target: "local" or "remote" (selects Dockerfile target) +# CONVERSION_MODE=local + # Conversion engine: "local" (in-process Docling) or "remote" (Docling Serve) +# Set automatically by the Docker target — override only for local dev # CONVERSION_ENGINE=local # Docling Serve settings (remote mode only) From 6a791cec4829c92b992344eff1ca15fd98bc7fd4 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Fri, 3 Apr 2026 13:05:44 +0200 Subject: [PATCH 09/10] Fix i18n placeholder replacement to handle repeated occurrences Use replaceAll instead of replace to substitute all instances of a placeholder in translation strings, not just the first one. --- frontend/src/shared/i18n.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts index 503967e..bdd9b09 100644 --- a/frontend/src/shared/i18n.ts +++ b/frontend/src/shared/i18n.ts @@ -233,7 +233,7 @@ export function useI18n() { function t(key: string, params: Record = {}): string { let str = messages[settings.locale]?.[key] || messages['fr'][key] || key for (const [k, v] of Object.entries(params)) { - str = str.replace(`{${k}}`, String(v)) + str = str.replaceAll(`{${k}}`, String(v)) } return str } From ab6d42aecd453c08f9ea198873be820a33764cd4 Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Fri, 3 Apr 2026 13:17:26 +0200 Subject: [PATCH 10/10] Move bbox.py from domain/ to infra/ and unify coordinate logic domain/ must be pure with no external dependencies. bbox.py imports docling_core and belongs in infra/. Also refactor ServeConverter to use the canonical to_topleft_list via BoundingBox instead of duplicated manual coordinate conversion. Move docling-core to base requirements since it is now needed in both modes. --- README.md | 4 +-- docs/architecture.md | 2 +- docs/bbox-pipeline.md | 2 +- document-parser/{domain => infra}/bbox.py | 0 document-parser/infra/local_chunker.py | 2 +- document-parser/infra/local_converter.py | 2 +- document-parser/infra/serve_converter.py | 31 +++++++++++++---------- document-parser/requirements-local.txt | 1 - document-parser/requirements.txt | 1 + document-parser/tests/test_bbox.py | 2 +- 10 files changed, 25 insertions(+), 22 deletions(-) rename document-parser/{domain => infra}/bbox.py (100%) diff --git a/README.md b/README.md index fda0a1a..cee5b1f 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,8 @@ document-parser/ ├── main.py # FastAPI app, CORS, lifespan ├── domain/ # Pure domain — no HTTP, no DB │ ├── models.py # Document, AnalysisJob dataclasses -│ ├── parsing.py # Docling conversion & page extraction -│ └── bbox.py # Bounding box coordinate normalization +│ ├── ports.py # Abstract protocols (converter, chunker) +│ └── value_objects.py # ConversionResult, PageDetail, ChunkResult ├── api/ # HTTP layer (FastAPI routers) │ ├── schemas.py # Pydantic DTOs (camelCase serialization) │ ├── documents.py # /api/documents endpoints diff --git a/docs/architecture.md b/docs/architecture.md index 9cfcade..f51795f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,7 +68,7 @@ document-parser/ | Layer | Role | Depends on | |-------|------|------------| -| **domain** | Dataclasses, bbox math, Docling conversion | Nothing (pure Python) | +| **domain** | Dataclasses, value objects, ports | Nothing (pure Python) | | **persistence** | SQLite CRUD, aiosqlite | domain (models) | | **services** | Orchestrate use cases, call Docling | domain + persistence | | **api** | HTTP endpoints, Pydantic DTOs, error handling | services | diff --git a/docs/bbox-pipeline.md b/docs/bbox-pipeline.md index b0be9e1..4f2e0fe 100644 --- a/docs/bbox-pipeline.md +++ b/docs/bbox-pipeline.md @@ -38,7 +38,7 @@ The frontend converts PDF points to CSS pixels, then the canvas renders at `devi ## Transformation 1 — `to_topleft_list()` -**File:** `document-parser/domain/bbox.py` +**File:** `document-parser/infra/bbox.py` Normalizes any Docling bbox to `[left, top, right, bottom]` in TOPLEFT coordinates. diff --git a/document-parser/domain/bbox.py b/document-parser/infra/bbox.py similarity index 100% rename from document-parser/domain/bbox.py rename to document-parser/infra/bbox.py diff --git a/document-parser/infra/local_chunker.py b/document-parser/infra/local_chunker.py index 500b691..1b59406 100644 --- a/document-parser/infra/local_chunker.py +++ b/document-parser/infra/local_chunker.py @@ -15,7 +15,7 @@ from docling_core.transforms.chunker import HierarchicalChunker from docling_core.transforms.chunker.hybrid_chunker import HybridChunker from docling_core.types.doc.document import DoclingDocument -from domain.bbox import EMPTY_BBOX, to_topleft_list +from infra.bbox import EMPTY_BBOX, to_topleft_list from domain.value_objects import ChunkBbox, ChunkingOptions, ChunkResult logger = logging.getLogger(__name__) diff --git a/document-parser/infra/local_converter.py b/document-parser/infra/local_converter.py index 9e0b23a..765f954 100644 --- a/document-parser/infra/local_converter.py +++ b/document-parser/infra/local_converter.py @@ -35,7 +35,7 @@ from docling_core.types.doc import ( TitleItem, ) -from domain.bbox import to_topleft_list +from infra.bbox import to_topleft_list from domain.value_objects import ( ConversionOptions, ConversionResult, diff --git a/document-parser/infra/serve_converter.py b/document-parser/infra/serve_converter.py index 137a0a9..a6a0502 100644 --- a/document-parser/infra/serve_converter.py +++ b/document-parser/infra/serve_converter.py @@ -18,6 +18,7 @@ import mimetypes from pathlib import Path import httpx +from docling_core.types.doc.base import BoundingBox, CoordOrigin from domain.value_objects import ( ConversionOptions, @@ -25,6 +26,7 @@ from domain.value_objects import ( PageDetail, PageElement, ) +from infra.bbox import to_topleft_list logger = logging.getLogger(__name__) @@ -222,21 +224,22 @@ def _add_element(item: dict, pages: dict[int, PageDetail]) -> None: def _extract_bbox(bbox_data: dict, page_height: float) -> list[float]: - """Extract and normalize bbox to TOPLEFT [l, t, r, b] format.""" + """Extract and normalize bbox to TOPLEFT [l, t, r, b] format. + + Delegates to the canonical to_topleft_list function via a docling-core + BoundingBox, ensuring consistent coordinate handling across all converters. + """ if not isinstance(bbox_data, dict): return [0.0, 0.0, 0.0, 0.0] - left = bbox_data.get("l", 0.0) - top = bbox_data.get("t", 0.0) - right = bbox_data.get("r", 0.0) - bottom = bbox_data.get("b", 0.0) - coord_origin = bbox_data.get("coord_origin", "TOPLEFT") + origin_str = bbox_data.get("coord_origin", "TOPLEFT") + origin = CoordOrigin.BOTTOMLEFT if origin_str == "BOTTOMLEFT" else CoordOrigin.TOPLEFT - if coord_origin == "BOTTOMLEFT": - # In BOTTOMLEFT: top has higher y, bottom has lower y - # In TOPLEFT: flip both — new_top = page_height - old_top - new_top = page_height - top - new_bottom = page_height - bottom - top, bottom = new_top, new_bottom - - return [left, top, right, bottom] + bbox = BoundingBox( + l=bbox_data.get("l", 0.0), + t=bbox_data.get("t", 0.0), + r=bbox_data.get("r", 0.0), + b=bbox_data.get("b", 0.0), + coord_origin=origin, + ) + return to_topleft_list(bbox, page_height) diff --git a/document-parser/requirements-local.txt b/document-parser/requirements-local.txt index 82cf543..fc2f3c7 100644 --- a/document-parser/requirements-local.txt +++ b/document-parser/requirements-local.txt @@ -1,3 +1,2 @@ -r requirements.txt docling>=2.80.0,<3.0.0 -docling-core>=2.0.0,<3.0.0 diff --git a/document-parser/requirements.txt b/document-parser/requirements.txt index 24aeedb..92dcf4c 100644 --- a/document-parser/requirements.txt +++ b/document-parser/requirements.txt @@ -1,3 +1,4 @@ +docling-core>=2.0.0,<3.0.0 fastapi>=0.115.0,<1.0.0 uvicorn[standard]>=0.32.0,<1.0.0 python-multipart>=0.0.12 diff --git a/document-parser/tests/test_bbox.py b/document-parser/tests/test_bbox.py index f201a73..8d3694f 100644 --- a/document-parser/tests/test_bbox.py +++ b/document-parser/tests/test_bbox.py @@ -8,7 +8,7 @@ misaligned overlays in the UI. import pytest from docling_core.types.doc.base import BoundingBox, CoordOrigin -from domain.bbox import EMPTY_BBOX, to_topleft_list +from infra.bbox import EMPTY_BBOX, to_topleft_list # --------------------------------------------------------------------------- # Standard conversions