fix(magazine): draw panel placed inside the window; Undo/Redo lead the toolbar's second row; lines and frames are not missed text

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-28 18:33:15 +02:00
parent 0136842258
commit 63cb129d24
7 changed files with 246 additions and 30 deletions

View File

@ -38,18 +38,20 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **Articles nobody has reviewed are blue.** In the article list an unapproved, unrejected article has a blue background and edge and a *not reviewed* pill (approved keeps its green edge), so what still needs a person stands out. - **Articles nobody has reviewed are blue.** In the article list an unapproved, unrejected article has a blue background and edge and a *not reviewed* pill (approved keeps its green edge), so what still needs a person stands out.
- **Overlapping boxes are fixed automatically.** Where a lower-ranked box runs over a higher-ranked one (headline > deck/byline > body > caption/sidebar/pull quote), the lower one is cut back to the largest part that no longer overlaps, unless that would leave under 40% of it (then both stay for a human). Hand-placed boxes and boxes of equal rank are never touched. - **Overlapping boxes are fixed automatically.** Where a lower-ranked box runs over a higher-ranked one (headline > deck/byline > body > caption/sidebar/pull quote), the lower one is cut back to the largest part that no longer overlaps, unless that would leave under 40% of it (then both stay for a human). Hand-placed boxes and boxes of equal rank are never touched.
- **Fewer false alarms and better fits:** a strip of solid ink beside a headline (a photo or filled panel behind it) is no longer counted as letters cut off; headline and deck boxes may grow up to 70 px sideways (body boxes 16 px); an inset pull-quote box that repeats text a box already holds becomes a duplicate. - **Fewer false alarms and better fits:** a strip of solid ink beside a headline (a photo or filled panel behind it) is no longer counted as letters cut off; headline and deck boxes may grow up to 70 px sideways (body boxes 16 px); an inset pull-quote box that repeats text a box already holds becomes a duplicate.
- **Lines and frames are not missed text.** Beyond dotted leaders and thin rules, the audit now tells letters from lines by shape: text is many small separate marks, whereas a screenshot or chart frame, a vertical rule, a chart axis or a signature is a few large connected shapes (long and thin, or big and hollow). Only such shapes are ignored; a paragraph of text inside a frame, and a solid photo, are still found. Unboxed areas on the measured issue fell from 105 to 27.
- **Rules and dotted leaders are not missed text.** The dots between a table-of-contents entry and its page number (and thin printed rules) are ink but no letters; the audit now ignores ink that is only a few pixel rows tall. - **Rules and dotted leaders are not missed text.** The dots between a table-of-contents entry and its page number (and thin printed rules) are ink but no letters; the audit now ignores ink that is only a few pixel rows tall.
- **Stacked boxes of a column now meet at the gap between two lines** (*seam mending*). Detection often cuts a column into slices that overlap or leave a line in the seam, so that line was in neither box and both reported a clipped edge. The shared boundary moves to the nearest blank gap (below the line for a paragraph that is still running, above it after a finished one, in the middle of an overlap); a line that had fallen into the seam is read on its own and attached to the box that now owns it, and the box takes the column's width. Page 32 of the measured issue: clipped edges 2 → 0 and the lost line "Entwicklungen auseinander. „KI ist bei uns in allen" is back in the text. 57 seams were mended across the issue. - **Stacked boxes of a column now meet at the gap between two lines** (*seam mending*). Detection often cuts a column into slices that overlap or leave a line in the seam, so that line was in neither box and both reported a clipped edge. The shared boundary moves to the nearest blank gap (below the line for a paragraph that is still running, above it after a finished one, in the middle of an overlap); a line that had fallen into the seam is read on its own and attached to the box that now owns it, and the box takes the column's width. Page 32 of the measured issue: clipped edges 2 → 0 and the lost line "Entwicklungen auseinander. „KI ist bei uns in allen" is back in the text. 57 seams were mended across the issue.
- **"Edge cuts text" needs the box's own lines.** Ink beside a left/right edge only counts as cut-off letters when it sits on the rows where the box itself has text and is intermittent (letters and gaps); a solid vertical line (a screenshot frame, a chart border, a photo edge) or ink between the box's lines does not, and a thin horizontal rule above or below is not a cut line. On the measured issue clipped edges fell from 82 to 48 and pages needing a decision from 53 to 42. - **"Edge cuts text" needs the box's own lines.** Ink beside a left/right edge only counts as cut-off letters when it sits on the rows where the box itself has text and is intermittent (letters and gaps); a solid vertical line (a screenshot frame, a chart border, a photo edge) or ink between the box's lines does not, and a thin horizontal rule above or below is not a cut line. On the measured issue clipped edges fell from 82 to 48 and pages needing a decision from 53 to 42.
- **Unboxed ink in or beside a picture, chart or advert is no longer reported as missed text** (labels of a chart, text set into a photo, an advert's own wording). - **Unboxed ink in or beside a picture, chart or advert is no longer reported as missed text** (labels of a chart, text set into a photo, an advert's own wording).
- **Undo and Redo buttons in the sticky review bar** (right after *Contents*), also on Ctrl+Z and Ctrl+Shift+Z / Ctrl+Y (not while typing in a text field, where the browser undoes the typing). The last 20 recorded changes are kept (compressed, in the magazine's `undo/` folder) and each button's tooltip names the change it would undo or redo. More changes are now recorded: approving/rejecting or editing an article, moving or resizing a region, drawing a region, re-reading a page, setting or releasing a page's reading order, *Fix reading order* and *Clean up boxes*, on top of region edits, linking, auto-fix and spelling corrections. A new change ends the redo history. As before, an undo (or redo) is refused if something else changed the magazine in between, so it can never roll back work it does not know about (`GET /history`, `POST /undo`, `POST /redo`). - **Undo and Redo buttons in the sticky review bar**, at the left end of its second row (placed by measuring the bar, so they stay there at any window width), also on Ctrl+Z and Ctrl+Shift+Z / Ctrl+Y (not while typing in a text field, where the browser undoes the typing). The last 20 recorded changes are kept (compressed, in the magazine's `undo/` folder) and each button's tooltip names the change it would undo or redo. More changes are now recorded: approving/rejecting or editing an article, moving or resizing a region, drawing a region, re-reading a page, setting or releasing a page's reading order, *Fix reading order* and *Clean up boxes*, on top of region edits, linking, auto-fix and spelling corrections. A new change ends the redo history. As before, an undo (or redo) is refused if something else changed the magazine in between, so it can never roll back work it does not know about (`GET /history`, `POST /undo`, `POST /redo`).
### Changed ### Changed
- **The Contents button is the first item of the sticky review bar**, so it stays in reach while scrolling a long page. - **The Contents button is the first item of the sticky review bar**, so it stays in reach while scrolling a long page.
- **The page thumbnail is the Review button.** In the thumbnail grid, clicking the (big) page image opens the page in review; the separate *Review* / *Review this page* buttons are gone. A page shared by two or more articles keeps one labelled *Review* button per article, so it stays clear which is meant. - **The page thumbnail is the Review button.** In the thumbnail grid, clicking the (big) page image opens the page in review; the separate *Review* / *Review this page* buttons are gone. A page shared by two or more articles keeps one labelled *Review* button per article, so it stays clear which is meant.
### Fixed ### Fixed
- **The new-box popup is no longer cut off.** Drawing a box near the bottom of a page put the *Region type / Add region* panel below the window edge. It is now placed in the window: below the box when it fits, above it otherwise, always fully inside, and it follows the box when the page scrolls.
- **Changed scripts and stylesheets now reach the browser immediately.** They were requested under a version that only moved at release time (`…js?v=1.53.4`) and cached as immutable for a year, so a browser or proxy could keep running old code after an update (a button added to a script simply did not appear). The app shell now requests every asset under `<version>-<build id>`, where the build id follows the newest script/stylesheet/manifest change. - **Changed scripts and stylesheets now reach the browser immediately.** They were requested under a version that only moved at release time (`…js?v=1.53.4`) and cached as immutable for a year, so a browser or proxy could keep running old code after an update (a button added to a script simply did not appear). The app shell now requests every asset under `<version>-<build id>`, where the build id follows the newest script/stylesheet/manifest change.
- **An approved page is no longer purple.** A page the quality check flagged stayed purple even after you approved its article; now your approval wins and the page shows its review colour (the warning badge on the card still lists what the check found). Purple means flagged *and* nobody has approved anything on the page. An old MP3 on an article nobody approved (left over from before pages were linked) no longer turns a page green either: audio only counts for approved articles. - **An approved page is no longer purple.** A page the quality check flagged stayed purple even after you approved its article; now your approval wins and the page shows its review colour (the warning badge on the card still lists what the check found). Purple means flagged *and* nobody has approved anything on the page. An old MP3 on an article nobody approved (left over from before pages were linked) no longer turns a page green either: audio only counts for approved articles.
- **Reading order is now applied automatically after import, *OCR again* and every per-page fix**, not only by the manual *Fix reading order* button. Until now the order after import was whatever the vision/LLM step returned, so a column's short last box could be read after the whole other column; on the measured issue 101 of 204 pages had a saved order that differs from the column-aware order. Pages whose order you fixed by hand stay untouched, except after *OCR again*, when every region is new and a hand order can no longer apply. - **Reading order is now applied automatically after import, *OCR again* and every per-page fix**, not only by the manual *Fix reading order* button. Until now the order after import was whatever the vision/LLM step returned, so a column's short last box could be read after the whole other column; on the measured issue 101 of 204 pages had a saved order that differs from the column-aware order. Pages whose order you fixed by hand stay untouched, except after *OCR again*, when every region is new and a hand order can no longer apply.

