feat(magazine): column reading order, region type picker and delete per row, fix reading order

Columns are found from where column-width text starts, not from the whitespace
between them (loose OCR boxes shrank one gutter to 4-6 pt and a pull-quote
bridged it, collapsing page 38 into one interleaved column). Headlines get
their own band, pull-quotes are read at the end of the page. Measured over a
204-page issue: 89 sentence continuations kept vs 85.

Each review row gets a type picker (text kinds, page number, source, ad,
image) and its own delete button; text<->image conversions keep the region
in its article. Fix reading order re-reads existing issues without OCR and
leaves hand-ordered pages alone. The shared confirm dialog is now an
alertdialog for screen readers. v1.35.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-21 11:38:33 +02:00
parent 981341de3f
commit fd1de80f83
14 changed files with 763 additions and 58 deletions

View File

@ -7,7 +7,11 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
## [1.35.0] — 2026-09-21
### Added ### Added
- **Magazine review: change a region's type or delete it from the list.** Every row on the review page has a type picker (paragraph, headline, subheading, standfirst, author, caption, pull quote, sidenote, page number, source/footer, advertisement, image) and its own delete button with a confirmation. Turning text into an image cuts the box out as a figure; turning an image into text reads it with OCR; the region stays in the article being reviewed.
- **Magazine: "Fix reading order"** re-reads every page of an already imported issue column by column, without OCR, keeping text, boxes and approvals (and any page ordered by hand).
- **Character images are checked and regenerated automatically.** Each profile picture and character sheet is inspected after it is rendered; a portrait or sheet that fails (wrong gender, three front views, cut-off feet, elf ears on a human, wounds, scenery behind a studio shot, a different face from the profile picture) is rendered again with a new seed, up to three times, and the attempt with the fewest faults is kept. The character page shows the outcome: checked with no faults, faults still found (listed), or not checked because the checker was unavailable — in which case it stops retrying rather than re-rendering blindly. - **Character images are checked and regenerated automatically.** Each profile picture and character sheet is inspected after it is rendered; a portrait or sheet that fails (wrong gender, three front views, cut-off feet, elf ears on a human, wounds, scenery behind a studio shot, a different face from the profile picture) is rendered again with a new seed, up to three times, and the attempt with the fewest faults is kept. The character page shows the outcome: checked with no faults, faults still found (listed), or not checked because the checker was unavailable — in which case it stops retrying rather than re-rendering blindly.
- **The character sheet now shows the profile picture's face.** The turnaround and the story moment take their face from the profile portrait (face transfer after rendering), so the portrait and the sheet show the same person; in the story moment only the main character's face is replaced, not the extras'. - **The character sheet now shows the profile picture's face.** The turnaround and the story moment take their face from the profile portrait (face transfer after rendering), so the portrait and the sheet show the same person; in the story moment only the main character's face is replaced, not the extras'.
- **Automatic check for generated character images** (`POST /api/character-image-check`). A vision model reports what it actually sees in a portrait, turnaround or scene — figure count, which way each figure faces, whether heads and feet are in frame, the apparent gender, pointed or round ears, wounds, plain or scenery background, and whether two images show the same person — and the app compares that with the character sheet. An unavailable checker or an unreadable answer counts as a failure, never a pass. It uses the LLM that is already loaded (no model swapping); calibrated at 14/14 on panels with known defects. - **Automatic check for generated character images** (`POST /api/character-image-check`). A vision model reports what it actually sees in a portrait, turnaround or scene — figure count, which way each figure faces, whether heads and feet are in frame, the apparent gender, pointed or round ears, wounds, plain or scenery background, and whether two images show the same person — and the app compares that with the character sheet. An unavailable checker or an unreadable answer counts as a failure, never a pass. It uses the LLM that is already loaded (no model swapping); calibrated at 14/14 on panels with known defects.
@ -19,6 +23,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **Book setting reworded to Dark Eye high-fantasy medieval** (was "early Renaissance 1420-1500", which produced musketeer/wartime looks), soot/dirt removed like injuries, and prop tiles are object-only (they used to show a person). Existing image pairs are unchanged until regenerated. - **Book setting reworded to Dark Eye high-fantasy medieval** (was "early Renaissance 1420-1500", which produced musketeer/wartime looks), soot/dirt removed like injuries, and prop tiles are object-only (they used to show a person). Existing image pairs are unchanged until regenerated.
### Fixed ### Fixed
- **Magazine pages with two columns were read interleaved** (half a sentence from each column in turn). Columns are now found from where column-width text starts rather than from the whitespace between them, which loose OCR boxes and a pull-quote across the gutter had defeated; headlines and standfirsts are read before the columns under them, a caption beside a photo above the columns before them, and short pull-quotes at the end of the page instead of mid-sentence. Measured over a 204-page issue: 89 sentence continuations kept intact versus 85 before. Existing issues need *Fix reading order* once.
- **Confirmation dialogs are announced to screen readers** (role alertdialog with title and message), app-wide.
- The magazine review list's type column no longer overlaps the text on narrow screens, and the type and delete controls stay available on phones.
- **Non-elves no longer get pointed ears.** The species test read the raw sheet field, so a hedged value such as "Mensch (oder Halbelfe, je nach Kontext, hier nicht spezifiziert)" matched "Halbelfe" and gave a human character elf ears — and that whole sentence went into the prompt as her species. Speculative sentences are now dropped from the species before either use, with more hedge wording recognised ("nicht spezifiziert", "je nach Kontext", "not specified", "depending on"). - **Non-elves no longer get pointed ears.** The species test read the raw sheet field, so a hedged value such as "Mensch (oder Halbelfe, je nach Kontext, hier nicht spezifiziert)" matched "Halbelfe" and gave a human character elf ears — and that whole sentence went into the prompt as her species. Speculative sentences are now dropped from the species before either use, with more hedge wording recognised ("nicht spezifiziert", "je nach Kontext", "not specified", "depending on").
- **The story-moment panel keeps the character's gender, age and species.** It restates them ("one adult woman named Karyla") instead of relying on the subject clause, after a female character rendered as a man among male extras. - **The story-moment panel keeps the character's gender, age and species.** It restates them ("one adult woman named Karyla") instead of relying on the subject clause, after a female character rendered as a man among male extras.
- **An established scar renders as healed** — an old, pale, closed line — rather than as a fresh red wound. Scars are permanent and deliberately survive the injury filter, but the model drew them bleeding. - **An established scar renders as healed** — an old, pale, closed line — rather than as a fresh red wound. Scars are permanent and deliberately survive the injury filter, but the model drew them bleeding.

View File

@ -133,7 +133,21 @@ turned into a podcast discussion between two or three voices you pick.
area again and replaces the text with that fresh reading (useful when the area again and replaces the text with that fresh reading (useful when the
box was too small or too large to read cleanly the first time, or after box was too small or too large to read cleanly the first time, or after
moving a shared boundary). Deleting a region removes it from the page moving a shared boundary). Deleting a region removes it from the page
entirely, not just from the article currently under review. Use entirely, not just from the article currently under review. Every row in
the list on the right also has its own **type picker** and **delete
button** (the bin icon), so you can fix a region without switching into
Edit boxes: change a paragraph into a *Headline*, a *Page number*, an
*Advertisement* or any other kind, or turn a text box into an *Image*
(the box is cut out as a figure) and back (the box is read with OCR).
Delete asks for confirmation first. The page's reading order is updated
after a type change, because a page number or advert is read differently
from running text. **"Fix reading order"** re-reads the order of every
page in the issue column by column — left column top to bottom, then the
next column — for issues imported before the column detection improved.
It changes only the order: text, boxes and approvals stay as they are,
although an approved article may now be read in a different (correct)
order. Short pull-quotes are read at the end of the page instead of in
the middle of a sentence. Use
**"Two-page spread"** to see this page next to its facing page (e.g. 22 **"Two-page spread"** to see this page next to its facing page (e.g. 22
and 23), each with its own OCR-results list directly beneath it — useful and 23), each with its own OCR-results list directly beneath it — useful
for lining up a figure or a jump that crosses the gutter. Only one page for lining up a figure or a jump that crosses the gutter. Only one page

View File

