Compare commits

...

14 Commits

Author SHA1 Message Date
dd5ddc5842 Release v1.56.0
Some checks are pending
Test and SonarQube / quality (push) Waiting to run
2026-09-30 11:33:30 +02:00
9ad881ada8 feat(magazine): manage OPDS catalogues in the app, env-based login, result paging
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 11:03:03 +02:00
7e9be98709 feat(magazine): page overview shows status, article count, page flow and what to do next
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 10:59:08 +02:00
85c5b8427a feat(magazine): hands-free listening (Media Session, wake lock); fix Listen not starting audio
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 10:50:41 +02:00
15f59428ca docs: put the order-panel note next to the reading-order text
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 10:46:35 +02:00
2318e8899e docs: file the new changelog entries under Unreleased, not the released 1.55.0
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 10:46:16 +02:00
60654bf867 chore(docker): allow-list .dockerignore; rebuilt image includes mutagen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 10:45:48 +02:00
e241821325 fix(magazine): keep the Order by clicking controls on screen in a floating panel
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 10:45:48 +02:00
268ed391a3 feat(magazine): EPUB 3 Media Overlay export with measured sentence timings; tolerate missing mutagen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 10:33:08 +02:00
4a20a12f66 feat(magazine): show sidebars in the reading view as labelled, never-narrated asides
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 10:26:27 +02:00
30b80fcbf7 feat(magazine): mark hand-edited text, show a word diff against the recognised text, restore it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 10:24:27 +02:00
0f4b28ba14 feat(magazine): link a tail to its head from 'continued from page N'
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 10:20:35 +02:00
0005ae0ebd feat(magazine): import a magazine from PNG/JPEG page images
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 10:19:14 +02:00
0d3178f733 docs: general-purpose OCR porting brief for letters, papers and forms
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 10:10:59 +02:00
23 changed files with 2802 additions and 116 deletions

13
.dockerignore Normal file
View File

