Compare commits
5 Commits
dd5ddc5842
...
81e2c6b4ef
| Author | SHA1 | Date | |
|---|---|---|---|
| 81e2c6b4ef | |||
| 8da797751b | |||
| 130fd273c6 | |||
| 6fdf9fbc38 | |||
| 119d3ba3ae |
17
CHANGELOG.md
17
CHANGELOG.md
@ -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
|
||||
|
||||
38
Manual.md
38
Manual.md
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
158
core/magazine.py
158
core/magazine.py
@ -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))
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 & 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;
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
380
tests/test_magazine_rotation.py
Normal file
380
tests/test_magazine_rotation.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user