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:
parent
c62cc6953d
commit
3d5da78a98
@ -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.
|
- **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.
|
- 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
|
### 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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@ -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).
|
**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.
|
**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
|
### 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.
|
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.
|
||||||
|
|||||||
@ -2873,6 +2873,8 @@ _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_EDGE_FRACTION = 0.03 # scan borders / spine shadows live here and are never article text
|
||||||
|
|
||||||
|
|
||||||
|
_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)
|
_AUDIT_CLIP_STRIP_PX = 14 # how far outside a box edge to look for ink that belongs to it (~1-2 letters)
|
||||||
|
|
||||||
|
|
||||||
@ -2900,14 +2902,82 @@ def _clipped_edges(pixels, page_meta: dict[str, Any], text_blocks: list[dict[str
|
|||||||
if r1 <= r0 or c1 <= c0:
|
if r1 <= r0 or c1 <= c0:
|
||||||
continue
|
continue
|
||||||
free = ~covered[r0:r1, c0:c1]
|
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})
|
found.append({"id": block["id"], "side": side})
|
||||||
return found
|
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
|
_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_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_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_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_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
|
_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
|
step = _SNAP_TEXT_STEP_PX
|
||||||
gap = _SNAP_TEXT_LINE_GAP_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}
|
"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
|
while True: # each side has its own allowance; stop when none can grow
|
||||||
grew = False
|
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)
|
pixels = page_ink_mask(png)
|
||||||
if pixels is not None:
|
if pixels is not None:
|
||||||
report["clipped"] = _clipped_edges(pixels, page_meta, text_blocks, boxes)
|
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)
|
er, ec = int(ink.shape[0] * _AUDIT_EDGE_FRACTION), int(ink.shape[1] * _AUDIT_EDGE_FRACTION)
|
||||||
edge = np.zeros_like(ink)
|
edge = np.zeros_like(ink)
|
||||||
edge[:er, :] = edge[-er:, :] = True
|
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)
|
rows, cols = np.nonzero((labels == index) & stray)
|
||||||
if len(rows) < _AUDIT_MIN_CELLS:
|
if len(rows) < _AUDIT_MIN_CELLS:
|
||||||
continue
|
continue
|
||||||
kept_cells += len(rows)
|
blob = [round(float(cols.min() * cell_w), 1), round(float(rows.min() * cell_h), 1),
|
||||||
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((cols.max() + 1 - cols.min()) * cell_w), 1),
|
||||||
round(float((rows.max() + 1 - rows.min()) * cell_h), 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(blob)
|
||||||
report["uncovered_ratio"] = round(kept_cells / total, 4)
|
report["uncovered_ratio"] = round(kept_cells / total, 4)
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
|||||||
@ -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, quotes = mag.detect_inset_pullquotes(d, layout, [page], url, model, fb_url, fb_model)
|
||||||
layout, wrap = mag.split_around_pullquotes(d, layout, [page])
|
layout, wrap = mag.split_around_pullquotes(d, layout, [page])
|
||||||
cleanup["wrap_split"] = wrap
|
cleanup["wrap_split"] = wrap
|
||||||
|
layout, trimmed = mag.resolve_box_overlaps(layout, [page])
|
||||||
layout, snapped = mag.snap_text_boxes(d, layout, pages=[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"
|
# 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.
|
# 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]))
|
owner["images"] = list(dict.fromkeys([*(owner.get("images") or []), *pictures]))
|
||||||
changed = True
|
changed = True
|
||||||
counts = {"noise_retyped": noise, "pullquotes_found": len(quotes), "paragraphs_cut": len(wrap),
|
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", []))}
|
"boxes_fitted": len(snapped), "bylines_split": len(cleanup.get("byline_split", []))}
|
||||||
return layout, counts, changed
|
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")
|
step("fixing boxes")
|
||||||
layout, geometry, moved_articles = _fix_page_geometry(d, layout, articles, page, settings)
|
layout, geometry, moved_articles = _fix_page_geometry(d, layout, articles, page, settings)
|
||||||
articles_changed = articles_changed or moved_articles
|
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")
|
step("finding missed text")
|
||||||
layout, added = mag.recover_missing_text(d, layout, page)
|
layout, added = mag.recover_missing_text(d, layout, page)
|
||||||
if verify:
|
if verify:
|
||||||
|
|||||||
@ -759,9 +759,12 @@ function renderContents() {
|
|||||||
const conf = confidenceLabel(a.confidence);
|
const conf = confidenceLabel(a.confidence);
|
||||||
const done = a.status === 'approved';
|
const done = a.status === 'approved';
|
||||||
const progress = (state.doc.progress || {})[a.id];
|
const progress = (state.doc.progress || {})[a.id];
|
||||||
const statusBadge = '<span class="mag-pill ' + (done ? 'ok' : '') + '">' +
|
// A piece nobody has approved or rejected yet is "not reviewed": blue, so the ones that
|
||||||
(done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'needs review') + '</span>';
|
// still need a person stand out from the approved (green edge) at a glance.
|
||||||
html += '<li class="mag-toc-row' + (done ? ' is-approved' : '') + '">' +
|
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) +
|
selectHtml(a) +
|
||||||
'<div class="mag-toc-main">' +
|
'<div class="mag-toc-main">' +
|
||||||
titleHtml(a, 'mag-toc-title') +
|
titleHtml(a, 'mag-toc-title') +
|
||||||
|
|||||||
@ -4912,6 +4912,11 @@ input[list="lang-codes"] {
|
|||||||
padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius);
|
padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
background: var(--surface); margin-bottom: 6px; }
|
background: var(--surface); margin-bottom: 6px; }
|
||||||
.mag-toc-row.is-approved { border-left: 3px solid var(--green); }
|
.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-title { font-weight: 600; }
|
||||||
.mag-toc-by { color: var(--subtext); font-size: 13px; margin-left: 8px; }
|
.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; }
|
.mag-toc-meta { display: flex; gap: 10px; align-items: center; color: var(--subtext); font-size: 13px; flex-wrap: wrap; }
|
||||||
|
|||||||
@ -238,7 +238,7 @@ test.describe('Magazine reader', () => {
|
|||||||
// Non-contiguous pages (a jump-linked continuation) must not read as a range.
|
// 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('pp. 3, 8');
|
||||||
await expect(page.locator('.mag-toc-row')).toContainText('90%');
|
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 }) => {
|
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', () => {
|
test.describe('Magazine: what is wrong on this page', () => {
|
||||||
async function openReview(page) {
|
async function openReview(page) {
|
||||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
|||||||
@ -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]},
|
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]}])
|
{"id": "a", "page": 1, "type": "ad", "text": "Kaufen", "bbox": [100, 520, 100, 10]}])
|
||||||
self.assertEqual(audit_page_coverage(self.dir, layout, 1)["clipped"], [])
|
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), [])
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user