View File

@ -298,7 +298,7 @@ unreviewed boundaries may include a neighbouring article or an advert.
### Undo and Redo ### Undo and Redo
**Purpose:** take back a change (or put it back) without leaving review. **Purpose:** take back a change (or put it back) without leaving review.
**Steps:** use **Undo** / **Redo** in the sticky bar (next to *Contents*), or Ctrl+Z / Ctrl+Shift+Z (Ctrl+Y). The button tooltip names the change, e.g. "Undo: Deleted 2 regions". The last 20 recorded changes are kept per magazine: region edits and moves, drawing a region, approving/rejecting an article, page linking, auto-fix, reading-order changes, box clean-up and spelling corrections. **Steps:** use **Undo** / **Redo** in the sticky bar (at the left of its second row), or Ctrl+Z / Ctrl+Shift+Z (Ctrl+Y). The button tooltip names the change, e.g. "Undo: Deleted 2 regions". The last 20 recorded changes are kept per magazine: region edits and moves, drawing a region, approving/rejecting an article, page linking, auto-fix, reading-order changes, box clean-up and spelling corrections.
**Errors:** a button stays disabled when there is nothing to undo/redo, or when something else has changed the magazine since (for example a whole-magazine run), because undoing then would roll that work back too. A new change ends the redo history. **Errors:** a button stays disabled when there is nothing to undo/redo, or when something else has changed the magazine since (for example a whole-magazine run), because undoing then would roll that work back too. A new change ends the redo history.
**Privacy:** the saved states live in the magazine's own `undo/` folder; nothing is sent anywhere. **Privacy:** the saved states live in the magazine's own `undo/` folder; nothing is sent anywhere.

