"""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 LadderTests(unittest.TestCase): """read_turned_page_regions: which reading wins, and how boxes from the original orientation are carried.""" GOOD = "Dies ist ein vollstaendiger deutscher Satz mit genug Woertern fuer eine brauchbare Lesung der Seite." def setUp(self): d = Path(tempfile.mkdtemp(prefix="ttvc-ladder-")) self.turned = d / "turned.png" Image.new("RGB", (200, 100), "white").save(self.turned) # the page after a 90 degree turn (was 100 wide, 200 tall) buf = __import__("io").BytesIO() Image.new("RGB", (100, 200), "white").save(buf, "PNG") self.original = buf.getvalue() self.calls = [] def run_ladder(self, vision, tesseract=None, which="/usr/bin/tesseract"): def chain(png, engines, timeout=0): self.calls.append(Path(png).name) return vision[len(self.calls) - 1] if len(self.calls) <= len(vision) else None with mock.patch.object(mag, "vision_ocr_page_chain", side_effect=chain), \ mock.patch.object(mag, "ocr_page", return_value=tesseract or []) as ocr, \ mock.patch("shutil.which", return_value=which): out = mag.read_turned_page_regions(self.turned, self.original, 90, [("http://x", "m")]) self.ocr = ocr return out def region(self, text, bbox=(10, 20, 30, 40)): return {"label": "text", "bbox": list(bbox), "text": text} def test_a_good_reading_of_the_turned_page_wins_at_once(self): regions, how = self.run_ladder([[self.region(self.GOOD)]]) self.assertEqual(how, "vision") self.assertEqual(self.calls, ["turned.png"]) # nothing else was tried self.ocr.assert_not_called() def test_when_the_engine_returns_nothing_the_original_orientation_is_read_and_its_boxes_carried_across(self): regions, how = self.run_ladder([None, [self.region(self.GOOD, (10, 20, 30, 40))]]) self.assertEqual(how, "vision-original") self.assertEqual(regions[0]["bbox"], [140, 10, 40, 30]) # rotate_bbox of the box on the 100x200 original, 90 degrees self.assertEqual(regions[0]["text"], self.GOOD) self.assertEqual(self.calls, ["turned.png", "original.png"]) def test_tesseract_is_the_last_rung(self): para = {"bbox": [5, 5, 50, 20], "text": self.GOOD, "confidence": 90, "line_height": 8} regions, how = self.run_ladder([None, None], tesseract=[para]) self.assertEqual(how, "tesseract") self.assertEqual(regions, [{"label": "text", "bbox": [5, 5, 50, 20], "text": self.GOOD}]) def test_a_short_or_implausible_reading_does_not_win_but_the_best_of_them_is_used_if_nothing_else_does(self): regions, how = self.run_ladder([[self.region("Seite 7")], [self.region("zzz qqq xxx")]], tesseract=[]) self.assertIn(how, ("vision", "vision-original")) self.assertTrue(regions) self.assertEqual(regions[0]["text"], "zzz qqq xxx" if how == "vision-original" else "Seite 7") def test_a_picture_page_is_an_empty_reading_not_a_failure_when_tesseract_ran(self): self.assertEqual(self.run_ladder([None, None], tesseract=[]), ([], "empty")) def test_without_tesseract_and_without_any_reading_it_is_a_failure(self): self.assertEqual(self.run_ladder([None, None], tesseract=[], which=None), (None, "failed")) def test_the_original_bytes_are_used_not_the_turned_file(self): self.run_ladder([None, [self.region(self.GOOD)]]) self.assertEqual(self.calls[1], "original.png") def test_bad_degrees_are_refused(self): with self.assertRaises(ValueError): mag.read_turned_page_regions(self.turned, self.original, 45, []) 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, ladder=None): ladder = ladder or (lambda *a, **k: ([{"label": "text", "bbox": [1, 1, 5, 5], "text": "x" * 50}], "vision")) with mock.patch.object(self.routes.mag, "reocr_page", side_effect=reread) as m, \ mock.patch.object(self.routes.mag, "read_turned_page_regions", side_effect=ladder) as lad: self.routes._reocr_page_sync("a" * 32, "job", 3, "http://ocr", "m", None, None, degrees) self.ladder = lad 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_a_turn_reads_through_the_ladder_and_hands_its_regions_to_the_re_read(self): regions = [{"label": "text", "bbox": [1, 2, 3, 4], "text": "gelesen"}] m = self.run_job(90, lambda d, layout, page, *a, **k: layout, ladder=lambda *a, **k: (regions, "vision-original")) self.assertEqual(m.call_args.kwargs["regions"], regions) self.assertEqual(self.jobs[-1]["result"]["read_with"], "vision-original") # the ladder gets the turned page, the bytes of the page as it was, and the turn args = self.ladder.call_args.args self.assertEqual((args[0], args[1], args[2]), (self.png, self.original, 90)) def test_when_nothing_can_read_the_page_it_is_put_back(self): self.run_job(90, lambda d, layout, page, *a, **k: layout, ladder=lambda *a, **k: (None, "failed")) self.assertEqual(self.png.read_bytes(), self.original) self.assertIn("Neither the OCR engines nor tesseract", self.jobs[-1]["error"]) self.assertNotIn("page_rotations", json.loads((self.d / "meta.json").read_text())) def test_a_picture_page_with_no_text_can_still_be_turned(self): self.run_job(90, lambda d, layout, page, *a, **k: layout, ladder=lambda *a, **k: ([], "empty")) self.assertEqual(self.jobs[-1]["stage"], "done") self.assertEqual(json.loads((self.d / "meta.json").read_text())["page_rotations"], {"3": 90}) def test_without_a_turn_the_job_behaves_as_before(self): self.run_job(0, lambda d, layout, page, *a, **k: layout) self.ladder.assert_not_called() 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()