@ -69,7 +69,7 @@ not part of this order.
read, or type a number into each one. The order drives playback and the OCR-results list. read, or type a number into each one. The order drives playback and the OCR-results list.
Today the order is whatever the layout analysis produced, and a page with two text columns Today the order is whatever the layout analysis produced, and a page with two text columns
comes out interleaved, so a listener hears half a sentence from the wrong column. comes out interleaved, so a listener hears half a sentence from the wrong column.
- [ ] **L — Magazine: overhaul region boxes and how they are handled in OCR and approval.** - [ ] **L — Magazine: overhaul region boxes and how they are handled in OCR and approval.** (Done so far: type picker and delete button per row, 2026-09-21.)
The boxes the layout analysis produces need a pass of their own, on top of the ordering work The boxes the layout analysis produces need a pass of their own, on top of the ordering work
above. Seen on real pages: a byline box that runs on into the first body line ("Von Hartmut above. Seen on real pages: a byline box that runs on into the first body line ("Von Hartmut
Gieselmann Ein guter Butler kennt die Vorlieben seines"), so author and paragraph both come Gieselmann Ein guter Butler kennt die Vorlieben seines"), so author and paragraph both come
@ -81,7 +81,7 @@ not part of this order.
degenerate slivers, and make the approval screen quick to correct — keyboard moves between degenerate slivers, and make the approval screen quick to correct — keyboard moves between
regions, merge/split in place, and a retype/re-OCR of one region without leaving the page. regions, merge/split in place, and a retype/re-OCR of one region without leaving the page.
- [ ] **M — Magazine: read text columns top-to-bottom, then left-to-right.** Group detected text - [x] **M — Magazine: read text columns top-to-bottom, then left-to-right.** Done 2026-09-21 (column anchors from left edges, bands, pull-quotes last; 85→89 continuations on a 204-page issue; *Fix reading order* for existing issues). Original note: Group detected text
regions into columns first, order each column downwards, then take the columns left to right regions into columns first, order each column downwards, then take the columns left to right
(mirroring for RTL). Confirmed wrong on page 38 of issue 204: the left column's paragraphs and (mirroring for RTL). Confirmed wrong on page 38 of issue 204: the left column's paragraphs and
the right column's are mixed into one sequence. Should land before the manual ordering above, the right column's are mixed into one sequence. Should land before the manual ordering above,

View File

@ -1 +1 @@
1.34.1 1.35.0

View File

@ -268,6 +268,47 @@ the fix). This is a geometry heuristic, not a certainty: review still shows
every sidebar block (unticked by default) and it can be included with one every sidebar block (unticked by default) and it can be included with one
click — nothing is silently discarded. click — nothing is silently discarded.
## Reading order (2026-09-21)
`assign_reading_order` reads a page the way a person does:
1. **Columns come from where column-width blocks start** (`column_anchors`: clustered left edges),
not from whitespace between columns. The whitespace signal failed on real scanned pages: OCR boxes
are loose, so a gutter shrank to 4-6 pt (page 38 of issue 204), and a pull-quote laid across the
gutter bridged the columns outright -- every block came back as column 0 and the two columns were
read interleaved. The column width is measured on body text only (a page-wide photo pushed it to
317 pt against real 210 pt columns), and columns never overlap.
2. **Bands.** Text across two or more columns *in a strip of its own* (a headline, a standfirst) is
read before what lies under it. A box lying over the column boxes (a duplicate region) does not
count. A photo with no running text beside it also separates the text above from the text below
(page 176: a caption beside a screenshot above the columns). Paragraph gaps are **never** used as
breaks: in neighbouring columns they often line up, and treating them as breaks interleaves the
columns again (tried, measured, reverted).
3. Short pull-quotes (≤ 260 chars, wrapped in quotation marks, or typed pullquote and short) are read
after the page's running text; types alone are not trusted (on page 38 the real pull-quote was
typed body, and a body paragraph was typed pullquote). Furniture last.
**Measured** over all 204 pages of a real issue with a text-only score -- a block that ends
mid-sentence must be followed by one that continues it in lower case, a split word ("hun-" /
"dertmal") by its other half: confirmed continuations 85 (old) → 89 (new). The two pages that score
lower both carry duplicate boxes stacked over the same text, where no order is right until the boxes
are fixed. The old detector had also counted full-page adverts and photo columns as text columns.
**Existing issues** get the new order with *Fix reading order* (`POST .../reading-order`): order
only, no OCR; text, boxes, types, articles and approvals unchanged. A page with `order_locked` (set
when the reader orders a page by hand) is always left alone -- by this action, by a type change and by
any other recompute.
## Region types from the review list
Every region in the review list has a type picker and a delete button. `PATCH .../regions/{id}` takes
`type` (`REGION_TYPES`: body, title, heading, deck, byline, caption, pullquote, sidebar, page_number,
source, ad, image). Between text kinds only the type changes. Text → image crops the box into a figure;
image → text reads the box with OCR (placeholder if nothing is recognised). Crossing between text and
image gives the region a new id, and the route moves its **article membership** over (figures and text
are listed separately on an article), so a converted figure stays in the article being reviewed. The
page's order is recomputed afterwards (unless hand-ordered), since the type decides where a region is read.
## Tests ## Tests
- [`tests/test_magazine.py`](../tests/test_magazine.py) — 100 tests on the pure - [`tests/test_magazine.py`](../tests/test_magazine.py) — 100 tests on the pure

View File

