feat(magazine): turn a page scanned sideways or upside down and read it again
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
119d3ba3ae
commit
6fdf9fbc38
@ -6,6 +6,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
### 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).
|
||||
|
||||
|
||||
26
Manual.md
26
Manual.md
@ -393,6 +393,32 @@ 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.
|
||||
|
||||
**Errors.** Without a vision OCR engine nothing is changed and you are told why. If the engine is down or returns nothing, 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.
|
||||
|
||||
@ -477,6 +477,22 @@ 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.
|
||||
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
|
||||
|
||||
@ -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,6 +2841,67 @@ 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 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,
|
||||
|
||||
@ -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,16 +969,25 @@ 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}")
|
||||
updated = mag.reocr_page(d, layout, page, vision_ocr_url, vision_ocr_model,
|
||||
vision_ocr_fallback_url, vision_ocr_fallback_model)
|
||||
@ -990,13 +1014,33 @@ 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})
|
||||
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 +1083,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,
|
||||
|
||||
@ -1992,6 +1992,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\')" ' +
|
||||
@ -3588,6 +3599,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;
|
||||
@ -5711,6 +5774,7 @@ root.magReviewToggleAllPage = magReviewToggleAllPage;
|
||||
root.magReviewClearCurrentPage = magReviewClearCurrentPage;
|
||||
root.magReviewRemoveAllBoxes = magReviewRemoveAllBoxes;
|
||||
root.magReocrPage = magReocrPage;
|
||||
root.magRotatePage = magRotatePage;
|
||||
root.magRefineFigures = magRefineFigures;
|
||||
root.magRefineAllFigures = magRefineAllFigures;
|
||||
root.magQualityRun = magQualityRun;
|
||||
|
||||
@ -5645,3 +5645,5 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
|
||||
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); }
|
||||
|
||||
@ -1989,3 +1989,90 @@ test.describe('Correcting flagged words in the review list', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
289
tests/test_magazine_rotation.py
Normal file
289
tests/test_magazine_rotation.py
Normal file
@ -0,0 +1,289 @@
|
||||
"""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 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):
|
||||
with mock.patch.object(self.routes.mag, "reocr_page", side_effect=reread) as m:
|
||||
self.routes._reocr_page_sync("a" * 32, "job", 3, "http://ocr", "m", None, None, degrees)
|
||||
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_without_a_turn_the_job_behaves_as_before(self):
|
||||
self.run_job(0, lambda d, layout, page, *a, **k: layout)
|
||||
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