diff --git a/CHANGELOG.md b/CHANGELOG.md index fd19dae..c95d03b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Manual.md b/Manual.md index 0a46e35..0cc181c 100644 --- a/Manual.md +++ b/Manual.md @@ -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. diff --git a/core/magazine.py b/core/magazine.py index c42b8ce..6fde51e 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -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 diff --git a/routes/magazine.py b/routes/magazine.py index 25c22d4..8d26465 100644 --- a/routes/magazine.py +++ b/routes/magazine.py @@ -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: diff --git a/static/js/magazine.js b/static/js/magazine.js index 592e97d..33b9ae0 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -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 = '' + - (done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'needs review') + ''; - html += '
  • ' + + // 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 = '' + + (done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'not reviewed') + ''; + html += '
  • ' + selectHtml(a) + '
    ' + titleHtml(a, 'mag-toc-title') + diff --git a/static/style.css b/static/style.css index 34cd6f3..7d0d590 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } diff --git a/tests/magazine.spec.js b/tests/magazine.spec.js index e47ed18..0d464d1 100644 --- a/tests/magazine.spec.js +++ b/tests/magazine.spec.js @@ -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' }); diff --git a/tests/test_magazine.py b/tests/test_magazine.py index f23397a..5e61a8e 100644 --- a/tests/test_magazine.py +++ b/tests/test_magazine.py @@ -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), [])