@ -184,49 +184,164 @@ def detect_columns(blocks: Sequence[Block], page_width: float,
return [(m[0], m[1]) for m in merged] return [(m[0], m[1]) for m in merged]
def assign_reading_order(blocks: list[Block], page_width: float) -> list[Block]: # Blocks that are not running text: they never define a column or split the page.
"""Order blocks the way a person reads the page: column by column. _NOT_TEXT_FLOW = (BLOCK_FURNITURE, BLOCK_AD, BLOCK_FIGURE, BLOCK_CHART, BLOCK_TABLE, BLOCK_CODE)
Sorting purely by y interleaves parallel columns, which is precisely the _QUOTE_PAIRS = (("»", "«"), ("„", "“"), ("“", "”"), ("«", "»"), ('"', '"'), ("‚", "‘"))
failure that splices one article's sentence into another's.
Column BOUNDARIES are detected from blocks close to the page's typical
width only. Confirmed live on a real scanned magazine: a caption running def is_pullquote_like(block: Block) -> bool:
under a photo, at 49.9% of the page width -- under the "spans the whole """A short display quote that repeats the article: read after the page's running text.
page" threshold below, so not given special handling -- sat squarely in
the whitespace gap between two real text columns and bridged them into Types are not trusted on their own: on a real scanned page the layout model typed the actual
one merged span, which meant EVERY block on the page got column=0 and pull-quote ("»Wenn man hundertmal die KI nutzt…«") as body text, and typed a left-column body
reading order fell back to pure top-to-bottom, splicing the two paragraph as a pull-quote. A pull-quote is short and wrapped in quotation marks; a long block
columns' sentences together (the exact failure this function exists to is running text whatever it was labelled.
prevent). Every block, wide ones included, is still assigned to
whichever column its centre or overlap lands in once boundaries are
known -- only the boundary DETECTION excludes them.
""" """
widths = sorted(b.bbox[2] for b in blocks if b.bbox[2] > 1) text = " ".join((block.text or "").split())
median_w = widths[len(widths) // 2] if widths else 0 if not text or len(text) > 260:
narrow = [b for b in blocks if median_w <= 0 or b.bbox[2] <= median_w * 1.4] return False
columns = detect_columns(narrow if len(narrow) >= 2 else blocks, page_width) if block.type == BLOCK_PULLQUOTE:
return True
return any(text.startswith(open_) and text.rstrip(".!? ").endswith(close) for open_, close in _QUOTE_PAIRS)
def column_anchors(blocks: Sequence[Block], page_width: float) -> tuple[list[float], float]:
"""Where the text columns begin, and the typical column width.
Found from the LEFT EDGES of column-width blocks, which cluster tightly, rather than from
whitespace between columns. The whitespace signal failed on a real scanned c't page: the OCR
boxes were loose enough that the gutter shrank to 4-6 points (under any sane threshold), and a
pull-quote laid across the gutter bridged the columns outright -- every block came back as
column 0 and the two columns were read interleaved. Left edges were unaffected: 25-29 and
236-240. Columns must be most of a column width apart (the heavier of two close candidates
wins), so a narrow stray box starting mid-column does not invent a column of its own. The
weight floor is deliberately low: on page 193 the left column is three paragraphs under a
screenshot, 19% of the right column's text, and a 25% floor dropped it and interleaved the page.
Measured over a whole 204-page issue, 5-15% all score the same.
"""
flow = [b for b in blocks if b.bbox[2] > 1 and b.type not in _NOT_TEXT_FLOW]
if not flow:
return [0.0], page_width
# The column width comes from running text only. Measured over every block, a page-wide photo
# and the headline pushed it to 317 points against real columns of ~210 (page 88), which made
# a plain right-column paragraph look as if it spanned two columns.
body = [b for b in flow if b.type == BLOCK_BODY] or flow
widths = sorted(b.bbox[2] for b in body)
col_w = widths[len(widths) // 2]
candidates = sorted((b for b in flow if 0.55 * col_w <= b.bbox[2] <= 1.4 * col_w), key=lambda b: b.bbox[0])
tol = page_width * 0.04
clusters: list[list[Block]] = []
for b in candidates:
if clusters and b.bbox[0] - clusters[-1][-1].bbox[0] <= tol:
clusters[-1].append(b)
else:
clusters.append([b])
if not clusters:
return [min(b.bbox[0] for b in flow)], col_w
weighted = [(min(b.bbox[0] for b in c), sum(b.bbox[3] for b in c)) for c in clusters]
heaviest = max(w for _, w in weighted)
anchors: list[tuple[float, float]] = []
for x, weight in weighted:
if weight < 0.10 * heaviest:
continue
if anchors and x - anchors[-1][0] < 0.6 * col_w:
if weight > anchors[-1][1]:
anchors[-1] = (x, weight)
continue
anchors.append((x, weight))
return [x for x, _ in anchors] or [weighted[0][0]], col_w
def assign_reading_order(blocks: list[Block], page_width: float) -> list[Block]:
"""Order blocks the way a person reads a magazine page.
1. Text spanning two or more columns (headline, standfirst) is read in turn from the top,
and splits the page into bands: everything under it comes after it.
2. Within a band, each column top to bottom, columns left to right. Sorting purely by y
interleaves parallel columns and splices one column's sentence into the next.
3. Short pull-quotes after the page's running text (they repeat the article, so reading
one mid-column interrupts a sentence), then furniture (running heads, folios).
Captions never split the page into bands: a caption under a photo in the middle of a page
would otherwise tear both columns in half.
"""
anchors, col_w = column_anchors(blocks, page_width)
# Columns never overlap: each ends where the next begins.
spans = [(a, min(a + col_w, anchors[i + 1]) if i + 1 < len(anchors) else a + col_w)
for i, a in enumerate(anchors)]
def overlap(b: Block, span: tuple[float, float]) -> float:
return max(0.0, min(b.bbox[0] + b.bbox[2], span[1]) - max(b.bbox[0], span[0]))
def column_of(b: Block) -> int: def column_of(b: Block) -> int:
centre = b.bbox[0] + b.bbox[2] / 2 best = max(range(len(spans)), key=lambda i: (overlap(b, spans[i]), -i))
for i, (left, right) in enumerate(columns): if overlap(b, spans[best]) > 0:
if left <= centre <= right: return best
return i # Outside every column (a margin note): the nearest column by left edge.
# Spanning blocks (a headline across all columns) belong to the first return min(range(len(anchors)), key=lambda i: abs(b.bbox[0] - anchors[i]))
# column they touch, so they precede the body they introduce.
for i, (left, right) in enumerate(columns): def crosses_columns(b: Block) -> bool:
if bbox_overlaps(b.bbox, [left, b.bbox[1], right - left, b.bbox[3]]): if len(spans) < 2:
return i return b.bbox[2] > page_width * 0.6
return len(columns) return sum(1 for s in spans if overlap(b, s) >= 0.3 * col_w) >= 2
def own_strip(b: Block) -> bool:
"""A real headline sits in a horizontal strip of its own. A wide box lying over the column
boxes (a duplicate region covering the same text, seen on pages 78 and 154) does not."""
top, bottom = b.bbox[1], b.bbox[1] + b.bbox[3]
for other in blocks:
if other is b or other.type in _NOT_TEXT_FLOW or crosses_columns(other):
continue
shared = min(bottom, other.bbox[1] + other.bbox[3]) - max(top, other.bbox[1])
if shared > 0.3 * max(1.0, min(b.bbox[3], other.bbox[3])):
return False
return True
def splits_page(b: Block) -> bool:
"""Text across two or more columns, in a strip of its own: read before what lies under it.
Captions never split the page -- one in the middle would tear both columns."""
if b.type in _NOT_TEXT_FLOW or b.type in (BLOCK_CAPTION, BLOCK_PULLQUOTE) or is_pullquote_like(b):
return False
return crosses_columns(b) and own_strip(b)
def photo_splits_page(b: Block) -> bool:
"""A photo with no running text beside it separates the text above from the text below.
Page 176: a caption beside a screenshot at the top, the two columns under it. Without this
the caption was read between the columns and split "…zumal sie auch | helfen könnte…". A
photo inset beside column text (page 164) does not split anything. Paragraph gaps are never
used for this: in neighbouring columns they often line up, and treating them as breaks
interleaves the columns again.
"""
if b.type not in _NOT_TEXT_FLOW or b.type in (BLOCK_FURNITURE, BLOCK_AD):
return False
top, bottom = b.bbox[1], b.bbox[1] + b.bbox[3]
for other in blocks:
if other.type in _NOT_TEXT_FLOW or is_pullquote_like(other):
continue
if min(bottom, other.bbox[1] + other.bbox[3]) - max(top, other.bbox[1]) > 2.0:
return False
return True
for b in blocks: for b in blocks:
b.column = column_of(b) b.column = column_of(b)
# A full-width headline must not be dragged to the bottom of column 0, so breakers = {id(b) for b in blocks if splits_page(b) or photo_splits_page(b)}
# blocks spanning most of the page sort by y before anything below them. band_tops = sorted(b.bbox[1] for b in blocks if id(b) in breakers)
def key(b: Block) -> tuple[float, int, float]: spanning = lambda b: id(b) in breakers # noqa: E731
spans_page = b.bbox[2] > page_width * 0.6
return (0 if spans_page else 1, b.column, round(b.bbox[1], 1)) def key(b: Block) -> tuple:
if b.type == BLOCK_FURNITURE:
return (3, 0, 0, round(b.bbox[1], 1), b.bbox[0])
if is_pullquote_like(b):
return (2, 0, b.column, round(b.bbox[1], 1), b.bbox[0])
# The band is the number of breakers starting at or above this block: a breaker opens its
# own band, so it follows the columns above it and precedes those below.
band = sum(1 for top in band_tops if top <= b.bbox[1] + 0.5)
if spanning(b):
return (1, band, 0, round(b.bbox[1], 1), b.bbox[0])
return (1, band, 1 + b.column, round(b.bbox[1], 1), b.bbox[0])
ordered = sorted(blocks, key=key) ordered = sorted(blocks, key=key)
for i, b in enumerate(ordered): for i, b in enumerate(ordered):
@ -2601,6 +2716,108 @@ def update_region(doc_dir: Path, layout: dict[str, Any], page_no: int, block_id:
return layout, None return layout, None
# What a region can be changed into from the review screen: text kinds map to a block type (and,
# for furniture, which kind of furniture); "image" turns the region into a figure crop.
REGION_TYPES: dict[str, tuple[str, str]] = {
"body": (BLOCK_BODY, ""),
"title": (BLOCK_TITLE, ""),
"heading": (BLOCK_HEADING, ""),
"deck": (BLOCK_DECK, ""),
"byline": (BLOCK_BYLINE, ""),
"caption": (BLOCK_CAPTION, ""),
"pullquote": (BLOCK_PULLQUOTE, ""),
"sidebar": (BLOCK_SIDEBAR, ""),
"page_number": (BLOCK_FURNITURE, "page_number"),
"source": (BLOCK_FURNITURE, "source"),
"ad": (BLOCK_AD, ""),
"image": ("", ""),
}
def change_region_type(doc_dir: Path, layout: dict[str, Any], page_no: int, region_id: str,
new_type: str,
vision_ocr_url: str | None = None,
vision_ocr_model: str | None = None,
vision_ocr_fallback_url: str | None = None,
vision_ocr_fallback_model: str | None = None,
timeout: float = 150.0) -> tuple[dict[str, Any], str]:
"""Change what a detected region is. Returns the new layout and the region's id afterwards.
Between text kinds only the type changes; the text and the box stay. Text to image crops the
box into a figure and drops the text block; image to text reads the box with OCR (a placeholder
when nothing is recognised, as for a hand-drawn region) and drops the figure. The id changes
when a region crosses between text and image, so the caller can move article membership over.
The page's reading order is recomputed, since a type decides where a region is read.
"""
if new_type not in REGION_TYPES:
raise ValueError(f"Unknown region type: {new_type}")
page_meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None)
if not page_meta:
raise ValueError("Unknown page")
block = next((b for b in layout.get("blocks", []) if b["id"] == region_id and b["page"] == page_no), None)
image = next((im for im in layout.get("images", []) if im["id"] == region_id and im["page"] == page_no), None)
if block is None and image is None:
raise ValueError("Region not found on this page")
if new_type == "image":
if image is not None:
return layout, region_id
before = {im["id"] for im in layout.get("images", [])}
layout = add_manual_region(doc_dir, layout, page_no, block["bbox"], "image")
new_id = next(im["id"] for im in layout["images"] if im["id"] not in before)
return _reorder_page(delete_region(layout, region_id), page_no), new_id
block_type, region_label = REGION_TYPES[new_type]
if block is not None:
layout = dict(layout)
layout["blocks"] = [dict(b, type=block_type, region_label=region_label, confidence=1.0)
if b["id"] == region_id else b for b in layout.get("blocks", [])]
return _reorder_page(layout, page_no), region_id
# Image to text: read the box, then give the new block the chosen type.
before = {b["id"] for b in layout.get("blocks", [])}
layout = add_manual_region(doc_dir, delete_region(layout, region_id), page_no, image["bbox"], "text", "",
vision_ocr_url, vision_ocr_model,
vision_ocr_fallback_url, vision_ocr_fallback_model, timeout)
new_id = next(b["id"] for b in layout["blocks"] if b["id"] not in before)
layout["blocks"] = [dict(b, type=block_type, region_label=region_label) if b["id"] == new_id else b
for b in layout["blocks"]]
return _reorder_page(layout, page_no), new_id
def recompute_reading_order(layout: dict[str, Any]) -> tuple[dict[str, Any], list[int]]:
"""Re-derive the reading order of every page in an issue, without any OCR.
For an issue ingested before the column detection improved. Only the order changes: text,
boxes, types, articles and approvals stay as they are, and a page whose order the reader set by
hand is left alone. Returns the layout and the pages whose order actually changed.
"""
changed: list[int] = []
for page_meta in layout.get("pages", []):
page_no = page_meta["page"]
before = [b["id"] for b in sorted((b for b in layout.get("blocks", []) if b["page"] == page_no),
key=lambda b: b.get("reading_order", 0))]
layout = _reorder_page(layout, page_no)
after = [b["id"] for b in sorted((b for b in layout.get("blocks", []) if b["page"] == page_no),
key=lambda b: b.get("reading_order", 0))]
if before != after:
changed.append(page_no)
return layout, changed
def _reorder_page(layout: dict[str, Any], page_no: int) -> dict[str, Any]:
"""Recompute one page's reading order -- unless the reader fixed that order by hand."""
page_meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None)
if not page_meta or page_meta.get("order_locked"):
return layout
blocks = [Block(**b) for b in layout.get("blocks", [])]
page_blocks = [b for b in blocks if b.page == page_no]
assign_reading_order(page_blocks, page_meta["width"])
layout = dict(layout)
layout["blocks"] = [b.to_json() for b in [b for b in blocks if b.page != page_no] + page_blocks]
return layout
def delete_region(layout: dict[str, Any], block_id: str) -> dict[str, Any]: def delete_region(layout: dict[str, Any], block_id: str) -> dict[str, Any]:
"""Remove a region the layout engine got wrong entirely (not just """Remove a region the layout engine got wrong entirely (not just
misplaced). An article referencing this id in its own blocks/images misplaced). An article referencing this id in its own blocks/images