View File

@ -2893,6 +2893,54 @@ def _is_rule_only(pixels, blob: Sequence[float], page_meta: dict[str, Any]) -> b
return int((region.sum(axis=1) >= 3).sum()) <= _RULE_MAX_INK_ROWS return int((region.sum(axis=1) >= 3).sum()) <= _RULE_MAX_INK_ROWS
_LINE_MIN_LONG_PX, _LINE_MAX_THIN_PX = 45, 8 # a thin, long connected shape is a rule
_FRAME_MIN_LONG_PX, _FRAME_MAX_FILL = 90, 0.2 # a big, mostly hollow shape is a frame or a scribble
_GLYPH_MIN_PX, _GLYPH_MAX_PX, _GLYPH_MIN_INK = 4, 80, 6
_MIN_GLYPHS, _MIN_GLYPH_SHARE = 6, 0.25
_PICTURE_MIN_SIDE_PX, _PICTURE_MIN_FILL = 20, 0.5
def _is_lines_or_frames_only(pixels, blob: Sequence[float], page_meta: dict[str, Any]) -> bool:
"""True when the ink inside `blob` is rules, frames, chart axes or a signature rather than text.
Text is many small separate marks (letters) sitting in rows. Rules, table and chart lines,
screenshot outlines and a signature are a few large connected shapes: long and thin, or big and
mostly hollow. The blob is dropped when fewer than six letter-sized marks are left, or they hold
under a quarter of the ink. Framed *text* is kept: its letters are many small marks.
"""
try:
import numpy as np
from scipy import ndimage
except Exception:
return False
height, width = pixels.shape
sx, sy = width / max(1.0, float(page_meta["width"])), height / max(1.0, float(page_meta["height"]))
x, y, w, h = blob
region = pixels[max(0, int(y * sy)):int((y + h) * sy) + 1, max(0, int(x * sx)):int((x + w) * sx) + 1]
if region.size == 0:
return False
labels, count = ndimage.label(region, structure=np.ones((3, 3), dtype=int))
if count == 0:
return True
sizes = np.bincount(labels.ravel())[1:]
glyphs = glyph_ink = total = 0
for index, sl in enumerate(ndimage.find_objects(labels), 1):
ink = int(sizes[index - 1])
if ink < _GLYPH_MIN_INK:
continue
box_h, box_w = sl[0].stop - sl[0].start, sl[1].stop - sl[1].start
long_side, thin_side = max(box_h, box_w), min(box_h, box_w)
total += ink
if thin_side >= _PICTURE_MIN_SIDE_PX and ink / float(box_h * box_w) >= _PICTURE_MIN_FILL:
return False # a solid picture: the figure logic decides, not this filter
is_line = (long_side >= _LINE_MIN_LONG_PX and thin_side <= _LINE_MAX_THIN_PX) or (
long_side >= _FRAME_MIN_LONG_PX and ink / float(box_h * box_w) < _FRAME_MAX_FILL)
if not is_line and _GLYPH_MIN_PX <= long_side <= _GLYPH_MAX_PX:
glyphs += 1
glyph_ink += ink
return total == 0 or glyphs < _MIN_GLYPHS or glyph_ink / float(total) < _MIN_GLYPH_SHARE
def _clipped_edges(pixels, page_meta: dict[str, Any], text_blocks: list[dict[str, Any]], def _clipped_edges(pixels, page_meta: dict[str, Any], text_blocks: list[dict[str, Any]],
boxes: list[Sequence[float]]) -> list[dict[str, Any]]: boxes: list[Sequence[float]]) -> list[dict[str, Any]]:
"""Text boxes whose edge cuts through text: ink in a thin strip just outside a side, ignoring """Text boxes whose edge cuts through text: ink in a thin strip just outside a side, ignoring
@ -3740,7 +3788,7 @@ def audit_page_coverage(doc_dir: Path, layout: dict[str, Any], page_no: int) ->
# graphic, not narratable text a box forgot: a blob in or beside one is not reported. # graphic, not narratable text a box forgot: a blob in or beside one is not reported.
if any(bbox_overlaps(blob, g, tol=_AUDIT_GRAPHIC_MARGIN) for g in graphic_boxes): if any(bbox_overlaps(blob, g, tol=_AUDIT_GRAPHIC_MARGIN) for g in graphic_boxes):
continue continue
if pixels is not None and _is_rule_only(pixels, blob, page_meta): if pixels is not None and (_is_rule_only(pixels, blob, page_meta) or _is_lines_or_frames_only(pixels, blob, page_meta)):
continue # a printed rule or dotted leader, not letters continue # a printed rule or dotted leader, not letters
kept_cells += len(rows) kept_cells += len(rows)
report["uncovered"].append(blob) report["uncovered"].append(blob)

