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:
parent
981341de3f
commit
fd1de80f83
@ -7,7 +7,11 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.35.0] — 2026-09-21
|
||||
|
||||
### 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.
|
||||
- **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.
|
||||
@ -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.
|
||||
|
||||
### 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").
|
||||
- **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.
|
||||
|
||||
16
Manual.md
16
Manual.md
@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
4
TODO.md
4
TODO.md
@ -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.
|
||||
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.
|
||||
- [ ] **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
|
||||
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
|
||||
@ -81,7 +81,7 @@ not part of this order.
|
||||
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.
|
||||
|
||||
- [ ] **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
|
||||
(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,
|
||||
|
||||
@ -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
|
||||
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/test_magazine.py`](../tests/test_magazine.py) — 100 tests on the pure
|
||||
|
||||
285
core/magazine.py
285
core/magazine.py
@ -184,49 +184,164 @@ def detect_columns(blocks: Sequence[Block], page_width: float,
|
||||
return [(m[0], m[1]) for m in merged]
|
||||
|
||||
|
||||
def assign_reading_order(blocks: list[Block], page_width: float) -> list[Block]:
|
||||
"""Order blocks the way a person reads the page: column by column.
|
||||
# Blocks that are not running text: they never define a column or split the page.
|
||||
_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
|
||||
failure that splices one article's sentence into another's.
|
||||
_QUOTE_PAIRS = (("»", "«"), ("„", "“"), ("“", "”"), ("«", "»"), ('"', '"'), ("‚", "‘"))
|
||||
|
||||
Column BOUNDARIES are detected from blocks close to the page's typical
|
||||
width only. Confirmed live on a real scanned magazine: a caption running
|
||||
under a photo, at 49.9% of the page width -- under the "spans the whole
|
||||
page" threshold below, so not given special handling -- sat squarely in
|
||||
the whitespace gap between two real text columns and bridged them into
|
||||
one merged span, which meant EVERY block on the page got column=0 and
|
||||
reading order fell back to pure top-to-bottom, splicing the two
|
||||
columns' sentences together (the exact failure this function exists to
|
||||
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.
|
||||
|
||||
def is_pullquote_like(block: Block) -> bool:
|
||||
"""A short display quote that repeats the article: read after the page's running text.
|
||||
|
||||
Types are not trusted on their own: on a real scanned page the layout model typed the actual
|
||||
pull-quote ("»Wenn man hundertmal die KI nutzt…«") as body text, and typed a left-column body
|
||||
paragraph as a pull-quote. A pull-quote is short and wrapped in quotation marks; a long block
|
||||
is running text whatever it was labelled.
|
||||
"""
|
||||
widths = sorted(b.bbox[2] for b in blocks if b.bbox[2] > 1)
|
||||
median_w = widths[len(widths) // 2] if widths else 0
|
||||
narrow = [b for b in blocks if median_w <= 0 or b.bbox[2] <= median_w * 1.4]
|
||||
columns = detect_columns(narrow if len(narrow) >= 2 else blocks, page_width)
|
||||
text = " ".join((block.text or "").split())
|
||||
if not text or len(text) > 260:
|
||||
return False
|
||||
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:
|
||||
centre = b.bbox[0] + b.bbox[2] / 2
|
||||
for i, (left, right) in enumerate(columns):
|
||||
if left <= centre <= right:
|
||||
return i
|
||||
# Spanning blocks (a headline across all columns) belong to the first
|
||||
# column they touch, so they precede the body they introduce.
|
||||
for i, (left, right) in enumerate(columns):
|
||||
if bbox_overlaps(b.bbox, [left, b.bbox[1], right - left, b.bbox[3]]):
|
||||
return i
|
||||
return len(columns)
|
||||
best = max(range(len(spans)), key=lambda i: (overlap(b, spans[i]), -i))
|
||||
if overlap(b, spans[best]) > 0:
|
||||
return best
|
||||
# Outside every column (a margin note): the nearest column by left edge.
|
||||
return min(range(len(anchors)), key=lambda i: abs(b.bbox[0] - anchors[i]))
|
||||
|
||||
def crosses_columns(b: Block) -> bool:
|
||||
if len(spans) < 2:
|
||||
return b.bbox[2] > page_width * 0.6
|
||||
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:
|
||||
b.column = column_of(b)
|
||||
|
||||
# A full-width headline must not be dragged to the bottom of column 0, so
|
||||
# blocks spanning most of the page sort by y before anything below them.
|
||||
def key(b: Block) -> tuple[float, int, float]:
|
||||
spans_page = b.bbox[2] > page_width * 0.6
|
||||
return (0 if spans_page else 1, b.column, round(b.bbox[1], 1))
|
||||
breakers = {id(b) for b in blocks if splits_page(b) or photo_splits_page(b)}
|
||||
band_tops = sorted(b.bbox[1] for b in blocks if id(b) in breakers)
|
||||
spanning = lambda b: id(b) in breakers # noqa: E731
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
# 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]:
|
||||
"""Remove a region the layout engine got wrong entirely (not just
|
||||
misplaced). An article referencing this id in its own blocks/images
|
||||
|
||||
@ -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])
|
||||
|
||||
|
||||
@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")
|
||||
async def magazine_refine_figures(doc_id: str):
|
||||
"""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)):
|
||||
raise HTTPException(400, "bbox must be [x, y, width, height]")
|
||||
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()
|
||||
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_model = str(settings.get("vision_ocr_fallback_model") or "").strip() or None
|
||||
|
||||
region_id, text = block_id, None
|
||||
try:
|
||||
layout, text = await asyncio.to_thread(
|
||||
mag.update_region, d, layout, page, block_id,
|
||||
[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 bbox is not None or reocr:
|
||||
layout, text = await asyncio.to_thread(
|
||||
mag.update_region, d, layout, page, block_id,
|
||||
[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:
|
||||
raise HTTPException(400, str(exc))
|
||||
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}")
|
||||
|
||||
@ -27,12 +27,12 @@
|
||||
|
||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||
<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/css/resource-diagnostics.css?v=1.34.1">
|
||||
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.34.1">
|
||||
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.34.1">
|
||||
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.34.1">
|
||||
<link rel="stylesheet" href="/static/css/audiobook.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.35.0">
|
||||
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.35.0">
|
||||
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.35.0">
|
||||
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.35.0">
|
||||
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.35.0">
|
||||
|
||||
|
||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||
|
||||
@ -662,6 +662,55 @@ function magReviewGoMagazinePage(page) {
|
||||
// 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
|
||||
// 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) {
|
||||
if (type === 'image' || type === 'figure') return 'mdi-image-outline';
|
||||
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)">' +
|
||||
'<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)">' +
|
||||
'<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">' +
|
||||
'<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)
|
||||
@ -782,7 +831,7 @@ function magReviewPageColumn(page, wrapId, interactive) {
|
||||
'onmouseenter="magReviewHighlightBox(\'' + esc(b.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
|
||||
'<span class="mag-block-num">' + (i + 1) + '</span>' +
|
||||
'<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">' +
|
||||
(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> ' : '') +
|
||||
@ -878,6 +927,9 @@ function renderReview() {
|
||||
'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>' +
|
||||
'<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()" ' +
|
||||
'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>' +
|
||||
@ -1277,6 +1329,29 @@ async function magReviewSubmitEdit(reocr) {
|
||||
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) {
|
||||
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 });
|
||||
@ -2340,6 +2415,8 @@ root.magReviewToggleEdit = magReviewToggleEdit;
|
||||
root.magReviewCancelEditPanel = magReviewCancelEditPanel;
|
||||
root.magReviewSubmitEdit = magReviewSubmitEdit;
|
||||
root.magReviewDeleteRegion = magReviewDeleteRegion;
|
||||
root.magReviewChangeType = magReviewChangeType;
|
||||
root.magFixReadingOrder = magFixReadingOrder;
|
||||
root.magReocrMagazine = magReocrMagazine;
|
||||
root.magApprove = magApprove;
|
||||
root.magSetStatus = magSetStatus;
|
||||
|
||||
@ -33,9 +33,11 @@ function confirmDialog(message, opts = {}) {
|
||||
return new Promise(resolve => {
|
||||
const ov = document.createElement('div');
|
||||
ov.className = 'audiobook-overlay';
|
||||
ov.innerHTML = `<div class="audiobook-box">
|
||||
<div class="audiobook-title"><span class="mdi mdi-alert-circle-outline"></span> ${escHtml(opts.title || 'Are you sure?')}</div>
|
||||
<div class="audiobook-msg">${escHtml(message || '')}</div>
|
||||
// An alertdialog, so assistive technology announces the question and its title.
|
||||
const uid = 'cd-' + Math.random().toString(36).slice(2, 9);
|
||||
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">
|
||||
<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>
|
||||
|
||||
@ -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-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);
|
||||
background: var(--surface); margin-bottom: 4px; cursor: pointer; }
|
||||
.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);
|
||||
display: flex; align-items: center; gap: 3px; }
|
||||
.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;
|
||||
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; }
|
||||
/* 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. */
|
||||
@ -5132,7 +5136,10 @@ input[list="lang-codes"] {
|
||||
.mag-toc-row { grid-template-columns: 1fr; }
|
||||
.mag-toc-actions { justify-content: flex-start; }
|
||||
.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-driving-controls { gap: 20px; }
|
||||
.mag-driving-btn { width: 76px; height: 76px; font-size: 28px; }
|
||||
|
||||
@ -321,3 +321,79 @@ test('dropping a PDF file onto mag-dropzone triggers creation without browser na
|
||||
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();
|
||||
});
|
||||
|
||||
@ -40,6 +40,8 @@ from core.magazine import (
|
||||
article_text,
|
||||
assign_images_to_articles,
|
||||
assign_reading_order,
|
||||
column_anchors,
|
||||
is_pullquote_like,
|
||||
body_font_size,
|
||||
build_articles,
|
||||
build_vision_blocks,
|
||||
@ -148,6 +150,83 @@ class ColumnGeometryTests(unittest.TestCase):
|
||||
self.assertEqual(cols["l1"], cols["l2"])
|
||||
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):
|
||||
def test_body_size_is_not_hijacked_by_display_type(self):
|
||||
|
||||
@ -577,3 +577,141 @@ class DeleteRegionRouteTests(unittest.IsolatedAsyncioTestCase):
|
||||
with self.assertRaises(HTTPException) as err:
|
||||
await magazine_delete_region('a' * 32, 1, 'b1')
|
||||
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"]))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user