2746 lines
138 KiB
Python
2746 lines
138 KiB
Python
"""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_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], {"region_label": "in_picture"})]
|
|
got, moved, _ = self.snap(boxes)
|
|
self.assertEqual(moved, [])
|
|
self.assertEqual(got["m"], [60, 60, 250, 200])
|
|
|
|
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], [])
|