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:
parent
0136842258
commit
63cb129d24
@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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
|
||||
- **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.
|
||||
|
||||
### 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.
|
||||
- **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.
|
||||
|
||||
@ -298,7 +298,7 @@ unreviewed boundaries may include a neighbouring article or an advert.
|
||||
### Undo and Redo
|
||||
|
||||
**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.
|
||||
**Privacy:** the saved states live in the magazine's own `undo/` folder; nothing is sent anywhere.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
_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]],
|
||||
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
|
||||
@ -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.
|
||||
if any(bbox_overlaps(blob, g, tol=_AUDIT_GRAPHIC_MARGIN) for g in graphic_boxes):
|
||||
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
|
||||
kept_cells += len(rows)
|
||||
report["uncovered"].append(blob)
|
||||
|
||||
@ -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.
|
||||
'<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>' +
|
||||
'<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
|
||||
// to just this article's own pages — the two almost always agreed and
|
||||
// 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="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="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()" ' +
|
||||
'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>' +
|
||||
@ -1756,6 +1757,7 @@ function renderReview() {
|
||||
magAuditLoad();
|
||||
magFlowLoad();
|
||||
magHistoryRefresh();
|
||||
magPlaceUndoRedo();
|
||||
}
|
||||
|
||||
// A Headline or Author region belonging to this article is exactly what its
|
||||
@ -2155,6 +2157,38 @@ async function magBulk(action, type) {
|
||||
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) ───────────────────
|
||||
function magHistoryPaint() {
|
||||
const h = state.history || {};
|
||||
@ -2784,12 +2818,39 @@ function magReviewOpenDrawPanel(box) {
|
||||
'<span class="mdi mdi-plus" aria-hidden="true"></span> Add region</button>' +
|
||||
'</div>';
|
||||
wrap.appendChild(panel);
|
||||
const top = parseFloat(box.style.top) || 0, left = parseFloat(box.style.left) || 0, height = parseFloat(box.style.height) || 0;
|
||||
panel.style.top = Math.min(85, top + height + 1) + '%';
|
||||
panel.style.left = Math.min(60, left) + '%';
|
||||
magPlaceDrawPanel(panel, box);
|
||||
// The panel is placed in the window, not in the page image, so it follows the rectangle when the
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
|
||||
@ -5125,7 +5125,7 @@ input[list="lang-codes"] {
|
||||
.mag-draw-rect { border: 2px dashed var(--green); background: rgba(22,163,74,.14); cursor: crosshair;
|
||||
transition: none; z-index: 3; }
|
||||
#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);
|
||||
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; }
|
||||
@ -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: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-pagenav-break { flex-basis: 100%; height: 0; margin: 0; padding: 0; }
|
||||
|
||||
@ -40,6 +40,21 @@ function silentWav(ms = 50) {
|
||||
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) {
|
||||
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||
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 openReview(page);
|
||||
const bar = page.locator('#mag-review .mag-review-pagenav');
|
||||
await expect(bar.locator('> *').nth(0)).toHaveId('mag-review-contents-btn');
|
||||
await expect(bar.locator('> *').nth(1).getByRole('button')).toHaveText([/Undo/, /Redo/]);
|
||||
const contents = await page.locator('#mag-review-contents-btn').boundingBox();
|
||||
const undo = await page.locator('#mag-undo-btn').boundingBox();
|
||||
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-redo-btn')).toBeDisabled();
|
||||
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');
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
await mockBackend(page);
|
||||
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.getByRole('button', { name: /^Review/ }).click();
|
||||
await page.getByRole('button', { name: 'Draw region', exact: true }).click();
|
||||
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();
|
||||
await page.mouse.move(wrap.x + wrap.width * 40 / 595, wrap.y + wrap.height * 110 / 842);
|
||||
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 page.getByRole('button', { name: 'Select boxes', exact: true }).click();
|
||||
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();
|
||||
// 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];
|
||||
|
||||
@ -86,6 +86,14 @@ def block(bid, page, x, y, w, h, text, size=10.0, btype=BLOCK_BODY):
|
||||
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):
|
||||
def test_repairs_split_ki_and_confirmed_short_drop_cap_words(self):
|
||||
from core.magazine import repair_drop_cap_omissions
|
||||
@ -2467,7 +2475,7 @@ class AuditPageCoverageTests(unittest.TestCase):
|
||||
im = Image.new("RGB", (400, 400), "white")
|
||||
draw = ImageDraw.Draw(im)
|
||||
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")
|
||||
blocks = [{"id": f"b{i}", "page": 1, "bbox": list(b), "type": "body", "text": "Hallo Welt"}
|
||||
for i, b in enumerate(boxes)]
|
||||
@ -3121,7 +3129,7 @@ class UncoveredPictureTests(unittest.TestCase):
|
||||
draw = ImageDraw.Draw(im)
|
||||
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
|
||||
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")
|
||||
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))
|
||||
draw = ImageDraw.Draw(im)
|
||||
for y in range(300, 360, 12): # text-like lines beside a chart
|
||||
draw.rectangle([320, y, 460, y + 3], fill=(93, 91, 92))
|
||||
_glyph_line(draw, 320, 460, y, 8)
|
||||
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")
|
||||
|
||||
def tearDown(self):
|
||||
@ -3297,7 +3305,7 @@ class LeaderLineIsNotMissedTextTests(unittest.TestCase):
|
||||
|
||||
def text(draw):
|
||||
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)
|
||||
|
||||
|
||||
@ -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, 132, 270, 134], fill=(93, 91, 92))
|
||||
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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user