diff --git a/CHANGELOG.md b/CHANGELOG.md index 03db407..41e56d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Added +- **Magazine import from PNG/JPEG page images.** Choose or drop several scans or phone photos; they are checked by content (not by name), ordered by filename with natural numbering, turned into one scan-style PDF (page size from the image's own resolution, EXIF rotation applied, 72 dpi treated as unknown) and ingested like any scanned issue. Limits: 300 images, 25 MB and 60 megapixels each; a bad image rejects the whole upload and leaves nothing behind; an existing source is never replaced. Verified on real pages: OCR through the image path scored like the original PDF (F1 0.85 / 0.78). - `architecture/ocr-porting-prompt-general.md`: the OCR porting brief rewritten for general documents (letters, papers, forms, invoices). Adds privacy, pre-processing of skewed/photographed scans, document-type handling, field-level accuracy and handwriting, and tags every claim as measured on magazines, a general principle, or untested for these document types. --- diff --git a/Manual.md b/Manual.md index bc7b3fe..b23d4ea 100644 --- a/Manual.md +++ b/Manual.md @@ -77,7 +77,7 @@ turned into a podcast discussion between two or three voices you pick. ### Prerequisites -- The magazine as a PDF (native text or scanned — both work). +- The magazine as a PDF (native text or scanned — both work), **or** its pages as PNG/JPEG images (scans or phone photos): choose or drop several at once and they become one magazine, pages in filename order (`page2` before `page10`). Up to 300 images of at most 25 MB each; a photo's EXIF rotation is applied. Images carry no text, so every page is read with OCR. Do not mix a PDF with images. - At least one voice in the library for narration. - For podcast mode: an LLM configured in Settings (`llm_url`, `llm_model`), and two or three voices chosen as hosts. diff --git a/TODO.md b/TODO.md index 49d5ee8..22f550f 100644 --- a/TODO.md +++ b/TODO.md @@ -656,7 +656,7 @@ casting. The original PDF remains the visual source of truth while approved arti ### P0 — Magazine foundation - [x] Add a Magazine project type beside Books and Theater Plays. -- [~] Add PDF/JPG/PNG import with page previews and safe local storage. *(PDF + page renders done; JPG/PNG not done)* +- [x] Add PDF/JPG/PNG import with page previews and safe local storage. *(2026-09-29: JPG/PNG page images become one scan-style PDF, see architecture/magazine-ingestion.md)* - [x] Add OCR-engine selection from Engines > OCR & Document Parsing, with page-image input and structured output. - [x] Detect contents/index pages and propose article titles, authors, and page ranges. - [x] Build a review screen where users approve or correct article boundaries before TTS. diff --git a/architecture/magazine-ingestion.md b/architecture/magazine-ingestion.md index 93fa82f..9bbee00 100644 --- a/architecture/magazine-ingestion.md +++ b/architecture/magazine-ingestion.md @@ -448,6 +448,17 @@ Verified end-to-end against a synthetic 8-page issue (contents page, two-column feature with a byline and a page-8 jump, full-page advert, second feature, reviews) and against an image-only rendering of the same file to exercise OCR. +## Import from page images + +`POST /api/magazine/{id}/source-images` (multipart `files`, in the order sent) builds `source.pdf` with +`images_to_pdf`, so nothing downstream changes: the PDF has no text layer and every page takes the OCR path. +Content is sniffed from the header (PNG/JPEG), never from the name or declared type; limits are 300 files, 25 MB and +60 megapixels per image (checked before decoding); EXIF orientation is applied (an unrotated JPEG is embedded +byte-for-byte, not re-encoded). Page size comes from the image's dpi; 72 and anything below 100 is the placeholder most +tools write, so it is treated as unknown (150 assumed) rather than producing a 33-inch page. The PDF is written through a +temporary file, one bad image rejects the whole upload, and a magazine that already has a source returns 409 so pages that +were reviewed can never be replaced. The browser orders files by name with numeric collation. + ## Vision OCR escalation Engines → OCR & Document Parsing lists layout-aware parsers (KDL Frontier Parser diff --git a/core/magazine.py b/core/magazine.py index bb954d5..8cc22f1 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -2367,6 +2367,81 @@ def ocr_page(png_path: Path, lang: str = "deu+eng", return out +# ── Image import ───────────────────────────────────────────────────────────── + +IMAGE_IMPORT_MAX_FILES = 300 +IMAGE_IMPORT_MAX_BYTES = 25 * 1024 * 1024 # per image +IMAGE_IMPORT_MAX_PIXELS = 60_000_000 # refuses decompression bombs before decoding +_ASSUMED_SCAN_DPI = 150 # when an image carries no (or an absurd) resolution + + +class ImageImportError(ValueError): + """An uploaded page image was rejected; the message is safe to show the user.""" + + +def _sniff_image(data: bytes) -> str | None: + """'png' or 'jpeg' judged by the file's own header, never by its name or declared type.""" + if data[:8] == b"\x89PNG\r\n\x1a\n": + return "png" + if data[:3] == b"\xff\xd8\xff": + return "jpeg" + return None + + +def images_to_pdf(images: Sequence[tuple[str, bytes]], dest: Path) -> int: + """Combine page images (PNG/JPEG, in the given order) into one PDF at `dest`; returns the page count. + + Each image becomes one page sized from its own resolution (so the app's fixed-dpi render of the + page comes out at the scan's real proportions), with EXIF rotation applied. The result has no text + layer, so ingestion OCRs every page exactly as for a scanned PDF. Nothing is written on failure. + """ + if not images: + raise ImageImportError("Choose at least one image") + if len(images) > IMAGE_IMPORT_MAX_FILES: + raise ImageImportError(f"At most {IMAGE_IMPORT_MAX_FILES} images per magazine") + import io + import pymupdf + from PIL import Image, ImageOps + + doc = pymupdf.open() + try: + for name, data in images: + label = Path(name or "image").name[:80] + if len(data) > IMAGE_IMPORT_MAX_BYTES: + raise ImageImportError(f"{label} is larger than {IMAGE_IMPORT_MAX_BYTES // (1024 * 1024)} MB") + kind = _sniff_image(data) + if kind is None: + raise ImageImportError(f"{label} is not a PNG or JPEG image") + try: + with Image.open(io.BytesIO(data)) as im: + width, height = im.size + if width * height > IMAGE_IMPORT_MAX_PIXELS: + raise ImageImportError(f"{label} has too many pixels") + dpi = im.info.get("dpi", (0, 0))[0] or 0 + if im.getexif().get(0x0112, 1) not in (0, 1): # EXIF says the pixels are rotated/flipped + upright = ImageOps.exif_transpose(im) + buf = io.BytesIO() + upright.convert("RGB" if kind == "jpeg" else upright.mode).save( + buf, format="JPEG" if kind == "jpeg" else "PNG", **({"quality": 95} if kind == "jpeg" else {})) + data = buf.getvalue() + width, height = upright.size + except ImageImportError: + raise + except Exception as e: # truncated / corrupt file + raise ImageImportError(f"{label} could not be read as an image") from e + # 72 (and anything below 100) is the placeholder most tools write, not a real scan resolution. + dpi = float(dpi) if 100 <= float(dpi or 0) <= 1200 else _ASSUMED_SCAN_DPI + w_pt, h_pt = width * 72.0 / dpi, height * 72.0 / dpi + page = doc.new_page(width=w_pt, height=h_pt) + page.insert_image(pymupdf.Rect(0, 0, w_pt, h_pt), stream=data) + tmp = dest.with_suffix(".part") + doc.save(str(tmp)) + tmp.replace(dest) + return len(images) + finally: + doc.close() + + _VISION_QUALITY_THRESHOLD = 0.5 # below this, existing native/OCR text is escalated diff --git a/routes/magazine.py b/routes/magazine.py index a12d1d4..3225501 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -31,7 +31,7 @@ import zipfile from pathlib import Path from typing import Any -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, File, HTTPException, Request, UploadFile from fastapi.responses import FileResponse, Response from core import magazine as mag @@ -121,6 +121,29 @@ async def magazine_create(request: Request): return {"id": doc_id} +@router.post("/api/magazine/{doc_id}/source-images") +async def magazine_source_images(doc_id: str, files: list[UploadFile] = File(...)): + """Build the magazine's source.pdf from uploaded page images (PNG/JPEG, in the order sent). + + Ingestion then treats it as any scanned PDF. Refused when a source already exists, so an upload can + never silently replace pages that were already reviewed. + """ + d, _ = _require_magazine(doc_id) + if (d / "source.pdf").exists(): + raise HTTPException(409, "This magazine already has a source file") + if len(files) > mag.IMAGE_IMPORT_MAX_FILES: + raise HTTPException(400, f"At most {mag.IMAGE_IMPORT_MAX_FILES} images per magazine") + images: list[tuple[str, bytes]] = [] + for f in files: + data = await f.read(mag.IMAGE_IMPORT_MAX_BYTES + 1) + images.append((f.filename or "image", data)) + try: + pages = await asyncio.to_thread(mag.images_to_pdf, images, d / "source.pdf") + except mag.ImageImportError as e: + raise HTTPException(400, str(e)) + return {"ok": True, "pages": pages} + + @router.patch("/api/magazine/{doc_id}") async def magazine_update_meta(doc_id: str, request: Request): """Edit title/issue/publisher after creation -- there was previously no diff --git a/static/js/magazine.js b/static/js/magazine.js index a3800d0..c6d7b6d 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -230,7 +230,7 @@ async function magDelete(docId) { } // ── Add-magazine dropzone (mirrors reader.js's setupReaderDropzone) ───────── -const MAG_DROPZONE_DEFAULT_NOTE = 'Scanned issues work too — pages without selectable text are read with OCR.'; +const MAG_DROPZONE_DEFAULT_NOTE = 'Scanned issues and phone photos work too — pages without selectable text are read with OCR. Several images are read in filename order.'; function magFormatFilenameAsTitle(filename) { if (!filename) return ''; @@ -241,6 +241,25 @@ function magFormatFilenameAsTitle(filename) { .trim(); } +// What was chosen or dropped: exactly one PDF, or several page images (PNG/JPEG) read in filename order. +// Returns { kind, files, label: {name, size} } or { error }. The server re-checks content by its bytes. +function magClassifyFiles(list) { + const files = Array.from(list || []).filter(Boolean); + if (!files.length) return { error: 'Choose or drop a PDF or page images first' }; + const isPdf = (f) => f.type === 'application/pdf' || /\.pdf$/i.test(f.name); + const isImg = (f) => /^image\/(png|jpeg)$/.test(f.type) || /\.(png|jpe?g)$/i.test(f.name); + if (files.every(isPdf)) { + if (files.length > 1) return { error: 'Choose one PDF, or several page images — not several PDFs' }; + return { kind: 'pdf', files, label: files[0] }; + } + if (files.every(isImg)) { + files.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })); + const size = files.reduce((n, f) => n + f.size, 0); + return { kind: 'images', files, label: { name: files.length === 1 ? files[0].name : files.length + ' page images', size } }; + } + return { error: 'Only a PDF, or PNG/JPEG page images, can be imported' }; +} + function magShowDroppedFile(file) { const noteEl = $('mag-dropzone-note'); const titleEl = $('mag-dropzone-title'); @@ -252,7 +271,7 @@ function magShowDroppedFile(file) { const sizeMb = (file.size / (1024 * 1024)).toFixed(1); if (dropzone) dropzone.classList.add('is-selected'); if (noteEl) noteEl.textContent = `Selected: ${file.name} (${sizeMb} MB)`; - if (titleEl) titleEl.textContent = 'PDF file selected — uploading...'; + if (titleEl) titleEl.textContent = 'File selected — uploading...'; if (iconEl) iconEl.className = 'mdi mdi-file-check-outline reader-dropzone-icon'; // Auto-fill title from filename if title input is empty @@ -262,7 +281,7 @@ function magShowDroppedFile(file) { } else { if (dropzone) dropzone.classList.remove('is-selected', 'is-uploading'); if (noteEl) noteEl.textContent = MAG_DROPZONE_DEFAULT_NOTE; - if (titleEl) titleEl.textContent = 'Drop a PDF document'; + if (titleEl) titleEl.textContent = 'Drop a PDF or page images'; if (iconEl) iconEl.className = 'mdi mdi-cloud-upload-outline reader-dropzone-icon'; } } @@ -330,30 +349,29 @@ function setupMagDropzone() { dragCounter = 0; zone.classList.remove('dragover'); - let file = e.dataTransfer?.files?.[0]; - if (!file && e.dataTransfer?.items?.[0]) { - file = e.dataTransfer.items[0].getAsFile(); + let dropped = Array.from(e.dataTransfer?.files || []); + if (!dropped.length && e.dataTransfer?.items?.length) { + dropped = Array.from(e.dataTransfer.items).map((i) => i.getAsFile()).filter(Boolean); } - if (!file) return; - if (file.type !== 'application/pdf' && !/\.pdf$/i.test(file.name)) { - return say('Only PDF files are supported', 'err'); - } - magShowDroppedFile(file); - magCreate(file); + const picked = magClassifyFiles(dropped); + if (picked.error) return say(picked.error, 'err'); + magShowDroppedFile(picked.label); + magCreate(picked.files); }); input.addEventListener('change', () => { - const file = input.files?.[0]; - if (file) { - magShowDroppedFile(file); - magCreate(file); - } + if (!input.files?.length) return; + const picked = magClassifyFiles(input.files); + if (picked.error) return say(picked.error, 'err'); + magShowDroppedFile(picked.label); + magCreate(picked.files); }); } -async function magCreate(droppedFile = null) { - const file = droppedFile || $('mag-new-file')?.files?.[0]; - if (!file) return say('Choose or drop a PDF file first', 'err'); +async function magCreate(dropped = null) { + const picked = magClassifyFiles(dropped ? (Array.isArray(dropped) ? dropped : [dropped]) : $('mag-new-file')?.files); + if (picked.error) return say(picked.error, 'err'); + const file = picked.label; let title = ($('mag-new-title')?.value || '').trim(); if (!title) { @@ -418,7 +436,13 @@ async function magCreate(droppedFile = null) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, issue }), }); - await api('/api/reader/docs/' + id + '/source?ext=pdf', { method: 'PUT', body: file }); + if (picked.kind === 'pdf') { + await api('/api/reader/docs/' + id + '/source?ext=pdf', { method: 'PUT', body: picked.files[0] }); + } else { + const form = new FormData(); + picked.files.forEach((f) => form.append('files', f, f.name)); + await api('/api/magazine/' + id + '/source-images', { method: 'POST', body: form }); + } say('Magazine uploaded — starting ingestion', 'ok'); if ($('mag-new-title')) $('mag-new-title').value = ''; diff --git a/static/sections/s-magazine.html b/static/sections/s-magazine.html index 9b9dd14..007750e 100644 --- a/static/sections/s-magazine.html +++ b/static/sections/s-magazine.html @@ -25,22 +25,22 @@