"""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, column_anchors, is_pullquote_like, body_font_size, build_articles, build_vision_blocks, classify_blocks, reocr_page, add_manual_region, update_region, delete_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, prefer_drop_cap_repair, score_contents_page, vision_ocr_page, vision_ocr_page_chain, describe_visual_region, describe_visual_region_chain, BLOCK_FIGURE_DESCRIPTION, interview_tts_segments, merge_missing_text_candidates, verify_ocr_recognition, ) 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 DropCapRepairTests(unittest.TestCase): def test_repairs_split_ki_and_confirmed_short_drop_cap_words(self): from core.magazine import repair_drop_cap_omissions regions = [ {"label": "paragraph", "bbox": [0, 0, 100, 100], "text": "K l soll Arbeit abnehmen und Kosten sparen."}, {"label": "body", "bbox": [0, 0, 100, 100], "text": "A m 3. Januar erschienen neue Bilder."}, {"label": "text", "bbox": [0, 0, 100, 100], "text": "B i s heute blieb das Problem bestehen."}, ] repaired = repair_drop_cap_omissions(pathlib.Path("missing-page.png"), regions) self.assertEqual(repaired, 3) self.assertEqual(regions[0]["text"], "KI soll Arbeit abnehmen und Kosten sparen.") self.assertEqual(regions[1]["text"], "Am 3. Januar erschienen neue Bilder.") self.assertEqual(regions[2]["text"], "Bis heute blieb das Problem bestehen.") def test_joins_a_detached_initial_already_found_by_primary_ocr(self): from core.magazine import repair_drop_cap_omissions regions = [{ "label": "paragraph", "bbox": [10, 10, 200, 300], "text": "S prechende Katzen, halbnackte Prominente und unglaubliche Sensationsmeldungen.", }] repaired = repair_drop_cap_omissions(pathlib.Path("missing-page.png"), regions) self.assertEqual(repaired, 1) self.assertEqual( regions[0]["text"], "Sprechende Katzen, halbnackte Prominente und unglaubliche Sensationsmeldungen.", ) def test_restores_omitted_first_line_and_joins_detached_initial(self): old = ("tert, dass es fast ein Garant für einen sicheren, gut bezahlten Bürojob ist, " "wenn man programmieren kann. Wer vor vier Jahren angefangen hat, Informatik zu studieren.") local = ("J ahrelang wurde jungen Menschen eingetrichtert, dass es fast ein Garant für " "einen sicheren, gut bezahlten Bürojob ist, wenn man programmieren kann. Wer vor " "vier Jahren angefangen hat, Informatik zu studieren.") repaired = prefer_drop_cap_repair(old, local) self.assertTrue(repaired.startswith("Jahrelang wurde jungen Menschen eingetrichtert,")) def test_does_not_replace_a_lowercase_continuation_with_unrelated_ocr(self): old = ("und damit setzt sich der Gedanke auf der nächsten Seite fort. " * 4).strip() unrelated = ("Andere Spalte mit vollständig anderem Inhalt und ganz anderen Worten. " * 4).strip() self.assertEqual(prefer_drop_cap_repair(old, unrelated), old) def test_restores_a_whole_first_line_before_capitalised_remainder(self): old = ("Herrn, verwaltet Termine, öffnet Türen, sortiert die Post und weiß, wo wichtige " "Unterlagen liegen. Genau diese Rolle wollen digitale Assistenten übernehmen.") local = ("Ein guter Butler kennt die Vorlieben seines Herrn, verwaltet Termine, öffnet Türen, " "sortiert die Post und weiß, wo wichtige Unterlagen liegen. Genau diese Rolle wollen " "digitale Assistenten übernehmen.") repaired = prefer_drop_cap_repair(old, local) self.assertEqual(repaired, local) def test_capitalised_remainder_rejects_a_tiny_prefix(self): old = "Herrn, verwaltet Termine und öffnet Türen für die Bewohner des Hauses." self.assertEqual(prefer_drop_cap_repair(old, "Der Herrn, verwaltet Termine und öffnet Türen für die Bewohner des Hauses."), old) 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"]) def _page_38(self): """A real scanned c't page (issue 204, page 38): measured boxes, placeholder text. The OCR boxes are loose, so the gutter between the two body columns is only 4-6 points, and a pull-quote sits across it. The old whitespace detector saw one column and read the page top to bottom, interleaving both columns. `box` is the number the review screen showed. """ page_w = 553.68 long_text = "Running text of an interview answer. " * 8 rows = [ # box, type, x, y, w, h, text (1, "furniture", 27, 646, 520, 12, "40 Arbeitswelt & Wirtschaft"), (2, "title", 29, 50, 344, 79, "Headline of the article"), (3, "body", 29, 145, 299, 56, "Standfirst introducing the interview. " * 3), (4, "byline", 29, 223, 105, 13, "Von A. Autor"), (5, "body", 29, 254, 200, 68, long_text), (6, "body", 238, 257, 205, 146, long_text), (7, "body", 28, 330, 204, 158, long_text), (8, "body", 240, 409, 204, 28, "Question from the interviewer, a single line?"), (9, "body", 238, 443, 209, 48, long_text), (10, "pullquote", 25, 488, 134, 87, long_text), # mislabelled: really left-column body (11, "body", 307, 489, 138, 87, long_text), (12, "body", 169, 503, 127, 60, "»A short quote from the interview, set large.«"), # the real pull-quote (13, "body", 25, 572, 203, 60, long_text), (14, "body", 236, 573, 210, 65, long_text), ] return page_w, [block(f"b{n}", 38, x, y, w, h, text, btype=t) for n, t, x, y, w, h, text in rows] def test_two_columns_with_a_hairline_gutter_are_read_column_by_column(self): page_w, blocks = self._page_38() order = [int(b.id[1:]) for b in assign_reading_order(blocks, page_w)] # Exactly the order marked by hand on the page: headline, standfirst, byline, the left # column top to bottom, the right column top to bottom, the pull-quote, then furniture. self.assertEqual(order, [2, 3, 4, 5, 7, 10, 13, 6, 8, 9, 11, 14, 12, 1]) def test_columns_come_from_left_edges_not_from_the_gutter(self): page_w, blocks = self._page_38() anchors, col_w = column_anchors(blocks, page_w) self.assertEqual(len(anchors), 2) self.assertLess(abs(anchors[0] - 25), 5) self.assertLess(abs(anchors[1] - 236), 5) def test_a_narrow_box_starting_mid_column_does_not_invent_a_column(self): page_w, blocks = self._page_38() anchors, _ = column_anchors(blocks, page_w) self.assertNotIn(307, [round(a) for a in anchors]) # box 11: the lower part of the right column self.assertNotIn(169, [round(a) for a in anchors]) # box 12: the pull-quote def test_pullquote_is_judged_by_its_text_not_only_its_label(self): short_quote = block("q", 1, 0, 0, 100, 40, "»Set large in the middle of the page.«") labelled_but_long = block("p", 1, 0, 0, 100, 40, "Running text. " * 40, btype=BLOCK_PULLQUOTE) self.assertTrue(is_pullquote_like(short_quote)) self.assertFalse(is_pullquote_like(labelled_but_long)) self.assertFalse(is_pullquote_like(block("b", 1, 0, 0, 100, 40, "Plain sentence."))) def test_a_caption_mid_page_does_not_tear_the_columns_in_half(self): blocks = [ block("l1", 1, 50, 100, 200, 150, "left top"), block("r1", 1, 320, 100, 200, 150, "right top"), block("cap", 1, 50, 280, 470, 20, "Caption under a wide photo", btype=BLOCK_CAPTION), block("l2", 1, 50, 320, 200, 150, "left bottom"), block("r2", 1, 320, 320, 200, 150, "right bottom"), ] order = [b.id for b in assign_reading_order(blocks, PAGE_W) if b.id != "cap"] self.assertEqual(order, ["l1", "l2", "r1", "r2"]) def test_a_text_block_across_two_columns_starts_a_new_band(self): """A second headline across both columns: what is under it is read after it.""" blocks = [ block("l1", 1, 50, 100, 200, 150, "left top"), block("r1", 1, 320, 100, 200, 150, "right top"), block("head2", 1, 50, 280, 470, 30, "Second story headline", size=20.0, btype=BLOCK_TITLE), block("l2", 1, 50, 330, 200, 150, "left bottom"), block("r2", 1, 320, 330, 200, 150, "right bottom"), ] order = [b.id for b in assign_reading_order(blocks, PAGE_W)] self.assertEqual(order, ["l1", "r1", "head2", "l2", "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)) def test_internal_heading_on_continuation_page_does_not_split_article(self): blocks = self._issue_blocks()[:3] continuation = block("b0019-000", 19, 50, 60, 200, 100, "Continued body copy. " * 20) subheading = block("b0019-001", 19, 50, 180, 300, 35, "A Large Internal Heading", size=22, btype=BLOCK_TITLE) more = block("b0019-002", 19, 50, 230, 200, 200, "More body copy. " * 20) continuation.reading_order, subheading.reading_order, more.reading_order = 0, 1, 2 blocks.extend([continuation, subheading, more]) arts = group_articles(blocks, [], page_count=19) self.assertEqual(len(arts), 1) self.assertEqual(arts[0].pages, [18, 19]) class InterviewVoiceTests(unittest.TestCase): def test_detects_exactly_two_recurring_speaker_labels(self): blocks = [ {"text": "Anna Meyer: Wie verändert KI unsere Arbeit?"}, {"text": "Tom Schulz: Sie automatisiert Routineaufgaben."}, {"text": "Anna Meyer: Wo liegen die Grenzen?"}, {"text": "Tom Schulz: Verantwortung bleibt beim Menschen."}, ] segments = interview_tts_segments(blocks) self.assertEqual([s["speaker"] for s in segments], ["Anna Meyer", "Tom Schulz", "Anna Meyer", "Tom Schulz"]) self.assertEqual(segments[0]["text"], "Wie verändert KI unsere Arbeit?") def test_a_colon_in_normal_prose_does_not_trigger_interview_mode(self): blocks = [{"text": "Das Ergebnis: Die Leistung steigt."}, {"text": "Ein zweiter gewöhnlicher Absatz."}] self.assertTrue(all(s["speaker"] is None for s in interview_tts_segments(blocks))) 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_visual_box_over_text_is_dropped_but_standalone_figure_survives(self): regions = [ {"label": "paragraph", "bbox": [100, 100, 300, 200], "text": "Article text."}, {"label": "image", "bbox": [80, 80, 340, 240], "text": ""}, {"label": "figure", "bbox": [500, 100, 200, 200], "text": ""}, ] blocks = build_vision_blocks(regions, page_no=5, scale_x=1.0, id_prefix="b") self.assertEqual([block.type for block in blocks], [BLOCK_BODY, "figure"]) def test_broad_visual_box_is_dropped_when_tighter_figure_is_nested(self): regions = [ {"label": "image", "bbox": [40, 40, 360, 700], "text": ""}, {"label": "figure", "bbox": [60, 60, 300, 260], "text": ""}, ] blocks = build_vision_blocks(regions, page_no=136, scale_x=1.0, id_prefix="b") self.assertEqual(len(blocks), 1) self.assertEqual(blocks[0].type, BLOCK_FIGURE) def test_tall_visual_box_stops_before_overlapping_caption(self): regions = [ {"label": "image", "bbox": [40, 40, 300, 700], "text": ""}, {"label": "caption", "bbox": [55, 500, 270, 80], "text": "Caption below the photograph."}, ] blocks = build_vision_blocks(regions, page_no=136, scale_x=1.0, id_prefix="b") self.assertEqual(blocks[0].bbox, [40.0, 40.0, 300.0, 460.0]) def test_text_inside_screenshot_is_not_a_separate_article_paragraph(self): regions = [ {"label": "image", "bbox": [100, 100, 400, 500], "text": ""}, {"label": "paragraph", "bbox": [130, 180, 330, 50], "text": "Text printed inside screenshot."}, ] blocks = build_vision_blocks(regions, page_no=139, scale_x=1.0, id_prefix="b") self.assertEqual([b.type for b in blocks], [BLOCK_FIGURE]) 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_uses_saved_reading_order_not_model_id_order(self): from core.magazine import apply_semantic_regions first = Block('first', 1, [20, 100, 200, 30], 'body', 'Ein guter Butler kennt die Vorlieben seines') second = Block('second', 1, [20, 135, 200, 80], 'body', 'Herrn, verwaltet Termine und öffnet Türen.') first.reading_order, second.reading_order = 3, 4 grouped = apply_semantic_regions( [first.to_json(), second.to_json()], [{'type': 'paragraph', 'block_ids': ['second', 'first']}], ) self.assertEqual( grouped[0]['text'], 'Ein guter Butler kennt die Vorlieben seines Herrn, verwaltet Termine und öffnet Türen.', ) def test_semantic_paragraph_continuing_across_columns_keeps_two_boxes(self): """A logical paragraph can end in the left column and continue in the right. Its bbox must not become the union covering both columns and every unrelated paragraph between those two pieces (real pages 111 and 114 of c't KI Wissen 2026).""" from core.magazine import apply_semantic_regions blocks = [ Block('left', 1, [30, 500, 200, 100], 'body', 'The paragraph starts in the left column and ends with').to_json(), Block('right', 1, [250, 80, 200, 80], 'body', 'the text at the top of the right column.').to_json(), Block('left-other', 1, [30, 80, 200, 380], 'body', 'Unrelated paragraphs in the left column.').to_json(), Block('right-other', 1, [250, 200, 200, 300], 'body', 'Unrelated paragraphs in the right column.').to_json(), ] grouped = apply_semantic_regions(blocks, [ {'type': 'paragraph', 'block_ids': ['left', 'right']}, {'type': 'paragraph', 'block_ids': ['left-other']}, {'type': 'paragraph', 'block_ids': ['right-other']}, ], {'page': 1, 'width': 480.0, 'height': 700.0}) continued = [b for b in grouped if b['id'] in {'left', 'right'}] self.assertEqual([b['id'] for b in continued], ['left', 'right']) self.assertEqual([b['bbox'] for b in continued], [[30, 500, 200, 100], [250, 80, 200, 80]]) self.assertEqual([b['text'] for b in continued], ['The paragraph starts in the left column and ends with', 'the text at the top of the right column.']) def test_semantic_fragments_still_merge_within_each_physical_column(self): from core.magazine import apply_semantic_regions blocks = [ Block('l1', 1, [30, 100, 200, 40], 'body', 'Left one').to_json(), Block('l2', 1, [30, 145, 200, 40], 'body', 'left two').to_json(), Block('r1', 1, [250, 100, 200, 40], 'body', 'Right one').to_json(), Block('r2', 1, [250, 145, 200, 40], 'body', 'right two').to_json(), ] grouped = apply_semantic_regions(blocks, [ {'type': 'paragraph', 'block_ids': ['l1', 'l2', 'r1', 'r2']}, ], {'page': 1, 'width': 480.0, 'height': 700.0}) self.assertEqual(len(grouped), 2) self.assertEqual([b['bbox'] for b in grouped], [[30, 100, 200, 85], [250, 100, 200, 85]]) self.assertEqual([b['text'] for b in grouped], ['Left one left two', 'Right one right two']) def test_body_paragraph_is_not_a_caption_without_a_nearby_figure(self): """A bottom-of-column interview answer remains body copy (page 47).""" from core.magazine import apply_semantic_regions paragraph = Block( 'answer', 47, [140, 530, 205, 145], 'body', 'Haverkamp: Teilweise ja. In den Handlungsempfehlungen der KMK heißt es, ' 'dass Prüfungsformate weiterentwickelt werden sollen.', ).to_json() grouped = apply_semantic_regions( [paragraph], [{'type': 'caption', 'block_ids': ['answer']}], {'page': 47, 'width': 595.0, 'height': 842.0}, ) self.assertEqual(grouped[0]['type'], BLOCK_BODY) def test_body_near_a_real_figure_may_be_promoted_to_caption(self): from core.magazine import apply_semantic_regions figure = Block('photo', 1, [40, 40, 200, 200], BLOCK_FIGURE, '').to_json() copy = Block('copy', 1, [40, 245, 200, 25], BLOCK_BODY, 'Portrait of the software developer.').to_json() grouped = apply_semantic_regions( [figure, copy], [{'type': 'figure', 'block_ids': ['photo']}, {'type': 'caption', 'block_ids': ['copy']}], {'page': 1, 'width': 595.0, 'height': 842.0}, ) self.assertEqual(grouped[1]['type'], BLOCK_CAPTION) 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_keeps_printed_abbreviation_but_provides_spoken_expansion(self): layout = { "blocks": [{"id": "b1", "page": 1, "type": BLOCK_BODY, "reading_order": 0, "bbox": [0, 0, 1, 1], "text": "KI hilft bei der Arbeit."}], } got = article_text(layout, {"blocks": ["b1"]}) self.assertEqual(got[0]["text"], "KI hilft bei der Arbeit.") self.assertEqual(got[0]["spoken_text"], "Künstliche Intelligenz hilft bei der Arbeit.") 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 OcrQualityPassTests(unittest.TestCase): def test_missing_text_adds_only_non_overlapping_non_visual_candidate(self): layout = { 'pages': [{'page': 1, 'width': 600, 'height': 800}], 'blocks': [block('saved', 1, 40, 50, 200, 100, 'Already recognised paragraph text.').to_json()], 'images': [{'id': 'photo', 'page': 1, 'bbox': [300, 40, 200, 180]}], } candidates = [ block('', 1, 42, 55, 195, 90, 'Already recognised paragraph text.'), block('', 1, 320, 70, 120, 80, 'Words printed inside the screenshot.'), block('', 1, 40, 300, 200, 100, 'This genuinely missed paragraph should be offered for review.'), ] updated, added = merge_missing_text_candidates(layout, 1, candidates) self.assertEqual(added, 1) new = [b for b in updated['blocks'] if b['id'].startswith('q0001-')] self.assertEqual(len(new), 1) self.assertIn('genuinely missed', new[0]['text']) self.assertEqual(new[0]['source'], 'quality_missing_text') def test_verification_accepts_only_a_similar_reading_with_fewer_suspects(self): old = 'Die Softwarentwicklng hilft Unternehmen bei dieser Aufgabe.' fixed = 'Die Softwareentwicklung hilft Unternehmen bei dieser Aufgabe.' layout = { 'pages': [{'page': 1, 'width': 600, 'height': 800}], 'blocks': [block('copy', 1, 40, 50, 200, 100, old).to_json()], } with mock.patch('core.magazine.suspect_ocr_tokens', side_effect=lambda text, *a, **k: ['Softwarentwicklng'] if text == old else []), \ mock.patch('core.magazine.extract_region_text', return_value=fixed): updated, checked, corrected = verify_ocr_recognition( pathlib.Path('/tmp/doc'), layout, 1) self.assertEqual((checked, corrected), (1, 1)) self.assertEqual(updated['blocks'][0]['text'], fixed) def test_verification_rejects_an_unrelated_second_reading(self): old = 'Die Softwarentwicklng hilft Unternehmen bei dieser Aufgabe.' layout = { 'pages': [{'page': 1, 'width': 600, 'height': 800}], 'blocks': [block('copy', 1, 40, 50, 200, 100, old).to_json()], } with mock.patch('core.magazine.suspect_ocr_tokens', return_value=['bad']), \ mock.patch('core.magazine.extract_region_text', return_value='Completely different words from another column entirely.'): updated, checked, corrected = verify_ocr_recognition( pathlib.Path('/tmp/doc'), layout, 1) self.assertEqual((checked, corrected), (1, 0)) self.assertEqual(updated['blocks'][0]['text'], old) 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], "unsupported") 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 UpdateRegionTests(unittest.TestCase): """Correcting a box the layout engine already found -- moving/resizing it, or deleting it entirely. A different job from add_manual_region (which is for a region detection missed outright): here block_id must already exist.""" def setUp(self): from PIL import Image self.tmp = tempfile.mkdtemp(prefix="ttvc-updateregion-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 = { "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}, ], "images": [ {"id": "img0003-000", "page": 3, "bbox": [20, 30, 120, 80], "filename": "img0003-000.png", "type": "chart"}, ], } def tearDown(self): shutil.rmtree(self.tmp, ignore_errors=True) def test_unknown_page_raises(self): with self.assertRaises(ValueError): update_region(self.doc_dir, self.layout, 99, "b0003-000", [10, 10, 50, 50]) def test_unknown_block_id_raises(self): with self.assertRaises(ValueError): update_region(self.doc_dir, self.layout, 3, "does-not-exist", [10, 10, 50, 50]) def test_degenerate_bbox_raises(self): with self.assertRaises(ValueError): update_region(self.doc_dir, self.layout, 3, "b0003-000", [10, 10, 0, 0]) def test_moves_a_text_block_without_reocr(self): out, text = update_region(self.doc_dir, self.layout, 3, "b0003-000", [70, 90, 300, 40]) moved = next(b for b in out["blocks"] if b["id"] == "b0003-000") self.assertEqual(moved["bbox"], [70, 90, 300, 40]) self.assertEqual(moved["text"], "Existing title") # unchanged self.assertIsNone(text) def test_reocr_replaces_the_blocks_text(self): with mock.patch("core.magazine.extract_region_text", return_value="Freshly read title"): out, text = update_region(self.doc_dir, self.layout, 3, "b0003-000", [70, 90, 300, 40], reocr=True) moved = next(b for b in out["blocks"] if b["id"] == "b0003-000") self.assertEqual(moved["text"], "Freshly read title") self.assertEqual(text, "Freshly read title") def test_reocr_finding_nothing_keeps_the_old_text(self): with mock.patch("core.magazine.extract_region_text", return_value=""): out, text = update_region(self.doc_dir, self.layout, 3, "b0003-000", [70, 90, 300, 40], reocr=True) moved = next(b for b in out["blocks"] if b["id"] == "b0003-000") self.assertEqual(moved["text"], "Existing title") self.assertEqual(text, "") def test_reocr_keeps_drop_cap_first_line_when_new_read_starts_later(self): old = ("Mails versenden, Kalender bearbeiten, Skripte schreiben und ausführen: " "Wenn man sie ließe, könnten LLMs viele Aufgaben ziemlich selbstständig erledigen.") reread = ("schreiben und ausführen: Wenn man sie ließe, könnten LLMs viele Aufgaben " "ziemlich selbstständig erledigen.") self.layout["blocks"][0]["text"] = old self.layout["blocks"][0]["type"] = BLOCK_BODY with mock.patch("core.magazine.extract_region_text", return_value=reread): out, text = update_region(self.doc_dir, self.layout, 3, "b0003-000", [50, 60, 320, 80], reocr=True) moved = next(b for b in out["blocks"] if b["id"] == "b0003-000") self.assertEqual(moved["text"], old) self.assertEqual(text, old) def test_reocr_allows_intentionally_moving_top_below_old_first_line(self): old = ("Mails versenden, Kalender bearbeiten, Skripte schreiben und ausführen: " "Wenn man sie ließe, könnten LLMs viele Aufgaben ziemlich selbstständig erledigen.") reread = "schreiben und ausführen: Wenn man sie ließe, könnten LLMs viele Aufgaben erledigen." self.layout["blocks"][0]["text"] = old with mock.patch("core.magazine.extract_region_text", return_value=reread): out, text = update_region(self.doc_dir, self.layout, 3, "b0003-000", [50, 75, 300, 25], reocr=True) moved = next(b for b in out["blocks"] if b["id"] == "b0003-000") self.assertEqual(moved["text"], reread) self.assertEqual(text, reread) def test_moving_an_image_recrops_its_file(self): from PIL import Image out, text = update_region(self.doc_dir, self.layout, 3, "img0003-000", [0, 0, 60, 40]) moved = next(i for i in out["images"] if i["id"] == "img0003-000") self.assertEqual(moved["bbox"], [0, 0, 60, 40]) self.assertIsNone(text) with Image.open(self.doc_dir / "images" / moved["filename"]) as im: self.assertEqual(im.size, (60, 40)) def test_bbox_none_leaves_geometry_untouched_for_reocr_only(self): with mock.patch("core.magazine.extract_region_text", return_value="Reread text"): out, text = update_region(self.doc_dir, self.layout, 3, "b0003-000", None, reocr=True) moved = next(b for b in out["blocks"] if b["id"] == "b0003-000") self.assertEqual(moved["bbox"], [50, 60, 300, 40]) self.assertEqual(text, "Reread text") class DeleteRegionTests(unittest.TestCase): def test_removes_a_block(self): layout = {"blocks": [{"id": "b1"}, {"id": "b2"}], "images": []} out = delete_region(layout, "b1") self.assertEqual([b["id"] for b in out["blocks"]], ["b2"]) def test_removes_an_image(self): layout = {"blocks": [], "images": [{"id": "img1"}, {"id": "img2"}]} out = delete_region(layout, "img1") self.assertEqual([i["id"] for i in out["images"]], ["img2"]) def test_unknown_id_is_a_harmless_no_op(self): layout = {"blocks": [{"id": "b1"}], "images": [{"id": "img1"}]} out = delete_region(layout, "does-not-exist") self.assertEqual(len(out["blocks"]), 1) self.assertEqual(len(out["images"]), 1) def test_does_not_mutate_the_original_layout(self): layout = {"blocks": [{"id": "b1"}], "images": []} delete_region(layout, "b1") self.assertEqual(len(layout["blocks"]), 1) class InventedVisualRegionScreenTests(unittest.TestCase): """A semantic-layout call can propose a brand-new picture/chart/table region (block_ids: []) for a graphic OCR never produced a block for. Confirmed on a real issue (c't KI Wissen 2026): it sometimes instead draws that box over a column gap and the text below it, which would bury real paragraphs under an empty "Figure" placeholder. region_looks_like_a_picture and apply_semantic_regions' page_ink screen that out using the page render's own ink density -- a real photo/chart/table is solid, a text area is not (confirmed: 0.56-0.97 vs 0.10-0.14 on the actual false positives and true positives found on that issue).""" PAGE_W, PAGE_H = 582.0, 678.0 PX_W, PX_H = 890, 1037 FIGURE_PX = (400, 100, 850, 400) 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): 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): 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 test_a_dense_region_looks_like_a_picture(self): from core.magazine import region_looks_like_a_picture, page_ink_grid_points page_ink = page_ink_grid_points(self.png, self.meta) self.assertIsNotNone(page_ink) self.assertTrue(region_looks_like_a_picture(self._pt(self.FIGURE_PX), page_ink)) def test_a_sparse_text_region_does_not_look_like_a_picture(self): from core.magazine import region_looks_like_a_picture, page_ink_grid_points page_ink = page_ink_grid_points(self.png, self.meta) self.assertFalse(region_looks_like_a_picture(self._pt(self.TEXT_PX), page_ink)) def test_no_page_ink_always_passes(self): from core.magazine import region_looks_like_a_picture self.assertTrue(region_looks_like_a_picture(self._pt(self.TEXT_PX), None)) def test_apply_semantic_regions_keeps_a_real_picture_and_drops_a_text_area(self): from core.magazine import apply_semantic_regions, page_ink_grid_points page_ink = page_ink_grid_points(self.png, self.meta) def permille(px_box): x, y, w, h = self._pt(px_box) return [x / self.PAGE_W * 1000, y / self.PAGE_H * 1000, (x + w) / self.PAGE_W * 1000, (y + h) / self.PAGE_H * 1000] grouped = apply_semantic_regions([], [ {"type": "image", "block_ids": [], "bbox": permille(self.FIGURE_PX)}, {"type": "image", "block_ids": [], "bbox": permille(self.TEXT_PX)}, ], self.meta, page_ink=page_ink) self.assertEqual(len(grouped), 1) self.assertEqual(grouped[0]["type"], "figure") for got, want in zip(grouped[0]["bbox"], self._pt(self.FIGURE_PX)): self.assertAlmostEqual(got, want, delta=1.0) def test_without_page_ink_both_are_kept_as_before(self): from core.magazine import apply_semantic_regions def permille(px_box): x, y, w, h = self._pt(px_box) return [x / self.PAGE_W * 1000, y / self.PAGE_H * 1000, (x + w) / self.PAGE_W * 1000, (y + h) / self.PAGE_H * 1000] grouped = apply_semantic_regions([], [ {"type": "image", "block_ids": [], "bbox": permille(self.FIGURE_PX)}, {"type": "image", "block_ids": [], "bbox": permille(self.TEXT_PX)}, ], self.meta) self.assertEqual(len(grouped), 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=2.0) def test_dense_photo_edges_do_not_keep_asymmetric_grid_margin(self): block = self._figure_block((200, 60, 600, 250)) snap_visual_bboxes(self.png, [block, self._text_block()], self.meta) got = block.bbox want = self._pt(self.FIGURE_PX) for actual, expected in zip(got, want): self.assertAlmostEqual(actual, expected, delta=2.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_displaced_advert_box_does_not_block_figure_snapping(self): block = self._figure_block((200, 60, 600, 250)) advert = Block(id="ad", page=1, bbox=list(block.bbox), type=BLOCK_AD, text="Advertisement copy " * 20) moved = snap_visual_bboxes(self.png, [block, advert, 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_refresh_moves_advert_outline_with_its_visual_parts(self): from core.magazine import refresh_visual_regions first = Block(id="f1", page=1, bbox=[10, 10, 100, 80], type=BLOCK_FIGURE, text="") second = Block(id="f2", page=1, bbox=[120, 10, 100, 80], type=BLOCK_FIGURE, text="") advert = Block(id="ad", page=1, bbox=[0, 0, 240, 100], type=BLOCK_AD, text="Buy now") layout = {"pages": [self.meta], "blocks": [first.to_json(), second.to_json(), advert.to_json()], "images": []} def move(_png, blocks, _meta): by_id = {b.id: b for b in blocks} by_id["f1"].bbox = [20, 400, 100, 80] by_id["f2"].bbox = [130, 400, 100, 80] return 2 with mock.patch("core.magazine.snap_visual_bboxes", side_effect=move), \ mock.patch("core.magazine.crop_visual_regions", return_value=[]): refresh_visual_regions(self.dir, layout, page=1) saved = {b["id"]: b for b in layout["blocks"]} self.assertEqual(saved["ad"]["bbox"], [20, 400, 210, 80]) 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) class AuditPageCoverageTests(unittest.TestCase): """audit_page_coverage: uncovered ink, overlapping boxes, clipped box edges.""" def _doc(self, boxes): from PIL import Image, ImageDraw d = pathlib.Path(tempfile.mkdtemp(prefix="ttvc-audit-")) self.addCleanup(shutil.rmtree, d, True) (d / "pages").mkdir() im = Image.new("RGB", (400, 400), "white") draw = ImageDraw.Draw(im) for y in range(60, 340, 12): # a text column at x 100..250 draw.rectangle([100, y, 250, y + 6], fill="black") im.save(d / "pages" / "0001.png") blocks = [{"id": f"b{i}", "page": 1, "bbox": list(b), "type": "body", "text": "Hallo Welt"} for i, b in enumerate(boxes)] layout = {"pages": [{"page": 1, "width": 400, "height": 400}], "blocks": blocks, "images": []} return d, layout def test_fully_covered_page_is_clean(self): from core.magazine import audit_page_coverage d, layout = self._doc([(90, 50, 170, 300)]) r = audit_page_coverage(d, layout, 1) self.assertTrue(r["measured"]) self.assertEqual((r["uncovered"], r["overlaps"], r["clipped"]), ([], [], [])) def test_text_without_a_box_is_reported(self): from core.magazine import audit_page_coverage d, layout = self._doc([(90, 50, 170, 100)]) # lower two thirds uncovered r = audit_page_coverage(d, layout, 1) self.assertTrue(r["uncovered"]) self.assertGreater(r["uncovered_ratio"], 0.3) def test_overlapping_text_boxes_are_reported(self): from core.magazine import audit_page_coverage d, layout = self._doc([(90, 50, 170, 160), (90, 120, 170, 180)]) self.assertEqual(audit_page_coverage(d, layout, 1)["overlaps"], [["b0", "b1"]]) def test_box_cutting_into_text_is_clipped(self): from core.magazine import audit_page_coverage d, layout = self._doc([(160, 50, 60, 290)]) # starts mid-line, text continues left clipped = audit_page_coverage(d, layout, 1)["clipped"] self.assertIn({"id": "b0", "side": "left"}, clipped) def test_missing_render_is_not_measured(self): from core.magazine import audit_page_coverage d, layout = self._doc([(90, 50, 170, 300)]) (d / "pages" / "0001.png").unlink() self.assertFalse(audit_page_coverage(d, layout, 1)["measured"]) class AdvertisementPageTests(unittest.TestCase): def test_article_text_skips_pages_approved_as_advertisement(self): from core.magazine import article_text layout = {"pages": [{"page": 1}, {"page": 2, "advertisement": True}], "blocks": [ {"id": "a", "page": 1, "type": "body", "text": "Real text.", "reading_order": 0}, {"id": "b", "page": 2, "type": "body", "text": "Buy now!", "reading_order": 0}], "images": []} out = article_text(layout, {"blocks": ["a", "b"], "pages": [1, 2]}) self.assertEqual([b["id"] for b in out], ["a"]) class DetectedAdvertisementPageTests(unittest.TestCase): def _layout(self, types): return {"pages": [{"page": 1}], "blocks": [{"id": f"b{i}", "page": 1, "type": t, "text": "x", "reading_order": i} for i, t in enumerate(types)], "images": []} def test_full_page_ad_is_detected_without_manual_approval(self): from core.magazine import page_is_advertisement self.assertTrue(page_is_advertisement(self._layout(["ad", "furniture"]), 1)) def test_page_with_article_text_is_not_an_ad(self): from core.magazine import page_is_advertisement self.assertFalse(page_is_advertisement(self._layout(["ad", "body"]), 1)) self.assertFalse(page_is_advertisement(self._layout(["furniture"]), 1)) def test_detected_ad_page_is_not_narrated(self): from core.magazine import article_text layout = self._layout(["ad", "body"]) layout["blocks"][1]["type"] = "ad" # only ads on the page layout["blocks"].append({"id": "t", "page": 1, "type": "body", "text": "Real.", "reading_order": 9}) # a narratable block means the page is not a pure ad, so it is still read self.assertEqual([b["id"] for b in article_text(layout, {"blocks": ["t"]})], ["t"]) class DuplicateArticleGroupTests(unittest.TestCase): def test_articles_sharing_their_regions_are_grouped(self): from core.magazine import duplicate_article_groups arts = [{"id": "a", "blocks": ["1", "2", "3", "4"]}, {"id": "b", "blocks": ["1", "2", "3", "4"]}, # same page approved again {"id": "c", "blocks": ["3", "9"]}, # shares 1 of 2 -> also a duplicate of a {"id": "d", "blocks": ["7", "8"]}, {"id": "e", "blocks": []}] self.assertEqual([sorted(g) for g in duplicate_article_groups(arts)], [["a", "b", "c"]]) def test_distinct_articles_are_not_grouped(self): from core.magazine import duplicate_article_groups self.assertEqual(duplicate_article_groups( [{"id": "a", "blocks": ["1", "2"]}, {"id": "b", "blocks": ["3", "4"]}]), []) class ClippedEdgeTests(unittest.TestCase): def test_text_running_past_the_bottom_and_right_edges_is_reported(self): import numpy as np from core.magazine import _clipped_edges pixels = np.zeros((200, 200), dtype=bool) pixels[20:100:6, 20:100] = True # ruled "text" lines, 6px pitch pixels[100:112:6, 20:100] = True # an extra line below the box pixels[20:100, 100:110] = True # ink running past the right edge meta = {"width": 200, "height": 200} block = {"id": "b", "bbox": [20, 20, 80, 80]} sides = {c["side"] for c in _clipped_edges(pixels, meta, [block], [block["bbox"]])} self.assertIn("right", sides) self.assertNotIn("left", sides) def test_neighbouring_box_is_not_clipping(self): import numpy as np from core.magazine import _clipped_edges pixels = np.zeros((200, 200), dtype=bool) pixels[20:100, 20:100] = True pixels[20:100, 100:190] = True meta = {"width": 200, "height": 200} left = {"id": "l", "bbox": [20, 20, 80, 80]} right = {"id": "r", "bbox": [100, 20, 90, 80]} found = _clipped_edges(pixels, meta, [left, right], [left["bbox"], right["bbox"]]) self.assertEqual([c for c in found if c["id"] == "l" and c["side"] == "right"], []) class FuseParagraphTests(unittest.TestCase): def _layout(self, rows): blocks = [{"id": i, "page": pg, "type": t, "text": txt, "reading_order": n, "bbox": [0, n, 1, n + 1]} for n, (i, pg, t, txt) in enumerate(rows)] return {"pages": [], "blocks": blocks, "images": []}, {"blocks": [r[0] for r in rows]} def _fused(self, rows, **kw): from core.magazine import article_text layout, article = self._layout(rows) return article_text(layout, article, fuse=True, **kw) def test_a_sentence_split_across_columns_and_pages_becomes_one_block(self): out = self._fused([ ("a", 1, "body", "Die Firma hat das neue Modell erst spät"), ("b", 1, "body", "veröffentlicht, weil die Tests nicht rechtzeitig"), ("c", 2, "body", "abgeschlossen waren. Danach ging alles schnell."), ]) self.assertEqual(len(out), 1) self.assertEqual(out[0]["text"], "Die Firma hat das neue Modell erst spät veröffentlicht, weil " "die Tests nicht rechtzeitig abgeschlossen waren. Danach ging alles schnell.") self.assertEqual([p["id"] for p in out[0]["parts"]], ["a", "b", "c"]) self.assertEqual(out[0]["parts"][1]["page"], 1) self.assertEqual(out[0]["parts"][2]["page"], 2) for part in out[0]["parts"]: self.assertTrue(out[0]["text"][part["start"]:part["end"]].strip()) def test_a_line_break_hyphen_at_the_seam_is_fused_and_umlauts_repaired(self): out = self._fused([("a", 1, "body", "Das Ergebnis ist jahr-"), ("b", 2, "body", "lich gleich gut.")]) self.assertEqual(out[0]["text"], "Das Ergebnis ist jährlich gleich gut.") def test_a_real_compound_hyphen_at_the_seam_is_kept(self): out = self._fused([("a", 1, "body", "Das neue KI-"), ("b", 2, "body", "Modelle sind schnell.")]) self.assertEqual(out[0]["text"], "Das neue KI-Modelle sind schnell.") def test_finished_paragraphs_are_never_glued_together(self): out = self._fused([("a", 1, "body", "Erster Absatz endet hier."), ("b", 1, "body", "zweiter beginnt klein.")]) self.assertEqual([b["id"] for b in out], ["a", "b"]) out = self._fused([("a", 1, "body", "Ein Absatz ohne Punkt"), ("b", 1, "body", "Ein neuer Absatz beginnt.")]) self.assertEqual(len(out), 2) def test_a_capitalised_german_noun_after_an_open_function_word_continues(self): out = self._fused([("a", 1, "body", "Sie kaufen die"), ("b", 2, "body", "Grafikkarte nicht.")]) self.assertEqual(out[0]["text"], "Sie kaufen die Grafikkarte nicht.") def test_headings_and_interview_turns_break_fusion(self): out = self._fused([("a", 1, "body", "Der Text geht weiter und"), ("h", 1, "heading", "Zwischenüberschrift"), ("b", 1, "body", "endet später.")]) self.assertEqual([b["id"] for b in out], ["a", "h", "b"]) out = self._fused([("a", 1, "body", "Frage: Was kam zuerst und"), ("b", 1, "body", "Antwort: Das Ei.")]) self.assertEqual(len(out), 2) def test_a_caption_between_fragments_moves_after_the_paragraph(self): from core.magazine import article_text layout, article = self._layout([ ("a", 1, "body", "Der Satz beginnt hier und"), ("c", 1, "caption", "Bild: Ein Chip"), ("b", 2, "body", "endet auf der nächsten Seite.")]) out = article_text(layout, article, include_captions=True, fuse=True) self.assertEqual([b["id"] for b in out], ["a", "c"]) self.assertEqual(out[0]["text"], "Der Satz beginnt hier und endet auf der nächsten Seite.") def test_fused_english_text_uses_the_article_language_for_acronyms(self): out = self._fused([("a", 1, "body", "The team says that AI is the future of the"), ("b", 2, "body", "industry and the API too.")]) self.assertIn("artificial intelligence", out[0]["spoken_text"]) self.assertIn("application programming interface", out[0]["spoken_text"]) def test_unfused_output_is_unchanged(self): from core.magazine import article_text layout, article = self._layout([("a", 1, "body", "Der Text geht weiter und"), ("b", 2, "body", "endet.")]) self.assertEqual([b["id"] for b in article_text(layout, article)], ["a", "b"]) class SuspendedCompoundSurvivesRegionEditsTests(unittest.TestCase): def test_merging_regions_keeps_a_suspended_compound_hyphen(self): from core.magazine import merge_with_next layout = {"blocks": [ {"id": "a", "page": 1, "type": "body", "bbox": [0, 0, 10, 10], "text": "bei Programmier-", "reading_order": 0}, {"id": "b", "page": 1, "type": "body", "bbox": [0, 10, 10, 10], "text": "und Hausaufgaben", "reading_order": 1}]} out, _ = merge_with_next(layout, 1, "a") self.assertEqual(out["blocks"][0]["text"], "bei Programmier- und Hausaufgaben") def test_merging_regions_still_closes_a_printed_line_break(self): from core.magazine import merge_with_next layout = {"blocks": [ {"id": "a", "page": 1, "type": "body", "bbox": [0, 0, 10, 10], "text": "eingetrich-", "reading_order": 0}, {"id": "b", "page": 1, "type": "body", "bbox": [0, 10, 10, 10], "text": "tert", "reading_order": 1}]} out, _ = merge_with_next(layout, 1, "a") self.assertEqual(out["blocks"][0]["text"], "eingetrichtert") class SnapTextBoxesTests(unittest.TestCase): """A 600x800 white page (1 px = 1 point) with solid 'text' blocks of known extent.""" def setUp(self): from pathlib import Path from PIL import Image, ImageDraw self.dir = Path(tempfile.mkdtemp(prefix="ttvc-snap-")) (self.dir / "pages").mkdir() im = Image.new("RGB", (600, 800), "white") draw = ImageDraw.Draw(im) for y in range(100, 200, 10): # ten 'lines' of ink: x 100..300, y 100..200 draw.rectangle([100, y, 300, y + 5], fill="black") for y in range(100, 200, 10): # a neighbouring column: x 320..500 draw.rectangle([320, y, 500, y + 5], fill="black") im.save(self.dir / "pages" / "0001.png") def tearDown(self): shutil.rmtree(self.dir, ignore_errors=True) def layout(self, boxes): return {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [], "blocks": [{"id": i, "page": 1, "type": "body", "text": "t", "bbox": bb, "reading_order": n, **extra} for n, (i, bb, extra) in enumerate(boxes)]} def snap(self, boxes): from core.magazine import snap_text_boxes layout, moved = snap_text_boxes(self.dir, self.layout(boxes)) return {b["id"]: b["bbox"] for b in layout["blocks"]}, moved, layout def test_empty_margin_is_trimmed_to_the_text_with_a_little_air(self): got, moved, _ = self.snap([("a", [60, 60, 250, 200], {})]) # box 60..310 x 60..260 around the text x, y, w, h = got["a"] self.assertTrue(96 <= x <= 100 and 96 <= y <= 100, got) self.assertTrue(300 <= x + w <= 306 and 196 <= y + h <= 206, got) self.assertEqual(len(moved), 1) def test_a_side_that_cuts_through_the_text_grows_until_the_text_ends(self): got, moved, _ = self.snap([("a", [100, 100, 190, 100], {})]) # right edge at 290, text runs to 300 x, y, w, h = got["a"] self.assertGreaterEqual(x + w, 298) self.assertLessEqual(x + w, 306) self.assertTrue(moved[0]["grew"]) def test_growth_never_enters_a_neighbouring_box(self): got, _, _ = self.snap([("a", [100, 100, 190, 100], {}), ("b", [296, 90, 220, 120], {})]) self.assertLessEqual(got["a"][0] + got["a"][2], 300) def test_a_box_never_grows_into_the_box_below_it(self): # text continues under 'a' (y 100..200) but 'b' already owns y 150.. of the same column got, _, _ = self.snap([("a", [100, 100, 200, 45], {}), ("b", [100, 150, 200, 60], {})]) self.assertLessEqual(got["a"][1] + got["a"][3], 150) def test_boxes_placed_by_hand_and_labelled_regions_are_left_alone(self): boxes = [("m", [60, 60, 250, 200], {"source": "manual"}), ("e", [60, 60, 250, 200], {"box_manual": True}), ("l", [60, 60, 250, 200], {"type": "furniture", "region_label": "in_picture"})] got, moved, _ = self.snap(boxes) self.assertEqual(moved, []) self.assertEqual(got["m"], [60, 60, 250, 200]) def test_a_vision_detected_paragraph_labelled_text_is_snapped_too(self): got, moved, _ = self.snap([("a", [60, 60, 250, 200], {"region_label": "text", "source": "vision_ocr"})]) self.assertEqual(len(moved), 1) def test_a_box_already_fitted_does_not_move_again_and_text_is_untouched(self): first, _, layout = self.snap([("a", [60, 60, 250, 200], {})]) from core.magazine import snap_text_boxes again, moved = snap_text_boxes(self.dir, layout) self.assertEqual(moved, []) self.assertEqual([b["text"] for b in again["blocks"]], ["t"]) def test_a_page_without_a_render_or_a_picture_type_is_skipped(self): from core.magazine import snap_text_boxes layout = self.layout([("f", [60, 60, 250, 200], {"type": "figure"})]) self.assertEqual(snap_text_boxes(self.dir, layout)[1], []) (self.dir / "pages" / "0001.png").unlink() self.assertEqual(snap_text_boxes(self.dir, self.layout([("a", [60, 60, 250, 200], {})]))[1], []) class BoxFlagsSurviveRebuildTests(unittest.TestCase): def test_snapped_and_hand_placed_flags_round_trip_through_block(self): """Regression: a saved box_snapped/box_manual key crashed every Block(**b) rebuild ("unexpected keyword argument 'box_snapped'") and so a page re-OCR.""" from core.magazine import Block raw = {"id": "a", "page": 1, "bbox": [0, 0, 1, 1], "type": "body", "text": "t", "box_snapped": True, "box_manual": True} self.assertEqual(Block(**raw).to_json()["box_snapped"], True) plain = Block(id="a", page=1, bbox=[0, 0, 1, 1], type="body", text="t").to_json() self.assertNotIn("box_snapped", plain) self.assertNotIn("box_manual", plain) def test_a_snapped_layout_can_be_rebuilt_by_the_semantic_layout_path(self): from core.magazine import Block, snap_text_boxes import tempfile, pathlib layout = {"pages": [{"page": 1, "width": 100, "height": 100}], "images": [], "blocks": [{"id": "a", "page": 1, "bbox": [0, 0, 10, 10], "type": "body", "text": "t", "box_snapped": True}]} self.assertEqual([Block(**b).id for b in layout["blocks"]], ["a"]) class RereadGrownRegionsTests(unittest.TestCase): def _layout(self, text): return {"pages": [{"page": 1, "width": 100, "height": 100}], "images": [], "blocks": [{"id": "a", "page": 1, "type": "body", "bbox": [0, 0, 50, 50], "text": text}]} def run_reread(self, old, new, moved=({"id": "a", "lines_added": True},)): from core.magazine import reread_grown_regions with mock.patch('core.magazine.extract_region_text', return_value=new): layout, n = reread_grown_regions(pathlib.Path('/tmp/x'), self._layout(old), list(moved), 'u', 'm') return layout["blocks"][0]["text"], n def test_a_longer_reading_that_starts_with_the_old_text_replaces_it(self): old = "Allein hilft dieser Index allerdings wenig." new = old + " Erst die sogenannten App Intents verraten dem System, was Apps mit den Inhalten anfangen." self.assertEqual(self.run_reread(old, new), (new, 1)) def test_a_reading_that_wandered_elsewhere_or_is_not_longer_is_refused(self): old = "Allein hilft dieser Index allerdings wenig." self.assertEqual(self.run_reread(old, "Ganz anderer Absatz aus der anderen Spalte mit viel mehr Text drin.")[1], 0) self.assertEqual(self.run_reread(old, old)[1], 0) def test_boxes_that_did_not_gain_lines_are_not_re_read(self): with mock.patch('core.magazine.extract_region_text') as read: from core.magazine import reread_grown_regions reread_grown_regions(pathlib.Path('/tmp/x'), self._layout("t"), [{"id": "a", "lines_added": False}], 'u', 'm') read.assert_not_called() class NonTextNoiseTests(unittest.TestCase): def test_the_engines_non_text_marker_and_hairline_boxes_become_noise(self): from core.magazine import clean_up_regions layout = {"pages": [{"page": 1, "width": 100, "height": 100}], "images": [], "blocks": [ {"id": "n", "page": 1, "type": "body", "bbox": [10, 10, 80, 0.7], "text": "[Non-Text]"}, {"id": "h", "page": 1, "type": "body", "bbox": [10, 20, 80, 1.0], "text": "A real looking sentence here."}, {"id": "ok", "page": 1, "type": "body", "bbox": [10, 30, 80, 12], "text": "A real looking sentence here."}]} out, report = clean_up_regions(layout) types = {b["id"]: b["type"] for b in out["blocks"]} self.assertEqual((types["n"], types["h"], types["ok"]), ("furniture", "furniture", "body")) self.assertEqual(len(report["ocr_noise"]), 2) class InsetPullquoteTests(unittest.TestCase): def setUp(self): from pathlib import Path from PIL import Image, ImageDraw self.dir = Path(tempfile.mkdtemp(prefix="ttvc-pq-")) (self.dir / "pages").mkdir() im = Image.new("RGB", (600, 800), (248, 242, 244)) draw = ImageDraw.Draw(im) for y in range(300, 350, 12): # muted teal display text, ~140x50 pt for x in range(100, 240, 6): # thin dashes, like letter strokes draw.rectangle([x, y, x + 2, y + 5], fill=(100, 122, 121)) for y in range(100, 200, 10): # grey body text elsewhere draw.rectangle([300, y, 500, y + 4], fill=(93, 91, 92)) draw.rectangle([400, 500, 560, 620], fill=(30, 40, 200)) # a strongly coloured picture im.save(self.dir / "pages" / "0001.png") def tearDown(self): shutil.rmtree(self.dir, ignore_errors=True) def layout(self, blocks=()): return {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [], "blocks": list(blocks)} def test_an_uncovered_display_coloured_quote_becomes_a_pullquote_region(self): from core.magazine import detect_inset_pullquotes out, added = detect_inset_pullquotes(self.dir, self.layout()) self.assertEqual(len(added), 1) block = out["blocks"][0] self.assertEqual((block["type"], block["source"]), ("pullquote", "inset_pullquote")) x, y, w, h = block["bbox"] self.assertTrue(90 <= x <= 105 and 290 <= y <= 305 and w >= 135, block["bbox"]) def test_body_text_photos_and_covered_headlines_are_not_quotes(self): from core.magazine import detect_inset_pullquotes covered = [{"id": "t", "page": 1, "type": "title", "bbox": [90, 290, 160, 70], "text": "Headline"}] self.assertEqual(detect_inset_pullquotes(self.dir, self.layout(covered))[1], []) def test_running_it_twice_adds_nothing_more(self): from core.magazine import detect_inset_pullquotes once, _ = detect_inset_pullquotes(self.dir, self.layout()) self.assertEqual(detect_inset_pullquotes(self.dir, once)[1], []) def test_the_quote_text_is_read_from_the_crop_when_a_vision_engine_is_configured(self): from core.magazine import detect_inset_pullquotes with mock.patch('core.magazine.extract_region_text', return_value="Weil virtuelle Butler mächtig werden"): out, _ = detect_inset_pullquotes(self.dir, self.layout(), None, "http://ocr", "m") self.assertEqual(out["blocks"][0]["text"], "Weil virtuelle Butler mächtig werden") class SplitAroundPullquoteTests(unittest.TestCase): """Page 600x800 (1 px = 1 pt): four full-width text lines, then five lines narrowed by an inset quote.""" def setUp(self): from pathlib import Path from PIL import Image, ImageDraw self.dir = Path(tempfile.mkdtemp(prefix="ttvc-wrap-")) (self.dir / "pages").mkdir() im = Image.new("RGB", (600, 800), (248, 242, 244)) draw = ImageDraw.Draw(im) for i in range(4): # full width: x 50..250, y 100.. draw.rectangle([50, 100 + i * 14, 250, 106 + i * 14], fill=(93, 91, 92)) for i in range(5): # narrow: x 50..170, y 160.. draw.rectangle([50, 160 + i * 14, 170, 166 + i * 14], fill=(93, 91, 92)) im.save(self.dir / "pages" / "0001.png") self.text = " ".join(f"word{i:02d}" for i in range(40)) self.layout = {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [], "blocks": [ {"id": "p", "page": 1, "type": "body", "bbox": [48, 98, 206, 136], "text": self.text, "reading_order": 0}, {"id": "q", "page": 1, "type": "pullquote", "bbox": [180, 158, 70, 60], "text": "quote", "reading_order": 1}]} def tearDown(self): shutil.rmtree(self.dir, ignore_errors=True) def test_the_paragraph_is_cut_into_a_full_width_part_and_a_narrow_part(self): from core.magazine import split_around_pullquotes out, rows = split_around_pullquotes(self.dir, self.layout) parts = [b for b in out["blocks"] if b["type"] == "body"] self.assertEqual(len(parts), 2) self.assertEqual(len(rows), 1) top, low = parts self.assertGreaterEqual(top["bbox"][2], 195) # full width self.assertLessEqual(top["bbox"][1] + top["bbox"][3], 162) # ends before the narrow lines self.assertLessEqual(low["bbox"][0] + low["bbox"][2], 178) # stays left of the quote (x 180) self.assertEqual(" ".join([top["text"], low["text"]]), self.text) # no word lost or split self.assertGreater(len(top["text"]), len(self.text) * 0.3) # roughly 4 of 9 lines of ink self.assertLess(len(top["text"]), len(self.text) * 0.6) self.assertEqual(rows[0], {"id": "p", "new_id": low["id"]}) def test_reading_order_keeps_the_new_part_directly_after_the_first(self): from core.magazine import split_around_pullquotes out, _ = split_around_pullquotes(self.dir, self.layout) order = [b["type"] for b in sorted(out["blocks"], key=lambda b: b["reading_order"])] self.assertEqual(order, ["body", "body", "pullquote"]) def test_running_twice_and_hand_placed_boxes_change_nothing(self): from core.magazine import split_around_pullquotes once, _ = split_around_pullquotes(self.dir, self.layout) self.assertEqual(split_around_pullquotes(self.dir, once)[1], []) manual = dict(self.layout, blocks=[dict(self.layout["blocks"][0], box_manual=True), self.layout["blocks"][1]]) self.assertEqual(split_around_pullquotes(self.dir, manual)[1], []) def test_a_paragraph_the_quote_does_not_touch_is_left_alone(self): from core.magazine import split_around_pullquotes layout = dict(self.layout, blocks=[self.layout["blocks"][0], dict(self.layout["blocks"][1], bbox=[400, 500, 70, 60])]) self.assertEqual(split_around_pullquotes(self.dir, layout)[1], []) class ArticleFlowTests(unittest.TestCase): def layout(self, rows): blocks = [{"id": i, "page": pg, "type": ty, "text": txt, "reading_order": n, "bbox": bb} for n, (i, pg, ty, txt, bb) in enumerate(rows)] return {"pages": [], "blocks": blocks, "images": []}, {"blocks": [r[0] for r in rows]} def flow(self, rows): from core.magazine import article_flow return article_flow(*self.layout(rows)) def test_every_step_between_regions_is_a_link_that_says_how_the_eye_travels(self): flow = self.flow([ ("t", 1, "title", "Titel", [10, 10, 200, 20]), ("a", 1, "body", "Der Satz beginnt in der ersten Spalte und", [10, 40, 90, 100]), ("b", 1, "body", "geht in der zweiten Spalte weiter. Fertig.", [120, 40, 90, 100]), ("c", 1, "body", "Ein neuer Absatz darunter.", [120, 150, 90, 50]), ("d", 2, "body", "Und ein neuer Absatz auf der nächsten Seite.", [10, 40, 90, 100])]) self.assertEqual([(l["from"], l["to"], l["relation"], l["sentence"]) for l in flow["links"]], [("t", "a", "below", False), ("a", "b", "column", True), ("b", "c", "below", False), ("c", "d", "page", False)]) self.assertEqual((flow["first"], flow["last"]), ("t", "d")) def test_a_next_column_link_exists_even_when_the_sentence_ended(self): flow = self.flow([("a", 1, "body", "Ein fertiger Absatz.", [10, 40, 90, 100]), ("b", 1, "body", "Der nächste Absatz in der anderen Spalte.", [120, 40, 90, 100])]) self.assertEqual((flow["links"][0]["relation"], flow["links"][0]["sentence"]), ("column", False)) class BlocksToLinkTests(unittest.TestCase): def setUp(self): def blk(i, pg, ty, txt, n): return {"id": i, "page": pg, "type": ty, "text": txt, "reading_order": n, "bbox": [0, 0, 1, 1]} self.layout = {"pages": [], "images": [], "blocks": [ blk("a", 1, "body", "Der Text beginnt hier und", 0), blk("p1", 2, "body", "Ein anderer, fertiger Absatz.", 0), blk("p2", 2, "body", "geht auf dieser Seite weiter und endet.", 1), blk("p3", 2, "body", "Noch ein Absatz desselben Beitrags.", 2), blk("h", 2, "heading", "Ein neuer Beitrag", 3), blk("p4", 2, "body", "Gehört zum neuen Beitrag.", 4)]} self.article = {"blocks": ["a"]} def test_linking_forward_starts_at_the_paragraph_that_carries_on_and_stops_at_a_new_headline(self): from core.magazine import blocks_to_link self.assertEqual(blocks_to_link(self.layout, self.article, 2, "after"), ["p2", "p3"]) def test_without_a_carrying_paragraph_it_starts_at_the_first_body_region(self): from core.magazine import blocks_to_link self.layout["blocks"][0]["text"] = "Der Text ist fertig." self.layout["blocks"][2]["text"] = "Geht auf dieser Seite weiter und endet." self.assertEqual(blocks_to_link(self.layout, self.article, 2, "after"), ["p1", "p2", "p3"]) def test_linking_backward_takes_the_last_opening_of_that_page(self): from core.magazine import blocks_to_link self.assertEqual(blocks_to_link(self.layout, {"blocks": []}, 2, "before"), ["h", "p4"]) def test_regions_already_in_the_article_are_not_offered_again(self): from core.magazine import blocks_to_link self.assertNotIn("p2", blocks_to_link(self.layout, {"blocks": ["a", "p2"]}, 2, "after")) class AutoLinkPagesTests(unittest.TestCase): def blk(self, i, page, ty, text, n=0): return {"id": i, "page": page, "type": ty, "text": text, "reading_order": n, "bbox": [0, 0, 1, 1]} def art(self, i, title, pages, blocks, status="detected"): return {"id": i, "title": title, "author": "", "pages": pages, "blocks": blocks, "images": [], "status": status, "confidence": 0.5, "section": "", "continued_to": [], "audio_status": "none"} def link(self, blocks, articles): from core.magazine import auto_link_pages return auto_link_pages({"pages": [], "images": [], "blocks": blocks}, articles) def test_pages_without_a_headline_continue_the_article_before_and_the_stand_ins_go(self): blocks = [self.blk("t", 1, "title", "Der Butler", 0), self.blk("a", 1, "body", "Der Text beginnt und", 1), self.blk("b", 2, "body", "geht auf Seite zwei weiter.", 0), self.blk("c", 3, "body", "Und endet auf Seite drei. (hag)", 0)] arts = [self.art("article-001", "Der Butler", [1], ["t", "a"], "approved"), self.art("article-002", "Page 2", [2], ["b"], "approved"), self.art("article-003", "Page 3", [3], ["c"], "approved")] out, report = self.link(blocks, arts) self.assertEqual([(a["id"], a["pages"], a["blocks"]) for a in out], [("article-001", [1, 2, 3], ["t", "a", "b", "c"])]) self.assertEqual(out[0]["status"], "approved") # the stand-ins were approved: still reviewed self.assertEqual((report["linked"], report["dropped"]), (2, 2)) def test_a_detected_article_made_only_of_already_approved_text_becomes_approved(self): blocks = [self.blk("t", 1, "title", "Der Butler", 0), self.blk("a", 1, "body", "Der Text beginnt und", 1), self.blk("b", 2, "body", "geht weiter.", 0)] arts = [self.art("article-001", "Der Butler", [1], ["t", "a"], "detected"), self.art("article-002", "Page 1", [1], ["t", "a"], "approved"), self.art("article-003", "Page 2", [2], ["b"], "approved")] out = self.link(blocks, arts)[0] self.assertEqual([(a["id"], a["status"]) for a in out], [("article-001", "approved")]) def test_text_nobody_approved_sends_the_article_back_to_review(self): blocks = [self.blk("t", 1, "title", "Der Butler", 0), self.blk("a", 1, "body", "Der Text beginnt und", 1), self.blk("b", 2, "body", "geht weiter.", 0)] arts = [self.art("article-001", "Der Butler", [1], ["t", "a"], "approved"), self.art("article-002", "Page 2", [2], ["b"], "detected")] self.assertEqual(self.link(blocks, arts)[0][0]["status"], "detected") def test_a_signed_end_stops_the_article_and_leaves_the_next_page_alone(self): blocks = [self.blk("t", 1, "title", "Kurz", 0), self.blk("a", 1, "body", "Fertig. (atr)", 1), self.blk("b", 2, "body", "Ein ganz anderer Text.", 0)] out, report = self.link(blocks, [self.art("article-001", "Kurz", [1], ["t", "a"]), self.art("article-002", "Page 2", [2], ["b"])]) self.assertEqual(len(out), 2) self.assertEqual(report["unlinked"], ["b"]) def test_a_headline_with_a_byline_opens_a_new_article(self): blocks = [self.blk("t", 1, "title", "Alt", 0), self.blk("a", 1, "body", "Text geht weiter und", 1), self.blk("n", 2, "title", "Neu", 0), self.blk("by", 2, "byline", "Von Anna Beispiel", 1), self.blk("x", 2, "body", "Der neue Beitrag beginnt.", 2)] out, report = self.link(blocks, [self.art("article-001", "Alt", [1], ["t", "a"]), self.art("article-002", "Page 2", [2], ["n", "by", "x"])]) new = next(a for a in out if a["title"] == "Neu") self.assertEqual((new["author"], new["blocks"], report["created"]), ("Anna Beispiel", ["n", "by", "x"], 1)) def test_too_far_from_the_running_article_stays_unlinked(self): blocks = [self.blk("t", 1, "title", "Alt", 0), self.blk("a", 1, "body", "Text geht weiter und", 1), self.blk("z", 6, "body", "weit weg", 0)] out, report = self.link(blocks, [self.art("article-001", "Alt", [1], ["t", "a"]), self.art("article-002", "Page 6", [6], ["z"])]) self.assertEqual(report["unlinked"], ["z"]) class FlowEndedAndAutoOrderTests(unittest.TestCase): def layout(self, last_text): blocks = [{"id": "a", "page": 1, "type": "body", "text": "Der Anfang.", "reading_order": 0, "bbox": [10, 10, 90, 50]}, {"id": "b", "page": 1, "type": "body", "text": last_text, "reading_order": 1, "bbox": [10, 70, 90, 50]}] return {"pages": [{"page": 1, "width": 200, "height": 300}], "blocks": blocks, "images": []}, {"blocks": ["a", "b"]} def test_only_a_signed_end_says_the_article_ended(self): from core.magazine import article_flow self.assertTrue(article_flow(*self.layout("Und Schluss. (hag) ct"))["ended"]) open_layout, article = self.layout("Und die Frage bleibt offen?") open_layout["blocks"].append({"id": "c", "page": 2, "type": "body", "text": "Geht weiter.", "reading_order": 0, "bbox": [10, 10, 90, 50]}) self.assertFalse(article_flow(open_layout, article)["ended"]) self.assertEqual(article_flow(*self.layout("x"))["last_page"], 1) def test_a_locked_page_keeps_its_order_and_an_unlocked_page_gets_the_column_order(self): from core.magazine import reorder_page def blk(i, x, y, n): return {"id": i, "page": 1, "type": "body", "text": "Text " + i, "reading_order": n, "bbox": [x, y, 90, 60]} # right column listed first by the (wrong) engine order; left column second blocks = [blk("r1", 110, 10, 0), blk("r2", 110, 80, 1), blk("l1", 10, 10, 2), blk("l2", 10, 80, 3)] layout = {"pages": [{"page": 1, "width": 210, "height": 300}], "images": [], "blocks": blocks} order = lambda l: [b["id"] for b in sorted(l["blocks"], key=lambda b: b["reading_order"])] self.assertEqual(order(reorder_page(layout, 1)), ["l1", "l2", "r1", "r2"]) locked = dict(layout, pages=[{"page": 1, "width": 210, "height": 300, "order_locked": True}]) self.assertEqual(order(reorder_page(locked, 1)), ["r1", "r2", "l1", "l2"]) class ArticleEndedTests(unittest.TestCase): def ended(self, rows, own): from core.magazine import article_flow blocks = [{"id": i, "page": pg, "type": ty, "text": txt, "reading_order": n, "bbox": [10, 10 + 60 * n, 90, 50]} for n, (i, pg, ty, txt) in enumerate(rows)] return article_flow({"pages": [], "blocks": blocks, "images": []}, {"blocks": own})["ended"] def test_a_signed_end_with_ocr_noise_after_it_ends_the_article(self): self.assertTrue(self.ended([("a", 1, "body", "Der Schluss. (rah) et"), ("z", 1, "body", "weiter geht es")], ["a"])) def test_a_headline_as_the_next_block_ends_the_article(self): self.assertTrue(self.ended([("a", 1, "body", "Ohne Kürzel am Ende"), ("h", 1, "title", "Neuer Beitrag")], ["a"])) def test_a_headline_at_the_top_of_the_next_page_ends_it_too(self): self.assertTrue(self.ended([("a", 1, "body", "Ohne Kürzel am Ende"), ("h", 2, "heading", "Neuer Beitrag")], ["a"])) def test_running_text_next_means_it_probably_continues(self): self.assertFalse(self.ended([("a", 1, "body", "Ohne Kürzel am Ende"), ("b", 2, "body", "Geht auf der nächsten Seite weiter.")], ["a"])) def test_a_headline_two_pages_on_does_not_end_it(self): self.assertFalse(self.ended([("a", 1, "body", "Ohne Kürzel am Ende"), ("h", 3, "title", "Viel später")], ["a"])) class PageJunkTests(unittest.TestCase): def test_page_lists_running_heads_and_reference_lists_are_junk(self): from core.magazine import looks_like_page_junk self.assertTrue(looks_like_page_junk("66 90,98 50,56 82, 114, 120 148, 168 172, 194")) self.assertTrue(looks_like_page_junk("46 Arbeitswelt. Wirtschaft", repeats=5)) self.assertTrue(looks_like_page_junk("Literatur [1] Trinkwalder, Wunderbare Einhörner")) self.assertTrue(looks_like_page_junk("Studien, weitere Quellen und Tools:")) self.assertFalse(looks_like_page_junk("Der Konzern braucht eine, die iPhone, iPad und Mac unentbehrlich macht.")) self.assertFalse(looks_like_page_junk("Ein kurzer Satz.", repeats=1)) def test_auto_link_leaves_such_text_out_of_the_article(self): from core.magazine import auto_link_pages blk = lambda i, pg, ty, t, n: {"id": i, "page": pg, "type": ty, "text": t, "reading_order": n, "bbox": [0, 0, 1, 1]} layout = {"pages": [], "images": [], "blocks": [ blk("t", 1, "title", "Der Butler", 0), blk("a", 1, "body", "Der Text beginnt und", 1), blk("b", 2, "body", "geht auf Seite zwei weiter.", 0), blk("j", 2, "body", "66 90,98 50,56 82, 114", 1)]} art = lambda i, ti, pg, bl, st: {"id": i, "title": ti, "author": "", "pages": pg, "blocks": bl, "images": [], "status": st, "confidence": 0.5, "section": "", "continued_to": [], "audio_status": "none"} out, _ = auto_link_pages(layout, [art("article-001", "Der Butler", [1], ["t", "a"], "approved"), art("article-002", "Page 2", [2], ["b"], "approved")]) self.assertEqual(out[0]["blocks"], ["t", "a", "b"]) class UncoveredPictureTests(unittest.TestCase): def setUp(self): from pathlib import Path from PIL import Image, ImageDraw self.dir = Path(tempfile.mkdtemp(prefix="ttvc-pic-")) (self.dir / "pages").mkdir() im = Image.new("RGB", (600, 800), (248, 242, 244)) draw = ImageDraw.Draw(im) draw.rectangle([100, 200, 300, 350], fill=(60, 90, 140)) # a photo: dense ink, 200x150 for y in range(500, 560, 12): # text lines: thin, sparse draw.rectangle([100, y, 300, y + 3], fill=(93, 91, 92)) im.save(self.dir / "pages" / "0001.png") self.layout = {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [], "blocks": []} def tearDown(self): shutil.rmtree(self.dir, ignore_errors=True) def test_a_dense_unboxed_blob_becomes_a_figure_and_text_lines_do_not(self): from core.magazine import add_uncovered_pictures out, added = add_uncovered_pictures(self.dir, self.layout, 1) self.assertEqual(len(added), 1) x, y, w, h = out["images"][0]["bbox"] self.assertTrue(90 <= x <= 105 and 190 <= y <= 205 and w >= 195 and h >= 145, out["images"][0]["bbox"]) self.assertTrue((self.dir / "images" / out["images"][0]["filename"]).is_file()) def test_running_it_again_finds_nothing_new(self): from core.magazine import add_uncovered_pictures once, _ = add_uncovered_pictures(self.dir, self.layout, 1) self.assertEqual(add_uncovered_pictures(self.dir, once, 1)[1], []) def test_running_heads_and_adverts_are_not_audited_for_clipped_edges(self): from core.magazine import audit_page_coverage layout = dict(self.layout, blocks=[{"id": "f", "page": 1, "type": "furniture", "text": "45", "bbox": [100, 500, 100, 10]}, {"id": "a", "page": 1, "type": "ad", "text": "Kaufen", "bbox": [100, 520, 100, 10]}]) self.assertEqual(audit_page_coverage(self.dir, layout, 1)["clipped"], []) class UncoveredNearGraphicsTests(unittest.TestCase): def setUp(self): from pathlib import Path from PIL import Image, ImageDraw self.dir = Path(tempfile.mkdtemp(prefix="ttvc-gfx-")) (self.dir / "pages").mkdir() im = Image.new("RGB", (600, 800), (248, 242, 244)) draw = ImageDraw.Draw(im) for y in range(300, 360, 12): # text-like lines beside a chart draw.rectangle([320, y, 460, y + 3], fill=(93, 91, 92)) for y in range(600, 660, 12): # the same lines, far from anything draw.rectangle([100, y, 240, y + 3], fill=(93, 91, 92)) im.save(self.dir / "pages" / "0001.png") def tearDown(self): shutil.rmtree(self.dir, ignore_errors=True) def audit(self, images=(), blocks=()): from core.magazine import audit_page_coverage layout = {"pages": [{"page": 1, "width": 600, "height": 800}], "images": list(images), "blocks": list(blocks)} return audit_page_coverage(self.dir, layout, 1)["uncovered"] def test_unboxed_ink_far_from_any_graphic_is_reported(self): self.assertEqual(len(self.audit()), 2) def test_ink_beside_a_picture_or_inside_an_advert_is_part_of_it_and_not_reported(self): chart = {"id": "c", "page": 1, "bbox": [200, 280, 110, 100], "filename": "c.png", "reading_order": 0} ad = {"id": "ad", "page": 1, "type": "ad", "text": "Kaufen", "bbox": [80, 580, 200, 100], "reading_order": 0} found = self.audit(images=[chart], blocks=[ad]) self.assertEqual(found, []) class ResolveBoxOverlapTests(unittest.TestCase): def blk(self, i, ty, bbox, **kw): return {"id": i, "page": 1, "type": ty, "text": "t " + i, "bbox": bbox, "reading_order": 0, **kw} def resolve(self, blocks): from core.magazine import resolve_box_overlaps out, trimmed = resolve_box_overlaps({"pages": [{"page": 1}], "images": [], "blocks": blocks}) return {b["id"]: b["bbox"] for b in out["blocks"]}, trimmed def test_a_caption_running_over_a_headline_is_cut_back_below_it(self): boxes, trimmed = self.resolve([self.blk("t", "title", [10, 10, 200, 40]), self.blk("c", "caption", [10, 30, 200, 120])]) self.assertEqual(trimmed, ["c"]) self.assertEqual(boxes["t"], [10, 10, 200, 40]) # the headline is untouched self.assertGreaterEqual(boxes["c"][1], 50) # the caption now starts under it self.assertLessEqual(boxes["c"][1] + boxes["c"][3], 150) def test_the_lower_ranked_box_is_the_one_that_moves_whichever_is_listed_first(self): boxes, _ = self.resolve([self.blk("c", "caption", [10, 30, 200, 120]), self.blk("t", "title", [10, 10, 200, 40])]) self.assertEqual(boxes["t"], [10, 10, 200, 40]) def test_equal_rank_hand_placed_and_barely_overlapping_boxes_are_left_alone(self): a, t = self.resolve([self.blk("a", "body", [10, 10, 100, 50]), self.blk("b", "body", [10, 40, 100, 50])]) self.assertEqual(t, []) _, t = self.resolve([self.blk("t", "title", [10, 10, 200, 40]), self.blk("c", "caption", [10, 30, 200, 120], box_manual=True)]) self.assertEqual(t, []) _, t = self.resolve([self.blk("t", "title", [10, 10, 200, 40]), self.blk("c", "caption", [10, 48, 200, 120])]) self.assertEqual(t, []) # a 2 pt graze is not an overlap def test_a_box_that_would_be_cut_to_almost_nothing_stays_for_a_human(self): _, trimmed = self.resolve([self.blk("t", "title", [0, 0, 200, 100]), self.blk("c", "caption", [10, 10, 180, 60])]) self.assertEqual(trimmed, []) class ResolveOverlapDuplicatesAndHeadlineGrowthTests(unittest.TestCase): def test_an_inset_pullquote_repeating_a_box_that_already_holds_that_text_becomes_a_duplicate(self): from core.magazine import resolve_box_overlaps text = "»Mitarbeiter, die ihre Aufgaben einfach an die KI durchreichen, ersetzen sich selbst.«" layout = {"pages": [{"page": 1}], "images": [], "blocks": [ {"id": "b", "page": 1, "type": "body", "text": text, "bbox": [10, 10, 120, 50], "reading_order": 0}, {"id": "q", "page": 1, "type": "pullquote", "source": "inset_pullquote", "text": text, "bbox": [15, 20, 110, 35], "reading_order": 1}]} out, fixed = resolve_box_overlaps(layout) self.assertEqual(fixed, ["q"]) self.assertEqual(next(b for b in out["blocks"] if b["id"] == "q")["type"], "furniture") self.assertEqual(next(b for b in out["blocks"] if b["id"] == "b")["type"], "body") def test_a_headline_box_may_grow_further_sideways_than_a_body_box(self): from pathlib import Path from PIL import Image, ImageDraw from core.magazine import snap_text_boxes d = Path(tempfile.mkdtemp(prefix="ttvc-headgrow-")) self.addCleanup(shutil.rmtree, d, True) (d / "pages").mkdir() im = Image.new("RGB", (600, 800), (248, 242, 244)) draw = ImageDraw.Draw(im) draw.rectangle([100, 100, 300, 130], fill=(30, 30, 30)) # a headline 200 px wide, thick strokes im.save(d / "pages" / "0001.png") def grown(kind): layout = {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [], "blocks": [ {"id": "a", "page": 1, "type": kind, "text": "t", "bbox": [98, 98, 140, 36], "reading_order": 0}]} # stops 60 px short out, _ = snap_text_boxes(d, layout) x, y, w, h = out["blocks"][0]["bbox"] return x + w self.assertGreaterEqual(grown("title"), 296) # reaches the end of the headline self.assertLess(grown("body"), 260) # a body box is capped at ~16 px class ClippedEdgeIgnoresSolidBackgroundsTests(unittest.TestCase): def audit(self, fill_solid): from pathlib import Path from PIL import Image, ImageDraw from core.magazine import audit_page_coverage d = Path(tempfile.mkdtemp(prefix="ttvc-clip-")) self.addCleanup(shutil.rmtree, d, True) (d / "pages").mkdir() im = Image.new("RGB", (600, 800), (248, 242, 244)) draw = ImageDraw.Draw(im) if fill_solid: draw.rectangle([250, 90, 330, 140], fill=(30, 40, 90)) # solid panel right of the box else: for y in range(100, 130, 8): # text lines running on right of the box draw.rectangle([250, y, 330, y + 3], fill=(93, 91, 92)) im.save(d / "pages" / "0001.png") layout = {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [], "blocks": [ {"id": "t", "page": 1, "type": "title", "text": "Headline", "bbox": [100, 95, 150, 40], "reading_order": 0}]} return audit_page_coverage(d, layout, 1)["clipped"] def test_text_running_past_the_edge_is_clipping_but_a_solid_panel_is_not(self): self.assertEqual([c["side"] for c in self.audit(False)], ["right"]) self.assertEqual(self.audit(True), []) class LeaderLineIsNotMissedTextTests(unittest.TestCase): def audit(self, draw_fn): from pathlib import Path from PIL import Image, ImageDraw from core.magazine import audit_page_coverage d = Path(tempfile.mkdtemp(prefix="ttvc-rule-")) self.addCleanup(shutil.rmtree, d, True) (d / "pages").mkdir() im = Image.new("RGB", (600, 800), (248, 242, 244)) draw_fn(ImageDraw.Draw(im)) im.save(d / "pages" / "0001.png") layout = {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [], "blocks": []} return audit_page_coverage(d, layout, 1)["uncovered"] def test_a_dotted_leader_or_rule_is_not_reported_but_text_lines_are(self): def leader(draw): for x in range(200, 400, 6): # dots between a heading and its page number draw.rectangle([x, 300, x + 1, 302], fill=(93, 91, 92)) self.assertEqual(self.audit(leader), []) def rule(draw): draw.rectangle([100, 500, 400, 502], fill=(93, 91, 92)) self.assertEqual(self.audit(rule), []) def text(draw): for y in range(300, 360, 12): draw.rectangle([100, y, 300, y + 8], fill=(93, 91, 92)) self.assertEqual(len(self.audit(text)), 1) class MendColumnSeamsTests(unittest.TestCase): """600x800 page (1 px = 1 pt): text lines 6 px tall every 12 px in one column, x 100..300.""" def setUp(self): from pathlib import Path from PIL import Image, ImageDraw self.dir = Path(tempfile.mkdtemp(prefix="ttvc-seam-")) (self.dir / "pages").mkdir() im = Image.new("RGB", (600, 800), (248, 242, 244)) draw = ImageDraw.Draw(im) for y in range(100, 260, 12): # 14 lines: y 100-106 ... 256-262 draw.rectangle([100, y, 300, y + 6], fill=(93, 91, 92)) im.save(self.dir / "pages" / "0001.png") def tearDown(self): shutil.rmtree(self.dir, ignore_errors=True) def layout(self, a_bottom, b_top, a_text="Der Absatz beginnt hier und"): blk = lambda i, bbox, t: {"id": i, "page": 1, "type": "body", "text": t, "bbox": bbox, "reading_order": 0} return {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [], "blocks": [ blk("a", [98, 98, 205, a_bottom - 98], a_text), blk("b", [98, b_top, 205, 264 - b_top], "geht weiter und endet.")]} def lines_cut(self, out): """Text lines (as y start) that a box boundary runs through.""" cuts = [] for y in range(100, 260, 12): for b in out["blocks"]: top, bottom = b["bbox"][1], b["bbox"][1] + b["bbox"][3] for edge in (top, bottom): if y + 1 < edge < y + 5: cuts.append(y) return cuts def test_overlapping_boxes_meet_at_a_gap_between_lines(self): from core.magazine import mend_column_seams layout = self.layout(a_bottom=180, b_top=166) # overlap of 14 px, through lines out, _ = mend_column_seams(self.dir, layout) a, b = out["blocks"] self.assertLessEqual(a["bbox"][1] + a["bbox"][3], b["bbox"][1] + 0.5) # no overlap any more self.assertEqual(self.lines_cut(out), []) self.assertTrue(a["box_snapped"] and b["box_snapped"]) def test_a_line_left_in_the_seam_goes_to_the_running_paragraph_above_and_is_reported_for_reading(self): from core.magazine import mend_column_seams layout = self.layout(a_bottom=137, b_top=160) # the lines at y 136 and 148 lie between the boxes out, strips = mend_column_seams(self.dir, layout) a, b = out["blocks"] self.assertGreaterEqual(a["bbox"][1] + a["bbox"][3], 155) # the upper box now covers them self.assertLessEqual(a["bbox"][1] + a["bbox"][3], b["bbox"][1] + 0.5) # and they no longer overlap self.assertEqual(self.lines_cut(out), []) self.assertEqual([(s["id"], s["where"]) for s in strips], [("a", "end")]) self.assertGreaterEqual(strips[0]["strip"][3], 15) def test_a_finished_paragraph_gives_the_seam_line_to_the_box_below(self): from core.magazine import mend_column_seams layout = self.layout(a_bottom=137, b_top=160, a_text="Der Absatz ist zu Ende.") _, strips = mend_column_seams(self.dir, layout) self.assertEqual([(s["id"], s["where"]) for s in strips], [("b", "start")]) def test_a_clean_seam_hand_placed_boxes_and_other_columns_are_left_alone(self): from core.magazine import mend_column_seams clean = self.layout(a_bottom=181, b_top=182) # already inside the gap after the line at 172-178 out, strips = mend_column_seams(self.dir, clean) self.assertEqual((out["blocks"][0]["bbox"], strips), (clean["blocks"][0]["bbox"], [])) manual = self.layout(a_bottom=180, b_top=166) manual["blocks"][0]["box_manual"] = True self.assertEqual(mend_column_seams(self.dir, manual)[0]["blocks"][0]["bbox"], manual["blocks"][0]["bbox"]) beside = self.layout(a_bottom=180, b_top=166) beside["blocks"][1]["bbox"][0] = 400 # a different column self.assertEqual(mend_column_seams(self.dir, beside)[0]["blocks"][1]["bbox"], beside["blocks"][1]["bbox"]) def test_read_seam_strips_attaches_the_words_before_or_after_the_right_text(self): from core.magazine import read_seam_strips layout = self.layout(a_bottom=137, b_top=160) strips = [{"id": "a", "where": "end", "strip": [98, 137, 205, 22]}, {"id": "b", "where": "start", "strip": [98, 160, 205, 10]}] with mock.patch('core.magazine.extract_region_text', side_effect=["Zwischenzeile mit genug Wörtern", "Vorlauf des Absatzes davor"]): out, n = read_seam_strips(self.dir, layout, strips) self.assertEqual(n, 2) self.assertEqual(out["blocks"][0]["text"], "Der Absatz beginnt hier und Zwischenzeile mit genug Wörtern") self.assertEqual(out["blocks"][1]["text"], "Vorlauf des Absatzes davor geht weiter und endet.") def test_an_unreadable_strip_leaves_the_text_as_it_was(self): from core.magazine import read_seam_strips layout = self.layout(a_bottom=137, b_top=160) with mock.patch('core.magazine.extract_region_text', return_value="|"): out, n = read_seam_strips(self.dir, layout, [{"id": "a", "where": "end", "strip": [98, 137, 205, 22]}]) self.assertEqual((n, out["blocks"][0]["text"]), (0, "Der Absatz beginnt hier und"))