feat(magazine): turn a page scanned sideways or upside down and read it again

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-30 12:13:13 +02:00
parent 119d3ba3ae
commit 6fdf9fbc38
9 changed files with 643 additions and 10 deletions

View File

@ -6,6 +6,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [Unreleased]
### Added
- **Turn a page that was scanned sideways or upside down.** *Turn left*, *Turn right* and *Turn 180°* in the review toolbar turn the page image, carry its boxes along so reviewed articles still match, and read the page again (`POST /api/magazine/{id}/pages/{page}/rotate`). The turn is remembered in the magazine's metadata and applied whenever pages are rendered, so re-importing does not undo it. If the re-read fails, the page goes back exactly as it was. Not covered by Undo (it changes the image): the opposite button turns it back. Measured on a real page scanned sideways: word-F1 0.016 as scanned, 0.876 after turning and re-reading. Page images now revalidate (`Cache-Control: no-cache`) so a turned page shows at once.
### Changed
- **Fixing flagged words in the review list no longer restyles the paragraph or hides the underlines.** Clicking an underlined word now edits just that word in place (a small field in the paragraph's own type, suggestion chips, Enter applies, Esc cancels, *Keep* for names), through the same checked one-word fix as the spelling list. Clicking elsewhere in the text opens the whole-text editor, which now keeps the paragraph's font size, line height, width and wrapping (it was 13 px in a full-row box against 14 px text in a narrower column), grows with its text instead of scrolling in a small box, and keeps the doubtful words underlined while you type. On a phone the row text and editors are both 16 px (what iOS needs to avoid zooming in).

View File

@ -393,6 +393,32 @@ reads the text directly; the overlay is an addition, not a replacement.
**Offline.** The file is self-contained. **Privacy.** It contains the magazine's text and your generated audio, so share it only
where the magazine's licence allows.
### A page that was scanned sideways or upside down
**Purpose.** Some scans arrive turned: the page looks sideways, and what OCR read from it is gibberish.
**Prerequisites.** A vision OCR engine set up in Settings (the same one *OCR again* uses), because turning re-reads the page.
**Steps.** Open the page in Review and press **Turn right** (90° clockwise), **Turn left** (90° counter-clockwise) or **Turn 180°**.
The app asks first, then turns the page image, reads the page again, and shows the result. Tick **two-page spread** to choose which
of the two pages the buttons act on, as with the other page tools.
**Expected result.** The page stands upright, its boxes and text are new readings of the upright page, and the page's quality findings
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.
**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.
**Errors.** Without a vision OCR engine nothing is changed and you are told why. If the engine is down or returns nothing, the page image is
put back exactly as it was and nothing is saved. A page that has a real text layer in the PDF is read from its image after a turn, because the
PDF's own text positions belong to the unturned page.
**Mobile / accessibility.** The three buttons are real buttons in a labelled group, each with its direction in words, and the confirmation is a dialog you can answer with the keyboard.
**Privacy.** The page image goes to the OCR engine you configured, exactly as for *OCR again*.
### Editing recognised text, and seeing what you changed
**Purpose.** Fix what OCR got wrong and always be able to see, and undo, what you changed.

View File

@ -477,6 +477,22 @@ 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.
## Turning a page scanned sideways
`POST /api/magazine/{id}/pages/{page}/rotate {degrees: 90|180|270|-90}` starts the same background job as "OCR again"
(`_reocr_page_sync`) with a turn in front of it. The job turns the rendered page PNG (`rotate_page_image`, clockwise, via a temporary
file), carries the page's existing boxes and figure boxes to their new places (`layout_with_rotated_page` / `rotate_bbox`, which also swaps
the page's width and height for a quarter turn and accumulates `rotation` in the page's layout entry), and re-reads the page. Carrying the old
boxes matters: `remap_reviewed_page` decides which article a new region joins by overlap with the old ones, which would find none if the old
boxes were still in the sideways geometry. If the read fails, raises or returns nothing, the original PNG bytes are written back and neither
layout, articles nor metadata are saved. On success the total turn per page is stored in `meta.json` `page_rotations` (four quarter turns delete
the entry); `extract_pages(page_rotations=...)` applies it with `page.set_rotation` whenever a page is rendered, so re-ingesting does not flip the
page back, and on a turned page the PDF's own text and embedded-image boxes (unturned coordinates) are ignored in favour of OCR of the render.
A turn takes no undo snapshot: Undo restores layout and articles but not the image, which would put old-orientation boxes on the turned page. The page
image endpoint now answers `Cache-Control: no-cache` so a turned page shows at once. Measured on a real page scanned sideways (Baidu Unlimited-OCR):
word-F1 0.016 read as scanned, 0.876 after a turn and re-read. Limitation: an automatic "this page looks sideways" hint would need tesseract's
orientation data (`osd.traineddata`), which the image does not ship.
## Import from page images
`POST /api/magazine/{id}/source-images` (multipart `files`, in the order sent) builds `source.pdf` with

View File