View File

@ -735,6 +735,23 @@ def _refine_figures_sync(doc_id: str, job_id: str) -> None:
background_job_update(job_id, stage="failed", done=True, error=str(exc)[:1000]) background_job_update(job_id, stage="failed", done=True, error=str(exc)[:1000])
@router.post("/api/magazine/{doc_id}/reading-order")
async def magazine_recompute_reading_order(doc_id: str):
"""Re-derive the reading order of every page (column by column), without re-OCR.
Text, boxes, articles and approvals are untouched; pages the reader ordered by hand keep their
order. Instant, so it runs inline rather than as a background job.
"""
d, _ = _require_magazine(doc_id)
layout = mag.load_layout(d)
if not layout:
raise HTTPException(404, "No layout yet -- run ingestion first")
layout, changed = await asyncio.to_thread(mag.recompute_reading_order, layout)
mag.save_layout(d, layout)
locked = sum(1 for p in layout.get("pages", []) if p.get("order_locked"))
return {"ok": True, "pages_changed": changed, "pages_locked": locked}
@router.post("/api/magazine/{doc_id}/figures/refine") @router.post("/api/magazine/{doc_id}/figures/refine")
async def magazine_refine_figures(doc_id: str): async def magazine_refine_figures(doc_id: str):
"""Re-check every figure boundary in this issue against the page renders. """Re-check every figure boundary in this issue against the page renders.
@ -828,6 +845,9 @@ async def magazine_update_region(doc_id: str, page: int, block_id: str, request:
and all(isinstance(v, (int, float)) for v in bbox)): and all(isinstance(v, (int, float)) for v in bbox)):
raise HTTPException(400, "bbox must be [x, y, width, height]") raise HTTPException(400, "bbox must be [x, y, width, height]")
reocr = bool(body.get("reocr")) reocr = bool(body.get("reocr"))
new_type = body.get("type")
if new_type is not None and new_type not in mag.REGION_TYPES:
raise HTTPException(400, f"type must be one of {', '.join(mag.REGION_TYPES)}")
settings = _load_settings() settings = _load_settings()
vision_ocr_url = str(settings.get("vision_ocr_url") or "").strip() or None vision_ocr_url = str(settings.get("vision_ocr_url") or "").strip() or None
@ -835,16 +855,43 @@ async def magazine_update_region(doc_id: str, page: int, block_id: str, request:
vision_ocr_fallback_url = str(settings.get("vision_ocr_fallback_url") or "").strip() or None vision_ocr_fallback_url = str(settings.get("vision_ocr_fallback_url") or "").strip() or None
vision_ocr_fallback_model = str(settings.get("vision_ocr_fallback_model") or "").strip() or None vision_ocr_fallback_model = str(settings.get("vision_ocr_fallback_model") or "").strip() or None
region_id, text = block_id, None
try: try:
layout, text = await asyncio.to_thread( if bbox is not None or reocr:
mag.update_region, d, layout, page, block_id, layout, text = await asyncio.to_thread(
[float(v) for v in bbox] if bbox is not None else None, reocr, mag.update_region, d, layout, page, block_id,
vision_ocr_url, vision_ocr_model, vision_ocr_fallback_url, vision_ocr_fallback_model, [float(v) for v in bbox] if bbox is not None else None, reocr,
) vision_ocr_url, vision_ocr_model, vision_ocr_fallback_url, vision_ocr_fallback_model,
)
if new_type is not None:
layout, region_id = await asyncio.to_thread(
mag.change_region_type, d, layout, page, block_id, new_type,
vision_ocr_url, vision_ocr_model, vision_ocr_fallback_url, vision_ocr_fallback_model,
)
except ValueError as exc: except ValueError as exc:
raise HTTPException(400, str(exc)) raise HTTPException(400, str(exc))
mag.save_layout(d, layout) mag.save_layout(d, layout)
return {"ok": True, "text": text} if region_id != block_id:
_move_article_membership(d, block_id, region_id, to_image=new_type == "image")
return {"ok": True, "text": text, "id": region_id, "type": new_type}
def _move_article_membership(d: Path, old_id: str, new_id: str, to_image: bool) -> None:
"""A region that crossed between text and image got a new id: keep it in the articles that
held it, on the list that matches what it is now (figures are listed apart from text)."""
articles = mag.load_articles(d)
changed = False
for article in articles:
blocks, images = list(article.get("blocks") or []), list(article.get("images") or [])
if old_id not in blocks and old_id not in images:
continue
blocks = [b for b in blocks if b != old_id]
images = [i for i in images if i != old_id]
(images if to_image else blocks).append(new_id)
article["blocks"], article["images"] = blocks, images
changed = True
if changed:
mag.save_articles(d, articles)
@router.delete("/api/magazine/{doc_id}/pages/{page}/regions/{block_id}") @router.delete("/api/magazine/{doc_id}/pages/{page}/regions/{block_id}")

View File

@ -27,12 +27,12 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.34.1"> <link rel="stylesheet" href="/static/style.css?v=1.35.0">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.34.1"> <link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.35.0">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.34.1"> <link rel="stylesheet" href="/static/css/background-jobs.css?v=1.35.0">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.34.1"> <link rel="stylesheet" href="/static/css/voice-design.css?v=1.35.0">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.34.1"> <link rel="stylesheet" href="/static/css/rehearser.css?v=1.35.0">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.34.1"> <link rel="stylesheet" href="/static/css/audiobook.css?v=1.35.0">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->

View File

@ -662,6 +662,55 @@ function magReviewGoMagazinePage(page) {
// tell "a paragraph of text" from "a photo" from "furniture that will not be // tell "a paragraph of text" from "a photo" from "furniture that will not be
// read" without reading the label. No table detection exists yet (P2), so a // read" without reading the label. No table detection exists yet (P2), so a
// table currently reads as its own text region like any other paragraph. // table currently reads as its own text region like any other paragraph.
// What a region can be changed into from the review list. Values match the server's REGION_TYPES.
const MAG_REGION_TYPES = [
['body', 'Paragraph'], ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Standfirst'],
['byline', 'Author'], ['caption', 'Caption'], ['pullquote', 'Pull quote'], ['sidebar', 'Sidenote'],
['page_number', 'Page number'], ['source', 'Source / footer'], ['ad', 'Advertisement'], ['image', 'Image'],
];
// The picker value for a region as it is now.
function magRegionTypeValue(region) {
if (region.kind === 'image') return 'image';
const b = region.data;
if (b.type === 'furniture') return b.region_label === 'source' ? 'source' : 'page_number';
return MAG_REGION_TYPES.some(([v]) => v === b.type) ? b.type : 'body';
}
// A type picker and a delete button for one row of the review list. Both stop the click from
// reaching the row, which is a <label>: a click inside it would otherwise toggle the checkbox.
function magRegionControls(region, number) {
const current = magRegionTypeValue(region);
const known = MAG_REGION_TYPES.some(([v]) => v === current);
const options = (known ? [] : [[current, current]]).concat(MAG_REGION_TYPES)
.map(([v, label]) => '<option value="' + v + '"' + (v === current ? ' selected' : '') + '>' + esc(label) + '</option>').join('');
return '<select class="mag-block-type-select" aria-label="Type of region ' + number + '" ' +
'onclick="event.stopPropagation()" onchange="magReviewChangeType(\'' + esc(region.id) + '\', this.value, this)">' +
options + '</select>' +
'<button type="button" class="btn-sm mag-block-delete" aria-label="Delete region ' + number + '" title="Delete this region" ' +
'onclick="event.preventDefault(); event.stopPropagation(); magReviewDeleteRegion(\'' + esc(region.id) + '\')">' +
'<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span></button>';
}
async function magReviewChangeType(regionId, type, select) {
const toImage = type === 'image';
const fromImage = !!(state.layout?.images || []).some((img) => img.id === regionId);
if (select) select.disabled = true;
say(fromImage && !toImage ? 'Reading the region as text…' : 'Changing region type…', 'info');
try {
await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions/' + encodeURIComponent(regionId), {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type }),
});
} catch (e) {
if (select) select.disabled = false;
return say('Could not change the region type: ' + e.message, 'err');
}
say('Region type changed', 'ok');
state.layout = null;
await ensureLayout();
renderReview();
}
function magBlockIcon(type) { function magBlockIcon(type) {
if (type === 'image' || type === 'figure') return 'mdi-image-outline'; if (type === 'image' || type === 'figure') return 'mdi-image-outline';
if (type === 'pullquote') return 'mdi-format-quote-close'; if (type === 'pullquote') return 'mdi-format-quote-close';
@ -765,7 +814,7 @@ function magReviewPageColumn(page, wrapId, interactive) {
'onmouseenter="magReviewHighlightBox(\'' + esc(r.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' + 'onmouseenter="magReviewHighlightBox(\'' + esc(r.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
'<span class="mag-block-num">' + (i + 1) + '</span>' + '<span class="mag-block-num">' + (i + 1) + '</span>' +
'<input type="checkbox" aria-label="Include image in playback" ' + (on ? 'checked' : '') + ' onchange="magReviewToggleImage(\'' + esc(r.id) + '\', this.checked)">' + '<input type="checkbox" aria-label="Include image in playback" ' + (on ? 'checked' : '') + ' onchange="magReviewToggleImage(\'' + esc(r.id) + '\', this.checked)">' +
'<span class="mag-block-type"><span class="mdi ' + icon + '"></span> ' + esc(r.data.type || 'image') + '</span>' + '<span class="mag-block-type"><span class="mdi ' + icon + '" aria-hidden="true"></span>' + magRegionControls(r, i + 1) + '</span>' +
'<div class="mag-block-thumb-wrap">' + '<div class="mag-block-thumb-wrap">' +
'<img class="mag-block-thumb" src="' + imgUrl + '" alt="' + esc(r.data.type || 'figure') + '" onerror="this.style.display=\'none\'">' + '<img class="mag-block-thumb" src="' + imgUrl + '" alt="' + esc(r.data.type || 'figure') + '" onerror="this.style.display=\'none\'">' +
'<span class="mag-block-text">' + (owner ? 'Belongs to Article ' + owner.index + ': ' + esc(owner.title) '<span class="mag-block-text">' + (owner ? 'Belongs to Article ' + owner.index + ': ' + esc(owner.title)
@ -782,7 +831,7 @@ function magReviewPageColumn(page, wrapId, interactive) {
'onmouseenter="magReviewHighlightBox(\'' + esc(b.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' + 'onmouseenter="magReviewHighlightBox(\'' + esc(b.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
'<span class="mag-block-num">' + (i + 1) + '</span>' + '<span class="mag-block-num">' + (i + 1) + '</span>' +
'<input type="checkbox" ' + (on ? 'checked' : '') + ' data-block="' + esc(b.id) + '" onchange="magReviewToggleBox(this)">' + '<input type="checkbox" ' + (on ? 'checked' : '') + ' data-block="' + esc(b.id) + '" onchange="magReviewToggleBox(this)">' +
'<span class="mag-block-type"><span class="mdi ' + icon + '"></span> ' + esc(b.region_label === 'page_number' ? 'page number' : b.type === 'body' ? 'paragraph' : b.type === 'title' || b.type === 'heading' ? 'headline' : b.type === 'byline' ? 'author' : b.type === 'sidebar' ? 'sidenote' : b.type === 'ad' ? 'advertisement' : b.type) + '</span>' + '<span class="mag-block-type"><span class="mdi ' + icon + '" aria-hidden="true"></span>' + magRegionControls(r, i + 1) + '</span>' +
'<span class="mag-block-text">' + '<span class="mag-block-text">' +
(owner ? '<span class="mag-block-owner">Article ' + owner.index + ': ' + esc(owner.title) + '</span> ' : '') + (owner ? '<span class="mag-block-owner">Article ' + owner.index + ': ' + esc(owner.title) + '</span> ' : '') +
(b.continues_from_previous_page ? '<span class="mag-block-owner">Continued from previous page</span> ' : '') + (b.continues_from_previous_page ? '<span class="mag-block-owner">Continued from previous page</span> ' : '') +
@ -878,6 +927,9 @@ function renderReview() {
'onclick="magReviewToggleEdit()"><span class="mdi mdi-vector-polygon" aria-hidden="true"></span> Edit boxes</button>' + 'onclick="magReviewToggleEdit()"><span class="mdi mdi-vector-polygon" aria-hidden="true"></span> Edit boxes</button>' +
'<button type="button" class="btn-sm" onclick="magReviewClearCurrentPage()"><span class="mdi mdi-checkbox-multiple-blank-outline" aria-hidden="true"></span> Clear all fields</button></div>' + '<button type="button" class="btn-sm" onclick="magReviewClearCurrentPage()"><span class="mdi mdi-checkbox-multiple-blank-outline" aria-hidden="true"></span> Clear all fields</button></div>' +
'<span class="mag-review-pagenav-spacer"></span>' + '<span class="mag-review-pagenav-spacer"></span>' +
'<button type="button" class="btn-sm" id="mag-reading-order-btn" onclick="magFixReadingOrder()" ' +
'title="Re-read every page column by column, left to right. Text and approvals stay as they are; pages you ordered by hand are kept." >' +
'<span class="mdi mdi-sort-numeric-ascending" aria-hidden="true"></span> Fix reading order</button>' +
'<button type="button" class="btn-sm" id="mag-refine-figures-btn" onclick="magRefineFigures()" ' + '<button type="button" class="btn-sm" id="mag-refine-figures-btn" onclick="magRefineFigures()" ' +
'title="Re-check every figure boundary in this issue against the scanned pages">' + 'title="Re-check every figure boundary in this issue against the scanned pages">' +
'<span class="mdi mdi-image-auto-adjust" aria-hidden="true"></span> Fix figure boundaries</button>' + '<span class="mdi mdi-image-auto-adjust" aria-hidden="true"></span> Fix figure boundaries</button>' +
@ -1277,6 +1329,29 @@ async function magReviewSubmitEdit(reocr) {
renderReview(); renderReview();
} }
async function magFixReadingOrder() {
const ok = await confirmDialog('Re-read the reading order of every page in this issue, column by column. ' +
'Text, boxes and approvals stay as they are, but the order in which approved articles are read aloud can change. ' +
'Pages you ordered by hand are kept.', { title: 'Fix reading order?', okLabel: 'Fix reading order' });
if (!ok) return;
const btn = document.getElementById('mag-reading-order-btn');
if (btn) btn.disabled = true;
let r;
try {
r = await api('/api/magazine/' + state.doc.id + '/reading-order', { method: 'POST' });
} catch (e) {
if (btn) btn.disabled = false;
return say('Could not fix the reading order: ' + e.message, 'err');
}
const n = (r.pages_changed || []).length;
say(n ? 'Reading order fixed on ' + n + ' page' + (n === 1 ? '' : 's') +
(r.pages_locked ? ' (' + r.pages_locked + ' hand-ordered page' + (r.pages_locked === 1 ? '' : 's') + ' kept)' : '') + '.'
: 'The reading order was already right on every page.', 'ok');
state.layout = null;
await ensureLayout();
renderReview();
}
async function magReviewDeleteRegion(blockId) { async function magReviewDeleteRegion(blockId) {
const ok = await confirmDialog('Delete this region? This removes it from the page entirely, not just from this article.', const ok = await confirmDialog('Delete this region? This removes it from the page entirely, not just from this article.',
{ title: 'Delete region?', okLabel: 'Delete', danger: true }); { title: 'Delete region?', okLabel: 'Delete', danger: true });
@ -2340,6 +2415,8 @@ root.magReviewToggleEdit = magReviewToggleEdit;
root.magReviewCancelEditPanel = magReviewCancelEditPanel; root.magReviewCancelEditPanel = magReviewCancelEditPanel;
root.magReviewSubmitEdit = magReviewSubmitEdit; root.magReviewSubmitEdit = magReviewSubmitEdit;
root.magReviewDeleteRegion = magReviewDeleteRegion; root.magReviewDeleteRegion = magReviewDeleteRegion;
root.magReviewChangeType = magReviewChangeType;
root.magFixReadingOrder = magFixReadingOrder;
root.magReocrMagazine = magReocrMagazine; root.magReocrMagazine = magReocrMagazine;
root.magApprove = magApprove; root.magApprove = magApprove;
root.magSetStatus = magSetStatus; root.magSetStatus = magSetStatus;

View File

@ -33,9 +33,11 @@ function confirmDialog(message, opts = {}) {
return new Promise(resolve => { return new Promise(resolve => {
const ov = document.createElement('div'); const ov = document.createElement('div');
ov.className = 'audiobook-overlay'; ov.className = 'audiobook-overlay';
ov.innerHTML = `<div class="audiobook-box"> // An alertdialog, so assistive technology announces the question and its title.
<div class="audiobook-title"><span class="mdi mdi-alert-circle-outline"></span> ${escHtml(opts.title || 'Are you sure?')}</div> const uid = 'cd-' + Math.random().toString(36).slice(2, 9);
<div class="audiobook-msg">${escHtml(message || '')}</div> ov.innerHTML = `<div class="audiobook-box" role="alertdialog" aria-modal="true" aria-labelledby="${uid}-t" aria-describedby="${uid}-m">
<div class="audiobook-title" id="${uid}-t"><span class="mdi mdi-alert-circle-outline" aria-hidden="true"></span> ${escHtml(opts.title || 'Are you sure?')}</div>
<div class="audiobook-msg" id="${uid}-m">${escHtml(message || '')}</div>
<div class="audiobook-actions"> <div class="audiobook-actions">
<button type="button" class="btn-secondary btn-sm" id="cd-cancel">${escHtml(opts.cancelLabel || 'Cancel')}</button> <button type="button" class="btn-secondary btn-sm" id="cd-cancel">${escHtml(opts.cancelLabel || 'Cancel')}</button>
<button type="button" class="btn-primary btn-sm${opts.danger ? ' btn-danger' : ''}" id="cd-ok">${escHtml(opts.okLabel || 'OK')}</button> <button type="button" class="btn-primary btn-sm${opts.danger ? ' btn-danger' : ''}" id="cd-ok">${escHtml(opts.okLabel || 'OK')}</button>

View File

@ -5009,7 +5009,7 @@ input[list="lang-codes"] {
.mag-review-box-resize.rz-w { top: 50%; left: -5px; margin-top: -5px; cursor: ew-resize; } .mag-review-box-resize.rz-w { top: 50%; left: -5px; margin-top: -5px; cursor: ew-resize; }
#mag-edit-toggle.active { background: var(--accent); color: var(--on-accent); border-color: var(--accent); } #mag-edit-toggle.active { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.mag-block { display: grid; grid-template-columns: auto auto 90px 1fr; gap: 10px; align-items: start; .mag-block { display: grid; grid-template-columns: auto auto max-content 1fr; gap: 10px; align-items: start;
padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--surface); margin-bottom: 4px; cursor: pointer; } background: var(--surface); margin-bottom: 4px; cursor: pointer; }
.mag-block.is-aux { opacity: .68; } .mag-block.is-aux { opacity: .68; }
@ -5018,9 +5018,13 @@ input[list="lang-codes"] {
.mag-block-type { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--subtext); .mag-block-type { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--subtext);
display: flex; align-items: center; gap: 3px; } display: flex; align-items: center; gap: 3px; }
.mag-block-type .mdi { font-size: 13px; } .mag-block-type .mdi { font-size: 13px; }
.mag-block-type-select { font-size: 11px; text-transform: none; letter-spacing: 0; padding: 2px 4px;
max-width: 9.5em; min-height: 28px; }
.mag-block-delete { padding: 2px 6px; min-width: 28px; min-height: 28px; color: var(--subtext); }
.mag-block-delete:hover, .mag-block-delete:focus-visible { color: var(--danger, #dc2626); }
.mag-block-owner { display: inline-block; padding: 1px 6px; margin-right: 6px; border-radius: 999px; .mag-block-owner { display: inline-block; padding: 1px 6px; margin-right: 6px; border-radius: 999px;
background: var(--mauve); color: #fff; font-size: 10px; font-weight: 700; } background: var(--mauve); color: #fff; font-size: 10px; font-weight: 700; }
.mag-block-image { cursor: default; grid-template-columns: auto auto 90px 1fr; } .mag-block-image { cursor: default; grid-template-columns: auto auto max-content 1fr; }
.mag-block-text { font-size: 14px; line-height: 1.45; } .mag-block-text { font-size: 14px; line-height: 1.45; }
/* A 5th grid child in the 4-column .mag-block-image row -- span the whole /* A 5th grid child in the 4-column .mag-block-image row -- span the whole
row below the thumbnail rather than being squeezed into column 1 alone. */ row below the thumbnail rather than being squeezed into column 1 alone. */
@ -5132,7 +5136,10 @@ input[list="lang-codes"] {
.mag-toc-row { grid-template-columns: 1fr; } .mag-toc-row { grid-template-columns: 1fr; }
.mag-toc-actions { justify-content: flex-start; } .mag-toc-actions { justify-content: flex-start; }
.mag-block { grid-template-columns: auto auto 1fr; } .mag-block { grid-template-columns: auto auto 1fr; }
.mag-block-type { display: none; } /* The type picker and delete button stay on small screens; the text drops to its own line. */
.mag-block-type { grid-column: 3; }
.mag-block-type > .mdi { display: none; }
.mag-block-text, .mag-block-thumb-wrap { grid-column: 1 / -1; }
.mag-article { font-size: 18px; } .mag-article { font-size: 18px; }
.mag-driving-controls { gap: 20px; } .mag-driving-controls { gap: 20px; }
.mag-driving-btn { width: 76px; height: 76px; font-size: 28px; } .mag-driving-btn { width: 76px; height: 76px; font-size: 28px; }

View File

@ -321,3 +321,79 @@ test('dropping a PDF file onto mag-dropzone triggers creation without browser na
expect(uploadCalled).toBe(true); expect(uploadCalled).toBe(true);
}); });
async function openReview(page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.getByRole('button', { name: 'Review', exact: true }).click();
}
test('a region type can be changed from the review list without toggling its checkbox', async ({ page }) => {
await mockBackend(page);
let changedTo = null;
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b2`, route => {
expect(route.request().method()).toBe('PATCH');
changedTo = route.request().postDataJSON().type;
return route.fulfill({ json: { ok: true, id: 'b2', type: changedTo } });
});
await openReview(page);
const row = page.locator('[data-block-row="b2"]');
const checkbox = page.locator('#mag-review input[data-block="b2"]');
const before = await checkbox.isChecked();
const picker = row.getByRole('combobox', { name: /Type of region/ });
await expect(picker).toHaveValue('byline');
await picker.click();
await expect(checkbox).toBeChecked({ checked: before }); // opening the picker did not toggle the row
await picker.selectOption('page_number');
await expect.poll(() => changedTo).toBe('page_number');
});
test('every type the server knows is offered, image included', async ({ page }) => {
await mockBackend(page);
await openReview(page);
await expect(page.locator('[data-block-row="b3"] select.mag-block-type-select')).toBeVisible();
const values = await page.locator('[data-block-row="b3"] select.mag-block-type-select option').evaluateAll(
options => options.map(o => o.value));
expect(values).toEqual(['body', 'title', 'heading', 'deck', 'byline', 'caption', 'pullquote', 'sidebar',
'page_number', 'source', 'ad', 'image']);
});
test('each review row has its own delete button that asks first', async ({ page }) => {
await mockBackend(page);
let deleted = false;
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3`, route => {
expect(route.request().method()).toBe('DELETE');
deleted = true;
return route.fulfill({ json: { ok: true } });
});
await openReview(page);
const button = page.locator('[data-block-row="b3"]').getByRole('button', { name: /Delete region/ });
// The button sits in its own column and does not cover the paragraph text.
const [btn, text] = [await button.boundingBox(), await page.locator('[data-block-row="b3"] .mag-block-text').boundingBox()];
expect(btn.x + btn.width).toBeLessThanOrEqual(text.x);
await button.click();
const dialog = page.getByRole('alertdialog', { name: 'Delete region?' });
await expect(dialog).toContainText('Delete this region?');
expect(deleted).toBe(false);
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
await expect.poll(() => deleted).toBe(true);
});
test('fix reading order asks first, then reports how many pages changed', async ({ page }) => {
await mockBackend(page);
let called = false;
await page.route(`**/api/magazine/${DOC_ID}/reading-order`, route => {
called = true;
return route.fulfill({ json: { ok: true, pages_changed: [3, 5], pages_locked: 1 } });
});
await openReview(page);
await page.getByRole('button', { name: 'Fix reading order', exact: true }).click();
const dialog = page.getByRole('alertdialog', { name: 'Fix reading order?' });
await expect(dialog).toContainText('Pages you ordered by hand are kept');
expect(called).toBe(false);
await dialog.getByRole('button', { name: 'Fix reading order', exact: true }).click();
await expect.poll(() => called).toBe(true);
await expect(page.getByText(/Reading order fixed on 2 pages \(1 hand-ordered page kept\)/)).toBeVisible();
});

View File

@ -40,6 +40,8 @@ from core.magazine import (
article_text, article_text,
assign_images_to_articles, assign_images_to_articles,
assign_reading_order, assign_reading_order,
column_anchors,
is_pullquote_like,
body_font_size, body_font_size,
build_articles, build_articles,
build_vision_blocks, build_vision_blocks,
@ -148,6 +150,83 @@ class ColumnGeometryTests(unittest.TestCase):
self.assertEqual(cols["l1"], cols["l2"]) self.assertEqual(cols["l1"], cols["l2"])
self.assertEqual(cols["r1"], cols["r2"]) self.assertEqual(cols["r1"], cols["r2"])
def _page_38(self):
"""A real scanned c't page (issue 204, page 38): measured boxes, placeholder text.
The OCR boxes are loose, so the gutter between the two body columns is only 4-6 points,
and a pull-quote sits across it. The old whitespace detector saw one column and read the
page top to bottom, interleaving both columns. `box` is the number the review screen showed.
"""
page_w = 553.68
long_text = "Running text of an interview answer. " * 8
rows = [ # box, type, x, y, w, h, text
(1, "furniture", 27, 646, 520, 12, "40 Arbeitswelt & Wirtschaft"),
(2, "title", 29, 50, 344, 79, "Headline of the article"),
(3, "body", 29, 145, 299, 56, "Standfirst introducing the interview. " * 3),
(4, "byline", 29, 223, 105, 13, "Von A. Autor"),
(5, "body", 29, 254, 200, 68, long_text),
(6, "body", 238, 257, 205, 146, long_text),
(7, "body", 28, 330, 204, 158, long_text),
(8, "body", 240, 409, 204, 28, "Question from the interviewer, a single line?"),
(9, "body", 238, 443, 209, 48, long_text),
(10, "pullquote", 25, 488, 134, 87, long_text), # mislabelled: really left-column body
(11, "body", 307, 489, 138, 87, long_text),
(12, "body", 169, 503, 127, 60, "»A short quote from the interview, set large.«"), # the real pull-quote
(13, "body", 25, 572, 203, 60, long_text),
(14, "body", 236, 573, 210, 65, long_text),
]
return page_w, [block(f"b{n}", 38, x, y, w, h, text, btype=t) for n, t, x, y, w, h, text in rows]
def test_two_columns_with_a_hairline_gutter_are_read_column_by_column(self):
page_w, blocks = self._page_38()
order = [int(b.id[1:]) for b in assign_reading_order(blocks, page_w)]
# Exactly the order marked by hand on the page: headline, standfirst, byline, the left
# column top to bottom, the right column top to bottom, the pull-quote, then furniture.
self.assertEqual(order, [2, 3, 4, 5, 7, 10, 13, 6, 8, 9, 11, 14, 12, 1])
def test_columns_come_from_left_edges_not_from_the_gutter(self):
page_w, blocks = self._page_38()
anchors, col_w = column_anchors(blocks, page_w)
self.assertEqual(len(anchors), 2)
self.assertLess(abs(anchors[0] - 25), 5)
self.assertLess(abs(anchors[1] - 236), 5)
def test_a_narrow_box_starting_mid_column_does_not_invent_a_column(self):
page_w, blocks = self._page_38()
anchors, _ = column_anchors(blocks, page_w)
self.assertNotIn(307, [round(a) for a in anchors]) # box 11: the lower part of the right column
self.assertNotIn(169, [round(a) for a in anchors]) # box 12: the pull-quote
def test_pullquote_is_judged_by_its_text_not_only_its_label(self):
short_quote = block("q", 1, 0, 0, 100, 40, "»Set large in the middle of the page.«")
labelled_but_long = block("p", 1, 0, 0, 100, 40, "Running text. " * 40, btype=BLOCK_PULLQUOTE)
self.assertTrue(is_pullquote_like(short_quote))
self.assertFalse(is_pullquote_like(labelled_but_long))
self.assertFalse(is_pullquote_like(block("b", 1, 0, 0, 100, 40, "Plain sentence.")))
def test_a_caption_mid_page_does_not_tear_the_columns_in_half(self):
blocks = [
block("l1", 1, 50, 100, 200, 150, "left top"),
block("r1", 1, 320, 100, 200, 150, "right top"),
block("cap", 1, 50, 280, 470, 20, "Caption under a wide photo", btype=BLOCK_CAPTION),
block("l2", 1, 50, 320, 200, 150, "left bottom"),
block("r2", 1, 320, 320, 200, 150, "right bottom"),
]
order = [b.id for b in assign_reading_order(blocks, PAGE_W) if b.id != "cap"]
self.assertEqual(order, ["l1", "l2", "r1", "r2"])
def test_a_text_block_across_two_columns_starts_a_new_band(self):
"""A second headline across both columns: what is under it is read after it."""
blocks = [
block("l1", 1, 50, 100, 200, 150, "left top"),
block("r1", 1, 320, 100, 200, 150, "right top"),
block("head2", 1, 50, 280, 470, 30, "Second story headline", size=20.0, btype=BLOCK_TITLE),
block("l2", 1, 50, 330, 200, 150, "left bottom"),
block("r2", 1, 320, 330, 200, 150, "right bottom"),
]
order = [b.id for b in assign_reading_order(blocks, PAGE_W)]
self.assertEqual(order, ["l1", "r1", "head2", "l2", "r2"])
class ClassificationTests(unittest.TestCase): class ClassificationTests(unittest.TestCase):
def test_body_size_is_not_hijacked_by_display_type(self): def test_body_size_is_not_hijacked_by_display_type(self):

View File

@ -577,3 +577,141 @@ class DeleteRegionRouteTests(unittest.IsolatedAsyncioTestCase):
with self.assertRaises(HTTPException) as err: with self.assertRaises(HTTPException) as err:
await magazine_delete_region('a' * 32, 1, 'b1') await magazine_delete_region('a' * 32, 1, 'b1')
self.assertEqual(err.exception.status_code, 404) self.assertEqual(err.exception.status_code, 404)
class ChangeRegionTypeTests(unittest.TestCase):
"""Changing what a detected region is, from the review list."""
def setUp(self):
from pathlib import Path
from PIL import Image
self.dir = Path(tempfile.mkdtemp(prefix="ttvc-regiontype-"))
(self.dir / "pages").mkdir()
Image.new("RGB", (600, 800), "white").save(self.dir / "pages" / "0001.png")
self.layout = {
"pages": [{"page": 1, "width": 600, "height": 800}],
"blocks": [
{"id": "b1", "page": 1, "bbox": [20, 20, 250, 40], "type": "body", "text": "A headline set as body"},
{"id": "b2", "page": 1, "bbox": [20, 100, 250, 200], "type": "body", "text": "Running text."},
{"id": "b3", "page": 1, "bbox": [320, 100, 250, 200], "type": "body", "text": "Actually a photo."},
],
"images": [{"id": "img1", "page": 1, "bbox": [320, 400, 250, 150], "filename": "img1.png", "reading_order": 0}],
}
def tearDown(self):
shutil.rmtree(self.dir, ignore_errors=True)
def test_a_text_region_becomes_a_headline_and_keeps_its_text(self):
from core.magazine import change_region_type
layout, new_id = change_region_type(self.dir, self.layout, 1, "b1", "title")
block = next(b for b in layout["blocks"] if b["id"] == "b1")
self.assertEqual((new_id, block["type"], block["text"]), ("b1", "title", "A headline set as body"))
def test_page_number_and_source_are_furniture_of_different_kinds(self):
from core.magazine import change_region_type
layout, _ = change_region_type(self.dir, self.layout, 1, "b1", "page_number")
block = next(b for b in layout["blocks"] if b["id"] == "b1")
self.assertEqual((block["type"], block["region_label"]), ("furniture", "page_number"))
layout, _ = change_region_type(self.dir, layout, 1, "b1", "source")
self.assertEqual(next(b for b in layout["blocks"] if b["id"] == "b1")["region_label"], "source")
def test_back_to_body_clears_the_furniture_kind(self):
from core.magazine import change_region_type
layout, _ = change_region_type(self.dir, self.layout, 1, "b1", "page_number")
layout, _ = change_region_type(self.dir, layout, 1, "b1", "body")
self.assertEqual(next(b for b in layout["blocks"] if b["id"] == "b1")["region_label"], "")
def test_a_text_region_becomes_an_image_crop(self):
from core.magazine import change_region_type
layout, new_id = change_region_type(self.dir, self.layout, 1, "b3", "image")
self.assertNotIn("b3", [b["id"] for b in layout["blocks"]])
image = next(i for i in layout["images"] if i["id"] == new_id)
self.assertEqual(image["bbox"], [320, 100, 250, 200])
self.assertTrue((self.dir / "images" / image["filename"]).is_file())
def test_an_image_becomes_text_read_by_ocr(self):
from unittest import mock
import core.magazine as mag
with mock.patch.object(mag, "vision_ocr_page_chain", return_value=[{"text": "Read from the box"}]):
layout, new_id = mag.change_region_type(self.dir, self.layout, 1, "img1", "caption", "http://ocr", "m")
self.assertNotIn("img1", [i["id"] for i in layout["images"]])
block = next(b for b in layout["blocks"] if b["id"] == new_id)
self.assertEqual((block["type"], block["text"]), ("caption", "Read from the box"))
def test_an_unknown_type_or_region_is_rejected(self):
from core.magazine import change_region_type
with self.assertRaises(ValueError):
change_region_type(self.dir, self.layout, 1, "b1", "nonsense")
with self.assertRaises(ValueError):
change_region_type(self.dir, self.layout, 1, "missing", "body")
def test_a_hand_ordered_page_keeps_its_order(self):
from core.magazine import change_region_type
self.layout["pages"][0]["order_locked"] = True
for i, b in enumerate(self.layout["blocks"]):
b["reading_order"] = [2, 0, 1][i]
layout, _ = change_region_type(self.dir, self.layout, 1, "b1", "page_number")
self.assertEqual([b["reading_order"] for b in layout["blocks"]], [2, 0, 1])
def test_route_moves_article_membership_when_text_becomes_an_image(self):
import asyncio
from unittest.mock import AsyncMock, patch
from routes.magazine import magazine_update_region
articles = [{"id": "a1", "blocks": ["b2", "b3"], "images": []}]
saved = {}
with patch('routes.magazine._require_magazine', return_value=(self.dir, {})), \
patch('routes.magazine.mag.load_layout', return_value=self.layout), \
patch('routes.magazine.mag.save_layout'), \
patch('routes.magazine.mag.load_articles', return_value=articles), \
patch('routes.magazine.mag.save_articles', side_effect=lambda d, a: saved.setdefault('a', a)), \
patch('routes.magazine._load_settings', return_value={}), \
patch('routes.magazine.asyncio.to_thread', new=_run_sync):
request = AsyncMock()
request.json.return_value = {'type': 'image'}
result = asyncio.run(magazine_update_region('a' * 32, 1, 'b3', request))
self.assertNotEqual(result['id'], 'b3')
self.assertEqual(saved['a'][0]['blocks'], ['b2'])
self.assertEqual(saved['a'][0]['images'], [result['id']])
def test_route_rejects_an_unknown_type(self):
import asyncio
from unittest.mock import AsyncMock, patch
from routes.magazine import magazine_update_region
with patch('routes.magazine._require_magazine', return_value=(self.dir, {})), \
patch('routes.magazine.mag.load_layout', return_value=self.layout):
request = AsyncMock()
request.json.return_value = {'type': 'nonsense'}
with self.assertRaises(HTTPException) as err:
asyncio.run(magazine_update_region('a' * 32, 1, 'b1', request))
self.assertEqual(err.exception.status_code, 400)
class RecomputeReadingOrderTests(unittest.TestCase):
def layout(self, locked=False):
# Two columns stored in an interleaved (wrong) order.
rows = [("l1", 50, 100, 1), ("r1", 320, 100, 2), ("l2", 50, 200, 3), ("r2", 320, 200, 4)]
return {"pages": [{"page": 1, "width": 595.0, "height": 842.0, **({"order_locked": True} if locked else {})}],
"blocks": [{"id": i, "page": 1, "bbox": [x, y, 200, 80], "type": "body", "text": i,
"reading_order": ro} for i, x, y, ro in rows]}
def order(self, layout):
return [b["id"] for b in sorted(layout["blocks"], key=lambda b: b["reading_order"])]
def test_an_interleaved_page_is_reread_column_by_column(self):
from core.magazine import recompute_reading_order
layout, changed = recompute_reading_order(self.layout())
self.assertEqual(self.order(layout), ["l1", "l2", "r1", "r2"])
self.assertEqual(changed, [1])
def test_a_hand_ordered_page_is_left_alone(self):
from core.magazine import recompute_reading_order
layout, changed = recompute_reading_order(self.layout(locked=True))
self.assertEqual(self.order(layout), ["l1", "r1", "l2", "r2"])
self.assertEqual(changed, [])
def test_text_and_types_are_untouched(self):
from core.magazine import recompute_reading_order
before = self.layout()
after, _ = recompute_reading_order(before)
strip = lambda bs: sorted((b["id"], b["text"], b["type"], tuple(b["bbox"])) for b in bs)
self.assertEqual(strip(after["blocks"]), strip(before["blocks"]))