Compare commits

...

5 Commits

Author SHA1 Message Date
81e2c6b4ef Release v1.56.1
Some checks are pending
Test and SonarQube / quality (push) Waiting to run
2026-09-30 12:49:30 +02:00
8da797751b fix(magazine): draw and edit panels are mounted on body so the sticky bars cannot cover them
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 12:48:27 +02:00
130fd273c6 fix(magazine): read a turned page through a ladder (turned, original with carried boxes, tesseract)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 12:31:44 +02:00
6fdf9fbc38 feat(magazine): turn a page scanned sideways or upside down and read it again
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 12:13:13 +02:00
119d3ba3ae fix(magazine): correct flagged words in place; whole-text editor keeps the paragraph's look and underlines
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 11:54:50 +02:00
11 changed files with 1245 additions and 42 deletions

View File

@ -7,6 +7,20 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
---
## [1.56.1] — 2026-09-30
### Fixed
- **The Draw region panel (and the "save new position" panel) could sit underneath the sticky Back / Approve bar and the status bar, cutting off Cancel / Add region.** The panel lived inside the page frame, which is in a sticky container; a sticky element is its own stacking context, so the panel's z-index only ranked it against the frame's content and the bars painted over it. Panels are now mounted on the page body (z-index 300: above the sticky bars, below dialogs), placed and followed as before, and cleaned up when the review redraws or is left. The region-type list opens upwards when there is no room below. The existing test only ever drew above the bar, so it could not see this; the new one draws where the panel lands over the bar and checks, with `elementFromPoint`, that the buttons are what a click would actually hit (verified to fail with the old layering).
- **Turning a page no longer fails when the OCR engine cannot read the page in its new orientation.** On a real page (c't p. 156) the vision engine read the sideways image well (word-F1 0.878) but returned an empty answer for the turned page, so the turn was rolled back with "The OCR engine returned nothing usable". A turn now reads through a ladder: the turned page with the vision engines, then the page as scanned with its boxes carried into the turned page, then tesseract; the first plausible reading wins (on that page: the second, 13 regions, 168 words, F1 0.878), a picture page with no text can still be turned, and only if nothing can read it is the page put back.
### Added
- **Turn a page that was scanned sideways or upside down.** *Turn left*, *Turn right* and *Turn 180°* in the review toolbar turn the page image, carry its boxes along so reviewed articles still match, and read the page again (`POST /api/magazine/{id}/pages/{page}/rotate`). The turn is remembered in the magazine's metadata and applied whenever pages are rendered, so re-importing does not undo it. If the re-read fails, the page goes back exactly as it was. Not covered by Undo (it changes the image): the opposite button turns it back. Measured on a real page scanned sideways: word-F1 0.016 as scanned, 0.876 after turning and re-reading. Page images now revalidate (`Cache-Control: no-cache`) so a turned page shows at once.
### Changed
- **Fixing flagged words in the review list no longer restyles the paragraph or hides the underlines.** Clicking an underlined word now edits just that word in place (a small field in the paragraph's own type, suggestion chips, Enter applies, Esc cancels, *Keep* for names), through the same checked one-word fix as the spelling list. Clicking elsewhere in the text opens the whole-text editor, which now keeps the paragraph's font size, line height, width and wrapping (it was 13 px in a full-row box against 14 px text in a narrower column), grows with its text instead of scrolling in a small box, and keeps the doubtful words underlined while you type. On a phone the row text and editors are both 16 px (what iOS needs to avoid zooming in).
---
## [1.56.0] — 2026-09-30
@ -4602,7 +4616,8 @@ Initial feature-complete release.
---
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.56.0...HEAD
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.56.1...HEAD
[1.56.1]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.56.0...v1.56.1
[1.56.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.55.0...v1.56.0
[1.55.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.54.0...v1.55.0
[1.54.0]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.53.4...v1.54.0

View File

@ -393,11 +393,47 @@ reads the text directly; the overlay is an addition, not a replacement.
**Offline.** The file is self-contained. **Privacy.** It contains the magazine's text and your generated audio, so share it only
where the magazine's licence allows.
### A page that was scanned sideways or upside down
**Purpose.** Some scans arrive turned: the page looks sideways, and what OCR read from it is gibberish.
**Prerequisites.** A vision OCR engine set up in Settings (the same one *OCR again* uses), because turning re-reads the page.
**Steps.** Open the page in Review and press **Turn right** (90° clockwise), **Turn left** (90° counter-clockwise) or **Turn 180°**.
The app asks first, then turns the page image, reads the page again, and shows the result. Tick **two-page spread** to choose which
of the two pages the buttons act on, as with the other page tools.
**Expected result.** The page stands upright, its boxes and text are new readings of the upright page, and the page's quality findings
are re-measured. On a test page, reading it sideways scored word-F1 0.02 and reading it after turning 0.88 (an ordinary page scores
about 0.88). The turn is remembered: re-running detection or re-importing does not flip the page back.
**What is replaced.** The page's boxes and text are replaced by the new reading, so corrections you made to that page by hand are lost, and
articles that used the page go back to "not approved" where the new boxes do not line up with the old ones. **Undo does not cover a turn**
(it changes the page image); if you turned the wrong way, press the opposite button.
**How the page is read after a turn.** An OCR engine can read a page well in one orientation and return nothing in another (on a real
page it read the sideways image at word-F1 0.88 and gave an empty answer for the same page turned upright). So after a turn the app tries, in
order: the turned page with your vision engines; then the page as it was scanned, with the boxes carried over into the turned page; then tesseract
on the turned page. The first plausible reading wins, and the finished message says which was used. A picture page with no text can be turned too.
**Errors.** Without a vision OCR engine nothing is changed and you are told why. If neither the engines nor tesseract can read the page, the page image is
put back exactly as it was and nothing is saved. A page that has a real text layer in the PDF is read from its image after a turn, because the
PDF's own text positions belong to the unturned page.
**Mobile / accessibility.** The three buttons are real buttons in a labelled group, each with its direction in words, and the confirmation is a dialog you can answer with the keyboard.
**Privacy.** The page image goes to the OCR engine you configured, exactly as for *OCR again*.
### Editing recognised text, and seeing what you changed
**Purpose.** Fix what OCR got wrong and always be able to see, and undo, what you changed.
**Steps.** In Review, click a region's text (or its pencil) and press *Save text*. A region you edited shows an
**Fix a flagged word where it stands.** Words the spelling check doubts are underlined in the region's text. Click one (or Tab to it
and press Enter): only that word turns into a small field, in the same type as the paragraph, so nothing around it moves or
changes. Type the correction and press **Enter**, or click one of the suggested words; **Esc** puts the word back; **Keep**
stops flagging a name or technical term in this issue. Each fix can be undone with Undo, like every other correction.
**Steps.** In Review, click a region's text away from an underlined word (or its pencil) to edit the whole text in place: it keeps the paragraph's type, width and line breaks, grows with what you type, and the doubtful words stay underlined (an underline goes as soon as the word is changed). Press *Save text*. A region you edited shows an
**Edited** mark. *Show changes* opens a word-by-word comparison with the text as first recognised: words removed are
struck through and marked with a minus, words added are underlined and marked with a plus (never colour alone).
*Restore recognised text* puts the original back. Typing the original back yourself also clears the mark.

View File

@ -1 +1 @@
1.56.0
1.56.1

View File

@ -477,6 +477,29 @@ Verified end-to-end against a synthetic 8-page issue (contents page, two-column
feature with a byline and a page-8 jump, full-page advert, second feature,
reviews) and against an image-only rendering of the same file to exercise OCR.
## Turning a page scanned sideways
`POST /api/magazine/{id}/pages/{page}/rotate {degrees: 90|180|270|-90}` starts the same background job as "OCR again"
(`_reocr_page_sync`) with a turn in front of it. The job turns the rendered page PNG (`rotate_page_image`, clockwise, via a temporary
file), carries the page's existing boxes and figure boxes to their new places (`layout_with_rotated_page` / `rotate_bbox`, which also swaps
the page's width and height for a quarter turn and accumulates `rotation` in the page's layout entry), and re-reads the page. Carrying the old
boxes matters: `remap_reviewed_page` decides which article a new region joins by overlap with the old ones, which would find none if the old
boxes were still in the sideways geometry. If the read fails, raises or returns nothing, the original PNG bytes are written back and neither
layout, articles nor metadata are saved. On success the total turn per page is stored in `meta.json` `page_rotations` (four quarter turns delete
the entry); `extract_pages(page_rotations=...)` applies it with `page.set_rotation` whenever a page is rendered, so re-ingesting does not flip the
page back, and on a turned page the PDF's own text and embedded-image boxes (unturned coordinates) are ignored in favour of OCR of the render.
The re-read of a turned page goes through `read_turned_page_regions`, a ladder, because a vision engine can read one orientation and return
nothing in another: measured on a real page (c't 156, Baidu Unlimited-OCR) the sideways image read at word-F1 0.878 while the upright image
returned an empty reply in every mode and size tried (base/tiny/gundam, 1.5x upscale; padding to a square hit the repetition-loop guard).
Rungs: the turned page with the vision chain; the original page bytes read with the vision chain and every region box carried across with
`rotate_bbox` ("vision-original", chosen on that page: 13 regions, 168 words, F1 0.878); tesseract on the turned page. A reading wins if it is at
least 40 characters and `ocr_quality_score` >= 0.55; otherwise the longest non-empty one is used; "empty" (tesseract ran, saw nothing) lets a picture
page be turned; nothing at all raises and rolls the image back. `reocr_page` takes the regions via its new `regions` argument. The job result carries
`read_with`. A turn takes no undo snapshot: Undo restores layout and articles but not the image, which would put old-orientation boxes on the turned page. The page
image endpoint now answers `Cache-Control: no-cache` so a turned page shows at once. Measured on a real page scanned sideways (Baidu Unlimited-OCR):
word-F1 0.016 read as scanned, 0.876 after a turn and re-read. Limitation: an automatic "this page looks sideways" hint would need tesseract's
orientation data (`osd.traineddata`), which the image does not ship.
## Import from page images
`POST /api/magazine/{id}/source-images` (multipart `files`, in the order sent) builds `source.pdf` with

View File

@ -2485,9 +2485,13 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110,
vision_ocr_fallback_model: str | None = None,
vision_ocr_timeout: float = 150.0,
force_vision_ocr: bool = False,
progress: Any = None) -> dict[str, Any]:
progress: Any = None,
page_rotations: dict[int, int] | None = None) -> dict[str, Any]:
"""Run the full extraction for one PDF, writing page renders and images.
`page_rotations` ({page: clockwise degrees}) are turns the reader applied to pages scanned sideways; a
rendered page is turned the same way whenever it is (re)rendered, so a re-import does not undo them.
When vision_ocr_url/model are given, a page whose text needs OCR (or
whose already-extracted text scores as mostly garbage, native or
tesseract alike -- see ocr_quality_score) is escalated to that engine
@ -2522,8 +2526,11 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110,
for pno in range(doc.page_count):
page = doc.load_page(pno)
rect = page.rect
page_no = pno + 1
extra_turn = int((page_rotations or {}).get(page_no, 0)) % 360
if extra_turn:
page.set_rotation((page.rotation + extra_turn) % 360)
rect = page.rect
png = pages_dir / f"{page_no:04d}.png"
if not png.exists():
@ -2532,7 +2539,9 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110,
image_boxes: list[list[float]] = []
page_images: list[dict[str, Any]] = []
for img_index, info in enumerate(page.get_images(full=True)):
# The PDF's own text and image boxes are in the page's unturned coordinates; on a page the reader
# turned, the render (and OCR of it) is the truth.
for img_index, info in enumerate([] if extra_turn else page.get_images(full=True)):
rects: list[list[float]] = []
try:
for r in page.get_image_rects(info[0]):
@ -2554,7 +2563,7 @@ def extract_pages(pdf_path: Path, out_dir: Path, render_dpi: int = 110,
})
blocks: list[Block] = []
data = page.get_text("dict")
data = {} if extra_turn else page.get_text("dict")
for bi, blk in enumerate(data.get("blocks", [])):
if blk.get("type") != 0: # 0 = text, 1 = image
continue
@ -2832,11 +2841,139 @@ def build_articles(layout: dict[str, Any]) -> list[dict[str, Any]]:
return out
# ── Turning a page that was scanned sideways or upside down ───────────────────
ROTATIONS = (90, 180, 270) # clockwise degrees a reader can ask for
def normalise_rotation(degrees: Any) -> int:
"""90, 180 or 270 (clockwise). -90 means 270; anything else is refused."""
try:
d = int(degrees) % 360
except (TypeError, ValueError):
raise ValueError("degrees must be 90, 180 or 270") from None
if d not in ROTATIONS:
raise ValueError("degrees must be 90, 180 or 270")
return d
def rotate_bbox(bbox: Sequence[float], width: float, height: float, degrees: int) -> list[float]:
"""Where a [x, y, w, h] box on a page of width x height lands after the page is turned clockwise."""
x, y, w, h = (float(v) for v in bbox)
d = normalise_rotation(degrees)
if d == 90:
return [height - (y + h), x, h, w]
if d == 180:
return [width - (x + w), height - (y + h), w, h]
return [y, width - (x + w), h, w] # 270
def rotate_page_image(png: Path, degrees: int) -> None:
"""Turn a rendered page clockwise in place (written to a temporary file first)."""
from PIL import Image
d = normalise_rotation(degrees)
op = {90: Image.Transpose.ROTATE_270, 180: Image.Transpose.ROTATE_180, 270: Image.Transpose.ROTATE_90}[d]
tmp = png.with_suffix(".rot.tmp")
try:
with Image.open(png) as im:
im.transpose(op).save(tmp, format="PNG")
tmp.replace(png)
finally:
tmp.unlink(missing_ok=True)
def layout_with_rotated_page(layout: dict[str, Any], page_no: int, degrees: int) -> dict[str, Any]:
"""The layout with one page turned: its size swapped for a quarter turn, `rotation` accumulated, and the
boxes already on it carried to where they are now, so whatever was approved still lines up with them."""
d = normalise_rotation(degrees)
meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None)
if meta is None:
raise ValueError("Page not found")
w, h = float(meta["width"]), float(meta["height"])
out = dict(layout)
out["pages"] = [
({**p, "width": h, "height": w, "rotation": (int(p.get("rotation") or 0) + d) % 360} if d != 180
else {**p, "rotation": (int(p.get("rotation") or 0) + d) % 360}) if p["page"] == page_no else p
for p in layout.get("pages", [])]
out["blocks"] = [{**b, "bbox": rotate_bbox(b["bbox"], w, h, d)} if b.get("page") == page_no else b
for b in layout.get("blocks", [])]
out["images"] = [{**i, "bbox": rotate_bbox(i["bbox"], w, h, d)} if i.get("page") == page_no and i.get("bbox") else i
for i in layout.get("images", [])]
return out
def _regions_text(regions: Sequence[dict[str, Any]] | None) -> str:
return " ".join(str(r.get("text") or "") for r in regions or []).strip()
def _usable_reading(regions: Sequence[dict[str, Any]] | None) -> bool:
text = _regions_text(regions)
return len(text) >= 40 and ocr_quality_score(text) >= 0.55
def read_turned_page_regions(png: Path, original_png: bytes, degrees: int,
engines: Sequence[tuple[str | None, str | None]],
lang: str = "deu+eng", timeout: float = 150.0
) -> tuple[list[dict[str, Any]] | None, str]:
"""Read a page that has just been turned, trying the ways that can work in order; returns (regions, how).
A vision engine is not equally good at every orientation: on a real page scanned sideways the engine read
the sideways image well (word-F1 0.88) and returned *nothing* for the same page turned upright, at every
size it was tried. So when a turn is asked for:
1. the turned page with the vision engines ("vision");
2. the page as it was, with the boxes carried across into the turned page ("vision-original") --
text read in the orientation the engine copes with, placed where it now is;
3. tesseract on the turned page ("tesseract").
The first reading that is long and plausible enough wins. If none is, the longest non-empty one is used
rather than giving up. `how` is "empty" when tesseract ran and found no text at all (a picture page): an
empty reading is then genuine, not a failure. (None, "failed") means nothing could read the page, so the
caller must put the page back.
"""
import shutil
import tempfile
from PIL import Image
d = normalise_rotation(degrees)
candidates: list[tuple[list[dict[str, Any]], str]] = []
turned = vision_ocr_page_chain(png, list(engines), timeout=timeout)
if _usable_reading(turned):
return turned, "vision"
if turned:
candidates.append((turned, "vision"))
with tempfile.TemporaryDirectory(prefix="turn-") as tmp:
original = Path(tmp) / "original.png"
original.write_bytes(original_png)
with Image.open(original) as im:
width_px, height_px = im.size
regions = vision_ocr_page_chain(original, list(engines), timeout=timeout)
if regions:
carried = [{**r, "bbox": rotate_bbox(r["bbox"], width_px, height_px, d)} for r in regions if r.get("bbox")]
if _usable_reading(carried):
return carried, "vision-original"
if carried:
candidates.append((carried, "vision-original"))
paragraphs = ocr_page(png, lang)
local = [{"label": "text", "bbox": list(p["bbox"]), "text": p["text"]} for p in paragraphs if p.get("text")]
if _usable_reading(local):
return local, "tesseract"
if local:
candidates.append((local, "tesseract"))
if candidates:
return max(candidates, key=lambda c: len(_regions_text(c[0])))
if shutil.which("tesseract"):
return [], "empty" # tesseract ran and saw no text: a picture page, not a failed read
return None, "failed"
def reocr_page(doc_dir: Path, layout: dict[str, Any], page_no: int,
vision_ocr_url: str, vision_ocr_model: str,
vision_ocr_fallback_url: str | None = None,
vision_ocr_fallback_model: str | None = None,
timeout: float = 150.0) -> dict[str, Any] | None:
timeout: float = 150.0,
regions: list[dict[str, Any]] | None = None) -> dict[str, Any] | None:
"""Force one page through the vision OCR chain again with the engine the
user picked, replacing only that page's blocks in an already-ingested
layout.
@ -2863,11 +3000,12 @@ def reocr_page(doc_dir: Path, layout: dict[str, Any], page_no: int,
if not page_meta:
return None
engines = [(vision_ocr_url, vision_ocr_model),
(vision_ocr_fallback_url, vision_ocr_fallback_model)]
regions = vision_ocr_page_chain(png, engines, timeout=timeout)
if not regions:
return None
if regions is None: # the caller did not read the page itself (a turned page is read by read_turned_page_regions)
engines = [(vision_ocr_url, vision_ocr_model),
(vision_ocr_fallback_url, vision_ocr_fallback_model)]
regions = vision_ocr_page_chain(png, engines, timeout=timeout)
if not regions:
return None
repair_drop_cap_omissions(png, regions)
scale_x = page_meta["width"] / max(1.0, _png_width(png, fitz))

View File

@ -314,6 +314,19 @@ def _render_ingest_preview_html(blocks: list, images: list | None, doc_id: str =
return "\n".join(parts)
def _page_rotations(meta: dict[str, Any]) -> dict[int, int]:
"""{page: clockwise degrees} the reader applied to pages scanned sideways (meta.json `page_rotations`)."""
out: dict[int, int] = {}
for k, v in (meta.get("page_rotations") or {}).items():
try:
page, deg = int(k), int(v) % 360
except (TypeError, ValueError):
continue
if page >= 1 and deg in mag.ROTATIONS:
out[page] = deg
return out
def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int,
vision_ocr_url: str | None, vision_ocr_model: str | None,
vision_ocr_fallback_url: str | None = None,
@ -346,7 +359,8 @@ def _ingest_sync(doc_id: str, job_id: str, ocr_lang: str, render_dpi: int,
vision_ocr_fallback_url=vision_ocr_fallback_url,
vision_ocr_fallback_model=vision_ocr_fallback_model,
force_vision_ocr=force_vision_ocr,
progress=progress)
progress=progress,
page_rotations=_page_rotations(_read_meta(d)))
page_sizes = {p["page"]: (p.get("width"), p.get("height")) for p in layout.get("pages", [])}
total_pages = len(page_sizes)
@ -774,7 +788,8 @@ async def magazine_page_image(doc_id: str, page: int):
png = d / "pages" / f"{page:04d}.png"
if not png.is_file():
raise HTTPException(404, "Page not rendered")
return FileResponse(png, media_type="image/png")
# "no-cache" = keep a copy but check it is still current (ETag): a page turned by the reader must show at once.
return FileResponse(png, media_type="image/png", headers={"Cache-Control": "no-cache"})
def _recognise_advertisements(layout: dict, settings: dict, page: int | None = None, on_page=None) -> None:
@ -954,19 +969,37 @@ def _recognise_semantic_layout(layout: dict, settings: dict, page: int | None =
def _reocr_page_sync(doc_id: str, job_id: str, page: int,
vision_ocr_url: str, vision_ocr_model: str,
vision_ocr_fallback_url: str | None,
vision_ocr_fallback_model: str | None) -> None:
vision_ocr_fallback_model: str | None,
rotate_degrees: int = 0) -> None:
""""OCR again" on one page with the engine the user picked. Restart-safe
job for the same reason ingestion is: a vision OCR call can take up to
~40s (confirmed live), too long for a plain request/response cycle.
"""
d = _LIB / doc_id
png = d / "pages" / f"{page:04d}.png"
original_png: bytes | None = None # kept while a turn is in flight, so a failed re-read can put the page back
try:
layout = mag.load_layout(d)
if not layout:
raise FileNotFoundError("No layout yet -- run ingestion first")
if rotate_degrees:
background_job_update(job_id, stage="rotate", detail=f"Turning page {page}")
original_png = png.read_bytes()
mag.rotate_page_image(png, rotate_degrees)
# The old boxes move with the page, so what was approved still lines up when the new ones are matched.
layout = mag.layout_with_rotated_page(layout, page, rotate_degrees)
background_job_update(job_id, stage="reocr", detail=f"Re-reading page {page}")
regions, how = None, ""
if rotate_degrees:
# A vision engine can read a page in one orientation and return nothing in another, so a turned
# page is read by the ladder in mag.read_turned_page_regions, not by the single-engine call.
regions, how = mag.read_turned_page_regions(
png, original_png, rotate_degrees,
[(vision_ocr_url, vision_ocr_model), (vision_ocr_fallback_url, vision_ocr_fallback_model)])
if regions is None:
raise RuntimeError("Neither the OCR engines nor tesseract could read the page")
updated = mag.reocr_page(d, layout, page, vision_ocr_url, vision_ocr_model,
vision_ocr_fallback_url, vision_ocr_fallback_model)
vision_ocr_fallback_url, vision_ocr_fallback_model, regions=regions)
if updated is None:
raise RuntimeError("The OCR engine returned nothing usable for this page")
background_job_update(job_id, stage="advertisements", detail="Recognising paragraphs, headings and advertisements")
@ -990,13 +1023,34 @@ def _reocr_page_sync(doc_id: str, job_id: str, page: int,
articles = mag.load_articles(d)
mag.remap_reviewed_page(articles, layout, updated, page)
_add_split_paragraphs_to_articles(articles, cleanup)
token = _snapshot_for_undo(d, f"Re-read page {page}")
# A turn rewrites the page image too, which an undo snapshot of layout + articles does not cover.
token = None if rotate_degrees else _snapshot_for_undo(d, f"Re-read page {page}")
mag.save_layout(d, updated)
mag.save_articles(d, articles)
_seal_undo(d, token)
if rotate_degrees:
meta = _read_meta(d)
turns = dict(meta.get("page_rotations") or {})
total = (int(turns.get(str(page), 0)) + rotate_degrees) % 360
if total:
turns[str(page)] = total
else:
turns.pop(str(page), None) # four quarter turns are no turn at all
meta["page_rotations"] = turns
_write_meta(d, meta)
_refresh_quality_rows(d, updated, [page]) # the old findings described the sideways page
original_png = None # saved: nothing to restore any more. Turning back is its own button.
else:
_seal_undo(d, token)
background_job_update(job_id, stage="done", done=True,
detail=f"Page {page} re-read", result={"page": page, "undo": token})
detail=f"Page {page} " + ("turned and re-read" if rotate_degrees else "re-read"),
result={"page": page, "undo": None if rotate_degrees else token,
"rotation": rotate_degrees, "read_with": how})
except Exception as exc: # noqa: BLE001 - surfaced to UI
if original_png is not None:
try:
png.write_bytes(original_png) # nothing was saved: the page goes back as it was
except OSError:
logging.exception("Could not restore page %s after a failed turn", page)
background_job_update(job_id, stage="failed", done=True, error=str(exc)[:1000])
@ -1039,6 +1093,38 @@ async def magazine_reocr_page(doc_id: str, page: int, request: Request):
return {"job_id": job_id}
@router.post("/api/magazine/{doc_id}/pages/{page}/rotate")
async def magazine_rotate_page(doc_id: str, page: int, request: Request):
"""Turn a page that was scanned sideways or upside down and read it again.
Body: {"degrees": 90 | 180 | 270 | -90} clockwise. The rendered page is turned, the page's old boxes
are carried with it so reviewed articles still match, and the page is re-read like "OCR again". The turn
is remembered (meta.json `page_rotations`) so re-importing does not undo it. Needs a vision OCR engine,
the same as "OCR again".
"""
d, _ = _require_magazine(doc_id)
if page < 1 or page > 10000:
raise HTTPException(400, "Bad page")
try:
body = await request.json()
degrees = mag.normalise_rotation(body.get("degrees"))
except (ValueError, AttributeError, TypeError) as exc:
raise HTTPException(400, str(exc) or "degrees must be 90, 180 or 270")
if not (d / "pages" / f"{page:04d}.png").is_file():
raise HTTPException(404, "Page not rendered -- run ingestion first")
layout = mag.load_layout(d)
if not layout or not any(p["page"] == page for p in layout.get("pages", [])):
raise HTTPException(404, "Page not found in the layout")
settings = _load_settings()
url, model, fb_url, fb_model = _vision_args(settings)
if not (url and model):
raise HTTPException(400, "No vision OCR engine configured -- set one in Settings (turning re-reads the page)")
job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-rotate-page", fingerprint=f"{doc_id}:{page}")
asyncio.create_task(asyncio.to_thread(_reocr_page_sync, doc_id, job_id, page, url, model, fb_url, fb_model, degrees))
return {"job_id": job_id, "degrees": degrees}
def _quality_page_sync(doc_id: str, job_id: str, page: int, mode: str,
vision_ocr_url: str | None = None,
vision_ocr_model: str | None = None,

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.56.0">
<meta name="app-version" content="1.56.1">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg">
@ -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.56.0">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.56.0">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.56.0">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.56.0">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.56.0">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.56.0">
<link rel="stylesheet" href="/static/style.css?v=1.56.1">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.56.1">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.56.1">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.56.1">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.56.1">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.56.1">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -403,7 +403,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.56.0"></script>
<script src="/static/loader.js?v=1.56.1"></script>
</body>
</html>

View File

@ -1927,6 +1927,7 @@ function renderReview() {
const host = $('mag-review');
const a = state.article;
if (!host || !a) return;
magClearFloatingPanels();
// A half-built click order belongs to the page it was started on.
if (state.orderMode && state.orderPage !== state.reviewPage) { state.orderMode = false; state.orderSeq = []; }
if (state.selectPage !== state.reviewPage) { state.selected = new Set(); state.selectPage = state.reviewPage; }
@ -1992,6 +1993,17 @@ function renderReview() {
'<button type="button" class="btn-sm" id="mag-refine-figures-btn" onclick="magRefineFigures()" ' +
'title="Re-check figure boundaries on this page only">' +
'<span class="mdi mdi-image-auto-adjust" aria-hidden="true"></span> Fix figure boundaries (this page)</button>' +
'<span class="mag-turn-group" role="group" aria-label="Turn this page">' +
'<button type="button" class="btn-sm" id="mag-turn-left" onclick="magRotatePage(-90)" ' +
'title="The page was scanned sideways: turn it 90° counter-clockwise and read it again">' +
'<span class="mdi mdi-rotate-left" aria-hidden="true"></span> Turn left</button>' +
'<button type="button" class="btn-sm" id="mag-turn-right" onclick="magRotatePage(90)" ' +
'title="The page was scanned sideways: turn it 90° clockwise and read it again">' +
'<span class="mdi mdi-rotate-right" aria-hidden="true"></span> Turn right</button>' +
'<button type="button" class="btn-sm" id="mag-turn-flip" onclick="magRotatePage(180)" ' +
'title="The page is upside down: turn it 180° and read it again">' +
'<span class="mdi mdi-flip-vertical" aria-hidden="true"></span> Turn 180°</button>' +
'</span>' +
'<button type="button" class="btn-sm" id="mag-reocr-btn" onclick="magReocrPage()">' +
'<span class="mdi mdi-text-recognition"></span> OCR again (this page)</button>' +
'<button type="button" class="btn-sm" id="mag-find-missing-btn" onclick="magRunPageQuality(\'missing\')" ' +
@ -2822,10 +2834,7 @@ function magReviewOpenEditPanel(blockId, box, orig) {
'<button type="button" class="btn-sm primary" onclick="magReviewSubmitEdit(true)"><span class="mdi mdi-text-recognition"></span> Save &amp; re-OCR</button>'
: '<button type="button" class="btn-sm primary" onclick="magReviewSubmitEdit(false)">Save</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(80, top + height + 1) + '%';
panel.style.left = Math.min(55, left) + '%';
magMountFloatingPanel(panel, box);
}
async function magReviewSubmitEdit(reocr) {
@ -3020,6 +3029,71 @@ async function magRowRestore(id) {
magReloadReview('Recognised text restored');
}
// ── Correct a flagged word where it stands ───────────────────────────────────
// The word becomes a small input set in the paragraph's own type (nothing around it reflows or restyles), with
// the dictionary's suggestions as one-click chips. Enter applies, Esc puts the word back. The fix goes through
// the same checked route as the spelling list (offsets re-verified server-side, one Undo).
function magWordEdit(blockId, start, end, ev) {
if (ev) { ev.preventDefault(); ev.stopPropagation(); }
const issues = state.spell?.issues || [];
const n = issues.findIndex((i) => i.block_id === blockId && i.start === start && i.end === end);
const row = document.querySelector('#mag-review [data-block-row="' + CSS.escape(blockId) + '"]');
const mark = row?.querySelector('mark[data-spell-start="' + Number(start) + '"]');
if (n < 0 || !mark || mark.querySelector('input')) return;
const issue = issues[n];
const original = issue.raw || issue.token;
const shown = mark.textContent;
mark.classList.add('is-editing');
mark.removeAttribute('role');
mark.innerHTML = '<input type="text" class="mag-word-input" maxlength="120" autocomplete="off" spellcheck="false" ' +
'aria-label="Correct the word ' + esc(issue.token) + '" value="' + esc(original) + '" style="width:' + Math.max(3, original.length + 1) + 'ch">' +
'<span class="mag-word-pop" role="group" aria-label="Corrections for ' + esc(issue.token) + '">' +
(issue.suggestions || []).slice(0, 5).map((sug, k) => '<button type="button" class="btn-sm" data-sug="' + k + '">' + esc(sug) + '</button>').join('') +
'<button type="button" class="btn-sm" data-keep title="This word is fine (a name or technical term): stop flagging it in this issue">Keep</button>' +
'<span class="mag-word-hint muted">Enter applies · Esc cancels</span></span>';
const input = mark.querySelector('input');
const restore = () => { mark.classList.remove('is-editing'); mark.setAttribute('role', 'button'); mark.textContent = shown; };
const apply = (value) => {
const v = String(value).replace(/\s+/g, ' ').trim();
if (!v) return say('Type the corrected word first', 'err');
if (v === original) return restore();
magSpellApply(n, v);
};
['click', 'keyup'].forEach((t) => mark.addEventListener(t, (e) => { if (mark.classList.contains('is-editing')) e.stopPropagation(); }));
input.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); apply(input.value); }
else if (e.key === 'Escape') { e.preventDefault(); restore(); }
});
input.addEventListener('input', () => { input.style.width = Math.max(3, input.value.length + 1) + 'ch'; });
mark.querySelector('.mag-word-pop').addEventListener('click', (e) => {
const btn = e.target.closest('button');
if (!btn) return;
e.preventDefault(); e.stopPropagation();
if (btn.dataset.keep !== undefined) return magSpellKeep(n);
apply(issue.suggestions[Number(btn.dataset.sug)]);
});
input.addEventListener('blur', () => setTimeout(() => {
if (mark.isConnected && mark.classList.contains('is-editing') && !mark.contains(document.activeElement)) restore();
}, 150));
input.focus();
input.select();
}
// The flagged words of a block as a regex over its *current* text, so underlines survive typing: a word stops
// being marked the moment it no longer matches, with no stale offsets.
function magMarkTokens(text, tokens) {
const words = [...new Set((tokens || []).filter(Boolean))].sort((a, b) => b.length - a.length);
if (!words.length) return esc(text);
const re = new RegExp('(?<![\\p{L}\\p{N}])(?:' + words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')(?![\\p{L}\\p{N}])', 'gu');
let out = '', pos = 0, m;
while ((m = re.exec(text))) {
out += esc(text.slice(pos, m.index)) + '<mark class="mag-spell">' + esc(m[0]) + '</mark>';
pos = m.index + m[0].length;
}
return out + esc(text.slice(pos));
}
async function magRowEdit(id) {
if (state.reloadPending) await state.reloadPending;
const block = (state.layout?.blocks || []).find((b) => b.id === id);
@ -3029,7 +3103,8 @@ async function magRowEdit(id) {
span.hidden = true;
const editor = document.createElement('span');
editor.className = 'mag-row-editor';
editor.innerHTML = '<textarea aria-label="Text of this region" rows="5"></textarea>' +
editor.innerHTML = '<span class="mag-hl-wrap"><span class="mag-hl-back" aria-hidden="true"></span>' +
'<textarea class="mag-hl-area" aria-label="Text of this region" rows="1" spellcheck="false"></textarea></span>' +
'<span class="mag-row-editor-actions">' +
'<button type="button" class="btn-sm btn-primary" data-act="save">Save text</button>' +
'<button type="button" class="btn-sm" data-act="split" title="Split this region into two where the cursor is">Split here</button>' +
@ -3037,6 +3112,16 @@ async function magRowEdit(id) {
span.after(editor);
const area = editor.querySelector('textarea');
area.value = block.text;
// Set in the paragraph's own type, growing with its text, with the flagged words still underlined while you type.
const back = editor.querySelector('.mag-hl-back');
const tokens = ((state.spellByBlock && state.spellByBlock.get(id)) || []).map((i) => i.raw || i.token);
const sync = () => {
back.innerHTML = magMarkTokens(area.value, tokens) + (area.value.endsWith('\n') ? '\n ' : '');
area.style.height = 'auto';
area.style.height = area.scrollHeight + 'px';
};
area.addEventListener('input', sync);
sync();
['click', 'keydown', 'keyup'].forEach((ev) => editor.addEventListener(ev, (e) => e.stopPropagation()));
area.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); magReloadReview(); } });
editor.addEventListener('click', async (e) => {
@ -3240,10 +3325,23 @@ function magReviewOpenDrawPanel(box) {
'<button type="button" class="btn-sm primary" onclick="magReviewSubmitDraw()">' +
'<span class="mdi mdi-plus" aria-hidden="true"></span> Add region</button>' +
'</div>';
wrap.appendChild(panel);
magMountFloatingPanel(panel, box);
if ($('mag-draw-kind-picker')) _magSetupDrawKindPicker(drawKindOptions);
}
// Panels mounted on <body> outlive the review list that opened them, so whatever redraws or leaves the review clears them.
function magClearFloatingPanels() {
document.querySelectorAll('.mag-draw-panel').forEach((n) => n.remove());
}
// The panels that belong to a rectangle on the page (draw, edit) are mounted on <body>, not inside the page frame.
// The frame sits in a sticky container, and a sticky element is its own stacking context: a panel inside it is ranked
// only against the frame's other content, so the sticky Back/Approve bar and the status bar painted over it and cut off
// its buttons however high its z-index was. On <body> the panel is ranked against the page, above those bars.
// It is placed in the window, follows the rectangle while the page scrolls or resizes, and goes away with its panel.
function magMountFloatingPanel(panel, box) {
document.body.appendChild(panel);
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);
@ -3254,7 +3352,6 @@ function magReviewOpenDrawPanel(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
@ -3313,17 +3410,26 @@ function _magSetupDrawKindPicker(options) {
_magDrawKindOnPick(value);
};
// Opens below the field, or above it when the window has more room there (a rectangle drawn at the foot of the page).
const place = () => {
const r = search.getBoundingClientRect();
const below = window.innerHeight - r.bottom - 12, above = r.top - 12;
const up = below < 200 && above > below;
list.classList.toggle('opens-up', up);
list.style.maxHeight = Math.max(110, Math.min(260, up ? above : below)) + 'px';
};
const open = () => {
renderList(search.value === (options.find(([v]) => v === hidden.value) || [])[1] ? '' : search.value);
list.hidden = false;
search.setAttribute('aria-expanded', 'true');
place();
};
// Start on the default (Paragraph) with its label showing, matching the
// hidden input's own default value set in the HTML above.
pick(hidden.value, (options.find(([v]) => v === hidden.value) || [])[1] || '');
search.addEventListener('focus', () => { search.value = ''; open(); });
search.addEventListener('input', () => { list.hidden = false; search.setAttribute('aria-expanded', 'true'); renderList(search.value); });
search.addEventListener('input', () => { list.hidden = false; search.setAttribute('aria-expanded', 'true'); renderList(search.value); place(); });
search.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { list.hidden = true; search.setAttribute('aria-expanded', 'false'); search.blur(); }
else if (e.key === 'Enter') {
@ -3512,6 +3618,58 @@ async function magReocrPage() {
renderReview();
}
// A page scanned sideways or upside down: turn the page itself, then read it again. The boxes and text
// produced from the wrong orientation are replaced, so this asks first; approvals carry over where the new
// regions line up with the old ones. Turning is not covered by Undo (it rewrites the page image), so the
// way back is the opposite turn.
const MAG_TURN_WORDS = { 90: 'clockwise', [-90]: 'counter-clockwise', 270: 'counter-clockwise', 180: 'half a turn' };
async function magRotatePage(degrees) {
const page = state.reviewPage;
if (page == null) return;
const what = degrees === 180 ? 'Turn page ' + page + ' upside down (180°)' : 'Turn page ' + page + ' 90° ' + MAG_TURN_WORDS[degrees];
const ok = await confirmDialog(
'The page image is turned and the page is read again, so the boxes and text now on it are replaced by new ones. ' +
'Any corrections you made to this page by hand are lost, and articles that used it are set back to "not approved". ' +
'This cannot be undone with Undo; turn it back with the opposite button if it was wrong.',
{ title: what + '?', okLabel: 'Turn and read again' });
if (!ok) return;
magOfferUndo(null); // an older Undo would put old-orientation boxes on the turned page
const buttons = ['mag-turn-left', 'mag-turn-right', 'mag-turn-flip', 'mag-reocr-btn'].map($).filter(Boolean);
const statusEl = $('mag-reocr-status');
const busy = (on, text) => { buttons.forEach((b) => { b.disabled = on; }); if (statusEl) statusEl.textContent = text || ''; };
busy(true, 'Turning page ' + page + '…');
let jobId;
try {
const r = await api('/api/magazine/' + state.doc.id + '/pages/' + page + '/rotate', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ degrees }),
});
jobId = r.job_id;
} catch (e) {
busy(false);
return say('Could not turn the page: ' + e.message, 'err');
}
for (;;) {
let job;
try { job = await api('/api/jobs/' + jobId); } catch (_) { break; }
if (job.done) {
if (job.error) { busy(false); return say('The page was left as it was: ' + job.error, 'err'); }
say('Page ' + page + ' turned and read again', 'ok');
break;
}
busy(true, job.detail || 'Turning page ' + page + '…');
await new Promise((r) => setTimeout(r, 2000));
}
busy(false);
state.layout = null;
await ensureLayout();
const articleId = state.article.id;
await magRefreshArticles();
state.article = state.articles.find((a) => a.id === articleId) || state.article;
state.quality = null; state.qualityDocId = null; // the old findings described the sideways page
_magReviewChecked = null;
renderReview();
}
async function magRunPageQuality(mode) {
magOfferUndo(null);
const page = state.reviewPage;
@ -4044,7 +4202,12 @@ function magSpellMarked(blockId, text) {
issues.filter((i) => i.end <= text.length).sort((x, y) => x.start - y.start).forEach((i) => {
if (i.start < pos) return;
const hint = 'Possible misspelling' + (i.suggestions.length ? ' — try: ' + i.suggestions.join(', ') : '');
out += esc(text.slice(pos, i.start)) + '<mark class="mag-spell" title="' + esc(hint) + '">' +
// In a review row a flagged word is a control: click it to correct just that word, in place.
const live = state.view === 'review' && i.block_id === blockId;
out += esc(text.slice(pos, i.start)) + '<mark class="mag-spell' + (live ? ' mag-spell-edit' : '') + '" title="' + esc(hint + (live ? ' — click to correct' : '')) + '"' +
(live ? ' role="button" tabindex="0" data-spell-start="' + Number(i.start) + '" data-spell-end="' + Number(i.end) + '"' +
' onclick="magWordEdit(\'' + esc(blockId) + '\', ' + Number(i.start) + ', ' + Number(i.end) + ', event)"' +
' onkeydown="if(event.key===\'Enter\'||event.key===\' \'){magWordEdit(\'' + esc(blockId) + '\', ' + Number(i.start) + ', ' + Number(i.end) + ', event);}"' : '') + '>' +
esc(text.slice(i.start, i.end)) + '</mark>';
pos = i.end;
});
@ -5217,6 +5380,7 @@ async function magPlayPodcast() {
function magShow(view) {
state.view = view;
if (view !== 'review') magClearFloatingPanels();
['library', 'contents', 'review', 'read'].forEach((v) => {
const el = $('mag-view-' + v);
if (el) el.hidden = v !== view;
@ -5630,6 +5794,9 @@ root.magReviewToggleAllPage = magReviewToggleAllPage;
root.magReviewClearCurrentPage = magReviewClearCurrentPage;
root.magReviewRemoveAllBoxes = magReviewRemoveAllBoxes;
root.magReocrPage = magReocrPage;
root.magMountFloatingPanel = magMountFloatingPanel;
root.magReviewOpenEditPanel = magReviewOpenEditPanel;
root.magRotatePage = magRotatePage;
root.magRefineFigures = magRefineFigures;
root.magRefineAllFigures = magRefineAllFigures;
root.magQualityRun = magQualityRun;
@ -5653,6 +5820,8 @@ root.magRunPageQuality = magRunPageQuality;
root.magFixReadingOrder = magFixReadingOrder;
root.magCleanUpBoxes = magCleanUpBoxes;
root.magRowEdit = magRowEdit;
root.magWordEdit = magWordEdit;
root.magMarkTokens = magMarkTokens;
root.magRowDiff = magRowDiff;
root.magRowRestore = magRowRestore;
root.magWordDiff = magWordDiff;

View File

@ -5128,7 +5128,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: fixed; z-index: 60; max-width: calc(100vw - 16px); display: flex; flex-direction: column; gap: 6px;
.mag-draw-panel { position: fixed; z-index: 300; /* mounted on <body>: above the sticky bars (30-100), below dialogs (1000+) */ 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; }
@ -5328,7 +5328,9 @@ input[list="lang-codes"] {
/* 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-block-text, .mag-block-thumb-wrap, .mag-row-editor { grid-column: 1 / -1; }
/* iOS zooms into any field under 16px, so on a phone the paragraph is 16px too: the editors then match it exactly. */
.mag-block-text { font-size: 16px; }
.mag-article { font-size: 18px; }
.mag-driving-controls { gap: 20px; }
.mag-driving-btn { width: 76px; height: 76px; font-size: 28px; }
@ -5366,7 +5368,7 @@ input[list="lang-codes"] {
.mag-row-actions { display: inline-flex; gap: 2px; grid-column: -2 / -1; justify-self: end; }
.mag-row-action { padding: 2px 6px; min-width: 28px; min-height: 28px; color: var(--subtext); }
.mag-row-action:hover, .mag-row-action:focus-visible { color: var(--text); }
.mag-row-editor { display: flex; flex-direction: column; gap: 6px; grid-column: 1 / -1; }
.mag-row-editor { display: flex; flex-direction: column; gap: 6px; min-width: 0; } /* the text's own column, so the wrapping does not change */
.mag-row-editor textarea { width: 100%; font: inherit; font-size: 13px; padding: 6px; border: 1px solid var(--border);
border-radius: var(--radius); background: var(--surface); color: var(--text); resize: vertical; }
.mag-row-editor-actions { display: flex; flex-wrap: wrap; gap: 6px; }
@ -5625,3 +5627,25 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
border: 1px solid var(--border); border-radius: var(--radius); overflow-wrap: anywhere; }
.mag-cat-form { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; align-items: end; }
.mag-cat-form label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
/* Editing a region's text in place: same type and wrapping as the displayed paragraph, flagged words still underlined */
.mag-hl-wrap { position: relative; display: block; font-size: 14px; line-height: 1.45; }
.mag-hl-back, .mag-hl-area { font: inherit; line-height: inherit; letter-spacing: inherit; white-space: pre-wrap; overflow-wrap: break-word;
word-break: normal; padding: 0; margin: 0; border: 0; width: 100%; box-sizing: border-box; }
.mag-hl-back { position: absolute; inset: 0; color: transparent; pointer-events: none; overflow: hidden; }
.mag-hl-back .mag-spell { color: transparent; }
.mag-row-editor textarea.mag-hl-area { position: relative; display: block; font: inherit; font-size: inherit; line-height: inherit; padding: 0; border: 0; width: 100%; background: transparent; color: var(--text); resize: none; overflow: hidden;
min-height: 1.45em; border-radius: 4px; outline: none; box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent), 0 0 0 5px var(--accent); }
/* One flagged word being corrected where it stands */
.mag-spell.is-editing { text-decoration: none; background: none; outline: none; position: relative; cursor: text; }
.mag-word-input { font: inherit; line-height: inherit; color: var(--text); background: var(--surface); padding: 0 2px; margin: 0; min-width: 3ch; max-width: 100%;
border: 0; border-radius: 3px; box-shadow: 0 0 0 2px var(--accent); }
.mag-word-pop { position: absolute; left: 0; top: 100%; z-index: 40; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; margin-top: 4px; padding: 6px;
min-width: 220px; max-width: min(420px, 80vw); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface);
box-shadow: 0 8px 24px rgba(0, 0, 0, .25); font-size: 13px; font-style: normal; line-height: 1.3; }
.mag-word-hint { flex: 1 1 100%; font-size: 12px; }
@media (max-width: 860px) { .mag-hl-wrap { font-size: 16px; } } /* after the desktop rule above, so it wins */
.mag-turn-group { display: inline-flex; gap: 4px; padding: 2px; border: 1px dashed var(--border); border-radius: var(--radius); }
.mag-type-picker-list.opens-up { top: auto; bottom: 100%; margin: 0 0 2px; }
.mag-type-picker-list { overflow-y: auto; }

View File

@ -1857,3 +1857,335 @@ test('a refused catalogue shows the server reason and keeps the form', async ({
await expect(page.locator('#mag-cat-status')).toContainText('Put the login in the username field');
await expect(page.locator('#mag-cat-url')).toHaveValue('http://me:pw@nas.local/opds');
});
test.describe('Correcting flagged words in the review list', () => {
const ISSUES = [
{ token: 'technology', raw: 'technology', block_id: 'b3', page: 3, start: 8, end: 18, language: 'en', suggestions: ['technologie', 'technologies'], context: 'Battery technology is entering' },
{ token: 'mature', raw: 'mature', block_id: 'b3', page: 3, start: 67, end: 73, language: 'en', suggestions: [], context: 'years to mature.' },
];
async function open(page) {
await mockBackend(page);
const posts = [];
await page.route(`**/api/magazine/${DOC_ID}/spelling/dictionary`, route => { posts.push({ keep: route.request().postDataJSON() }); return route.fulfill({ json: { ok: true } }); });
await page.route(`**/api/magazine/${DOC_ID}/spelling/fix`, route => { posts.push({ fix: route.request().postDataJSON() }); return route.fulfill({ json: { ok: true, undo: 'tok', text: 'x' } }); });
await page.route(`**/api/magazine/${DOC_ID}/spelling?**`, route => route.fulfill({ json: { issues: posts.some(p => p.fix || p.keep) ? [] : ISSUES, dictionary: {} } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).getByRole('button', { name: /^Review/ }).click();
await page.waitForSelector('#mag-undo-btn');
await expect(page.locator('[data-block-row="b3"] mark.mag-spell')).toHaveCount(2);
return posts;
}
const fontOf = (page, sel) => page.locator(sel).first().evaluate(el => { const c = getComputedStyle(el); return `${c.fontSize}/${c.lineHeight}/${c.fontFamily}`; });
test('clicking a flagged word edits only that word, in the paragraph\'s own type, and leaves the other marks', async ({ page }) => {
await open(page);
const before = await fontOf(page, '[data-block-row="b3"] .mag-block-text');
const box = await page.locator('[data-block-row="b3"] .mag-block-text').boundingBox();
await page.locator('[data-block-row="b3"] mark.mag-spell').first().click();
const input = page.locator('[data-block-row="b3"] .mag-word-input');
await expect(input).toBeFocused();
await expect(input).toHaveValue('technology');
await expect(page.locator('[data-block-row="b3"] .mag-row-editor')).toHaveCount(0); // not the whole-text editor
await expect(page.locator('[data-block-row="b3"] mark.mag-spell:not(.is-editing)')).toHaveCount(1); // the other word stays underlined
expect(await fontOf(page, '[data-block-row="b3"] .mag-block-text')).toBe(before); // nothing restyled
expect(await fontOf(page, '[data-block-row="b3"] .mag-word-input')).toBe(before);
const after = await page.locator('[data-block-row="b3"] .mag-block-text').boundingBox();
expect(Math.abs(after.width - box.width)).toBeLessThan(2); // the column did not change width
await expect(page.locator('[data-block-row="b3"] input[type="checkbox"]')).toBeChecked(); // the click did not untick the region
});
test('a suggestion chip corrects the word with one click', async ({ page }) => {
const posts = await open(page);
await page.locator('[data-block-row="b3"] mark.mag-spell').first().click();
await page.locator('[data-block-row="b3"] .mag-word-pop').getByRole('button', { name: 'technologie', exact: true }).click();
await expect.poll(() => posts.find(p => p.fix)?.fix).toMatchObject({ block_id: 'b3', start: 8, end: 18, token: 'technology', replacement: 'technologie' });
});
test('typing a correction and pressing Enter applies it; Escape puts the word back and sends nothing', async ({ page }) => {
const posts = await open(page);
const mark = page.locator('[data-block-row="b3"] mark.mag-spell').first();
await mark.click();
const input = page.locator('[data-block-row="b3"] .mag-word-input');
await input.fill('Technologie');
await input.press('Escape');
await expect(page.locator('[data-block-row="b3"] .mag-word-input')).toHaveCount(0);
await expect(page.locator('[data-block-row="b3"] mark.mag-spell').first()).toHaveText('technology');
expect(posts).toEqual([]);
await page.locator('[data-block-row="b3"] mark.mag-spell').first().click();
await page.locator('[data-block-row="b3"] .mag-word-input').fill('Technologie');
await page.locator('[data-block-row="b3"] .mag-word-input').press('Enter');
await expect.poll(() => posts.find(p => p.fix)?.fix).toMatchObject({ token: 'technology', replacement: 'Technologie', start: 8, end: 18 });
});
test('an empty or unchanged word is not sent', async ({ page }) => {
const posts = await open(page);
await page.locator('[data-block-row="b3"] mark.mag-spell').first().click();
const input = page.locator('[data-block-row="b3"] .mag-word-input');
await input.fill(' ');
await input.press('Enter');
await expect(input).toBeVisible(); // still editing: nothing to apply
await input.fill('technology');
await input.press('Enter');
await expect(page.locator('[data-block-row="b3"] .mag-word-input')).toHaveCount(0);
expect(posts).toEqual([]);
});
test('Keep adds the word to the issue dictionary', async ({ page }) => {
const posts = await open(page);
await page.locator('[data-block-row="b3"] mark.mag-spell').nth(1).click();
await page.locator('[data-block-row="b3"] .mag-word-pop').getByRole('button', { name: 'Keep' }).click();
await expect.poll(() => posts.find(p => p.keep)?.keep).toEqual({ word: 'mature' });
});
test('a flagged word can be reached and opened with the keyboard', async ({ page }) => {
await open(page);
const mark = page.locator('[data-block-row="b3"] mark.mag-spell').first();
await mark.focus();
await page.keyboard.press('Enter');
await expect(page.locator('[data-block-row="b3"] .mag-word-input')).toBeFocused();
});
test('the whole-text editor keeps the paragraph\'s type, width and the flagged words underlined', async ({ page }) => {
await open(page);
const textSel = '[data-block-row="b3"] .mag-block-text';
const before = await fontOf(page, textSel);
const box = await page.locator(textSel).boundingBox();
// Click plain text, away from any flagged word.
await page.locator(textSel).click({ position: { x: box.width - 4, y: 4 } });
const area = page.locator('[data-block-row="b3"] .mag-hl-area');
await expect(area).toBeFocused();
expect(await fontOf(page, '[data-block-row="b3"] .mag-hl-area')).toBe(before);
const editorBox = await area.boundingBox();
expect(Math.abs(editorBox.width - box.width)).toBeLessThan(3); // same column, same wrapping
await expect(page.locator('[data-block-row="b3"] .mag-hl-back mark.mag-spell')).toHaveCount(2);
// Fix one word by typing: its underline goes, the other stays.
await area.fill('Battery technologie is entering a new phase. It will take years to mature.');
await expect(page.locator('[data-block-row="b3"] .mag-hl-back mark.mag-spell')).toHaveCount(1);
await expect(page.locator('[data-block-row="b3"] .mag-hl-back mark.mag-spell')).toHaveText('mature');
});
test('the editor grows with its text instead of scrolling inside a small box', async ({ page }) => {
await open(page);
await page.locator('[data-block-row="b3"] .mag-block-text').click({ position: { x: 4, y: 4 } });
const area = page.locator('[data-block-row="b3"] .mag-hl-area');
const h1 = (await area.boundingBox()).height;
await area.fill(Array.from({ length: 40 }, (_, i) => 'Zeile ' + i + ' mit etwas mehr Text').join(' '));
await expect.poll(async () => (await area.boundingBox()).height).toBeGreaterThan(h1 * 3);
expect(await area.evaluate(el => el.scrollHeight <= el.clientHeight + 1)).toBe(true);
});
test('Save text still saves the edited text', async ({ page }) => {
await open(page);
const patches = [];
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3`, route => { patches.push(route.request().postDataJSON()); route.fulfill({ json: { ok: true } }); });
await page.locator('[data-block-row="b3"] .mag-block-text').click({ position: { x: 4, y: 4 } });
await page.locator('[data-block-row="b3"] .mag-hl-area').fill('Neuer Text.');
await page.locator('[data-block-row="b3"]').getByRole('button', { name: 'Save text' }).click();
await expect.poll(() => patches[0]).toEqual({ text: 'Neuer Text.' });
});
});
test.describe('Turning a page scanned sideways', () => {
async function open(page) {
await mockBackend(page);
const calls = { rotate: [], layout: 0, jobs: 0 };
await page.route(`**/api/magazine/${DOC_ID}/layout*`, route => { calls.layout++; return route.fulfill({ json: LAYOUT }); });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).getByRole('button', { name: /^Review/ }).click();
await page.waitForSelector('#mag-undo-btn');
return calls;
}
const routeRotate = (page, calls, { status = 200, job = { done: true, error: null } } = {}) => Promise.all([
page.route(`**/api/magazine/${DOC_ID}/pages/3/rotate`, route => {
calls.rotate.push(route.request().postDataJSON());
return status === 200 ? route.fulfill({ json: { job_id: 'rot1', degrees: calls.rotate.at(-1).degrees } })
: route.fulfill({ status, json: { detail: 'No vision OCR engine configured -- set one in Settings (turning re-reads the page)' } });
}),
page.route('**/api/jobs/rot1', route => { calls.jobs++; return route.fulfill({ json: job }); }),
]);
test('three labelled turn buttons sit together in the toolbar', async ({ page }) => {
await open(page);
const group = page.getByRole('group', { name: 'Turn this page' });
await expect(group.getByRole('button')).toHaveText([/Turn left/, /Turn right/, /Turn 180°/]);
await expect(group.getByRole('button', { name: /Turn right/ })).toHaveAttribute('title', /clockwise/);
});
test('turning right asks first and, once confirmed, sends 90 and reloads the page', async ({ page }) => {
const calls = await open(page);
await routeRotate(page, calls);
const layoutBefore = calls.layout;
await page.getByRole('button', { name: /Turn right/ }).click();
const dialog = page.getByRole('alertdialog');
await expect(dialog).toContainText('Turn page 3 90° clockwise?');
await expect(dialog).toContainText('corrections you made to this page by hand are lost');
expect(calls.rotate).toEqual([]); // nothing is sent before the answer
await dialog.getByRole('button', { name: 'Turn and read again' }).click();
await expect.poll(() => calls.rotate).toEqual([{ degrees: 90 }]);
await expect.poll(() => calls.jobs).toBeGreaterThan(0);
await expect.poll(() => calls.layout).toBeGreaterThan(layoutBefore); // the layout is fetched again
await expect(page.getByRole('button', { name: /Turn right/ })).toBeEnabled();
});
test('cancelling the question turns nothing', async ({ page }) => {
const calls = await open(page);
await routeRotate(page, calls);
await page.getByRole('button', { name: /Turn left/ }).click();
await expect(page.getByRole('alertdialog')).toContainText('Turn page 3 90° counter-clockwise?');
await page.getByRole('alertdialog').getByRole('button', { name: /Cancel/ }).click();
await page.waitForTimeout(300);
expect(calls.rotate).toEqual([]);
});
test('left sends -90 and half a turn sends 180', async ({ page }) => {
const calls = await open(page);
await routeRotate(page, calls);
await page.getByRole('button', { name: /Turn left/ }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
await expect.poll(() => calls.rotate.length).toBe(1);
await page.getByRole('button', { name: /Turn 180°/ }).click();
await expect(page.getByRole('alertdialog')).toContainText('upside down');
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
await expect.poll(() => calls.rotate).toEqual([{ degrees: -90 }, { degrees: 180 }]);
});
test('a failed read says the page was left as it was and the buttons work again', async ({ page }) => {
const calls = await open(page);
await routeRotate(page, calls, { job: { done: true, error: 'The OCR engine returned nothing usable for this page' } });
await page.getByRole('button', { name: /Turn right/ }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
await expect(page.locator('.toast, #toast, [role="status"]').filter({ hasText: 'The page was left as it was' }).first()).toBeVisible();
await expect(page.getByRole('button', { name: /Turn right/ })).toBeEnabled();
});
test('a refusal to start (no OCR engine) is explained', async ({ page }) => {
const calls = await open(page);
await routeRotate(page, calls, { status: 400 });
await page.getByRole('button', { name: /Turn right/ }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
await expect(page.locator('.toast, #toast, [role="status"]').filter({ hasText: 'No vision OCR engine configured' }).first()).toBeVisible();
await expect(page.getByRole('button', { name: /Turn right/ })).toBeEnabled();
});
});
test.describe('Draw region panel layering', () => {
async function openDrawing(page, height = 640) {
await page.setViewportSize({ width: 1300, height });
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);
}
// Drag a rectangle whose bottom edge is at yEnd (window coordinates) over the page image.
async function drawEndingAt(page, yEnd, height = 40) {
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
await page.mouse.move(wrap.x + 40, yEnd - height);
await page.mouse.down();
await page.mouse.move(wrap.x + 200, yEnd, { steps: 4 });
await page.mouse.up();
}
// What the user would actually hit at the centre of each button: it must be that button, not a bar laid over it.
const topmostIsOwn = (page, name) => page.locator('.mag-draw-panel').getByRole('button', { name, exact: true }).evaluate((btn) => {
const r = btn.getBoundingClientRect();
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return !!hit && (hit === btn || btn.contains(hit));
});
async function geometry(page) {
const vp = page.viewportSize();
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
const footTop = await page.locator('.mag-review-foot').evaluate(el => el.getBoundingClientRect().top);
await drawEndingAt(page, wrap.y + 80); // a first rectangle, only to measure the panel
const height = (await page.locator('.mag-draw-panel').boundingBox()).height;
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
return { vp, wrap, footTop, height };
}
test('a panel opened where the sticky bottom bar lies is on top of it, so every button can be clicked', async ({ page }) => {
await openDrawing(page);
const { vp, wrap, footTop, height } = await geometry(page);
// A rectangle that leaves room for the panel below it *only just*: it fits in the window, but reaches into the
// band the sticky Back / Approve bar occupies -- the case that hid Cancel / Add region.
const yEnd = Math.round(Math.min(vp.height - 24 - height, footTop - 8));
expect(yEnd - 40).toBeGreaterThan(wrap.y); // the rectangle is on the page image
expect(yEnd + 6 + height).toBeGreaterThan(footTop); // and the panel below it does overlap the bar
await drawEndingAt(page, yEnd);
const panel = page.locator('.mag-draw-panel');
await expect(panel).toBeVisible();
const box = await panel.boundingBox();
expect(box.y + box.height).toBeLessThanOrEqual(vp.height); // inside the window...
expect(box.y + box.height).toBeGreaterThan(footTop); // ...and over the bar's band
expect(await topmostIsOwn(page, 'Add region')).toBe(true);
expect(await topmostIsOwn(page, 'Cancel')).toBe(true);
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(page.locator('.mag-draw-panel')).toHaveCount(0);
});
test('the panel is mounted on the page body, outside the sticky stacking contexts', async ({ page }) => {
await openDrawing(page);
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
await drawEndingAt(page, wrap.y + 80);
const info = await page.locator('.mag-draw-panel').evaluate((el) => ({ parent: el.parentElement.tagName, z: getComputedStyle(el).zIndex }));
expect(info.parent).toBe('BODY');
expect(Number(info.z)).toBeGreaterThan(100); // above the sticky bars (30-100)
expect(Number(info.z)).toBeLessThan(1000); // below dialogs
});
test('the region type list stays inside the window and its options can be clicked', async ({ page }) => {
await openDrawing(page);
const { vp, footTop, height } = await geometry(page);
await drawEndingAt(page, Math.round(Math.min(vp.height - 24 - height, footTop - 8)));
await page.locator('#mag-draw-kind-search').focus();
const list = page.locator('#mag-draw-kind-list');
await expect(list).toBeVisible();
const r = await list.boundingBox();
expect(r.y).toBeGreaterThanOrEqual(0);
expect(r.y + r.height).toBeLessThanOrEqual(vp.height);
const reachable = await list.locator('.mag-type-picker-option').first().evaluate((el) => {
const b = el.getBoundingClientRect();
const hit = document.elementFromPoint(b.x + b.width / 2, b.y + b.height / 2);
return !!hit && el.contains(hit);
});
expect(reachable).toBe(true);
await list.locator('.mag-type-picker-option').nth(2).click(); // a real click on an option
await expect(list).toBeHidden();
});
test('panels are removed when the review is left', async ({ page }) => {
await openDrawing(page);
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
await drawEndingAt(page, wrap.y + 80);
await expect(page.locator('.mag-draw-panel')).toHaveCount(1);
await page.evaluate(() => window.magBackToContents());
await expect(page.locator('.mag-draw-panel')).toHaveCount(0);
});
test('the save-new-position panel is mounted and placed the same way', async ({ page }) => {
await openDrawing(page);
await page.evaluate(() => {
const box = document.querySelector('#mag-review-page-wrap .mag-review-box');
window.magReviewOpenEditPanel(box.dataset.block, box, { origLeft: 0, origTop: 0, origW: 10, origH: 10 });
});
const panel = page.locator('.mag-edit-panel');
await expect(panel).toBeVisible();
expect(await panel.evaluate(el => el.parentElement.tagName)).toBe('BODY');
const box = await panel.boundingBox();
const vp = page.viewportSize();
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.y).toBeGreaterThanOrEqual(0);
expect(box.y + box.height).toBeLessThanOrEqual(vp.height);
expect(box.x + box.width).toBeLessThanOrEqual(vp.width);
});
});

View File

@ -0,0 +1,380 @@
"""Turning a page that was scanned sideways: geometry, persistence, failure handling, validation."""
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest import mock
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-test-config-"))
from PIL import Image
from core import magazine as mag
class GeometryTests(unittest.TestCase):
def test_a_box_turns_with_the_page(self):
# 100 wide, 200 tall page; a box near the top-left corner.
self.assertEqual(mag.rotate_bbox([10, 20, 30, 40], 100, 200, 90), [140, 10, 40, 30]) # lands top-right
self.assertEqual(mag.rotate_bbox([10, 20, 30, 40], 100, 200, 180), [60, 140, 30, 40]) # bottom-right
self.assertEqual(mag.rotate_bbox([10, 20, 30, 40], 100, 200, 270), [20, 60, 40, 30]) # bottom-left
def test_four_quarter_turns_are_no_turn(self):
box, w, h = [10.0, 20.0, 30.0, 40.0], 100.0, 200.0
for _ in range(4):
box = mag.rotate_bbox(box, w, h, 90)
w, h = h, w
self.assertEqual(box, [10.0, 20.0, 30.0, 40.0])
def test_a_turn_and_its_opposite_cancel(self):
turned = mag.rotate_bbox([10, 20, 30, 40], 100, 200, 90)
self.assertEqual(mag.rotate_bbox(turned, 200, 100, 270), [10, 20, 30, 40])
def test_only_quarter_turns_are_accepted(self):
self.assertEqual([mag.normalise_rotation(v) for v in (90, -90, 270, 180, "90", 450, -270)], [90, 270, 270, 180, 90, 90, 90])
for bad in (0, 45, 360, "x", None, 1):
with self.assertRaises(ValueError):
mag.normalise_rotation(bad)
class ImageTests(unittest.TestCase):
def png(self, w=100, h=200):
p = Path(tempfile.mkdtemp(prefix="ttvc-rot-")) / "0001.png"
im = Image.new("RGB", (w, h), (255, 255, 255))
im.putpixel((10, 20), (255, 0, 0))
im.save(p)
return p
def test_a_quarter_turn_is_clockwise_and_swaps_the_size(self):
p = self.png()
mag.rotate_page_image(p, 90)
with Image.open(p) as im:
self.assertEqual(im.size, (200, 100))
self.assertEqual(im.getpixel((200 - 1 - 20, 10)), (255, 0, 0)) # (10,20) -> (H-1-y, x)
def test_a_half_turn_keeps_the_size(self):
p = self.png()
mag.rotate_page_image(p, 180)
with Image.open(p) as im:
self.assertEqual(im.size, (100, 200))
self.assertEqual(im.getpixel((100 - 1 - 10, 200 - 1 - 20)), (255, 0, 0))
def test_the_pixel_agrees_with_the_box_maths(self):
p = self.png()
x, y, _, _ = mag.rotate_bbox([10, 20, 1, 1], 100, 200, 90)
mag.rotate_page_image(p, 90)
with Image.open(p) as im:
self.assertEqual(im.getpixel((int(x), int(y))), (255, 0, 0))
def test_no_temporary_file_is_left_behind(self):
p = self.png()
mag.rotate_page_image(p, 270)
self.assertEqual(sorted(f.name for f in p.parent.iterdir()), ["0001.png"])
class LayoutTests(unittest.TestCase):
def layout(self):
return {"pages": [{"page": 1, "width": 100.0, "height": 200.0}, {"page": 2, "width": 100.0, "height": 200.0}],
"blocks": [{"id": "a", "page": 1, "bbox": [10, 20, 30, 40]}, {"id": "b", "page": 2, "bbox": [10, 20, 30, 40]}],
"images": [{"id": "i", "page": 1, "bbox": [0, 0, 50, 50]}]}
def test_only_the_turned_page_changes(self):
out = mag.layout_with_rotated_page(self.layout(), 1, 90)
self.assertEqual((out["pages"][0]["width"], out["pages"][0]["height"], out["pages"][0]["rotation"]), (200.0, 100.0, 90))
self.assertEqual(out["pages"][1], {"page": 2, "width": 100.0, "height": 200.0})
self.assertEqual(out["blocks"][0]["bbox"], [140, 10, 40, 30])
self.assertEqual(out["blocks"][1]["bbox"], [10, 20, 30, 40])
self.assertEqual(out["images"][0]["bbox"], [150, 0, 50, 50])
def test_the_input_is_not_modified_and_rotation_accumulates(self):
original = self.layout()
snapshot = json.dumps(original)
once = mag.layout_with_rotated_page(original, 1, 90)
self.assertEqual(json.dumps(original), snapshot)
twice = mag.layout_with_rotated_page(once, 1, 270)
self.assertEqual(twice["pages"][0]["rotation"], 0)
self.assertEqual((twice["pages"][0]["width"], twice["pages"][0]["height"]), (100.0, 200.0))
self.assertEqual(twice["blocks"][0]["bbox"], [10, 20, 30, 40])
def test_an_unknown_page_is_refused(self):
with self.assertRaises(ValueError):
mag.layout_with_rotated_page(self.layout(), 9, 90)
class LadderTests(unittest.TestCase):
"""read_turned_page_regions: which reading wins, and how boxes from the original orientation are carried."""
GOOD = "Dies ist ein vollstaendiger deutscher Satz mit genug Woertern fuer eine brauchbare Lesung der Seite."
def setUp(self):
d = Path(tempfile.mkdtemp(prefix="ttvc-ladder-"))
self.turned = d / "turned.png"
Image.new("RGB", (200, 100), "white").save(self.turned) # the page after a 90 degree turn (was 100 wide, 200 tall)
buf = __import__("io").BytesIO()
Image.new("RGB", (100, 200), "white").save(buf, "PNG")
self.original = buf.getvalue()
self.calls = []
def run_ladder(self, vision, tesseract=None, which="/usr/bin/tesseract"):
def chain(png, engines, timeout=0):
self.calls.append(Path(png).name)
return vision[len(self.calls) - 1] if len(self.calls) <= len(vision) else None
with mock.patch.object(mag, "vision_ocr_page_chain", side_effect=chain), \
mock.patch.object(mag, "ocr_page", return_value=tesseract or []) as ocr, \
mock.patch("shutil.which", return_value=which):
out = mag.read_turned_page_regions(self.turned, self.original, 90, [("http://x", "m")])
self.ocr = ocr
return out
def region(self, text, bbox=(10, 20, 30, 40)):
return {"label": "text", "bbox": list(bbox), "text": text}
def test_a_good_reading_of_the_turned_page_wins_at_once(self):
regions, how = self.run_ladder([[self.region(self.GOOD)]])
self.assertEqual(how, "vision")
self.assertEqual(self.calls, ["turned.png"]) # nothing else was tried
self.ocr.assert_not_called()
def test_when_the_engine_returns_nothing_the_original_orientation_is_read_and_its_boxes_carried_across(self):
regions, how = self.run_ladder([None, [self.region(self.GOOD, (10, 20, 30, 40))]])
self.assertEqual(how, "vision-original")
self.assertEqual(regions[0]["bbox"], [140, 10, 40, 30]) # rotate_bbox of the box on the 100x200 original, 90 degrees
self.assertEqual(regions[0]["text"], self.GOOD)
self.assertEqual(self.calls, ["turned.png", "original.png"])
def test_tesseract_is_the_last_rung(self):
para = {"bbox": [5, 5, 50, 20], "text": self.GOOD, "confidence": 90, "line_height": 8}
regions, how = self.run_ladder([None, None], tesseract=[para])
self.assertEqual(how, "tesseract")
self.assertEqual(regions, [{"label": "text", "bbox": [5, 5, 50, 20], "text": self.GOOD}])
def test_a_short_or_implausible_reading_does_not_win_but_the_best_of_them_is_used_if_nothing_else_does(self):
regions, how = self.run_ladder([[self.region("Seite 7")], [self.region("zzz qqq xxx")]], tesseract=[])
self.assertIn(how, ("vision", "vision-original"))
self.assertTrue(regions)
self.assertEqual(regions[0]["text"], "zzz qqq xxx" if how == "vision-original" else "Seite 7")
def test_a_picture_page_is_an_empty_reading_not_a_failure_when_tesseract_ran(self):
self.assertEqual(self.run_ladder([None, None], tesseract=[]), ([], "empty"))
def test_without_tesseract_and_without_any_reading_it_is_a_failure(self):
self.assertEqual(self.run_ladder([None, None], tesseract=[], which=None), (None, "failed"))
def test_the_original_bytes_are_used_not_the_turned_file(self):
self.run_ladder([None, [self.region(self.GOOD)]])
self.assertEqual(self.calls[1], "original.png")
def test_bad_degrees_are_refused(self):
with self.assertRaises(ValueError):
mag.read_turned_page_regions(self.turned, self.original, 45, [])
class JobTests(unittest.TestCase):
"""_reocr_page_sync with a turn: success persists it, failure puts the page back."""
def setUp(self):
import routes.magazine as routes
self.routes = routes
self.lib = Path(tempfile.mkdtemp(prefix="ttvc-lib-"))
self.d = self.lib / ("a" * 32)
(self.d / "pages").mkdir(parents=True)
self.png = self.d / "pages" / "0003.png"
Image.new("RGB", (100, 200), (255, 255, 255)).save(self.png)
self.original = self.png.read_bytes()
layout = {"pages": [{"page": 3, "width": 100.0, "height": 200.0}], "images": [],
"blocks": [{"id": "b1", "page": 3, "bbox": [10, 20, 30, 40], "type": "body", "text": "x", "reading_order": 0}]}
(self.d / "layout.json").write_text(json.dumps(layout))
(self.d / "articles.json").write_text(json.dumps({"articles": []}))
(self.d / "meta.json").write_text(json.dumps({"kind": "magazine", "title": "T"}))
self.jobs = []
patches = [mock.patch.object(routes, "_LIB", self.lib),
mock.patch.object(routes, "background_job_update", side_effect=lambda jid, **kw: self.jobs.append(kw)),
mock.patch.object(routes, "_recognise_advertisements"), mock.patch.object(routes, "_recognise_semantic_layout"),
mock.patch.object(routes, "_refresh_quality_rows"), mock.patch.object(routes, "_load_settings", return_value={}),
mock.patch.object(routes, "_snapshot_for_undo", return_value="tok"), mock.patch.object(routes, "_seal_undo"),
mock.patch.object(routes.mag, "refresh_visual_regions"),
mock.patch.object(routes.mag, "clean_up_regions", side_effect=lambda lay, pages=None: (lay, {})),
mock.patch.object(routes.mag, "reorder_page", side_effect=lambda lay, p: lay),
mock.patch.object(routes.mag, "detect_inset_pullquotes", side_effect=lambda d, lay, pages, *a: (lay, 0)),
mock.patch.object(routes.mag, "split_around_pullquotes", side_effect=lambda d, lay, pages: (lay, 0)),
mock.patch.object(routes.mag, "snap_text_boxes", side_effect=lambda d, lay, pages=None: (lay, [])),
mock.patch.object(routes.mag, "remap_reviewed_page")]
for p in patches:
p.start()
self.addCleanup(p.stop)
def run_job(self, degrees, reread, ladder=None):
ladder = ladder or (lambda *a, **k: ([{"label": "text", "bbox": [1, 1, 5, 5], "text": "x" * 50}], "vision"))
with mock.patch.object(self.routes.mag, "reocr_page", side_effect=reread) as m, \
mock.patch.object(self.routes.mag, "read_turned_page_regions", side_effect=ladder) as lad:
self.routes._reocr_page_sync("a" * 32, "job", 3, "http://ocr", "m", None, None, degrees)
self.ladder = lad
return m
def test_success_turns_the_image_remembers_it_and_re_measures_the_page(self):
seen = {}
def reread(d, layout, page, *a, **k):
seen["meta"] = next(p for p in layout["pages"] if p["page"] == page)
seen["bbox"] = layout["blocks"][0]["bbox"]
return layout
self.run_job(90, reread)
self.assertEqual((seen["meta"]["width"], seen["meta"]["height"]), (200.0, 100.0)) # the read sees the turned page
self.assertEqual(seen["bbox"], [140, 10, 40, 30]) # ...with the old boxes carried along
with Image.open(self.png) as im:
self.assertEqual(im.size, (200, 100))
self.assertEqual(json.loads((self.d / "meta.json").read_text())["page_rotations"], {"3": 90})
self.routes._refresh_quality_rows.assert_called_once()
self.assertEqual(self.jobs[-1]["stage"], "done")
self.assertIsNone(self.jobs[-1]["result"]["undo"])
self.routes._snapshot_for_undo.assert_not_called() # a turn is not undoable
def test_turns_add_up_and_a_full_circle_forgets_itself(self):
for _ in range(4):
self.run_job(90, lambda d, layout, page, *a, **k: layout)
self.assertNotIn("3", json.loads((self.d / "meta.json").read_text()).get("page_rotations", {}))
def test_a_failed_read_puts_the_page_back_and_saves_nothing(self):
before_layout = (self.d / "layout.json").read_text()
self.run_job(90, lambda *a, **k: None) # the engine returned nothing usable
self.assertEqual(self.png.read_bytes(), self.original)
self.assertEqual((self.d / "layout.json").read_text(), before_layout)
self.assertNotIn("page_rotations", json.loads((self.d / "meta.json").read_text()))
self.assertEqual(self.jobs[-1]["stage"], "failed")
self.routes._refresh_quality_rows.assert_not_called()
def test_an_exception_during_the_read_also_restores_the_page(self):
def boom(*a, **k):
raise RuntimeError("engine down")
self.run_job(180, boom)
self.assertEqual(self.png.read_bytes(), self.original)
self.assertIn("engine down", self.jobs[-1]["error"])
def test_a_turn_reads_through_the_ladder_and_hands_its_regions_to_the_re_read(self):
regions = [{"label": "text", "bbox": [1, 2, 3, 4], "text": "gelesen"}]
m = self.run_job(90, lambda d, layout, page, *a, **k: layout, ladder=lambda *a, **k: (regions, "vision-original"))
self.assertEqual(m.call_args.kwargs["regions"], regions)
self.assertEqual(self.jobs[-1]["result"]["read_with"], "vision-original")
# the ladder gets the turned page, the bytes of the page as it was, and the turn
args = self.ladder.call_args.args
self.assertEqual((args[0], args[1], args[2]), (self.png, self.original, 90))
def test_when_nothing_can_read_the_page_it_is_put_back(self):
self.run_job(90, lambda d, layout, page, *a, **k: layout, ladder=lambda *a, **k: (None, "failed"))
self.assertEqual(self.png.read_bytes(), self.original)
self.assertIn("Neither the OCR engines nor tesseract", self.jobs[-1]["error"])
self.assertNotIn("page_rotations", json.loads((self.d / "meta.json").read_text()))
def test_a_picture_page_with_no_text_can_still_be_turned(self):
self.run_job(90, lambda d, layout, page, *a, **k: layout, ladder=lambda *a, **k: ([], "empty"))
self.assertEqual(self.jobs[-1]["stage"], "done")
self.assertEqual(json.loads((self.d / "meta.json").read_text())["page_rotations"], {"3": 90})
def test_without_a_turn_the_job_behaves_as_before(self):
self.run_job(0, lambda d, layout, page, *a, **k: layout)
self.ladder.assert_not_called()
self.routes._snapshot_for_undo.assert_called_once()
self.assertEqual(self.png.read_bytes(), self.original)
self.assertNotIn("page_rotations", json.loads((self.d / "meta.json").read_text()))
class RenderTests(unittest.TestCase):
"""A stored turn survives re-rendering: extract_pages applies it, so a re-import does not flip the page back."""
def pdf(self):
import pymupdf
d = Path(tempfile.mkdtemp(prefix="ttvc-pdf-"))
doc = pymupdf.open()
page = doc.new_page(width=300, height=200)
page.insert_text((20, 50), "A sentence with enough words to count as native text on this page, repeated. " * 3, fontsize=8)
doc.save(str(d / "source.pdf"))
doc.close()
return d
def test_extract_pages_renders_a_stored_turn_and_ignores_the_unturned_text_layer(self):
d = self.pdf()
plain = mag.extract_pages(d / "source.pdf", d / "plain", render_dpi=50)
self.assertEqual((plain["pages"][0]["width"], plain["pages"][0]["height"]), (300.0, 200.0))
self.assertTrue(any(b["text"].startswith("A sentence") for b in plain["blocks"]))
turned = mag.extract_pages(d / "source.pdf", d / "turned", render_dpi=50, page_rotations={1: 90})
self.assertEqual((turned["pages"][0]["width"], turned["pages"][0]["height"]), (200.0, 300.0))
with Image.open(d / "turned" / "pages" / "0001.png") as im, Image.open(d / "plain" / "pages" / "0001.png") as ref:
self.assertEqual(im.size, (ref.size[1], ref.size[0]))
# The PDF's own text boxes belong to the unturned page; on a turned page only OCR of the render counts.
self.assertFalse(any(b["source"] == "native" or b["text"].startswith("A sentence") for b in turned["blocks"]))
def test_no_stored_turn_changes_nothing(self):
d = self.pdf()
a = mag.extract_pages(d / "source.pdf", d / "a", render_dpi=50)
b = mag.extract_pages(d / "source.pdf", d / "b", render_dpi=50, page_rotations={})
self.assertEqual(a["pages"], b["pages"])
self.assertEqual([x["text"] for x in a["blocks"]], [x["text"] for x in b["blocks"]])
class EndpointTests(unittest.TestCase):
def setUp(self):
from fastapi import FastAPI
from fastapi.testclient import TestClient
import routes.magazine as routes
self.routes = routes
self.lib = Path(tempfile.mkdtemp(prefix="ttvc-lib-"))
self.patches = [mock.patch.object(routes, "_LIB", self.lib)]
for p in self.patches:
p.start()
self.addCleanup(p.stop)
app = FastAPI(); app.include_router(routes.router)
self.client = TestClient(app)
self.doc = self.client.post("/api/magazine/docs", json={"title": "T"}).json()["id"]
self.d = self.lib / self.doc
(self.d / "pages").mkdir()
Image.new("RGB", (50, 80), (255, 255, 255)).save(self.d / "pages" / "0002.png")
(self.d / "layout.json").write_text(json.dumps({"pages": [{"page": 2, "width": 50.0, "height": 80.0}], "blocks": [], "images": []}))
def post(self, degrees, page=2, vision=True):
settings = {"vision_ocr_url": "http://ocr", "vision_ocr_model": "m"} if vision else {}
created = []
async def fake_task(coro):
created.append(coro)
with mock.patch.object(self.routes, "_load_settings", return_value=settings), \
mock.patch.object(self.routes.asyncio, "create_task", side_effect=lambda c: (c.close(), created.append(1))[1]):
return self.client.post(f"/api/magazine/{self.doc}/pages/{page}/rotate", json={"degrees": degrees}), created
def test_a_valid_request_starts_a_job(self):
r, created = self.post(90)
self.assertEqual(r.status_code, 200, r.text)
self.assertEqual(r.json()["degrees"], 90)
self.assertEqual(len(created), 1)
def test_minus_ninety_means_two_seventy(self):
self.assertEqual(self.post(-90)[0].json()["degrees"], 270)
def test_bad_degrees_are_refused_and_nothing_starts(self):
for bad in (0, 45, "sideways", None, 360):
r, created = self.post(bad)
self.assertEqual(r.status_code, 400, bad)
self.assertEqual(created, [])
def test_a_page_that_is_not_there_is_404(self):
self.assertEqual(self.post(90, page=7)[0].status_code, 404)
def test_no_vision_engine_is_a_clear_400_and_the_page_is_untouched(self):
before = (self.d / "pages" / "0002.png").read_bytes()
r, created = self.post(90, vision=False)
self.assertEqual(r.status_code, 400)
self.assertIn("vision OCR engine", r.json()["detail"])
self.assertEqual((self.d / "pages" / "0002.png").read_bytes(), before)
self.assertEqual(created, [])
def test_page_images_are_revalidated_so_a_turned_page_shows_at_once(self):
r = self.client.get(f"/api/magazine/{self.doc}/pages/2")
self.assertEqual(r.status_code, 200)
self.assertEqual(r.headers["cache-control"], "no-cache")
def test_stored_turns_reach_ingestion(self):
meta = {"page_rotations": {"3": 90, "4": "180", "5": 45, "x": 90, "0": 90}}
self.assertEqual(self.routes._page_rotations(meta), {3: 90, 4: 180})
if __name__ == "__main__":
unittest.main()