@ -0,0 +1,13 @@
# Allow-list: the image copies only what the Dockerfile names. Everything else (user data under config/,
# logs, tests, the git history, worktrees, attachments) must never enter the build context.
*
!requirements.txt
!server.py
!CHANGELOG.md
!VERSION
!core
!routes
!workflows
!static
**/__pycache__
**/*.pyc

View File

@ -9,6 +9,28 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [1.56.0] — 2026-09-30
### Added
- **OPDS catalogues: manage them in the app, log in, and page through results.** A *Your catalogues (OPDS)* panel adds and removes catalogues (validated server-side). A catalogue that needs a login takes a username and the *name* of an environment variable holding the password, which is never stored or shown and only sent to that catalogue's own host. Searches follow the catalogue's own next-page links (up to five result pages, same host only, loop-protected).
- **Page overview says what to do.** Every page card now states its status in words, how many articles are on the page, where each article continues from / on to ("continues from p.3", "continues on p.6"), and a **To do** list when the system could not settle the page (approve it; fix the quality findings with the tool that fixes each; link orphan text to an article; generate audio; approve the other articles on a shared page). A summary line counts the pages that need you and *Show only pages that need me* hides the rest. The legend is larger (14 px, 18 px swatches). A page coloured "audio" that still has an unapproved article is now labelled "Partly done" with the reason.
- **Hands-free listening.** The article player now registers with the browser's Media Session: title, magazine and first-page artwork on the lock screen / headset / car display, with play, pause, previous/next article, ±15 s and seek, plus a position bar. While driving mode is open and audio is playing the screen is kept awake (Screen Wake Lock), and released on pause or exit; a refused lock never affects playback.
- **EPUB 3 with Media Overlays.** *EPUB with audio* in the contents toolbar (and `GET /api/magazine/{id}/epub`) packages the approved articles that have audio into a book whose text highlights as the audio plays. MP3 export now records each sentence's real start and end (`audio/<id>.timings.json`, no text), so new audio highlights exactly; older audio is placed by estimate and the book's description says so. Validated with W3C EPUBCheck 5.2.1 (0 errors, 0 warnings) on a real 12-minute two-article issue.
- **Magazine reading view shows sidebars.** A sidebar was kept out of narration and therefore never shown at all. The article text endpoint now also returns `asides` and the reflowed view displays each one where it sits, labelled "Sidebar · not read aloud · p. N", as plain text that is neither a sentence nor highlighted. Only for pages the article alone occupies (on a shared page there is no telling which article a sidebar belongs to); not for advertisement pages; a sidebar you ticked into the narration stays narrated.
- **Magazine review: extraction diff.** A region whose text you edited by hand is marked *Edited*, with *Show changes* (word-level comparison against the text as first recognised; removals struck through and marked "−", additions underlined and marked "+") and *Restore recognised text*. The first reading is kept in a new `ocr_text` field across repeated edits; re-reading the region or typing the original back clears it.
- **"Continued from page N" now joins a feature's tail to its head.** Until now only the forward "continued on page N" reference linked pages. A tail that says "Fortsetzung von Seite 10" / "Continued from page 10" (found even when set as small furniture or sidebar text, on lines up to 160 characters) is attached to the single article on that page. Skipped when a contents entry anchors the tail, when the page is shared by several articles, or when the marker points forward or at an empty page. Articles gain a `continued_from` list.
- **Magazine import from PNG/JPEG page images.** Choose or drop several scans or phone photos; they are checked by content (not by name), ordered by filename with natural numbering, turned into one scan-style PDF (page size from the image's own resolution, EXIF rotation applied, 72 dpi treated as unknown) and ingested like any scanned issue. Limits: 300 images, 25 MB and 60 megapixels each; a bad image rejects the whole upload and leaves nothing behind; an existing source is never replaced. Verified on real pages: OCR through the image path scored like the original PDF (F1 0.85 / 0.78).
- `architecture/ocr-porting-prompt-general.md`: the OCR porting brief rewritten for general documents (letters, papers, forms, invoices). Adds privacy, pre-processing of skewed/photographed scans, document-type handling, field-level accuracy and handwriting, and tags every claim as measured on magazines, a general principle, or untested for these document types.
### Changed
- **"Order by clicking" now keeps its controls on screen.** *Save order*, *Undo last* and *Cancel* lived in the toolbar row and could scroll out of reach, leaving the task unfinishable. While ordering they are a floating panel pinned to the bottom-right of the window (full width on a phone): a progress meter, a large *Save order* button that turns green when every box is numbered, *Undo last*, and *Cancel* (also **Esc**, except while typing in a field). It sits above the Back/Approve bar and clear of the page image. Tests scroll the real scroll container, and were verified to fail against the old layout.
### Fixed
- **"Listen" on an article that already has audio did nothing until the native play button was pressed.** The player element is `preload="none"`, and starting a track waited for `loadedmetadata`, which such an element never fires. Choosing a track now sets `preload` so it loads and plays (and resumes at the saved position).
- **MP3 export no longer throws away a finished synthesis when ID3 tagging is unavailable, and article durations no longer read 0 without mutagen.** The deployed image lacked `mutagen` although requirements.txt lists it, so tagging raised after every sentence had been rendered and the contents showed no MP3 lengths. Tagging is now best-effort (a warning), and durations fall back to ffprobe. Rebuild the image to get the tags back.
- Docker: added an allow-list `.dockerignore`, so a build no longer uploads `config/` (user data, settings, keys), logs, tests or the git history into the build context; only the files the Dockerfile copies are sent. New image `tts-voice-creator-clone-and-design-2:latest` (also `:1.55.0`) includes `mutagen`; the previous image is kept as `:rollback-20260930`.
---
## [1.55.0] — 2026-09-29
### Fixed
- Tests: the form-pair fixture used the retired prompt version `v3` as "current"; the phone-width case of the Characters sorting test is marked known-failing (sticky name column covers the rating stars, tracked in TODO.md).
@ -4580,7 +4602,8 @@ Initial feature-complete release.
---
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.55.0...HEAD
[Unreleased]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.56.0...HEAD
[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
[1.53.4]: https://github.com/mARTin-B78/tts-voice-creator-clone-and-design-2/compare/v1.53.3...v1.53.4

View File

@ -77,7 +77,7 @@ turned into a podcast discussion between two or three voices you pick.
### Prerequisites
- The magazine as a PDF (native text or scanned — both work).
- The magazine as a PDF (native text or scanned — both work), **or** its pages as PNG/JPEG images (scans or phone photos): choose or drop several at once and they become one magazine, pages in filename order (`page2` before `page10`). Up to 300 images of at most 25 MB each; a photo's EXIF rotation is applied. Images carry no text, so every page is read with OCR. Do not mix a PDF with images.
- At least one voice in the library for narration.
- For podcast mode: an LLM configured in Settings (`llm_url`, `llm_model`), and
two or three voices chosen as hosts.
@ -179,6 +179,7 @@ turned into a podcast discussion between two or three voices you pick.
and is never reshuffled by *Fix reading order*, a type change or a newly
drawn box (a new box is read last until you move it); **"Automatic
order"** hands the page back to the automatic column order.
While you order, a panel pinned to the bottom-right of the window shows how many boxes are numbered and holds **Save order**, **Undo last** and **Cancel** (or press **Esc**). It stays in view however far you scroll, sits above the Back / Approve bar and clear of the page image, and turns green when every box is numbered. **Save order** is only enabled once you have clicked at least one box; on a phone the panel spans the width of the screen.
**"Clean up boxes"** fixes four problems across the whole issue at once:
short text inside a picture (a label on a screenshot, a word on a slide in
a photo) becomes *Text in picture*; boxes of one or two characters become
@ -253,6 +254,7 @@ turned into a podcast discussion between two or three voices you pick.
**Text** / **Images** toggles that show or hide the article text as karaoke (the whole article scrolls, the sentence being read is marked and the current word is highlighted; word timing is estimated from the MP3 position) and the article's current picture. Everything is a
large touch target and keyboard reachable; the speed also applies to the
playlist player.
**Hands-free:** while a track plays, the lock screen, notification shade, Bluetooth headset and car display show the article title, magazine and first-page artwork and offer play/pause, previous/next article and ±15 s (and seeking, where the device offers it). In driving mode the screen is kept awake while audio plays and allowed to sleep when it is paused or you leave. Both are optional browser features; where one is missing or refused, playback itself is unaffected. Opening driving mode resumes the first unfinished article at the position you left it.
**Expected result:** the article plays from its headline to its final paragraph.
Page furniture (running heads, page numbers), adverts, sidebars and pull quotes
@ -338,13 +340,90 @@ Open an article and expand **Pronunciation & spelling** under the player.
**Mobile / accessibility:** the lists are ordinary buttons with labels ("Replace X with Y"); marks are underlined, not colour-only.
**Privacy:** the agent sends only the flagged word and its surrounding sentence to the LLM you configured in Settings; without an LLM nothing leaves the machine.
### The page overview: what every page needs from you
**Purpose.** The page grid (the *Thumbnails* view of a magazine's contents) shows every page of the issue at once and tells you,
in words, where you still have to act.
**Steps.** Read the line above the grid: "*N of M pages need you*". Tick **Show only pages that need me** to hide everything the
system has settled. Each card shows, top to bottom: the page, **how many articles are on it**, each article's title with
**"continues from p.X" / "continues on p.Y"** where it flows to another page, a **status** (for example *Text found, not checked yet*,
*Problems found*, *Approved*, *Done: audio is ready*, *Advertisement: not read*), and either a blue **To do** box or "Nothing to do
here". Click the page image to open it in Review.
**What the to-do lines mean.** *Open the page … and approve it*: detection found text and nobody has confirmed it. *Problems found*
lists what the quality check saw and which tool fixes it (*Find missed text*, *Clean up boxes*, *Spelling*, merge or shrink
overlapping boxes, keep a page in one article only). *Text was found here but it belongs to no article*: link the page to the
article it continues, or start a new one. *Generate the audio*: approved, but no MP3 yet. *N of the M articles here still wait for
approval*: a page shared by several articles is coloured by its most advanced one, so check the others. A small grey note tells you
when a page you approved had problems that the quality check found earlier.
**Expected result.** When the summary says nothing is left, every page is approved, settled as an advertisement or empty, or has audio.
**Errors / limits.** The quality-check lines appear only after a quality run (*Auto-fix all pages* or *Quality check*); before that,
pages show the status only. A page with no text and no article is reported as having nothing to do, which is right for a full-page
picture but worth a glance if you expected text.
**Mobile / accessibility.** Status and to-do are plain text beside the colour, so nothing depends on seeing colours; the legend is 14 px
with large swatches. On a phone use fewer columns (the *Columns* slider) for more readable cards.
### EPUB with synchronised audio
**Purpose.** Take an issue to an e-reader or reading app that highlights the sentence being spoken (EPUB 3 *Media Overlays*).
**Prerequisites.** At least one approved article with generated audio. Only those are included, in contents order.
**Steps.** Open the magazine's contents and press **EPUB with audio**. The book downloads; open it in a reader that supports
read-aloud overlays (for example Thorium Reader, Apple Books, or Readium-based apps).
**Expected result.** One chapter per article, a table of contents, the MP3s inside the file, and each sentence linked to its
place in the audio. The file passes the W3C EPUBCheck (0 errors, 0 warnings on a real two-article, twelve-minute test).
**How exact is the highlighting.** Audio rendered **after 2026-09-30** records where every sentence starts and ends, so the
highlight is exact. Older audio has no such record: its sentences are placed by estimate, proportionally to their length
across the real duration, so the highlight can drift by a sentence or two. The book's description states which it is; to get
exact timings for an older article, regenerate its MP3.
**Errors.** No approved article with audio gives "No approved article has audio yet". Audio whose length cannot be read is
skipped. Sidebars and figures are not part of the book.
**Mobile / accessibility.** The book is reflowable text, so text size and contrast are set in the reader. A screen reader
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.
### 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
**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.
**Expected result.** The first recognised text is kept through any number of edits; reading the region again (*Re-OCR*)
makes the new reading the original. Edits are reversible with Undo as before.
**Errors.** Empty text is refused (delete the region instead). Very long texts (over about 1,500 words) are compared as
one removed block and one added block rather than word by word.
**Mobile / accessibility.** The buttons are real buttons with an expanded/collapsed state; the comparison is a labelled
group that explains the minus/plus convention to a screen reader.
**Privacy.** The recognised text stays on the server with the magazine; nothing is sent elsewhere.
### Find an issue online
**Purpose.** Pull a magazine issue into the app without leaving for a browser and uploading by hand.
**Prerequisites.** Internet access for the Internet Archive; for your own library, an OPDS catalogue
(Calibre-Web, Kavita or Komga all serve one) added under `magazine_opds_catalogs` in the app settings as
`[{"name": "NAS", "url": "http://nas.local:8083/opds/"}]`. Catalogues that need a login are not supported yet.
**Prerequisites.** Internet access for the Internet Archive. For your own library, an OPDS catalogue (Calibre-Web, Kavita or
Komga all serve one): open **Your catalogues (OPDS)** in the same panel and add its name and address (for example
`http://nas.local:8083/opds/`). If it needs a login, enter the username and the **name of an environment variable** that holds the
password (for example `NAS_OPDS_PASSWORD`), and set that variable for the app's container; the password itself is never typed
into, stored in, or shown by the app, and it is sent only to that catalogue's own address. The list tells you if the variable is not
set on the server. Up to 10 catalogues. Use an `https://` address for a catalogue reached over the internet: a login to an `http://`
address travels unencrypted.
**Steps.**
1. Open Magazines. In *Find an issue online* pick a source, type a title or subject, press Search.
@ -357,7 +436,7 @@ Open an article and expand **Pronunciation & spelling** under the player.
**Errors.** A source that is down, rate-limiting or returns something other than a PDF gives a plain message
under the search box and nothing is created that you must clean up beyond the empty project shown in your library.
Lending-only Internet Archive items are never listed. Only one download runs at a time; a PDF larger than
400 MB is refused.
400 MB is refused. A catalogue's results are read across up to five of its result pages, so "next page" of a large library works.
**Mobile / accessibility.** The form stacks on a phone; results are a list with labelled Import buttons; status
text is a polite live region, so a screen reader hears "N results" and download progress.

34
TODO.md
View File

@ -656,7 +656,7 @@ casting. The original PDF remains the visual source of truth while approved arti
### P0 — Magazine foundation
- [x] Add a Magazine project type beside Books and Theater Plays.
- [~] Add PDF/JPG/PNG import with page previews and safe local storage. *(PDF + page renders done; JPG/PNG not done)*
- [x] Add PDF/JPG/PNG import with page previews and safe local storage. *(2026-09-29: JPG/PNG page images become one scan-style PDF, see architecture/magazine-ingestion.md)*
- [x] Add OCR-engine selection from Engines > OCR & Document Parsing, with page-image input and structured output.
- [x] Detect contents/index pages and propose article titles, authors, and page ranges.
- [x] Build a review screen where users approve or correct article boundaries before TTS.
@ -680,16 +680,16 @@ casting. The original PDF remains the visual source of truth while approved arti
### P1 — Magazine listening experience
- [~] Generate clean article HTML with headings, captions, figures, sidebars, and page links.
*(headings/byline/captions styled and page-linked, figures now shown during playback; sidebars are auto-excluded
from narration rather than rendered inline, per the P2 sidebar-exclusion item below)*
- [x] Generate clean article HTML with headings, captions, figures, sidebars, and page links.
*(headings/byline/captions styled and page-linked, figures shown during playback, and (2026-09-29) sidebars shown in
place as labelled, never-narrated asides)*
- [x] Generate narrator audio per article with resume/progress and a play-all queue.
- [x] Synchronize paragraph/sentence highlighting with playback and keep PDF and article views linked.
*(sentence-level; clicking a sentence or switching to the original-page view jumps to its source bbox)*
- [x] Show article images or page crops during playback, with optional caption reading.
*(images shown, matched to reading position; caption text is not separately spoken)*
- [~] Add confidence warnings, extraction diffs, and manual text correction before audio generation.
*(confidence badges + block-level boundary editing done; no extraction diff view or free-text correction)*
- [x] Add confidence warnings, extraction diffs, and manual text correction before audio generation.
*(confidence badges, boundary editing, per-region text editing, and (2026-09-29) an Edited mark with a word diff against the recognised text and one-click restore)*
- [ ] **Redesign the magazine contents view** (`#s-magazine`, the article list for one issue). Today it is a flat
list of title + page range + badge + Review button, which shows nothing of the issue itself: no page, no
opening text, no figure, no sign of which articles already have audio. Requested directions, none decided:
@ -705,9 +705,9 @@ casting. The original PDF remains the visual source of truth while approved arti
### P2 — Advanced magazine intelligence
- [~] Improve cross-page continuation detection and automatic sidebar/advertisement exclusion.
*(sidebar exclusion done via mark_sidebars, a narrow-column geometry heuristic; cross-page continuation still only
uses the forward "continued on page N" reference, not "continued from")*
- [x] Improve cross-page continuation detection and automatic sidebar/advertisement exclusion.
*(sidebar exclusion via mark_sidebars, a narrow-column geometry heuristic; cross-page continuation now follows both
"continued on page N" and "continued from page N", 2026-09-29, unit-tested, not yet seen on a real issue)*
- [x] Add image descriptions, chart/table handling, and optional spoken figure summaries.
*(per-figure "Describe" in review generates a type-aware description — chart/table/code/figure each get their
own prompt — via the configured vision engine with fallback; editable before approval; spoken only if "Read
@ -717,13 +717,13 @@ casting. The original PDF remains the visual source of truth while approved arti
caption-capable model is reachable as the fallback; documented as a config-dependent limitation in Manual.md,
not a code defect.)*
- [~] Add word-level karaoke timing and EPUB 3 Media Overlay export.
*(word-level highlighting done as a character-length estimate against the real measured audio duration, not true
forced alignment; no EPUB 3 export)*
- [~] Add mobile/driving mode with large controls, automatic article advance, and hands-free resume.
*(driving mode done: two oversized controls, reuses whatever is already playing/queued; not mobile-specific)*
- [~] Add OCR-engine benchmark comparisons and per-page fallback selection.
*(per-page fallback done, now a real two-engine chain: vision OCR escalates when a page needs OCR or scores
poorly, KDL Frontier Parser nano is the vision fallback, tesseract is the last resort; no formal benchmark)*
*(EPUB 3 Media Overlay export done 2026-09-30, EPUBCheck-clean, with exact sentence timings for newly rendered audio; word-level
highlighting is still a character-length estimate within each sentence, not forced alignment; the in-app MP3 player still
aligns its transcript by estimate and could use the new timing records)*
- [x] Add mobile/driving mode with large controls, automatic article advance, and hands-free resume.
*(driving mode with large controls, queue auto-advance and resume at the saved position; 2026-09-30: Media Session for lock screen / headset / car controls and artwork, screen wake lock while playing)*
- [x] Add OCR-engine benchmark comparisons and per-page fallback selection.
*(per-page fallback is a real engine chain: vision OCR escalates when a page needs OCR or scores poorly, KDL nano is the vision fallback, tesseract the last resort; 2026-09-29: `scripts/bench_ocr.py` scores any engine against a native-text PDF's own text layer, first results for tesseract, Unlimited-OCR, docling and PaddleOCR in `architecture/evidence/ocr-bench-2026-09-29/`)*
- [ ] **Evaluate additional OCR/vision candidates against the two already wired (baidu/Unlimited-OCR,
KDL-Frontier-Parser-nano) and pick the best fit per task** — user-requested 2026-09-14. Goal: low RAM/VRAM
footprint with excellent speed and quality, for both page-text transcription and open-ended figure/chart
@ -745,7 +745,7 @@ casting. The original PDF remains the visual source of truth while approved arti
### P2 — Finding magazines to read
- [~] **Browse and search online PDF magazine sources from inside the app** *(done 2026-09-29: Internet Archive + OPDS catalogues, see architecture/magazine-sources.md; still open: publisher free issues/feeds, open-access journals, OPDS login and pagination)*, so an issue can be found and pulled in
- [~] **Browse and search online PDF magazine sources from inside the app** *(done 2026-09-29/30: Internet Archive + OPDS catalogues with in-app management, login via an environment variable, and result paging, see architecture/magazine-sources.md; still open: publisher free issues/feeds, open-access journals)*
without leaving for a browser and uploading by hand. Wanted: a catalogue of sources, a search across them, and
a one-click "ingest this issue".
**Blocked on a source decision, and that decision is the whole task.** The two sites named as examples

View File

@ -1 +1 @@
1.55.0
1.56.0

View File

@ -283,6 +283,35 @@ it can drift on a sentence with heavy punctuation-driven pauses. A checkbox
(measured, not estimated — it just waits for `audio.onended`) always still
works regardless.
### EPUB 3 export and sentence timings
MP3 export renders one TTS call per spoken sentence and joins them, so every sentence's position is known exactly.
`synthesize_article_audio` now records it (`write_audio_timings`) in `audio/<id>.timings.json`: per narrated segment its
character count and the `[start_ms, end_ms]` of each of its sentences, plus the total duration and whether the audio used
interview voices. No text is stored. Older MP3s have no record.
`core/epub_export.py` (pure packaging, no layout or OCR code) builds the book. `plan_sentences` splits each narrated block
with the same sentence rule the MP3 and the reader use and assigns times in three tiers, reported per article: **exact**
(record present, segment count and every segment's character count match the current text, and the sentence counts agree),
**anchored** (paragraph start/end measured, its sentences share that span by length), **estimated** (no usable record, changed
text, or interview audio: each sentence's share of the real duration by spoken length). The package: `mimetype` stored first,
`container.xml`, an OPF 3.0 with `media:duration` per overlay and the active class, a nav document, one XHTML + one SMIL + the
stored MP3 per article. All text is XML-escaped. `GET /api/magazine/{id}/epub[?article=id]` includes approved articles that
have audio, builds into a temp file (removed after sending) and returns `application/epub+zip`. Validated with W3C EPUBCheck
5.2.1: 0 fatals, 0 errors, 0 warnings on a real two-article issue.
`mp3_duration_seconds` uses mutagen and falls back to ffprobe. ID3 tagging failing because mutagen is missing no longer
discards a finished synthesis.
### Sidebars in the reading view
`article_asides(layout, article, all_articles)` lists the sidebar regions on an article's own pages that are not part of
its narrated blocks; `GET .../articles/{id}/text` returns them as `asides` (`id, page, bbox, text, after`), where `after`
is the last narrated region before the sidebar in reading order (None = before the first on its page). A page that any
other article also lists is skipped, as are advertisement pages and empty boxes; jump cues are stripped. The client
inserts each as an `<aside>` after the sentence of region `after`, as escaped text, outside the sentence list, so nothing
about narration, highlighting, seeking or progress changes.
### Driving mode
`#mag-driving` is a full-screen overlay, not a separate playback path: it
@ -448,6 +477,17 @@ 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.
## Import from page images
`POST /api/magazine/{id}/source-images` (multipart `files`, in the order sent) builds `source.pdf` with
`images_to_pdf`, so nothing downstream changes: the PDF has no text layer and every page takes the OCR path.
Content is sniffed from the header (PNG/JPEG), never from the name or declared type; limits are 300 files, 25 MB and
60 megapixels per image (checked before decoding); EXIF orientation is applied (an unrotated JPEG is embedded
byte-for-byte, not re-encoded). Page size comes from the image's dpi; 72 and anything below 100 is the placeholder most
tools write, so it is treated as unknown (150 assumed) rather than producing a 33-inch page. The PDF is written through a
temporary file, one bad image rejects the whole upload, and a magazine that already has a source returns 409 so pages that
were reviewed can never be replaced. The browser orders files by name with numeric collation.
## Vision OCR escalation
Engines → OCR & Document Parsing lists layout-aware parsers (KDL Frontier Parser
@ -707,8 +747,9 @@ Magazines, Settings, or any future section.
- Word-level highlighting is an *estimate* (proportional to character length
within the sentence's real measured audio duration), not true forced
alignment — see "Word-level highlighting is estimated, not measured" above.
- `continued from page N` is parsed but not used for linking — only the forward
`continued on page N` reference drives it.
- `continued from page N` now links the tail to the article on page N (forward references still win; a contents-anchored
tail, a page shared by several articles, or a marker pointing forward or at an empty page changes nothing). Verified by
unit tests only: the measured c't issue contains no jump markers at all.
- Reviews-style pages with no headline are absorbed by the preceding article
when the contents page does not list them.
- KDL Frontier Parser nano (the OCR fallback engine) returns no layout

View File

@ -34,5 +34,17 @@ Every provider failure is a `SourceError` with a user-safe message (unreachable,
## Offline / sync
Needs the network only for search and download. No synchronisation state.
## Catalogues, paging and login (2026-09-30)
- `GET/PUT /api/magazine-sources/catalogues` manage the OPDS list, validated at the boundary (<= 10; `http(s)` with a host; no credentials in the
address; a login needs both a username and `password_env`, a capitalised variable name; duplicates and wrong shapes are refused and
change nothing). The GET tells whether the named variable is set, never its value.
- Login is HTTP Basic: username from the settings, password read from the environment variable named by `password_env`. It is sent only
to the catalogue's own host (every hop, including redirects, must match it; a cross-host redirect or `next` link is refused), used for
both search and download, and appears in no response, log line or error message.
- A search walks the catalogue's `rel="next"` links (at most 5 result pages, loop-protected, same host only) until the requested page of 20
can be filled, since entries without a PDF are dropped.
## Known limitations
OPDS catalogues requiring login; OPDS pagination beyond the first feed page; no thumbnails shown yet (returned in the API).
No thumbnails shown yet (returned in the API). Basic auth only (no OAuth/cookies). Each search page re-reads the catalogue from its first page
(bounded at 5), there is no result cache. Anyone who can edit the catalogue list can name any environment variable as the password source,
so the list is an administrator setting.

View File

@ -0,0 +1,230 @@
# Prompt: make this app's OCR more accurate, robust and reviewable (general documents)
Copy everything below the line into the agent of the app that processes documents, letters, papers, forms, invoices
or similar. It is written for an AI coding agent that has never seen the source repository.
The lessons come from a different app (tts-voice-creator-clone-and-design-2) that OCRs **scanned German-language
magazines** and has run it on a 204-page issue for weeks. Its reference code is on the same machine, if you want to read
how a rule was implemented (read it, do not blindly copy it):
`/home/sparky/Docker/tts-voice-creator-clone-and-design-2` — `core/magazine.py` (pure logic), `routes/magazine.py`
(API/jobs), `static/js/magazine.js` (review UI), `scripts/bench_ocr.py` (benchmark), `tests/test_magazine.py`,
`architecture/magazine-ingestion.md` (the reasoning, including every measured number quoted below).
**How to read the evidence.** Each claim below is tagged:
- **[measured]** — measured on magazine pages, with the number given.
- **[principle]** — a general rule the magazine work made clear; expected to transfer.
- **[untested here]** — a recommendation for document types the reference app never processed. Treat it as a hypothesis
and check it on your own documents before relying on it.
---
## Your task
This app already runs OCR on documents such as letters, papers and forms. Improve its accuracy, robustness and
reviewability by adopting the approach below **where it fits this app's documents** — first measure, then change.
Do not port code wholesale. Sort every numbered item into *applies / does not apply / needs a decision* for this app
and show me the plan before starting the larger ones.
Follow this project's own CLAUDE.md and conventions. Inspect the repository and the current OCR code first; preserve
behaviour that works; update docs and tests in the same change; commit per feature; do not push unless asked.
## 0. Privacy first (letters, papers and forms are personal data)
**[principle]** Documents like these contain names, addresses, account numbers, health or legal content. Before any
other work, find out where the current pipeline sends pages and decide with me:
- Prefer **local** OCR. Do not add a cloud OCR/vision API without checking its licence, cost, rate limits, data
retention and privacy terms, and without my explicit approval.
- Keep page images and recognised text out of logs, error messages, analytics, git, test fixtures and benchmark output.
Benchmark results and bug reports contain **metrics only**, never page text.
- Bound retention: say where originals, renders and text are stored, and provide a way to delete a document completely.
- Redaction/PII: if the app shows or exports text, consider a review-time option to mask identifiers; do not build
PII detection unless asked.
## 1. Measure before touching anything
Build a benchmark first, or every later claim is opinion.
1. **Ground truth without hand labelling [measured].** Use documents that already have a real text layer (born-digital
PDFs: papers, generated letters, invoices). Render pages to images at the resolution the app really uses, run each OCR
engine on the *image*, and score against the PDF's own text layer. Score lower-cased word tokens (join line-end
hyphenation first): **bag F1** (order-insensitive), **sequence similarity** (`difflib.SequenceMatcher` over tokens,
which also rewards correct reading order), seconds per page, and memory footprint. Reference: `scripts/bench_ocr.py`.
The score is capped below 1.0 by things a text layer has that a transcription need not (headers, footers, footnotes),
so **compare engines to each other, never to 1.0**.
2. **Also test what the text layer cannot grade [principle].** Real letters and photographed documents are scans or
phone photos: render some born-digital pages, then *degrade* them on purpose (rotate 1–3°, blur, add noise, JPEG-compress
hard, lower dpi, uneven lighting) and measure again. Add a handful of genuinely scanned or photographed documents
(yours, with permission) that you correct by hand; keep them outside git.
3. **Structure needs its own small labelled set [principle].** Transcription accuracy and layout quality are separate
problems. Label 10–20 pages (boxes, reading order, region types, and for forms the key fields) and score them
separately.
4. **Field-level accuracy, if the app extracts fields [untested here].** For letters/invoices/forms the useful metric is
"did we get the date / total / sender / IBAN right", exact-match per field, not character accuracy.
**What we found [measured, magazines, 6 clean pages, 2026-09-29]:** tesseract deu+eng F1 0.880 at 2.2 s/page; a GPU
vision model (Baidu Unlimited-OCR) 0.865 at 44 s/page and returned nothing on 1 of 6 pages; docling on CPU 0.867 at
8.9 s; PaddleOCR 3.x on CPU 0.881 at 36 s. **Plain transcription accuracy was a wash; the cheapest engine was 20×
faster.** What the heavier engines add is *structure*, not better characters. **This may not hold for degraded scans,
handwriting, stamps, tables or non-Latin scripts — test it on your documents** before paying for a heavier engine.
## 2. The pipeline shape that worked
```
render page (fixed dpi) ─► use native text if the page has it ─► OCR only where needed ─► regions with bbox
─► trust engine labels ─► fit boxes to the ink ─► classify the ambiguous ─► reading order
─► clean-up (re-type, never delete) ─► quality audit ─► human review ─► only then use the text
```
### 2.1 Pre-processing for real scans [untested here]
The magazine scans were clean. Letters and photos are not. Before OCR, evaluate (each measured on your benchmark, kept
only if it helps): deskew (estimate skew from text-line angle), orientation detection (0/90/180/270), border/shadow
removal, contrast normalisation/binarisation for uneven lighting, and dpi normalisation (too low hurts, too high is
slow). **Never overwrite the original**; keep the processed render separate so any step can be undone and re-run.
Fixed page rotation is a common hidden cause of "OCR is bad".
### 2.2 Escalate per page, not per document [measured]
Use native text when present. Escalate a page to OCR when it has almost no native text (we used < 120 characters) **or**
its existing text scores badly on a coarse plausibility score (share of letters vs symbol noise, word shape). The score is
deliberately coarse: it gates an expensive call; it will **not** catch one misread in a good sentence (`KI` → `K |`).
### 2.3 A fallback ladder where every rung may fail [measured]
Primary vision/layout engine → second engine → local classical OCR (tesseract) → keep whatever text exists. Any failure
(unreachable, timeout, HTTP error, unparseable, repetition loop) returns `None` and the next rung runs. **OCR must never be
the reason a whole import fails.** An engine that returns plain text with no coordinates is still useful as one
full-page "text" region, gated by the same plausibility score, accepting that layout is lost for that page.
### 2.4 Distrust model output in specific, testable ways [measured]
Each of these happened on real pages; each is a small function with tests:
- **Repetition loops:** a vision model transcribed a chart as the same phrase for its whole token budget. A short phrase
repeated ≥ 8× back-to-back = failed call, fall through to the next rung.
- **Hallucinated refusals:** over unreadable regions an engine writes "the image is too blurry to recognise…". Drop a
sentence that repeats across ≥ 3 regions (29 and 122 such regions removed on re-OCR of three pages).
- **Invented graphics:** a layout model may propose a picture/table box where OCR found nothing. Screen each against the
page's own ink density before creating it: invented boxes that were really text had 0.10–0.14 mean ink, real
photos/charts/tables 0.56–0.97 — a wide gap.
- **Engines transcribe better than they localise:** see 2.5.
- A **direct engine label** (header, footer, page number, caption, title) is authoritative: skip heuristic
re-classification for it; only ambiguous "text" regions go through your own classifier.
- **A model may never edit text unless a deterministic check confirms the edit** (for spelling: the replacement must be a
dictionary suggestion or a single known word within two edits, with offsets re-verified against the current text).
### 2.5 Get graphic/table bounds from the render, not from the engine [measured]
Over 138 figure regions the engine's text boxes matched the render closely enough, but its figure boxes were routinely
offset, undersized or on the neighbouring column — while the *contents* were right. The model knows **which** graphic;
the render knows **where it ends**. Approach (`snap_visual_bboxes`): reduce the render to an ink grid (10 px cells →
inked fraction; body text ≈ 0.25, a photo ≈ 1.0); a strict solid-ink pass (cell ≥ 0.55) anchors on photos/panels and a loose
pass (any mark ≥ 0.15, ≤ 2 blank cells crossed) grows sparse charts; decide connectivity **once** (dilate, label, pick the
component the seed overlaps most — re-picking per seed made results depend on seed size); trim edges back to the nearest
row/column blocked across ≥ 70% of the width/height; fence off text blocks and other figures; accept only if solidly
inked (≥ 0.45) or framed on ~90% of rows and columns, never shrunk below 30% of the seed, and **reproducing itself** when
fed back (IoU ≥ 0.7); two figures pulled onto one graphic → revert. 98–119 of 147 regions corrected, none worse. This
matters for papers (figures, charts) and tables; it is **[untested here]** for ruled tables and forms, where line detection
(rows/columns from long horizontal/vertical strokes) is the better anchor — evaluate it.
Make it idempotent and re-runnable on existing documents without paying for transcription again.
### 2.6 Fit text boxes to their ink [measured]
Small, bounded, geometry-only: if a side cuts through letters (ink continues just outside where no other box is) grow it
until the text ends, by at most ~16 px sideways (70 for large type, 90 downward); trim empty margin back to the first
inked line, leaving 2 px of air. Never touch a box a human drew or resized; never change text. On the measured issue only 5
of 658 boxes needed it — measure first, and make sure it can never make a good box worse. Re-check for duplicates
afterwards, because fitting can turn a neighbour into one.
### 2.7 Clean-up rules: re-type, never delete; size each on real data first [measured]
The reference measured 2,038 boxes before writing any rule: text ≤ 80 chars and ≥ 90% inside a picture → `in_picture`
(71 found; longer text was real running text in a loose frame); ≤ 2 alphanumeric characters → `ocr_noise` (25; longer short
strings included a phone number, a price, a citation); a box ≥ 80% inside a bigger one whose text contains ≥ 90% of it →
`duplicate` (38). **The other 333 overlapping pairs held different text, and hiding one loses text.** So every rule
*re-types* (kept, visible, reversible), is idempotent, runs on import and on re-OCR, and writes a timestamped backup first.
**Overlaps with different text go to a human review list with a suggested action per pair, not to an auto-fix.**
### 2.8 Reading order [measured for multi-column pages]
Sorting by y interleaves parallel columns. What worked: columns from where column-width blocks *start* (clustered left
edges), not from whitespace between columns (OCR boxes are loose; a 2 pt gutter or a block across it defeated the
whitespace method); measure column width on body text only; a headline/abstract/title spanning columns in a strip of its
own is read first; **never** use paragraph gaps as breaks (they line up across columns and re-interleave them — tried,
measured, reverted). Score it with a text-only check (a block ending mid-sentence must be followed by its continuation; a
split word by its other half): 85 → 89 confirmed continuations over 204 pages. Let the user lock a hand-ordered page.
- **Papers [untested here]:** two-column body, full-width title/abstract, footnotes and running headers at the edges,
references as a list, figure/table captions that belong to their float, equations as their own region. The column logic
above should apply; footnote/caption/reference handling needs its own rules and tests.
- **Letters [untested here]:** mostly single column, but with a fixed semantic layout: letterhead, sender line,
recipient block, date, subject line, salutation, body, closing/signature, footer (bank details, registration). Reading
order is simple; **classifying these blocks** is the value (often more so than transcription) and is where a layout
model or simple position+keyword rules can pay off. Stamps, handwritten notes and signatures overlap text — treat them
as their own region types, not as text.
- **Forms and invoices [untested here]:** reading order is secondary to key-value pairing (label ↔ value) and table rows.
Don't force them through a prose pipeline.
### 2.9 Running headers/footers/page numbers [measured]
Identify by **repetition**, not position (a running head and a title both sit at the top). Exact-match repetition fails
once OCR is involved (`c't Wissen 2026` came back as `cE KI-WISSEN`, `ce Wissen 2136`…), so add a fuzzy pass: near-edge
blocks, digits stripped, `SequenceMatcher` ≥ 0.6, ≥ 3 occurrences. Without it, footers produced 26 bogus sections in one
scan. Matters for multi-page letters, contracts, reports and papers.
### 2.10 A whole-document quality pass that reports, and fixes only what is provable [measured]
Per page, read-only, from the render: ink blobs outside every box (ignore page-edge margins ~3%, dust < 10 cells, rules and
dotted leaders < 6 ink rows, unboxed ink within 10 pt of a picture); overlapping text boxes (sharing ≥ 30% of the smaller);
boxes whose edge clips letters (ink in a 14 px strip just outside a side; skip strips that are pictures, ink > 0.7); and
suspect blocks. An independent completeness check: run a second, local OCR over the page and **add** missing text as new
**unticked** regions, never replace boxes. Re-read dictionary-suspicious regions with the image engine and accept **only** a
better reading. Run it automatically as the last import step under the same progress job; report failures instead of
hiding them.
- **Language/dictionary [principle]:** a spell check needs the document's language. Letters and papers are often
multilingual or full of names, codes and jargon: decide language per document (and per block only when decisively
different), keep a per-document dictionary of accepted words, and treat spelling as *findings*, not rewrites. For
identifiers (IBAN, invoice numbers, dates, amounts) prefer **checksum/format validation** (IBAN mod-97, date parsing,
sum checks) over dictionaries **[untested here]** — a wrong digit is the costly error in this domain.
### 2.11 Handwriting [untested here]
Classical OCR and most of the engines above do poorly on handwriting. Do not assume otherwise. If the documents contain
any: detect it (a region type), keep it as an image with an editable transcription, and evaluate a handwriting-capable
model on your own samples against a manual transcription before promising anything.
### 2.12 Human review is part of the design [measured]
Nothing downstream (search, export, automation) should use unreviewed text for high-stakes fields. Review-screen essentials
that paid off: region boxes drawn on the page, colour-coded by type with a legend; numbered in reading order; per-region type
picker, delete, merge-with-next, split-at-cursor, per-region re-OCR, inline text edit; keyboard navigation; bulk actions with
**undo instead of confirmation dialogs** (one level, tokenised, sealed with a digest so a stale Undo can never roll back
newer work); draw-a-missed-region; a live ingest preview that draws boxes as they are found (decorative — progress lives in a
separate status line for screen readers). Show low-confidence words/fields visibly. WCAG 2.2 AA: icon plus text, never
colour alone; labelled controls; a keyboard alternative for every drag; reduced motion respected.
## 3. Engineering rules that kept this maintainable [principle]
- **Pure logic in one module, I/O at the edges.** Geometry and classification take plain data, so tests need no PDF, no
engine, no network. Import heavy libraries lazily. Mock HTTP for engine tests.
- **Constants are named and commented with the measurement that set them**; when you change one, re-measure.
- **Every destructive action writes a backup or an undo point first;** writes are atomic; jobs are restart-safe and a
"running" state whose job died is reported as failed, not spun forever.
- **Idempotence is a feature:** re-running any fix on an already-fixed document changes nothing.
- **Security at the boundary:** validate ids, bound upload and download sizes, set timeouts on every engine call, no
credentials in URLs or logs, validate MIME type and scan uploads, protect against path traversal and SSRF.
- **Test both directions:** for each heuristic add a test for the case it fixes *and* the case it must leave alone (the
"left alone on purpose" cases above came from failing tests, not foresight).
## 4. Order of work I recommend
1. Privacy review of the current pipeline (section 0) and the benchmark + baseline (section 1). Report the numbers before
changing anything, including results on degraded renders.
2. Pre-processing for real scans (2.1), evaluated one step at a time.
3. Cheap, high-yield robustness: per-page escalation, fallback ladder, repetition/refusal filters, trusted engine labels.
4. Box and table quality: fit to ink, graphic/table bounds from the render, clean-up rules — each sized on real data first.
5. Document-type handling: papers (columns, captions, references), letters (block classification), forms (key-value,
tables). Start with the type most common in this app; build a small labelled set for it.
6. Quality audit, review list for the ambiguous cases, and the review-UI improvements that fit.
7. Re-benchmark with the same metrics; report before/after and list what did **not** help.
## 5. Deliverables
- A short plan classifying each item (applies / does not apply / needs decision) with expected effort and risk.
- The benchmark script, results (metrics only) and a README saying what was measured, when, on what.
- Each adopted item: implementation, unit tests (positive and negative), docs/manual/changelog updated, one commit.
- A final before/after table and an honest list of limitations and of things tried that did not help.
## 6. What not to do
- Do not add a heavy OCR/vision engine because it is fashionable: on clean magazine pages it gave no accuracy gain over
tesseract at a 4–20× cost. Prove the gain on *this* app's documents, especially degraded ones.
- Do not send private documents to a cloud service without my explicit approval.
- Do not auto-delete or auto-merge overlapping boxes that hold different text.
- Do not let a model edit text without a deterministic confirmation.
- Do not trust an engine's bounding boxes for graphics or tables.
- Do not put page text into logs, tests, fixtures, benchmarks or git.
- Do not install multi-GB models without checking free memory first (GPU and system memory can be one pool; an
out-of-memory kill can take down unrelated services).

223
core/epub_export.py Normal file
View File

@ -0,0 +1,223 @@
"""EPUB 3 with Media Overlays: an issue's narrated articles as a book that highlights while it plays.
Pure packaging: text blocks, the article's MP3 and (when it exists) the sentence timings recorded at
synthesis go in; a .epub file comes out. No network, no layout or OCR code, so it is testable on its own.
Timing quality is reported per article, honestly:
exact every sentence's start/end was measured when the MP3 was rendered (audio/<id>.timings.json)
anchored the paragraph's start/end were measured; sentences inside it share that span by length
estimated no usable record (an MP3 from before timings were kept, or the text changed since): every
sentence's share of the MP3's real duration is proportional to its spoken length
"""
from __future__ import annotations
import re
import uuid
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Sequence
from xml.sax.saxutils import escape, quoteattr
_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?…])\s+(?=[A-ZÀ-Ü»„\"'‘])") # same rule the MP3 and the reader use
_HEADING_TYPES = {"title": "h1", "heading": "h2"}
_CLASS_TYPES = {"deck": "deck", "byline": "byline", "caption": "caption", "figure_description": "figure"}
_ACTIVE_CLASS = "-epub-media-overlay-active"
MAX_ARTICLES = 200
def split_sentences(text: str) -> list[str]:
return [p.strip() for p in _SENTENCE_SPLIT_RE.split(text or "") if p.strip()]
def clock(ms: float) -> str:
"""SMIL clock value h:mm:ss.mmm."""
ms = max(0, int(round(ms)))
h, rem = divmod(ms, 3_600_000)
m, rem = divmod(rem, 60_000)
return f"{h}:{m:02d}:{rem // 1000:02d}.{rem % 1000:03d}"
def _weight(text: str) -> float:
words = text.split()
return sum(max(1, len(re.sub(r"[.,!?;:\"'()«»„“”]", "", w))) + 0.6 for w in words) or 1.0
def plan_sentences(blocks: Sequence[dict[str, Any]], duration_ms: float,
timings: dict[str, Any] | None) -> tuple[list[dict[str, Any]], str]:
"""Split narrated blocks into sentences with [start, end] milliseconds; returns (units, quality).
Each unit: {block: index, text, start, end}. Blocks with no text are skipped.
"""
narrated = [(i, b) for i, b in enumerate(blocks) if (b.get("text") or "").strip()]
units: list[dict[str, Any]] = []
for i, b in narrated:
for s in split_sentences(b["text"]):
units.append({"block": i, "text": s, "spoken": s})
if not units:
return [], "estimated"
segs = (timings or {}).get("segments") if timings and not timings.get("interview") else None
usable = isinstance(segs, list) and len(segs) == len(narrated) and all(
isinstance(s, dict) and s.get("chars") == len(str(b["text"])) and isinstance(s.get("times"), list)
and s["times"] for (_, b), s in zip(narrated, segs))
if usable:
quality = "exact"
for (i, b), seg in zip(narrated, segs):
mine = [u for u in units if u["block"] == i]
times = seg["times"]
if len(times) == len(mine):
for u, (s0, s1) in zip(mine, times):
u["start"], u["end"] = float(s0), float(s1)
else: # counts disagree: keep the paragraph exact, split by length
quality = "anchored" if quality == "exact" else quality
a, z = float(times[0][0]), float(times[-1][1])
total = sum(_weight(u["text"]) for u in mine)
t = a
for u in mine:
share = (z - a) * _weight(u["text"]) / total
u["start"], u["end"] = t, t + share
t += share
return units, quality
total = sum(_weight(u["text"]) for u in units)
t = 0.0
for u in units:
share = duration_ms * _weight(u["text"]) / total
u["start"], u["end"] = t, t + share
t += share
return units, "estimated"
def _xhtml(title: str, lang: str, body: str) -> str:
return (
'<?xml version="1.0" encoding="utf-8"?>\n<!DOCTYPE html>\n'
f'<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" '
f'xml:lang={quoteattr(lang)} lang={quoteattr(lang)}>\n<head><meta charset="utf-8"/>'
f'<title>{escape(title)}</title><link rel="stylesheet" type="text/css" href="style.css"/></head>\n'
f'<body>\n{body}\n</body>\n</html>\n')
_CSS = f""".deck {{ font-style: italic; }}
.byline {{ color: #555; }}
.caption, .figure {{ font-size: .9em; color: #444; }}
.{_ACTIVE_CLASS} {{ background: #ffe08a; color: #000; }}
"""
def build_epub(dest: Path, *, title: str, issue: str, language: str,
articles: Sequence[dict[str, Any]], identifier: str | None = None) -> dict[str, Any]:
"""Write the EPUB to `dest`; returns {articles, duration_ms, quality: {article title: tier}}.
Each article: {"title", "author", "blocks": [article_text dicts], "mp3": Path, "duration_ms", "timings"}.
Raises ValueError when there is nothing to package. The file is written to a temporary name and moved
into place, so a failure never leaves a half-written book.
"""
if not articles:
raise ValueError("No article has audio to package")
if len(articles) > MAX_ARTICLES:
raise ValueError(f"At most {MAX_ARTICLES} articles per book")
lang = re.sub(r"[^A-Za-z-]", "", language or "") or "de"
uid = identifier or f"urn:uuid:{uuid.uuid4()}"
modified = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
manifest: list[str] = []
spine: list[str] = []
nav_items: list[str] = []
meta_durations: list[str] = []
files: list[tuple[str, bytes | Path, bool]] = [] # (path in package, content, compress?)
quality: dict[str, str] = {}
total_ms = 0.0
for n, art in enumerate(articles, 1):
key = f"a{n:03d}"
units, tier = plan_sentences(art["blocks"], float(art["duration_ms"]), art.get("timings"))
if not units:
continue
quality[str(art.get("title") or key)] = tier
body: list[str] = ['<section epub:type="chapter">']
pars: list[str] = []
current, open_tag = -1, ""
for k, u in enumerate(units, 1):
if u["block"] != current:
if open_tag:
body.append(f"</{open_tag}>")
btype = str(art["blocks"][u["block"]].get("type") or "body")
open_tag = _HEADING_TYPES.get(btype, "p")
cls = _CLASS_TYPES.get(btype)
body.append(f"<{open_tag}" + (f' class="{cls}"' if cls else "") + ">")
current = u["block"]
sid = f"{key}s{k:04d}"
body.append(f'<span id="{sid}">{escape(u["text"])}</span> ')
start = u["start"]
end = max(u["end"], start + 1)
pars.append(f'<par id="{key}p{k:04d}"><text src="{key}.xhtml#{sid}"/>'
f'<audio src="audio/{key}.mp3" clipBegin="{clock(start)}" clipEnd="{clock(end)}"/></par>')
body.append(f"</{open_tag}>")
body.append("</section>")
smil = ('<?xml version="1.0" encoding="utf-8"?>\n'
'<smil xmlns="http://www.w3.org/ns/SMIL" xmlns:epub="http://www.idpf.org/2007/ops" version="3.0">\n'
f'<body><seq id="{key}seq" epub:textref="{key}.xhtml" epub:type="bodymatter chapter">\n'
+ "\n".join(pars) + "\n</seq></body>\n</smil>\n")
dur = float(art["duration_ms"])
total_ms += dur
a_title = str(art.get("title") or f"Article {n}")
files.append((f"OEBPS/{key}.xhtml", _xhtml(a_title, lang, "\n".join(body)).encode("utf-8"), True))
files.append((f"OEBPS/{key}.smil", smil.encode("utf-8"), True))
files.append((f"OEBPS/audio/{key}.mp3", Path(art["mp3"]), False))
manifest += [
f'<item id="{key}" href="{key}.xhtml" media-type="application/xhtml+xml" media-overlay="{key}smil"/>',
f'<item id="{key}smil" href="{key}.smil" media-type="application/smil+xml"/>',
f'<item id="{key}audio" href="audio/{key}.mp3" media-type="audio/mpeg"/>',
]
spine.append(f'<itemref idref="{key}"/>')
nav_items.append(f'<li><a href="{key}.xhtml">{escape(a_title)}</a></li>')
meta_durations.append(f'<meta property="media:duration" refines="#{key}smil">{clock(dur)}</meta>')
if not spine:
raise ValueError("No article has narratable text")
tiers = sorted(set(quality.values()))
note = ("Sentence timings: " + ", ".join(tiers)
+ (" (estimated where the audio predates timing records)" if "estimated" in tiers else ""))
full_title = f"{title} — {issue}" if issue else title
opf = ('<?xml version="1.0" encoding="utf-8"?>\n'
f'<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid" xml:lang="{lang}">\n'
'<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">\n'
f'<dc:identifier id="bookid">{escape(uid)}</dc:identifier>\n<dc:title>{escape(full_title)}</dc:title>\n'
f'<dc:language>{lang}</dc:language>\n<dc:description>{escape(note)}</dc:description>\n'
f'<meta property="dcterms:modified">{modified}</meta>\n'
+ "\n".join(meta_durations) + f'\n<meta property="media:duration">{clock(total_ms)}</meta>\n'
f'<meta property="media:active-class">{_ACTIVE_CLASS}</meta>\n</metadata>\n<manifest>\n'
'<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>\n'
'<item id="css" href="style.css" media-type="text/css"/>\n' + "\n".join(manifest) +
'\n</manifest>\n<spine>\n' + "\n".join(spine) + '\n</spine>\n</package>\n')
nav = _xhtml(full_title, lang,
'<nav epub:type="toc" id="toc"><h1>' + escape(full_title) + '</h1><ol>\n'
+ "\n".join(nav_items) + '\n</ol></nav>')
container = ('<?xml version="1.0" encoding="utf-8"?>\n'
'<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
'<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>'
'</rootfiles></container>\n')
tmp = dest.with_suffix(".part")
try:
with zipfile.ZipFile(tmp, "w") as z:
z.writestr(zipfile.ZipInfo("mimetype"), "application/epub+zip", compress_type=zipfile.ZIP_STORED)
z.writestr("META-INF/container.xml", container, compress_type=zipfile.ZIP_DEFLATED)
z.writestr("OEBPS/content.opf", opf, compress_type=zipfile.ZIP_DEFLATED)
z.writestr("OEBPS/nav.xhtml", nav, compress_type=zipfile.ZIP_DEFLATED)
z.writestr("OEBPS/style.css", _CSS, compress_type=zipfile.ZIP_DEFLATED)
for name, content, compress in files:
method = zipfile.ZIP_DEFLATED if compress else zipfile.ZIP_STORED
if isinstance(content, Path):
z.write(content, name, compress_type=method)
else:
z.writestr(name, content, compress_type=method)
tmp.replace(dest)
except BaseException:
tmp.unlink(missing_ok=True)
raise
return {"articles": len(spine), "duration_ms": int(total_ms), "quality": quality}

View File

@ -126,6 +126,9 @@ class Block:
# (clean_up_regions). Part of the model so every path that rebuilds blocks keeps it -- and does
# not crash on it (drawing a region did, "unexpected keyword argument 'original_text'").
original_text: str = ""
# The text as recognised, kept when the reader first edits it by hand (set_region_text), so the
# review can show what changed and put it back. Empty = never edited.
ocr_text: str = ""
# box_manual: the reviewer placed or resized this box, so snap_text_boxes never moves it.
# box_snapped: snap_text_boxes fitted it to its text. Both are model fields for the same
# reason as original_text: every path that rebuilds blocks with Block(**b) must accept them.
@ -134,7 +137,7 @@ class Block:
def to_json(self) -> dict[str, Any]:
data = asdict(self)
for optional in ("original_text", "box_manual", "box_snapped"):
for optional in ("original_text", "ocr_text", "box_manual", "box_snapped"):
if not data[optional]:
del data[optional]
return data
@ -152,6 +155,7 @@ class Article:
confidence: float = 0.0
section: str = ""
continued_to: list[int] = field(default_factory=list)
continued_from: list[int] = field(default_factory=list) # 'continued from page N' seen on this article's pages
audio_status: str = "none" # none | narration | podcast | both
def to_json(self) -> dict[str, Any]:
@ -806,6 +810,15 @@ def group_articles(blocks: Sequence[Block],
to_page, _ = find_continuation(b.text)
if to_page and to_page not in art.continued_to:
art.continued_to.append(to_page)
# "Continued from page N" is usually set as small print the classifier files as furniture or a
# sidebar, so look at every short line on the page, not only at what is narrated. A long block that
# merely mentions the phrase is prose, not a jump marker.
for b in page_blocks:
if len(b.text or "") > _MAX_JUMP_MARKER_CHARS:
continue
_, from_page = find_continuation(b.text)
if from_page and from_page != page and from_page not in art.continued_from:
art.continued_from.append(from_page)
if contributed:
art.pages.append(page)
@ -821,12 +834,25 @@ def group_articles(blocks: Sequence[Block],
return _link_continuations(articles, page_count)
def _link_continuations(articles: list[Article], page_count: int) -> list[Article]:
"""Attach jump pages ('continued on page 88') to their originating article.
_MAX_JUMP_MARKER_CHARS = 160 # a "continued from" line is a few words; longer text is prose that quotes the phrase
Without this, the tail of a feature parked at the back of the issue is
orphaned and gets narrated as a separate stub article that begins
mid-sentence.
def _absorb(head: Article, tail: Article) -> None:
head.pages.extend(p for p in tail.pages if p not in head.pages)
head.blocks.extend(tail.blocks)
head.images.extend(tail.images)
tail.status = "merged"
def _link_continuations(articles: list[Article], page_count: int) -> list[Article]:
"""Attach jump pages to their originating article, in both directions.
Forward: the head says "continued on page 88" -- the article that starts on page 88 is its tail.
Backward: the tail says "continued from page 27" -- the article that occupies page 27 is its head.
Without this, the tail of a feature parked at the back of the issue is orphaned and narrated as a
separate stub that begins mid-sentence. A forward link wins when both exist (it is processed first,
and a merged article is never absorbed twice). An article a contents entry anchors (confidence >= 0.9)
keeps its own identity either way.
"""
by_start = {a.pages[0]: a for a in articles if a.pages}
for art in articles:
@ -836,14 +862,20 @@ def _link_continuations(articles: list[Article], page_count: int) -> list[Articl
tail = by_start.get(target)
if tail is None or tail is art or tail.status == "merged":
continue
# Only absorb a genuine continuation: a real article listed in the
# contents keeps its own identity.
if tail.confidence >= 0.9:
continue
art.pages.extend(p for p in tail.pages if p not in art.pages)
art.blocks.extend(tail.blocks)
art.images.extend(tail.images)
tail.status = "merged"
_absorb(art, tail)
for tail in articles:
if tail.status == "merged" or tail.confidence >= 0.9 or not tail.pages:
continue
for source in tail.continued_from:
if not (1 <= source <= page_count) or source >= tail.pages[0]:
continue # a tail can only follow a page before it
heads = [a for a in articles if a is not tail and a.status != "merged" and source in a.pages]
if len(heads) != 1:
continue # no article there, or ambiguous (a page shared by several): leave it to the reviewer
_absorb(heads[0], tail)
break
return [a for a in articles if a.status != "merged"]
@ -2367,6 +2399,81 @@ def ocr_page(png_path: Path, lang: str = "deu+eng",
return out
# ── Image import ─────────────────────────────────────────────────────────────
IMAGE_IMPORT_MAX_FILES = 300
IMAGE_IMPORT_MAX_BYTES = 25 * 1024 * 1024 # per image
IMAGE_IMPORT_MAX_PIXELS = 60_000_000 # refuses decompression bombs before decoding
_ASSUMED_SCAN_DPI = 150 # when an image carries no (or an absurd) resolution
class ImageImportError(ValueError):
"""An uploaded page image was rejected; the message is safe to show the user."""
def _sniff_image(data: bytes) -> str | None:
"""'png' or 'jpeg' judged by the file's own header, never by its name or declared type."""
if data[:8] == b"\x89PNG\r\n\x1a\n":
return "png"
if data[:3] == b"\xff\xd8\xff":
return "jpeg"
return None
def images_to_pdf(images: Sequence[tuple[str, bytes]], dest: Path) -> int:
"""Combine page images (PNG/JPEG, in the given order) into one PDF at `dest`; returns the page count.
Each image becomes one page sized from its own resolution (so the app's fixed-dpi render of the
page comes out at the scan's real proportions), with EXIF rotation applied. The result has no text
layer, so ingestion OCRs every page exactly as for a scanned PDF. Nothing is written on failure.
"""
if not images:
raise ImageImportError("Choose at least one image")
if len(images) > IMAGE_IMPORT_MAX_FILES:
raise ImageImportError(f"At most {IMAGE_IMPORT_MAX_FILES} images per magazine")
import io
import pymupdf
from PIL import Image, ImageOps
doc = pymupdf.open()
try:
for name, data in images:
label = Path(name or "image").name[:80]
if len(data) > IMAGE_IMPORT_MAX_BYTES:
raise ImageImportError(f"{label} is larger than {IMAGE_IMPORT_MAX_BYTES // (1024 * 1024)} MB")
kind = _sniff_image(data)
if kind is None:
raise ImageImportError(f"{label} is not a PNG or JPEG image")
try:
with Image.open(io.BytesIO(data)) as im:
width, height = im.size
if width * height > IMAGE_IMPORT_MAX_PIXELS:
raise ImageImportError(f"{label} has too many pixels")
dpi = im.info.get("dpi", (0, 0))[0] or 0
if im.getexif().get(0x0112, 1) not in (0, 1): # EXIF says the pixels are rotated/flipped
upright = ImageOps.exif_transpose(im)
buf = io.BytesIO()
upright.convert("RGB" if kind == "jpeg" else upright.mode).save(
buf, format="JPEG" if kind == "jpeg" else "PNG", **({"quality": 95} if kind == "jpeg" else {}))
data = buf.getvalue()
width, height = upright.size
except ImageImportError:
raise
except Exception as e: # truncated / corrupt file
raise ImageImportError(f"{label} could not be read as an image") from e
# 72 (and anything below 100) is the placeholder most tools write, not a real scan resolution.
dpi = float(dpi) if 100 <= float(dpi or 0) <= 1200 else _ASSUMED_SCAN_DPI
w_pt, h_pt = width * 72.0 / dpi, height * 72.0 / dpi
page = doc.new_page(width=w_pt, height=h_pt)
page.insert_image(pymupdf.Rect(0, 0, w_pt, h_pt), stream=data)
tmp = dest.with_suffix(".part")
doc.save(str(tmp))
tmp.replace(dest)
return len(images)
finally:
doc.close()
_VISION_QUALITY_THRESHOLD = 0.5 # below this, existing native/OCR text is escalated
@ -4380,6 +4487,7 @@ def update_region(doc_dir: Path, layout: dict[str, Any], page_no: int, block_id:
if same_top:
new_text = prefer_drop_cap_repair(new_text, old_text)
block["text"] = new_text
block.pop("ocr_text", None) # freshly read: the new reading is the original
blocks[block_idx] = block
layout["blocks"] = blocks
return layout, new_text
@ -4670,8 +4778,18 @@ def set_region_text(layout: dict[str, Any], page_no: int, region_id: str, text:
if not any(b["id"] == region_id and b["page"] == page_no for b in layout.get("blocks", [])):
raise ValueError("Region not found on this page")
layout = dict(layout)
layout["blocks"] = [dict(b, text=text, confidence=1.0) if b["id"] == region_id else b
for b in layout.get("blocks", [])]
def edited(b: dict[str, Any]) -> dict[str, Any]:
before = str(b.get("text") or "")
first_read = b.get("ocr_text") or before
out = dict(b, text=text, confidence=1.0)
if text == first_read:
out.pop("ocr_text", None) # typed back to the original: no longer an edit
else:
out["ocr_text"] = first_read
return out
layout["blocks"] = [edited(b) if b["id"] == region_id else b for b in layout.get("blocks", [])]
return layout
@ -4874,6 +4992,39 @@ def page_is_advertisement(layout: dict[str, Any], page_no: int) -> bool:
and not any(b.get("type") in NARRATABLE for b in on_page))
def article_asides(layout: dict[str, Any], article: dict[str, Any],
all_articles: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
"""Sidebars lying on this article's pages, to show beside the text but never narrate.
A sidebar is kept out of narration (mark_sidebars), which also keeps it out of the article's own block
list -- so the reading view never showed it. Only pages this article alone occupies are used: on a page
shared by several articles there is no telling which one a sidebar belongs to, and showing it in the
wrong one is worse than not showing it. Advertisement pages carry none. Each entry says which of the
article's narrated regions it follows (`after`, or None = before the first on its page).
"""
mine = {int(p) for p in article.get("pages") or []}
shared = {int(p) for a in all_articles if a.get("id") != article.get("id") for p in a.get("pages") or []}
pages = {p for p in mine - shared if not page_is_advertisement(layout, p)}
narrated = set(article.get("blocks") or [])
blocks = layout.get("blocks", [])
out = []
for b in blocks:
if b.get("type") != BLOCK_SIDEBAR or b.get("page") not in pages or b.get("id") in narrated:
continue
text = strip_continuation_markers(str(b.get("text") or "")).strip()
if not text:
continue
before = [x for x in blocks if x.get("page") == b["page"] and x.get("id") in narrated
and (x.get("reading_order") or 0) < (b.get("reading_order") or 0)]
after = max(before, key=lambda x: x.get("reading_order") or 0)["id"] if before else None
out.append({"id": b["id"], "page": b["page"], "bbox": b.get("bbox"), "text": text[:4000],
"after": after, "_order": (b["page"], b.get("reading_order") or 0)})
out.sort(key=lambda a: a["_order"])
for a in out:
del a["_order"]
return out
def article_text(layout: dict[str, Any], article: dict[str, Any],
include_captions: bool = False,
include_descriptions: bool = False,
@ -5373,18 +5524,23 @@ def synthesize_article_audio(doc_dir: Path, article: dict[str, Any], layout: dic
voice_for = {speaker: (voice if i == 0 else interview_voice)
for i, speaker in enumerate(speakers[:2])}
utterances: list[tuple[str, str]] = []
for segment in segments:
utterance_segment: list[int] = [] # which segment each utterance came from, for the timing record
for n, segment in enumerate(segments):
spoken = segment.get("spoken") or prepare_text_for_tts(str(segment["text"]), language, glossary)
segment_voice = voice_for.get(segment.get("speaker"), voice)
utterances.extend((sentence, segment_voice)
for sentence in split_sentences_for_tts(spoken))
for sentence in split_sentences_for_tts(spoken):
utterances.append((sentence, segment_voice))
utterance_segment.append(n)
total = len(utterances)
combined = AudioSegment.empty()
spans: list[tuple[int, int]] = [] # [start_ms, end_ms) of every utterance in the final MP3
for i, (sentence, sentence_voice) in enumerate(utterances):
audio_bytes, media_type = _tts_request_audio(sentence, sentence_voice, settings)
fmt = "wav" if audio_bytes[:4] == b"RIFF" or "wav" in media_type.lower() else None
combined += AudioSegment.from_file(_io.BytesIO(audio_bytes), format=fmt)
piece = AudioSegment.from_file(_io.BytesIO(audio_bytes), format=fmt)
spans.append((len(combined), len(combined) + len(piece)))
combined += piece
if progress_cb:
progress_cb(i + 1, total)
@ -5392,11 +5548,68 @@ def synthesize_article_audio(doc_dir: Path, article: dict[str, Any], layout: dic
audio_dir.mkdir(parents=True, exist_ok=True)
out_path = audio_dir / f"{article['id']}.mp3"
combined.export(out_path, format="mp3", bitrate="128k")
tag_article_mp3(out_path, article, full_text, album=album,
cover_bytes=cover_bytes, cover_mime=cover_mime)
try:
tag_article_mp3(out_path, article, full_text, album=album,
cover_bytes=cover_bytes, cover_mime=cover_mime)
except ImportError:
# Tags (title, artist, cover, lyrics) are a convenience; losing them must not throw away minutes of
# synthesis. The audio and its timings are complete without them.
logging.getLogger(__name__).warning("mutagen is not installed: %s was saved without ID3 tags", out_path.name)
write_audio_timings(out_path, segments, utterance_segment, spans, interview=bool(interview_voice))
return out_path
def mp3_duration_seconds(path: Path) -> float | None:
"""Length of an MP3 in seconds: mutagen when installed, else ffprobe, else None (never raises)."""
try:
from mutagen import File as MutagenFile
parsed = MutagenFile(path)
if parsed is not None and parsed.info is not None and parsed.info.length:
return float(parsed.info.length)
except Exception:
pass
try:
out = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of",
"default=nw=1:nk=1", str(path)], capture_output=True, text=True, timeout=20, check=True).stdout
value = float(out.strip())
return value if value > 0 else None
except (subprocess.SubprocessError, OSError, ValueError):
return None
def write_audio_timings(mp3_path: Path, segments: Sequence[dict[str, Any]], utterance_segment: Sequence[int],
spans: Sequence[tuple[int, int]], *, interview: bool) -> None:
"""Record where every spoken sentence sits in the MP3 (`<id>.timings.json` beside it).
Measured, not estimated: the sentences were rendered one by one and joined, so each one's length is
known exactly. Holds no text -- only, per narrated segment, its character count (to notice later that
the text changed) and the [start, end] milliseconds of each of its sentences. Best-effort: a failed
write only costs the EPUB its exact timings.
"""
per_segment: list[list[list[int]]] = [[] for _ in segments]
for n, (start, end) in zip(utterance_segment, spans):
per_segment[n].append([int(start), int(end)])
record = {
"version": 1, "interview": bool(interview), "duration_ms": int(spans[-1][1]) if spans else 0,
"segments": [{"chars": len(str(seg.get("text") or "")), "times": times}
for seg, times in zip(segments, per_segment)],
}
try:
tmp = mp3_path.with_suffix(".timings.tmp")
tmp.write_text(json.dumps(record), "utf-8")
tmp.replace(mp3_path.with_suffix(".timings.json"))
except OSError:
logging.getLogger(__name__).warning("Could not write sentence timings for %s", mp3_path.name)
def read_audio_timings(mp3_path: Path) -> dict[str, Any] | None:
try:
data = json.loads(mp3_path.with_suffix(".timings.json").read_text("utf-8"))
except (OSError, ValueError):
return None
return data if isinstance(data, dict) and data.get("version") == 1 and isinstance(data.get("segments"), list) else None
def tag_article_mp3(path: Path, article: dict[str, Any], lyrics: str, *,
album: str = "", cover_bytes: bytes | None = None,
cover_mime: str = "image/png") -> None:

View File

@ -73,14 +73,14 @@ def _clip(value, limit: int) -> str:
def _get(session: requests.Session, url: str, *, timeout, stream: bool = False, host_ok=None,
accept: str = "*/*") -> requests.Response:
accept: str = "*/*", auth=None) -> requests.Response:
"""GET with manual, host-checked redirects."""
for _ in range(MAX_REDIRECTS + 1):
if host_ok is not None and not host_ok(url):
raise SourceError("The source pointed to a different host; refusing to follow it")
try:
resp = session.get(url, timeout=timeout, stream=stream, allow_redirects=False,
headers={"User-Agent": USER_AGENT, "Accept": accept})
headers={"User-Agent": USER_AGENT, "Accept": accept}, **({"auth": auth} if auth else {}))
except requests.RequestException as e:
raise SourceError(f"Could not reach the source ({type(e).__name__})") from e
if resp.is_redirect or resp.status_code in (301, 302, 303, 307, 308):
@ -132,7 +132,7 @@ def download_pdf(provider: Provider, item_id: str, dest, *, session: requests.Se
session = session or requests.Session()
url = provider.resolve_pdf(item_id)
resp = _get(session, url, timeout=DOWNLOAD_TIMEOUT, stream=True, host_ok=provider.allowed_host,
accept="application/pdf,*/*")
accept="application/pdf,*/*", auth=getattr(provider, "auth", lambda: None)())
tmp = dest.with_suffix(".part")
total = 0
try:
@ -251,14 +251,34 @@ class InternetArchiveProvider:
# ── OPDS ─────────────────────────────────────────────────────────────────────
class OpdsProvider:
"""A user-configured OPDS catalogue (their own Calibre-Web/Kavita/Komga, or any public one)."""
"""A user-configured OPDS catalogue (their own Calibre-Web/Kavita/Komga, or any public one).
def __init__(self, key: str, label: str, root_url: str, session: requests.Session | None = None):
Login, when the catalogue needs one, is HTTP Basic with a username from the settings and a password read from
the *environment variable whose name the settings give* (`password_env`): the password itself is never stored
in the settings, the URL, a log line or an error message. It is only ever sent to the catalogue's own host.
"""
MAX_FEED_PAGES = 5 # how many of the catalogue's result pages one search may walk
def __init__(self, key: str, label: str, root_url: str, session: requests.Session | None = None,
username: str = "", password_env: str = ""):
self.key = key
self.label = label
self._root = root_url
self._host = (urlsplit(root_url).hostname or "").lower()
self._s = session or requests.Session()
self._username = username
self._password_env = password_env
def auth(self):
"""(user, password) for this catalogue, or None when it is open. Raises SourceError if configured but unset."""
if not self._username and not self._password_env:
return None
import os
password = os.environ.get(self._password_env or "", "")
if not self._username or not password:
raise SourceError(f"The catalogue \u201c{self.label}\u201d needs a login: set a username and the environment "
f"variable named in password_env ({self._password_env or 'not configured'})")
return (self._username, password)
def allowed_host(self, url: str) -> bool:
parts = urlsplit(url)
@ -266,7 +286,7 @@ class OpdsProvider:
def _feed(self, url: str) -> ET.Element:
resp = _get(self._s, url, timeout=SEARCH_TIMEOUT, stream=True, host_ok=self.allowed_host,
accept="application/atom+xml,application/xml,text/xml,*/*")
accept="application/atom+xml,application/xml,text/xml,*/*", auth=self.auth())
try:
return parse_xml_safely(_read_limited(resp, MAX_FEED_BYTES))
finally:
@ -286,10 +306,23 @@ class OpdsProvider:
q = _clip(query, 200)
if not q:
raise SourceError("Enter something to search for")
page = max(1, int(page))
url = self._search_url().replace("{searchTerms}", quote(q))
items = list(self._entries(self._feed(url), url))
start = (max(1, int(page)) - 1) * PAGE_SIZE
return items[start:start + PAGE_SIZE]
need = page * PAGE_SIZE
items: list[SourceItem] = []
seen: set[str] = set()
# A catalogue answers a search one result page at a time and points at the next with rel="next".
# Entries without a PDF are dropped, so walk on (bounded) until this page can be filled.
for _ in range(self.MAX_FEED_PAGES):
if not url or url in seen or len(items) >= need:
break
seen.add(url)
root = self._feed(url)
items.extend(self._entries(root, url))
nxt = next((urljoin(url, l.get("href", "")) for l in root.findall(f"{_ATOM}link")
if l.get("rel") == "next" and l.get("href")), "")
url = nxt if nxt and self.allowed_host(nxt) else ""
return items[(page - 1) * PAGE_SIZE: page * PAGE_SIZE]
def _entries(self, root: ET.Element, base: str) -> Iterable[SourceItem]:
for e in root.findall(f"{_ATOM}entry"):
@ -334,5 +367,9 @@ def build_providers(catalogs: list[dict] | None, session: requests.Session | Non
if parts.scheme not in ("http", "https") or not parts.hostname or parts.username or parts.password:
continue
key = f"opds-{n}"
providers[key] = OpdsProvider(key, _clip((cat or {}).get("name"), 80) or parts.hostname, url, session)
env = str((cat or {}).get("password_env") or "").strip()
if env and not re.fullmatch(r"[A-Z_][A-Z0-9_]{0,63}", env):
continue # not a plausible variable name: refuse rather than read an arbitrary one
providers[key] = OpdsProvider(key, _clip((cat or {}).get("name"), 80) or parts.hostname, url, session,
username=_clip((cat or {}).get("username"), 120), password_env=env)
return providers

View File

@ -31,7 +31,7 @@ import zipfile
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, Response
from core import magazine as mag
@ -121,6 +121,29 @@ async def magazine_create(request: Request):
return {"id": doc_id}
@router.post("/api/magazine/{doc_id}/source-images")
async def magazine_source_images(doc_id: str, files: list[UploadFile] = File(...)):
"""Build the magazine's source.pdf from uploaded page images (PNG/JPEG, in the order sent).
Ingestion then treats it as any scanned PDF. Refused when a source already exists, so an upload can
never silently replace pages that were already reviewed.
"""
d, _ = _require_magazine(doc_id)
if (d / "source.pdf").exists():
raise HTTPException(409, "This magazine already has a source file")
if len(files) > mag.IMAGE_IMPORT_MAX_FILES:
raise HTTPException(400, f"At most {mag.IMAGE_IMPORT_MAX_FILES} images per magazine")
images: list[tuple[str, bytes]] = []
for f in files:
data = await f.read(mag.IMAGE_IMPORT_MAX_BYTES + 1)
images.append((f.filename or "image", data))
try:
pages = await asyncio.to_thread(mag.images_to_pdf, images, d / "source.pdf")
except mag.ImageImportError as e:
raise HTTPException(400, str(e))
return {"ok": True, "pages": pages}
@router.patch("/api/magazine/{doc_id}")
async def magazine_update_meta(doc_id: str, request: Request):
"""Edit title/issue/publisher after creation -- there was previously no
@ -559,9 +582,8 @@ async def magazine_articles(doc_id: str):
a["has_audio"] = audio_path.is_file()
if a["has_audio"]:
try:
from mutagen import File as MutagenFile
parsed = MutagenFile(audio_path)
a["audio_seconds"] = round(float(parsed.info.length), 2) if parsed and parsed.info else 0
length = mag.mp3_duration_seconds(audio_path)
a["audio_seconds"] = round(length, 2) if length else 0
except Exception:
# Duration is presentational; a damaged/mid-write tag must not
# make the whole contents endpoint fail. The browser will read
@ -655,7 +677,8 @@ async def magazine_article_text(doc_id: str, article_id: str,
include_captions=bool(captions),
include_descriptions=bool(descriptions),
fuse=True, glossary=target.get("glossary"))
return {"article": target, "blocks": blocks}
layout = mag.load_layout(d)
return {"article": target, "blocks": blocks, "asides": mag.article_asides(layout, target, articles)}
@router.post("/api/magazine/{doc_id}/articles/auto-link")
@ -2927,6 +2950,62 @@ async def magazine_generate_batch_audio(doc_id: str, request: Request):
return {"job_id": job_id, "count": len(article_ids)}
@router.get("/api/magazine/{doc_id}/epub")
async def magazine_epub(doc_id: str, article: str = ""):
"""The issue (or one article) as an EPUB 3 with Media Overlays: text that highlights as the MP3 plays.
Only approved articles that already have audio are included, in listing order. Sentence timings are
exact for audio rendered since timings were recorded and estimated for older audio (the book's
description says which).
"""
from starlette.background import BackgroundTask
from core import epub_export
d, meta = _require_magazine(doc_id)
layout = mag.load_layout(d)
if not layout:
raise HTTPException(404, "Not ingested yet")
articles = mag.load_articles(d)
if article:
articles = [a for a in articles if a.get("id") == _article_id(article)]
if not articles:
raise HTTPException(404, "Article not found")
parts = []
for a in articles:
mp3 = d / "audio" / f"{a['id']}.mp3"
if a.get("status") != "approved" or not mp3.is_file():
continue
blocks = mag.article_text(layout, a, fuse=True, glossary=a.get("glossary"))
timings = mag.read_audio_timings(mp3)
duration_ms = (timings or {}).get("duration_ms") or 0
if not duration_ms:
length = mag.mp3_duration_seconds(mp3)
if not length:
continue # an unreadable MP3 cannot be given timings at all
duration_ms = int(length * 1000)
parts.append({"title": a.get("title") or "", "author": a.get("author") or "", "blocks": blocks,
"mp3": mp3, "duration_ms": duration_ms, "timings": timings})
if not parts:
raise HTTPException(404, "No approved article has audio yet")
language = mag.detect_article_language(b["text"] for p in parts for b in p["blocks"])
import tempfile
tmp = tempfile.NamedTemporaryFile(prefix="mag-epub-", suffix=".epub", delete=False)
tmp.close()
out = Path(tmp.name)
try:
await asyncio.to_thread(epub_export.build_epub, out, title=str(meta.get("title") or "Magazine"),
issue=str(meta.get("issue") or ""), language=language or "de",
articles=parts, identifier=f"urn:uuid:{doc_id[:8]}-{doc_id[8:12]}-{doc_id[12:16]}-{doc_id[16:20]}-{doc_id[20:32]}")
except ValueError as exc:
out.unlink(missing_ok=True)
raise HTTPException(404, str(exc))
except BaseException:
out.unlink(missing_ok=True)
raise
filename = re.sub(r"[^A-Za-z0-9._ -]+", "_", str(meta.get("title") or "magazine")).strip(" ._")[:80] or "magazine"
return FileResponse(out, media_type="application/epub+zip", filename=f"{filename}.epub",
background=BackgroundTask(lambda: out.unlink(missing_ok=True)))
@router.get("/api/magazine/{doc_id}/audio.zip")
async def magazine_audio_zip(doc_id: str):
d, meta = _require_magazine(doc_id)

View File

@ -15,7 +15,7 @@ import uuid
from fastapi import APIRouter, HTTPException, Request
from core import magazine_sources as ms
from core.config import _load_settings
from core.config import _load_settings, _save_settings
from core.constants import CONFIG_DIR
from core.database import background_job_create, background_job_update
from routes.magazine import _write_meta
@ -44,6 +44,62 @@ async def magazine_sources_list():
for p in _providers().values()]}
_ENV_NAME = re.compile(r"[A-Z_][A-Z0-9_]{0,63}")
MAX_CATALOGUES = 10
def _clean_catalogues(raw) -> list[dict]:
"""Validate a catalogue list from the browser. Anything doubtful is refused, never silently repaired."""
from urllib.parse import urlsplit
if not isinstance(raw, list) or len(raw) > MAX_CATALOGUES:
raise HTTPException(400, f"Send a list of at most {MAX_CATALOGUES} catalogues")
out, seen = [], set()
for item in raw:
if not isinstance(item, dict):
raise HTTPException(400, "Each catalogue must be an object")
name = re.sub(r"\s+", " ", str(item.get("name") or "")).strip()[:80]
url = str(item.get("url") or "").strip()
parts = urlsplit(url)
if parts.scheme not in ("http", "https") or not parts.hostname:
raise HTTPException(400, f"\u201c{name or url[:40]}\u201d: the address must start with http:// or https://")
if parts.username or parts.password:
raise HTTPException(400, "Put the login in the username field, not in the address")
if len(url) > 500:
raise HTTPException(400, "The address is too long")
username = str(item.get("username") or "").strip()[:120]
env = str(item.get("password_env") or "").strip()
if env and not _ENV_NAME.fullmatch(env):
raise HTTPException(400, "The password variable name may only use capital letters, digits and underscores")
if bool(username) != bool(env):
raise HTTPException(400, "A login needs both a username and the name of the password variable")
if url in seen:
raise HTTPException(400, "That catalogue is already listed")
seen.add(url)
out.append({"name": name or parts.hostname, "url": url, "username": username, "password_env": env})
return out
@router.get("/api/magazine-sources/catalogues")
async def magazine_catalogues_get():
import os
cats = _load_settings().get("magazine_opds_catalogs") or []
return {"catalogues": [
{"name": str(c.get("name") or ""), "url": str(c.get("url") or ""), "username": str(c.get("username") or ""),
"password_env": str(c.get("password_env") or ""),
"password_set": bool(c.get("password_env") and os.environ.get(str(c.get("password_env")))) }
for c in cats if isinstance(c, dict)]}
@router.put("/api/magazine-sources/catalogues")
async def magazine_catalogues_put(request: Request):
body = await request.json()
cats = _clean_catalogues(body.get("catalogues") if isinstance(body, dict) else None)
settings = _load_settings()
settings["magazine_opds_catalogs"] = cats
_save_settings(settings)
return {"ok": True, "count": len(cats)}
@router.get("/api/magazine-sources/{key}/search")
async def magazine_sources_search(key: str, q: str = "", page: int = 1):
provider = _provider(key)

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.55.0">
<meta name="app-version" content="1.56.0">
<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.55.0">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.55.0">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.55.0">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.55.0">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.55.0">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.55.0">
<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">
<!-- ── 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.55.0"></script>
<script src="/static/loader.js?v=1.56.0"></script>
</body>
</html>

View File

@ -21,6 +21,7 @@ const state = {
article: null, // the article being read or reviewed
sentences: [], // [{ text, blockId, page, bbox }]
images: [], // current article's figures, in reading order
asides: [], // sidebars shown beside the text (not narrated), see article_asides
view: 'library', // library | contents | review | read
contentsView: 'list', // list | pages — how the article list itself is shown
contentsSelectMode: false, // batch-select articles for bulk approve/reject
@ -181,6 +182,7 @@ async function magLoadLibrary() {
state.docs = (data.docs || []).filter((d) => d.kind === 'magazine');
renderLibrary();
magFindLoadSources();
magCatLoad();
}
function renderLibrary() {
@ -230,7 +232,7 @@ async function magDelete(docId) {
}
// ── Add-magazine dropzone (mirrors reader.js's setupReaderDropzone) ─────────
const MAG_DROPZONE_DEFAULT_NOTE = 'Scanned issues work too — pages without selectable text are read with OCR.';
const MAG_DROPZONE_DEFAULT_NOTE = 'Scanned issues and phone photos work too — pages without selectable text are read with OCR. Several images are read in filename order.';
function magFormatFilenameAsTitle(filename) {
if (!filename) return '';
@ -241,6 +243,25 @@ function magFormatFilenameAsTitle(filename) {
.trim();
}
// What was chosen or dropped: exactly one PDF, or several page images (PNG/JPEG) read in filename order.
// Returns { kind, files, label: {name, size} } or { error }. The server re-checks content by its bytes.
function magClassifyFiles(list) {
const files = Array.from(list || []).filter(Boolean);
if (!files.length) return { error: 'Choose or drop a PDF or page images first' };
const isPdf = (f) => f.type === 'application/pdf' || /\.pdf$/i.test(f.name);
const isImg = (f) => /^image\/(png|jpeg)$/.test(f.type) || /\.(png|jpe?g)$/i.test(f.name);
if (files.every(isPdf)) {
if (files.length > 1) return { error: 'Choose one PDF, or several page images — not several PDFs' };
return { kind: 'pdf', files, label: files[0] };
}
if (files.every(isImg)) {
files.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
const size = files.reduce((n, f) => n + f.size, 0);
return { kind: 'images', files, label: { name: files.length === 1 ? files[0].name : files.length + ' page images', size } };
}
return { error: 'Only a PDF, or PNG/JPEG page images, can be imported' };
}
function magShowDroppedFile(file) {
const noteEl = $('mag-dropzone-note');
const titleEl = $('mag-dropzone-title');
@ -252,7 +273,7 @@ function magShowDroppedFile(file) {
const sizeMb = (file.size / (1024 * 1024)).toFixed(1);
if (dropzone) dropzone.classList.add('is-selected');
if (noteEl) noteEl.textContent = `Selected: ${file.name} (${sizeMb} MB)`;
if (titleEl) titleEl.textContent = 'PDF file selected — uploading...';
if (titleEl) titleEl.textContent = 'File selected — uploading...';
if (iconEl) iconEl.className = 'mdi mdi-file-check-outline reader-dropzone-icon';
// Auto-fill title from filename if title input is empty
@ -262,7 +283,7 @@ function magShowDroppedFile(file) {
} else {
if (dropzone) dropzone.classList.remove('is-selected', 'is-uploading');
if (noteEl) noteEl.textContent = MAG_DROPZONE_DEFAULT_NOTE;
if (titleEl) titleEl.textContent = 'Drop a PDF document';
if (titleEl) titleEl.textContent = 'Drop a PDF or page images';
if (iconEl) iconEl.className = 'mdi mdi-cloud-upload-outline reader-dropzone-icon';
}
}
@ -330,30 +351,29 @@ function setupMagDropzone() {
dragCounter = 0;
zone.classList.remove('dragover');
let file = e.dataTransfer?.files?.[0];
if (!file && e.dataTransfer?.items?.[0]) {
file = e.dataTransfer.items[0].getAsFile();
let dropped = Array.from(e.dataTransfer?.files || []);
if (!dropped.length && e.dataTransfer?.items?.length) {
dropped = Array.from(e.dataTransfer.items).map((i) => i.getAsFile()).filter(Boolean);
}
if (!file) return;
if (file.type !== 'application/pdf' && !/\.pdf$/i.test(file.name)) {
return say('Only PDF files are supported', 'err');
}
magShowDroppedFile(file);
magCreate(file);
const picked = magClassifyFiles(dropped);
if (picked.error) return say(picked.error, 'err');
magShowDroppedFile(picked.label);
magCreate(picked.files);
});
input.addEventListener('change', () => {
const file = input.files?.[0];
if (file) {
magShowDroppedFile(file);
magCreate(file);
}
if (!input.files?.length) return;
const picked = magClassifyFiles(input.files);
if (picked.error) return say(picked.error, 'err');
magShowDroppedFile(picked.label);
magCreate(picked.files);
});
}
async function magCreate(droppedFile = null) {
const file = droppedFile || $('mag-new-file')?.files?.[0];
if (!file) return say('Choose or drop a PDF file first', 'err');
async function magCreate(dropped = null) {
const picked = magClassifyFiles(dropped ? (Array.isArray(dropped) ? dropped : [dropped]) : $('mag-new-file')?.files);
if (picked.error) return say(picked.error, 'err');
const file = picked.label;
let title = ($('mag-new-title')?.value || '').trim();
if (!title) {
@ -418,7 +438,13 @@ async function magCreate(droppedFile = null) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, issue }),
});
await api('/api/reader/docs/' + id + '/source?ext=pdf', { method: 'PUT', body: file });
if (picked.kind === 'pdf') {
await api('/api/reader/docs/' + id + '/source?ext=pdf', { method: 'PUT', body: picked.files[0] });
} else {
const form = new FormData();
picked.files.forEach((f) => form.append('files', f, f.name));
await api('/api/magazine/' + id + '/source-images', { method: 'POST', body: form });
}
say('Magazine uploaded — starting ingestion', 'ok');
if ($('mag-new-title')) $('mag-new-title').value = '';
@ -450,6 +476,54 @@ async function magFindLoadSources() {
}
}
// ── Your OPDS catalogues ─────────────────────────────────────────────────
async function magCatLoad() {
const list = $('mag-cat-list');
if (!list) return;
try {
const { catalogues } = await api('/api/magazine-sources/catalogues');
state.catalogues = catalogues || [];
} catch (e) {
const st = $('mag-cat-status');
if (st) st.textContent = 'Could not load your catalogues: ' + e.message;
return;
}
list.innerHTML = state.catalogues.length ? state.catalogues.map((c, i) =>
'<li><span><strong>' + esc(c.name) + '</strong> <span class="muted">' + esc(c.url) + '</span>' +
(c.username ? ' <span class="muted">· login ' + esc(c.username) + (c.password_set ? '' : ' — <em>password variable ' + esc(c.password_env) + ' is not set on the server</em>') + '</span>' : '') +
'</span><button type="button" class="btn-sm" aria-label="Remove ' + esc(c.name) + '" onclick="magCatRemove(' + i + ')">Remove</button></li>').join('')
: '<li class="muted">None yet. The Internet Archive is always available.</li>';
}
async function magCatSave(next, doneText) {
const st = $('mag-cat-status');
try {
await api('/api/magazine-sources/catalogues', { method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ catalogues: next.map((c) => ({ name: c.name, url: c.url, username: c.username, password_env: c.password_env })) }) });
} catch (e) {
if (st) st.textContent = 'Not saved: ' + e.message;
return false;
}
if (st) st.textContent = doneText;
await magCatLoad();
const sel = $('mag-find-source');
if (sel) { delete sel.dataset.loaded; await magFindLoadSources(); }
return true;
}
async function magCatAdd(ev) {
if (ev) ev.preventDefault();
const entry = { name: $('mag-cat-name').value.trim(), url: $('mag-cat-url').value.trim(),
username: $('mag-cat-user').value.trim(), password_env: $('mag-cat-env').value.trim() };
if (await magCatSave([...(state.catalogues || []), entry], 'Catalogue added.')) $('mag-cat-form').reset();
}
async function magCatRemove(i) {
const next = (state.catalogues || []).filter((_, k) => k !== i);
await magCatSave(next, 'Catalogue removed.');
}
async function magFindSearch(ev) {
if (ev) ev.preventDefault();
const q = ($('mag-find-query')?.value || '').trim();
@ -1039,6 +1113,89 @@ function magQualityBadge(q, page) {
bits.length + '</button>';
}
// What each page card tells the reader, in words (colour alone is not enough): what state the page is in,
// which articles sit on it and how they flow to neighbouring pages, and what -- if anything -- the reader still has to
// do because the system could not settle it alone. Pure: the card markup only renders what this returns.
const MAG_PAGE_LABELS = {
blank: ['Not looked at yet', 'mdi-help-circle-outline'],
detected: ['Text found, not checked yet', 'mdi-text-search'],
checked: ['Checked: no problems found', 'mdi-check-circle-outline'],
flagged: ['Problems found', 'mdi-alert-circle-outline'],
approved: ['Approved', 'mdi-check-decagram-outline'],
audio: ['Done: audio is ready', 'mdi-volume-high'],
ad: ['Advertisement: not read', 'mdi-bullhorn-outline'],
empty: ['Reviewed: nothing to read', 'mdi-check'],
none: ['No article on this page', 'mdi-file-hidden'],
};
// Where an article comes from and goes to around `page`, by its own page list.
function magArticleFlow(article, page) {
const pages = (article?.pages || []).slice().sort((a, b) => a - b);
const i = pages.indexOf(page);
if (i < 0) return { from: null, to: null };
return { from: i > 0 ? pages[i - 1] : null, to: i < pages.length - 1 ? pages[i + 1] : null };
}
function magQualityActions(q) {
const n = (list) => (list || []).length;
const plural = (k, one, many) => k + ' ' + (k === 1 ? one : many);
const out = [];
if (n(q.uncovered)) out.push(plural(n(q.uncovered), 'text area lies', 'text areas lie') + ' outside every box: use "Find missed text"');
if (n(q.overlaps)) out.push(plural(n(q.overlaps), 'pair of boxes overlaps', 'pairs of boxes overlap') + ': merge, shrink or delete one');
if (n(q.clipped)) out.push(plural(n(q.clipped), 'box edge cuts', 'box edges cut') + ' into text: use "Clean up boxes"');
if (n(q.suspect_blocks)) out.push(plural(n(q.suspect_blocks), 'region has', 'regions have') + ' doubtful words: check them with "Spelling"');
if (n(q.duplicates)) out.push('several articles claim this page: keep it in one of them');
if (q.error) out.push('the check itself failed: run it again');
return out;
}
function magPageGuide({ page, articles, quality, stateName, hasText }) {
const arts = articles || [];
const name = !arts.length && stateName !== 'ad' ? 'none' : stateName;
const [label, icon] = MAG_PAGE_LABELS[name] || MAG_PAGE_LABELS.blank;
const todo = [];
let note = '';
if (!arts.length) {
if (stateName !== 'ad' && hasText) todo.push('Text was found here but it belongs to no article: open the page and link it to the article it continues, or start a new one');
} else if (stateName === 'flagged') {
const actions = magQualityActions(quality || {});
actions.slice(0, 3).forEach((t) => todo.push(t.charAt(0).toUpperCase() + t.slice(1)));
if (actions.length > 3) todo.push('and ' + (actions.length - 3) + ' more: open the page for the full list');
if (!actions.length) todo.push('Open the page and check what the quality run found');
todo.push('Then approve the page');
} else if (stateName === 'detected' || stateName === 'checked') {
todo.push(stateName === 'checked'
? 'Nothing wrong was found, but nobody has confirmed it: open the page and approve it'
: 'Open the page, check the boxes and approve it');
} else if (stateName === 'approved' && arts.some((a) => a.status === 'approved' && !a.has_audio)) {
todo.push('Generate the audio (toolbar: "Generate all audio")');
}
// A page's colour follows its most advanced article ("audio" beats "approved" beats "detected"), so a page can
// look finished while another article on it still waits. Say so whatever the colour is.
const waiting = arts.filter((a) => a.status === 'detected');
if (arts.length > 1 && waiting.length && stateName !== 'flagged') {
todo.push(waiting.length + ' of the ' + arts.length + ' articles here still wait for approval: open the page and approve ' + (waiting.length === 1 ? 'it' : 'them'));
}
if (arts.some((a) => a.status === 'approved') && quality && quality.needs_review && stateName !== 'flagged') {
const actions = magQualityActions(quality);
if (actions.length) note = 'You approved this page, but the quality check had found: ' + actions.slice(0, 2).join('; ');
}
// Never call a page "Done" while it still asks something of the reader.
const finalLabel = todo.length && name === 'audio' ? 'Partly done: some audio is ready' : label;
return { label: finalLabel, icon, name, todo, needs: todo.length > 0, note };
}
function magPageHasText(page) {
const layout = state.layout;
if (!layout) return false;
return (layout.blocks || []).some((b) => b.page === page && MAG_NARRATABLE_TYPES.includes(b.type) && String(b.text || '').trim());
}
function magTogglePagesTodo(on) {
state.pagesOnlyTodo = !!on;
renderContents();
}
function renderPagesView(host) {
if (!state.layout) ensureLayout().then(() => { if (state.view !== 'review') renderContents(); });
if (state.qualityDocId !== state.doc?.id) magLoadQuality(false);
@ -1055,33 +1212,62 @@ function renderPagesView(host) {
['checked', 'quality check clean'], ['flagged', 'quality check found problems'],
['approved', 'reviewed'], ['ad', 'advertisement (detected, not read)'], ['audio', 'reviewed + audio generated'], ['empty', 'reviewed, nothing to narrate'],
];
let html = '<p class="mag-pages-legend">' +
legend.map(([cls, label]) => '<span class="mag-pages-legend-item"><span class="mag-pages-legend-swatch is-page-' + cls + '"></span>' + label + '</span>').join('') +
'</p>';
html += '<div class="mag-pages-grid" style="--cols:' + state.pagesCols + '">';
// The guide for every page first, so the summary can say how many pages still need the reader.
const guides = new Map();
for (let p = 1; p <= pageCount; p++) {
const arts = byPage.get(p) || [];
const st = magPageState(arts, quality.get(p), p);
guides.set(p, { st, arts, guide: magPageGuide({ page: p, articles: arts, quality: quality.get(p), stateName: st, hasText: magPageHasText(p) }) });
}
const needing = [...guides.values()].filter((g) => g.guide.needs).length;
let html = '<div class="mag-pages-help">' +
'<p class="mag-pages-summary" role="status"><strong>' + (needing ? needing + ' of ' + pageCount + ' pages need you.' : 'Nothing left for you to do on these ' + pageCount + ' pages.') + '</strong> ' +
'Each card says what state the page is in, how many articles are on it, and what to do next.</p>' +
'<label class="mag-pages-filter"><input type="checkbox" ' + (state.pagesOnlyTodo ? 'checked' : '') + ' onchange="magTogglePagesTodo(this.checked)"> Show only pages that need me</label>' +
'<p class="mag-pages-legend" aria-label="Card colours">' +
legend.map(([cls, label]) => '<span class="mag-pages-legend-item"><span class="mag-pages-legend-swatch is-page-' + cls + '"></span>' + label + '</span>').join('') +
'</p></div>';
html += '<div class="mag-pages-grid" style="--cols:' + state.pagesCols + '">';
for (let p = 1; p <= pageCount; p++) {
const { st, arts, guide } = guides.get(p);
if (state.pagesOnlyTodo && !guide.needs) continue;
// The thumbnail is the big, easy target: it opens the page in review (in the context of the
// first article on it). A separate Review button per article only appears when the page
// really holds several, to say which one is meant.
const reviewJs = arts.length ? 'magReviewAtPage(\'' + arts[0].id + '\', ' + p + ')' : 'magReviewPage(' + p + ')';
html += '<div class="mag-card-wrap" data-page="' + p + '"><div class="mag-card is-page-' + magPageState(arts, quality.get(p), p) + '">' +
const flowChips = (a) => {
const f = magArticleFlow(a, p);
return (f.from ? '<span class="mag-flow" title="This article started on page ' + f.from + '"><span class="mdi mdi-arrow-left-bottom" aria-hidden="true"></span> continues from p.' + f.from + '</span>' : '') +
(f.to ? '<span class="mag-flow" title="This article goes on on page ' + f.to + '"><span class="mdi mdi-arrow-right-bottom" aria-hidden="true"></span> continues on p.' + f.to + '</span>' : '');
};
html += '<div class="mag-card-wrap" data-page="' + p + '"><div class="mag-card is-page-' + st + (guide.needs ? ' needs-you' : '') + '">' +
'<button type="button" class="mag-card-thumb-btn" title="Review page ' + p + '" aria-label="Review page ' + p + '" onclick="' + reviewJs + '">' +
'<img class="mag-card-thumb" loading="lazy" alt="Page ' + p + '" src="/api/magazine/' + esc(state.doc.id) + '/pages/' + p + '"></button>' +
'<span class="mag-card-title">Page ' + p + '</span>' + magQualityBadge(quality.get(p), p) +
'<span class="mag-card-title">Page ' + p + '</span>' +
'<span class="mag-card-count">' + (arts.length ? arts.length + (arts.length === 1 ? ' article' : ' articles') : 'no article') + '</span>' +
magQualityBadge(quality.get(p), p) +
(arts.length
? '<div class="mag-page-articles">' + arts.map((a) =>
'<div class="mag-page-article">' +
'<span class="mag-page-article-title">' + esc(a.title || 'Untitled') + '</span>' +
(flowChips(a) ? '<span class="mag-flows">' + flowChips(a) + '</span>' : '') +
'<span class="mag-page-article-actions">' +
(arts.length > 1 ? '<button type="button" class="btn-sm" aria-label="Review ' + esc(a.title || 'article') + '" onclick="magReviewAtPage(\'' + a.id + '\', ' + p + ')">Review</button>' : '') +
(a.has_audio ? '<button type="button" class="btn-sm primary" onclick="magRead(\'' + a.id + '\')" ' +
'aria-label="Play ' + esc(a.title || 'article') + '"><span class="mdi mdi-play" aria-hidden="true"></span> Play</button>' : '') +
'</span>' +
'</div>').join('') + '</div>'
: '<span class="mag-pill">no article</span>') +
: '') +
'<div class="mag-page-guide">' +
'<span class="mag-page-status"><span class="mdi ' + guide.icon + '" aria-hidden="true"></span> ' + esc(guide.label) + '</span>' +
(guide.needs
? '<div class="mag-page-todo"><strong>To do:</strong><ul>' + guide.todo.map((t) => '<li>' + esc(t) + '</li>').join('') + '</ul></div>'
: '<span class="mag-page-done"><span class="mdi mdi-check" aria-hidden="true"></span> Nothing to do here</span>') +
(guide.note ? '<span class="mag-page-note">' + esc(guide.note) + '</span>' : '') +
'</div>' +
'</div></div>';
}
if (state.pagesOnlyTodo && !needing) html += '<p class="muted">No page needs you.</p>';
html += '</div>';
host.innerHTML = html;
}
@ -1300,6 +1486,13 @@ function magUpdateAudioZipLink() {
const any = state.articles.some((a) => a.has_audio);
link.hidden = !any;
if (any) link.href = '/api/magazine/' + state.doc.id + '/audio.zip';
// The EPUB carries only approved articles that have audio.
const epub = $('mag-epub-link');
if (epub) {
const ready = state.articles.some((a) => a.has_audio && a.status === 'approved');
epub.hidden = !ready;
if (ready) epub.href = '/api/magazine/' + state.doc.id + '/epub';
}
}
// ── Review (Stage D) ─────────────────────────────────────────────────────────
@ -1695,7 +1888,8 @@ function magReviewPageColumn(page, wrapId, interactive) {
(interactive ? ' onclick="event.preventDefault();event.stopPropagation();magRowEdit(\'' + b.id + '\')" title="Click to edit"' : '') + '>' +
(owner ? '<span class="mag-block-owner">Article ' + owner.index + ': ' + esc(owner.title) + '</span> ' : '') +
(b.continues_from_previous_page ? '<span class="mag-block-owner">Continued from previous page</span> ' : '') +
'<span data-text-of="' + esc(b.id) + '">' + magSpellMarked(b.id, b.text) + '</span></span>' +
'<span data-text-of="' + esc(b.id) + '">' + magSpellMarked(b.id, b.text) + '</span>' +
(b.ocr_text ? magEditedChip(b.id, interactive) : '') + '</span>' +
(interactive ? magRowActions(b.id, i + 1, i + 1 < regions.length) : '') + '</label>';
});
if (!regions.length) listHtml += '<p class="muted">Nothing detected on this page.</p>';
@ -1820,7 +2014,7 @@ function renderReview() {
'<label>Author<span class="mag-review-field-control"><input id="mag-rev-author" type="text" value="' + esc(a.author) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'author\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
'</div>' +
'<span id="mag-draw-hint" class="muted"></span>' +
'<div id="mag-order-bar" class="mag-order-bar" role="status" aria-live="polite">' + magOrderBarHtml() + '</div>' +
'<div id="mag-order-bar" class="mag-order-bar' + (state.orderMode ? ' is-floating' : '') + '" role="region" aria-label="Order by clicking" aria-live="polite">' + magOrderBarHtml() + '</div>' +
'<div id="mag-select-bar" class="mag-order-bar" role="status" aria-live="polite">' + magSelectBarHtml() + '</div>' +
'<div id="mag-undo-bar" class="mag-order-bar" role="status" aria-live="polite">' + magUndoBarHtml() + '</div>';
html += '</div>';
@ -2106,12 +2300,23 @@ function magReviewPageLocked() {
function magOrderBarHtml() {
if (state.orderMode) {
// A floating panel (see .mag-order-bar.is-floating): pinned to the viewport so the button that finishes
// the task can never scroll out of sight, yet parked in a corner clear of the page image being clicked.
const done = (state.orderSeq || []).length, total = (state.reviewRegionOrder || []).length;
return '<span class="mdi mdi-order-numeric-ascending" aria-hidden="true"></span> ' +
'Click the boxes in the order they are read — ' + done + ' of ' + total + ' set. Boxes you skip follow in their current order. ' +
'<button type="button" class="btn-sm btn-primary" onclick="magOrderSave()"' + (done ? '' : ' disabled') + '>Save order</button> ' +
'<button type="button" class="btn-sm" onclick="magOrderUndo()"' + (done ? '' : ' disabled') + '>Undo last</button> ' +
'<button type="button" class="btn-sm" onclick="magOrderToggle()">Cancel</button>';
const all = total > 0 && done >= total;
return '<div class="mag-order-head"><span class="mdi mdi-order-numeric-ascending" aria-hidden="true"></span> ' +
'<strong>Order by clicking</strong></div>' +
'<p class="mag-order-hint">' + (all
? 'Every box is numbered. Press <strong>Save order</strong> to keep it.'
: 'Click the boxes on the page in the order they are read. Boxes you skip keep their current order.') + '</p>' +
'<div class="mag-order-progress"><progress max="' + Math.max(total, 1) + '" value="' + done + '" aria-label="Boxes numbered"></progress>' +
'<span>' + done + ' of ' + total + ' set</span></div>' +
'<div class="mag-order-actions">' +
'<button type="button" class="btn-primary mag-order-save' + (all ? ' is-ready' : '') + '" onclick="magOrderSave()"' + (done ? '' : ' disabled') + '>' +
'<span class="mdi mdi-content-save-outline" aria-hidden="true"></span> Save order</button>' +
'<button type="button" class="btn-sm" onclick="magOrderUndo()"' + (done ? '' : ' disabled') + '>Undo last</button>' +
'<button type="button" class="btn-sm" onclick="magOrderToggle()" title="Discard this numbering (Esc)">Cancel</button>' +
'</div>';
}
if (magReviewPageLocked()) {
return '<span class="mdi mdi-lock-outline" aria-hidden="true"></span> Reading order on this page was set by hand and is kept. ' +
@ -2122,7 +2327,7 @@ function magOrderBarHtml() {
function magOrderRefreshBar() {
const bar = $('mag-order-bar');
if (bar) bar.innerHTML = magOrderBarHtml();
if (bar) { bar.innerHTML = magOrderBarHtml(); bar.classList.toggle('is-floating', !!state.orderMode); }
document.querySelectorAll('#mag-review-page-wrap .mag-review-box').forEach((box) => {
const at = (state.orderSeq || []).indexOf(box.dataset.block);
box.classList.toggle('is-ordered', state.orderMode && at >= 0);
@ -2137,6 +2342,8 @@ function magOrderToggle() {
state.orderPage = state.reviewPage;
if (state.orderMode && state.drawMode) magReviewToggleDraw(state.drawKind);
if (state.orderMode && state.editMode) magReviewToggleEdit();
document.removeEventListener('keydown', magOrderKey);
if (state.orderMode) document.addEventListener('keydown', magOrderKey);
const btn = $('mag-order-toggle');
if (btn) { btn.classList.toggle('active', state.orderMode); btn.setAttribute('aria-pressed', String(state.orderMode)); }
const wrap = $('mag-review-page-wrap');
@ -2145,6 +2352,14 @@ function magOrderToggle() {
magOrderRefreshBar();
}
// Esc discards the numbering, like Cancel -- unless the key is meant for a field (a position box, a select).
function magOrderKey(e) {
if (e.key !== 'Escape' || !state.orderMode) return;
if (e.target && e.target.closest && e.target.closest('input, textarea, select')) return;
e.preventDefault();
magOrderToggle();
}
function magOrderPick(id) {
const seq = state.orderSeq || (state.orderSeq = []);
if (!seq.includes(id)) seq.push(id);
@ -2731,6 +2946,80 @@ async function magReloadReviewNow(message) {
if (message) say(message, 'ok');
}
// Word-level difference between the recognised text and the reader's edit. Whitespace-separated words,
// longest-common-subsequence; returns escaped HTML with <del> (only in the original) and <ins> (only in
// the edit). Bounded: two very long texts are shown whole rather than freezing the page.
const MAG_DIFF_MAX_WORDS = 1500;
function magWordDiff(before, after) {
const a = String(before || '').split(/\s+/).filter(Boolean);
const b = String(after || '').split(/\s+/).filter(Boolean);
if (a.length > MAG_DIFF_MAX_WORDS || b.length > MAG_DIFF_MAX_WORDS) {
return '<del>' + esc(a.join(' ')) + '</del> <ins>' + esc(b.join(' ')) + '</ins>';
}
const n = a.length, m = b.length, w = m + 1;
const t = new Uint16Array((n + 1) * w);
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
t[i * w + j] = a[i] === b[j] ? t[(i + 1) * w + j + 1] + 1 : Math.max(t[(i + 1) * w + j], t[i * w + j + 1]);
}
}
const out = [];
let i = 0, j = 0;
const push = (kind, word) => {
const last = out[out.length - 1];
if (last && last.kind === kind) last.words.push(word); else out.push({ kind, words: [word] });
};
while (i < n && j < m) {
if (a[i] === b[j]) { push('same', a[i]); i++; j++; }
else if (t[(i + 1) * w + j] >= t[i * w + j + 1]) { push('del', a[i]); i++; }
else { push('ins', b[j]); j++; }
}
while (i < n) push('del', a[i++]);
while (j < m) push('ins', b[j++]);
return out.map((g) => {
const text = esc(g.words.join(' '));
return g.kind === 'del' ? '<del>' + text + '</del>' : g.kind === 'ins' ? '<ins>' + text + '</ins>' : text;
}).join(' ');
}
// Marks a region the reader edited by hand, with a way to see the change and to put the recognised text back.
// Inside a <label> row, so every click must stop the tick box from toggling.
function magEditedChip(id, interactive) {
const stop = 'event.preventDefault();event.stopPropagation();';
return ' <span class="mag-edited"><span class="mdi mdi-pencil-outline" aria-hidden="true"></span> Edited' +
(interactive
? ' <button type="button" class="btn-sm" aria-expanded="false" onclick="' + stop + 'magRowDiff(\'' + esc(id) + '\', this)">Show changes</button>' +
' <button type="button" class="btn-sm" onclick="' + stop + 'magRowRestore(\'' + esc(id) + '\')">Restore recognised text</button>'
: '') + '</span>';
}
function magRowDiff(id, btn) {
const row = document.querySelector('#mag-review [data-block-row="' + CSS.escape(id) + '"]');
const block = (state.layout?.blocks || []).find((b) => b.id === id);
if (!row || !block?.ocr_text) return;
let box = row.querySelector('.mag-diff');
if (box) { box.remove(); btn?.setAttribute('aria-expanded', 'false'); if (btn) btn.textContent = 'Show changes'; return; }
box = document.createElement('div');
box.className = 'mag-diff';
box.setAttribute('role', 'group');
box.setAttribute('aria-label', 'Changes from the recognised text: removed words struck through and marked minus, added words underlined and marked plus');
box.innerHTML = magWordDiff(block.ocr_text, block.text);
row.querySelector('.mag-block-text').after(box);
btn?.setAttribute('aria-expanded', 'true');
if (btn) btn.textContent = 'Hide changes';
}
async function magRowRestore(id) {
const block = (state.layout?.blocks || []).find((b) => b.id === id);
if (!block?.ocr_text) return;
try {
await api(magRegionUrl(id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: block.ocr_text }) });
} catch (err) {
return say('Could not restore the recognised text: ' + err.message, 'err');
}
magReloadReview('Recognised text restored');
}
async function magRowEdit(id) {
if (state.reloadPending) await state.reloadPending;
const block = (state.layout?.blocks || []).find((b) => b.id === id);
@ -4134,6 +4423,7 @@ async function magRead(articleId, opts) {
($('mag-read-captions')?.checked ? 1 : 0) + '&descriptions=' +
($('mag-read-descriptions')?.checked ? 1 : 0));
state.sentences = splitSentences(data.blocks || []);
state.asides = Array.isArray(data.asides) ? data.asides : [];
} catch (e) {
return say('Could not load article text: ' + e.message, 'err');
}
@ -4155,7 +4445,22 @@ function renderArticle() {
$('mag-read-by').textContent = a.author ? 'By ' + a.author : '';
$('mag-read-pages').textContent = fmtPages(a.pages);
host.innerHTML = state.sentences.map((s, i) => {
// Sidebars are shown where they sit in the article but are not sentences: never narrated, never highlighted.
const asideHtml = (a2) => '<aside class="mag-aside" aria-label="Sidebar, not read aloud">' +
'<span class="mag-aside-label"><span class="mdi mdi-page-layout-sidebar-right" aria-hidden="true"></span> Sidebar · not read aloud · p. ' +
Number(a2.page) + '</span><span class="mag-aside-text">' + esc(a2.text) + '</span></aside>';
const asidesAfter = new Map();
let asidesFirst = '';
(state.asides || []).forEach((a2) => {
let at = a2.after ? state.sentences.map((x) => x.blockId).lastIndexOf(a2.after) : -1;
if (at < 0) {
// Not after a narrated region on its page: after the last sentence from an earlier page, else first.
for (let k = state.sentences.length - 1; k >= 0; k--) if (state.sentences[k].page < a2.page) { at = k; break; }
}
if (at < 0) asidesFirst += asideHtml(a2);
else asidesAfter.set(at, (asidesAfter.get(at) || '') + asideHtml(a2));
});
host.innerHTML = asidesFirst + state.sentences.map((s, i) => {
const tag = s.type === 'title' ? 'h2' : s.type === 'deck' ? 'p class="mag-deck"'
: s.type === 'byline' ? 'p class="mag-byline"' : s.type === 'caption'
? 'p class="mag-caption"' : s.type === 'figure_description'
@ -4174,7 +4479,7 @@ function renderArticle() {
return '<span class="mag-w" data-w="' + j + '">' + esc(w) + '</span>' + marker;
}).join(' ');
return '<' + tag + ' class="mag-s' + (open === 'span' ? '' : '') + '" data-i="' + i + '" ' +
'onclick="magSeek(' + i + ')">' + wordsHtml + '</' + open + '> ';
'onclick="magSeek(' + i + ')">' + wordsHtml + '</' + open + '> ' + (asidesAfter.get(i) || '');
}).join('');
highlight(state.idx);
}
@ -4676,6 +4981,9 @@ function magPlaylistPlay(articleId, options) {
if (audio) {
state.playlistSwitching = true;
audio.dataset.article = articleId;
// The element ships with preload="none" (nothing is fetched until a track is chosen). Choosing one must
// load it: with "none" the browser never fires loadedmetadata, and the code below waits for exactly that.
audio.preload = 'auto';
audio.src = '/api/magazine/' + state.doc.id + '/articles/' + articleId + '/audio';
queueMicrotask(() => { state.playlistSwitching = false; });
const saved = (state.doc.progress || {})[articleId] || {};
@ -4949,6 +5257,7 @@ function magToggleDriving() {
const overlay = $('mag-driving');
if (overlay) overlay.hidden = !state.driving;
if (state.driving) renderDriving();
magDrivingWake();
}
function magOpenDriving() {
@ -5146,12 +5455,85 @@ function magDrivingPrevious() {
}
}
// ── Lock screen, headset and car controls; screen stays awake while driving ───
// Hands-free means the phone in a pocket or a dashboard mount must still be controllable: the Media Session
// API puts title, artwork, position and play/pause/next/previous/±15 s on the lock screen, in a notification,
// on a Bluetooth headset or a car's display. Everything is optional: a browser without it simply plays.
const MAG_SEEK_STEP = 15;
function magMediaSessionSync() {
if (!('mediaSession' in navigator)) return;
const audio = $('mag-playlist-audio');
const a = state.articles.find((x) => x.id === state.playlistCurrent);
if (!audio || !a) return;
try {
const first = (a.pages || [])[0];
navigator.mediaSession.metadata = new MediaMetadata({
title: a.title || 'Untitled',
artist: a.author || state.doc?.title || '',
album: [state.doc?.title, state.doc?.issue].filter(Boolean).join(' — '),
artwork: first ? [{ src: '/api/magazine/' + state.doc.id + '/pages/' + first, type: 'image/png' }] : [],
});
navigator.mediaSession.playbackState = audio.paused ? 'paused' : 'playing';
} catch (_) { /* metadata is presentation only */ }
}
function magMediaSessionPosition() {
if (!('mediaSession' in navigator) || !navigator.mediaSession.setPositionState) return;
const audio = $('mag-playlist-audio');
const now = Date.now();
if (!audio || now - (state._msPosAt || 0) < 1000) return; // once a second is plenty for a lock screen
state._msPosAt = now;
if (!Number.isFinite(audio.duration) || audio.duration <= 0) return;
try {
navigator.mediaSession.setPositionState({
duration: audio.duration, playbackRate: audio.playbackRate || 1, position: Math.min(audio.currentTime, audio.duration),
});
} catch (_) { /* inconsistent state during a track change: skip this tick */ }
}
function magWireMediaSession() {
if (!('mediaSession' in navigator) || state._msWired) return;
state._msWired = true;
const audio = () => $('mag-playlist-audio');
const handlers = {
play: () => audio()?.play().catch(() => {}),
pause: () => audio()?.pause(),
stop: () => audio()?.pause(),
previoustrack: () => magDrivingPrevious(),
nexttrack: () => magDrivingNext(),
seekbackward: (d) => magDrivingSkip(-(d?.seekOffset || MAG_SEEK_STEP)),
seekforward: (d) => magDrivingSkip(d?.seekOffset || MAG_SEEK_STEP),
seekto: (d) => { const el = audio(); if (el && Number.isFinite(d?.seekTime)) el.currentTime = d.seekTime; },
};
Object.entries(handlers).forEach(([action, fn]) => {
try { navigator.mediaSession.setActionHandler(action, fn); } catch (_) { /* action not supported here */ }
});
}
// Keep the screen on while driving mode is open AND audio is playing; let it sleep otherwise (battery).
async function magDrivingWake() {
if (!('wakeLock' in navigator)) return;
const audio = $('mag-playlist-audio');
const want = !!(state.driving && audio && !audio.paused);
try {
if (want && !state.wakeLock) {
state.wakeLock = await navigator.wakeLock.request('screen');
state.wakeLock.addEventListener?.('release', () => { state.wakeLock = null; });
} else if (!want && state.wakeLock) {
const lock = state.wakeLock;
state.wakeLock = null;
await lock.release();
}
} catch (_) { state.wakeLock = null; /* refused (battery saver, hidden tab): playback is unaffected */ }
}
function wirePlaylistAudio() {
const audio = $('mag-playlist-audio');
if (!audio || audio.dataset.wired) return;
audio.dataset.wired = '1';
audio.addEventListener('ended', _magPlaylistNext);
audio.addEventListener('timeupdate', () => { magPlaylistSync(); savePlaylistProgress(false, false); });
audio.addEventListener('timeupdate', () => { magPlaylistSync(); savePlaylistProgress(false, false); magMediaSessionPosition(); });
audio.addEventListener('loadedmetadata', () => {
const a = state.articles.find((x) => x.id === state.playlistCurrent);
if (a && Number.isFinite(audio.duration)) a.audio_seconds = audio.duration;
@ -5159,11 +5541,16 @@ function wirePlaylistAudio() {
magPlaylistSync();
});
audio.addEventListener('loadedmetadata', magApplyPlaybackSpeed);
audio.addEventListener('play', syncDrivingPlayButton);
audio.addEventListener('play', () => { syncDrivingPlayButton(); magWireMediaSession(); magMediaSessionSync(); magDrivingWake(); });
audio.addEventListener('pause', () => {
if (!state.playlistSwitching) savePlaylistProgress(false, true);
syncDrivingPlayButton();
magMediaSessionSync();
magDrivingWake();
});
audio.addEventListener('loadedmetadata', magMediaSessionSync);
// The browser drops a screen wake lock when the tab is hidden; take it again on return.
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') magDrivingWake(); });
}
// ── Boot ─────────────────────────────────────────────────────────────────────
@ -5266,6 +5653,9 @@ root.magRunPageQuality = magRunPageQuality;
root.magFixReadingOrder = magFixReadingOrder;
root.magCleanUpBoxes = magCleanUpBoxes;
root.magRowEdit = magRowEdit;
root.magRowDiff = magRowDiff;
root.magRowRestore = magRowRestore;
root.magWordDiff = magWordDiff;
root.magSelectToggle = magSelectToggle;
root.magSelectNone = magSelectNone;
root.magBulk = magBulk;
@ -5284,7 +5674,12 @@ root.magReocrMagazine = magReocrMagazine;
root.magApprove = magApprove;
root.magSetStatus = magSetStatus;
root.magRead = magRead;
root.magPageGuide = magPageGuide;
root.magArticleFlow = magArticleFlow;
root.magTogglePagesTodo = magTogglePagesTodo;
root.magFindSearch = magFindSearch;
root.magCatAdd = magCatAdd;
root.magCatRemove = magCatRemove;
root.magFindImport = magFindImport;
root.magTocPlay = magTocPlay;
root.magSeek = magSeek;

View File

@ -25,22 +25,22 @@
</label>
</div>
<div class="reader-paste-actions" style="margin-top: auto; padding-top: 6px;">
<label class="btn-primary btn-sm reader-file-pick" id="mag-browse-btn" title="Choose a PDF file from your computer">
<label class="btn-primary btn-sm reader-file-pick" id="mag-browse-btn" title="Choose a PDF file or page images (PNG/JPEG) from your computer">
<span class="mdi mdi-folder-open-outline"></span> Durchsuchen
<input type="file" id="mag-new-file" accept="application/pdf" style="display:none">
<input type="file" id="mag-new-file" accept="application/pdf,image/png,image/jpeg" multiple style="display:none">
</label>
</div>
</div>
<div class="reader-dropzone" id="mag-dropzone" style="cursor: pointer;">
<span class="mdi mdi-cloud-upload-outline reader-dropzone-icon" id="mag-dropzone-icon"></span>
<strong id="mag-dropzone-title">Drop a PDF document</strong>
<strong id="mag-dropzone-title">Drop a PDF or page images</strong>
<span class="note" id="mag-dropzone-note">Scanned issues work too &mdash; pages without selectable text are read with OCR.</span>
<div class="mag-dropzone-progress" id="mag-dropzone-progress" hidden>
<div class="mag-dropzone-progress-bar"></div>
</div>
<label class="btn-secondary btn-sm reader-file-pick" id="mag-dropzone-pick-btn" title="Choose a PDF file">
<span class="mdi mdi-file-upload-outline"></span> Import PDF
<label class="btn-secondary btn-sm reader-file-pick" id="mag-dropzone-pick-btn" title="Choose a PDF file or page images (PNG/JPEG)">
<span class="mdi mdi-file-upload-outline"></span> Import PDF or images
</label>
</div>
</div>
@ -58,6 +58,19 @@
</label>
<button type="submit" class="btn-primary btn-sm" id="mag-find-btn"><span class="mdi mdi-magnify" aria-hidden="true"></span> Search</button>
</form>
<details class="mag-cat" id="mag-cat">
<summary>Your catalogues (OPDS)</summary>
<p class="note">Add your own library (Calibre-Web, Kavita, Komga) or any OPDS address. If it needs a login, enter the username and the <strong>name of the environment variable</strong> that holds the password &mdash; the password itself is never stored here.</p>
<ul class="mag-cat-list" id="mag-cat-list"></ul>
<form class="mag-cat-form" id="mag-cat-form" onsubmit="magCatAdd(event)">
<label for="mag-cat-name">Name <input id="mag-cat-name" type="text" maxlength="80" autocomplete="off" placeholder="My NAS"></label>
<label for="mag-cat-url">Address <input id="mag-cat-url" type="url" required maxlength="500" autocomplete="off" placeholder="http://nas.local:8083/opds/"></label>
<label for="mag-cat-user">Username <span class="muted">(optional)</span> <input id="mag-cat-user" type="text" maxlength="120" autocomplete="off"></label>
<label for="mag-cat-env">Password variable <span class="muted">(optional)</span> <input id="mag-cat-env" type="text" maxlength="64" autocomplete="off" placeholder="NAS_OPDS_PASSWORD" pattern="[A-Z_][A-Z0-9_]*"></label>
<button type="submit" class="btn-secondary btn-sm"><span class="mdi mdi-plus" aria-hidden="true"></span> Add catalogue</button>
</form>
<p class="note" id="mag-cat-status" role="status" aria-live="polite"></p>
</details>
<p class="note" id="mag-find-status" role="status" aria-live="polite"></p>
<ul class="mag-find-results" id="mag-find-results"></ul>
</div>
@ -138,6 +151,10 @@
<a class="btn-sm" id="mag-audio-zip-link" href="#" hidden download>
<span class="mdi mdi-folder-zip-outline"></span> Download all (ZIP)
</a>
<a class="btn-sm" id="mag-epub-link" href="#" hidden download
title="An EPUB 3 book whose text highlights while the audio plays (Media Overlays). Approved articles that already have audio.">
<span class="mdi mdi-book-open-page-variant-outline" aria-hidden="true"></span> EPUB with audio
</a>
<span class="mag-audio-export-progress-wrap" hidden id="mag-audio-export-progress-wrap">
<progress id="mag-audio-export-progress" max="1" value="0"></progress>
<span id="mag-audio-export-count" class="muted"></span>

View File

@ -5558,3 +5558,70 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
.mag-find-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.mag-find-summary { color: var(--subtext); font-size: 13px; overflow-wrap: anywhere; }
@media (max-width: 600px) { .mag-find-item { flex-direction: column; align-items: stretch; } }
/* Review: text edited by hand, and its difference from the recognised text */
.mag-edited { display: inline-flex; align-items: center; gap: 6px; margin-left: 8px; font-size: 12px; color: var(--subtext); }
.mag-diff { margin: 6px 0 2px 28px; padding: 8px 10px; border: 1px dashed var(--border); border-radius: var(--radius);
background: var(--surface); line-height: 1.6; overflow-wrap: anywhere; }
.mag-diff del { text-decoration: line-through; color: var(--red, #b91c1c); }
.mag-diff ins { text-decoration: underline; color: var(--green); }
.mag-diff del::before { content: "\2212\00a0"; }
.mag-diff ins::before { content: "+\00a0"; }
/* Reading view: sidebars shown in place but not narrated */
.mag-aside { display: block; margin: 12px 0; padding: 10px 14px; border-left: 4px solid var(--border);
background: var(--surface); border-radius: 0 var(--radius) var(--radius) 0; color: var(--subtext); font-size: .92em; }
.mag-aside-label { display: block; font-size: 12px; font-weight: 600; margin-bottom: 4px; }
.mag-aside-text { display: block; white-space: pre-line; overflow-wrap: anywhere; }
/* Order by clicking: a floating panel, so "Save order" is always on screen and never inside the way */
.mag-order-bar.is-floating { position: fixed; right: 16px; bottom: calc(76px + env(safe-area-inset-bottom)); z-index: 60; /* 76px clears the sticky Back/Approve bar */
flex-direction: column; align-items: stretch; gap: 8px; width: min(340px, calc(100vw - 32px)); margin: 0; padding: 14px;
border: 2px solid var(--accent, #2563eb); border-radius: 12px; background: var(--surface);
box-shadow: 0 10px 30px rgba(0, 0, 0, .28); font-size: 14px; }
.mag-order-head { display: flex; align-items: center; gap: 8px; font-size: 15px; }
.mag-order-hint { margin: 0; color: var(--subtext); line-height: 1.4; }
.mag-order-progress { display: flex; align-items: center; gap: 10px; font-variant-numeric: tabular-nums; }
.mag-order-progress progress { flex: 1; height: 10px; accent-color: var(--accent, #2563eb); }
.mag-order-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.mag-order-save { flex: 1 1 100%; min-height: 48px; font-size: 16px; font-weight: 700; }
.mag-order-save.is-ready { background: var(--green, #16a34a); border-color: var(--green, #16a34a); color: #fff; }
.mag-order-actions .btn-sm { min-height: 40px; flex: 1 1 auto; }
.mag-order-bar.is-floating :focus-visible { outline: 3px solid var(--accent, #2563eb); outline-offset: 2px; }
@media (max-width: 700px) {
.mag-order-bar.is-floating { left: 8px; right: 8px; bottom: calc(76px + env(safe-area-inset-bottom)); width: auto; padding: 10px; gap: 6px; }
.mag-order-hint { font-size: 12px; }
.mag-order-save { min-height: 44px; }
.mag-order-actions .btn-sm { min-height: 40px; }
}
@media (prefers-reduced-motion: no-preference) {
.mag-order-bar.is-floating { animation: mag-order-in .18s ease-out; }
@keyframes mag-order-in { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
}
/* Page grid: bigger legend, and a status / articles / next-step block on every card */
.mag-pages-help { margin: 0 0 14px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
.mag-pages-summary { margin: 0 0 8px; font-size: 15px; }
.mag-pages-filter { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; margin-bottom: 10px; cursor: pointer; }
.mag-pages-filter input { width: 18px; height: 18px; accent-color: var(--accent); }
.mag-pages-help .mag-pages-legend { font-size: 14px; gap: 8px 20px; margin: 0; color: var(--text); }
.mag-pages-help .mag-pages-legend-swatch { width: 18px; height: 18px; border-radius: 4px; }
.mag-card-count { display: block; font-size: 13px; font-weight: 600; color: var(--text); }
.mag-flows { display: flex; flex-direction: column; gap: 2px; }
.mag-flow { font-size: 12px; color: var(--subtext); }
.mag-page-guide { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); font-size: 13px; line-height: 1.4; }
.mag-page-status { font-weight: 700; display: inline-flex; gap: 6px; align-items: flex-start; }
.mag-page-todo { padding: 6px 8px; border-radius: 6px; background: color-mix(in srgb, var(--accent) 12%, var(--surface)); border-left: 4px solid var(--accent); }
.mag-page-todo ul { margin: 4px 0 0; padding-left: 18px; }
.mag-page-todo li { margin: 2px 0; }
.mag-page-done { color: var(--subtext); }
.mag-page-note { color: var(--subtext); font-size: 12px; }
.mag-card.needs-you { box-shadow: 0 0 0 2px var(--accent); }
.mag-cat { margin: 10px 0; }
.mag-cat > summary { cursor: pointer; font-weight: 600; }
.mag-cat-list { list-style: none; margin: 8px 0; padding: 0; display: grid; gap: 6px; }
.mag-cat-list li { display: flex; gap: 10px; align-items: center; justify-content: space-between; padding: 6px 10px;
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; }

View File

@ -1084,6 +1084,68 @@ test('order by clicking: boxes clicked on the page are saved in click order', as
await expect.poll(() => sent).toEqual(['b2', 'b1']);
});
test('order by clicking: Save order stays on screen however far the page is scrolled', async ({ page }) => {
await mockBackend(page);
await openReview(page);
await page.getByRole('button', { name: 'Order by clicking', exact: true }).click();
const save = page.getByRole('button', { name: 'Save order', exact: true });
await expect(save).toBeVisible();
await expect(save).toBeDisabled(); // nothing numbered yet
await page.locator('#mag-review-page-wrap [data-block="b2"]').click();
await expect(save).toBeEnabled();
// The app scrolls inside its own container, not the window. Find it, make the review long, and scroll that.
const scroller = await page.evaluateHandle(() => {
let el = document.getElementById('mag-review');
while (el && el !== document.documentElement) {
const oy = getComputedStyle(el).overflowY;
if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight - 1) return el;
el = el.parentElement;
}
return document.scrollingElement;
});
await scroller.evaluate((el) => { const f = document.createElement('div'); f.style.height = '6000px'; f.id = 'test-filler'; (el === document.scrollingElement ? document.body : el).appendChild(f); });
const scrollBy = async (top) => scroller.evaluate((el, y) => { el.scrollTo(0, y); return el.scrollTop; }, top);
expect(await scrollBy(4000)).toBeGreaterThan(1000); // proves the container really scrolled
for (const y of [0, 1500, 4000, 6500]) {
await scrollBy(y);
await expect(save).toBeInViewport({ ratio: 1 });
}
// It floats in a corner: a proper touch target, inside the viewport, clear of the toolbar.
const box = await save.boundingBox();
const vp = page.viewportSize();
expect(box.y + box.height).toBeLessThanOrEqual(vp.height);
expect(box.height).toBeGreaterThanOrEqual(44);
await scrollBy(0);
await expect(page.getByRole('button', { name: 'Order by clicking', exact: true })).toBeInViewport();
});
test('order by clicking: progress, a ready state when every box is numbered, and Esc to cancel', async ({ page }) => {
await mockBackend(page);
await openReview(page);
await page.getByRole('button', { name: 'Order by clicking', exact: true }).click();
const bar = page.locator('#mag-order-bar');
await expect(bar).toHaveClass(/is-floating/);
await expect(bar.locator('progress')).toHaveAttribute('value', '0');
for (const id of ['b1', 'b2', 'b3']) await page.locator(`#mag-review-page-wrap [data-block="${id}"]`).click();
await expect(bar.locator('progress')).toHaveAttribute('value', '3');
await expect(bar).toContainText('Every box is numbered');
await expect(page.getByRole('button', { name: 'Save order', exact: true })).toHaveClass(/is-ready/);
await page.keyboard.press('Escape');
await expect(bar).not.toHaveClass(/is-floating/);
await expect(bar).toBeEmpty();
await expect(page.getByRole('button', { name: 'Order by clicking', exact: true })).toHaveAttribute('aria-pressed', 'false');
});
test('order by clicking: Esc inside a position field does not cancel the numbering', async ({ page }) => {
await mockBackend(page);
await openReview(page);
await page.getByRole('button', { name: 'Order by clicking', exact: true }).click();
await page.locator('#mag-review-page-wrap [data-block="b1"]').click();
await page.getByRole('spinbutton', { name: /Reading position of region 2/ }).focus();
await page.keyboard.press('Escape');
await expect(page.locator('#mag-order-bar')).toHaveClass(/is-floating/);
});
test('a hand-ordered page says so and can go back to the automatic order', async ({ page }) => {
await mockBackend(page);
let reset = false;
@ -1400,3 +1462,398 @@ test('finding an issue online: search, licence shown, import starts ingestion',
await expect.poll(() => imported.length).toBe(1);
expect(imported[0]).toMatchObject({ source: 'internet-archive', item_id: 'sci-am-1923' });
});
const TINY_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
async function openMagazineLibrary(page) {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await expect(page.locator('#mag-new-file')).toBeAttached();
}
test('several page images are uploaded together, in filename order', async ({ page }) => {
await openMagazineLibrary(page);
const posted = [];
await page.route('**/api/magazine/docs', route => route.fulfill({ json: { id: DOC_ID } }));
await page.route(`**/api/magazine/${DOC_ID}/source-images`, route => {
posted.push(route.request().postDataBuffer().toString('latin1'));
route.fulfill({ json: { ok: true, pages: 3 } });
});
await page.route(`**/api/magazine/${DOC_ID}/ingest`, route => route.fulfill({ json: { job_id: 'ing1' } }));
await page.locator('#mag-new-file').setInputFiles([
{ name: 'page10.png', mimeType: 'image/png', buffer: TINY_PNG },
{ name: 'page2.png', mimeType: 'image/png', buffer: TINY_PNG },
{ name: 'page1.png', mimeType: 'image/png', buffer: TINY_PNG },
]);
await expect.poll(() => posted.length).toBe(1);
const names = [...posted[0].matchAll(/filename="([^"]+)"/g)].map(m => m[1]);
expect(names).toEqual(['page1.png', 'page2.png', 'page10.png']);
});
test('mixing a PDF with images, or choosing another type, is refused before anything is created', async ({ page }) => {
await openMagazineLibrary(page);
let created = 0;
await page.route('**/api/magazine/docs', route => { created++; route.fulfill({ json: { id: DOC_ID } }); });
await page.locator('#mag-new-file').setInputFiles([
{ name: 'a.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4') },
{ name: 'b.png', mimeType: 'image/png', buffer: TINY_PNG },
]);
await expect(page.locator('.toast, #toast, [role="status"]').filter({ hasText: /PDF|images/ }).first()).toBeVisible();
await page.locator('#mag-new-file').setInputFiles([{ name: 'c.gif', mimeType: 'image/gif', buffer: Buffer.from('GIF89a') }]);
await page.waitForTimeout(300);
expect(created).toBe(0);
});
test.describe('Edited text in review', () => {
async function openReviewWithEditedBlock(page) {
await mockBackend(page);
const edited = { ...LAYOUT, blocks: LAYOUT.blocks.map(b => b.id === 'b3'
? { ...b, ocr_text: 'Battery technolgy is entering a new phase. It will take years to mature.' } : b) };
await page.route(`**/api/magazine/${DOC_ID}/layout*`, route => route.fulfill({ json: edited }));
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 expect(page.locator('[data-block-row="b3"]')).toBeVisible();
}
test('a hand-edited region is marked and shows a word diff against the recognised text', async ({ page }) => {
await openReviewWithEditedBlock(page);
const row = page.locator('[data-block-row="b3"]');
await expect(row).toContainText('Edited');
await expect(page.locator('[data-block-row="b1"]')).not.toContainText('Edited');
const toggle = row.locator('.mag-edited button').first();
await expect(toggle).toHaveText('Show changes');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(row.locator('.mag-diff del')).toHaveText('technolgy');
await expect(row.locator('.mag-diff ins')).toHaveText('technology');
await row.getByRole('button', { name: 'Hide changes' }).click();
await expect(row.locator('.mag-diff')).toHaveCount(0);
});
test('restoring puts the recognised text back through the region route', async ({ page }) => {
await openReviewWithEditedBlock(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"]').getByRole('button', { name: 'Restore recognised text' }).click();
await expect.poll(() => patches.length).toBe(1);
expect(patches[0]).toEqual({ text: 'Battery technolgy is entering a new phase. It will take years to mature.' });
});
test('the word diff handles insertions, deletions, escaping and oversize input', async ({ page }) => {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
const r = await page.evaluate(() => ({
same: window.magWordDiff('a b c', 'a b c'),
ins: window.magWordDiff('a c', 'a b c'),
del: window.magWordDiff('a b c', 'a c'),
html: window.magWordDiff('x <b>y</b>', 'x y'),
big: window.magWordDiff('w '.repeat(2000), 'v '.repeat(2000)).startsWith('<del>'),
}));
expect(r.same).toBe('a b c');
expect(r.ins).toBe('a <ins>b</ins> c');
expect(r.del).toBe('a <del>b</del> c');
expect(r.html).not.toContain('<b>');
expect(r.big).toBe(true);
});
});
test('sidebars appear in the reading view in place, labelled, and are not sentences', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved' });
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({
json: { article: ARTICLE_APPROVED, blocks: LAYOUT.blocks, asides: [
{ id: 's1', page: 3, text: 'Box: <b>Did you know?</b> Lithium is light.', after: 'b2' },
{ id: 's2', page: 3, text: 'Glossary of terms', after: 'b3' },
{ id: 's0', page: 3, text: 'Before everything', after: null },
] } }));
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-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
await expect(page.locator('#mag-view-read')).toBeVisible();
await expect(page.locator('#mag-article [data-i]')).toHaveCount(4); // asides add no sentences
const asides = page.locator('#mag-article aside.mag-aside');
await expect(asides).toHaveCount(3);
await expect(asides.first()).toContainText('Before everything');
await expect(asides.first()).toHaveAttribute('aria-label', 'Sidebar, not read aloud');
await expect(asides.nth(1)).toContainText('Did you know?');
await expect(page.locator('#mag-article aside b')).toHaveCount(0); // text, never markup
// Order in the DOM: before-all aside, title/byline sentences, aside after the byline, body sentences, aside last.
const order = await page.locator('#mag-article').evaluate(el => Array.from(el.children).map(c => c.matches('aside') ? 'A' : 'S').join(''));
expect(order).toBe('ASSASSA');
await asides.nth(1).click();
await expect(page.locator('#mag-article [data-i].is-current')).toHaveCount(1);
});
test('the EPUB link appears only when an approved article has audio', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
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();
const link = page.getByRole('link', { name: /EPUB with audio/ });
await expect(link).toBeVisible();
await expect(link).toHaveAttribute('href', `/api/magazine/${DOC_ID}/epub`);
});
test('no EPUB link for an approved article without audio', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved', hasAudio: false });
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.waitForSelector('.mag-toc-row');
await expect(page.locator('#mag-epub-link')).toBeHidden();
});
test.describe('Hands-free listening', () => {
async function startPlayback(page) {
await page.addInitScript(() => {
window.__ms = { actions: {}, positions: 0 };
const ms = navigator.mediaSession;
const set = ms.setActionHandler.bind(ms);
ms.setActionHandler = (name, fn) => { window.__ms.actions[name] = fn; return set(name, fn); };
const pos = ms.setPositionState.bind(ms);
ms.setPositionState = (s) => { window.__ms.positions++; return pos(s); };
window.__wl = { requested: 0, released: 0 };
Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: {
request: async () => { window.__wl.requested++; return { addEventListener() {}, release: async () => { window.__wl.released++; } }; } } });
});
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
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.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
await expect.poll(() => page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); return !!a && !a.paused; })).toBe(true);
}
test('lock screen shows the article and offers play, pause, next, previous and seek', async ({ page }) => {
await startPlayback(page);
const meta = await page.evaluate(() => ({ title: navigator.mediaSession.metadata?.title, album: navigator.mediaSession.metadata?.album,
state: navigator.mediaSession.playbackState, actions: Object.keys(window.__ms.actions).sort() }));
expect(meta.title).toBe('The Future of Batteries');
expect(meta.album).toContain('Scientific American');
expect(meta.state).toBe('playing');
expect(meta.actions).toEqual(['nexttrack', 'pause', 'play', 'previoustrack', 'seekbackward', 'seekforward', 'seekto', 'stop']);
await expect.poll(() => page.evaluate(() => window.__ms.positions)).toBeGreaterThan(0);
});
test('pause and play from the lock screen control the real player', async ({ page }) => {
await startPlayback(page);
await page.evaluate(() => window.__ms.actions.pause());
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').paused)).toBe(true);
await expect.poll(() => page.evaluate(() => navigator.mediaSession.playbackState)).toBe('paused');
await page.evaluate(() => window.__ms.actions.play());
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').paused)).toBe(false);
});
test('seekto and seekforward move the playhead', async ({ page }) => {
await startPlayback(page);
await page.evaluate(() => window.__ms.actions.seekto({ seekTime: 1.2 }));
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').currentTime)).toBeGreaterThan(1);
});
test('the screen is kept awake only while driving mode is open and playing', async ({ page }) => {
await startPlayback(page);
expect(await page.evaluate(() => window.__wl.requested)).toBe(0); // reading view: no lock
await page.evaluate(() => window.magOpenDriving());
await expect.poll(() => page.evaluate(() => window.__wl.requested)).toBe(1);
await page.evaluate(() => window.__ms.actions.pause()); // paused: let the screen sleep
await expect.poll(() => page.evaluate(() => window.__wl.released)).toBe(1);
await page.evaluate(() => window.__ms.actions.play());
await expect.poll(() => page.evaluate(() => window.__wl.requested)).toBe(2);
await page.evaluate(() => window.magToggleDriving()); // leaving driving mode releases it
await expect.poll(() => page.evaluate(() => window.__wl.released)).toBe(2);
});
test('a refused wake lock never stops playback', async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request: async () => { throw new Error('denied'); } } });
});
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
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.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
await expect.poll(() => page.evaluate(() => !document.getElementById('mag-playlist-audio').paused)).toBe(true);
await page.evaluate(() => window.magOpenDriving());
await expect(page.locator('#mag-driving')).toBeVisible();
await expect.poll(() => page.evaluate(() => !document.getElementById('mag-playlist-audio').paused)).toBe(true);
});
});
test.describe('Page cards say what to do', () => {
const guide = (page, args) => page.evaluate((a) => window.magPageGuide(a), args);
async function openGrid(page, articles, quality) {
await mockBackend(page, { articleStatus: 'approved' });
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({
json: { articles, progress: {}, default_voice: '', podcast: { hosts: [] } } }));
if (quality) await page.route(`**/api/magazine/${DOC_ID}/quality/report`, route => route.fulfill({ json: { pages: quality, summary: {} } }));
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.locator('#mag-contents-view-toggle').click();
await expect(page.locator('.mag-pages-grid .mag-card-wrap').first()).toBeVisible();
}
const art = (id, title, pages, status = 'detected', extra = {}) => ({ id, title, author: '', pages, blocks: [], images: [], status, confidence: 0.6, section: '', ...extra });
test('the guide names the state and the next step for every kind of page', async ({ page }) => {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
const a = (status, has_audio = false) => ({ id: 'x', title: 'T', pages: [3], status, has_audio });
const flagged = await guide(page, { page: 3, articles: [a('detected')], quality: { needs_review: true, uncovered: [[1, 1, 1, 1]], overlaps: [['a', 'b'], ['c', 'd']], clipped: [], suspect_blocks: ['s'], duplicates: [] }, stateName: 'flagged', hasText: true });
expect(flagged.needs).toBe(true);
expect(flagged.label).toBe('Problems found');
expect(flagged.todo[0]).toMatch(/^1 text area lies outside every box: use "Find missed text"/);
expect(flagged.todo).toContain('2 pairs of boxes overlap: merge, shrink or delete one');
expect(flagged.todo.at(-1)).toBe('Then approve the page');
const detected = await guide(page, { page: 3, articles: [a('detected')], quality: null, stateName: 'detected', hasText: true });
expect(detected.todo[0]).toMatch(/approve it/);
const done = await guide(page, { page: 3, articles: [a('approved', true)], quality: null, stateName: 'audio', hasText: true });
expect([done.needs, done.label]).toEqual([false, 'Done: audio is ready']);
const noAudio = await guide(page, { page: 3, articles: [a('approved')], quality: null, stateName: 'approved', hasText: true });
expect(noAudio.todo[0]).toMatch(/Generate the audio/);
const ad = await guide(page, { page: 3, articles: [], quality: null, stateName: 'ad', hasText: false });
expect([ad.needs, ad.label]).toEqual([false, 'Advertisement: not read']);
const orphan = await guide(page, { page: 3, articles: [], quality: null, stateName: 'blank', hasText: true });
expect(orphan.needs).toBe(true);
expect(orphan.todo[0]).toMatch(/belongs to no article/);
const empty = await guide(page, { page: 3, articles: [], quality: null, stateName: 'blank', hasText: false });
expect([empty.needs, empty.label]).toEqual([false, 'No article on this page']);
const shared = await guide(page, { page: 3, articles: [a('detected'), a('approved')], quality: null, stateName: 'detected', hasText: true });
expect(shared.todo.join(' ')).toMatch(/1 of the 2 articles here still wait/);
const hidden = await guide(page, { page: 3, articles: [a('detected'), a('approved', true)], quality: null, stateName: 'audio', hasText: true });
expect([hidden.label, hidden.needs]).toEqual(['Partly done: some audio is ready', true]); // colour says audio, one article still waits
expect(hidden.todo.join(' ')).toMatch(/1 of the 2 articles here still wait for approval/);
const noted = await guide(page, { page: 3, articles: [a('approved', true)], quality: { needs_review: true, uncovered: [], overlaps: [['a', 'b']], clipped: [], suspect_blocks: [], duplicates: [] }, stateName: 'audio', hasText: true });
expect(noted.needs).toBe(false);
expect(noted.note).toMatch(/You approved this page, but the quality check had found/);
});
test('how an article flows between pages is computed from its page list', async ({ page }) => {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
const r = await page.evaluate(() => ({
first: window.magArticleFlow({ pages: [10, 11, 25] }, 10), mid: window.magArticleFlow({ pages: [10, 11, 25] }, 11),
last: window.magArticleFlow({ pages: [25, 10, 11] }, 25), single: window.magArticleFlow({ pages: [4] }, 4), absent: window.magArticleFlow({ pages: [4] }, 9) }));
expect(r.first).toEqual({ from: null, to: 11 });
expect(r.mid).toEqual({ from: 10, to: 25 });
expect(r.last).toEqual({ from: 11, to: null });
expect(r.single).toEqual({ from: null, to: null });
expect(r.absent).toEqual({ from: null, to: null });
});
test('every card shows its status, article count, page flow and to-do in words', async ({ page }) => {
await openGrid(page, [
art('a1', 'Long feature', [3, 4], 'detected'),
art('a2', 'Short piece', [4], 'approved', { has_audio: true }),
], [{ page: 3, needs_review: true, uncovered: [[1, 1, 1, 1]], overlaps: [], clipped: [], suspect_blocks: [], duplicates: [] }]);
const p3 = page.locator('.mag-card-wrap[data-page="3"]');
await expect(p3.locator('.mag-card-count')).toHaveText('1 article');
await expect(p3.locator('.mag-page-status')).toContainText('Problems found');
await expect(p3.locator('.mag-flow')).toHaveText(/continues on p\.4/);
await expect(p3.locator('.mag-page-todo')).toContainText('Find missed text');
const p4 = page.locator('.mag-card-wrap[data-page="4"]');
await expect(p4.locator('.mag-card-count')).toHaveText('2 articles');
await expect(p4.locator('.mag-flow').first()).toHaveText(/continues from p\.3/);
await expect(p4.locator('.mag-page-todo')).toContainText('1 of the 2 articles here still wait for approval');
const p6 = page.locator('.mag-card-wrap[data-page="6"]');
await expect(p6.locator('.mag-card-count')).toHaveText('no article');
await expect(p6.locator('.mag-page-status')).toContainText('No article on this page');
});
test('the summary counts pages that need you and the filter hides the rest', async ({ page }) => {
await openGrid(page, [art('a1', 'Done piece', [3], 'approved', { has_audio: true }), art('a2', 'Open piece', [4], 'detected')], null);
const summary = page.locator('.mag-pages-summary');
await expect(summary).toContainText(/pages need you/);
const total = await page.locator('.mag-pages-grid .mag-card-wrap').count();
await page.getByLabel('Show only pages that need me').check();
const shown = await page.locator('.mag-pages-grid .mag-card-wrap').count();
expect(shown).toBeLessThan(total);
await expect(page.locator('.mag-card-wrap[data-page="4"]')).toBeVisible();
await expect(page.locator('.mag-card-wrap[data-page="3"]')).toHaveCount(0);
await page.getByLabel('Show only pages that need me').uncheck();
await expect(page.locator('.mag-pages-grid .mag-card-wrap')).toHaveCount(total);
});
test('the legend is large enough to read', async ({ page }) => {
await openGrid(page, [art('a1', 'X', [3])], null);
const sizes = await page.evaluate(() => ({
font: parseFloat(getComputedStyle(document.querySelector('.mag-pages-help .mag-pages-legend-item')).fontSize),
swatch: parseFloat(getComputedStyle(document.querySelector('.mag-pages-help .mag-pages-legend-swatch')).width) }));
expect(sizes.font).toBeGreaterThanOrEqual(14);
expect(sizes.swatch).toBeGreaterThanOrEqual(18);
});
});
test('catalogues can be added and removed, and the source list follows', async ({ page }) => {
await mockBackend(page);
let cats = [];
const puts = [];
await page.route('**/api/magazine-sources/catalogues', route => {
if (route.request().method() === 'PUT') {
cats = route.request().postDataJSON().catalogues.map(c => ({ ...c, password_set: !!c.password_env }));
puts.push(route.request().postDataJSON());
return route.fulfill({ json: { ok: true, count: cats.length } });
}
return route.fulfill({ json: { catalogues: cats } });
});
await page.route('**/api/magazine-sources', route => route.fulfill({ json: { sources: [
{ key: 'internet-archive', label: 'Internet Archive', kind: 'archive' },
...cats.map((c, i) => ({ key: `opds-${i + 1}`, label: c.name, kind: 'opds' }))] } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-cat summary').click();
await expect(page.locator('#mag-cat-list')).toContainText('None yet');
await page.locator('#mag-cat-name').fill('My NAS');
await page.locator('#mag-cat-url').fill('http://nas.local:8083/opds/');
await page.locator('#mag-cat-user').fill('me');
await page.locator('#mag-cat-env').fill('NAS_PW');
await page.getByRole('button', { name: /Add catalogue/ }).click();
await expect(page.locator('#mag-cat-status')).toHaveText('Catalogue added.');
await expect(page.locator('#mag-cat-list')).toContainText('My NAS');
// The form never carries a password, only the name of the variable that holds it.
expect(JSON.stringify(puts[0])).not.toMatch(/"password"/);
expect(puts[0].catalogues[0]).toEqual({ name: 'My NAS', url: 'http://nas.local:8083/opds/', username: 'me', password_env: 'NAS_PW' });
await expect(page.locator('#mag-find-source option')).toHaveText(['Internet Archive', 'My NAS']);
await page.getByRole('button', { name: 'Remove My NAS' }).click();
await expect(page.locator('#mag-cat-status')).toHaveText('Catalogue removed.');
await expect(page.locator('#mag-find-source option')).toHaveCount(1);
});
test('a refused catalogue shows the server reason and keeps the form', async ({ page }) => {
await mockBackend(page);
await page.route('**/api/magazine-sources/catalogues', route => route.request().method() === 'PUT'
? route.fulfill({ status: 400, json: { detail: 'Put the login in the username field, not in the address' } })
: route.fulfill({ json: { catalogues: [] } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-cat summary').click();
await page.locator('#mag-cat-url').fill('http://me:pw@nas.local/opds');
await page.getByRole('button', { name: /Add catalogue/ }).click();
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');
});

300
tests/test_epub_export.py Normal file
View File

@ -0,0 +1,300 @@
"""EPUB 3 Media Overlay export: package structure, timing tiers, escaping, and the timing record."""
import json
import os
import re
import tempfile
import unittest
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-test-config-"))
from core import epub_export as ee
from core import magazine as mag
NS = {"o": "http://www.idpf.org/2007/opf", "s": "http://www.w3.org/ns/SMIL", "x": "http://www.w3.org/1999/xhtml"}
def blocks():
return [
{"type": "title", "text": "Die Zukunft der Batterien"},
{"type": "byline", "text": "Von Jane Smith"},
{"type": "body", "text": "Erster Satz. Zweiter Satz! Dritter Satz?"},
{"type": "body", "text": "Ein einzelner Absatz."},
]
def exact_timings(bl=None):
bl = bl or blocks()
times, t = [], 0
segs = []
for b in bl:
n = len(ee.split_sentences(b["text"]))
seg = []
for _ in range(n):
seg.append([t, t + 1000]); t += 1000
segs.append({"chars": len(b["text"]), "times": seg})
return {"version": 1, "interview": False, "duration_ms": t, "segments": segs}
class PlanTests(unittest.TestCase):
def test_exact_uses_the_recorded_times(self):
units, q = ee.plan_sentences(blocks(), 99999, exact_timings())
self.assertEqual(q, "exact")
self.assertEqual(len(units), 1 + 1 + 3 + 1)
self.assertEqual((units[0]["start"], units[0]["end"]), (0, 1000))
self.assertEqual(units[-1]["end"], 6000)
def test_a_sentence_count_mismatch_keeps_the_paragraph_anchored(self):
t = exact_timings()
t["segments"][2]["times"] = [[2000, 5000]] # recorded as one utterance, text splits into three
units, q = ee.plan_sentences(blocks(), 99999, t)
self.assertEqual(q, "anchored")
para = [u for u in units if u["block"] == 2]
self.assertAlmostEqual(para[0]["start"], 2000)
self.assertAlmostEqual(para[-1]["end"], 5000)
self.assertTrue(all(a["end"] <= b["start"] + 1e-6 for a, b in zip(para, para[1:])))
def test_changed_text_falls_back_to_an_estimate_over_the_real_duration(self):
t = exact_timings()
t["segments"][3]["chars"] += 1 # text was edited after the audio was rendered
units, q = ee.plan_sentences(blocks(), 10000, t)
self.assertEqual(q, "estimated")
self.assertAlmostEqual(units[-1]["end"], 10000, places=3)
self.assertTrue(all(a["end"] <= b["start"] + 1e-6 for a, b in zip(units, units[1:])))
def test_no_record_and_interview_audio_are_estimated(self):
self.assertEqual(ee.plan_sentences(blocks(), 5000, None)[1], "estimated")
t = exact_timings(); t["interview"] = True
self.assertEqual(ee.plan_sentences(blocks(), 5000, t)[1], "estimated")
def test_empty_blocks_yield_nothing(self):
self.assertEqual(ee.plan_sentences([{"type": "body", "text": " "}], 1000, None), ([], "estimated"))
def test_clock_format(self):
self.assertEqual(ee.clock(0), "0:00:00.000")
self.assertEqual(ee.clock(3_723_456), "1:02:03.456")
self.assertEqual(ee.clock(-5), "0:00:00.000")
class BuildTests(unittest.TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp(prefix="ttvc-epub-"))
self.mp3 = self.dir / "a.mp3"
self.mp3.write_bytes(b"ID3fakeaudio" * 50)
self.out = self.dir / "book.epub"
def article(self, **kw):
base = {"title": "Batterien & <Zukunft>", "author": "J", "blocks": blocks(), "mp3": self.mp3,
"duration_ms": 6000, "timings": exact_timings()}
base.update(kw)
return base
def build(self, arts=None, **kw):
return ee.build_epub(self.out, title=kw.pop("title", "c't"), issue=kw.pop("issue", "9/2026"),
language="de", articles=arts or [self.article()], identifier="urn:uuid:test", **kw)
def test_mimetype_is_first_and_stored_and_every_xml_part_is_well_formed(self):
info = self.build()
with zipfile.ZipFile(self.out) as z:
first = z.infolist()[0]
self.assertEqual((first.filename, first.compress_type), ("mimetype", zipfile.ZIP_STORED))
self.assertEqual(z.read("mimetype"), b"application/epub+zip")
for name in z.namelist():
if name.endswith((".xhtml", ".smil", ".opf", ".xml")):
ET.fromstring(z.read(name)) # raises on malformed XML
self.assertEqual(info["quality"], {"Batterien & <Zukunft>": "exact"})
def test_manifest_references_exist_ids_are_unique_and_overlay_is_linked(self):
self.build()
with zipfile.ZipFile(self.out) as z:
opf = ET.fromstring(z.read("OEBPS/content.opf"))
items = opf.findall(".//o:manifest/o:item", NS)
ids = [i.get("id") for i in items]
self.assertEqual(len(ids), len(set(ids)))
for i in items:
self.assertIn("OEBPS/" + i.get("href"), z.namelist())
xhtml = next(i for i in items if i.get("media-type") == "application/xhtml+xml" and i.get("id") == "a001")
self.assertEqual(xhtml.get("media-overlay"), "a001smil")
spine = [r.get("idref") for r in opf.findall(".//o:spine/o:itemref", NS)]
self.assertEqual(spine, ["a001"])
props = {m.get("property") for m in opf.findall(".//o:metadata/o:meta", NS)}
self.assertTrue({"media:duration", "media:active-class", "dcterms:modified"} <= props)
def test_every_smil_text_target_exists_and_clips_are_ordered(self):
self.build()
with zipfile.ZipFile(self.out) as z:
page = ET.fromstring(z.read("OEBPS/a001.xhtml"))
span_ids = {e.get("id") for e in page.iter("{%s}span" % NS["x"])}
smil = ET.fromstring(z.read("OEBPS/a001.smil"))
pars = smil.findall(".//s:par", NS)
self.assertEqual(len(pars), 6)
last_end = -1.0
for p in pars:
self.assertIn(p.find("s:text", NS).get("src").split("#")[1], span_ids)
a = p.find("s:audio", NS)
def secs(v):
h, m, s = v.split(":"); return int(h) * 3600 + int(m) * 60 + float(s)
b, e = secs(a.get("clipBegin")), secs(a.get("clipEnd"))
self.assertLess(b, e)
self.assertGreaterEqual(b, last_end - 1e-9)
last_end = e
self.assertEqual(a.get("src"), "audio/a001.mp3")
def test_hostile_text_is_escaped_not_interpreted(self):
bl = [{"type": "title", "text": "T"}, {"type": "body", "text": "Ein <script>alert(1)</script> & \"Test\"."}]
self.build([self.article(blocks=bl, timings=None, title="x</title><b>")])
with zipfile.ZipFile(self.out) as z:
raw = z.read("OEBPS/a001.xhtml").decode()
self.assertNotIn("<script>", raw)
self.assertNotIn("<b>", raw)
ET.fromstring(raw)
def test_headings_and_classes_follow_block_types_and_audio_is_stored_not_recompressed(self):
self.build()
with zipfile.ZipFile(self.out) as z:
page = z.read("OEBPS/a001.xhtml").decode()
self.assertIn("<h1>", page)
self.assertIn('<p class="byline">', page)
self.assertEqual(z.getinfo("OEBPS/audio/a001.mp3").compress_type, zipfile.ZIP_STORED)
self.assertEqual(z.read("OEBPS/audio/a001.mp3"), self.mp3.read_bytes())
def test_several_articles_keep_order_and_an_estimated_one_is_declared(self):
arts = [self.article(title="Erster"), self.article(title="Zweiter", timings=None)]
info = self.build(arts)
self.assertEqual(info["quality"], {"Erster": "exact", "Zweiter": "estimated"})
with zipfile.ZipFile(self.out) as z:
opf = z.read("OEBPS/content.opf").decode()
self.assertIn("estimated", re.search(r"<dc:description>(.*?)</dc:description>", opf).group(1))
nav = z.read("OEBPS/nav.xhtml").decode()
self.assertLess(nav.index("Erster"), nav.index("Zweiter"))
def test_nothing_to_package_is_an_error_and_leaves_no_file(self):
with self.assertRaises(ValueError):
ee.build_epub(self.out, title="t", issue="", language="de", articles=[])
with self.assertRaises(ValueError):
self.build([self.article(blocks=[{"type": "body", "text": " "}])])
self.assertEqual([p.name for p in self.dir.iterdir() if p.suffix in (".epub", ".part")], [])
def test_a_missing_audio_file_leaves_no_partial_book(self):
with self.assertRaises(OSError):
self.build([self.article(mp3=self.dir / "missing.mp3")])
self.assertEqual([p.name for p in self.dir.iterdir() if p.suffix in (".epub", ".part")], [])
class TimingRecordTests(unittest.TestCase):
def test_write_and_read_round_trip_without_any_text(self):
d = Path(tempfile.mkdtemp(prefix="ttvc-tm-"))
mp3 = d / "article-001.mp3"
mp3.write_bytes(b"x")
segs = [{"text": "Erster Satz. Zweiter."}, {"text": "Dritter."}]
mag.write_audio_timings(mp3, segs, [0, 0, 1], [(0, 900), (900, 1500), (1500, 2100)], interview=False)
rec = mag.read_audio_timings(mp3)
self.assertEqual(rec["duration_ms"], 2100)
self.assertEqual(rec["segments"], [{"chars": 21, "times": [[0, 900], [900, 1500]]}, {"chars": 8, "times": [[1500, 2100]]}])
self.assertNotIn("Satz", (d / "article-001.timings.json").read_text())
def test_missing_or_corrupt_record_reads_as_none(self):
d = Path(tempfile.mkdtemp(prefix="ttvc-tm-"))
mp3 = d / "x.mp3"
self.assertIsNone(mag.read_audio_timings(mp3))
(d / "x.timings.json").write_text("not json")
self.assertIsNone(mag.read_audio_timings(mp3))
(d / "x.timings.json").write_text(json.dumps({"version": 2, "segments": []}))
self.assertIsNone(mag.read_audio_timings(mp3))
class Mp3DurationTests(unittest.TestCase):
def setUp(self):
from unittest import mock
self.mock = mock
self.mp3 = Path(tempfile.mkdtemp(prefix="ttvc-dur-")) / "a.mp3"
self.mp3.write_bytes(b"not really audio")
def test_falls_back_to_ffprobe_when_mutagen_is_missing(self):
import subprocess, sys
done = subprocess.CompletedProcess([], 0, stdout="12.5\n")
with self.mock.patch.dict(sys.modules, {"mutagen": None}), \
self.mock.patch.object(mag.subprocess, "run", return_value=done):
self.assertEqual(mag.mp3_duration_seconds(self.mp3), 12.5)
def test_unreadable_audio_is_none_not_an_exception(self):
import subprocess, sys
with self.mock.patch.dict(sys.modules, {"mutagen": None}), \
self.mock.patch.object(mag.subprocess, "run", side_effect=subprocess.CalledProcessError(1, "ffprobe")):
self.assertIsNone(mag.mp3_duration_seconds(self.mp3))
with self.mock.patch.dict(sys.modules, {"mutagen": None}), \
self.mock.patch.object(mag.subprocess, "run", side_effect=FileNotFoundError()):
self.assertIsNone(mag.mp3_duration_seconds(self.mp3))
def test_garbage_or_zero_output_is_none(self):
import subprocess, sys
for out in ("N/A\n", "0\n", ""):
with self.mock.patch.dict(sys.modules, {"mutagen": None}), \
self.mock.patch.object(mag.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, stdout=out)):
self.assertIsNone(mag.mp3_duration_seconds(self.mp3))
class EndpointTests(unittest.TestCase):
def setUp(self):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from unittest import mock
import routes.magazine as routes
self.lib = Path(tempfile.mkdtemp(prefix="ttvc-lib-"))
p = mock.patch.object(routes, "_LIB", self.lib)
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": "Test Mag", "issue": "1/2026"}).json()["id"]
self.d = self.lib / self.doc
def seed(self, approved=True, with_audio=True):
layout = {"page_count": 1, "pages": [{"page": 1, "width": 595, "height": 842}], "images": [], "blocks": [
{"id": "t", "page": 1, "bbox": [0, 0, 1, 1], "type": "title", "text": "Kopf", "reading_order": 0},
{"id": "b", "page": 1, "bbox": [0, 2, 1, 1], "type": "body", "text": "Ein Satz. Noch ein Satz.", "reading_order": 1}]}
(self.d / "layout.json").write_text(json.dumps(layout))
(self.d / "articles.json").write_text(json.dumps({"articles": [{"id": "article-001", "title": "Kopf", "pages": [1],
"blocks": ["t", "b"], "images": [], "status": "approved" if approved else "detected", "confidence": .9}]}))
import shutil
shutil.rmtree(self.d / "audio", ignore_errors=True)
if with_audio:
(self.d / "audio").mkdir()
(self.d / "audio" / "article-001.mp3").write_bytes(b"ID3" + b"0" * 200)
(self.d / "audio" / "article-001.timings.json").write_text(json.dumps(
{"version": 1, "interview": False, "duration_ms": 3000, "segments": [
{"chars": 4, "times": [[0, 500]]}, {"chars": 24, "times": [[500, 1700], [1700, 3000]]}]}))
def test_downloads_a_valid_epub(self):
self.seed()
r = self.client.get(f"/api/magazine/{self.doc}/epub")
self.assertEqual(r.status_code, 200, r.text[:200])
self.assertEqual(r.headers["content-type"], "application/epub+zip")
self.assertIn("Test%20Mag.epub", r.headers["content-disposition"])
import io
with zipfile.ZipFile(io.BytesIO(r.content)) as z:
self.assertEqual(z.namelist()[0], "mimetype")
self.assertIn("exact", z.read("OEBPS/content.opf").decode())
def test_unapproved_or_silent_articles_are_not_packaged(self):
self.seed(approved=False)
self.assertEqual(self.client.get(f"/api/magazine/{self.doc}/epub").status_code, 404)
self.seed(with_audio=False)
self.assertEqual(self.client.get(f"/api/magazine/{self.doc}/epub").status_code, 404)
def test_unknown_article_and_bad_document(self):
self.seed()
self.assertEqual(self.client.get(f"/api/magazine/{self.doc}/epub?article=nope").status_code, 404)
self.assertIn(self.client.get("/api/magazine/xx/epub").status_code, (400, 404))
def test_the_temporary_file_is_removed_after_sending(self):
import glob
self.seed()
before = set(glob.glob(os.path.join(tempfile.gettempdir(), "mag-epub-*")))
self.client.get(f"/api/magazine/{self.doc}/epub")
self.assertEqual(set(glob.glob(os.path.join(tempfile.gettempdir(), "mag-epub-*"))) - before, set())
if __name__ == "__main__":
unittest.main()

View File

@ -465,6 +465,64 @@ class ContinuationTests(unittest.TestCase):
self.assertEqual(find_continuation("The page turned slowly."), (None, None))
class BackwardContinuationTests(unittest.TestCase):
"""A tail that says "continued from page N" is attached to the article on page N."""
def issue(self, tail_marker=None, marker_type=BLOCK_FURNITURE, marker_page=20):
blocks = []
def add(b, order):
b.reading_order = order
blocks.append(b)
add(block("b0010-000", 10, 50, 60, 470, 50, "The Future of Solid State Batteries", size=26.0, btype=BLOCK_TITLE), 0)
add(block("b0010-001", 10, 50, 130, 200, 300, "Body copy of the feature. " * 30), 1)
add(block("b0020-000", 20, 50, 60, 470, 50, "Lessons From The Lab Bench", size=26.0, btype=BLOCK_TITLE), 0)
add(block("b0020-001", 20, 50, 130, 200, 300, "More body copy after the jump. " * 30), 1)
if tail_marker:
add(block("b0020-009", marker_page, 50, 760, 200, 12, tail_marker, size=7.0, btype=marker_type), 9)
return blocks
def test_continued_from_joins_the_tail_to_its_head(self):
arts = group_articles(self.issue("Fortsetzung von Seite 10"), [], page_count=30)
self.assertEqual(len(arts), 1)
self.assertEqual(arts[0].pages, [10, 20])
self.assertIn("b0020-001", arts[0].blocks)
def test_english_marker_in_a_sidebar_block_also_works(self):
arts = group_articles(self.issue("Continued from page 10", marker_type=BLOCK_SIDEBAR), [], page_count=30)
self.assertEqual([a.pages for a in arts], [[10, 20]])
def test_without_a_marker_the_two_stay_separate(self):
arts = group_articles(self.issue(), [], page_count=30)
self.assertEqual(len(arts), 2)
def test_a_marker_pointing_at_an_empty_page_changes_nothing(self):
arts = group_articles(self.issue("Fortsetzung von Seite 15"), [], page_count=30)
self.assertEqual(len(arts), 2)
def test_a_marker_pointing_forward_is_ignored(self):
arts = group_articles(self.issue("Fortsetzung von Seite 25"), [], page_count=30)
self.assertEqual(len(arts), 2)
def test_long_prose_quoting_the_phrase_is_not_a_marker(self):
prose = "As the editors noted, the story was continued from page 10 of the previous issue. " * 4
arts = group_articles(self.issue(prose, marker_type=BLOCK_BODY), [], page_count=30)
self.assertEqual(len(arts), 2)
def test_a_tail_anchored_by_the_contents_keeps_its_identity(self):
contents = [{"title": "Lessons From The Lab Bench", "page": 20, "section": ""}]
arts = group_articles(self.issue("Fortsetzung von Seite 10"), contents, page_count=30)
self.assertEqual(len(arts), 2)
def test_forward_and_backward_references_together_join_once(self):
blocks = self.issue("Fortsetzung von Seite 10")
blocks.append(block("b0010-009", 10, 50, 760, 200, 12, "Fortsetzung auf Seite 20", size=7.0, btype=BLOCK_BODY))
blocks[-1].reading_order = 9
arts = group_articles(blocks, [], page_count=30)
self.assertEqual(len(arts), 1)
self.assertEqual(len(arts[0].blocks), len(set(arts[0].blocks)))
class OcrParagraphGroupingTests(unittest.TestCase):
"""ocr_page() groups tesseract's TSV by paragraph, not by line -- matching
how the native PyMuPDF path already aggregates a paragraph's spans into

View File

@ -0,0 +1,139 @@
"""Importing a magazine from page images: conversion to a scan-style PDF and its refusals."""
import io
import os
import tempfile
import unittest
from pathlib import Path
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-test-config-"))
from PIL import Image
from core import magazine as mag
def png(w=300, h=400, dpi=None, color=(250, 250, 250)):
buf = io.BytesIO()
kw = {"dpi": (dpi, dpi)} if dpi else {}
Image.new("RGB", (w, h), color).save(buf, format="PNG", **kw)
return buf.getvalue()
def jpeg(w=300, h=400, orientation=None):
buf = io.BytesIO()
im = Image.new("RGB", (w, h), (200, 200, 200))
exif = Image.Exif()
if orientation:
exif[0x0112] = orientation
im.save(buf, format="JPEG", exif=exif)
return buf.getvalue()
class ImagesToPdfTests(unittest.TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp(prefix="ttvc-img-"))
self.dest = self.dir / "source.pdf"
def pages(self):
import pymupdf
with pymupdf.open(str(self.dest)) as d:
return [(round(p.rect.width), round(p.rect.height), len(p.get_text().strip())) for p in d]
def test_pages_in_order_sized_from_resolution_with_no_text_layer(self):
n = mag.images_to_pdf([("a.png", png(600, 900, dpi=300)), ("b.jpg", jpeg(150, 300))], self.dest)
self.assertEqual(n, 2)
# 600x900 px at 300 dpi = 144x216 pt; the jpeg has no dpi -> assumed 150 dpi = 72x144 pt
self.assertEqual(self.pages(), [(144, 216, 0), (72, 144, 0)])
def test_exif_rotation_is_applied(self):
mag.images_to_pdf([("r.jpg", jpeg(300, 200, orientation=6))], self.dest) # rotated 90°: becomes portrait
w, h, _ = self.pages()[0]
self.assertLess(w, h)
def test_unrotated_jpeg_is_not_reencoded(self):
data = jpeg(300, 200)
mag.images_to_pdf([("r.jpg", data)], self.dest)
import pymupdf
with pymupdf.open(str(self.dest)) as d:
extracted = d.extract_image(d[0].get_images()[0][0])["image"]
self.assertEqual(extracted, data)
def test_rejects_a_renamed_non_image_by_content(self):
with self.assertRaisesRegex(mag.ImageImportError, "not a PNG or JPEG"):
mag.images_to_pdf([("page.png", b"<html>not an image</html>")], self.dest)
self.assertFalse(self.dest.exists())
def test_rejects_a_truncated_image(self):
with self.assertRaisesRegex(mag.ImageImportError, "could not be read"):
mag.images_to_pdf([("t.png", png()[:40])], self.dest)
def test_rejects_too_many_pixels_before_decoding(self):
old = mag.IMAGE_IMPORT_MAX_PIXELS
mag.IMAGE_IMPORT_MAX_PIXELS = 1000
try:
with self.assertRaisesRegex(mag.ImageImportError, "too many pixels"):
mag.images_to_pdf([("big.png", png(300, 400))], self.dest)
finally:
mag.IMAGE_IMPORT_MAX_PIXELS = old
def test_rejects_oversize_file_and_empty_and_too_many(self):
with self.assertRaisesRegex(mag.ImageImportError, "larger than"):
mag.images_to_pdf([("x.png", b"\x89PNG\r\n\x1a\n" + b"0" * (mag.IMAGE_IMPORT_MAX_BYTES + 1))], self.dest)
with self.assertRaises(mag.ImageImportError):
mag.images_to_pdf([], self.dest)
with self.assertRaisesRegex(mag.ImageImportError, "At most"):
mag.images_to_pdf([("a.png", png(10, 10))] * (mag.IMAGE_IMPORT_MAX_FILES + 1), self.dest)
def test_one_bad_image_leaves_no_partial_pdf(self):
with self.assertRaises(mag.ImageImportError):
mag.images_to_pdf([("a.png", png()), ("b.png", b"nope")], self.dest)
self.assertEqual(list(self.dir.iterdir()), [])
def test_name_is_only_used_as_a_short_label(self):
with self.assertRaises(mag.ImageImportError) as ctx:
mag.images_to_pdf([("../../etc/passwd" + "x" * 200, b"nope")], self.dest)
self.assertNotIn("..", str(ctx.exception))
class EndpointTests(unittest.TestCase):
def setUp(self):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from unittest import mock
import routes.magazine as routes
self.routes = routes
self.lib = Path(tempfile.mkdtemp(prefix="ttvc-lib-"))
self.patch = mock.patch.object(routes, "_LIB", self.lib)
self.patch.start()
self.addCleanup(self.patch.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"]
def test_upload_builds_the_source_pdf(self):
r = self.client.post(f"/api/magazine/{self.doc}/source-images",
files=[("files", ("1.png", png(), "image/png")), ("files", ("2.jpg", jpeg(), "image/jpeg"))])
self.assertEqual(r.status_code, 200, r.text)
self.assertEqual(r.json()["pages"], 2)
self.assertTrue((self.lib / self.doc / "source.pdf").is_file())
def test_a_second_upload_cannot_replace_existing_source(self):
files = [("files", ("1.png", png(), "image/png"))]
self.assertEqual(self.client.post(f"/api/magazine/{self.doc}/source-images", files=files).status_code, 200)
self.assertEqual(self.client.post(f"/api/magazine/{self.doc}/source-images", files=files).status_code, 409)
def test_bad_content_is_a_400_with_a_message(self):
r = self.client.post(f"/api/magazine/{self.doc}/source-images",
files=[("files", ("1.png", b"GIF89a....", "image/png"))])
self.assertEqual(r.status_code, 400)
self.assertIn("not a PNG or JPEG", r.json()["detail"])
self.assertFalse((self.lib / self.doc / "source.pdf").exists())
def test_unknown_document_is_rejected(self):
r = self.client.post("/api/magazine/nope/source-images", files=[("files", ("1.png", png(), "image/png"))])
self.assertIn(r.status_code, (400, 404))
if __name__ == "__main__":
unittest.main()

View File

@ -1051,6 +1051,50 @@ class CleanUpBoxesTests(unittest.TestCase):
shutil.rmtree(d, ignore_errors=True)
class ArticleAsidesTests(unittest.TestCase):
"""Sidebars are shown beside an article's text but are never part of what is narrated."""
def layout(self):
def blk(i, page, typ, text, order):
return {"id": i, "page": page, "bbox": [0, 0, 10, 10], "type": typ, "text": text, "reading_order": order}
return {"pages": [{"page": 5}, {"page": 6}, {"page": 7}], "images": [], "blocks": [
blk("t", 5, "title", "Headline", 0), blk("p1", 5, "body", "First paragraph.", 1),
blk("s1", 5, "sidebar", "Box: did you know?", 2), blk("p2", 5, "body", "Second paragraph.", 3),
blk("s2", 6, "sidebar", "Glossary (continued on page 9)", 0),
blk("ad", 7, "sidebar", "Shared page box", 0),
]}
def article(self, aid="a1", pages=(5, 6), blocks=("t", "p1", "p2")):
return {"id": aid, "pages": list(pages), "blocks": list(blocks)}
def test_sidebars_on_the_articles_own_pages_follow_the_right_block(self):
from core.magazine import article_asides
a = self.article()
out = article_asides(self.layout(), a, [a])
self.assertEqual([(x["id"], x["after"]) for x in out], [("s1", "p1"), ("s2", None)])
self.assertEqual(out[1]["text"], "Glossary") # the jump cue is print furniture, not content
def test_a_page_shared_with_another_article_shows_no_sidebar(self):
from core.magazine import article_asides
a, b = self.article(), self.article("a2", pages=(5,), blocks=())
self.assertEqual([x["id"] for x in article_asides(self.layout(), a, [a, b])], ["s2"])
def test_a_sidebar_the_reader_ticked_in_is_narrated_not_an_aside(self):
from core.magazine import article_asides
a = self.article(blocks=("t", "p1", "s1", "p2"))
self.assertEqual([x["id"] for x in article_asides(self.layout(), a, [a])], ["s2"])
def test_advertisement_pages_and_empty_sidebars_are_skipped(self):
from core.magazine import article_asides
layout = self.layout()
layout["pages"][1]["advertisement"] = True
a = self.article()
self.assertEqual([x["id"] for x in article_asides(layout, a, [a])], ["s1"])
layout = self.layout()
layout["blocks"][2]["text"] = " "
self.assertEqual([x["id"] for x in article_asides(layout, a, [a])], ["s2"])
class QuickCorrectionTests(unittest.TestCase):
"""Edit text, merge with the next region, split a region -- from the review list."""
@ -1071,6 +1115,40 @@ class QuickCorrectionTests(unittest.TestCase):
with self.assertRaises(ValueError):
set_region_text(self.layout(), 1, "c", " ")
def get(self, layout, bid):
return next(b for b in layout["blocks"] if b["id"] == bid)
def test_the_first_edit_keeps_the_recognised_text_and_later_edits_do_not_overwrite_it(self):
from core.magazine import set_region_text
once = set_region_text(self.layout(), 1, "c", "Erster Satz. Zweiter Absatz.")
self.assertEqual(self.get(once, "c")["ocr_text"], "Erster Satz. Zweiter Absatz beginnt.")
twice = set_region_text(once, 1, "c", "Ganz anders.")
self.assertEqual(self.get(twice, "c")["ocr_text"], "Erster Satz. Zweiter Absatz beginnt.")
def test_typing_the_original_back_clears_the_edit_mark(self):
from core.magazine import set_region_text
edited = set_region_text(self.layout(), 1, "c", "Changed.")
back = set_region_text(edited, 1, "c", "Erster Satz. Zweiter Absatz beginnt.")
self.assertNotIn("ocr_text", self.get(back, "c"))
def test_saving_unchanged_text_marks_nothing(self):
from core.magazine import set_region_text
same = set_region_text(self.layout(), 1, "c", "Erster Satz. Zweiter Absatz beginnt.")
self.assertNotIn("ocr_text", self.get(same, "c"))
def test_other_regions_and_the_input_layout_are_untouched(self):
from core.magazine import set_region_text
layout = self.layout()
out = set_region_text(layout, 1, "c", "Changed.")
self.assertNotIn("ocr_text", self.get(out, "a"))
self.assertNotIn("ocr_text", self.get(layout, "c"))
def test_ocr_text_survives_a_block_round_trip(self):
from core.magazine import Block
b = Block(id="x", page=1, bbox=[0, 0, 1, 1], type="body", text="t", ocr_text="old")
self.assertEqual(b.to_json()["ocr_text"], "old")
self.assertNotIn("ocr_text", Block(id="y", page=1, bbox=[0, 0, 1, 1], type="body", text="t").to_json())
def test_merging_closes_a_hyphenated_word_and_covers_both_boxes(self):
from core.magazine import merge_with_next
out, removed = merge_with_next(self.layout(), 1, "a")

View File

@ -37,6 +37,7 @@ class FakeSession:
def get(self, url, **kw):
self.calls.append(url)
self.kwargs = getattr(self, "kwargs", []) + [kw]
for prefix, resp in self.routes.items():
if url.startswith(prefix):
return resp() if callable(resp) else resp
@ -87,6 +88,116 @@ class OpdsTests(unittest.TestCase):
self.provider({}).search(" ")
def feed(entries, next_href=None):
body = '<feed xmlns="http://www.w3.org/2005/Atom">'
for n in entries:
body += (f'<entry><title>Issue {n}</title><link rel="http://opds-spec.org/acquisition" type="application/pdf" '
f'href="/dl/{n}.pdf"/></entry>')
if next_href:
body += f'<link rel="next" href="{next_href}"/>'
return FakeResp(body=(body + "</feed>").encode())
ROOT = FakeResp(body=b'<feed xmlns="http://www.w3.org/2005/Atom"/>')
class OpdsPagingTests(unittest.TestCase):
def provider(self, routes, **kw):
routes = {**routes, "http://nas.local/opds/": ROOT}
self.session = FakeSession(routes)
return ms.OpdsProvider("opds-1", "NAS", "http://nas.local/opds/", self.session, **kw)
def test_a_second_results_page_follows_the_catalogues_next_link(self):
# Most specific route first: the fake session matches by prefix.
p = self.provider({"http://nas.local/opds/search?page=2": feed(range(16, 31)),
"http://nas.local/opds/search": feed(range(1, 16), "/opds/search?page=2")})
self.assertEqual(p.search("x", 1)[0].title, "Issue 1")
page2 = p.search("x", 2)
self.assertEqual([i.title for i in page2], [f"Issue {n}" for n in range(21, 31)])
def test_a_next_link_to_another_host_is_not_followed(self):
p = self.provider({"http://nas.local/opds/search": feed([1, 2], "http://evil.example/opds?page=2")})
self.assertEqual(len(p.search("x", 2)), 0)
self.assertFalse(any("evil.example" in c for c in self.session.calls))
def test_a_next_link_loop_ends(self):
p = self.provider({"http://nas.local/opds/search": feed([1], "/opds/search")})
self.assertEqual(len(p.search("x", 3)), 0)
self.assertLessEqual(len(self.session.calls), 3)
def test_paging_is_bounded(self):
routes = {f"http://nas.local/opds/search?p={n}": feed([n], f"/opds/search?p={n + 1}") for n in range(1, 50)}
routes["http://nas.local/opds/search"] = feed([0], "/opds/search?p=1")
p = self.provider(routes)
p.search("x", 9)
self.assertLessEqual(len(self.session.calls), 1 + ms.OpdsProvider.MAX_FEED_PAGES)
class OpdsLoginTests(unittest.TestCase):
def setUp(self):
import os
self.os = os
os.environ["TEST_OPDS_SECRET"] = "s3cret-value"
self.addCleanup(lambda: os.environ.pop("TEST_OPDS_SECRET", None))
def provider(self, routes, **kw):
self.session = FakeSession({**routes, "http://nas.local/opds/": ROOT})
return ms.OpdsProvider("opds-1", "NAS", "http://nas.local/opds/", self.session, **kw)
def test_credentials_come_from_the_named_environment_variable_and_go_to_the_catalogue(self):
p = self.provider({"http://nas.local/opds/search": feed([1])}, username="me", password_env="TEST_OPDS_SECRET")
self.assertEqual(len(p.search("x")), 1)
self.assertTrue(all(kw.get("auth") == ("me", "s3cret-value") for kw in self.session.kwargs))
def test_an_open_catalogue_sends_no_auth(self):
p = self.provider({"http://nas.local/opds/search": feed([1])})
p.search("x")
self.assertTrue(all("auth" not in kw for kw in self.session.kwargs))
def test_a_missing_password_is_a_clear_error_that_never_shows_a_secret(self):
p = self.provider({}, username="me", password_env="TEST_OPDS_UNSET")
with self.assertRaises(ms.SourceError) as ctx:
p.search("x")
self.assertIn("TEST_OPDS_UNSET", str(ctx.exception))
self.assertNotIn("s3cret", str(ctx.exception))
def test_downloads_are_authenticated_too(self):
import tempfile
from pathlib import Path
p = self.provider({"http://nas.local/dl": FakeResp(body=b"%PDF-1.7 x")}, username="me", password_env="TEST_OPDS_SECRET")
ms.download_pdf(p, "http://nas.local/dl/1.pdf", Path(tempfile.mkdtemp()) / "source.pdf", session=self.session)
self.assertEqual(self.session.kwargs[-1].get("auth"), ("me", "s3cret-value"))
def test_auth_is_not_sent_when_a_redirect_leaves_the_host(self):
import tempfile
from pathlib import Path
p = self.provider({"http://nas.local/dl": FakeResp(status=302, headers={"location": "http://evil.example/x"})},
username="me", password_env="TEST_OPDS_SECRET")
with self.assertRaises(ms.SourceError):
ms.download_pdf(p, "http://nas.local/dl/1.pdf", Path(tempfile.mkdtemp()) / "source.pdf", session=self.session)
self.assertFalse(any("evil.example" in c for c in self.session.calls))
def test_settings_with_an_implausible_variable_name_are_ignored(self):
provs = ms.build_providers([
{"name": "ok", "url": "http://nas.local/opds", "username": "me", "password_env": "NAS_PASSWORD"},
{"name": "bad", "url": "http://nas2.local/opds", "username": "me", "password_env": "x; rm -rf /"},
{"name": "lower", "url": "http://nas3.local/opds", "username": "me", "password_env": "lowercase"}])
self.assertEqual(sorted(provs), ["internet-archive", "opds-1"])
def test_the_source_list_never_exposes_credentials(self):
from unittest import mock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes import magazine_sources as routes
app = FastAPI(); app.include_router(routes.router)
cats = [{"name": "NAS", "url": "http://nas.local/opds", "username": "me", "password_env": "TEST_OPDS_SECRET"}]
with mock.patch.object(routes, "_load_settings", return_value={"magazine_opds_catalogs": cats}):
body = TestClient(app).get("/api/magazine-sources").text
self.assertNotIn("s3cret", body)
self.assertNotIn("TEST_OPDS_SECRET", body)
self.assertNotIn("me\"", body.replace("name\"", ""))
class ArchiveTests(unittest.TestCase):
def test_search_builds_items_and_skips_bad_identifiers(self):
docs = [{"identifier": "ct-2026-01", "title": "c't", "creator": ["Heise"], "date": "2026-01-01T00:00:00Z"},
@ -209,3 +320,61 @@ class RouteTests(unittest.TestCase):
with self.mock.patch.object(self.routes, "_load_settings", return_value={}):
r = self.client.post("/api/magazine-sources/import", json={"source": "internet-archive"})
self.assertEqual(r.status_code, 400)
class CatalogueEndpointTests(unittest.TestCase):
def setUp(self):
from unittest import mock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes import magazine_sources as routes
self.store = {"magazine_opds_catalogs": []}
for name, fn in (("_load_settings", lambda: dict(self.store)), ("_save_settings", lambda s: self.store.update(s))):
p = mock.patch.object(routes, name, side_effect=fn)
p.start(); self.addCleanup(p.stop)
app = FastAPI(); app.include_router(routes.router)
self.client = TestClient(app)
def put(self, cats):
return self.client.put("/api/magazine-sources/catalogues", json={"catalogues": cats})
def test_a_valid_list_is_saved_and_read_back(self):
r = self.put([{"name": " My NAS ", "url": "http://nas.local:8083/opds/", "username": "me", "password_env": "NAS_PW"},
{"name": "", "url": "https://books.example/opds"}])
self.assertEqual(r.status_code, 200, r.text)
got = self.client.get("/api/magazine-sources/catalogues").json()["catalogues"]
self.assertEqual([c["name"] for c in got], ["My NAS", "books.example"])
self.assertEqual(got[0]["password_env"], "NAS_PW")
self.assertFalse(got[0]["password_set"]) # the variable is not set in this environment
def test_credentials_in_the_address_are_refused(self):
self.assertEqual(self.put([{"url": "http://me:pw@nas.local/opds"}]).status_code, 400)
def test_other_schemes_and_missing_hosts_are_refused(self):
for bad in ("ftp://nas/opds", "javascript:alert(1)", "file:///etc/passwd", "http://", "nas.local/opds", ""):
self.assertEqual(self.put([{"url": bad}]).status_code, 400, bad)
def test_a_login_needs_both_halves_and_a_plausible_variable_name(self):
self.assertEqual(self.put([{"url": "http://a.local/o", "username": "me"}]).status_code, 400)
self.assertEqual(self.put([{"url": "http://a.local/o", "password_env": "PW"}]).status_code, 400)
self.assertEqual(self.put([{"url": "http://a.local/o", "username": "me", "password_env": "lower; rm"}]).status_code, 400)
def test_duplicates_too_many_and_wrong_shapes_are_refused(self):
self.assertEqual(self.put([{"url": "http://a.local/o"}, {"url": "http://a.local/o"}]).status_code, 400)
self.assertEqual(self.put([{"url": f"http://h{n}.local/o"} for n in range(11)]).status_code, 400)
self.assertEqual(self.put("nope").status_code, 400)
self.assertEqual(self.put(["nope"]).status_code, 400)
def test_a_refused_list_changes_nothing(self):
self.put([{"url": "http://ok.local/o"}])
self.put([{"url": "http://ok.local/o"}, {"url": "ftp://bad"}])
self.assertEqual([c["url"] for c in self.store["magazine_opds_catalogs"]], ["http://ok.local/o"])
def test_the_password_itself_is_never_returned(self):
import os
os.environ["CAT_TEST_PW"] = "topsecret"
self.addCleanup(lambda: os.environ.pop("CAT_TEST_PW", None))
self.put([{"url": "http://a.local/o", "username": "me", "password_env": "CAT_TEST_PW"}])
r = self.client.get("/api/magazine-sources/catalogues")
self.assertNotIn("topsecret", r.text)
self.assertTrue(r.json()["catalogues"][0]["password_set"])