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