feat(magazine): blue unreviewed articles, auto-resolve overlapping boxes, ignore unboxed ink near graphics/ads, fewer false clipped edges

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-28 15:20:25 +02:00
parent c62cc6953d
commit 3d5da78a98
8 changed files with 259 additions and 13 deletions

View File

@ -35,6 +35,11 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **Text that is not narratable page furniture stays out of linked articles:** running heads that recur on 3+ pages, lists of page numbers and reference/source lists are no longer pulled in when continuation pages are linked. An article made only of text you already approved becomes approved.
- The per-page fix and the whole-issue run keep `quality_report.json` in step, so the thumbnail badges and the list of pages needing a decision match the layout as it is now. Running heads and adverts are no longer audited for clipped edges or overlaps.
- **Articles nobody has reviewed are blue.** In the article list an unapproved, unrejected article has a blue background and edge and a *not reviewed* pill (approved keeps its green edge), so what still needs a person stands out.
- **Overlapping boxes are fixed automatically.** Where a lower-ranked box runs over a higher-ranked one (headline > deck/byline > body > caption/sidebar/pull quote), the lower one is cut back to the largest part that no longer overlaps, unless that would leave under 40% of it (then both stay for a human). Hand-placed boxes and boxes of equal rank are never touched.
- **Fewer false alarms and better fits:** a strip of solid ink beside a headline (a photo or filled panel behind it) is no longer counted as letters cut off; headline and deck boxes may grow up to 70 px sideways (body boxes 16 px); an inset pull-quote box that repeats text a box already holds becomes a duplicate.
- **Unboxed ink in or beside a picture, chart or advert is no longer reported as missed text** (labels of a chart, text set into a photo, an advert's own wording).
### Fixed
- **Reading order is now applied automatically after import, *OCR again* and every per-page fix**, not only by the manual *Fix reading order* button. Until now the order after import was whatever the vision/LLM step returned, so a column's short last box could be read after the whole other column; on the measured issue 101 of 204 pages had a saved order that differs from the column-aware order. Pages whose order you fixed by hand stay untouched, except after *OCR again*, when every region is new and a hand order can no longer apply.
- **The review list shows each region's whole text**, not the first 220 characters, so every misspelling gets its red wavy underline (like a word processor). The engine's `[Non-Text]` marker and hairline boxes (under 3 pt tall) are now treated as OCR noise by *Clean up boxes* and after a page re-OCR, so they no longer show up as near-invisible regions. Fitted boxes keep a little air on grown sides.

View File

@ -293,6 +293,8 @@ unreviewed boundaries may include a neighbouring article or an advert.
**Whole magazine:** **Auto-fix all pages** (contents view, formerly *Quality check / run*) applies the same fixes to every page and then lists the pages that still need a look (purple badges).
**Errors:** a failed fix shows the reason and changes nothing. **Offline / privacy:** the box fixes and the audit are local; the OCR re-read and spelling steps use only the engines you configured.
**Colours in the article list:** a **blue** row with a *not reviewed* pill is an article nobody has approved or rejected yet; a green edge means approved.
### Less manual work: what happens automatically
At import, and whenever you press **Auto-fix all pages**: boxes are fitted, inset pull quotes found, wrapped paragraphs cut, continuation pages **linked into the article running from the page before**, missed text added, suspicious words re-read and (with a language model) provable misspellings fixed. The report then lists only the pages that need a *decision about their boxes*, with **Start with page N**; pages that only have words to check are collapsed and optional. Articles that gained pages go back to *detected*, so re-approve them (the batch tools in *Select* do many at once). A backup of the layout is kept.

View File

@ -2873,7 +2873,9 @@ _AUDIT_CLIP_INK = 0.10 # ink density in the cell column just outside a
_AUDIT_EDGE_FRACTION = 0.03 # scan borders / spine shadows live here and are never article text
_AUDIT_CLIP_STRIP_PX = 14 # how far outside a box edge to look for ink that belongs to it (~1-2 letters)
_AUDIT_CLIP_MAX_INK = 0.7 # above this the strip is a picture or a filled panel, not text
_AUDIT_GRAPHIC_MARGIN = 10.0 # points: unboxed ink this close to a picture/chart/ad belongs to it
_AUDIT_CLIP_STRIP_PX = 14 # how far outside a box edge to look for ink that belongs to it (~1-2 letters)
def _clipped_edges(pixels, page_meta: dict[str, Any], text_blocks: list[dict[str, Any]],
@ -2900,14 +2902,82 @@ def _clipped_edges(pixels, page_meta: dict[str, Any], text_blocks: list[dict[str
if r1 <= r0 or c1 <= c0:
continue
free = ~covered[r0:r1, c0:c1]
if free.sum() >= 40 and (pixels[r0:r1, c0:c1] & free).sum() / free.sum() >= _AUDIT_CLIP_INK:
if free.sum() < 40:
continue
ink_share = (pixels[r0:r1, c0:c1] & free).sum() / free.sum()
# Text is ink between the lines; a strip that is (nearly) solid ink is a photo or a
# filled panel behind the headline, not letters cut off by the box.
if _AUDIT_CLIP_INK <= ink_share <= _AUDIT_CLIP_MAX_INK:
found.append({"id": block["id"], "side": side})
return found
_OVERLAP_RANK = {BLOCK_TITLE: 0, BLOCK_HEADING: 0, BLOCK_DECK: 1, BLOCK_BYLINE: 1, BLOCK_BODY: 2,
BLOCK_QUOTE: 2, BLOCK_PULLQUOTE: 3, BLOCK_CAPTION: 3, BLOCK_SIDEBAR: 3}
_OVERLAP_MIN_KEEP = 0.4 # never trim a box down to less than this share of its own area
def resolve_box_overlaps(layout: dict[str, Any], pages: Iterable[int] | None = None) -> tuple[dict[str, Any], list[str]]:
"""Trim boxes of lower rank where they run over a higher-ranked one.
A caption or sidebar drawn over a headline, or a headline box over body text, covers the same
printed area twice. The box of lower rank (headline > deck/byline > body > caption/sidebar/
pull quote) is cut back to the largest part that no longer overlaps the higher one -- a
rectangle above, below, left or right of it -- unless that would leave under 40% of it, in
which case both stay for a human (the audit still reports the pair). Boxes of equal rank, hand
placed boxes and anything not text are left alone; only geometry changes. Returns the layout
and the ids of the trimmed boxes.
"""
wanted = set(pages) if pages is not None else None
layout = dict(layout)
blocks = [dict(b) for b in layout.get("blocks", [])]
trimmed: list[str] = []
by_page: dict[int, list[dict[str, Any]]] = {}
for b in blocks:
if b["type"] in _OVERLAP_RANK and (wanted is None or b["page"] in wanted):
by_page.setdefault(b["page"], []).append(b)
for page_blocks in by_page.values():
for i, a in enumerate(page_blocks):
for b in page_blocks[i + 1:]:
ra, rb = _OVERLAP_RANK[a["type"]], _OVERLAP_RANK[b["type"]]
if ra == rb:
continue
low, high = (a, b) if ra > rb else (b, a)
if low.get("box_manual") or low.get("source") == "manual":
continue
if (low["type"] == BLOCK_PULLQUOTE and low.get("source") == "inset_pullquote"
and _text_found_in(low.get("text") or "", high.get("text") or "") >= 0.8
and _bbox_intersection_ratio(low["bbox"], high["bbox"]) >= 0.5):
# the detector found a quote the layout engine had already read as text
low.update(type=BLOCK_FURNITURE, region_label="duplicate", confidence=0.9)
trimmed.append(low["id"])
continue
if max(_bbox_intersection_ratio(low["bbox"], high["bbox"]),
_bbox_intersection_ratio(high["bbox"], low["bbox"])) < _AUDIT_OVERLAP_RATIO:
continue
x, y, w, h = low["bbox"]
hx, hy, hw, hh = high["bbox"]
ix0, iy0 = max(x, hx), max(y, hy)
ix1, iy1 = min(x + w, hx + hw), min(y + h, hy + hh)
if ix1 <= ix0 or iy1 <= iy0:
continue
options = [[x, y, w, iy0 - y], [x, iy1, w, y + h - iy1],
[x, y, ix0 - x, h], [ix1, y, x + w - ix1, h]]
best = max(options, key=lambda o: max(0.0, o[2]) * max(0.0, o[3]))
if best[2] < 6 or best[3] < 6 or best[2] * best[3] < _OVERLAP_MIN_KEEP * w * h:
continue
low["bbox"] = [round(v, 1) for v in best]
low["box_snapped"] = True
if low["id"] not in trimmed:
trimmed.append(low["id"])
layout["blocks"] = blocks
return layout, trimmed
_MIN_TEXT_BOX_HEIGHT = 3.0 # points: a text box thinner than this holds no line of text
_SNAP_TEXT_STEP_PX = 2 # grow/shrink granularity
_SNAP_TEXT_MAX_GROW_PX = 16 # never grow a side sideways further than about a letter or two
_SNAP_TEXT_MAX_GROW_TITLE_PX = 70 # headlines, decks: large type, the last word often sticks out
_SNAP_TEXT_MAX_GROW_DOWN_PX = 90 # a paragraph cut short at the top/bottom can be a few lines
_SNAP_TEXT_LINE_GAP_PX = 14 # blank pixels between two text lines that still count as one block
_SNAP_TEXT_PAD_PX = 2 # air left between the text and its snapped edge
@ -2987,7 +3057,9 @@ def snap_text_boxes(doc_dir: Path, layout: dict[str, Any],
step = _SNAP_TEXT_STEP_PX
gap = _SNAP_TEXT_LINE_GAP_PX
budget = {"left": _SNAP_TEXT_MAX_GROW_PX, "right": _SNAP_TEXT_MAX_GROW_PX,
side = (_SNAP_TEXT_MAX_GROW_TITLE_PX if block["type"] in (BLOCK_TITLE, BLOCK_HEADING, BLOCK_DECK)
else _SNAP_TEXT_MAX_GROW_PX) # display type runs past a box by more
budget = {"left": side, "right": side,
"top": _SNAP_TEXT_MAX_GROW_DOWN_PX, "bottom": _SNAP_TEXT_MAX_GROW_DOWN_PX}
while True: # each side has its own allowance; stop when none can grow
grew = False
@ -3437,6 +3509,8 @@ def audit_page_coverage(doc_dir: Path, layout: dict[str, Any], page_no: int) ->
pixels = page_ink_mask(png)
if pixels is not None:
report["clipped"] = _clipped_edges(pixels, page_meta, text_blocks, boxes)
graphic_boxes = ([i["bbox"] for i in layout.get("images", []) if i["page"] == page_no]
+ [b["bbox"] for b in blocks if b["type"] in VISUAL_TYPES or b["type"] == BLOCK_AD])
er, ec = int(ink.shape[0] * _AUDIT_EDGE_FRACTION), int(ink.shape[1] * _AUDIT_EDGE_FRACTION)
edge = np.zeros_like(ink)
edge[:er, :] = edge[-er:, :] = True
@ -3453,11 +3527,15 @@ def audit_page_coverage(doc_dir: Path, layout: dict[str, Any], page_no: int) ->
rows, cols = np.nonzero((labels == index) & stray)
if len(rows) < _AUDIT_MIN_CELLS:
continue
blob = [round(float(cols.min() * cell_w), 1), round(float(rows.min() * cell_h), 1),
round(float((cols.max() + 1 - cols.min()) * cell_w), 1),
round(float((rows.max() + 1 - rows.min()) * cell_h), 1)]
# Labels of a chart, text set into a photo or an advert's own wording are part of that
# graphic, not narratable text a box forgot: a blob in or beside one is not reported.
if any(bbox_overlaps(blob, g, tol=_AUDIT_GRAPHIC_MARGIN) for g in graphic_boxes):
continue
kept_cells += len(rows)
report["uncovered"].append([
round(float(cols.min() * cell_w), 1), round(float(rows.min() * cell_h), 1),
round(float((cols.max() + 1 - cols.min()) * cell_w), 1),
round(float((rows.max() + 1 - rows.min()) * cell_h), 1)])
report["uncovered"].append(blob)
report["uncovered_ratio"] = round(kept_cells / total, 4)
return report

View File

@ -1108,6 +1108,7 @@ def _fix_page_geometry(d: Path, layout: dict[str, Any], articles: list[dict[str,
layout, quotes = mag.detect_inset_pullquotes(d, layout, [page], url, model, fb_url, fb_model)
layout, wrap = mag.split_around_pullquotes(d, layout, [page])
cleanup["wrap_split"] = wrap
layout, trimmed = mag.resolve_box_overlaps(layout, [page])
layout, snapped = mag.snap_text_boxes(d, layout, pages=[page])
# The column-aware reading order is otherwise only applied by the manual "Fix reading order"
# button; after import or a re-OCR the order would be whatever the vision/LLM step returned.
@ -1125,7 +1126,7 @@ def _fix_page_geometry(d: Path, layout: dict[str, Any], articles: list[dict[str,
owner["images"] = list(dict.fromkeys([*(owner.get("images") or []), *pictures]))
changed = True
counts = {"noise_retyped": noise, "pullquotes_found": len(quotes), "paragraphs_cut": len(wrap),
"pictures_added": len(pictures),
"pictures_added": len(pictures), "overlaps_fixed": len(trimmed),
"boxes_fitted": len(snapped), "bylines_split": len(cleanup.get("byline_split", []))}
return layout, counts, changed
@ -1176,7 +1177,8 @@ def _quality_run_sync(doc_id: str, job_id: str, settings: dict[str, Any]) -> Non
step("fixing boxes")
layout, geometry, moved_articles = _fix_page_geometry(d, layout, articles, page, settings)
articles_changed = articles_changed or moved_articles
fixed_boxes += geometry["boxes_fitted"] + geometry["paragraphs_cut"] + geometry["pullquotes_found"]
fixed_boxes += (geometry["boxes_fitted"] + geometry["paragraphs_cut"] + geometry["pullquotes_found"]
+ geometry["pictures_added"] + geometry["overlaps_fixed"])
step("finding missed text")
layout, added = mag.recover_missing_text(d, layout, page)
if verify:

View File

@ -759,9 +759,12 @@ function renderContents() {
const conf = confidenceLabel(a.confidence);
const done = a.status === 'approved';
const progress = (state.doc.progress || {})[a.id];
const statusBadge = '<span class="mag-pill ' + (done ? 'ok' : '') + '">' +
(done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'needs review') + '</span>';
html += '<li class="mag-toc-row' + (done ? ' is-approved' : '') + '">' +
// A piece nobody has approved or rejected yet is "not reviewed": blue, so the ones that
// still need a person stand out from the approved (green edge) at a glance.
const unreviewed = !done && a.status !== 'rejected';
const statusBadge = '<span class="mag-pill ' + (done ? 'ok' : unreviewed ? 'unreviewed' : '') + '">' +
(done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'not reviewed') + '</span>';
html += '<li class="mag-toc-row' + (done ? ' is-approved' : '') + (unreviewed ? ' is-unreviewed' : '') + '">' +
selectHtml(a) +
'<div class="mag-toc-main">' +
titleHtml(a, 'mag-toc-title') +

View File

@ -4912,6 +4912,11 @@ input[list="lang-codes"] {
padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--surface); margin-bottom: 6px; }
.mag-toc-row.is-approved { border-left: 3px solid var(--green); }
/* Not reviewed yet: blue background and edge (approved keeps the green edge). */
.mag-toc-row.is-unreviewed { border-left: 3px solid #2563eb; background: color-mix(in srgb, #2563eb 10%, var(--surface)); }
.mag-pill.unreviewed { background: rgba(37,99,235,.14); color: #1d4ed8; border-color: transparent; }
:root[data-theme="dark"] .mag-pill.unreviewed { color: #93c5fd; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .mag-pill.unreviewed { color: #93c5fd; } }
.mag-toc-title { font-weight: 600; }
.mag-toc-by { color: var(--subtext); font-size: 13px; margin-left: 8px; }
.mag-toc-meta { display: flex; gap: 10px; align-items: center; color: var(--subtext); font-size: 13px; flex-wrap: wrap; }

View File

@ -238,7 +238,7 @@ test.describe('Magazine reader', () => {
// Non-contiguous pages (a jump-linked continuation) must not read as a range.
await expect(page.locator('.mag-toc-row')).toContainText('pp. 3, 8');
await expect(page.locator('.mag-toc-row')).toContainText('90%');
await expect(page.locator('.mag-toc-row')).toContainText('needs review');
await expect(page.locator('.mag-toc-row')).toContainText('not reviewed');
});
test('review screen shows layout blocks pre-selected and approves', async ({ page }) => {
@ -464,6 +464,34 @@ test.describe('Magazine pronunciation and spelling checks', () => {
});
});
test.describe('Magazine: unreviewed articles', () => {
async function openContents(page, opts) {
await mockBackend(page, opts);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.waitForSelector('.mag-toc-row');
}
test('an article nobody has approved is blue and says "not reviewed"', async ({ page }) => {
await openContents(page, { articleStatus: 'detected' });
const row = page.locator('.mag-toc-row');
await expect(row).toHaveClass(/is-unreviewed/);
await expect(row.locator('.mag-pill.unreviewed')).toHaveText('not reviewed');
const edge = await row.evaluate((el) => getComputedStyle(el).borderLeftColor);
expect(edge).toBe('rgb(37, 99, 235)'); // the blue edge
const bg = await row.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(bg).not.toBe('rgba(0, 0, 0, 0)'); // and a tinted background, not transparent
});
test('an approved article is not blue', async ({ page }) => {
await openContents(page, { articleStatus: 'approved' });
await expect(page.locator('.mag-toc-row')).not.toHaveClass(/is-unreviewed/);
await expect(page.locator('.mag-toc-row')).toHaveClass(/is-approved/);
});
});
test.describe('Magazine: what is wrong on this page', () => {
async function openReview(page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });

View File

@ -3146,3 +3146,126 @@ class UncoveredPictureTests(unittest.TestCase):
layout = dict(self.layout, blocks=[{"id": "f", "page": 1, "type": "furniture", "text": "45", "bbox": [100, 500, 100, 10]},
{"id": "a", "page": 1, "type": "ad", "text": "Kaufen", "bbox": [100, 520, 100, 10]}])
self.assertEqual(audit_page_coverage(self.dir, layout, 1)["clipped"], [])
class UncoveredNearGraphicsTests(unittest.TestCase):
def setUp(self):
from pathlib import Path
from PIL import Image, ImageDraw
self.dir = Path(tempfile.mkdtemp(prefix="ttvc-gfx-"))
(self.dir / "pages").mkdir()
im = Image.new("RGB", (600, 800), (248, 242, 244))
draw = ImageDraw.Draw(im)
for y in range(300, 360, 12): # text-like lines beside a chart
draw.rectangle([320, y, 460, y + 3], fill=(93, 91, 92))
for y in range(600, 660, 12): # the same lines, far from anything
draw.rectangle([100, y, 240, y + 3], fill=(93, 91, 92))
im.save(self.dir / "pages" / "0001.png")
def tearDown(self):
shutil.rmtree(self.dir, ignore_errors=True)
def audit(self, images=(), blocks=()):
from core.magazine import audit_page_coverage
layout = {"pages": [{"page": 1, "width": 600, "height": 800}], "images": list(images), "blocks": list(blocks)}
return audit_page_coverage(self.dir, layout, 1)["uncovered"]
def test_unboxed_ink_far_from_any_graphic_is_reported(self):
self.assertEqual(len(self.audit()), 2)
def test_ink_beside_a_picture_or_inside_an_advert_is_part_of_it_and_not_reported(self):
chart = {"id": "c", "page": 1, "bbox": [200, 280, 110, 100], "filename": "c.png", "reading_order": 0}
ad = {"id": "ad", "page": 1, "type": "ad", "text": "Kaufen", "bbox": [80, 580, 200, 100], "reading_order": 0}
found = self.audit(images=[chart], blocks=[ad])
self.assertEqual(found, [])
class ResolveBoxOverlapTests(unittest.TestCase):
def blk(self, i, ty, bbox, **kw):
return {"id": i, "page": 1, "type": ty, "text": "t " + i, "bbox": bbox, "reading_order": 0, **kw}
def resolve(self, blocks):
from core.magazine import resolve_box_overlaps
out, trimmed = resolve_box_overlaps({"pages": [{"page": 1}], "images": [], "blocks": blocks})
return {b["id"]: b["bbox"] for b in out["blocks"]}, trimmed
def test_a_caption_running_over_a_headline_is_cut_back_below_it(self):
boxes, trimmed = self.resolve([self.blk("t", "title", [10, 10, 200, 40]), self.blk("c", "caption", [10, 30, 200, 120])])
self.assertEqual(trimmed, ["c"])
self.assertEqual(boxes["t"], [10, 10, 200, 40]) # the headline is untouched
self.assertGreaterEqual(boxes["c"][1], 50) # the caption now starts under it
self.assertLessEqual(boxes["c"][1] + boxes["c"][3], 150)
def test_the_lower_ranked_box_is_the_one_that_moves_whichever_is_listed_first(self):
boxes, _ = self.resolve([self.blk("c", "caption", [10, 30, 200, 120]), self.blk("t", "title", [10, 10, 200, 40])])
self.assertEqual(boxes["t"], [10, 10, 200, 40])
def test_equal_rank_hand_placed_and_barely_overlapping_boxes_are_left_alone(self):
a, t = self.resolve([self.blk("a", "body", [10, 10, 100, 50]), self.blk("b", "body", [10, 40, 100, 50])])
self.assertEqual(t, [])
_, t = self.resolve([self.blk("t", "title", [10, 10, 200, 40]), self.blk("c", "caption", [10, 30, 200, 120], box_manual=True)])
self.assertEqual(t, [])
_, t = self.resolve([self.blk("t", "title", [10, 10, 200, 40]), self.blk("c", "caption", [10, 48, 200, 120])])
self.assertEqual(t, []) # a 2 pt graze is not an overlap
def test_a_box_that_would_be_cut_to_almost_nothing_stays_for_a_human(self):
_, trimmed = self.resolve([self.blk("t", "title", [0, 0, 200, 100]), self.blk("c", "caption", [10, 10, 180, 60])])
self.assertEqual(trimmed, [])
class ResolveOverlapDuplicatesAndHeadlineGrowthTests(unittest.TestCase):
def test_an_inset_pullquote_repeating_a_box_that_already_holds_that_text_becomes_a_duplicate(self):
from core.magazine import resolve_box_overlaps
text = "»Mitarbeiter, die ihre Aufgaben einfach an die KI durchreichen, ersetzen sich selbst.«"
layout = {"pages": [{"page": 1}], "images": [], "blocks": [
{"id": "b", "page": 1, "type": "body", "text": text, "bbox": [10, 10, 120, 50], "reading_order": 0},
{"id": "q", "page": 1, "type": "pullquote", "source": "inset_pullquote", "text": text, "bbox": [15, 20, 110, 35], "reading_order": 1}]}
out, fixed = resolve_box_overlaps(layout)
self.assertEqual(fixed, ["q"])
self.assertEqual(next(b for b in out["blocks"] if b["id"] == "q")["type"], "furniture")
self.assertEqual(next(b for b in out["blocks"] if b["id"] == "b")["type"], "body")
def test_a_headline_box_may_grow_further_sideways_than_a_body_box(self):
from pathlib import Path
from PIL import Image, ImageDraw
from core.magazine import snap_text_boxes
d = Path(tempfile.mkdtemp(prefix="ttvc-headgrow-"))
self.addCleanup(shutil.rmtree, d, True)
(d / "pages").mkdir()
im = Image.new("RGB", (600, 800), (248, 242, 244))
draw = ImageDraw.Draw(im)
draw.rectangle([100, 100, 300, 130], fill=(30, 30, 30)) # a headline 200 px wide, thick strokes
im.save(d / "pages" / "0001.png")
def grown(kind):
layout = {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [], "blocks": [
{"id": "a", "page": 1, "type": kind, "text": "t", "bbox": [98, 98, 140, 36], "reading_order": 0}]} # stops 60 px short
out, _ = snap_text_boxes(d, layout)
x, y, w, h = out["blocks"][0]["bbox"]
return x + w
self.assertGreaterEqual(grown("title"), 296) # reaches the end of the headline
self.assertLess(grown("body"), 260) # a body box is capped at ~16 px
class ClippedEdgeIgnoresSolidBackgroundsTests(unittest.TestCase):
def audit(self, fill_solid):
from pathlib import Path
from PIL import Image, ImageDraw
from core.magazine import audit_page_coverage
d = Path(tempfile.mkdtemp(prefix="ttvc-clip-"))
self.addCleanup(shutil.rmtree, d, True)
(d / "pages").mkdir()
im = Image.new("RGB", (600, 800), (248, 242, 244))
draw = ImageDraw.Draw(im)
if fill_solid:
draw.rectangle([250, 90, 330, 140], fill=(30, 40, 90)) # solid panel right of the box
else:
for y in range(100, 130, 8): # text lines running on right of the box
draw.rectangle([250, y, 330, y + 3], fill=(93, 91, 92))
im.save(d / "pages" / "0001.png")
layout = {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [], "blocks": [
{"id": "t", "page": 1, "type": "title", "text": "Headline", "bbox": [100, 95, 150, 40], "reading_order": 0}]}
return audit_page_coverage(d, layout, 1)["clipped"]
def test_text_running_past_the_edge_is_clipping_but_a_solid_panel_is_not(self):
self.assertEqual([c["side"] for c in self.audit(False)], ["right"])
self.assertEqual(self.audit(True), [])