diff --git a/CHANGELOG.md b/CHANGELOG.md index c853979..f5111ba 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] ### Fixed +- **A turned page could keep showing its old picture, which made turning look broken (and invited pressing the buttons again).** Page pictures had no cache header until recently, and a browser may treat an unchanged URL as fresh for days, so after a successful turn the review could keep showing the old image. The layout now stamps every page with its picture's version (the file's modification time) and every page picture URL (review, page grid, reading pane, cover, lock-screen artwork) carries it, so a changed picture is a new URL in any browser. Turns also add up (right + half turn = three quarters), so the toolbar now says how far the page is turned from the scan ("Turned 270° clockwise from the scan") with an **Undo turns** button back to the PDF's own orientation, and a second turn or re-read of a page that is still being processed is refused (HTTP 409) instead of stacking. `extract_pages` records a stored turn in the page's layout entry, so it survives a re-import visibly. Verified end to end with the real OCR engines on a scratch copy of c't p. 156: turn 90 gives an upright 886x1039 page read at word-F1 0.878, Undo turns restores the 1039x886 original and clears the stored turn. - **A page's picture could vanish from the review (seen on c't p. 176).** One region held a 3,408-character token with no spaces (`Giro + 2.1.1.1.1…`, the OCR engine stuck in a loop). Its width was taken as the minimum width of the list column, which widened to 20,305 px and squeezed the page-picture column to 2 px. The review grid's tracks can now shrink (`minmax(0, …)`), and text in the review list, reading view, figure diffs and page cards wraps anywhere, so no content can collapse the layout again; a long URL or a loop now simply wraps. The cause is also handled: *Clean up boxes* (and every import and re-read, which run it) re-type a region that is mostly one endless repeated letter/digit token as OCR noise (kept, visible, reversible in the type picker, not read aloud). Leader dots, rules, section numbers, phone numbers, prices and URLs are not affected; run over all three of your magazines (about 6,000 regions) the rule flags exactly that one region on page 176 in each and nothing else. To clear it on an existing magazine press *Clean up boxes* or *Fix this page automatically*. diff --git a/Manual.md b/Manual.md index 5c8c3b7..cbdd30b 100644 --- a/Manual.md +++ b/Manual.md @@ -407,6 +407,13 @@ of the two pages the buttons act on, as with the other page tools. are re-measured. On a test page, reading it sideways scored word-F1 0.02 and reading it after turning 0.88 (an ordinary page scores about 0.88). The turn is remembered: re-running detection or re-importing does not flip the page back. +**Keeping track of turns.** Turns add up: a right turn plus a half turn is three quarters. The toolbar says so in words (for example +"Turned 270° clockwise from the scan") and offers **Undo turns**, which returns the page to the orientation the PDF itself gives it. Only one +turn or re-read of a page can run at a time: pressing a button again while one is still working is refused with a message, instead of stacking +a second turn. The turned picture appears as soon as the page is re-read; page pictures are requested under a version number, so a browser +that remembered the old picture fetches the new one. Note what a PDF's own orientation can be: some pages are stored sideways on purpose +(c't page 156 is a landscape page that reads sideways), and one **Turn right** is then the whole fix. + **What is replaced.** The page's boxes and text are replaced by the new reading, so corrections you made to that page by hand are lost, and articles that used the page go back to "not approved" where the new boxes do not line up with the old ones. **Undo does not cover a turn** (it changes the page image); if you turned the wrong way, press the opposite button. diff --git a/core/magazine.py b/core/magazine.py index 204d3c9..7bf0622 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -2670,6 +2670,7 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110, "page": page_no, "width": rect.width, "height": rect.height, + "rotation": extra_turn, "blocks": blocks, "image_boxes": image_boxes, "images": page_images, @@ -2734,7 +2735,8 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110, return { "page_count": doc.page_count, - "pages": [{"page": p["page"], "width": p["width"], "height": p["height"]} + "pages": [{"page": p["page"], "width": p["width"], "height": p["height"], + **({"rotation": p["rotation"]} if p.get("rotation") else {})} for p in raw_pages], "blocks": [b.to_json() for b in all_blocks], "images": all_images, diff --git a/routes/magazine.py b/routes/magazine.py index 2c73df7..b72f810 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -40,6 +40,7 @@ from core.constants import CONFIG_DIR from core.database import ( background_job_create, background_job_get, + background_job_list, background_job_update, ) from core import spelling @@ -551,9 +552,20 @@ async def magazine_layout(doc_id: str, page: int | None = None): layout = dict(layout) layout["blocks"] = [b for b in layout.get("blocks", []) if b.get("page") == page] layout["pages"] = [p for p in layout.get("pages", []) if p.get("page") == page] + # The page picture's URL carries this number, so a turned or re-rendered page is fetched again by every browser + # whatever it cached earlier (a browser may treat an unchanged URL as fresh for days). + layout = dict(layout) + layout["pages"] = [{**p, "image_v": _page_image_version(d, p.get("page"))} for p in layout.get("pages", [])] return layout +def _page_image_version(d: Path, page: Any) -> int: + try: + return int((d / "pages" / f"{int(page):04d}.png").stat().st_mtime) + except (OSError, TypeError, ValueError): + return 0 + + def _next_article_id(articles: list[dict]) -> str: nums = [int(m.group(1)) for a in articles if (m := re.fullmatch(r"article-(\d+)", a.get("id") or ""))] @@ -1054,6 +1066,22 @@ def _reocr_page_sync(doc_id: str, job_id: str, page: int, background_job_update(job_id, stage="failed", done=True, error=str(exc)[:1000]) +_PAGE_JOB_KINDS = ("magazine-rotate-page", "magazine-reocr-page") +_PAGE_JOB_STALE_SECONDS = 15 * 60 # a job that has not reported for this long died with the server + + +def _page_job_running(doc_id: str, page: int) -> bool: + """True while a turn or re-read of this page is in flight. Two at once would read and rewrite the same image and + layout; pressing the button again because the first one seemed to do nothing must not stack a second turn.""" + now = time.time() + for kind in _PAGE_JOB_KINDS: + for job in background_job_list(kind, 50): + if (job and not job.get("done") and job.get("fingerprint") == f"{doc_id}:{page}" + and now - float(job.get("updated") or 0) < _PAGE_JOB_STALE_SECONDS): + return True + return False + + @router.post("/api/magazine/{doc_id}/pages/{page}/reocr") async def magazine_reocr_page(doc_id: str, page: int, request: Request): """Force one page through the configured vision OCR engine again, @@ -1083,6 +1111,8 @@ async def magazine_reocr_page(doc_id: str, page: int, request: Request): vision_ocr_fallback_model = str( body.get("vision_ocr_fallback_model") or settings.get("vision_ocr_fallback_model") or "").strip() or None + if _page_job_running(doc_id, page): + raise HTTPException(409, f"Page {page} is still being read; wait for it to finish") job_id = uuid.uuid4().hex background_job_create(job_id, "magazine-reocr-page", fingerprint=f"{doc_id}:{page}") asyncio.create_task( @@ -1119,6 +1149,8 @@ async def magazine_rotate_page(doc_id: str, page: int, request: Request): url, model, fb_url, fb_model = _vision_args(settings) if not (url and model): raise HTTPException(400, "No vision OCR engine configured -- set one in Settings (turning re-reads the page)") + if _page_job_running(doc_id, page): + raise HTTPException(409, f"Page {page} is still being turned or read; wait for it to finish before turning it again") job_id = uuid.uuid4().hex background_job_create(job_id, "magazine-rotate-page", fingerprint=f"{doc_id}:{page}") asyncio.create_task(asyncio.to_thread(_reocr_page_sync, doc_id, job_id, page, url, model, fb_url, fb_model, degrees)) diff --git a/static/js/magazine.js b/static/js/magazine.js index e37e6a2..2804dd9 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -175,6 +175,15 @@ function setupMagIngestPreviewResize() { }); } +// The URL of a page's picture. It carries the picture's version (its modification time, stamped on each page by the +// layout endpoint), so when a page is turned or re-rendered the URL changes and every browser fetches it again instead +// of trusting a copy it cached under the old one. +function magPageSrc(page, docId) { + const id = docId || state.doc?.id; + const meta = (state.layout?.pages || []).find((p) => p.page === Number(page)); + return '/api/magazine/' + id + '/pages/' + page + (meta && meta.image_v ? '?v=' + meta.image_v : ''); +} + // ── Library ────────────────────────────────────────────────────────────────── async function magLoadLibrary() { @@ -773,7 +782,7 @@ async function pollIngest() { if (data.preview.page !== lastPreviewPage) { lastPreviewPage = data.preview.page; const img = $('mag-ingest-preview-img'); - if (img) img.src = '/api/magazine/' + state.doc.id + '/pages/' + data.preview.page; + if (img) img.src = '/api/magazine/' + state.doc.id + '/pages/' + data.preview.page + '?t=' + Date.now(); } magRenderPreviewBoxes(data.preview.page, data.preview.boxes || [], data.preview.stage); const textEl = $('mag-ingest-preview-text'); @@ -1242,7 +1251,7 @@ function renderPagesView(host) { }; html += '