"""Magazine layout analysis regressions. These cover the pure geometry/classification logic rather than PyMuPDF I/O: reading order across columns and article boundary detection are what decide whether an article is narrated from its true beginning to its true end, so they are the parts worth locking down. """ from __future__ import annotations import os import tempfile import shutil import pathlib import unittest from unittest import mock os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-magazine-test-config-")) from core.magazine import ( ocr_page, cluster_running_furniture, snap_visual_bboxes, _looks_like_repetition_loop, page_ink_grid, BLOCK_FIGURE, BLOCK_CHART, BLOCK_TABLE, looks_like_real_title, Article, BLOCK_AD, BLOCK_BODY, BLOCK_BYLINE, BLOCK_CAPTION, BLOCK_DECK, BLOCK_FURNITURE, BLOCK_PULLQUOTE, BLOCK_SIDEBAR, BLOCK_TITLE, Block, article_text, assign_images_to_articles, assign_reading_order, body_font_size, build_articles, build_vision_blocks, classify_blocks, reocr_page, add_manual_region, detect_columns, estimate_font_size_from_area, estimate_listen_seconds, find_continuation, find_repeated_lines, image_reading_position, strip_continuation_markers, group_articles, looks_like_byline, mark_advert_pages, mark_pullquotes, mark_sidebars, ocr_quality_score, parse_contents, parse_unlimited_ocr_regions, parse_vision_ocr_regions, score_contents_page, vision_ocr_page, vision_ocr_page_chain, describe_visual_region, describe_visual_region_chain, BLOCK_FIGURE_DESCRIPTION, ) PAGE_W, PAGE_H = 595.0, 842.0 # A4 in points def block(bid, page, x, y, w, h, text, size=10.0, btype=BLOCK_BODY): return Block(id=bid, page=page, bbox=[x, y, w, h], type=btype, text=text, font_size=size) class ColumnGeometryTests(unittest.TestCase): def test_two_columns_detected_from_the_gutter(self): blocks = [ block("a", 1, 50, 100, 200, 300, "left copy"), block("b", 1, 320, 100, 200, 300, "right copy"), ] cols = detect_columns(blocks, PAGE_W) self.assertEqual(len(cols), 2) self.assertLess(cols[0][1], cols[1][0]) def test_single_column_when_no_gutter(self): blocks = [ block("a", 1, 50, 100, 480, 100, "wide copy"), block("b", 1, 50, 220, 480, 100, "more wide copy"), ] self.assertEqual(len(detect_columns(blocks, PAGE_W)), 1) def test_reading_order_finishes_a_column_before_starting_the_next(self): """The failure this prevents: interleaving columns by y, which splices the left column's sentence into the right column's.""" blocks = [ block("l1", 1, 50, 100, 200, 80, "left top"), block("r1", 1, 320, 100, 200, 80, "right top"), block("l2", 1, 50, 200, 200, 80, "left bottom"), block("r2", 1, 320, 200, 200, 80, "right bottom"), ] order = [b.id for b in assign_reading_order(blocks, PAGE_W)] self.assertEqual(order, ["l1", "l2", "r1", "r2"]) def test_full_width_headline_precedes_both_columns(self): blocks = [ block("l1", 1, 50, 200, 200, 80, "left copy"), block("r1", 1, 320, 200, 200, 80, "right copy"), block("head", 1, 50, 80, 470, 60, "The Future of Batteries", size=24.0), ] order = [b.id for b in assign_reading_order(blocks, PAGE_W)] self.assertEqual(order[0], "head") def test_a_wide_caption_does_not_bridge_two_real_columns(self): """Grounded in real data from a live scanned c't magazine page: every block came back column=0, and reading order fell back to pure top-to-bottom, splicing the two columns' sentences together. Cause: a caption running under a photo at 49.9% of the page width -- under the "spans the whole page" threshold, so not given special handling -- sat in the whitespace gap between two real columns and merged them into one. Page width and block widths below are the real measured values (in points), simplified to two blocks per column.""" page_w = 583.2 blocks = [ block("caption", 1, 31.3, 268.0, 291.3, 23.4, "Caption under a photo, spanning most of the page."), block("l1", 1, 138.4, 345.4, 201.7, 55.8, "Left column body copy."), block("l2", 1, 138.9, 401.8, 198.9, 21.4, "Left column continues."), block("r1", 1, 352.4, 58.0, 199.2, 10.6, "Right column body copy."), block("r2", 1, 352.7, 79.8, 201.0, 44.0, "Right column continues."), ] ordered = assign_reading_order(blocks, page_w) cols = {b.id: b.column for b in ordered} self.assertNotEqual(cols["l1"], cols["r1"], "left and right column blocks landed in the same detected column") # The left column (l1, l2) must not be interleaved with the right # column (r1, r2) by y-position -- confirmed live as the actual # symptom: an earlier left-column block sorted after a later # right-column one purely because both were column=0. order = [b.id for b in ordered] self.assertLess(order.index("l1"), order.index("r2")) self.assertEqual(cols["l1"], cols["l2"]) self.assertEqual(cols["r1"], cols["r2"]) class ClassificationTests(unittest.TestCase): def test_body_size_is_not_hijacked_by_display_type(self): blocks = [ block("t", 1, 50, 50, 400, 60, "HUGE HEADLINE", size=40.0), block("b1", 1, 50, 150, 200, 200, "x" * 400, size=10.0), block("b2", 1, 320, 150, 200, 200, "y" * 400, size=10.0), ] self.assertAlmostEqual(body_font_size(blocks), 10.0, places=1) def test_headline_byline_and_body_are_separated(self): blocks = [ block("t", 1, 50, 60, 470, 50, "The Future of Batteries", size=26.0), block("by", 1, 50, 120, 200, 14, "By Jane Smith", size=10.0), block("b", 1, 50, 150, 200, 300, "Battery technology " * 40, size=10.0), ] classify_blocks(blocks, PAGE_H, PAGE_W) kinds = {b.id: b.type for b in blocks} self.assertEqual(kinds["t"], BLOCK_TITLE) self.assertEqual(kinds["by"], BLOCK_BYLINE) self.assertEqual(kinds["b"], BLOCK_BODY) def test_running_head_and_folio_become_furniture(self): blocks = [ block("head", 1, 50, 20, 200, 12, "SCIENTIFIC AMERICAN", size=8.0), block("folio", 1, 520, 820, 20, 12, "18", size=8.0), block("b", 1, 50, 150, 200, 300, "Body copy " * 40, size=10.0), ] classify_blocks(blocks, PAGE_H, PAGE_W, repeated={"SCIENTIFIC AMERICAN"}) kinds = {b.id: b.type for b in blocks} self.assertEqual(kinds["head"], BLOCK_FURNITURE) self.assertEqual(kinds["folio"], BLOCK_FURNITURE) self.assertEqual(kinds["b"], BLOCK_BODY) def test_small_text_next_to_a_figure_is_a_caption(self): """A photo page has no body copy of its own, so the baseline must come from the document -- otherwise the caption becomes its own reference.""" blocks = [block("cap", 1, 50, 420, 200, 20, "A lithium cell.", size=7.5)] classify_blocks(blocks, PAGE_H, PAGE_W, image_boxes=[[50, 200, 200, 210]], base_size=10.0) self.assertEqual(blocks[0].type, BLOCK_CAPTION) def test_small_text_away_from_any_figure_is_not_a_caption(self): blocks = [block("fn", 1, 50, 420, 200, 20, "See note 4.", size=7.5)] classify_blocks(blocks, PAGE_H, PAGE_W, image_boxes=[], base_size=10.0) self.assertNotEqual(blocks[0].type, BLOCK_CAPTION) def test_photo_page_caption_would_misclassify_on_a_per_page_baseline(self): """Guards the regression directly: no base_size means the lone caption measures itself and reads as body text.""" blocks = [block("cap", 1, 50, 420, 200, 20, "A lithium cell.", size=7.5)] classify_blocks(blocks, PAGE_H, PAGE_W, image_boxes=[[50, 200, 200, 210]]) self.assertEqual(blocks[0].type, BLOCK_BODY) def test_advertisement_marker_is_excluded(self): blocks = [block("ad", 1, 50, 50, 200, 20, "ADVERTISEMENT", size=9.0)] classify_blocks(blocks, PAGE_H, PAGE_W) self.assertEqual(blocks[0].type, BLOCK_AD) def test_an_advertisement_label_carries_its_whole_page(self): """The ad's own display type would otherwise read as a headline.""" blocks = [ block("m", 4, 50, 50, 200, 20, "ADVERTISEMENT", size=9.0), block("big", 4, 60, 400, 400, 60, "BUY OUR THING", size=40.0), block("folio", 4, 520, 820, 20, 12, "4", size=8.0), ] classify_blocks(blocks, PAGE_H, PAGE_W, repeated=set(), base_size=10.0) mark_advert_pages(blocks) kinds = {b.id: b.type for b in blocks} self.assertEqual(kinds["big"], BLOCK_AD) self.assertEqual(kinds["folio"], BLOCK_FURNITURE) def test_a_page_without_the_label_is_untouched(self): blocks = [ block("t", 3, 50, 60, 470, 50, "The Future of Batteries", size=26.0), block("b", 3, 50, 150, 200, 300, "Battery copy. " * 40, size=10.0), ] classify_blocks(blocks, PAGE_H, PAGE_W, base_size=10.0) mark_advert_pages(blocks) self.assertEqual(blocks[0].type, BLOCK_TITLE) def test_pullquote_repeating_body_copy_is_demoted(self): """Narrating a pull quote reads the same sentence twice.""" sentence = "Battery chemistry is entering an entirely new phase" blocks = [ block("b", 1, 50, 150, 200, 300, f"Some lead in. {sentence}. And more copy follows.", size=10.0), block("pq", 1, 320, 300, 200, 60, f"“{sentence}”", size=14.0), ] classify_blocks(blocks, PAGE_H, PAGE_W) mark_pullquotes(blocks) self.assertEqual(blocks[1].type, BLOCK_PULLQUOTE) def test_deck_that_does_not_repeat_body_stays_a_deck(self): blocks = [ block("b", 1, 50, 300, 200, 300, "Entirely different copy. " * 20, size=10.0), block("dk", 1, 50, 120, 470, 40, "Why the next decade belongs to solid state", size=14.0), ] classify_blocks(blocks, PAGE_H, PAGE_W) mark_pullquotes(blocks) self.assertEqual(blocks[1].type, BLOCK_DECK) def test_byline_rejects_a_sentence(self): self.assertEqual(looks_like_byline("By the time the cell charges, it is hot.")[0], False) self.assertEqual(looks_like_byline("By Jane Smith"), (True, "Jane Smith")) self.assertEqual(looks_like_byline("Von Jan Müller"), (True, "Jan Müller")) class FurnitureDetectionTests(unittest.TestCase): def test_lines_repeating_across_pages_are_furniture(self): pages = [["SCIENTIFIC AMERICAN", f"unique body {i}"] for i in range(10)] repeated = find_repeated_lines(pages) self.assertIn("SCIENTIFIC AMERICAN", repeated) self.assertNotIn("unique body 3", repeated) def test_no_false_positives_on_a_short_document(self): self.assertEqual(find_repeated_lines([["a"], ["b"]]), set()) class ContentsPageTests(unittest.TestCase): def test_contents_page_scores_above_a_feature_page(self): toc = [ "Contents", "Features", "The future of batteries ......... 18", "Inside the deep ocean ........... 30", "The new space race .............. 44", "Departments", "Books ........................... 60", "Reviews ......................... 64", ] feature = ["The Future of Batteries", "By Jane Smith", "Battery technology is entering a new phase. " * 20] self.assertGreater(score_contents_page(toc), 0.45) self.assertLess(score_contents_page(feature), 0.45) def test_entries_parse_with_section_and_page(self): entries = parse_contents([ "Contents", "Features", "The future of batteries ......... 18", "Inside the deep ocean ........... 30", "Departments", "Reviews ......................... 64", ]) self.assertEqual(len(entries), 3) self.assertEqual(entries[0], {"title": "The future of batteries", "page": 18, "section": "Features"}) self.assertEqual(entries[2]["section"], "Departments") def test_garbage_entries_are_rejected(self): self.assertEqual(parse_contents(["ab .... 99999", "x . 3"]), []) class ContinuationTests(unittest.TestCase): def test_continued_on_and_from_are_found(self): self.assertEqual(find_continuation("(continued on page 88)")[0], 88) self.assertEqual(find_continuation("Fortsetzung auf Seite 90")[0], 90) self.assertEqual(find_continuation("continued from page 27")[1], 27) def test_plain_text_has_no_continuation(self): self.assertEqual(find_continuation("The page turned slowly."), (None, None)) class OcrParagraphGroupingTests(unittest.TestCase): """ocr_page() groups tesseract's TSV by paragraph, not by line -- matching how the native PyMuPDF path already aggregates a paragraph's spans into one Block with one representative font size. One Block per line let a single anomalous line (a chart number, scan noise, a headline's wrapped second line) independently trigger BLOCK_TITLE classification; confirmed live as the dominant cause of a 28-page scan producing ~11 "title" blocks per page instead of grouping around real headlines.""" TSV_HEADER = "level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext" def _row(self, block, par, line, word_num, left, top, width, height, conf, text): return f"5\t1\t{block}\t{par}\t{line}\t{word_num}\t{left}\t{top}\t{width}\t{height}\t{conf}\t{text}" def _run(self, rows): tsv = "\n".join([self.TSV_HEADER] + rows) fake = mock.Mock(stdout=tsv) with mock.patch("subprocess.run", return_value=fake): return ocr_page(pathlib.Path("/fake.png")) def test_two_lines_of_one_paragraph_become_a_single_block(self): rows = [ self._row(1, 1, 1, 1, 10, 100, 40, 20, 92, "Battery"), self._row(1, 1, 1, 2, 55, 100, 40, 20, 91, "technology"), self._row(1, 1, 2, 1, 10, 125, 60, 20, 90, "continues"), ] out = self._run(rows) self.assertEqual(len(out), 1) self.assertEqual(out[0]["text"], "Battery technology continues") def test_font_size_uses_median_line_height_not_total_bbox_height(self): """The regression this guards: a paragraph's bbox spans every line it contains, so using that height as font_size scales with line count. A 5-line normal paragraph would then misread as giant display type.""" rows = [self._row(1, 1, i, 1, 10, 100 + i * 20, 200, 18, 90, "word") for i in range(1, 6)] out = self._run(rows) self.assertEqual(len(out), 1) # Combined bbox height spans 5 lines (~118px); the real type size is # each line's own ~18px height. self.assertLess(out[0]["line_height"], out[0]["bbox"][3]) self.assertAlmostEqual(out[0]["line_height"], 18.0, delta=1.0) def test_one_anomalous_line_does_not_dominate_the_paragraph_median(self): """A single chart-axis number or scan artifact inside an otherwise normal paragraph must not make the whole paragraph read as a title.""" rows = [ self._row(1, 1, 1, 1, 10, 100, 40, 18, 90, "normal"), self._row(1, 1, 2, 1, 10, 120, 40, 95, 90, "GIANT"), # one outlier line self._row(1, 1, 3, 1, 10, 220, 40, 19, 90, "normal"), ] out = self._run(rows) self.assertLess(out[0]["line_height"], 30.0) def test_low_confidence_words_are_dropped(self): rows = [ self._row(1, 1, 1, 1, 10, 100, 40, 18, 90, "good"), self._row(1, 1, 1, 2, 55, 100, 40, 18, 5, "garbage"), ] out = self._run(rows) self.assertEqual(out[0]["text"], "good") def test_different_paragraphs_stay_separate_blocks(self): rows = [ self._row(1, 1, 1, 1, 10, 100, 40, 18, 90, "first"), self._row(1, 2, 1, 1, 10, 300, 40, 18, 90, "second"), ] out = self._run(rows) self.assertEqual(len(out), 2) def test_tesseract_failure_returns_empty_not_an_exception(self): with mock.patch("subprocess.run", side_effect=FileNotFoundError()): self.assertEqual(ocr_page(pathlib.Path("/fake.png")), []) class RunningFurnitureFuzzyMatchTests(unittest.TestCase): """Grounded in real OCR output from a scanned c't magazine: a running footer ("c't Wissen 2026") was read differently on nearly every page, defeating exact-match repetition and producing 26 near-1-per-page "articles" on a 28-page scan instead of grouping around real headlines.""" def _footer(self, page, text, y=800): b = block(f"f{page}", page, 60, y, 200, 18, text, size=18.3, btype=BLOCK_TITLE) return b def test_ocr_noisy_footer_variants_cluster_as_furniture(self): variants = ["c't Wissen 2026", "cE KI-WISSEN", "ce Wissen 2136", "cA Wissen 2036", "Wissen 205", "Wissen 2026", "Wissen 2025", "ce Wissen 206"] blocks = [self._footer(i + 1, v) for i, v in enumerate(variants)] heights = {b.page: PAGE_H for b in blocks} cluster_running_furniture(blocks, heights) self.assertTrue(all(b.type == BLOCK_FURNITURE for b in blocks), [b.type for b in blocks]) def test_a_real_headline_appearing_once_is_not_reclassified(self): blocks = [self._footer(1, "Wissen 2026"), self._footer(2, "Wissen 2026"), self._footer(3, "Wissen 2026")] headline = block("h1", 4, 50, 60, 470, 50, "The Future of Solid State Batteries", size=26.0, btype=BLOCK_TITLE) blocks.append(headline) heights = {b.page: PAGE_H for b in blocks} cluster_running_furniture(blocks, heights) self.assertEqual(headline.type, BLOCK_TITLE) def test_fewer_than_min_count_repeats_are_left_alone(self): """Two coincidental near-matches should not be enough -- avoids reclassifying a genuinely rare pair of similar short headlines.""" blocks = [self._footer(1, "Wissen 2026"), self._footer(2, "Wissen 2036")] heights = {b.page: PAGE_H for b in blocks} cluster_running_furniture(blocks, heights) self.assertTrue(all(b.type == BLOCK_TITLE for b in blocks)) def test_body_position_text_is_never_reclassified_regardless_of_repetition(self): """Only near-edge blocks are candidates -- a repeated body phrase (e.g. a slogan) must not be swept into furniture.""" blocks = [block(f"b{i}", i + 1, 50, 400, 200, 20, "Wissen 2026", size=18.3) for i in range(5)] heights = {b.page: PAGE_H for b in blocks} cluster_running_furniture(blocks, heights) self.assertTrue(all(b.type == BLOCK_BODY for b in blocks)) def test_heavily_truncated_ocr_fragment_does_not_falsely_join(self): variants = ["Arbeitswelt & Wirtschaft"] * 3 + ["& Wi"] blocks = [self._footer(i + 1, v) for i, v in enumerate(variants)] heights = {b.page: PAGE_H for b in blocks} cluster_running_furniture(blocks, heights) # The three clean repeats still cluster... self.assertEqual(sum(1 for b in blocks if b.type == BLOCK_FURNITURE), 3) # ...the badly truncated one is left for a human to catch in review. self.assertEqual(blocks[3].type, BLOCK_TITLE) class TitlePlausibilityTests(unittest.TestCase): def test_ocr_noise_fragments_are_rejected(self): for junk in ("I", "@)X", ". |", "12", ""): self.assertFalse(looks_like_real_title(junk), junk) def test_real_headlines_are_accepted(self): for title in ("Wie KI Entwickler-Jobs verändert", "AI Jobs", "Batteries"): self.assertTrue(looks_like_real_title(title), title) def test_single_garbled_title_does_not_start_an_empty_article(self): """The exact failure seen live: a stray 'I' at running-footer size on an otherwise bodyless page must not spawn its own article.""" blocks = [block("b1", 10, 50, 800, 40, 18, "I", size=18.3, btype=BLOCK_TITLE)] blocks[0].reading_order = 0 self.assertEqual(group_articles(blocks, [], page_count=28), []) class ArticleGroupingTests(unittest.TestCase): def _issue_blocks(self): blocks = [ block("b0018-000", 18, 50, 60, 470, 50, "The Future of Batteries", size=26.0, btype=BLOCK_TITLE), block("b0018-001", 18, 50, 120, 200, 14, "By Jane Smith", size=10.0, btype=BLOCK_BYLINE), block("b0018-002", 18, 50, 150, 200, 300, "Battery copy. " * 40, size=10.0), block("b0030-000", 30, 50, 60, 470, 50, "Inside the Deep Ocean", size=26.0, btype=BLOCK_TITLE), block("b0030-001", 30, 50, 150, 200, 300, "Ocean copy. " * 40, size=10.0), ] for i, b in enumerate(blocks): b.reading_order = i return blocks def test_contents_entries_anchor_article_boundaries(self): contents = [ {"title": "The future of batteries", "page": 18, "section": "Features"}, {"title": "Inside the deep ocean", "page": 30, "section": "Features"}, ] arts = group_articles(self._issue_blocks(), contents, page_count=40) self.assertEqual(len(arts), 2) self.assertEqual(arts[0].pages[0], 18) self.assertEqual(arts[0].author, "Jane Smith") self.assertEqual(arts[1].pages[0], 30) # The first article must stop before the second begins. self.assertNotIn(30, arts[0].pages) def test_headline_starts_an_article_without_a_contents_entry(self): arts = group_articles(self._issue_blocks(), [], page_count=40) self.assertEqual(len(arts), 2) self.assertLess(arts[0].confidence, 0.9) # weaker signal, flagged for review def test_furniture_and_ads_never_enter_an_article(self): blocks = self._issue_blocks() ad = block("b0019-000", 19, 50, 50, 400, 400, "ADVERTISEMENT", size=9.0, btype=BLOCK_AD) folio = block("b0019-001", 19, 520, 820, 20, 12, "19", size=8.0, btype=BLOCK_FURNITURE) blocks.extend([ad, folio]) arts = group_articles(blocks, [{"title": "The future of batteries", "page": 18, "section": ""}], page_count=40) self.assertNotIn("b0019-000", arts[0].blocks) self.assertNotIn("b0019-001", arts[0].blocks) def test_an_advert_page_between_features_is_not_claimed(self): """The ad page starts no article, so it must not be swallowed by the preceding one and highlighted as part of that feature.""" blocks = self._issue_blocks() ad = block("b0019-000", 19, 50, 50, 400, 400, "ADVERTISEMENT", size=9.0, btype=BLOCK_AD) ad.reading_order = 50 blocks.append(ad) arts = group_articles(blocks, [ {"title": "The future of batteries", "page": 18, "section": ""}, {"title": "Inside the deep ocean", "page": 30, "section": ""}, ], page_count=40) self.assertNotIn(19, arts[0].pages) self.assertEqual(arts[0].pages, [18]) def test_a_jump_tail_is_absorbed_into_its_originating_article(self): blocks = self._issue_blocks() blocks[2].text += " (continued on page 88)" tail_title = block("b0088-000", 88, 50, 60, 470, 30, "Batteries, continued", size=18.0, btype=BLOCK_TITLE) tail_body = block("b0088-001", 88, 50, 110, 200, 300, "The rest of the battery copy. " * 20, size=10.0) tail_title.reading_order, tail_body.reading_order = 90, 91 blocks.extend([tail_title, tail_body]) arts = group_articles(blocks, [{"title": "The future of batteries", "page": 18, "section": ""}], page_count=90) first = arts[0] self.assertIn(88, first.pages) self.assertIn("b0088-001", first.blocks) # and the tail is no longer a standalone stub self.assertTrue(all("continued" not in a.title.lower() for a in arts[1:])) def test_a_page_with_only_a_headline_starts_no_article(self): """Covers and full-page adverts carry display type but no copy; each would otherwise become an entry the reviewer rejects on every issue.""" blocks = [block("b0005-000", 5, 50, 60, 470, 50, "Special Offer", size=30.0, btype=BLOCK_TITLE)] blocks[0].reading_order = 0 self.assertEqual(group_articles(blocks, [], page_count=10), []) def test_a_listed_article_with_no_body_copy_is_low_confidence(self): """A contents entry still anchors an article, but an empty one is flagged rather than silently trusted.""" blocks = [block("b0005-000", 5, 50, 60, 470, 50, "Special Offer", size=30.0, btype=BLOCK_TITLE)] blocks[0].reading_order = 0 arts = group_articles(blocks, [{"title": "Special Offer", "page": 5, "section": ""}], page_count=10) self.assertEqual(len(arts), 1) self.assertLess(arts[0].confidence, 0.4) def test_the_contents_page_is_not_itself_an_article(self): blocks = self._issue_blocks() toc = block("b0002-000", 2, 50, 90, 300, 40, "Contents", size=28.0, btype=BLOCK_TITLE) toc_entry = block("b0002-001", 2, 60, 175, 300, 20, "The future of batteries ......... 18", size=11.0) toc.reading_order, toc_entry.reading_order = 0, 1 blocks.extend([toc, toc_entry]) arts = group_articles(blocks, [], page_count=40, skip_pages={2}) self.assertTrue(all(a.pages[0] != 2 for a in arts)) class OcrQualityScoreTests(unittest.TestCase): """Grounded in real output from both OCR paths on a scanned c't magazine. Deliberately coarse: it only needs to tell "mostly garbage" from "plausible prose" well enough to gate an expensive vision-OCR escalation -- not to catch a subtle character substitution inside an otherwise normal sentence (confirmed live: a misread "KI" -> "K |" inside a real German sentence still scores high, since 7 of its 8 words are genuine; that class of error needs a dictionary or language-model check). """ def test_pure_symbol_noise_scores_at_or_near_zero(self): for junk in (". |", "@)X", ""): self.assertLess(ocr_quality_score(junk), 0.4, junk) def test_clean_prose_scores_high(self): for text in ( "Damit stellen die Forscher das Hauptversprechen der KI-Branche infrage.", "1. Irrtum: KI ist ein allgemeiner Jobkiller", ): self.assertGreater(ocr_quality_score(text), 0.85, text) def test_a_single_misread_word_in_an_otherwise_real_sentence_is_not_caught(self): """Documents the known limitation rather than hiding it: this is a coarse gate, not an accuracy detector.""" text = "K | soll Arbeit abnehmen, die Produktivität stei-" self.assertGreater(ocr_quality_score(text), 0.7, text) def test_empty_and_whitespace_only_are_zero(self): self.assertEqual(ocr_quality_score(""), 0.0) self.assertEqual(ocr_quality_score(" "), 0.0) class FontSizeAreaEstimateTests(unittest.TestCase): """A vision OCR engine returns one box per paragraph, not per line (see vision_ocr_page's docstring) -- there is no per-line height to read a font size from, so it must be estimated from the paragraph's area and its own text length.""" def test_longer_text_in_the_same_box_implies_a_smaller_font(self): short = estimate_font_size_from_area(300, 100, text_length=50) long_ = estimate_font_size_from_area(300, 100, text_length=500) self.assertGreater(short, long_) def test_degenerate_inputs_return_zero_not_an_error(self): self.assertEqual(estimate_font_size_from_area(0, 100, 50), 0.0) self.assertEqual(estimate_font_size_from_area(100, 0, 50), 0.0) self.assertEqual(estimate_font_size_from_area(100, 100, 0), 0.0) def test_a_plausible_headline_box_yields_a_plausible_display_size(self): # "1. Irrtum: KI ist ein allgemeiner Jobkiller" in a 309x42 box. size = estimate_font_size_from_area(309, 42, text_length=44) self.assertGreater(size, 10.0) self.assertLess(size, 40.0) class VisionOcrRegionParsingTests(unittest.TestCase): """Grounded in ocr-baidu-unlimited-ocr's actual response shape, confirmed live against a real scanned c't magazine page.""" REAL_SAMPLE = ( " Do not use special punctuation or formatting as visual depts. " "Special formats such as Oalemium namespace.\n" "text [260, 86, 589, 248]Killo Bertine in Brackmur et B. Bruche der " "IT-Branche.\n" "title [605, 294, 914, 336]1. Irrtum: KI ist ein allgemeiner Jobkiller\n" "footer [65, 953, 143, 965]c.t KI Wissen 2026\n" "footer [810, 948, 924, 960]Arbeitswelt & Wirtschaft\n" "page_number [945, 945, 959, 959]7" ) def test_preamble_before_the_first_marker_is_discarded(self): """The engine sometimes prepends instruction-echoing junk, including the literal decoy phrase '[No text detected]', before real output.""" regions = parse_vision_ocr_regions(self.REAL_SAMPLE) self.assertNotIn("Do not use", regions[0]["text"]) self.assertNotIn("namespace", regions[0]["text"]) def test_every_labeled_region_is_recovered(self): regions = parse_vision_ocr_regions(self.REAL_SAMPLE) labels = [r["label"] for r in regions] self.assertEqual(labels, ["text", "title", "footer", "footer", "page_number"]) def test_bbox_is_corner_to_corner_not_xywh(self): regions = parse_vision_ocr_regions(self.REAL_SAMPLE) footer = regions[2] self.assertEqual(footer["bbox"], [65.0, 953.0, 78.0, 12.0]) def test_page_number_text_is_preserved(self): regions = parse_vision_ocr_regions(self.REAL_SAMPLE) self.assertEqual(regions[-1]["text"], "7") def test_completely_unstructured_output_yields_no_regions(self): self.assertEqual(parse_vision_ocr_regions("just some prose, no markers"), []) def test_empty_input_yields_no_regions(self): self.assertEqual(parse_vision_ocr_regions(""), []) def test_a_degenerate_zero_area_box_is_dropped(self): self.assertEqual(parse_vision_ocr_regions("text [10,10,10,10]junk"), []) class VisionBlockBuildingTests(unittest.TestCase): """An explicit engine label (footer/title/page_number/caption) is trusted outright rather than re-derived from font size, which vision OCR gives no reliable measurement of -- only "text" (or an unrecognised label) is left ambiguous for the normal classification pass.""" def test_footer_label_becomes_furniture_and_is_protected_from_reclassification(self): regions = [{"label": "footer", "bbox": [10, 900, 100, 20], "text": "c.t Wissen 2026"}] blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b") self.assertEqual(blocks[0].type, BLOCK_FURNITURE) self.assertEqual(blocks[0].source, "vision_label") # classify_blocks must leave a vision_label block alone even though # its heuristics, run in isolation, would call it something else. classify_blocks(blocks, page_height=1049, page_width=897) self.assertEqual(blocks[0].type, BLOCK_FURNITURE) def test_title_label_becomes_title_and_survives_classification(self): regions = [{"label": "title", "bbox": [605, 294, 914, 336], "text": "1. Irrtum: KI ist ein allgemeiner Jobkiller"}] blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b") classify_blocks(blocks, page_height=1049, page_width=897) self.assertEqual(blocks[0].type, BLOCK_TITLE) def test_page_number_label_becomes_furniture(self): regions = [{"label": "page_number", "bbox": [945, 945, 959, 959], "text": "7"}] blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b") self.assertEqual(blocks[0].type, BLOCK_FURNITURE) def test_caption_label_becomes_caption(self): regions = [{"label": "caption", "bbox": [10, 10, 100, 20], "text": "A lithium cell."}] blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b") self.assertEqual(blocks[0].type, BLOCK_CAPTION) def test_semantic_groups_join_fragments_and_keep_continuation(self): from core.magazine import apply_semantic_regions blocks = [Block('a', 1, [10,10,100,20], 'body', 'A para-').to_json(), Block('b', 1, [10,30,100,20], 'body', 'graph continues.').to_json(), Block('c', 1, [120,10,100,20], 'sidebar', 'Editorial right column').to_json()] groups = apply_semantic_regions(blocks, [ {'type': 'paragraph', 'block_ids': ['a', 'b'], 'continues_from_previous_page': True}, {'type': 'paragraph', 'block_ids': ['c']}]) self.assertEqual(groups[0]['text'], 'A paragraph continues.') self.assertEqual(groups[0]['bbox'], [10,10,100,40]) self.assertTrue(groups[0]['continues_from_previous_page']) self.assertEqual(groups[1]['type'], 'body') def test_semantic_group_cannot_absorb_editorial_into_advert(self): from core.magazine import apply_semantic_regions blocks = [Block('a', 1, [0,0,100,20], 'body', 'Editorial').to_json(), Block('b', 1, [0,30,100,20], 'ad', 'Buy tickets').to_json()] with self.assertRaises(ValueError): apply_semantic_regions(blocks, [{'type':'advertisement', 'block_ids':['a','b']}]) for ids in (['invented'], ['a','a']): with self.assertRaises(ValueError): apply_semantic_regions(blocks, [{'type':'paragraph', 'block_ids':ids}]) self.assertEqual(apply_semantic_regions(blocks, [ {'type':'paragraph', 'block_ids':['a']}])[1], blocks[1]) def test_semantic_layout_can_add_an_image_missed_by_ocr(self): from core.magazine import apply_semantic_regions page = {'page': 7, 'width': 600.0, 'height': 800.0} blocks = [Block('headline', 7, [50,400,500,50], 'title', 'Feature').to_json()] grouped = apply_semantic_regions(blocks, [ {'type': 'image', 'block_ids': [], 'bbox': [100,50,900,450]}, {'type': 'title', 'block_ids': ['headline']}, ], page) self.assertEqual(grouped[0]['type'], 'figure') self.assertEqual(grouped[0]['bbox'], [60.0, 40.0, 480.0, 320.0]) self.assertEqual(grouped[1]['type'], 'title') def test_reocr_remaps_article_ids_and_excludes_adverts(self): from core.magazine import remap_reviewed_page old = {'blocks': [Block('old', 1, [0,0,200,200], 'body', 'Original').to_json()]} updated = {'blocks': [Block('new', 1, [0,0,200,100], 'body', 'New copy').to_json(), Block('ad', 1, [0,100,200,100], 'ad', 'Buy now').to_json()], 'images': []} articles = [{'id': 'article-001', 'title': 'Feature', 'pages': [1], 'blocks': ['old'], 'images': [], 'status': 'approved'}] remap_reviewed_page(articles, old, updated, 1) self.assertEqual(articles[0]['blocks'], ['new']) self.assertEqual(articles[0]['status'], 'detected') def test_requested_region_types_preserve_layout_and_code(self): raw = ("title [0,0,200,30]Headline\nparagraph [0,35,200,100]Article paragraph.\n" "image [0,110,100,200]\nchart [110,110,210,200]\n" "table [0,210,210,280]| A | B |\n| 1 | 2 |\n" "code [0,290,210,370]if ready:\n run()\npage_number [0,380,20,400]7") regions = parse_vision_ocr_regions(raw) blocks = build_vision_blocks(regions, 1, 1, 'b') classify_blocks(blocks, 400, 220) self.assertEqual([b.type for b in blocks], ['title', 'body', 'figure', 'chart', 'table', 'code', 'furniture']) self.assertEqual(blocks[5].text, 'if ready:\n run()') self.assertEqual(blocks[6].region_label, 'page_number') from core.magazine import article_text narrated = article_text({'blocks': [b.to_json() for b in blocks]}, {'blocks': [b.id for b in blocks]}) self.assertEqual([b['type'] for b in narrated], ['title', 'body']) def test_region_ad_does_not_exclude_adjacent_editorial(self): regions = [{"label": "paragraph", "bbox": [0,0,200,100], "text": "Editorial copy."}, {"label": "advertisement", "bbox": [0,120,200,100], "text": "Buy tickets."}] blocks = build_vision_blocks(regions, 1, 1, 'b') mark_advert_pages(blocks) self.assertEqual([b.type for b in blocks], ['body', 'ad']) def test_text_label_is_ambiguous_and_goes_through_normal_classification(self): regions = [{"label": "text", "bbox": [50, 100, 400, 300], "text": "Ordinary paragraph copy. " * 10}] blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b") self.assertEqual(blocks[0].source, "vision_ocr") classify_blocks(blocks, page_height=1049, page_width=897, base_size=10.0) self.assertEqual(blocks[0].type, BLOCK_BODY) def test_an_unrecognised_label_is_also_treated_as_ambiguous(self): regions = [{"label": "sidebar_thing", "bbox": [50, 100, 400, 300], "text": "Some text"}] blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b") self.assertEqual(blocks[0].source, "vision_ocr") def test_scale_factor_is_applied_to_the_bbox(self): regions = [{"label": "footer", "bbox": [100, 200, 300, 220], "text": "x"}] blocks = build_vision_blocks(regions, page_no=1, scale_x=0.5, id_prefix="b") self.assertEqual(blocks[0].bbox, [50.0, 100.0, 150.0, 110.0]) def test_block_ids_are_unique_and_page_scoped(self): regions = [{"label": "text", "bbox": [0, 0, 10, 10], "text": "a"}, {"label": "text", "bbox": [0, 20, 10, 10], "text": "b"}] blocks = build_vision_blocks(regions, page_no=3, scale_x=1.0, id_prefix="b") self.assertEqual(len({b.id for b in blocks}), 2) self.assertTrue(all("0003" in b.id for b in blocks)) class VisionOcrPageCallTests(unittest.TestCase): """The HTTP call itself, with the network mocked -- vision_ocr_page must degrade to None on any failure so ingestion can fall back to tesseract rather than fail outright.""" def _resp(self, status=200, json_body=None): import requests resp = mock.Mock(status_code=status) resp.raise_for_status = mock.Mock( side_effect=None if status < 400 else requests.exceptions.HTTPError(f"HTTP {status}")) resp.json = mock.Mock(return_value=json_body or {}) return resp def test_successful_call_returns_parsed_regions(self): body = {"choices": [{"message": {"content": "text [10,10,110,60]Hello there, this is a real sentence."}}]} with mock.patch("core.magazine.request_with_retries", return_value=self._resp(json_body=body)): out = vision_ocr_page(pathlib.Path(__file__), "http://engine/v1", "model-x") self.assertIsNotNone(out) self.assertEqual(out[0]["label"], "text") def test_unlimited_ocr_request_enables_its_required_repeat_guard(self): body = {"choices": [{"message": {"content": "<|det|>chart [10,10,110,60]<|/det|>"}}]} with mock.patch("core.magazine.request_with_retries", return_value=self._resp(json_body=body)) as request, \ mock.patch("PIL.Image.open") as mopen: mopen.return_value.__enter__.return_value.size = (1000, 1400) self.assertIsNotNone(vision_ocr_page(pathlib.Path(__file__), "http://engine/v1", "baidu/Unlimited-OCR")) payload = request.call_args.kwargs["json"] self.assertFalse(payload["skip_special_tokens"]) self.assertEqual(payload["ngram_size"], 35) self.assertEqual(payload["window_size"], 128) self.assertEqual(payload["images_config"], {"image_mode": "gundam"}) self.assertEqual(payload["messages"][0]["content"][0]["text"], "<|grounding|>Convert the document to markdown.") def test_other_vision_engines_do_not_receive_unlimited_ocr_options(self): body = {"choices": [{"message": {"content": "text [10,10,110,60]Hello there, this is a real sentence."}}]} with mock.patch("core.magazine.request_with_retries", return_value=self._resp(json_body=body)) as request: self.assertIsNotNone(vision_ocr_page(pathlib.Path(__file__), "http://engine/v1", "other-vision")) payload = request.call_args.kwargs["json"] self.assertNotIn("ngram_size", payload) self.assertNotIn("window_size", payload) def test_a_repeated_phrase_loop_is_treated_as_a_failed_call(self): """Confirmed live against a Qwen VL engine on a busy chart page: the response degenerated into "das Produkt, " repeated hundreds of times, filling the whole response budget. Baking that into a block's text would narrate it verbatim, so it must come back as a failure -- giving vision_ocr_page_chain's fallback engine, then tesseract, a real chance -- not as a labelled region.""" body = {"choices": [{"message": {"content": "text [10,10,880,900]Das Weitere Produkt, " + "das Produkt, " * 300}}]} with mock.patch("core.magazine.request_with_retries", return_value=self._resp(json_body=body)): self.assertIsNone(vision_ocr_page(pathlib.Path(__file__), "http://engine/v1", "qwen-vl")) class DescribeVisualRegionTests(unittest.TestCase): """describe_visual_region() is a deliberately different task from vision_ocr_page(): that prompt forbids inventing a description, this one asks for exactly that -- but shares the same reliability guards.""" def _resp(self, status=200, json_body=None): import requests resp = mock.Mock(status_code=status) resp.raise_for_status = mock.Mock( side_effect=None if status < 400 else requests.exceptions.HTTPError(f"HTTP {status}")) resp.json = mock.Mock(return_value=json_body or {}) return resp def test_successful_call_returns_the_description(self): body = {"choices": [{"message": {"content": "A bar chart showing sales rising over five years."}}]} with mock.patch("core.magazine.request_with_retries", return_value=self._resp(json_body=body)): got = describe_visual_region(pathlib.Path(__file__), BLOCK_CHART, "http://engine/v1", "m") self.assertEqual(got, "A bar chart showing sales rising over five years.") def test_prompt_is_chosen_by_region_type(self): body = {"choices": [{"message": {"content": "A description long enough to pass."}}]} with mock.patch("core.magazine.request_with_retries", return_value=self._resp(json_body=body)) as request: describe_visual_region(pathlib.Path(__file__), BLOCK_TABLE, "http://engine/v1", "m") prompt = request.call_args.kwargs["json"]["messages"][0]["content"][0]["text"] self.assertIn("table", prompt.lower()) self.assertNotIn("chart or graph", prompt.lower()) def test_unknown_region_type_falls_back_to_the_figure_prompt(self): body = {"choices": [{"message": {"content": "A description long enough to pass."}}]} with mock.patch("core.magazine.request_with_retries", return_value=self._resp(json_body=body)) as request: describe_visual_region(pathlib.Path(__file__), "something-odd", "http://engine/v1", "m") prompt = request.call_args.kwargs["json"]["messages"][0]["content"][0]["text"] self.assertIn("photo or illustration", prompt.lower()) def test_a_repeated_phrase_loop_is_rejected(self): body = {"choices": [{"message": {"content": "chart chart chart " * 20}}]} with mock.patch("core.magazine.request_with_retries", return_value=self._resp(json_body=body)): self.assertIsNone(describe_visual_region(pathlib.Path(__file__), BLOCK_CHART, "http://engine/v1", "m")) def test_too_short_a_response_is_rejected(self): body = {"choices": [{"message": {"content": "Yes."}}]} with mock.patch("core.magazine.request_with_retries", return_value=self._resp(json_body=body)): self.assertIsNone(describe_visual_region(pathlib.Path(__file__), BLOCK_FIGURE, "http://engine/v1", "m")) def test_empty_response_is_rejected(self): body = {"choices": [{"message": {"content": " "}}]} with mock.patch("core.magazine.request_with_retries", return_value=self._resp(json_body=body)): self.assertIsNone(describe_visual_region(pathlib.Path(__file__), BLOCK_FIGURE, "http://engine/v1", "m")) def test_a_long_response_is_cut_at_a_sentence_boundary(self): # Genuinely varied sentences -- a repeated one would (correctly) # trip the repetition-loop guard instead of exercising truncation. sentences = [f"This is plausible sentence number {i} about the figure." for i in range(20)] body = {"choices": [{"message": {"content": " ".join(sentences)}}]} with mock.patch("core.magazine.request_with_retries", return_value=self._resp(json_body=body)): got = describe_visual_region(pathlib.Path(__file__), BLOCK_FIGURE, "http://engine/v1", "m") self.assertLessEqual(len(got), 601) self.assertTrue(got.endswith(".")) def test_unreachable_engine_returns_none(self): import requests with mock.patch("core.magazine.request_with_retries", side_effect=requests.exceptions.ConnectionError()): self.assertIsNone(describe_visual_region(pathlib.Path(__file__), BLOCK_FIGURE, "http://engine/v1", "m")) def test_missing_file_returns_none(self): self.assertIsNone(describe_visual_region(pathlib.Path("/no/such/file.png"), BLOCK_FIGURE, "http://engine/v1", "m")) def test_chain_falls_back_to_the_second_engine(self): with mock.patch("core.magazine.describe_visual_region", side_effect=[None, "A usable description of the figure."]) as mocked: got = describe_visual_region_chain(pathlib.Path(__file__), BLOCK_FIGURE, [("http://a/v1", "m1"), ("http://b/v1", "m2")]) self.assertEqual(got, "A usable description of the figure.") self.assertEqual(mocked.call_count, 2) def test_chain_with_no_configured_engines_returns_none(self): self.assertIsNone(describe_visual_region_chain(pathlib.Path(__file__), BLOCK_FIGURE, [("", ""), (None, None)])) class UnlimitedOcrRegionTests(unittest.TestCase): # The URL/error-path tests below remain deliberately adjacent to the # native-marker tests; share the same mocked OpenAI response helper. def _resp(self, status=200, json_body=None): import requests resp = mock.Mock(status_code=status) resp.raise_for_status = mock.Mock( side_effect=None if status < 400 else requests.exceptions.HTTPError(f"HTTP {status}")) resp.json = mock.Mock(return_value=json_body or {}) return resp def test_native_detection_markers_preserve_visuals_and_page_furniture(self): raw = "\n".join([ "<|det|>text [10,20,900,180]<|/det|>Article body.", "<|det|>chart [100,210,900,650]<|/det|>", "<|det|>footer [10,930,700,960]<|/det|>Sources: OECD.", "<|det|>page_number [940,930,980,960]<|/det|>18", ]) regions = parse_unlimited_ocr_regions(raw) self.assertEqual([r["label"] for r in regions], ["text", "chart", "footer", "page_number"]) self.assertEqual(regions[1]["text"], "") self.assertTrue(all(r["native_normalized_bbox"] for r in regions)) def test_bare_server_url_gets_v1_and_explicit_paths_are_preserved(self): body = {"choices": [{"message": {"content": "text [10,10,110,60]Hello there, this is a real sentence."}}]} for base, expected in (("http://engine:8098", "http://engine:8098/v1"), ("http://engine:8098/", "http://engine:8098/v1"), ("http://engine/v1/", "http://engine/v1"), ("http://engine/proxy/v1", "http://engine/proxy/v1")): with self.subTest(base=base), mock.patch("core.magazine.request_with_retries", return_value=self._resp(json_body=body)) as request: self.assertIsNotNone(vision_ocr_page(pathlib.Path(__file__), base, "m")) self.assertEqual(request.call_args.args[1], expected + "/chat/completions") def test_unreachable_engine_returns_none(self): import requests with mock.patch("core.magazine.request_with_retries", side_effect=requests.exceptions.ConnectionError()): self.assertIsNone(vision_ocr_page(pathlib.Path(__file__), "http://engine/v1", "m")) def test_http_error_status_returns_none(self): with mock.patch("core.magazine.request_with_retries", return_value=self._resp(status=500)): self.assertIsNone(vision_ocr_page(pathlib.Path(__file__), "http://engine/v1", "m")) def test_unparseable_response_shape_returns_none(self): with mock.patch("core.magazine.request_with_retries", return_value=self._resp(json_body={"unexpected": "shape"})): self.assertIsNone(vision_ocr_page(pathlib.Path(__file__), "http://engine/v1", "m")) def test_output_with_zero_usable_regions_returns_none_not_empty_list(self): """An empty-but-truthy list would look like success to the caller.""" body = {"choices": [{"message": {"content": "no markers in here at all"}}]} with mock.patch("core.magazine.request_with_retries", return_value=self._resp(json_body=body)): self.assertIsNone(vision_ocr_page(pathlib.Path(__file__), "http://engine/v1", "m")) def test_missing_file_returns_none(self): self.assertIsNone(vision_ocr_page(pathlib.Path("/no/such/file.png"), "http://engine/v1", "m")) class ContinuationMarkerStrippingTests(unittest.TestCase): def test_a_print_layout_cue_is_removed_before_narration(self): """Spoken aloud this would read as literal parenthesised text -- a print convention, not article content.""" text = strip_continuation_markers( "The promises did not survive contact. (continued on page 8)") self.assertNotIn("continued", text.lower()) self.assertTrue(text.endswith("contact.")) def test_german_marker_is_also_removed(self): text = strip_continuation_markers("Mehr dazu. Fortsetzung auf Seite 90") self.assertNotIn("Seite", text) def test_ordinary_text_is_unaffected(self): self.assertEqual(strip_continuation_markers("Nothing special here."), "Nothing special here.") class ArticleTextTests(unittest.TestCase): def test_continuation_marker_is_stripped_from_narrated_output(self): """article_text() feeds TTS directly, so the print cue must not survive into what gets spoken -- even though it stays in layout.json for the review screen.""" layout = { "blocks": [{"id": "b1", "page": 1, "type": BLOCK_BODY, "reading_order": 0, "bbox": [0, 0, 1, 1], "text": "End of the thought. (continued on page 8)"}], } got = article_text(layout, {"blocks": ["b1"]}) self.assertNotIn("continued", got[0]["text"].lower()) def test_only_narratable_blocks_in_reading_order(self): layout = { "page_count": 2, "blocks": [ {"id": "b2", "page": 1, "type": BLOCK_BODY, "reading_order": 2, "text": "second", "bbox": [0, 0, 1, 1]}, {"id": "b1", "page": 1, "type": BLOCK_TITLE, "reading_order": 1, "text": "first", "bbox": [0, 0, 1, 1]}, {"id": "cap", "page": 1, "type": BLOCK_CAPTION, "reading_order": 3, "text": "a caption", "bbox": [0, 0, 1, 1]}, {"id": "ad", "page": 1, "type": BLOCK_AD, "reading_order": 4, "text": "buy this", "bbox": [0, 0, 1, 1]}, ], } article = {"blocks": ["b1", "b2", "cap", "ad"]} got = [b["text"] for b in article_text(layout, article)] self.assertEqual(got, ["first", "second"]) with_caps = [b["text"] for b in article_text(layout, article, include_captions=True)] self.assertEqual(with_caps, ["first", "second", "a caption"]) def test_described_image_is_narrated_at_its_reading_position(self): layout = { "blocks": [ {"id": "b1", "page": 1, "type": BLOCK_TITLE, "reading_order": 1, "text": "Headline", "bbox": [0, 0, 1, 1]}, {"id": "b2", "page": 1, "type": BLOCK_BODY, "reading_order": 3, "text": "Body after the figure", "bbox": [0, 0, 1, 1]}, ], "images": [ {"id": "img1", "page": 1, "bbox": [0, 0, 1, 1], "reading_order": 2, "description": "A photo of a cat."}, ], } article = {"blocks": ["b1", "b2"], "images": ["img1"]} got = article_text(layout, article, include_descriptions=True) self.assertEqual([b["text"] for b in got], ["Headline", "A photo of a cat.", "Body after the figure"]) self.assertEqual(got[1]["type"], BLOCK_FIGURE_DESCRIPTION) self.assertEqual(got[1]["page"], 1) def test_descriptions_are_off_by_default(self): layout = { "blocks": [{"id": "b1", "page": 1, "type": BLOCK_BODY, "reading_order": 1, "text": "Body", "bbox": [0, 0, 1, 1]}], "images": [{"id": "img1", "page": 1, "bbox": [0, 0, 1, 1], "reading_order": 0, "description": "A photo."}], } article = {"blocks": ["b1"], "images": ["img1"]} got = article_text(layout, article) self.assertEqual([b["text"] for b in got], ["Body"]) def test_an_undescribed_image_is_silently_skipped(self): """No description yet (or an empty one) -- nothing to narrate, not an empty sentence.""" layout = { "blocks": [{"id": "b1", "page": 1, "type": BLOCK_BODY, "reading_order": 1, "text": "Body", "bbox": [0, 0, 1, 1]}], "images": [{"id": "img1", "page": 1, "bbox": [0, 0, 1, 1], "reading_order": 0}], } article = {"blocks": ["b1"], "images": ["img1"]} got = article_text(layout, article, include_descriptions=True) self.assertEqual([b["text"] for b in got], ["Body"]) def test_a_described_image_belonging_to_another_article_is_excluded(self): layout = { "blocks": [{"id": "b1", "page": 1, "type": BLOCK_BODY, "reading_order": 1, "text": "Body", "bbox": [0, 0, 1, 1]}], "images": [{"id": "img1", "page": 1, "bbox": [0, 0, 1, 1], "reading_order": 0, "description": "Not this article's figure."}], } article = {"blocks": ["b1"], "images": []} got = article_text(layout, article, include_descriptions=True) self.assertEqual([b["text"] for b in got], ["Body"]) def test_listening_estimate_counts_only_narratable_words(self): blocks = [ Block(id="b1", page=1, bbox=[0, 0, 1, 1], type=BLOCK_BODY, text=" ".join(["word"] * 155)), Block(id="ad", page=1, bbox=[0, 0, 1, 1], type=BLOCK_AD, text=" ".join(["spam"] * 500)), ] from core.magazine import Article art = Article(id="a1", title="t", blocks=["b1", "ad"]) self.assertEqual(estimate_listen_seconds(blocks, art), 60) class BuildArticlesTests(unittest.TestCase): def test_best_contents_candidate_drives_grouping(self): layout = { "page_count": 40, "blocks": [ {"id": "b0018-000", "page": 18, "type": BLOCK_TITLE, "reading_order": 0, "text": "The Future of Batteries", "bbox": [50, 60, 470, 50], "font_size": 26.0, "font_name": "", "bold": True, "column": 0, "confidence": 0.9, "source": "native"}, {"id": "b0018-001", "page": 18, "type": BLOCK_BODY, "reading_order": 1, "text": "Battery copy. " * 40, "bbox": [50, 150, 200, 300], "font_size": 10.0, "font_name": "", "bold": False, "column": 0, "confidence": 0.85, "source": "native"}, ], "contents_candidates": [ {"page": 2, "score": 0.5, "entries": []}, {"page": 3, "score": 0.9, "entries": [ {"title": "The future of batteries", "page": 18, "section": "Features"}]}, ], } arts = build_articles(layout) self.assertEqual(len(arts), 1) self.assertEqual(arts[0]["section"], "Features") self.assertGreater(arts[0]["estimated_seconds"], 0) class SidebarDetectionTests(unittest.TestCase): """BLOCK_SIDEBAR is geometry-only, like the rest of this module: a boxed insert is reliably narrower than the page's regular column grid. This is a heuristic, not a certainty -- review still shows every sidebar block and lets it be ticked back in with one click.""" def test_a_narrow_third_column_is_marked_a_sidebar(self): blocks = [ block("main1", 1, 50, 100, 300, 400, "Main body copy. " * 40), block("side1", 1, 400, 100, 100, 400, "A boxed insert. " * 10), ] assign_reading_order(blocks, PAGE_W) mark_sidebars(blocks, PAGE_W) self.assertEqual(next(b for b in blocks if b.id == "side1").type, BLOCK_SIDEBAR) self.assertEqual(next(b for b in blocks if b.id == "main1").type, BLOCK_BODY) def test_two_similarly_wide_columns_are_left_alone(self): """A normal two-column layout must not be mistaken for a sidebar.""" blocks = [ block("l", 1, 50, 100, 200, 400, "Left column. " * 30), block("r", 1, 280, 100, 200, 400, "Right column. " * 30), ] assign_reading_order(blocks, PAGE_W) mark_sidebars(blocks, PAGE_W) self.assertTrue(all(b.type == BLOCK_BODY for b in blocks)) def test_a_single_column_page_has_no_sidebar_candidates(self): blocks = [block("a", 1, 50, 100, 480, 400, "Full width copy. " * 40)] assign_reading_order(blocks, PAGE_W) mark_sidebars(blocks, PAGE_W) self.assertEqual(blocks[0].type, BLOCK_BODY) def test_a_title_or_caption_in_the_narrow_column_is_untouched(self): """Only BLOCK_BODY is reclassified -- a caption already correctly typed should not be relabelled just for sitting in a narrow column.""" blocks = [ block("main1", 1, 50, 100, 300, 400, "Main body copy. " * 40), block("cap", 1, 400, 100, 100, 30, "A caption.", btype=BLOCK_CAPTION), ] assign_reading_order(blocks, PAGE_W) mark_sidebars(blocks, PAGE_W) self.assertEqual(next(b for b in blocks if b.id == "cap").type, BLOCK_CAPTION) def test_sidebar_text_is_excluded_from_narration_by_default(self): """End-to-end: a detected sidebar must not be swept into the article's narrated blocks the way an advert page already is not.""" blocks = [ block("t", 18, 50, 60, 470, 50, "The Future of Batteries", size=26.0, btype=BLOCK_TITLE), block("main1", 18, 50, 150, 300, 400, "Main body copy. " * 40), block("side1", 18, 400, 150, 100, 400, "Battery myths. " * 20), ] assign_reading_order(blocks, PAGE_W) mark_sidebars(blocks, PAGE_W) arts = group_articles(blocks, [{"title": "The Future of Batteries", "page": 18, "section": ""}], page_count=20) self.assertNotIn("side1", arts[0].blocks) self.assertIn("main1", arts[0].blocks) class ImageAssignmentTests(unittest.TestCase): """Extracted figures are useless until linked to the article they illustrate -- assign_images_to_articles is that link.""" def test_an_image_on_a_single_article_page_is_assigned_to_it(self): art = Article(id="a1", title="T", pages=[5], blocks=["b1"]) blocks = [block("b1", 5, 50, 100, 300, 50, "Some text")] images = [{"id": "img1", "page": 5, "bbox": [50, 200, 200, 150]}] assign_images_to_articles(images, blocks, [art]) self.assertEqual(art.images, ["img1"]) def test_an_image_on_a_page_with_no_matching_article_is_skipped(self): art = Article(id="a1", title="T", pages=[5], blocks=[]) images = [{"id": "img1", "page": 9, "bbox": [50, 200, 200, 150]}] assign_images_to_articles(images, [], [art]) self.assertEqual(art.images, []) def test_shared_page_image_goes_to_the_nearer_articles_blocks(self): """Two articles land on the same page (one ending, one starting) -- the figure must follow whichever article it actually sits beside.""" art_a = Article(id="a1", title="First", pages=[6], blocks=["ba"]) art_b = Article(id="a2", title="Second", pages=[6], blocks=["bb"]) blocks = [ block("ba", 6, 50, 50, 300, 100, "End of the first article."), block("bb", 6, 50, 500, 300, 100, "Start of the second article."), ] # The image sits right next to article B's block (y=520 vs y=500). images = [{"id": "img1", "page": 6, "bbox": [50, 520, 200, 150]}] assign_images_to_articles(images, blocks, [art_a, art_b]) self.assertEqual(art_a.images, []) self.assertEqual(art_b.images, ["img1"]) class ImageReadingPositionTests(unittest.TestCase): def test_image_takes_the_position_of_the_nearest_block_above_it(self): blocks = [ block("b1", 1, 50, 50, 300, 100, "First paragraph."), block("b2", 1, 50, 300, 300, 100, "Second paragraph."), ] blocks[0].reading_order = 0 blocks[1].reading_order = 1 image = {"page": 1, "bbox": [50, 320, 200, 150]} self.assertEqual(image_reading_position(image, blocks), 1) def test_image_above_all_text_falls_back_to_the_nearest_block(self): blocks = [block("b1", 1, 50, 300, 300, 100, "Paragraph.")] blocks[0].reading_order = 5 image = {"page": 1, "bbox": [50, 10, 200, 150]} self.assertEqual(image_reading_position(image, blocks), 5) def test_no_blocks_on_the_page_returns_zero(self): self.assertEqual(image_reading_position({"page": 9, "bbox": [0, 0, 1, 1]}, []), 0) class WholePageVisionFallbackTests(unittest.TestCase): """KDL Frontier Parser nano, confirmed live, answers with plain text and no coordinate markers at all -- unlike ocr-baidu-unlimited-ocr's labeled regions. _whole_page_region is what lets that still produce something usable rather than being discarded outright.""" def test_plain_text_with_no_markers_becomes_one_page_sized_region(self): from core.magazine import _whole_page_region with mock.patch("PIL.Image.open") as mopen: mopen.return_value.__enter__.return_value.size = (897, 1049) regions = _whole_page_region( "Battery technology is entering a new phase. " * 5, pathlib.Path("/fake.png")) self.assertEqual(len(regions), 1) self.assertEqual(regions[0]["label"], "text") self.assertEqual(regions[0]["bbox"], [0.0, 0.0, 897.0, 1049.0]) def test_garbage_text_is_rejected_not_wrapped(self): from core.magazine import _whole_page_region self.assertIsNone(_whole_page_region(". | @)X", pathlib.Path("/fake.png"))) def test_instruction_echo_with_repeated_literal_equations_is_rejected(self): """Don't save a model's formatting instruction as a whole-page OCR block.""" from core.magazine import _whole_page_region poisoned = "Do not use special symbols \\n `equation` \\n `equation` \\n `equation` \\n `equation`" with mock.patch("PIL.Image.open") as mopen: mopen.return_value.__enter__.return_value.size = (897, 1049) self.assertIsNone(_whole_page_region(poisoned, pathlib.Path("/fake.png"))) def test_repeated_fabricated_latex_loop_is_rejected(self): """Keep a vision model's repeated formula loop out of OCR text.""" from core.magazine import _whole_page_region poisoned = "Studien widerlegen. " + (r"\( \therefore m = \frac{3}{11} \) " * 6) with mock.patch("PIL.Image.open") as mopen: mopen.return_value.__enter__.return_value.size = (897, 1049) self.assertIsNone(_whole_page_region(poisoned, pathlib.Path("/fake.png"))) def test_too_short_text_is_rejected(self): from core.magazine import _whole_page_region self.assertIsNone(_whole_page_region("Hi.", pathlib.Path("/fake.png"))) def test_empty_text_is_rejected(self): from core.magazine import _whole_page_region self.assertIsNone(_whole_page_region("", pathlib.Path("/fake.png"))) def test_a_repeated_phrase_loop_with_no_known_signature_is_rejected(self): """Confirmed live: a Qwen VL engine transcribing a population chart produced "das Produkt, das Produkt, ..." several hundred times -- a generic decoding loop, not one of the two specific LaTeX/equation signatures above.""" from core.magazine import _whole_page_region poisoned = "Das Weitere Produkt, " + "das Produkt, " * 60 with mock.patch("PIL.Image.open") as mopen: mopen.return_value.__enter__.return_value.size = (897, 1049) self.assertIsNone(_whole_page_region(poisoned, pathlib.Path("/fake.png"))) class RepetitionLoopDetectionTests(unittest.TestCase): """The general "any short phrase repeated many times back-to-back" check backing the two call sites above -- must catch a decoding loop in any language or engine without a specific per-failure signature, while never flagging patterns real OCR output legitimately produces.""" def test_repeated_phrase_is_detected(self): self.assertTrue(_looks_like_repetition_loop("das Produkt, " * 20)) def test_repeated_bare_word_is_detected(self): self.assertTrue(_looks_like_repetition_loop("Produkt " * 20)) def test_a_sentence_repeated_a_few_times_is_not_flagged(self): """Genuine text can restate a short phrase a handful of times -- only a long run is a decoding loop, not three occurrences.""" self.assertFalse(_looks_like_repetition_loop( "Wichtig: Bitte prüfen. " * 3 + "Der Rest des Artikels folgt hier normal.")) def test_a_dot_leader_table_of_contents_line_is_not_flagged(self): self.assertFalse(_looks_like_repetition_loop( "Studien widerlegen Job-Killer-Mythos" + "." * 60 + "6")) def test_a_repeated_numeric_sequence_is_not_flagged(self): """No letters in the repeated unit -- a legitimate table/axis run, not a language-model decoding loop.""" self.assertFalse(_looks_like_repetition_loop("1 2 3 4 5 6 7 8 9 10 " * 5)) def test_empty_text_is_not_flagged(self): self.assertFalse(_looks_like_repetition_loop("")) class VisionEngineChainTests(unittest.TestCase): """The primary engine (labeled regions, best quality) is tried first; a differently-shaped secondary is a real fallback, not just tesseract.""" def test_primary_success_short_circuits_the_fallback(self): with mock.patch("core.magazine.vision_ocr_page") as mocked: mocked.return_value = [{"label": "text", "bbox": [0, 0, 1, 1], "text": "x"}] out = vision_ocr_page_chain(pathlib.Path("/fake.png"), [("http://a", "m1"), ("http://b", "m2")]) self.assertEqual(mocked.call_count, 1) self.assertIsNotNone(out) def test_primary_failure_falls_through_to_the_secondary(self): with mock.patch("core.magazine.vision_ocr_page") as mocked: mocked.side_effect = [None, [{"label": "text", "bbox": [0, 0, 1, 1], "text": "x"}]] out = vision_ocr_page_chain(pathlib.Path("/fake.png"), [("http://a", "m1"), ("http://b", "m2")]) self.assertEqual(mocked.call_count, 2) self.assertIsNotNone(out) def test_every_engine_failing_returns_none(self): with mock.patch("core.magazine.vision_ocr_page", return_value=None): out = vision_ocr_page_chain(pathlib.Path("/fake.png"), [("http://a", "m1"), ("http://b", "m2")]) self.assertIsNone(out) def test_blank_engine_entries_are_skipped_without_calling_out(self): with mock.patch("core.magazine.vision_ocr_page") as mocked: mocked.return_value = [{"label": "text", "bbox": [0, 0, 1, 1], "text": "x"}] vision_ocr_page_chain(pathlib.Path("/fake.png"), [("", ""), ("http://b", "m2")]) self.assertEqual(mocked.call_count, 1) self.assertEqual(mocked.call_args[0][1], "http://b") def test_empty_engine_list_returns_none(self): self.assertIsNone(vision_ocr_page_chain(pathlib.Path("/fake.png"), [])) class ReocrPageTests(unittest.TestCase): """User-triggered "OCR again" on one page -- must replace only that page's blocks, never touch build_articles() (an already-approved article's boundaries must not silently change), and never discard good existing blocks on a failed retry.""" def setUp(self): self.tmp = tempfile.mkdtemp(prefix="ttvc-reocr-test-") self.doc_dir = pathlib.Path(self.tmp) (self.doc_dir / "pages").mkdir() (self.doc_dir / "pages" / "0005.png").write_bytes(b"fake-png") self.layout = { "page_count": 10, "pages": [{"page": 5, "width": 595.0, "height": 842.0}], "blocks": [ {"id": "b0005-000", "page": 5, "bbox": [50, 60, 470, 50], "type": BLOCK_TITLE, "text": "Old garbled title", "reading_order": 0, "confidence": 0.5, "font_size": 26.0, "font_name": "", "bold": True, "column": 0, "source": "ocr"}, {"id": "b0009-000", "page": 9, "bbox": [50, 60, 300, 400], "type": BLOCK_BODY, "text": "Untouched other page copy. " * 40, "reading_order": 0, "confidence": 0.85, "font_size": 10.0, "font_name": "", "bold": False, "column": 0, "source": "native"}, ], } self._fitz_patch = mock.patch("core.magazine._require_fitz", return_value=mock.Mock()) self._pngw_patch = mock.patch("core.magazine._png_width", return_value=595.0) self._fitz_patch.start() self._pngw_patch.start() def tearDown(self): self._fitz_patch.stop() self._pngw_patch.stop() shutil.rmtree(self.tmp, ignore_errors=True) def _mock_chain(self, regions): return mock.patch("core.magazine.vision_ocr_page_chain", return_value=regions) def test_missing_png_returns_none(self): (self.doc_dir / "pages" / "0005.png").unlink() out = reocr_page(self.doc_dir, self.layout, 5, "http://e", "m") self.assertIsNone(out) def test_page_not_in_layout_returns_none(self): out = reocr_page(self.doc_dir, self.layout, 99, "http://e", "m") self.assertIsNone(out) def test_engine_returning_nothing_returns_none_and_keeps_old_blocks(self): with self._mock_chain(None): out = reocr_page(self.doc_dir, self.layout, 5, "http://e", "m") self.assertIsNone(out) # The original (unsuccessful) caller-visible layout is untouched -- # reocr_page never mutates its input on failure. self.assertEqual(len(self.layout["blocks"]), 2) def test_successful_reocr_replaces_only_the_target_pages_blocks(self): regions = [{"label": "title", "bbox": [605, 294, 914, 336], "text": "1. Irrtum: KI ist ein allgemeiner Jobkiller"}] with self._mock_chain(regions): out = reocr_page(self.doc_dir, self.layout, 5, "http://e", "m") self.assertIsNotNone(out) pages = {b["page"] for b in out["blocks"]} self.assertEqual(pages, {5, 9}) page5 = [b for b in out["blocks"] if b["page"] == 5] self.assertEqual(len(page5), 1) self.assertEqual(page5[0]["text"], "1. Irrtum: KI ist ein allgemeiner Jobkiller") self.assertEqual(page5[0]["type"], BLOCK_TITLE) # Page 9's block is byte-for-byte the original -- never regrouped or # reclassified just because a different page was re-OCR'd. page9 = [b for b in out["blocks"] if b["page"] == 9] self.assertEqual(page9[0]["text"], self.layout["blocks"][1]["text"]) def test_vision_ocr_pages_counter_increments(self): regions = [{"label": "text", "bbox": [50, 60, 500, 400], "text": "Some real text. " * 10}] with self._mock_chain(regions): out = reocr_page(self.doc_dir, self.layout, 5, "http://e", "m") self.assertEqual(out["vision_ocr_pages"], 1) def test_baseline_uses_the_rest_of_the_document_not_the_new_page_alone(self): """A page reduced to one block would otherwise become its own font-size reference (see classify_blocks' docstring): with only this page's own text to measure against, its ratio is always exactly 1.0, which would make it read as body regardless of what it actually is. Judged against page 9's real ~10pt body baseline instead, a similarly-sized estimate for ordinary paragraph text should also land as body -- not because the ratio is trivially 1, but because the two independently-measured sizes genuinely agree.""" text = "Body copy text repeated many times to fill the box. " * 4 regions = [{"label": "text", "bbox": [50, 60, 250, 60], "text": text}] with self._mock_chain(regions): out = reocr_page(self.doc_dir, self.layout, 5, "http://e", "m") page5 = [b for b in out["blocks"] if b["page"] == 5][0] self.assertEqual(page5["type"], BLOCK_BODY) def test_fallback_engine_is_passed_through_the_chain(self): with mock.patch("core.magazine.vision_ocr_page_chain") as mocked: mocked.return_value = [{"label": "text", "bbox": [0, 0, 10, 10], "text": "x y z real words"}] reocr_page(self.doc_dir, self.layout, 5, "http://primary", "m1", vision_ocr_fallback_url="http://fallback", vision_ocr_fallback_model="m2") engines_arg = mocked.call_args[0][1] self.assertEqual(engines_arg, [("http://primary", "m1"), ("http://fallback", "m2")]) class ManualRegionTests(unittest.TestCase): """Drawing a region by hand is the escape hatch for when OCR/layout detection misses something entirely -- the user has already judged automatic detection unreliable here, so this must never silently re-apply the same heuristics or discard what they drew.""" def setUp(self): from PIL import Image self.tmp = tempfile.mkdtemp(prefix="ttvc-manualregion-test-") self.doc_dir = pathlib.Path(self.tmp) (self.doc_dir / "pages").mkdir() Image.new("RGB", (595, 842), "white").save(self.doc_dir / "pages" / "0003.png") self.layout = { "page_count": 10, "pages": [{"page": 3, "width": 595.0, "height": 842.0}], "blocks": [ {"id": "b0003-000", "page": 3, "bbox": [50, 60, 300, 40], "type": BLOCK_TITLE, "text": "Existing title", "reading_order": 0, "confidence": 0.9, "font_size": 20.0, "font_name": "", "bold": True, "column": 0, "source": "native"}, ], "images": [], } def tearDown(self): shutil.rmtree(self.tmp, ignore_errors=True) def test_visual_region_is_cropped_with_type_and_text(self): from core.magazine import crop_visual_regions from PIL import Image block = Block(id='chart', page=3, bbox=[20,30,120,80], type='chart', text='84.5') images = crop_visual_regions(self.doc_dir, 3, self.layout['pages'][0], [block]) self.assertEqual(images[0]['type'], 'chart') self.assertEqual(images[0]['source_block'], 'chart') with Image.open(self.doc_dir / 'images' / images[0]['filename']) as image: self.assertEqual(image.size, (120,80)) def test_unknown_page_raises(self): with self.assertRaises(ValueError): add_manual_region(self.doc_dir, self.layout, 99, [10, 10, 50, 50], "text") def test_degenerate_bbox_raises(self): with self.assertRaises(ValueError): add_manual_region(self.doc_dir, self.layout, 3, [10, 10, 0, 0], "text") def test_bad_kind_raises(self): with self.assertRaises(ValueError): add_manual_region(self.doc_dir, self.layout, 3, [10, 10, 50, 50], "table") def test_manual_text_with_typed_content_is_used_verbatim(self): out = add_manual_region(self.doc_dir, self.layout, 3, [50, 200, 200, 40], "text", text="Hand-typed caption the OCR missed.") new = [b for b in out["blocks"] if b["source"] == "manual"] self.assertEqual(len(new), 1) self.assertEqual(new[0]["text"], "Hand-typed caption the OCR missed.") self.assertEqual(new[0]["type"], BLOCK_BODY) def test_manual_text_is_never_reclassified_by_heuristics(self): """A short, large region drawn by hand would classify as a title under classify_blocks' ratio rule -- must stay BLOCK_BODY regardless, since the whole point is the user already knows what it is.""" out = add_manual_region(self.doc_dir, self.layout, 3, [50, 300, 500, 100], "text", text="X") new = [b for b in out["blocks"] if b["source"] == "manual"][0] self.assertEqual(new["type"], BLOCK_BODY) def test_manual_semantic_regions_keep_the_reviewers_classification(self): heading = add_manual_region(self.doc_dir, self.layout, 3, [50, 80, 300, 40], "heading", text="A real headline") heading_block = [b for b in heading["blocks"] if b["source"] == "manual"][0] self.assertEqual(heading_block["type"], "heading") folio = add_manual_region(self.doc_dir, self.layout, 3, [500, 760, 30, 20], "page_number", text="17") folio_block = [b for b in folio["blocks"] if b["source"] == "manual"][0] self.assertEqual(folio_block["type"], "furniture") self.assertEqual(folio_block["region_label"], "page_number") advert = add_manual_region(self.doc_dir, self.layout, 3, [50, 500, 300, 120], "ad", text="Buy now") self.assertEqual([b for b in advert["blocks"] if b["source"] == "manual"][0]["type"], "ad") def test_manual_text_with_no_content_and_no_engine_gets_a_placeholder(self): out = add_manual_region(self.doc_dir, self.layout, 3, [50, 200, 200, 40], "text") new = [b for b in out["blocks"] if b["source"] == "manual"][0] self.assertIn("no text recognised", new["text"]) def test_manual_text_triggers_ocr_when_no_text_supplied(self): with mock.patch("core.magazine.vision_ocr_page_chain") as mocked: mocked.return_value = [{"label": "text", "bbox": [0, 0, 10, 10], "text": "Recognised from the crop."}] out = add_manual_region(self.doc_dir, self.layout, 3, [50, 200, 200, 40], "text", vision_ocr_url="http://e", vision_ocr_model="m") new = [b for b in out["blocks"] if b["source"] == "manual"][0] self.assertEqual(new["text"], "Recognised from the crop.") self.assertEqual(mocked.call_count, 1) def test_existing_blocks_on_the_page_are_preserved(self): out = add_manual_region(self.doc_dir, self.layout, 3, [50, 200, 200, 40], "text", text="New region.") ids = {b["id"] for b in out["blocks"]} self.assertIn("b0003-000", ids) def test_blocks_on_other_pages_are_untouched(self): layout = dict(self.layout) layout["blocks"] = layout["blocks"] + [ {"id": "b0007-000", "page": 7, "bbox": [10, 10, 100, 20], "type": BLOCK_BODY, "text": "Other page.", "reading_order": 0, "confidence": 0.8, "font_size": 10.0, "font_name": "", "bold": False, "column": 0, "source": "native"}, ] out = add_manual_region(self.doc_dir, layout, 3, [50, 200, 200, 40], "text", text="New.") other = [b for b in out["blocks"] if b["page"] == 7][0] self.assertEqual(other["text"], "Other page.") def test_manual_image_crops_and_saves_a_new_figure(self): out = add_manual_region(self.doc_dir, self.layout, 3, [100, 400, 150, 100], "image") self.assertEqual(len(out["images"]), 1) img = out["images"][0] self.assertEqual(img["page"], 3) self.assertEqual(img["bbox"], [100, 400, 150, 100]) self.assertTrue((self.doc_dir / "images" / img["filename"]).is_file()) def test_manual_image_does_not_touch_blocks(self): out = add_manual_region(self.doc_dir, self.layout, 3, [100, 400, 150, 100], "image") self.assertEqual(len(out["blocks"]), len(self.layout["blocks"])) def test_two_manual_images_get_distinct_filenames(self): out1 = add_manual_region(self.doc_dir, self.layout, 3, [100, 400, 150, 100], "image") out2 = add_manual_region(self.doc_dir, out1, 3, [300, 400, 150, 100], "image") self.assertEqual(len(out2["images"]), 2) names = {i["filename"] for i in out2["images"]} self.assertEqual(len(names), 2) class SnapVisualBboxesTests(unittest.TestCase): """A vision OCR engine grounds a figure loosely; the render is the truth. The synthetic page mirrors the real failure seen on c't KI Wissen 2026: a solid graphic on the right, a text column on the left, and a reported figure box that straddles both at a fraction of the real size. """ PAGE_W, PAGE_H = 582.0, 678.0 # PDF points PX_W, PX_H = 890, 1037 # rendered pixels FIGURE_PX = (400, 100, 850, 400) # x0, y0, x1, y1 TEXT_PX = (60, 100, 350, 500) def setUp(self): from PIL import Image, ImageDraw self.dir = pathlib.Path(tempfile.mkdtemp()) (self.dir / "pages").mkdir() page = Image.new("RGB", (self.PX_W, self.PX_H), (255, 255, 255)) draw = ImageDraw.Draw(page) draw.rectangle(self.FIGURE_PX, fill=(40, 90, 200)) x0, y0, x1, y1 = self.TEXT_PX for y in range(y0, y1, 12): # type covers ~a third of its cells draw.rectangle((x0, y, x1, y + 3), fill=(20, 20, 20)) self.png = self.dir / "pages" / "0001.png" page.save(self.png) self.meta = {"page": 1, "width": self.PAGE_W, "height": self.PAGE_H} def tearDown(self): shutil.rmtree(self.dir, ignore_errors=True) def _pt(self, px_box): """Pixel [x0, y0, x1, y1] as a PDF-point [x, y, w, h] bbox.""" sx, sy = self.PAGE_W / self.PX_W, self.PAGE_H / self.PX_H x0, y0, x1, y1 = px_box return [x0 * sx, y0 * sy, (x1 - x0) * sx, (y1 - y0) * sy] def _text_block(self): return Block(id="t1", page=1, bbox=self._pt(self.TEXT_PX), type=BLOCK_BODY, text="body copy " * 40) def _figure_block(self, px_box): return Block(id="f1", page=1, bbox=self._pt(px_box), type=BLOCK_FIGURE, text="figure", source="vision_label") def test_grid_separates_graphic_from_type(self): grid = page_ink_grid(self.png) self.assertIsNotNone(grid) rows, cols = grid.shape fig = grid[int(200 / self.PX_H * rows), int(600 / self.PX_W * cols)] txt = grid[int(200 / self.PX_H * rows), int(200 / self.PX_W * cols)] self.assertGreater(fig, 0.9) self.assertLess(txt, 0.5) def test_offset_box_snaps_onto_the_graphic(self): block = self._figure_block((200, 60, 600, 250)) moved = snap_visual_bboxes(self.png, [block, self._text_block()], self.meta) self.assertEqual(moved, 1) want = self._pt(self.FIGURE_PX) for got, expected in zip(block.bbox, want): self.assertAlmostEqual(got, expected, delta=12.0) def test_snap_does_not_swallow_the_text_column(self): block = self._figure_block((200, 60, 600, 250)) snap_visual_bboxes(self.png, [block, self._text_block()], self.meta) self.assertGreater(block.bbox[0], self._pt(self.TEXT_PX)[0] + 20) def test_snapping_is_idempotent(self): block = self._figure_block((200, 60, 600, 250)) blocks = [block, self._text_block()] snap_visual_bboxes(self.png, blocks, self.meta) settled = list(block.bbox) self.assertEqual(snap_visual_bboxes(self.png, blocks, self.meta), 0) self.assertEqual(block.bbox, settled) def test_box_over_type_only_is_left_alone(self): block = self._figure_block((60, 100, 350, 500)) before = list(block.bbox) self.assertEqual( snap_visual_bboxes(self.png, [block, self._text_block()], self.meta), 0) self.assertEqual(block.bbox, before) def test_two_figures_on_one_graphic_keep_their_boxes(self): left = self._figure_block((410, 110, 600, 380)) right = Block(id="f2", page=1, bbox=self._pt((610, 110, 840, 380)), type=BLOCK_FIGURE, text="figure", source="vision_label") before = [list(left.bbox), list(right.bbox)] self.assertEqual(snap_visual_bboxes(self.png, [left, right], self.meta), 0) self.assertEqual([left.bbox, right.bbox], before) def test_missing_render_is_a_no_op(self): block = self._figure_block((200, 60, 600, 250)) before = list(block.bbox) self.assertEqual( snap_visual_bboxes(self.dir / "pages" / "0099.png", [block], self.meta), 0) self.assertEqual(block.bbox, before) if __name__ == "__main__": unittest.main() class SnapVisualBboxesStabilityTests(unittest.TestCase): """Regressions from finishing the sparse-chart growth pass: an anchor chosen independently of the seed's own size, and repeated calls landing on the same answer instead of drifting or ping-ponging between two adjacent figures. """ PAGE_W, PAGE_H = 582.0, 678.0 PX_W, PX_H = 890, 1037 def setUp(self): self.dir = pathlib.Path(tempfile.mkdtemp()) (self.dir / "pages").mkdir() self.meta = {"page": 1, "width": self.PAGE_W, "height": self.PAGE_H} def tearDown(self): shutil.rmtree(self.dir, ignore_errors=True) def _save(self, image, name="0001.png"): path = self.dir / "pages" / name image.save(path) return path def _pt(self, px_box): sx, sy = self.PAGE_W / self.PX_W, self.PAGE_H / self.PX_H x0, y0, x1, y1 = px_box return [x0 * sx, y0 * sy, (x1 - x0) * sx, (y1 - y0) * sy] def _chart_page(self): """A dense anchor line (the 0% axis) with sparse 40%-filled 'bars' beneath it -- individually below the solid threshold, matching the measured coverage of real bar-chart rows (see snap_visual_bboxes' module docstring).""" from PIL import Image, ImageDraw page = Image.new("RGB", (self.PX_W, self.PX_H), (255, 255, 255)) draw = ImageDraw.Draw(page) draw.rectangle((300, 100, 305, 400), fill=(20, 20, 20)) # dense axis for y in range(110, 390, 20): draw.rectangle((200, y, 600, y + 4), fill=(30, 30, 30)) # sparse bar return page def test_sparse_chart_box_is_stable_across_repeated_snaps(self): png = self._save(self._chart_page()) # A seed only loosely overlapping the dense axis, the way a vision # engine's own grounding does -- see page 18 of c't KI Wissen 2026. seed = Block(id="f1", page=1, bbox=self._pt((250, 150, 400, 250)), type=BLOCK_FIGURE, text="chart", source="vision_label") moved1 = snap_visual_bboxes(png, [seed], self.meta) settled = list(seed.bbox) self.assertGreater(moved1, 0) moved2 = snap_visual_bboxes(png, [seed], self.meta) self.assertEqual(moved2, 0) self.assertEqual(seed.bbox, settled) def test_reseeding_with_the_grown_box_does_not_shrink_it(self): """The exact page-18 failure: anchor selection must not flip to a different, unrelated component just because the seed grew larger.""" png = self._save(self._chart_page()) wide_seed = Block(id="f1", page=1, bbox=self._pt((190, 95, 610, 405)), type=BLOCK_FIGURE, text="chart", source="vision_label") before = list(wide_seed.bbox) snap_visual_bboxes(png, [wide_seed], self.meta) # A box already covering the whole chart must not be cut down. self.assertGreaterEqual(wide_seed.bbox[2], before[2] * 0.9) self.assertGreaterEqual(wide_seed.bbox[3], before[3] * 0.9) def _two_photos_page(self): """Two solid panels a narrow (~2-cell) gutter apart -- the measured gap between two head-and-shoulders portraits on page 31 of c't KI Wissen 2026.""" from PIL import Image, ImageDraw page = Image.new("RGB", (self.PX_W, self.PX_H), (255, 255, 255)) draw = ImageDraw.Draw(page) draw.rectangle((100, 100, 380, 500), fill=(40, 60, 90)) draw.rectangle((400, 100, 680, 500), fill=(90, 60, 40)) return page def test_two_adjacent_figures_settle_without_oscillating(self): png = self._save(self._two_photos_page()) left = Block(id="f1", page=1, bbox=self._pt((130, 130, 350, 470)), type=BLOCK_FIGURE, text="left", source="vision_label") right = Block(id="f2", page=1, bbox=self._pt((430, 130, 650, 470)), type=BLOCK_FIGURE, text="right", source="vision_label") snap_visual_bboxes(png, [left, right], self.meta) first = (list(left.bbox), list(right.bbox)) snap_visual_bboxes(png, [left, right], self.meta) second = (list(left.bbox), list(right.bbox)) # Settle to within a small fraction of a grid cell, not bounce # between substantially different splits of the shared gutter. for a, b in zip(first[0] + first[1], second[0] + second[1]): self.assertAlmostEqual(a, b, delta=8.0) # And never merge into one box spanning both panels. self.assertLess(left.bbox[0] + left.bbox[2], right.bbox[0] + 1.0)