@ -2485,9 +2485,13 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110,
vision_ocr_fallback_model: str | None = None,
vision_ocr_timeout: float = 150.0,
force_vision_ocr: bool = False,
progress: Any = None) -> dict[str, Any]:
progress: Any = None,
page_rotations: dict[int, int] | None = None) -> dict[str, Any]:
"""Run the full extraction for one PDF, writing page renders and images.
`page_rotations` ({page: clockwise degrees}) are turns the reader applied to pages scanned sideways; a
rendered page is turned the same way whenever it is (re)rendered, so a re-import does not undo them.
When vision_ocr_url/model are given, a page whose text needs OCR (or
whose already-extracted text scores as mostly garbage, native or
tesseract alike -- see ocr_quality_score) is escalated to that engine
@ -2522,8 +2526,11 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110,
for pno in range(doc.page_count):
page = doc.load_page(pno)
rect = page.rect
page_no = pno + 1
extra_turn = int((page_rotations or {}).get(page_no, 0)) % 360
if extra_turn:
page.set_rotation((page.rotation + extra_turn) % 360)
rect = page.rect
png = pages_dir / f"{page_no:04d}.png"
if not png.exists():
@ -2532,7 +2539,9 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110,
image_boxes: list[list[float]] = []
page_images: list[dict[str, Any]] = []
for img_index, info in enumerate(page.get_images(full=True)):
# The PDF's own text and image boxes are in the page's unturned coordinates; on a page the reader
# turned, the render (and OCR of it) is the truth.
for img_index, info in enumerate([] if extra_turn else page.get_images(full=True)):
rects: list[list[float]] = []
try:
for r in page.get_image_rects(info[0]):
@ -2554,7 +2563,7 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110,
})
blocks: list[Block] = []
data = page.get_text("dict")
data = {} if extra_turn else page.get_text("dict")
for bi, blk in enumerate(data.get("blocks", [])):
if blk.get("type") != 0: # 0 = text, 1 = image
continue
@ -2832,6 +2841,67 @@ def build_articles(layout: dict[str, Any]) -> list[dict[str, Any]]:
return out
# ── Turning a page that was scanned sideways or upside down ───────────────────
ROTATIONS = (90, 180, 270) # clockwise degrees a reader can ask for
def normalise_rotation(degrees: Any) -> int:
"""90, 180 or 270 (clockwise). -90 means 270; anything else is refused."""
try:
d = int(degrees) % 360
except (TypeError, ValueError):
raise ValueError("degrees must be 90, 180 or 270") from None
if d not in ROTATIONS:
raise ValueError("degrees must be 90, 180 or 270")
return d
def rotate_bbox(bbox: Sequence[float], width: float, height: float, degrees: int) -> list[float]:
"""Where a [x, y, w, h] box on a page of width x height lands after the page is turned clockwise."""
x, y, w, h = (float(v) for v in bbox)
d = normalise_rotation(degrees)
if d == 90:
return [height - (y + h), x, h, w]
if d == 180:
return [width - (x + w), height - (y + h), w, h]
return [y, width - (x + w), h, w] # 270
def rotate_page_image(png: Path, degrees: int) -> None:
"""Turn a rendered page clockwise in place (written to a temporary file first)."""
from PIL import Image
d = normalise_rotation(degrees)
op = {90: Image.Transpose.ROTATE_270, 180: Image.Transpose.ROTATE_180, 270: Image.Transpose.ROTATE_90}[d]
tmp = png.with_suffix(".rot.tmp")
try:
with Image.open(png) as im:
im.transpose(op).save(tmp, format="PNG")
tmp.replace(png)
finally:
tmp.unlink(missing_ok=True)
def layout_with_rotated_page(layout: dict[str, Any], page_no: int, degrees: int) -> dict[str, Any]:
"""The layout with one page turned: its size swapped for a quarter turn, `rotation` accumulated, and the
boxes already on it carried to where they are now, so whatever was approved still lines up with them."""
d = normalise_rotation(degrees)
meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None)
if meta is None:
raise ValueError("Page not found")
w, h = float(meta["width"]), float(meta["height"])
out = dict(layout)
out["pages"] = [
({**p, "width": h, "height": w, "rotation": (int(p.get("rotation") or 0) + d) % 360} if d != 180
else {**p, "rotation": (int(p.get("rotation") or 0) + d) % 360}) if p["page"] == page_no else p
for p in layout.get("pages", [])]
out["blocks"] = [{**b, "bbox": rotate_bbox(b["bbox"], w, h, d)} if b.get("page") == page_no else b
for b in layout.get("blocks", [])]
out["images"] = [{**i, "bbox": rotate_bbox(i["bbox"], w, h, d)} if i.get("page") == page_no and i.get("bbox") else i
for i in layout.get("images", [])]
return out
def reocr_page(doc_dir: Path, layout: dict[str, Any], page_no: int,
vision_ocr_url: str, vision_ocr_model: str,
vision_ocr_fallback_url: str | None = None,

View File

@ -314,6 +314,19 @@ def _render_ingest_preview_html(blocks: list, images: list | None, doc_id: str =
return "\n".join(parts)
def _page_rotations(meta: dict[str, Any]) -> dict[int, int]:
"""{page: clockwise degrees} the reader applied to pages scanned sideways (meta.json `page_rotations`)."""
out: dict[int, int] = {}
for k, v in (meta.get("page_rotations") or {}).items():
try:
page, deg = int(k), int(v) % 360
except (TypeError, ValueError):
continue
if page >= 1 and deg in mag.ROTATIONS:
out[page] = deg
return out
def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int,
vision_ocr_url: str | None, vision_ocr_model: str | None,
vision_ocr_fallback_url: str | None = None,
@ -346,7 +359,8 @@ def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int,
vision_ocr_fallback_url=vision_ocr_fallback_url,
vision_ocr_fallback_model=vision_ocr_fallback_model,
force_vision_ocr=force_vision_ocr,
progress=progress)
progress=progress,
page_rotations=_page_rotations(_read_meta(d)))
page_sizes = {p["page"]: (p.get("width"), p.get("height")) for p in layout.get("pages", [])}
total_pages = len(page_sizes)
@ -774,7 +788,8 @@ async def magazine_page_image(doc_id: str, page: int):
png = d / "pages" / f"{page:04d}.png"
if not png.is_file():
raise HTTPException(404, "Page not rendered")
return FileResponse(png, media_type="image/png")
# "no-cache" = keep a copy but check it is still current (ETag): a page turned by the reader must show at once.
return FileResponse(png, media_type="image/png", headers={"Cache-Control": "no-cache"})
def _recognise_advertisements(layout: dict, settings: dict, page: int | None = None, on_page=None) -> None:
@ -954,16 +969,25 @@ def _recognise_semantic_layout(layout: dict, settings: dict, page: int | None =
def _reocr_page_sync(doc_id: str, job_id: str, page: int,
vision_ocr_url: str, vision_ocr_model: str,
vision_ocr_fallback_url: str | None,
vision_ocr_fallback_model: str | None) -> None:
vision_ocr_fallback_model: str | None,
rotate_degrees: int = 0) -> None:
""""OCR again" on one page with the engine the user picked. Restart-safe
job for the same reason ingestion is: a vision OCR call can take up to
~40s (confirmed live), too long for a plain request/response cycle.
"""
d = _LIB / doc_id
png = d / "pages" / f"{page:04d}.png"
original_png: bytes | None = None # kept while a turn is in flight, so a failed re-read can put the page back
try:
layout = mag.load_layout(d)
if not layout:
raise FileNotFoundError("No layout yet -- run ingestion first")
if rotate_degrees:
background_job_update(job_id, stage="rotate", detail=f"Turning page {page}")
original_png = png.read_bytes()
mag.rotate_page_image(png, rotate_degrees)
# The old boxes move with the page, so what was approved still lines up when the new ones are matched.
layout = mag.layout_with_rotated_page(layout, page, rotate_degrees)
background_job_update(job_id, stage="reocr", detail=f"Re-reading page {page}")
updated = mag.reocr_page(d, layout, page, vision_ocr_url, vision_ocr_model,
vision_ocr_fallback_url, vision_ocr_fallback_model)
@ -990,13 +1014,33 @@ def _reocr_page_sync(doc_id: str, job_id: str, page: int,
articles = mag.load_articles(d)
mag.remap_reviewed_page(articles, layout, updated, page)
_add_split_paragraphs_to_articles(articles, cleanup)
token = _snapshot_for_undo(d, f"Re-read page {page}")
# A turn rewrites the page image too, which an undo snapshot of layout + articles does not cover.
token = None if rotate_degrees else _snapshot_for_undo(d, f"Re-read page {page}")
mag.save_layout(d, updated)
mag.save_articles(d, articles)
_seal_undo(d, token)
if rotate_degrees:
meta = _read_meta(d)
turns = dict(meta.get("page_rotations") or {})
total = (int(turns.get(str(page), 0)) + rotate_degrees) % 360
if total:
turns[str(page)] = total
else:
turns.pop(str(page), None) # four quarter turns are no turn at all
meta["page_rotations"] = turns
_write_meta(d, meta)
_refresh_quality_rows(d, updated, [page]) # the old findings described the sideways page
original_png = None # saved: nothing to restore any more. Turning back is its own button.
else:
_seal_undo(d, token)
background_job_update(job_id, stage="done", done=True,
detail=f"Page {page} re-read", result={"page": page, "undo": token})
detail=f"Page {page} " + ("turned and re-read" if rotate_degrees else "re-read"),
result={"page": page, "undo": None if rotate_degrees else token, "rotation": rotate_degrees})
except Exception as exc: # noqa: BLE001 - surfaced to UI
if original_png is not None:
try:
png.write_bytes(original_png) # nothing was saved: the page goes back as it was
except OSError:
logging.exception("Could not restore page %s after a failed turn", page)
background_job_update(job_id, stage="failed", done=True, error=str(exc)[:1000])
@ -1039,6 +1083,38 @@ async def magazine_reocr_page(doc_id: str, page: int, request: Request):
return {"job_id": job_id}
@router.post("/api/magazine/{doc_id}/pages/{page}/rotate")
async def magazine_rotate_page(doc_id: str, page: int, request: Request):
"""Turn a page that was scanned sideways or upside down and read it again.
Body: {"degrees": 90 | 180 | 270 | -90} clockwise. The rendered page is turned, the page's old boxes
are carried with it so reviewed articles still match, and the page is re-read like "OCR again". The turn
is remembered (meta.json `page_rotations`) so re-importing does not undo it. Needs a vision OCR engine,
the same as "OCR again".
"""
d, _ = _require_magazine(doc_id)
if page < 1 or page > 10000:
raise HTTPException(400, "Bad page")
try:
body = await request.json()
degrees = mag.normalise_rotation(body.get("degrees"))
except (ValueError, AttributeError, TypeError) as exc:
raise HTTPException(400, str(exc) or "degrees must be 90, 180 or 270")
if not (d / "pages" / f"{page:04d}.png").is_file():
raise HTTPException(404, "Page not rendered -- run ingestion first")
layout = mag.load_layout(d)
if not layout or not any(p["page"] == page for p in layout.get("pages", [])):
raise HTTPException(404, "Page not found in the layout")
settings = _load_settings()
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)")
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))
return {"job_id": job_id, "degrees": degrees}
def _quality_page_sync(doc_id: str, job_id: str, page: int, mode: str,
vision_ocr_url: str | None = None,
vision_ocr_model: str | None = None,

View File

@ -1992,6 +1992,17 @@ function renderReview() {
'<button type="button" class="btn-sm" id="mag-refine-figures-btn" onclick="magRefineFigures()" ' +
'title="Re-check figure boundaries on this page only">' +
'<span class="mdi mdi-image-auto-adjust" aria-hidden="true"></span> Fix figure boundaries (this page)</button>' +
'<span class="mag-turn-group" role="group" aria-label="Turn this page">' +
'<button type="button" class="btn-sm" id="mag-turn-left" onclick="magRotatePage(-90)" ' +
'title="The page was scanned sideways: turn it 90° counter-clockwise and read it again">' +
'<span class="mdi mdi-rotate-left" aria-hidden="true"></span> Turn left</button>' +
'<button type="button" class="btn-sm" id="mag-turn-right" onclick="magRotatePage(90)" ' +
'title="The page was scanned sideways: turn it 90° clockwise and read it again">' +
'<span class="mdi mdi-rotate-right" aria-hidden="true"></span> Turn right</button>' +
'<button type="button" class="btn-sm" id="mag-turn-flip" onclick="magRotatePage(180)" ' +
'title="The page is upside down: turn it 180° and read it again">' +
'<span class="mdi mdi-flip-vertical" aria-hidden="true"></span> Turn 180°</button>' +
'</span>' +
'<button type="button" class="btn-sm" id="mag-reocr-btn" onclick="magReocrPage()">' +
'<span class="mdi mdi-text-recognition"></span> OCR again (this page)</button>' +
'<button type="button" class="btn-sm" id="mag-find-missing-btn" onclick="magRunPageQuality(\'missing\')" ' +
@ -3588,6 +3599,58 @@ async function magReocrPage() {
renderReview();
}
// A page scanned sideways or upside down: turn the page itself, then read it again. The boxes and text
// produced from the wrong orientation are replaced, so this asks first; approvals carry over where the new
// regions line up with the old ones. Turning is not covered by Undo (it rewrites the page image), so the
// way back is the opposite turn.
const MAG_TURN_WORDS = { 90: 'clockwise', [-90]: 'counter-clockwise', 270: 'counter-clockwise', 180: 'half a turn' };
async function magRotatePage(degrees) {
const page = state.reviewPage;
if (page == null) return;
const what = degrees === 180 ? 'Turn page ' + page + ' upside down (180°)' : 'Turn page ' + page + ' 90° ' + MAG_TURN_WORDS[degrees];
const ok = await confirmDialog(
'The page image is turned and the page is read again, so the boxes and text now on it are replaced by new ones. ' +
'Any corrections you made to this page by hand are lost, and articles that used it are set back to "not approved". ' +
'This cannot be undone with Undo; turn it back with the opposite button if it was wrong.',
{ title: what + '?', okLabel: 'Turn and read again' });
if (!ok) return;
magOfferUndo(null); // an older Undo would put old-orientation boxes on the turned page
const buttons = ['mag-turn-left', 'mag-turn-right', 'mag-turn-flip', 'mag-reocr-btn'].map($).filter(Boolean);
const statusEl = $('mag-reocr-status');
const busy = (on, text) => { buttons.forEach((b) => { b.disabled = on; }); if (statusEl) statusEl.textContent = text || ''; };
busy(true, 'Turning page ' + page + '…');
let jobId;
try {
const r = await api('/api/magazine/' + state.doc.id + '/pages/' + page + '/rotate', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ degrees }),
});
jobId = r.job_id;
} catch (e) {
busy(false);
return say('Could not turn the page: ' + e.message, 'err');
}
for (;;) {
let job;
try { job = await api('/api/jobs/' + jobId); } catch (_) { break; }
if (job.done) {
if (job.error) { busy(false); return say('The page was left as it was: ' + job.error, 'err'); }
say('Page ' + page + ' turned and read again', 'ok');
break;
}
busy(true, job.detail || 'Turning page ' + page + '…');
await new Promise((r) => setTimeout(r, 2000));
}
busy(false);
state.layout = null;
await ensureLayout();
const articleId = state.article.id;
await magRefreshArticles();
state.article = state.articles.find((a) => a.id === articleId) || state.article;
state.quality = null; state.qualityDocId = null; // the old findings described the sideways page
_magReviewChecked = null;
renderReview();
}
async function magRunPageQuality(mode) {
magOfferUndo(null);
const page = state.reviewPage;
@ -5711,6 +5774,7 @@ root.magReviewToggleAllPage = magReviewToggleAllPage;
root.magReviewClearCurrentPage = magReviewClearCurrentPage;
root.magReviewRemoveAllBoxes = magReviewRemoveAllBoxes;
root.magReocrPage = magReocrPage;
root.magRotatePage = magRotatePage;
root.magRefineFigures = magRefineFigures;
root.magRefineAllFigures = magRefineAllFigures;
root.magQualityRun = magQualityRun;

View File

@ -5645,3 +5645,5 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
box-shadow: 0 8px 24px rgba(0, 0, 0, .25); font-size: 13px; font-style: normal; line-height: 1.3; }
.mag-word-hint { flex: 1 1 100%; font-size: 12px; }
@media (max-width: 860px) { .mag-hl-wrap { font-size: 16px; } } /* after the desktop rule above, so it wins */
.mag-turn-group { display: inline-flex; gap: 4px; padding: 2px; border: 1px dashed var(--border); border-radius: var(--radius); }

View File

@ -1989,3 +1989,90 @@ test.describe('Correcting flagged words in the review list', () => {
await expect.poll(() => patches[0]).toEqual({ text: 'Neuer Text.' });
});
});
test.describe('Turning a page scanned sideways', () => {
async function open(page) {
await mockBackend(page);
const calls = { rotate: [], layout: 0, jobs: 0 };
await page.route(`**/api/magazine/${DOC_ID}/layout*`, route => { calls.layout++; return route.fulfill({ json: LAYOUT }); });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).getByRole('button', { name: /^Review/ }).click();
await page.waitForSelector('#mag-undo-btn');
return calls;
}
const routeRotate = (page, calls, { status = 200, job = { done: true, error: null } } = {}) => Promise.all([
page.route(`**/api/magazine/${DOC_ID}/pages/3/rotate`, route => {
calls.rotate.push(route.request().postDataJSON());
return status === 200 ? route.fulfill({ json: { job_id: 'rot1', degrees: calls.rotate.at(-1).degrees } })
: route.fulfill({ status, json: { detail: 'No vision OCR engine configured -- set one in Settings (turning re-reads the page)' } });
}),
page.route('**/api/jobs/rot1', route => { calls.jobs++; return route.fulfill({ json: job }); }),
]);
test('three labelled turn buttons sit together in the toolbar', async ({ page }) => {
await open(page);
const group = page.getByRole('group', { name: 'Turn this page' });
await expect(group.getByRole('button')).toHaveText([/Turn left/, /Turn right/, /Turn 180°/]);
await expect(group.getByRole('button', { name: /Turn right/ })).toHaveAttribute('title', /clockwise/);
});
test('turning right asks first and, once confirmed, sends 90 and reloads the page', async ({ page }) => {
const calls = await open(page);
await routeRotate(page, calls);
const layoutBefore = calls.layout;
await page.getByRole('button', { name: /Turn right/ }).click();
const dialog = page.getByRole('alertdialog');
await expect(dialog).toContainText('Turn page 3 90° clockwise?');
await expect(dialog).toContainText('corrections you made to this page by hand are lost');
expect(calls.rotate).toEqual([]); // nothing is sent before the answer
await dialog.getByRole('button', { name: 'Turn and read again' }).click();
await expect.poll(() => calls.rotate).toEqual([{ degrees: 90 }]);
await expect.poll(() => calls.jobs).toBeGreaterThan(0);
await expect.poll(() => calls.layout).toBeGreaterThan(layoutBefore); // the layout is fetched again
await expect(page.getByRole('button', { name: /Turn right/ })).toBeEnabled();
});
test('cancelling the question turns nothing', async ({ page }) => {
const calls = await open(page);
await routeRotate(page, calls);
await page.getByRole('button', { name: /Turn left/ }).click();
await expect(page.getByRole('alertdialog')).toContainText('Turn page 3 90° counter-clockwise?');
await page.getByRole('alertdialog').getByRole('button', { name: /Cancel/ }).click();
await page.waitForTimeout(300);
expect(calls.rotate).toEqual([]);
});
test('left sends -90 and half a turn sends 180', async ({ page }) => {
const calls = await open(page);
await routeRotate(page, calls);
await page.getByRole('button', { name: /Turn left/ }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
await expect.poll(() => calls.rotate.length).toBe(1);
await page.getByRole('button', { name: /Turn 180°/ }).click();
await expect(page.getByRole('alertdialog')).toContainText('upside down');
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
await expect.poll(() => calls.rotate).toEqual([{ degrees: -90 }, { degrees: 180 }]);
});
test('a failed read says the page was left as it was and the buttons work again', async ({ page }) => {
const calls = await open(page);
await routeRotate(page, calls, { job: { done: true, error: 'The OCR engine returned nothing usable for this page' } });
await page.getByRole('button', { name: /Turn right/ }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
await expect(page.locator('.toast, #toast, [role="status"]').filter({ hasText: 'The page was left as it was' }).first()).toBeVisible();
await expect(page.getByRole('button', { name: /Turn right/ })).toBeEnabled();
});
test('a refusal to start (no OCR engine) is explained', async ({ page }) => {
const calls = await open(page);
await routeRotate(page, calls, { status: 400 });
await page.getByRole('button', { name: /Turn right/ }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
await expect(page.locator('.toast, #toast, [role="status"]').filter({ hasText: 'No vision OCR engine configured' }).first()).toBeVisible();
await expect(page.getByRole('button', { name: /Turn right/ })).toBeEnabled();
});
});

View File

@ -0,0 +1,289 @@
"""Turning a page that was scanned sideways: geometry, persistence, failure handling, validation."""
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest import mock
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-test-config-"))
from PIL import Image
from core import magazine as mag
class GeometryTests(unittest.TestCase):
def test_a_box_turns_with_the_page(self):
# 100 wide, 200 tall page; a box near the top-left corner.
self.assertEqual(mag.rotate_bbox([10, 20, 30, 40], 100, 200, 90), [140, 10, 40, 30]) # lands top-right
self.assertEqual(mag.rotate_bbox([10, 20, 30, 40], 100, 200, 180), [60, 140, 30, 40]) # bottom-right
self.assertEqual(mag.rotate_bbox([10, 20, 30, 40], 100, 200, 270), [20, 60, 40, 30]) # bottom-left
def test_four_quarter_turns_are_no_turn(self):
box, w, h = [10.0, 20.0, 30.0, 40.0], 100.0, 200.0
for _ in range(4):
box = mag.rotate_bbox(box, w, h, 90)
w, h = h, w
self.assertEqual(box, [10.0, 20.0, 30.0, 40.0])
def test_a_turn_and_its_opposite_cancel(self):
turned = mag.rotate_bbox([10, 20, 30, 40], 100, 200, 90)
self.assertEqual(mag.rotate_bbox(turned, 200, 100, 270), [10, 20, 30, 40])
def test_only_quarter_turns_are_accepted(self):
self.assertEqual([mag.normalise_rotation(v) for v in (90, -90, 270, 180, "90", 450, -270)], [90, 270, 270, 180, 90, 90, 90])
for bad in (0, 45, 360, "x", None, 1):
with self.assertRaises(ValueError):
mag.normalise_rotation(bad)
class ImageTests(unittest.TestCase):
def png(self, w=100, h=200):
p = Path(tempfile.mkdtemp(prefix="ttvc-rot-")) / "0001.png"
im = Image.new("RGB", (w, h), (255, 255, 255))
im.putpixel((10, 20), (255, 0, 0))
im.save(p)
return p
def test_a_quarter_turn_is_clockwise_and_swaps_the_size(self):
p = self.png()
mag.rotate_page_image(p, 90)
with Image.open(p) as im:
self.assertEqual(im.size, (200, 100))
self.assertEqual(im.getpixel((200 - 1 - 20, 10)), (255, 0, 0)) # (10,20) -> (H-1-y, x)
def test_a_half_turn_keeps_the_size(self):
p = self.png()
mag.rotate_page_image(p, 180)
with Image.open(p) as im:
self.assertEqual(im.size, (100, 200))
self.assertEqual(im.getpixel((100 - 1 - 10, 200 - 1 - 20)), (255, 0, 0))
def test_the_pixel_agrees_with_the_box_maths(self):
p = self.png()
x, y, _, _ = mag.rotate_bbox([10, 20, 1, 1], 100, 200, 90)
mag.rotate_page_image(p, 90)
with Image.open(p) as im:
self.assertEqual(im.getpixel((int(x), int(y))), (255, 0, 0))
def test_no_temporary_file_is_left_behind(self):
p = self.png()
mag.rotate_page_image(p, 270)
self.assertEqual(sorted(f.name for f in p.parent.iterdir()), ["0001.png"])
class LayoutTests(unittest.TestCase):
def layout(self):
return {"pages": [{"page": 1, "width": 100.0, "height": 200.0}, {"page": 2, "width": 100.0, "height": 200.0}],
"blocks": [{"id": "a", "page": 1, "bbox": [10, 20, 30, 40]}, {"id": "b", "page": 2, "bbox": [10, 20, 30, 40]}],
"images": [{"id": "i", "page": 1, "bbox": [0, 0, 50, 50]}]}
def test_only_the_turned_page_changes(self):
out = mag.layout_with_rotated_page(self.layout(), 1, 90)
self.assertEqual((out["pages"][0]["width"], out["pages"][0]["height"], out["pages"][0]["rotation"]), (200.0, 100.0, 90))
self.assertEqual(out["pages"][1], {"page": 2, "width": 100.0, "height": 200.0})
self.assertEqual(out["blocks"][0]["bbox"], [140, 10, 40, 30])
self.assertEqual(out["blocks"][1]["bbox"], [10, 20, 30, 40])
self.assertEqual(out["images"][0]["bbox"], [150, 0, 50, 50])
def test_the_input_is_not_modified_and_rotation_accumulates(self):
original = self.layout()
snapshot = json.dumps(original)
once = mag.layout_with_rotated_page(original, 1, 90)
self.assertEqual(json.dumps(original), snapshot)
twice = mag.layout_with_rotated_page(once, 1, 270)
self.assertEqual(twice["pages"][0]["rotation"], 0)
self.assertEqual((twice["pages"][0]["width"], twice["pages"][0]["height"]), (100.0, 200.0))
self.assertEqual(twice["blocks"][0]["bbox"], [10, 20, 30, 40])
def test_an_unknown_page_is_refused(self):
with self.assertRaises(ValueError):
mag.layout_with_rotated_page(self.layout(), 9, 90)
class JobTests(unittest.TestCase):
"""_reocr_page_sync with a turn: success persists it, failure puts the page back."""
def setUp(self):
import routes.magazine as routes
self.routes = routes
self.lib = Path(tempfile.mkdtemp(prefix="ttvc-lib-"))
self.d = self.lib / ("a" * 32)
(self.d / "pages").mkdir(parents=True)
self.png = self.d / "pages" / "0003.png"
Image.new("RGB", (100, 200), (255, 255, 255)).save(self.png)
self.original = self.png.read_bytes()
layout = {"pages": [{"page": 3, "width": 100.0, "height": 200.0}], "images": [],
"blocks": [{"id": "b1", "page": 3, "bbox": [10, 20, 30, 40], "type": "body", "text": "x", "reading_order": 0}]}
(self.d / "layout.json").write_text(json.dumps(layout))
(self.d / "articles.json").write_text(json.dumps({"articles": []}))
(self.d / "meta.json").write_text(json.dumps({"kind": "magazine", "title": "T"}))
self.jobs = []
patches = [mock.patch.object(routes, "_LIB", self.lib),
mock.patch.object(routes, "background_job_update", side_effect=lambda jid, **kw: self.jobs.append(kw)),
mock.patch.object(routes, "_recognise_advertisements"), mock.patch.object(routes, "_recognise_semantic_layout"),
mock.patch.object(routes, "_refresh_quality_rows"), mock.patch.object(routes, "_load_settings", return_value={}),
mock.patch.object(routes, "_snapshot_for_undo", return_value="tok"), mock.patch.object(routes, "_seal_undo"),
mock.patch.object(routes.mag, "refresh_visual_regions"),
mock.patch.object(routes.mag, "clean_up_regions", side_effect=lambda lay, pages=None: (lay, {})),
mock.patch.object(routes.mag, "reorder_page", side_effect=lambda lay, p: lay),
mock.patch.object(routes.mag, "detect_inset_pullquotes", side_effect=lambda d, lay, pages, *a: (lay, 0)),
mock.patch.object(routes.mag, "split_around_pullquotes", side_effect=lambda d, lay, pages: (lay, 0)),
mock.patch.object(routes.mag, "snap_text_boxes", side_effect=lambda d, lay, pages=None: (lay, [])),
mock.patch.object(routes.mag, "remap_reviewed_page")]
for p in patches:
p.start()
self.addCleanup(p.stop)
def run_job(self, degrees, reread):
with mock.patch.object(self.routes.mag, "reocr_page", side_effect=reread) as m:
self.routes._reocr_page_sync("a" * 32, "job", 3, "http://ocr", "m", None, None, degrees)
return m
def test_success_turns_the_image_remembers_it_and_re_measures_the_page(self):
seen = {}
def reread(d, layout, page, *a, **k):
seen["meta"] = next(p for p in layout["pages"] if p["page"] == page)
seen["bbox"] = layout["blocks"][0]["bbox"]
return layout
self.run_job(90, reread)
self.assertEqual((seen["meta"]["width"], seen["meta"]["height"]), (200.0, 100.0)) # the read sees the turned page
self.assertEqual(seen["bbox"], [140, 10, 40, 30]) # ...with the old boxes carried along
with Image.open(self.png) as im:
self.assertEqual(im.size, (200, 100))
self.assertEqual(json.loads((self.d / "meta.json").read_text())["page_rotations"], {"3": 90})
self.routes._refresh_quality_rows.assert_called_once()
self.assertEqual(self.jobs[-1]["stage"], "done")
self.assertIsNone(self.jobs[-1]["result"]["undo"])
self.routes._snapshot_for_undo.assert_not_called() # a turn is not undoable
def test_turns_add_up_and_a_full_circle_forgets_itself(self):
for _ in range(4):
self.run_job(90, lambda d, layout, page, *a, **k: layout)
self.assertNotIn("3", json.loads((self.d / "meta.json").read_text()).get("page_rotations", {}))
def test_a_failed_read_puts_the_page_back_and_saves_nothing(self):
before_layout = (self.d / "layout.json").read_text()
self.run_job(90, lambda *a, **k: None) # the engine returned nothing usable
self.assertEqual(self.png.read_bytes(), self.original)
self.assertEqual((self.d / "layout.json").read_text(), before_layout)
self.assertNotIn("page_rotations", json.loads((self.d / "meta.json").read_text()))
self.assertEqual(self.jobs[-1]["stage"], "failed")
self.routes._refresh_quality_rows.assert_not_called()
def test_an_exception_during_the_read_also_restores_the_page(self):
def boom(*a, **k):
raise RuntimeError("engine down")
self.run_job(180, boom)
self.assertEqual(self.png.read_bytes(), self.original)
self.assertIn("engine down", self.jobs[-1]["error"])
def test_without_a_turn_the_job_behaves_as_before(self):
self.run_job(0, lambda d, layout, page, *a, **k: layout)
self.routes._snapshot_for_undo.assert_called_once()
self.assertEqual(self.png.read_bytes(), self.original)
self.assertNotIn("page_rotations", json.loads((self.d / "meta.json").read_text()))
class RenderTests(unittest.TestCase):
"""A stored turn survives re-rendering: extract_pages applies it, so a re-import does not flip the page back."""
def pdf(self):
import pymupdf
d = Path(tempfile.mkdtemp(prefix="ttvc-pdf-"))
doc = pymupdf.open()
page = doc.new_page(width=300, height=200)
page.insert_text((20, 50), "A sentence with enough words to count as native text on this page, repeated. " * 3, fontsize=8)
doc.save(str(d / "source.pdf"))
doc.close()
return d
def test_extract_pages_renders_a_stored_turn_and_ignores_the_unturned_text_layer(self):
d = self.pdf()
plain = mag.extract_pages(d / "source.pdf", d / "plain", render_dpi=50)
self.assertEqual((plain["pages"][0]["width"], plain["pages"][0]["height"]), (300.0, 200.0))
self.assertTrue(any(b["text"].startswith("A sentence") for b in plain["blocks"]))
turned = mag.extract_pages(d / "source.pdf", d / "turned", render_dpi=50, page_rotations={1: 90})
self.assertEqual((turned["pages"][0]["width"], turned["pages"][0]["height"]), (200.0, 300.0))
with Image.open(d / "turned" / "pages" / "0001.png") as im, Image.open(d / "plain" / "pages" / "0001.png") as ref:
self.assertEqual(im.size, (ref.size[1], ref.size[0]))
# The PDF's own text boxes belong to the unturned page; on a turned page only OCR of the render counts.
self.assertFalse(any(b["source"] == "native" or b["text"].startswith("A sentence") for b in turned["blocks"]))
def test_no_stored_turn_changes_nothing(self):
d = self.pdf()
a = mag.extract_pages(d / "source.pdf", d / "a", render_dpi=50)
b = mag.extract_pages(d / "source.pdf", d / "b", render_dpi=50, page_rotations={})
self.assertEqual(a["pages"], b["pages"])
self.assertEqual([x["text"] for x in a["blocks"]], [x["text"] for x in b["blocks"]])
class EndpointTests(unittest.TestCase):
def setUp(self):
from fastapi import FastAPI
from fastapi.testclient import TestClient
import routes.magazine as routes
self.routes = routes
self.lib = Path(tempfile.mkdtemp(prefix="ttvc-lib-"))
self.patches = [mock.patch.object(routes, "_LIB", self.lib)]
for p in self.patches:
p.start()
self.addCleanup(p.stop)
app = FastAPI(); app.include_router(routes.router)
self.client = TestClient(app)
self.doc = self.client.post("/api/magazine/docs", json={"title": "T"}).json()["id"]
self.d = self.lib / self.doc
(self.d / "pages").mkdir()
Image.new("RGB", (50, 80), (255, 255, 255)).save(self.d / "pages" / "0002.png")
(self.d / "layout.json").write_text(json.dumps({"pages": [{"page": 2, "width": 50.0, "height": 80.0}], "blocks": [], "images": []}))
def post(self, degrees, page=2, vision=True):
settings = {"vision_ocr_url": "http://ocr", "vision_ocr_model": "m"} if vision else {}
created = []
async def fake_task(coro):
created.append(coro)
with mock.patch.object(self.routes, "_load_settings", return_value=settings), \
mock.patch.object(self.routes.asyncio, "create_task", side_effect=lambda c: (c.close(), created.append(1))[1]):
return self.client.post(f"/api/magazine/{self.doc}/pages/{page}/rotate", json={"degrees": degrees}), created
def test_a_valid_request_starts_a_job(self):
r, created = self.post(90)
self.assertEqual(r.status_code, 200, r.text)
self.assertEqual(r.json()["degrees"], 90)
self.assertEqual(len(created), 1)
def test_minus_ninety_means_two_seventy(self):
self.assertEqual(self.post(-90)[0].json()["degrees"], 270)
def test_bad_degrees_are_refused_and_nothing_starts(self):
for bad in (0, 45, "sideways", None, 360):
r, created = self.post(bad)
self.assertEqual(r.status_code, 400, bad)
self.assertEqual(created, [])
def test_a_page_that_is_not_there_is_404(self):
self.assertEqual(self.post(90, page=7)[0].status_code, 404)
def test_no_vision_engine_is_a_clear_400_and_the_page_is_untouched(self):
before = (self.d / "pages" / "0002.png").read_bytes()
r, created = self.post(90, vision=False)
self.assertEqual(r.status_code, 400)
self.assertIn("vision OCR engine", r.json()["detail"])
self.assertEqual((self.d / "pages" / "0002.png").read_bytes(), before)
self.assertEqual(created, [])
def test_page_images_are_revalidated_so_a_turned_page_shows_at_once(self):
r = self.client.get(f"/api/magazine/{self.doc}/pages/2")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.headers["cache-control"], "no-cache")
def test_stored_turns_reach_ingestion(self):
meta = {"page_rotations": {"3": 90, "4": "180", "5": 45, "x": 90, "0": 90}}
self.assertEqual(self.routes._page_rotations(meta), {3: 90, 4: 180})
if __name__ == "__main__":
unittest.main()