View File

@ -1616,13 +1616,6 @@ function renderReview() {
// Back to the contents lives in the sticky bar, so it stays in reach while scrolling a long page. // Back to the contents lives in the sticky bar, so it stays in reach while scrolling a long page.
'<button type="button" class="btn-sm" id="mag-review-contents-btn" onclick="magBackToContents()" title="Back to the contents">' + '<button type="button" class="btn-sm" id="mag-review-contents-btn" onclick="magBackToContents()" title="Back to the contents">' +
'<span class="mdi mdi-arrow-left" aria-hidden="true"></span> Contents</button>' + '<span class="mdi mdi-arrow-left" aria-hidden="true"></span> Contents</button>' +
'<div class="mag-review-pagenav-group" role="group" aria-label="Undo and redo">' +
'<button type="button" class="btn-sm" id="mag-undo-btn" onclick="magHistoryStep(\'undo\')" aria-keyshortcuts="Control+Z" ' +
(state.history?.can_undo ? '' : 'disabled ') + 'title="' + esc(state.history?.can_undo ? 'Undo: ' + state.history.undo_label + ' (Ctrl+Z)' : 'Nothing to undo') + '">' +
'<span class="mdi mdi-undo" aria-hidden="true"></span> Undo</button>' +
'<button type="button" class="btn-sm" id="mag-redo-btn" onclick="magHistoryStep(\'redo\')" aria-keyshortcuts="Control+Shift+Z Control+Y" ' +
(state.history?.can_redo ? '' : 'disabled ') + 'title="' + esc(state.history?.can_redo ? 'Redo: ' + state.history.redo_label + ' (Ctrl+Shift+Z)' : 'Nothing to redo') + '">' +
'<span class="mdi mdi-redo" aria-hidden="true"></span> Redo</button></div>' +
// One page stepper for the whole magazine, not a separate one bounded // One page stepper for the whole magazine, not a separate one bounded
// to just this article's own pages — the two almost always agreed and // to just this article's own pages — the two almost always agreed and
// having both doubled the toolbar's width for no real benefit. // having both doubled the toolbar's width for no real benefit.
@ -1653,7 +1646,15 @@ function renderReview() {
'<button type="button" class="btn-sm" onclick="magReviewClearCurrentPage()"><span class="mdi mdi-checkbox-multiple-blank-outline" aria-hidden="true"></span> Clear all fields</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>' +
'<button type="button" class="btn-sm" onclick="magReviewRemoveAllBoxes()" title="Delete every detected box on this page, so you can draw the layout from scratch">' + '<button type="button" class="btn-sm" onclick="magReviewRemoveAllBoxes()" title="Delete every detected box on this page, so you can draw the layout from scratch">' +
'<span class="mdi mdi-eraser" aria-hidden="true"></span> Remove all boxes</button></div>' + '<span class="mdi mdi-eraser" aria-hidden="true"></span> Remove all boxes</button></div>' +
'<span class="mag-review-pagenav-spacer"></span>' + // Undo / Redo: magPlaceUndoRedo() moves this group to the start of the bar's second row.
'<div class="mag-review-pagenav-group" id="mag-undo-group" role="group" aria-label="Undo and redo">' +
'<button type="button" class="btn-sm" id="mag-undo-btn" onclick="magHistoryStep(\'undo\')" aria-keyshortcuts="Control+Z" ' +
(state.history?.can_undo ? '' : 'disabled ') + 'title="' + esc(state.history?.can_undo ? 'Undo: ' + state.history.undo_label + ' (Ctrl+Z)' : 'Nothing to undo') + '">' +
'<span class="mdi mdi-undo" aria-hidden="true"></span> Undo</button>' +
'<button type="button" class="btn-sm" id="mag-redo-btn" onclick="magHistoryStep(\'redo\')" aria-keyshortcuts="Control+Shift+Z Control+Y" ' +
(state.history?.can_redo ? '' : 'disabled ') + 'title="' + esc(state.history?.can_redo ? 'Redo: ' + state.history.redo_label + ' (Ctrl+Shift+Z)' : 'Nothing to redo') + '">' +
'<span class="mdi mdi-redo" aria-hidden="true"></span> Redo</button></div>' +
'<button type="button" class="btn-sm" id="mag-reading-order-btn" onclick="magFixReadingOrder()" ' + '<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." >' + '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>' + '<span class="mdi mdi-sort-numeric-ascending" aria-hidden="true"></span> Fix reading order</button>' +
@ -1756,6 +1757,7 @@ function renderReview() {
magAuditLoad(); magAuditLoad();
magFlowLoad(); magFlowLoad();
magHistoryRefresh(); magHistoryRefresh();
magPlaceUndoRedo();
} }
// A Headline or Author region belonging to this article is exactly what its // A Headline or Author region belonging to this article is exactly what its
@ -2155,6 +2157,38 @@ async function magBulk(action, type) {
await magReloadReview(); await magReloadReview();
} }
// Undo / Redo belong at the left end of the bar's SECOND row, wherever the rows happen to break at
// this window width, so the group is placed by measuring: take it out, find the first control that
// sits below the first row, put the group in front of it (or at the end if the bar is one row).
function magPlaceUndoRedo() {
const bar = document.querySelector('#mag-review .mag-review-pagenav');
const group = $('mag-undo-group');
if (!bar || !group) return;
const place = () => {
group.remove();
bar.querySelectorAll('.mag-pagenav-break').forEach((el) => el.remove());
const items = Array.from(bar.children).filter((el) => !el.classList.contains('mag-review-pagenav-spacer'));
const firstTop = items.length ? items[0].offsetTop : 0;
const secondRowStart = items.find((el) => el.offsetTop > firstTop + 8);
if (!secondRowStart) { bar.appendChild(group); return; } // one row only: keep it at the end
// A forced line break in front of the group, so it opens the second row instead of sliding
// back into free space at the end of the first.
const lineBreak = document.createElement('span');
lineBreak.className = 'mag-pagenav-break';
lineBreak.setAttribute('aria-hidden', 'true');
bar.insertBefore(lineBreak, secondRowStart);
bar.insertBefore(group, secondRowStart);
};
place();
if (!bar._magUndoObserver && typeof ResizeObserver === 'function') {
let width = bar.clientWidth;
bar._magUndoObserver = new ResizeObserver(() => {
if (bar.clientWidth !== width) { width = bar.clientWidth; place(); }
});
bar._magUndoObserver.observe(bar);
}
}
// ── Undo / redo (server keeps the last 20 recorded changes) ─────────────────── // ── Undo / redo (server keeps the last 20 recorded changes) ───────────────────
function magHistoryPaint() { function magHistoryPaint() {
const h = state.history || {}; const h = state.history || {};
@ -2783,13 +2817,40 @@ function magReviewOpenDrawPanel(box) {
'<button type="button" class="btn-sm primary" onclick="magReviewSubmitDraw()">' + '<button type="button" class="btn-sm primary" onclick="magReviewSubmitDraw()">' +
'<span class="mdi mdi-plus" aria-hidden="true"></span> Add region</button>' + '<span class="mdi mdi-plus" aria-hidden="true"></span> Add region</button>' +
'</div>'; '</div>';
wrap.appendChild(panel); wrap.appendChild(panel);
const top = parseFloat(box.style.top) || 0, left = parseFloat(box.style.left) || 0, height = parseFloat(box.style.height) || 0; magPlaceDrawPanel(panel, box);
panel.style.top = Math.min(85, top + height + 1) + '%'; // The panel is placed in the window, not in the page image, so it follows the rectangle when the
panel.style.left = Math.min(60, left) + '%'; // page scrolls or the window changes size, and goes away with its panel.
const follow = () => {
if (!panel.isConnected) {
window.removeEventListener('scroll', follow, true);
window.removeEventListener('resize', follow);
return;
}
magPlaceDrawPanel(panel, box);
};
window.addEventListener('scroll', follow, true);
window.addEventListener('resize', follow);
if ($('mag-draw-kind-picker')) _magSetupDrawKindPicker(drawKindOptions); if ($('mag-draw-kind-picker')) _magSetupDrawKindPicker(drawKindOptions);
} }
// Below the drawn rectangle when it fits, above it when not, and always fully inside the window
// (a rectangle drawn near the bottom of the page used to push the panel past the edge, cutting off
// the Add region button).
function magPlaceDrawPanel(panel, box) {
const margin = 8;
const r = box.getBoundingClientRect();
const width = Math.min(panel.offsetWidth, window.innerWidth - 2 * margin);
const height = Math.min(panel.offsetHeight, window.innerHeight - 2 * margin);
let top = r.bottom + 6;
if (top + height > window.innerHeight - margin) {
const above = r.top - height - 6;
top = above >= margin ? above : Math.max(margin, window.innerHeight - height - margin);
}
panel.style.top = Math.round(top) + 'px';
panel.style.left = Math.round(Math.min(Math.max(margin, r.left), window.innerWidth - width - margin)) + 'px';
}
// A 16-option <select> for something picked constantly deserved faster // A 16-option <select> for something picked constantly deserved faster
// access than scrolling a native dropdown -- type to filter, arrow keys or // access than scrolling a native dropdown -- type to filter, arrow keys or
// click to choose, icon per type so the list scans visually instead of by // click to choose, icon per type so the list scans visually instead of by

View File

@ -5125,7 +5125,7 @@ input[list="lang-codes"] {
.mag-draw-rect { border: 2px dashed var(--green); background: rgba(22,163,74,.14); cursor: crosshair; .mag-draw-rect { border: 2px dashed var(--green); background: rgba(22,163,74,.14); cursor: crosshair;
transition: none; z-index: 3; } transition: none; z-index: 3; }
#mag-draw-toggle.active { background: var(--accent); color: var(--on-accent); border-color: var(--accent); } #mag-draw-toggle.active { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.mag-draw-panel { position: absolute; z-index: 4; display: flex; flex-direction: column; gap: 6px; .mag-draw-panel { position: fixed; z-index: 60; max-width: calc(100vw - 16px); display: flex; flex-direction: column; gap: 6px;
padding: 8px; min-width: 220px; background: var(--panel); border: 1px solid var(--border); padding: 8px; min-width: 220px; background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: 0 4px 16px rgba(0,0,0,.25); } border-radius: var(--radius); box-shadow: 0 4px 16px rgba(0,0,0,.25); }
.mag-draw-panel-row { display: flex; gap: 10px; align-items: center; justify-content: space-between; } .mag-draw-panel-row { display: flex; gap: 10px; align-items: center; justify-content: space-between; }
@ -5536,3 +5536,5 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
.mag-card-thumb-btn { display: block; width: 100%; padding: 0; margin: 0; border: 0; background: none; cursor: pointer; border-radius: 6px; } .mag-card-thumb-btn { display: block; width: 100%; padding: 0; margin: 0; border: 0; background: none; cursor: pointer; border-radius: 6px; }
.mag-card-thumb-btn:hover .mag-card-thumb, .mag-card-thumb-btn:focus-visible .mag-card-thumb { outline: 3px solid var(--accent); outline-offset: 2px; } .mag-card-thumb-btn:hover .mag-card-thumb, .mag-card-thumb-btn:focus-visible .mag-card-thumb { outline: 3px solid var(--accent); outline-offset: 2px; }
.mag-card-thumb-btn:focus-visible { outline: none; } .mag-card-thumb-btn:focus-visible { outline: none; }
.mag-pagenav-break { flex-basis: 100%; height: 0; margin: 0; padding: 0; }

View File

@ -40,6 +40,21 @@ function silentWav(ms = 50) {
return buf; return buf;
} }
// The review toolbar is sticky and several rows tall: scroll so the page image starts just below it,
// as a person would, otherwise a mouse press meant for the image lands on the toolbar.
async function scrollPageImageBelowToolbar(page, width) {
await page.locator('#mag-review-page-wrap').evaluate((el, w) => {
if (w) el.style.width = w + 'px';
let pane = el.parentElement; // the app scrolls inside a content pane, not the window
while (pane && !(pane.scrollHeight > pane.clientHeight + 1 && /(auto|scroll)/.test(getComputedStyle(pane).overflowY))) pane = pane.parentElement;
pane = pane || document.scrollingElement;
const bar = document.querySelector('#mag-review .mag-review-pagenav');
// a phone lays the toolbar out in normal flow (not sticky): nothing to leave room for
const barBottom = bar && getComputedStyle(bar).position === 'sticky' ? bar.getBoundingClientRect().bottom : 0;
pane.scrollTop += el.getBoundingClientRect().top - barBottom - 8;
}, width);
}
async function navTo(page, section) { async function navTo(page, section) {
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 }); await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await page.evaluate((s) => window.navTo(s), section); await page.evaluate((s) => window.navTo(s), section);
@ -493,12 +508,26 @@ test.describe('Magazine: undo and redo', () => {
}); });
} }
test('Undo and Redo sit right after Contents in the sticky bar and are disabled with nothing to do', async ({ page }) => { test('Undo and Redo lead the second row of the sticky bar, on the left, and are disabled with nothing to do', async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 });
await mockBackend(page); await mockBackend(page);
await openReview(page); await openReview(page);
const bar = page.locator('#mag-review .mag-review-pagenav'); const contents = await page.locator('#mag-review-contents-btn').boundingBox();
await expect(bar.locator('> *').nth(0)).toHaveId('mag-review-contents-btn'); const undo = await page.locator('#mag-undo-btn').boundingBox();
await expect(bar.locator('> *').nth(1).getByRole('button')).toHaveText([/Undo/, /Redo/]); const redo = await page.locator('#mag-redo-btn').boundingBox();
const fixOrder = await page.locator('#mag-reading-order-btn').boundingBox();
const rowsAbove = await page.evaluate(() => {
const bar = document.querySelector('#mag-review .mag-review-pagenav');
const tops = [...new Set([...bar.children].filter((c) => c.id !== 'mag-undo-group' && !c.classList.contains('mag-pagenav-break')).map((c) => Math.round(c.getBoundingClientRect().top / 4)))];
const undoTop = Math.round(document.getElementById('mag-undo-group').getBoundingClientRect().top / 4);
return tops.filter((t) => t < undoTop).length; // how many rows lie above the group
});
expect(rowsAbove).toBe(1); // exactly one row above: it is the second row
expect(undo.y).toBeGreaterThan(contents.y + 10);
expect(Math.abs(undo.x - contents.x)).toBeLessThan(4); // at the left edge of the bar
expect(redo.x).toBeGreaterThan(undo.x);
expect(Math.abs(redo.y - undo.y)).toBeLessThan(4);
expect(fixOrder.y).toBeGreaterThan(contents.y + 10);
await expect(page.locator('#mag-undo-btn')).toBeDisabled(); await expect(page.locator('#mag-undo-btn')).toBeDisabled();
await expect(page.locator('#mag-redo-btn')).toBeDisabled(); await expect(page.locator('#mag-redo-btn')).toBeDisabled();
await expect(page.locator('#mag-undo-btn')).toHaveAttribute('title', 'Nothing to undo'); await expect(page.locator('#mag-undo-btn')).toHaveAttribute('title', 'Nothing to undo');
@ -798,6 +827,37 @@ test('an article is linked to a page it was not connected to, from the review pa
await expect(page.locator('#mag-review .mag-review-pagenav-group strong')).toContainText('Page 4'); await expect(page.locator('#mag-review .mag-review-pagenav-group strong')).toContainText('Page 4');
}); });
test('the draw panel stays fully inside the window even for a box drawn at the bottom of the page', async ({ page }) => {
await page.setViewportSize({ width: 1300, height: 700 });
await mockBackend(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/ }).click();
await page.getByRole('button', { name: 'Draw region', exact: true }).click();
await scrollPageImageBelowToolbar(page, 300);
// now scroll further so the bottom of the page image is at the bottom edge of the window
await page.locator('#mag-review-page-wrap').evaluate(el => window.scrollBy(0, el.getBoundingClientRect().bottom - window.innerHeight + 4));
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
const footerTop = await page.locator('.mag-review-foot').evaluate(el => el.getBoundingClientRect().top);
const bottom = Math.min(wrap.y + wrap.height, footerTop) - 6; // lowest point of the image not under the footer
await page.mouse.move(wrap.x + 40, bottom - 60);
await page.mouse.down();
await page.mouse.move(wrap.x + 240, bottom, { steps: 5 });
await page.mouse.up();
const panel = page.locator('.mag-draw-panel');
await expect(panel).toBeVisible();
const box = await panel.boundingBox();
expect(box.y).toBeGreaterThanOrEqual(0);
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.y + box.height).toBeLessThanOrEqual(700);
expect(box.x + box.width).toBeLessThanOrEqual(1300);
await expect(page.getByRole('button', { name: 'Add region', exact: true })).toBeInViewport();
await page.getByRole('button', { name: 'Cancel', exact: true }).click(); // a real click: fails if something covers it
await expect(page.locator('.mag-draw-panel')).toHaveCount(0);
});
test('drawing a chart links the crop and excludes enclosed narration on approval', async ({ page }) => { test('drawing a chart links the crop and excludes enclosed narration on approval', async ({ page }) => {
await mockBackend(page); await mockBackend(page);
let cropped = false; let cropped = false;
@ -817,10 +877,7 @@ test('drawing a chart links the crop and excludes enclosed narration on approval
await page.locator('#mag-library-list .mag-card').first().click(); await page.locator('#mag-library-list .mag-card').first().click();
await page.getByRole('button', { name: /^Review/ }).click(); await page.getByRole('button', { name: /^Review/ }).click();
await page.getByRole('button', { name: 'Draw region', exact: true }).click(); await page.getByRole('button', { name: 'Draw region', exact: true }).click();
await page.locator('#mag-review-page-wrap').evaluate(el => { await scrollPageImageBelowToolbar(page, 400);
el.style.width = '400px';
el.scrollIntoView();
});
const wrap = await page.locator('#mag-review-page-wrap').boundingBox(); const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
await page.mouse.move(wrap.x + wrap.width * 40 / 595, wrap.y + wrap.height * 110 / 842); await page.mouse.move(wrap.x + wrap.width * 40 / 595, wrap.y + wrap.height * 110 / 842);
await page.mouse.down(); await page.mouse.down();
@ -1154,7 +1211,7 @@ test('select boxes by dragging a rectangle, then delete them in one go with undo
await openReview(page); await openReview(page);
await page.getByRole('button', { name: 'Select boxes', exact: true }).click(); await page.getByRole('button', { name: 'Select boxes', exact: true }).click();
await expect(page.locator('#mag-select-bar')).toContainText('drag a rectangle'); await expect(page.locator('#mag-select-bar')).toContainText('drag a rectangle');
await page.locator('#mag-review-page-wrap').evaluate(el => { el.style.width = '400px'; el.scrollIntoView(); }); await scrollPageImageBelowToolbar(page, 400);
const wrap = await page.locator('#mag-review-page-wrap').boundingBox(); const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
// b2 [50,120,200,14] and b3 [50,150,200,300] on a 595x842 page; b1 (the headline at y 60) stays out. // b2 [50,120,200,14] and b3 [50,150,200,300] on a 595x842 page; b1 (the headline at y 60) stays out.
const at = (x, y) => [wrap.x + wrap.width * x / 595, wrap.y + wrap.height * y / 842]; const at = (x, y) => [wrap.x + wrap.width * x / 595, wrap.y + wrap.height * y / 842];

View File

@ -86,6 +86,14 @@ def block(bid, page, x, y, w, h, text, size=10.0, btype=BLOCK_BODY):
text=text, font_size=size) text=text, font_size=size)
def _glyph_line(draw, x0, x1, y, height=7, fill=(93, 91, 92)):
"""A line of 'text' as a row of separate letter-sized marks (5 px wide, 3 px apart), which is
how real text differs from a solid rule or frame line."""
for x in range(x0, x1, 8):
draw.rectangle([x, y, min(x + 4, x1), y + height - 1], fill=fill)
class DropCapRepairTests(unittest.TestCase): class DropCapRepairTests(unittest.TestCase):
def test_repairs_split_ki_and_confirmed_short_drop_cap_words(self): def test_repairs_split_ki_and_confirmed_short_drop_cap_words(self):
from core.magazine import repair_drop_cap_omissions from core.magazine import repair_drop_cap_omissions
@ -2467,7 +2475,7 @@ class AuditPageCoverageTests(unittest.TestCase):
im = Image.new("RGB", (400, 400), "white") im = Image.new("RGB", (400, 400), "white")
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
for y in range(60, 340, 12): # a text column at x 100..250 for y in range(60, 340, 12): # a text column at x 100..250
draw.rectangle([100, y, 250, y + 6], fill="black") _glyph_line(draw, 100, 250, y, 7, "black")
im.save(d / "pages" / "0001.png") im.save(d / "pages" / "0001.png")
blocks = [{"id": f"b{i}", "page": 1, "bbox": list(b), "type": "body", "text": "Hallo Welt"} blocks = [{"id": f"b{i}", "page": 1, "bbox": list(b), "type": "body", "text": "Hallo Welt"}
for i, b in enumerate(boxes)] for i, b in enumerate(boxes)]
@ -3121,7 +3129,7 @@ class UncoveredPictureTests(unittest.TestCase):
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
draw.rectangle([100, 200, 300, 350], fill=(60, 90, 140)) # a photo: dense ink, 200x150 draw.rectangle([100, 200, 300, 350], fill=(60, 90, 140)) # a photo: dense ink, 200x150
for y in range(500, 560, 12): # text lines: thin, sparse for y in range(500, 560, 12): # text lines: thin, sparse
draw.rectangle([100, y, 300, y + 3], fill=(93, 91, 92)) _glyph_line(draw, 100, 300, y, 5) # ~25% ink, like real body text
im.save(self.dir / "pages" / "0001.png") im.save(self.dir / "pages" / "0001.png")
self.layout = {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [], "blocks": []} self.layout = {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [], "blocks": []}
@ -3157,9 +3165,9 @@ class UncoveredNearGraphicsTests(unittest.TestCase):
im = Image.new("RGB", (600, 800), (248, 242, 244)) im = Image.new("RGB", (600, 800), (248, 242, 244))
draw = ImageDraw.Draw(im) draw = ImageDraw.Draw(im)
for y in range(300, 360, 12): # text-like lines beside a chart for y in range(300, 360, 12): # text-like lines beside a chart
draw.rectangle([320, y, 460, y + 3], fill=(93, 91, 92)) _glyph_line(draw, 320, 460, y, 8)
for y in range(600, 660, 12): # the same lines, far from anything for y in range(600, 660, 12): # the same lines, far from anything
draw.rectangle([100, y, 240, y + 3], fill=(93, 91, 92)) _glyph_line(draw, 100, 240, y, 8)
im.save(self.dir / "pages" / "0001.png") im.save(self.dir / "pages" / "0001.png")
def tearDown(self): def tearDown(self):
@ -3297,7 +3305,7 @@ class LeaderLineIsNotMissedTextTests(unittest.TestCase):
def text(draw): def text(draw):
for y in range(300, 360, 12): for y in range(300, 360, 12):
draw.rectangle([100, y, 300, y + 8], fill=(93, 91, 92)) _glyph_line(draw, 100, 300, y, 9)
self.assertEqual(len(self.audit(text)), 1) self.assertEqual(len(self.audit(text)), 1)
@ -3426,3 +3434,41 @@ class ClippedEdgeNeedsTheBoxsOwnLinesTests(unittest.TestCase):
draw.rectangle([256, 116, 270, 118], fill=(93, 91, 92)) # ink between the box's own lines draw.rectangle([256, 116, 270, 118], fill=(93, 91, 92)) # ink between the box's own lines
draw.rectangle([256, 132, 270, 134], fill=(93, 91, 92)) draw.rectangle([256, 132, 270, 134], fill=(93, 91, 92))
self.assertEqual(self.audit(off_lines), []) self.assertEqual(self.audit(off_lines), [])
class LinesAndFramesAreNotMissedTextTests(unittest.TestCase):
def audit(self, draw_fn):
from pathlib import Path
from PIL import Image, ImageDraw
from core.magazine import audit_page_coverage
d = Path(tempfile.mkdtemp(prefix="ttvc-lines-"))
self.addCleanup(shutil.rmtree, d, True)
(d / "pages").mkdir()
im = Image.new("RGB", (600, 800), (248, 242, 244))
draw_fn(ImageDraw.Draw(im))
im.save(d / "pages" / "0001.png")
layout = {"pages": [{"page": 1, "width": 600, "height": 800}], "images": [], "blocks": []}
return audit_page_coverage(d, layout, 1)["uncovered"]
def test_frames_vertical_rules_and_signatures_are_not_text(self):
def frame(draw): # an empty screenshot/chat frame
draw.rectangle([100, 300, 300, 440], outline=(93, 91, 92), width=2)
draw.rounded_rectangle([120, 400, 280, 425], radius=10, outline=(93, 91, 92), width=2)
self.assertEqual(self.audit(frame), [])
def vertical_rule(draw):
draw.rectangle([200, 100, 203, 700], fill=(93, 91, 92))
self.assertEqual(self.audit(vertical_rule), [])
def signature(draw): # one long connected scribble
pts = [(100 + i * 4, 500 + (12 if (i // 3) % 2 else -12) + (i % 3) * 2) for i in range(45)]
draw.line(pts, fill=(30, 40, 90), width=3)
self.assertEqual(self.audit(signature), [])
def test_a_framed_paragraph_of_text_is_still_missed_text(self):
def framed(draw):
draw.rectangle([100, 300, 400, 420], outline=(93, 91, 92), width=2) # the panel's frame
for y in range(315, 410, 14): # its letters
for x in range(110, 390, 9):
draw.rectangle([x, y, x + 5, y + 8], fill=(93, 91, 92))
self.assertEqual(len(self.audit(framed)), 1)