Compare commits
3 Commits
4417363531
...
94331320c0
| Author | SHA1 | Date | |
|---|---|---|---|
| 94331320c0 | |||
| cddc3c226d | |||
| f24a83eb75 |
27
CHANGELOG.md
27
CHANGELOG.md
@ -7,6 +7,33 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.43.0] — 2026-09-22
|
||||
|
||||
### Added
|
||||
- **Magazine: one "Draw region" tool instead of six.** Draw text, Draw image, Headline, Page number, Source/footer and Advertisement were six separate buttons that each pre-selected a region kind before you'd even drawn a box. There is now a single Draw region tool; the panel that appears after you draw the rectangle has a Region type dropdown covering every kind (Paragraph, Headline, Subheading, Standfirst, Author, Caption, Pull quote, Sidenote, Page number, Source/footer, Advertisement, Text in picture, OCR noise, Duplicate, Table, Image) — pick what it is after you've seen the shape, not before.
|
||||
- **Magazine: "Table" is a real region type.** Marking a region as a table keeps its OCR'd text visible for reference but excludes it from narration, the same as other non-body furniture — no more a dense grid of numbers being read aloud as a flat word stream.
|
||||
- **Magazine: every text-like drawn or re-read region auto-OCRs when left blank.** Previously only a handful of preset kinds triggered automatic reading; now the rule is uniform — every text-like kind reads itself in, and only Advertisement and Image are skipped (nothing to transcribe in either).
|
||||
- **Magazine: thumbnail grid view for the article list**, toggleable alongside the existing list view, remembered per browser.
|
||||
- **Magazine: batch approve/reject.** Select several articles at once and approve or reject them in one action — accepts each exactly as auto-detected, for the pieces that already look right; a messy one still needs individual Review.
|
||||
- **Magazine: inline title editing.** Click an article's title, in either view, and type — fixes a mangled OCR title ("Sbimmb das? c'b prüFb") without opening Review.
|
||||
- **Magazine: standalone MP3 export.** Render any approved article to a single MP3 tagged with title, author, album (the magazine's title), its own text as embedded lyrics, and its page image as cover art — for a phone's own music/podcast player or a car head unit, not just this app. "Generate all audio" renders every approved article in one background job; per-article downloads plus a "Download all" ZIP.
|
||||
- **Magazine: "Remove all boxes."** Deletes every detected region on the current page in one action, with confirmation and Undo, for when the auto-detected layout is too tangled to fix box by box and it's faster to start the page from nothing.
|
||||
- **Magazine: Approve moves straight to the next article** for review, instead of dropping back to the list — reviewing a long queue no longer means re-opening each one by hand.
|
||||
|
||||
### Fixed
|
||||
- **Magazine: resizing an image region's box didn't visibly update the crop.** The server already re-crops the file correctly, but writes it under the same filename every time, so the browser kept serving its cached copy of the old crop. The preview now busts the cache on the region's own bbox, which changes exactly when the crop does.
|
||||
- **Magazine: a region drawn or re-read while the vision OCR backend was unreachable always came back "no text recognised."** Confirmed live: the configured vision OCR endpoints were refusing connections (a GPU memory conflict on the host). Both the new-region and re-read paths now fall back to the local Tesseract engine instead of giving up outright.
|
||||
- **Magazine: the review toolbar no longer fits or stays put on a long page.** Its two page steppers (one bounded to the current article's own pages, one to the whole magazine) almost always agreed and just doubled the toolbar's width; merged into one. The toolbar is now sticky so it stays visible while scrolling a long page's regions.
|
||||
- **Characters: several recurring portrait-rendering biases fixed**, each confirmed live across this session's cast for the DSA audiobook series: hair defaulting to a modern barbershop cut, or outright bald/receding, regardless of what the sheet said about coverage; an armored woman rendering with a shaved head and masculinized features; a duplicated or malformed weapon (a second sword shown alongside the drawn one, a two-handed Zweihänder rendered with two separate hilts); a crossbow or bow rendering as a modern firearm, traced to the ranged-weapon detection regex missing German compound words like "Armbrustbolzen"; an established dwarf still rendered with tall human body proportions; and a scene panel occasionally adding an extra hand or losing an established non-human species.
|
||||
|
||||
### Changed
|
||||
- Added `mutagen` to `requirements.txt` for the new MP3 export's ID3 tagging (title/artist/album, embedded lyrics, cover art).
|
||||
|
||||
## [1.42.4] — 2026-09-22
|
||||
|
||||
### Fixed
|
||||
- **Magazine ingestion: a proposed picture, chart or table sometimes wasn't one.** The semantic-layout pass can invent a new picture region for a graphic OCR missed; on a real issue it sometimes drew that box over a column gap and the text in it instead (a table-of-contents heading and intro paragraph in one case), burying real paragraphs under an empty "Figure" placeholder. A proposed region is now checked against the page render's own ink density before it is created — a real photo, chart or table is solidly inked, a text area is not — confirmed against the actual false positives and true positives found on that issue (0.10-0.14 vs 0.56-0.97 mean ink, no overlap). QA also spot-checked headline, paragraph, column, page-number and advertisement recognition across a sample of pages of that issue; no other errors found.
|
||||
|
||||
## [1.42.3] — 2026-09-22
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -113,7 +113,12 @@ turned into a podcast discussion between two or three voices you pick.
|
||||
real edges of the picture for long. The recognised-text panel shows the
|
||||
actual cropped picture, chart or table for the same reason, rather than
|
||||
a plain "Figure" placeholder, as soon as it has been cropped. The page
|
||||
scan itself (a scanned magazine is one picture per page) is not framed. If a run is interrupted
|
||||
scan itself (a scanned magazine is one picture per page) is not framed.
|
||||
A proposed picture, chart or table is checked against the page render
|
||||
itself before it is created, so a text area or column gap is not shown
|
||||
as a picture. A real picture can still get a loose or shifted box in
|
||||
some cases; use *Refine figures* (or re-run detection on the page) to
|
||||
try snapping it again, or fix the box by hand on the review page. If a run is interrupted
|
||||
(for example when the server restarts), the status now says so instead
|
||||
of spinning forever; start it again with *Re-run detection*.
|
||||
3. **Review the articles.** This step is not optional and cannot be skipped.
|
||||
|
||||
10
TODO.md
10
TODO.md
@ -656,3 +656,13 @@ became behaviour ("peers through small windows, moves nimbly on spiral staircase
|
||||
rather than appearance, where the older sheet had "shoulder-length white hair, faded scar
|
||||
across forehead". Prefer the app's own Character Sheets flow over evidence-window
|
||||
shortcuts when appearance quality matters.
|
||||
|
||||
- [ ] **Magazine: deleting a picture/chart/table region leaves its crop file and article
|
||||
reference behind.** Found 2026-09-22 while cleaning up two invented figure regions by
|
||||
hand. `delete_region()`/`DELETE .../regions/{block_id}` matches only the block's own id;
|
||||
a picture/chart/table's derived crop has a *different* id (`p{page}-region-{block_id}`,
|
||||
from `crop_visual_regions`), so deleting the block leaves that crop file on disk and its
|
||||
id in any article's `images` list (the article would still try to show a picture of
|
||||
content that's no longer recognised as one). Fix: when the deleted id is a block with a
|
||||
crop derived from it, also remove the `p{page}-region-{id}` image entry, delete its file,
|
||||
and strip that derived id (not just the block id) from every article's `images` list.
|
||||
|
||||
@ -615,6 +615,40 @@ rendered. Confirmed live: the page now shows both engines as ready-made
|
||||
local cards (Connect/Start/Restart/Use as OCR/Show code snippet), matching
|
||||
the LLM page, with no "Add Custom" dialog needed for either.
|
||||
|
||||
### A new figure the model invents is screened against the page's own ink
|
||||
|
||||
The semantic-layout call is allowed to propose a picture/chart/table region
|
||||
with `block_ids: []` -- a graphic OCR never produced a block for. Confirmed
|
||||
on a real issue: it sometimes drew that box over a column gap and the text
|
||||
sitting in it instead -- a table-of-contents heading and its intro paragraph
|
||||
in one case, a photo caption's column in another -- which buried real,
|
||||
already-recognised paragraphs under an empty "Figure" placeholder rather
|
||||
than adding a picture that was actually missing.
|
||||
|
||||
`region_looks_like_a_picture()` (core/magazine.py) screens every such
|
||||
invented region against `page_ink_grid_points()` before it is created:
|
||||
mean ink coverage over the proposed box's own area must reach
|
||||
`_INVENTED_VISUAL_MIN_INK` (0.4). Measured on the confirmed false positives
|
||||
and true positives of the same issue, the two cases do not come close to
|
||||
overlapping -- 0.10-0.14 for the invented regions that were actually text,
|
||||
0.56-0.97 for real photos, charts and tables -- so this is a wide floor, not
|
||||
a fine-tuned one. `apply_semantic_regions()` takes `page_ink` as an optional
|
||||
parameter (`None` skips the check, e.g. in a unit test with no page render
|
||||
to check against); `_recognise_semantic_layout()` computes it once per page
|
||||
from the same render already loaded for the vision prompt.
|
||||
|
||||
This is a *different* signal from `snap_visual_bboxes()` above: that one
|
||||
corrects a real graphic's loose boundary once OCR/the model has already
|
||||
agreed something is there. This one decides whether the "something" should
|
||||
have existed at all. A region that survives this check can still get a loose
|
||||
box from the snap pass afterwards -- confirmed on the same issue, several
|
||||
real photos kept a box shifted or oversized enough to miss half the actual
|
||||
picture, which the snap pass's own stability check declined to correct
|
||||
rather than risk publishing a box the next run would replace (see the
|
||||
"pulled onto one graphic" fence above). That remaining imprecision is a
|
||||
candidate for the model-based layout detection already on the TODO list
|
||||
(PP-DocLayout gives a native picture bbox instead of an LLM's guess).
|
||||
|
||||
### A workflow-pipeline nav bar was bleeding into unrelated pages
|
||||
|
||||
`_wfUpdateHeaderNav` (utils.js) injects the audiobook pipeline's "Previous /
|
||||
|
||||
162
core/magazine.py
162
core/magazine.py
@ -1527,6 +1527,24 @@ def page_ink_grid(png_path: Path, cell: int = _SNAP_CELL_PX):
|
||||
rows, cell, cols, cell).mean(axis=(1, 3))
|
||||
|
||||
|
||||
def page_ink_grid_points(png_path: Path, page_meta: dict, cell: int = _SNAP_CELL_PX):
|
||||
"""page_ink_grid(), plus each cell's size in the page's own point units (as bboxes are stored
|
||||
in), for a caller that has no reason to know the render's pixel size -- see
|
||||
region_looks_like_a_picture, which screens a proposed visual region against this."""
|
||||
grid = page_ink_grid(png_path, cell)
|
||||
if grid is None:
|
||||
return None
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(png_path) as im:
|
||||
w_px, h_px = im.size
|
||||
except Exception:
|
||||
return None
|
||||
if not w_px or not h_px or not page_meta.get("width") or not page_meta.get("height"):
|
||||
return None
|
||||
return grid, page_meta["width"] * cell / w_px, page_meta["height"] * cell / h_px
|
||||
|
||||
|
||||
_SNAP_STABLE_IOU = 0.7 # a result must reproduce at least this much of
|
||||
# itself (intersection over union) when fed back
|
||||
# in as its own seed, or it is discarded -- see
|
||||
@ -2385,7 +2403,34 @@ def reocr_page(doc_dir: Path, layout: dict[str, Any], page_no: int,
|
||||
return layout
|
||||
|
||||
|
||||
def apply_semantic_regions(blocks: list[dict], regions: list[dict], page_meta: dict | None = None) -> list[dict]:
|
||||
_INVENTED_VISUAL_MIN_INK = 0.4 # see the guard below apply_semantic_regions; a real photo, chart
|
||||
# or table measured 0.56-0.97 on confirmed real pages of the same
|
||||
# issue, a text area the model mistook for one 0.10-0.14 -- wide
|
||||
# margin either side of this floor
|
||||
|
||||
|
||||
def region_looks_like_a_picture(bbox: Sequence[float], page_ink) -> bool:
|
||||
"""Whether a NEW visual region (see apply_semantic_regions) is inked densely enough, over its
|
||||
own area, to plausibly be a photo, chart or table rather than a text area or column gap the
|
||||
model mistook for one. `page_ink` is (coverage_grid, cell_w, cell_h) in the same page-point
|
||||
units as `bbox`, as produced by page_ink_grid() -- or None to skip the check (no page render to
|
||||
check against, e.g. in a unit test), which always passes.
|
||||
"""
|
||||
if page_ink is None:
|
||||
return True
|
||||
grid, cell_w, cell_h = page_ink
|
||||
if grid is None or not cell_w or not cell_h:
|
||||
return True
|
||||
x, y, w, h = bbox
|
||||
r0, r1 = max(0, int(y / cell_h)), min(grid.shape[0], int(math.ceil((y + h) / cell_h)))
|
||||
c0, c1 = max(0, int(x / cell_w)), min(grid.shape[1], int(math.ceil((x + w) / cell_w)))
|
||||
if r1 <= r0 or c1 <= c0:
|
||||
return True # too small to sample a page-render cell -- err toward keeping it
|
||||
return float(grid[r0:r1, c0:c1].mean()) >= _INVENTED_VISUAL_MIN_INK
|
||||
|
||||
|
||||
def apply_semantic_regions(blocks: list[dict], regions: list[dict], page_meta: dict | None = None,
|
||||
page_ink=None) -> list[dict]:
|
||||
"""Group existing OCR fragments without allowing invented IDs or text."""
|
||||
known = {b["id"]: b for b in blocks}
|
||||
used = set()
|
||||
@ -2409,6 +2454,14 @@ def apply_semantic_regions(blocks: list[dict], regions: list[dict], page_meta: d
|
||||
visual_box = [x0 / 1000 * page_meta["width"], y0 / 1000 * page_meta["height"],
|
||||
(x1 - x0) / 1000 * page_meta["width"], (y1 - y0) / 1000 * page_meta["height"]]
|
||||
if not ids and kind in VISUAL_TYPES and visual_box:
|
||||
# A "new" picture the model invents (block_ids: []) is meant for a graphic OCR never
|
||||
# produced a block for. Confirmed on a real issue: it sometimes instead draws that box
|
||||
# over a column gap and the text below it (a table-of-contents heading and its intro
|
||||
# paragraph in one case) -- which would otherwise bury real paragraphs under an empty
|
||||
# "Figure" placeholder. A real photo/chart/table is a densely inked area of the page
|
||||
# render; that case is not, so page_ink screens it out before it is ever created.
|
||||
if not region_looks_like_a_picture(visual_box, page_ink):
|
||||
continue
|
||||
out.append(Block(id=f"p{page_meta['page']:04d}-visual-{len(out)}", page=page_meta['page'],
|
||||
bbox=visual_box, type=kind, text="", reading_order=len(out),
|
||||
source="llm_layout", region_label=label).to_json())
|
||||
@ -2604,24 +2657,48 @@ def add_manual_region(doc_dir: Path, layout: dict[str, Any], page_no: int,
|
||||
|
||||
manual_type, region_label = {
|
||||
"text": (BLOCK_BODY, None),
|
||||
"body": (BLOCK_BODY, None),
|
||||
"title": (BLOCK_TITLE, None),
|
||||
"heading": (BLOCK_HEADING, None),
|
||||
"deck": (BLOCK_DECK, None),
|
||||
"byline": (BLOCK_BYLINE, None),
|
||||
"caption": (BLOCK_CAPTION, None),
|
||||
"pullquote": (BLOCK_PULLQUOTE, None),
|
||||
"sidebar": (BLOCK_SIDEBAR, None),
|
||||
"page_number": (BLOCK_FURNITURE, "page_number"),
|
||||
"source": (BLOCK_FURNITURE, "source"),
|
||||
"in_picture": (BLOCK_FURNITURE, "in_picture"),
|
||||
"ocr_noise": (BLOCK_FURNITURE, "ocr_noise"),
|
||||
"duplicate": (BLOCK_FURNITURE, "duplicate"),
|
||||
"table": (BLOCK_TABLE, None),
|
||||
"ad": (BLOCK_AD, None),
|
||||
}.get(kind, (None, None))
|
||||
if manual_type is None:
|
||||
raise ValueError("Unsupported manual region kind")
|
||||
|
||||
final_text = text.strip()
|
||||
if not final_text and vision_ocr_url and vision_ocr_model:
|
||||
if not final_text:
|
||||
crop_path = doc_dir / "pages" / f".manual-crop-{page_no}.png"
|
||||
try:
|
||||
_crop_page_png(doc_dir, page_no, page_meta, bbox, crop_path)
|
||||
# Same two-step read as extract_region_text (used by "read again"
|
||||
# on an existing region): vision OCR first, but a region drawn
|
||||
# while that backend is unreachable -- confirmed live, both the
|
||||
# primary and fallback vision endpoints were refusing connections
|
||||
# -- must not always come back empty when the local Tesseract
|
||||
# pass could have read it instead.
|
||||
if vision_ocr_url and vision_ocr_model:
|
||||
try:
|
||||
engines = [(vision_ocr_url, vision_ocr_model),
|
||||
(vision_ocr_fallback_url, vision_ocr_fallback_model)]
|
||||
regions = vision_ocr_page_chain(crop_path, engines, timeout=timeout)
|
||||
if regions:
|
||||
final_text = " ".join(r["text"] for r in regions).strip()
|
||||
final_text = " ".join(r["text"] for r in regions if r.get("text")).strip()
|
||||
except Exception: # noqa: BLE001 - fall through to local OCR
|
||||
logging.getLogger(__name__).warning(
|
||||
"vision OCR unreachable for a manually drawn region; falling back to local OCR")
|
||||
if not final_text:
|
||||
final_text = " ".join(p.get("text", "") for p in ocr_page(crop_path) if p.get("text")).strip()
|
||||
finally:
|
||||
crop_path.unlink(missing_ok=True)
|
||||
if not final_text:
|
||||
@ -2671,10 +2748,14 @@ def extract_region_text(doc_dir: Path, layout: dict[str, Any], page_no: int,
|
||||
_crop_page_png(doc_dir, page_no, page_meta, bbox, crop_path)
|
||||
regions = None
|
||||
if vision_ocr_url and vision_ocr_model:
|
||||
try:
|
||||
regions = vision_ocr_page_chain(crop_path, [
|
||||
(vision_ocr_url, vision_ocr_model),
|
||||
(vision_ocr_fallback_url, vision_ocr_fallback_model),
|
||||
], timeout=timeout)
|
||||
except Exception: # noqa: BLE001 - fall through to local OCR
|
||||
logging.getLogger(__name__).warning(
|
||||
"vision OCR unreachable for a re-read region; falling back to local OCR")
|
||||
if regions:
|
||||
return " ".join(r.get("text", "") for r in regions if r.get("text")).strip()
|
||||
return " ".join(p.get("text", "") for p in ocr_page(crop_path) if p.get("text")).strip()
|
||||
@ -2937,6 +3018,7 @@ REGION_TYPES: dict[str, tuple[str, str]] = {
|
||||
"in_picture": (BLOCK_FURNITURE, "in_picture"),
|
||||
"ocr_noise": (BLOCK_FURNITURE, "ocr_noise"),
|
||||
"duplicate": (BLOCK_FURNITURE, "duplicate"),
|
||||
"table": (BLOCK_TABLE, ""),
|
||||
"image": ("", ""),
|
||||
}
|
||||
|
||||
@ -3237,3 +3319,77 @@ def article_text(layout: dict[str, Any], article: dict[str, Any],
|
||||
})
|
||||
blocks.sort(key=lambda b: (b["page"], b["reading_order"]))
|
||||
return blocks
|
||||
|
||||
|
||||
# ── Standalone audio (Stage E, optional) ─────────────────────────────────────
|
||||
# The in-browser reader synthesises sentence-by-sentence into memory and never
|
||||
# writes a file -- fine for reading inside this app, useless for a car head
|
||||
# unit or a phone's own podcast/music player. This renders one article down
|
||||
# to a single tagged MP3 a person can actually take with them.
|
||||
|
||||
_SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!?…])\s+(?=[A-ZÀ-Ü»„\"'‘])")
|
||||
|
||||
|
||||
def split_sentences_for_tts(text: str) -> list[str]:
|
||||
return [p.strip() for p in _SENTENCE_SPLIT_RE.split(text) if p.strip()]
|
||||
|
||||
|
||||
def synthesize_article_audio(doc_dir: Path, article: dict[str, Any], layout: dict[str, Any],
|
||||
voice: str, settings: dict[str, Any], *,
|
||||
album: str = "", cover_bytes: bytes | None = None,
|
||||
cover_mime: str = "image/png", progress_cb=None) -> Path:
|
||||
"""Render one article to a single MP3, tagged with title/artist/album,
|
||||
its own text as embedded lyrics, and a cover image.
|
||||
|
||||
progress_cb(done, total), if given, is called after each sentence.
|
||||
"""
|
||||
from pydub import AudioSegment
|
||||
import io as _io
|
||||
from core.tts_helpers import _tts_request_audio
|
||||
|
||||
blocks = article_text(layout, article)
|
||||
full_text = " ".join(b["text"] for b in blocks if (b.get("text") or "").strip()).strip()
|
||||
if not full_text:
|
||||
raise ValueError("Article has no narratable text -- approve it with at least one block ticked")
|
||||
sentences = split_sentences_for_tts(full_text)
|
||||
total = len(sentences)
|
||||
|
||||
combined = AudioSegment.empty()
|
||||
for i, sentence in enumerate(sentences):
|
||||
audio_bytes, media_type = _tts_request_audio(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)
|
||||
if progress_cb:
|
||||
progress_cb(i + 1, total)
|
||||
|
||||
audio_dir = doc_dir / "audio"
|
||||
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)
|
||||
return out_path
|
||||
|
||||
|
||||
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:
|
||||
from mutagen.id3 import ID3, ID3NoHeaderError, TIT2, TPE1, TALB, USLT, APIC
|
||||
|
||||
try:
|
||||
tags = ID3(path)
|
||||
except ID3NoHeaderError:
|
||||
tags = ID3()
|
||||
title = (article.get("title") or "Untitled").strip()
|
||||
author = (article.get("author") or "").strip()
|
||||
tags.setall("TIT2", [TIT2(encoding=3, text=title)])
|
||||
tags.setall("TPE1", [TPE1(encoding=3, text=author or album or "Unknown")])
|
||||
if album:
|
||||
tags.setall("TALB", [TALB(encoding=3, text=album)])
|
||||
# 20000 chars matches the review/edit text-field limit used elsewhere for
|
||||
# article text -- long enough for any real article, short enough that a
|
||||
# malformed layout cannot balloon the tag.
|
||||
tags.setall("USLT", [USLT(encoding=3, lang="deu", desc="", text=lyrics[:20000])])
|
||||
if cover_bytes:
|
||||
tags.setall("APIC", [APIC(encoding=3, mime=cover_mime, type=3, desc="Cover", data=cover_bytes)])
|
||||
tags.save(path, v2_version=3)
|
||||
|
||||
@ -9,3 +9,4 @@ requests>=2.31.0
|
||||
Pillow>=10.3.0
|
||||
yt-dlp>=2024.3.10
|
||||
pymupdf>=1.24.0
|
||||
mutagen>=1.47.0
|
||||
|
||||
@ -1963,9 +1963,13 @@ def _character_is_leather_only_armor(sheet: dict) -> bool:
|
||||
|
||||
|
||||
_COMBAT_GEAR_RE = re.compile(
|
||||
# No trailing \b on the German terms: they compound with a suffix and no
|
||||
# word break ("Armbrustbolzen", "Kriegsaxt", "Bogenschütze") — the same
|
||||
# miss that let Glombo Brohm's crossbow render as a rifle (see
|
||||
# _RANGED_WEAPON_RE above).
|
||||
r"\b(r[üu]stung|panzer|harnisch|kettenhemd|schild|schwert|dolch|messer|speer|lanze|"
|
||||
r"armbrust|bogen|axt|streitaxt|keule|hellebarde|"
|
||||
r"armor|armour|shield|sword|dagger|blade|spear|lance|crossbow|bow|axe|mace|halberd)\b",
|
||||
r"armor\b|armour\b|shield\b|sword\b|dagger\b|blade\b|spear\b|lance\b|crossbow\b|bow\b|axe\b|mace\b|halberd\b)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@ -1989,6 +1993,36 @@ def _character_has_established_combat_gear(sheet: dict) -> bool:
|
||||
return bool(_COMBAT_GEAR_RE.search(established))
|
||||
|
||||
|
||||
# No trailing \b on armbrust/bogen: German compounds attach a suffix directly
|
||||
# with no word break ("Armbrustbolzen", "Bogenschütze") — confirmed live on
|
||||
# Glombo Brohm, whose "Köcher... für Armbrustbolzen" clothing text never
|
||||
# matched the old \barmbrust\b, so no ranged-weapon shape lock reached the
|
||||
# render and his crossbow came out rifle-shaped.
|
||||
_RANGED_WEAPON_RE = re.compile(r"\b(crossbow|longbow|shortbow|bow\b|armbrust|bogen)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _character_ranged_weapon_lock(sheet: dict) -> str:
|
||||
"""Positive shape lock for an established bow/crossbow.
|
||||
|
||||
Without this, an action scene where the character "aims"/"shoots" fell
|
||||
back on the base model's default for that verb — a modern firearm —
|
||||
confirmed live on Kolon, whose established "schwere Armbrust" rendered as
|
||||
a rifle in the story-moment panel even though the isolated prop shot
|
||||
(which already has this same silhouette lock, see _prop_render_description)
|
||||
came out correctly. Reuses that same wording so the two panels agree.
|
||||
"""
|
||||
costume = _visual_prompt_value(sheet.get("visual_costume")).strip()
|
||||
established = " ".join(filter(None, (
|
||||
costume or _visual_prompt_value(sheet.get("clothing")),
|
||||
", ".join(_character_prop_item_list(sheet)),
|
||||
)))
|
||||
match = _RANGED_WEAPON_RE.search(established)
|
||||
if not match:
|
||||
return ""
|
||||
shape = _prop_render_description(match.group(0))
|
||||
return f"Any ranged weapon shown is {shape}, drawn and aimed by hand exactly like that object. "
|
||||
|
||||
|
||||
_EXPLICIT_AGE_RE = re.compile(
|
||||
r"\b(\d{1,2})[\s-]*(?:jahre|jährig|j[äa]hrige[rns]?|years?[\s-]*old|y\.?o\.?)\b", re.IGNORECASE,
|
||||
)
|
||||
@ -2068,7 +2102,31 @@ _AGE_BODY_LOCK = {
|
||||
}
|
||||
|
||||
|
||||
_DWARF_SPECIES_RE = re.compile(r"zwerg|dwarf", re.IGNORECASE)
|
||||
|
||||
|
||||
def _character_is_dwarf(sheet: dict) -> bool:
|
||||
species = _visual_prompt_value(sheet.get("race_species")).strip()
|
||||
return bool(_DWARF_SPECIES_RE.search(species))
|
||||
|
||||
|
||||
# An established dwarf (race_species "Erzzwerg") still rendered with the tall,
|
||||
# long-legged adult proportions below — confirmed live on Himgi and Kolon,
|
||||
# both explicitly written as dwarf-built but rendered as ordinary tall men.
|
||||
# _AGE_BODY_LOCK's "adult" entry exists specifically to stop an unrelated
|
||||
# short/stubby rendering bug, which fights a genuine dwarf body in the
|
||||
# opposite direction. Positive-only per the same reasoning as that entry.
|
||||
_DWARF_BODY_LOCK = (
|
||||
"EXACTLY ONE ADULT DWARF.",
|
||||
"adult dwarf",
|
||||
"Natural adult dwarf body proportions: roughly five and a half head-heights tall, a broad, dense, "
|
||||
"muscular build, a wide chest, short strong legs, and a thick, full beard. ",
|
||||
)
|
||||
|
||||
|
||||
def _age_body_lock(sheet: dict) -> tuple[str, str, str]:
|
||||
if _character_is_dwarf(sheet) and _character_age_category(sheet) == "adult":
|
||||
return _DWARF_BODY_LOCK
|
||||
return _AGE_BODY_LOCK[_character_age_category(sheet)]
|
||||
|
||||
|
||||
@ -2098,7 +2156,9 @@ def _period_lock(book: str, book_profile: dict, sheet: dict) -> str:
|
||||
headwear = (
|
||||
"Use only the explicitly established period headwear and reproduce it unchanged in every view."
|
||||
if _character_has_established_headwear(sheet) else
|
||||
"The scalp, crown, hairline, ears, and complete natural hairstyle are visibly exposed and unobstructed."
|
||||
"The scalp, crown, hairline, ears, and a full, thick head of hair reaching at least to the ears or collar "
|
||||
"are visibly exposed and unobstructed, grown out and trimmed by hand with a knife or shears rather than a "
|
||||
"modern barbershop cut or a bald/receding head."
|
||||
)
|
||||
leather_only = _character_is_leather_only_armor(sheet)
|
||||
materials = (
|
||||
@ -2134,8 +2194,8 @@ def _character_prop_item_list(sheet: dict) -> list[str]:
|
||||
excluded = re.compile(
|
||||
r"\b(location|room|place|viewpoint|observation point|residence|stairs?|tower|"
|
||||
r"post|outpost|station|ort|raum|platz|posten|beobachtungspunkt|beobachtungsposten|wendeltreppe|wohnort|turm|"
|
||||
r"person|people|child|baby|infant|companion|horse|battlehorse|pony|animal|creature|"
|
||||
r"kind|baby|s[äa]ugling|begleiter|pferd|schlachtro(?:ss|ß)|pony|tier|kreatur|"
|
||||
r"person|people|child|baby|infant|companion|horse|battlehorse|pony|animal|creature|hair|wig|"
|
||||
r"kind|baby|s[äa]ugling|begleiter|pferd|schlachtro(?:ss|ß)|pony|tier|kreatur|haar|per[üu]cke|"
|
||||
r"hat|headwear|hood|cap|helmet|veil|cloak|coat|shirt|robe|armor set|armour set|"
|
||||
r"clothing|riding clothes|forest clothes|hut|m[üu]tze|kapuze|haube|helm|schleier|umhang|mantel|hemd|waldkleidung|reiterkleidung|robe|r[üu]stung|panzer|"
|
||||
# Vague plural/collective nouns describe a CATEGORY, not one
|
||||
@ -2429,6 +2489,24 @@ def _character_identity_phrase(name: str, sheet: dict) -> str:
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
# established costume text is restated verbatim in CLOTHING LOCK below, so a
|
||||
# loanword the book itself uses for a military-adjacent role (Offizier,
|
||||
# Uniform, Korporal, General) reaches the render as-is and outweighs the
|
||||
# generic period-lock wording — confirmed live on Uriens (ex-corporal) and
|
||||
# Oberst Alrik von Blautann (title "Oberst"), both of whom rendered in
|
||||
# 20th-century-style wool uniforms with peaked caps. This is a backstop for
|
||||
# characters whose sheet text hasn't been hand-corrected yet; the real fix is
|
||||
# still to write concrete period costume into the sheet.
|
||||
_ANACHRONISTIC_COSTUME_WORD_RE = re.compile(
|
||||
r"\boffiziers?(?:uniform)?\b|\buniform\b|\bkorporal\b|\bkorporals\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_anachronistic_costume_words(text: str) -> str:
|
||||
return _ANACHRONISTIC_COSTUME_WORD_RE.sub("commander's dress", text)
|
||||
|
||||
|
||||
def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
|
||||
book_profile: dict, visual_style: str,
|
||||
saved_profile: str, saved_reference: str,
|
||||
@ -2526,7 +2604,9 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
|
||||
# a civilian with no armor established, repeating the actual clothing
|
||||
# words here (not just pointing at them) gives that description a
|
||||
# second, closer mention instead of relying on the Subject clause alone.
|
||||
established_costume = _established_visual_value(sheet, "visual_costume", "clothing", 320, strip_injury=True)
|
||||
established_costume = _sanitize_anachronistic_costume_words(
|
||||
_established_visual_value(sheet, "visual_costume", "clothing", 320, strip_injury=True)
|
||||
)
|
||||
clothing_lock = (
|
||||
f"CLOTHING LOCK: this character's complete outfit in every view is exactly {established_costume} — "
|
||||
"its own established materials, and nothing more elaborate. "
|
||||
@ -2557,14 +2637,50 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
|
||||
# approach, aimed at the armor's surface condition instead of the skin.
|
||||
# A sword was rendered shouldered and aimed forward like a firearm (confirmed live on
|
||||
# Karyla's scene panel). Positive-only wording for how period arms actually sit in the hand.
|
||||
# A single sword rendered both drawn in the hand and sheathed at the belt
|
||||
# in the same figure (confirmed live on Lysandra and Oberst Alrik) — the
|
||||
# existing "no duplicate gear" wording lives only in the prop-panel
|
||||
# constraints, never in the render prompt that actually reaches the
|
||||
# figure itself. Positive-only per the reasoning above: state the single
|
||||
# weapon as a fact of the figure rather than warning against a second one.
|
||||
weapon_count_lock = (
|
||||
"This character carries exactly one bladed or hafted weapon, worn or held in a single place. "
|
||||
) if _character_has_established_combat_gear(sheet) else ""
|
||||
weapon_handling_lock = (
|
||||
"Any blade or hafted weapon is held as it was truly used: gripped low by its hilt or haft with a relaxed "
|
||||
"wrist, resting against the shoulder, hanging at the side, or sheathed at the belt. "
|
||||
"wrist, resting against the shoulder, hanging at the side, or sheathed at the belt. Each weapon is one "
|
||||
"single complete object with exactly one hilt and one blade or head, correctly proportioned and "
|
||||
"physically attached. "
|
||||
) if _character_has_established_combat_gear(sheet) else ""
|
||||
ranged_weapon_lock = _character_ranged_weapon_lock(sheet)
|
||||
gear_condition_lock = (
|
||||
"Any worn armor or equipment is clean, freshly polished, and well-kept, showing only ordinary wear from "
|
||||
"daily use. "
|
||||
)
|
||||
# Hair kept coming out precision-tapered and blow-dried straight out of a
|
||||
# modern barbershop (confirmed live on Darrag, Oberst Alrik, and Eolan),
|
||||
# and in other cases outright bald or balding (confirmed live on Odalbert)
|
||||
# — nothing told the model this world has no salons, clippers, or razors,
|
||||
# and nothing ever stated hair COVERAGE, only its texture, so the model's
|
||||
# own "gritty medieval warrior" bias defaulted to a shaved or receding
|
||||
# head. Positive-only per the reasoning above: state the full coverage
|
||||
# and hand-cut texture actually wanted rather than naming the modern or
|
||||
# bald look to avoid.
|
||||
hair_texture_lock = (
|
||||
"A full, thick head of hair covering the entire scalp, reaching at least to the ears or collar, grown out "
|
||||
"and trimmed by hand with a knife or shears, natural uneven ends and texture, simply combed, tied back, or "
|
||||
"left loose, with a lived-in, handmade, slightly imperfect look. "
|
||||
) if bareheaded else ""
|
||||
# A woman in armor kept rendering with a shaved or buzzed head and a
|
||||
# masculinized face (confirmed live on Misira and Baronin Ira von
|
||||
# Seewiesen) — the base model's "female knight" bias reads combat gear as
|
||||
# a stronger cue than the stated gender. Positive-only: state the long
|
||||
# hair and feminine features as a fact of this figure, same reasoning as
|
||||
# every other lock above.
|
||||
female_presentation_lock = (
|
||||
"Long hair reaching past the shoulders, worn loose, braided, or tied back, and clearly feminine facial "
|
||||
"features and build, exactly as a woman looks wearing this armor. "
|
||||
) if bareheaded and _visual_prompt_value(sheet.get("gender")).strip().lower() in {"female", "weiblich"} and _character_has_established_combat_gear(sheet) else ""
|
||||
period = (
|
||||
f"BOOK PERIOD: {_positive_conditioning_text(_book_visual_context(book, book_profile))}. high-fantasy medieval costume design (The Dark Eye), roughly 900-1300 AD: homespun wool and linen tunics, long belted robes, leather belts and jerkins, and simple stitched-leather boots only: "
|
||||
f"{period_materials}. The Subject alone defines occupation-specific costume elements. Every visible element is pre-industrial and period-authentic. "
|
||||
@ -2572,8 +2688,12 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
|
||||
f"{authority_lock}"
|
||||
f"{baseline_lock}"
|
||||
f"{gear_condition_lock}"
|
||||
f"{weapon_count_lock}"
|
||||
f"{weapon_handling_lock}"
|
||||
f"{ranged_weapon_lock}"
|
||||
f"{clothing_lock}"
|
||||
f"{hair_texture_lock}"
|
||||
f"{female_presentation_lock}"
|
||||
f"{no_headwear}"
|
||||
)
|
||||
if visual_style == "concept":
|
||||
@ -2616,9 +2736,12 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
|
||||
f"This character's own costume cut and colours: {_character_design_variation(name, sheet)}."
|
||||
if _character_has_established_combat_gear(sheet) else "",
|
||||
"Bare head, the whole hairstyle visible." if bareheaded else "Only the established headwear, the same in every view.",
|
||||
hair_texture_lock.strip(),
|
||||
f"{ear_shape.capitalize()}." if ear_shape.startswith("pointed") else "",
|
||||
proportion_lock.strip() if identical_noun != "adult person" else "",
|
||||
"Clean healthy skin and clean, well-kept clothes.",
|
||||
weapon_count_lock.strip(),
|
||||
female_presentation_lock.strip(),
|
||||
)))
|
||||
turnaround_style = ("Photorealistic, medieval high-fantasy costume in the world of The Dark Eye, hand-made materials."
|
||||
if visual_style != "concept" else
|
||||
@ -2654,7 +2777,7 @@ def _build_compact_character_render_prompts(name: str, book: str, sheet: dict,
|
||||
f"EXACT CHARACTER: {sheet_subject}" + (f"; exactly as in the portrait: {portrait_look}" if portrait_look else "") + ". "
|
||||
f"BOOK-SOURCE EVIDENCE: {scene_evidence}. "
|
||||
f"FOCAL PERSON: {_character_identity_phrase(name, sheet)}, exactly as described above, the single clear subject in the foreground. "
|
||||
"Show the character naturally acting, speaking, working, commanding, using magic, or fighting exactly as the evidence supports. The named character appears exactly once as the focal actor. Preserve the stated face, age, species, build, hair, costume, materials, and period. Use only verified equipment needed by this moment. When the evidence requires supporting figures, give them clearly different faces and simpler clothing while keeping them secondary. "
|
||||
"Show the character naturally acting, speaking, working, commanding, using magic, or fighting exactly as the evidence supports. The named character appears exactly once as the focal actor, with exactly one head and exactly two hands, each holding at most one item, and the same species, skin, and facial features as the turnaround sheet — never rendered as an unmarked ordinary human when a non-human species is established. Preserve the stated face, age, species, build, hair, costume, materials, and period. Every held or worn weapon is one single complete object with exactly one hilt and one blade or head, correctly proportioned and physically attached, never a duplicated or fused shape. Use only verified equipment needed by this moment. When the evidence requires supporting figures, give them clearly different faces and simpler clothing while keeping them secondary. "
|
||||
f"{period} {sheet_style} Use a believable location from the book, restrained cinematic staging, and complete uncropped head and hands. Keep all surfaces unlettered and free of graphic overlays. Output 768x512."
|
||||
)
|
||||
prop_period = (
|
||||
|
||||
@ -20,17 +20,19 @@ import asyncio
|
||||
import base64
|
||||
import html
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import statistics
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, Response
|
||||
|
||||
from core import magazine as mag
|
||||
from core.config import _load_settings
|
||||
@ -462,6 +464,7 @@ async def magazine_articles(doc_id: str):
|
||||
# generated, instead of a 404 on every article open.
|
||||
for a in articles:
|
||||
a["has_podcast"] = (d / "podcast" / f"{a['id']}.json").is_file()
|
||||
a["has_audio"] = (d / "audio" / f"{a['id']}.mp3").is_file()
|
||||
return {
|
||||
"articles": articles,
|
||||
"progress": meta.get("progress") or {},
|
||||
@ -720,6 +723,7 @@ def _recognise_semantic_layout(layout: dict, settings: dict, page: int | None =
|
||||
for b in batch], ensure_ascii=False)}]}
|
||||
page_meta = next((p for p in layout.get("pages", []) if p["page"] == page_no), None)
|
||||
png = doc_dir / "pages" / f"{page_no:04d}.png" if doc_dir else None
|
||||
page_ink = mag.page_ink_grid_points(png, page_meta) if png and png.is_file() and page_meta else None
|
||||
if png and png.is_file():
|
||||
prompt = payload["messages"][1]["content"]
|
||||
payload["messages"][1]["content"] = [
|
||||
@ -740,7 +744,7 @@ def _recognise_semantic_layout(layout: dict, settings: dict, page: int | None =
|
||||
isinstance(confidence, (int, float)) and .9 <= confidence <= 1:
|
||||
batch = [dict(b, type=mag.BLOCK_AD) for b in batch]
|
||||
regions = [{"type": "advertisement", "block_ids": [b["id"] for b in batch], "bbox": [0,0,1000,1000]}]
|
||||
grouped = mag.apply_semantic_regions(batch, regions, page_meta)
|
||||
grouped = mag.apply_semantic_regions(batch, regions, page_meta, page_ink=page_ink)
|
||||
ids = {b["id"] for b in batch}
|
||||
layout["blocks"] = [b for b in layout["blocks"] if b["id"] not in ids] + grouped
|
||||
except Exception:
|
||||
@ -1165,7 +1169,9 @@ async def magazine_add_region(doc_id: str, page: int, request: Request):
|
||||
and all(isinstance(v, (int, float)) for v in bbox)):
|
||||
raise HTTPException(400, "bbox must be [x, y, width, height]")
|
||||
kind = str(body.get("kind") or "text")
|
||||
if kind not in ("text", "image", "heading", "page_number", "source", "ad"):
|
||||
if kind not in ("text", "body", "image", "title", "heading", "deck", "byline", "caption",
|
||||
"pullquote", "sidebar", "page_number", "source", "in_picture", "ocr_noise",
|
||||
"duplicate", "table", "ad"):
|
||||
raise HTTPException(400, "Unsupported region kind")
|
||||
text = str(body.get("text") or "")[:20000]
|
||||
|
||||
@ -1624,3 +1630,143 @@ async def magazine_podcast_delete(doc_id: str, article_id: str):
|
||||
article_id = _article_id(article_id)
|
||||
(d / "podcast" / f"{article_id}.json").unlink(missing_ok=True)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Standalone tagged MP3 export (Stage E) ───────────────────────────────────
|
||||
# For listening away from this app entirely -- a phone's own music/podcast
|
||||
# player, a car head unit -- not just the in-browser reader.
|
||||
|
||||
def _article_cover(d: Path, article: dict[str, Any]) -> tuple[bytes | None, str]:
|
||||
page = (article.get("pages") or [None])[0]
|
||||
if page is None:
|
||||
return None, "image/png"
|
||||
png = d / "pages" / f"{page:04d}.png"
|
||||
if not png.is_file():
|
||||
return None, "image/png"
|
||||
try:
|
||||
return png.read_bytes(), "image/png"
|
||||
except OSError:
|
||||
return None, "image/png"
|
||||
|
||||
|
||||
def _synthesize_article_audio_sync(doc_id: str, job_id: str, article_id: str, voice: str,
|
||||
total_hint: int | None = None, index: int | None = None,
|
||||
count: int | None = None) -> Path:
|
||||
d = _LIB / doc_id
|
||||
meta = _read_meta(d)
|
||||
layout = mag.load_layout(d)
|
||||
if not layout:
|
||||
raise FileNotFoundError("No layout yet -- run ingestion first")
|
||||
articles = mag.load_articles(d)
|
||||
article = next((a for a in articles if a.get("id") == article_id), None)
|
||||
if article is None:
|
||||
raise FileNotFoundError("Article not found")
|
||||
if article.get("status") != "approved":
|
||||
raise ValueError("Article is not approved yet")
|
||||
settings = _load_settings()
|
||||
cover_bytes, cover_mime = _article_cover(d, article)
|
||||
prefix = f"({index}/{count}) " if index and count else ""
|
||||
|
||||
def progress(done: int, total: int) -> None:
|
||||
background_job_update(
|
||||
job_id, detail=f"{prefix}{article.get('title') or article_id}: sentence {done}/{total}")
|
||||
|
||||
return mag.synthesize_article_audio(
|
||||
d, article, layout, voice, settings,
|
||||
album=meta.get("title") or "", cover_bytes=cover_bytes, cover_mime=cover_mime,
|
||||
progress_cb=progress,
|
||||
)
|
||||
|
||||
|
||||
def _audio_generate_one_sync(doc_id: str, job_id: str, article_id: str, voice: str) -> None:
|
||||
try:
|
||||
path = _synthesize_article_audio_sync(doc_id, job_id, article_id, voice)
|
||||
background_job_update(job_id, stage="done", done=True, detail="Audio ready",
|
||||
result={"article": article_id, "file": path.name})
|
||||
except Exception as exc: # noqa: BLE001 - surfaced to UI
|
||||
background_job_update(job_id, stage="failed", done=True, error=str(exc)[:1000])
|
||||
|
||||
|
||||
@router.post("/api/magazine/{doc_id}/articles/{article_id}/audio")
|
||||
async def magazine_generate_article_audio(doc_id: str, article_id: str, request: Request):
|
||||
"""Render one approved article to a single tagged MP3 (title/author/album,
|
||||
its own text as embedded lyrics, cover art) -- for taking it out of the app."""
|
||||
d, meta = _require_magazine(doc_id)
|
||||
article_id = _article_id(article_id)
|
||||
body = await request.json() if await request.body() else {}
|
||||
voice = str(body.get("voice") or meta.get("default_voice") or "").strip()
|
||||
if not voice:
|
||||
raise HTTPException(400, "No narrator voice given")
|
||||
job_id = uuid.uuid4().hex
|
||||
background_job_create(job_id, "magazine-article-audio", fingerprint=f"{doc_id}:{article_id}")
|
||||
asyncio.create_task(asyncio.to_thread(_audio_generate_one_sync, doc_id, job_id, article_id, voice))
|
||||
return {"job_id": job_id}
|
||||
|
||||
|
||||
@router.get("/api/magazine/{doc_id}/articles/{article_id}/audio")
|
||||
async def magazine_article_audio_file(doc_id: str, article_id: str):
|
||||
d, _ = _require_magazine(doc_id)
|
||||
article_id = _article_id(article_id)
|
||||
path = d / "audio" / f"{article_id}.mp3"
|
||||
if not path.is_file():
|
||||
raise HTTPException(404, "No audio generated yet")
|
||||
return FileResponse(path, media_type="audio/mpeg", filename=f"{article_id}.mp3")
|
||||
|
||||
|
||||
def _audio_generate_batch_sync(doc_id: str, job_id: str, article_ids: list[str], voice: str) -> None:
|
||||
ok, failed = [], []
|
||||
total = len(article_ids)
|
||||
for i, article_id in enumerate(article_ids, start=1):
|
||||
background_job_update(job_id, detail=f"Article {i}/{total}…")
|
||||
try:
|
||||
_synthesize_article_audio_sync(doc_id, job_id, article_id, voice, index=i, count=total)
|
||||
ok.append(article_id)
|
||||
except Exception as exc: # noqa: BLE001 - one bad article should not stop the rest
|
||||
failed.append({"article": article_id, "error": str(exc)[:300]})
|
||||
background_job_update(
|
||||
job_id, stage="done", done=True,
|
||||
detail=f"{len(ok)} of {total} article{'s' if total != 1 else ''} rendered" + (f", {len(failed)} failed" if failed else ""),
|
||||
result={"ok": ok, "failed": failed},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/magazine/{doc_id}/audio/batch")
|
||||
async def magazine_generate_batch_audio(doc_id: str, request: Request):
|
||||
"""Render every approved article (or a given subset) to a tagged MP3, one
|
||||
background job for the whole run so the browser does not have to stay
|
||||
open article by article."""
|
||||
d, meta = _require_magazine(doc_id)
|
||||
body = await request.json() if await request.body() else {}
|
||||
voice = str(body.get("voice") or meta.get("default_voice") or "").strip()
|
||||
if not voice:
|
||||
raise HTTPException(400, "No narrator voice given")
|
||||
requested = body.get("article_ids")
|
||||
articles = mag.load_articles(d)
|
||||
if isinstance(requested, list) and requested:
|
||||
wanted = {str(x) for x in requested}
|
||||
article_ids = [a["id"] for a in articles if a["id"] in wanted and a.get("status") == "approved"]
|
||||
else:
|
||||
article_ids = [a["id"] for a in articles if a.get("status") == "approved"]
|
||||
if not article_ids:
|
||||
raise HTTPException(400, "No approved articles to render")
|
||||
job_id = uuid.uuid4().hex
|
||||
background_job_create(job_id, "magazine-audio-batch", fingerprint=doc_id)
|
||||
asyncio.create_task(asyncio.to_thread(_audio_generate_batch_sync, doc_id, job_id, article_ids, voice))
|
||||
return {"job_id": job_id, "count": len(article_ids)}
|
||||
|
||||
|
||||
@router.get("/api/magazine/{doc_id}/audio.zip")
|
||||
async def magazine_audio_zip(doc_id: str):
|
||||
d, meta = _require_magazine(doc_id)
|
||||
audio_dir = d / "audio"
|
||||
files = sorted(audio_dir.glob("*.mp3")) if audio_dir.is_dir() else []
|
||||
if not files:
|
||||
raise HTTPException(404, "No audio generated yet")
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
|
||||
for f in files:
|
||||
zf.write(f, arcname=f.name)
|
||||
buf.seek(0)
|
||||
safe_title = re.sub(r"[^\w \-]+", "", meta.get("title") or "magazine").strip() or "magazine"
|
||||
return Response(content=buf.getvalue(), media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_title}.zip"'})
|
||||
|
||||
@ -22,6 +22,9 @@ const state = {
|
||||
sentences: [], // [{ text, blockId, page, bbox }]
|
||||
images: [], // current article's figures, in reading order
|
||||
view: 'library', // library | contents | review | read
|
||||
contentsView: 'list', // list | grid — how the article list itself is shown
|
||||
contentsSelectMode: false, // batch-select articles for bulk approve/reject
|
||||
contentsSelected: new Set(),
|
||||
pane: 'reflow', // reflow | pdf
|
||||
playing: false,
|
||||
cancel: false,
|
||||
@ -392,6 +395,7 @@ async function magCreate(droppedFile = null) {
|
||||
// completely different kind ("book", via Studio/Reader's own import) because
|
||||
// the magazine they had been working on appeared to have vanished.
|
||||
const MAG_LAST_DOC_KEY = 'ttsvc_mag_last_doc';
|
||||
const MAG_CONTENTS_VIEW_KEY = 'ttsvc_mag_contents_view';
|
||||
|
||||
async function magOpen(docId) {
|
||||
try {
|
||||
@ -633,16 +637,69 @@ function renderContents() {
|
||||
bySection.get(key).push(a);
|
||||
});
|
||||
|
||||
const grid = state.contentsView === 'grid';
|
||||
const toggleBtn = $('mag-contents-view-toggle');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.innerHTML = grid
|
||||
? '<span class="mdi mdi-view-list-outline"></span> List'
|
||||
: '<span class="mdi mdi-view-grid-outline"></span> Thumbnails';
|
||||
toggleBtn.setAttribute('aria-pressed', String(grid));
|
||||
}
|
||||
|
||||
const actionsHtml = (a, done, progress) =>
|
||||
'<button type="button" class="btn-sm" onclick="magReview(\'' + a.id + '\')">Review</button>' +
|
||||
(done
|
||||
? '<button type="button" class="btn-sm primary" onclick="magRead(\'' + a.id + '\')">' +
|
||||
(progress ? 'Continue' : 'Listen') + '</button>'
|
||||
: '') +
|
||||
(a.has_audio
|
||||
? '<a class="btn-sm" href="/api/magazine/' + esc(state.doc.id) + '/articles/' + a.id + '/audio" download ' +
|
||||
'title="Download the exported MP3 (with title, author, cover and lyrics tags)"><span class="mdi mdi-download"></span> MP3</a>'
|
||||
: '');
|
||||
const selectHtml = (a) => !state.contentsSelectMode ? '' :
|
||||
'<label class="mag-select-check" onclick="event.stopPropagation()">' +
|
||||
'<input type="checkbox" ' + (state.contentsSelected.has(a.id) ? 'checked' : '') +
|
||||
' onchange="magContentsToggleArticle(\'' + a.id + '\')" aria-label="Select ' + esc(a.title || 'this article') + '"></label>';
|
||||
// Detection routinely mangles a title through OCR ("Sbimmb das? c'b
|
||||
// prüFb") -- fixing it should not require opening Review. contenteditable
|
||||
// rather than swapping in an <input> keeps the card's own font/wrapping
|
||||
// instead of a generic text box.
|
||||
const titleHtml = (a, cls) =>
|
||||
'<span class="' + cls + '" contenteditable="true" spellcheck="false" ' +
|
||||
'onblur="magContentsCommitTitle(this, \'' + a.id + '\')" ' +
|
||||
'onkeydown="if(event.key===\'Enter\'){event.preventDefault();this.blur();}" ' +
|
||||
'onclick="event.stopPropagation()" title="Click to edit the title">' +
|
||||
esc(a.title || 'Untitled') + '</span>';
|
||||
|
||||
let html = '';
|
||||
bySection.forEach((arts, section) => {
|
||||
html += '<div class="mag-section"><h4>' + esc(section) + '</h4><ul class="mag-toc">';
|
||||
html += '<div class="mag-section"><h4>' + esc(section) + '</h4>' + (grid ? '<div class="mag-grid">' : '<ul class="mag-toc">');
|
||||
arts.forEach((a) => {
|
||||
const conf = confidenceLabel(a.confidence);
|
||||
const done = a.status === 'approved';
|
||||
const progress = (state.doc.progress || {})[a.id];
|
||||
const statusBadge = '<span class="mag-pill ' + (done ? 'ok' : '') + '">' +
|
||||
(done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'needs review') + '</span>';
|
||||
if (grid) {
|
||||
const firstPage = (a.pages || [])[0];
|
||||
html += '<div class="mag-card-wrap"><div class="mag-card' + (done ? ' is-approved' : '') + '">' +
|
||||
selectHtml(a) +
|
||||
(firstPage
|
||||
? '<img class="mag-card-thumb" loading="lazy" alt="Page ' + firstPage + '" ' +
|
||||
'src="/api/magazine/' + esc(state.doc.id) + '/pages/' + firstPage + '">'
|
||||
: '<div class="mag-card-thumb"></div>') +
|
||||
titleHtml(a, 'mag-card-title') +
|
||||
(a.author ? '<span class="mag-card-sub">by ' + esc(a.author) + '</span>' : '') +
|
||||
'<span class="mag-card-sub">' + fmtPages(a.pages) + ' · ' + fmtDuration(a.estimated_seconds) + '</span>' +
|
||||
'<div class="mag-toc-meta"><span class="mag-pill ' + conf.cls + '" title="' + esc(conf.text) + '">' +
|
||||
esc(conf.badge) + '</span>' + statusBadge + '</div>' +
|
||||
'<div class="mag-toc-actions">' + actionsHtml(a, done, progress) + '</div>' +
|
||||
'</div></div>';
|
||||
} else {
|
||||
html += '<li class="mag-toc-row' + (done ? ' is-approved' : '') + '">' +
|
||||
selectHtml(a) +
|
||||
'<div class="mag-toc-main">' +
|
||||
'<span class="mag-toc-title">' + esc(a.title || 'Untitled') + '</span>' +
|
||||
titleHtml(a, 'mag-toc-title') +
|
||||
(a.author ? '<span class="mag-toc-by">by ' + esc(a.author) + '</span>' : '') +
|
||||
'</div>' +
|
||||
'<div class="mag-toc-meta">' +
|
||||
@ -650,20 +707,155 @@ function renderContents() {
|
||||
'<span>' + fmtDuration(a.estimated_seconds) + '</span>' +
|
||||
'<span class="mag-pill ' + conf.cls + '" title="' + esc(conf.text) + '">' +
|
||||
esc(conf.badge) + '</span>' +
|
||||
'<span class="mag-pill ' + (done ? 'ok' : '') + '">' +
|
||||
(done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'needs review') + '</span>' +
|
||||
statusBadge +
|
||||
'</div>' +
|
||||
'<div class="mag-toc-actions">' +
|
||||
'<button type="button" class="btn-sm" onclick="magReview(\'' + a.id + '\')">Review</button>' +
|
||||
(done
|
||||
? '<button type="button" class="btn-sm primary" onclick="magRead(\'' + a.id + '\')">' +
|
||||
(progress ? 'Continue' : 'Listen') + '</button>'
|
||||
: '') +
|
||||
'</div></li>';
|
||||
'<div class="mag-toc-actions">' + actionsHtml(a, done, progress) + '</div></li>';
|
||||
}
|
||||
});
|
||||
html += '</ul></div>';
|
||||
html += grid ? '</div></div>' : '</ul></div>';
|
||||
});
|
||||
host.innerHTML = html;
|
||||
renderContentsBulkBar();
|
||||
magUpdateAudioZipLink();
|
||||
}
|
||||
|
||||
function magToggleContentsView() {
|
||||
state.contentsView = state.contentsView === 'grid' ? 'list' : 'grid';
|
||||
try { localStorage.setItem(MAG_CONTENTS_VIEW_KEY, state.contentsView); } catch (_) {}
|
||||
renderContents();
|
||||
}
|
||||
|
||||
// ── Batch approve/reject from the article list ──────────────────────────────
|
||||
// Reviewing every single article one at a time does not scale to a 200-page
|
||||
// issue with 50+ detected pieces. This accepts each selected article exactly
|
||||
// as auto-detected (no block-list edit, unlike a normal Approve from inside
|
||||
// review) -- a deliberate trade-off: bulk action is for the pieces that
|
||||
// already look right, not a replacement for reviewing a messy one.
|
||||
function magContentsToggleSelect() {
|
||||
state.contentsSelectMode = !state.contentsSelectMode;
|
||||
if (!state.contentsSelectMode) state.contentsSelected.clear();
|
||||
const btn = $('mag-contents-select-toggle');
|
||||
if (btn) btn.setAttribute('aria-pressed', String(state.contentsSelectMode));
|
||||
renderContents();
|
||||
}
|
||||
|
||||
async function magContentsCommitTitle(el, id) {
|
||||
const a = state.articles.find((x) => x.id === id);
|
||||
if (!a) return;
|
||||
const value = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (!value || value === (a.title || '')) { el.textContent = a.title || 'Untitled'; return; }
|
||||
const previous = a.title;
|
||||
a.title = value;
|
||||
try {
|
||||
await api('/api/magazine/' + state.doc.id + '/articles/' + id, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: value }),
|
||||
});
|
||||
} catch (e) {
|
||||
a.title = previous;
|
||||
el.textContent = previous || 'Untitled';
|
||||
say('Could not save the title: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
function magContentsToggleArticle(id) {
|
||||
if (state.contentsSelected.has(id)) state.contentsSelected.delete(id);
|
||||
else state.contentsSelected.add(id);
|
||||
renderContents();
|
||||
}
|
||||
|
||||
function magContentsSelectAllVisible() {
|
||||
state.articles.forEach((a) => state.contentsSelected.add(a.id));
|
||||
renderContents();
|
||||
}
|
||||
|
||||
function magContentsClearSelection() {
|
||||
state.contentsSelected.clear();
|
||||
renderContents();
|
||||
}
|
||||
|
||||
async function magContentsBulkStatus(status) {
|
||||
const ids = Array.from(state.contentsSelected);
|
||||
if (!ids.length) return;
|
||||
const bar = $('mag-contents-bulkbar');
|
||||
let done = 0;
|
||||
for (const id of ids) {
|
||||
if (bar) bar.querySelector('.mag-bulkbar-progress')?.replaceChildren(
|
||||
document.createTextNode(`${status === 'approved' ? 'Approving' : 'Rejecting'} ${done + 1} of ${ids.length}…`));
|
||||
try {
|
||||
await api('/api/magazine/' + state.doc.id + '/articles/' + id, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
} catch (e) { say('Could not update ' + id + ': ' + e.message, 'err'); }
|
||||
done += 1;
|
||||
}
|
||||
state.contentsSelected.clear();
|
||||
say(`${done} article${done === 1 ? '' : 's'} ${status}`, 'ok');
|
||||
await magRefreshArticles();
|
||||
renderContents();
|
||||
}
|
||||
|
||||
function renderContentsBulkBar() {
|
||||
const bar = $('mag-contents-bulkbar');
|
||||
if (!bar) return;
|
||||
const n = state.contentsSelected.size;
|
||||
if (!state.contentsSelectMode) { bar.hidden = true; bar.innerHTML = ''; return; }
|
||||
bar.hidden = false;
|
||||
bar.innerHTML =
|
||||
'<span class="mag-bulkbar-count">' + n + ' selected</span>' +
|
||||
'<span class="mag-bulkbar-progress muted"></span>' +
|
||||
'<div class="mag-bulkbar-actions">' +
|
||||
'<button type="button" class="btn-sm" onclick="magContentsSelectAllVisible()">Select all</button>' +
|
||||
'<button type="button" class="btn-sm" onclick="magContentsClearSelection()" ' + (n ? '' : 'disabled') + '>Clear</button>' +
|
||||
'<button type="button" class="btn-sm" onclick="magContentsBulkStatus(\'rejected\')" ' + (n ? '' : 'disabled') + '>Reject selected</button>' +
|
||||
'<button type="button" class="btn-sm primary" onclick="magContentsBulkStatus(\'approved\')" ' + (n ? '' : 'disabled') + '>Approve selected</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ── Standalone MP3 export (title/author/cover/lyrics tags) ─────────────────
|
||||
// For listening away from this app entirely -- a phone's own music/podcast
|
||||
// player, a car head unit -- not the in-browser sentence-by-sentence player.
|
||||
async function magGenerateAllAudio() {
|
||||
const voice = ($('mag-audio-voice')?.value || '').trim();
|
||||
if (!voice) return say('Pick a narrator voice for the export first', 'err');
|
||||
const btn = $('mag-audio-export-btn');
|
||||
const status = $('mag-audio-export-status');
|
||||
if (btn) btn.disabled = true;
|
||||
let jobId;
|
||||
try {
|
||||
const r = await api('/api/magazine/' + state.doc.id + '/audio/batch', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ voice }),
|
||||
});
|
||||
jobId = r.job_id;
|
||||
} catch (e) {
|
||||
if (btn) btn.disabled = false;
|
||||
return say('Could not start audio export: ' + e.message, 'err');
|
||||
}
|
||||
for (;;) {
|
||||
let job;
|
||||
try { job = await api('/api/jobs/' + jobId); }
|
||||
catch (e) { if (btn) btn.disabled = false; return say('Could not check audio export: ' + e.message, 'err'); }
|
||||
if (status) status.textContent = job.detail || 'Working…';
|
||||
if (job.done) {
|
||||
say(job.error ? 'Audio export failed: ' + job.error : (job.detail || 'Audio export done'), job.error ? 'err' : 'ok');
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
}
|
||||
if (btn) btn.disabled = false;
|
||||
if (status) status.textContent = '';
|
||||
await magRefreshArticles();
|
||||
renderContents();
|
||||
}
|
||||
|
||||
function magUpdateAudioZipLink() {
|
||||
const link = $('mag-audio-zip-link');
|
||||
if (!link) return;
|
||||
const any = state.articles.some((a) => a.has_audio);
|
||||
link.hidden = !any;
|
||||
if (any) link.href = '/api/magazine/' + state.doc.id + '/audio.zip';
|
||||
}
|
||||
|
||||
// ── Review (Stage D) ─────────────────────────────────────────────────────────
|
||||
@ -731,7 +923,7 @@ const MAG_REGION_TYPES = [
|
||||
['body', 'Paragraph'], ['title', 'Headline'], ['heading', 'Subheading'], ['deck', 'Standfirst'],
|
||||
['byline', 'Author'], ['caption', 'Caption'], ['pullquote', 'Pull quote'], ['sidebar', 'Sidenote'],
|
||||
['page_number', 'Page number'], ['source', 'Source / footer'], ['ad', 'Advertisement'],
|
||||
['in_picture', 'Text in picture'], ['ocr_noise', 'OCR noise'], ['duplicate', 'Duplicate'], ['image', 'Image'],
|
||||
['in_picture', 'Text in picture'], ['ocr_noise', 'OCR noise'], ['duplicate', 'Duplicate'], ['table', 'Table'], ['image', 'Image'],
|
||||
];
|
||||
// Furniture kinds that the picker shows under their own name (everything else is a page number).
|
||||
const MAG_FURNITURE_KINDS = ['source', 'in_picture', 'ocr_noise', 'duplicate'];
|
||||
@ -881,7 +1073,13 @@ function magReviewPageColumn(page, wrapId, interactive) {
|
||||
if (isImage) {
|
||||
const on = ownImages.has(r.id);
|
||||
const imgFilename = r.data.filename || (r.id + '.png');
|
||||
const imgUrl = '/api/magazine/' + state.doc.id + '/images/' + esc(imgFilename);
|
||||
// Resizing an image region re-crops the same filename in place (see
|
||||
// update_region in core/magazine.py) -- an identical URL never
|
||||
// re-fetches in the browser, so the old crop kept showing after a
|
||||
// resize was saved. bbox changes exactly when the crop does, so it
|
||||
// makes a cheap, correct cache-busting key.
|
||||
const imgUrl = '/api/magazine/' + state.doc.id + '/images/' + esc(imgFilename) +
|
||||
'?v=' + encodeURIComponent((r.data.bbox || []).join(','));
|
||||
listHtml += '<div class="mag-block mag-block-image' + (on ? '' : ' is-aux') + '" data-block-row="' + esc(r.id) + '" ' +
|
||||
'onmouseenter="magReviewHighlightBox(\'' + esc(r.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
|
||||
magOrderNumber(r.id, i + 1, regions.length, interactive) +
|
||||
@ -941,14 +1139,6 @@ function magReviewSetActivePage(page) {
|
||||
renderReview();
|
||||
}
|
||||
|
||||
function magReviewStepPage(dir) {
|
||||
const pages = state.article?.pages || [];
|
||||
const next = pages[pages.indexOf(state.reviewPage) + dir];
|
||||
if (next == null) return;
|
||||
state.reviewPage = next;
|
||||
renderReview();
|
||||
}
|
||||
|
||||
function renderReview() {
|
||||
const host = $('mag-review');
|
||||
const a = state.article;
|
||||
@ -959,7 +1149,6 @@ function renderReview() {
|
||||
const conf = confidenceLabel(a.confidence);
|
||||
const page = state.reviewPage;
|
||||
const pageCount = Number(state.layout?.page_count || 0);
|
||||
const pageIdx = (a?.pages || []).indexOf(page);
|
||||
const secondaryPage = state.reviewSpread ? magSpreadPartner(page, pageCount) : null;
|
||||
const spreadPages = secondaryPage != null ? [page, secondaryPage].sort((x, y) => x - y) : null;
|
||||
const issuePages = pageCount || '—';
|
||||
@ -968,17 +1157,14 @@ function renderReview() {
|
||||
|
||||
let html = '';
|
||||
html += '<div class="mag-review-pagenav">' +
|
||||
'<div class="mag-review-pagenav-group" role="group" aria-label="Review pages">' +
|
||||
'<button type="button" class="btn-sm" aria-label="Previous page" ' + (pageIdx > 0 ? '' : 'disabled') +
|
||||
' onclick="magReviewStepPage(-1)"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' +
|
||||
'<strong class="mag-review-page-number">Page ' + (page ?? '—') + '</strong>' +
|
||||
'<button type="button" class="btn-sm" aria-label="Next page" ' + (pageIdx >= 0 && pageIdx < (a?.pages || []).length - 1 ? '' : 'disabled') +
|
||||
' onclick="magReviewStepPage(1)"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' +
|
||||
'<div class="mag-review-pagenav-group" role="group" aria-label="Whole magazine pages">' +
|
||||
'<button type="button" class="btn-sm" aria-label="Previous magazine page" ' + (prevIssuePage ? '' : 'disabled') +
|
||||
// One page stepper for the whole magazine, not a separate one bounded
|
||||
// to just this article's own pages — the two almost always agreed and
|
||||
// having both doubled the toolbar's width for no real benefit.
|
||||
'<div class="mag-review-pagenav-group" role="group" aria-label="Magazine page">' +
|
||||
'<button type="button" class="btn-sm" aria-label="Previous page" ' + (prevIssuePage ? '' : 'disabled') +
|
||||
' onclick="magReviewGoMagazinePage(' + prevIssuePage + ')"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' +
|
||||
'<strong class="mag-review-page-number">Magazine ' + (page ?? '—') + ' / ' + issuePages + '</strong>' +
|
||||
'<button type="button" class="btn-sm" aria-label="Next magazine page" ' + (nextIssuePage ? '' : 'disabled') +
|
||||
'<strong class="mag-review-page-number">Page ' + (page ?? '—') + ' / ' + issuePages + '</strong>' +
|
||||
'<button type="button" class="btn-sm" aria-label="Next page" ' + (nextIssuePage ? '' : 'disabled') +
|
||||
' onclick="magReviewGoMagazinePage(' + nextIssuePage + ')"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' +
|
||||
'<div class="mag-review-pagenav-group" role="group" aria-label="Spread view">' +
|
||||
'<button type="button" class="btn-sm" id="mag-spread-toggle" aria-pressed="' + String(!!state.reviewSpread) + '" ' +
|
||||
@ -987,17 +1173,7 @@ function renderReview() {
|
||||
(state.reviewSpread ? 'Single page' : 'Two-page spread') + '</button></div>' +
|
||||
'<div class="mag-review-draw-controls" role="group" aria-label="Draw regions">' +
|
||||
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw()" id="mag-draw-toggle">' +
|
||||
'<span class="mdi mdi-vector-square-edit" aria-hidden="true"></span> Draw text</button>' +
|
||||
'<button type="button" class="btn-sm" id="mag-draw-image-toggle" onclick="magReviewToggleDraw(\'image\')">' +
|
||||
'<span class="mdi mdi-image-outline" aria-hidden="true"></span> Draw image</button>' +
|
||||
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'heading\')">' +
|
||||
'<span class="mdi mdi-format-header-1" aria-hidden="true"></span> Headline</button>' +
|
||||
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'page_number\')">' +
|
||||
'<span class="mdi mdi-pound" aria-hidden="true"></span> Page number</button>' +
|
||||
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'source\')">' +
|
||||
'<span class="mdi mdi-book-open-page-variant-outline" aria-hidden="true"></span> Source / footer</button>' +
|
||||
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'ad\')">' +
|
||||
'<span class="mdi mdi-advertisements-outline" aria-hidden="true"></span> Advertisement</button>' +
|
||||
'<span class="mdi mdi-vector-square-edit" aria-hidden="true"></span> Draw region</button>' +
|
||||
'<button type="button" class="btn-sm" id="mag-edit-toggle" aria-pressed="false" ' +
|
||||
'title="Drag a region to move or resize it, or delete one that was detected wrongly" ' +
|
||||
'onclick="magReviewToggleEdit()"><span class="mdi mdi-vector-polygon" aria-hidden="true"></span> Edit boxes</button>' +
|
||||
@ -1007,7 +1183,9 @@ function renderReview() {
|
||||
'<button type="button" class="btn-sm" id="mag-order-toggle" aria-pressed="' + String(!!state.orderMode) + '" ' +
|
||||
'title="Click the boxes on the page in the order they should be read" onclick="magOrderToggle()">' +
|
||||
'<span class="mdi mdi-order-numeric-ascending" aria-hidden="true"></span> Order by clicking</button>' +
|
||||
'<button type="button" class="btn-sm" onclick="magReviewClearCurrentPage()"><span class="mdi mdi-checkbox-multiple-blank-outline" aria-hidden="true"></span> Clear all fields</button></div>' +
|
||||
'<button type="button" class="btn-sm" onclick="magReviewClearCurrentPage()"><span class="mdi mdi-checkbox-multiple-blank-outline" aria-hidden="true"></span> Clear all fields</button>' +
|
||||
'<button type="button" class="btn-sm" onclick="magReviewRemoveAllBoxes()" title="Delete every detected box on this page, so you can draw the layout from scratch">' +
|
||||
'<span class="mdi mdi-eraser" aria-hidden="true"></span> Remove all boxes</button></div>' +
|
||||
'<span class="mag-review-pagenav-spacer"></span>' +
|
||||
'<button type="button" class="btn-sm" id="mag-reading-order-btn" onclick="magFixReadingOrder()" ' +
|
||||
'title="Re-read every page column by column, left to right. Text and approvals stay as they are; pages you ordered by hand are kept." >' +
|
||||
@ -1202,6 +1380,40 @@ function magReviewClearCurrentPage() {
|
||||
say('All fields on this page cleared — select only the article content to keep.', 'ok');
|
||||
}
|
||||
|
||||
// "Clear all fields" above only deselects a page's regions from narration --
|
||||
// the boxes themselves, right or wrong, stay. When the detected layout on a
|
||||
// page is too tangled to fix by editing individual boxes, this deletes every
|
||||
// region on the page outright so "Draw region" can rebuild it from nothing.
|
||||
// Goes through the same bulk endpoint as multi-select delete, so it gets the
|
||||
// same one-shot Undo.
|
||||
async function magReviewRemoveAllBoxes() {
|
||||
if (state.reviewPage == null) return;
|
||||
const page = state.reviewPage;
|
||||
const ids = [
|
||||
...(state.layout.blocks || []).filter((b) => b.page === page).map((b) => b.id),
|
||||
...(state.layout.images || []).filter((im) => im.page === page).map((im) => im.id),
|
||||
];
|
||||
if (!ids.length) return say('No boxes on this page to remove', '');
|
||||
const ok = await confirmDialog(
|
||||
`Delete all ${ids.length} box${ids.length === 1 ? '' : 'es'} on page ${page}? ` +
|
||||
'This can be undone with the Undo button that appears after.',
|
||||
{ title: 'Remove all boxes?', okLabel: 'Remove all' });
|
||||
if (!ok) return;
|
||||
try {
|
||||
const r = await api('/api/magazine/' + state.doc.id + '/pages/' + page + '/regions/bulk', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids, action: 'delete' }),
|
||||
});
|
||||
magOfferUndo(r?.undo, `Removed ${ids.length} box${ids.length === 1 ? '' : 'es'}.`);
|
||||
} catch (e) {
|
||||
return say('Could not remove boxes: ' + e.message, 'err');
|
||||
}
|
||||
state.layout = null;
|
||||
await ensureLayout();
|
||||
renderReview();
|
||||
say('All boxes on this page removed', 'ok');
|
||||
}
|
||||
|
||||
// ── Reading order by hand ─────────────────────────────────────────────────
|
||||
// The number in front of each row is its reading position. Typing a new number moves the region
|
||||
// there; "Order by clicking" lets the reader click the boxes on the page in reading order. Either
|
||||
@ -1469,16 +1681,13 @@ function magReviewToggleDraw(kind = 'text') {
|
||||
if (state.drawMode && state.editMode) magReviewToggleEdit();
|
||||
const hint = $('mag-draw-hint');
|
||||
const field = kind === 'title' ? 'title' : kind === 'author' ? 'author' : '';
|
||||
const drawLabels = { text: 'text region', image: 'image, chart, or graphic', heading: 'headline', page_number: 'page number', source: 'source or footer', ad: 'advertisement' };
|
||||
if (hint) hint.textContent = state.drawMode
|
||||
? (field ? ` Drag around the ${field} text to OCR it into this field. Press Escape to cancel.`
|
||||
: ' Drag around the whole ' + (drawLabels[kind] || 'text region') + '. Press Escape to cancel.') : '';
|
||||
: ' Drag around the region, then say what it is. Press Escape to cancel.') : '';
|
||||
const wrap = $('mag-review-page-wrap');
|
||||
if (wrap) wrap.classList.toggle('is-drawing', state.drawMode);
|
||||
const btn = $('mag-draw-toggle');
|
||||
if (btn) { btn.classList.toggle('active', state.drawMode && kind === 'text'); btn.setAttribute('aria-pressed', String(state.drawMode && kind === 'text')); }
|
||||
const imageBtn = $('mag-draw-image-toggle');
|
||||
if (imageBtn) { imageBtn.classList.toggle('active', state.drawMode && kind === 'image'); imageBtn.setAttribute('aria-pressed', String(state.drawMode && kind === 'image')); }
|
||||
magReviewCancelDrawPanel();
|
||||
}
|
||||
|
||||
@ -1941,26 +2150,25 @@ function magReviewOpenDrawPanel(box) {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'mag-draw-panel';
|
||||
const field = state.drawKind === 'title' || state.drawKind === 'author' ? state.drawKind : '';
|
||||
const semanticKinds = { heading: 'headline', page_number: 'page number', source: 'source / footer', ad: 'advertisement' };
|
||||
const semanticLabel = semanticKinds[state.drawKind] || '';
|
||||
// One generic draw tool now covers every region kind (previously six
|
||||
// separate "Draw X" buttons pre-selected the kind before drawing) — the
|
||||
// kind is picked here, after the rectangle is already on the page, since
|
||||
// that's the order a person actually works in: see the shape, then say
|
||||
// what it is.
|
||||
const drawKindOptions = MAG_REGION_TYPES;
|
||||
panel.innerHTML = field
|
||||
? '<div class="mag-draw-panel-row"><strong>Extract ' + field + '</strong></div>' +
|
||||
'<p class="muted">OCR the selected rectangle and use its recognised text for the article ' + field + '.</p>' +
|
||||
'<div class="mag-draw-panel-row"><button type="button" class="btn-sm" onclick="magReviewCancelDrawPanel()">Cancel</button>' +
|
||||
'<button type="button" class="btn-sm primary" onclick="magReviewSubmitDraw()"><span class="mdi mdi-text-recognition"></span> Use recognised text</button></div>'
|
||||
: semanticLabel
|
||||
? '<div class="mag-draw-panel-row"><strong>Add ' + semanticLabel + '</strong></div>' +
|
||||
'<p class="muted">This region is classified as ' + semanticLabel + (state.drawKind === 'heading' ? ' and can be included in narration.' : ' and is kept out of narration.') + '</p>' +
|
||||
'<textarea id="mag-draw-text" rows="2" placeholder="Type the text, or leave blank and use OCR"></textarea>' +
|
||||
'<div class="mag-draw-panel-row"><button type="button" class="btn-sm" onclick="magReviewCancelDrawPanel()">Cancel</button>' +
|
||||
'<button type="button" class="btn-sm primary" onclick="magReviewSubmitDraw()"><span class="mdi mdi-plus" aria-hidden="true"></span> Add ' + semanticLabel + '</button></div>'
|
||||
:
|
||||
'<div class="mag-draw-panel-row">' +
|
||||
'<label><input type="radio" name="mag-draw-kind" value="text" ' + (state.drawKind !== 'image' ? 'checked' : '') + '> Text</label>' +
|
||||
'<label><input type="radio" name="mag-draw-kind" value="image" ' + (state.drawKind === 'image' ? 'checked' : '') + '> Image</label>' +
|
||||
'<label>Region type <select id="mag-draw-kind">' +
|
||||
drawKindOptions.map(([v, label]) => '<option value="' + v + '">' + label + '</option>').join('') +
|
||||
'</select></label>' +
|
||||
'</div>' +
|
||||
'<label id="mag-draw-exclude"' + (state.drawKind === 'image' ? '' : ' hidden') + '><input id="mag-draw-exclude-check" type="checkbox" checked> Exclude enclosed text from narration</label>' +
|
||||
'<textarea id="mag-draw-text"' + (state.drawKind === 'image' ? ' hidden' : '') + ' rows="2" placeholder="Type the text, or leave blank and use OCR"></textarea>' +
|
||||
'<label id="mag-draw-exclude" hidden><input id="mag-draw-exclude-check" type="checkbox" checked> Exclude enclosed text from narration</label>' +
|
||||
'<textarea id="mag-draw-text" rows="2" placeholder="Type the text, or leave blank and use OCR"></textarea>' +
|
||||
'<div class="mag-draw-panel-row">' +
|
||||
'<button type="button" class="btn-sm" onclick="magReviewCancelDrawPanel()">Cancel</button>' +
|
||||
'<button type="button" class="btn-sm primary" onclick="magReviewSubmitDraw()">' +
|
||||
@ -1970,14 +2178,16 @@ function magReviewOpenDrawPanel(box) {
|
||||
const top = parseFloat(box.style.top) || 0, left = parseFloat(box.style.left) || 0, height = parseFloat(box.style.height) || 0;
|
||||
panel.style.top = Math.min(85, top + height + 1) + '%';
|
||||
panel.style.left = Math.min(60, left) + '%';
|
||||
wrap.querySelectorAll('input[name="mag-draw-kind"]').forEach((r) => {
|
||||
r.addEventListener('change', () => {
|
||||
const kindSelect = $('mag-draw-kind');
|
||||
if (kindSelect) {
|
||||
kindSelect.addEventListener('change', () => {
|
||||
const isImage = kindSelect.value === 'image';
|
||||
const ta = $('mag-draw-text');
|
||||
if (ta) ta.hidden = r.value === 'image' && r.checked;
|
||||
$('mag-draw-exclude').hidden = r.value !== 'image';
|
||||
});
|
||||
if (ta) ta.hidden = isImage;
|
||||
$('mag-draw-exclude').hidden = !isImage;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function magReviewSubmitDraw() {
|
||||
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
|
||||
@ -1985,8 +2195,7 @@ async function magReviewSubmitDraw() {
|
||||
const pageMeta = (state.layout.pages || []).find((p) => p.page === state.reviewPage);
|
||||
if (!box || !pageMeta) return;
|
||||
const field = state.drawKind === 'title' || state.drawKind === 'author' ? state.drawKind : '';
|
||||
const kind = field ? 'text' : (['heading', 'page_number', 'source', 'ad'].includes(state.drawKind)
|
||||
? state.drawKind : (document.querySelector('input[name="mag-draw-kind"]:checked')?.value || 'text'));
|
||||
const kind = field ? 'text' : ($('mag-draw-kind')?.value || 'text');
|
||||
const text = ($('mag-draw-text')?.value || '').trim();
|
||||
const bbox = [
|
||||
parseFloat(box.style.left) / 100 * pageMeta.width,
|
||||
@ -2024,7 +2233,9 @@ async function magReviewSubmitDraw() {
|
||||
try {
|
||||
const r = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bbox, kind, text, ocr: kind !== 'image' && !text }),
|
||||
// Every text-like kind gets read automatically if left blank — except
|
||||
// an advertisement, which is a graphic, not something worth OCRing.
|
||||
body: JSON.stringify({ bbox, kind, text, ocr: kind !== 'image' && kind !== 'ad' && !text }),
|
||||
});
|
||||
jobId = r.job_id;
|
||||
} catch (e) {
|
||||
@ -2192,8 +2403,15 @@ async function magApprove() {
|
||||
});
|
||||
Object.assign(a, updated);
|
||||
say('Approved — ready to listen', 'ok');
|
||||
// Approving one article at a time is a queue, not a one-off — staying on
|
||||
// the list meant re-finding and re-opening whichever article came next
|
||||
// after every single approval. Go straight to it instead, and only fall
|
||||
// back to the list once nothing is left to review.
|
||||
const idx = state.articles.findIndex((x) => x.id === a.id);
|
||||
const nextId = idx >= 0 ? state.articles[idx + 1]?.id : null;
|
||||
await magRefreshArticles();
|
||||
magShow('contents');
|
||||
if (nextId && state.articles.some((x) => x.id === nextId)) magReview(nextId);
|
||||
else magShow('contents');
|
||||
} catch (e) {
|
||||
say('Could not approve: ' + e.message, 'err');
|
||||
}
|
||||
@ -2683,7 +2901,7 @@ async function magLoadVoices() {
|
||||
if (root.VoicePicker && el.dataset.vpUpgraded) root.VoicePicker.setValue(id, value);
|
||||
else el.value = value;
|
||||
};
|
||||
const voiceLabels = { 'mag-voice': 'Narrator voice', 'mag-host-a': 'Host A voice', 'mag-host-b': 'Host B voice', 'mag-host-c': 'Host C voice' };
|
||||
const voiceLabels = { 'mag-voice': 'Narrator voice', 'mag-host-a': 'Host A voice', 'mag-host-b': 'Host B voice', 'mag-host-c': 'Host C voice', 'mag-audio-voice': 'Narrator for exported MP3s' };
|
||||
Object.keys(voiceLabels).forEach((id) => {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
@ -2704,6 +2922,7 @@ async function magLoadVoices() {
|
||||
});
|
||||
const def = state.doc && state.doc.default_voice;
|
||||
if (def && $('mag-voice') && !$('mag-voice').value) setVoice('mag-voice', def);
|
||||
if (def && $('mag-audio-voice') && !$('mag-audio-voice').value) setVoice('mag-audio-voice', def);
|
||||
}
|
||||
|
||||
async function loadPodcastIfAny() {
|
||||
@ -2880,6 +3099,10 @@ async function magOnShow() {
|
||||
async function magInit() {
|
||||
setupMagDropzone();
|
||||
setupMagIngestPreviewResize();
|
||||
try {
|
||||
const savedView = localStorage.getItem(MAG_CONTENTS_VIEW_KEY);
|
||||
if (savedView === 'grid' || savedView === 'list') state.contentsView = savedView;
|
||||
} catch (_) {}
|
||||
if (!$('mag-library-list')) return; // section not mounted
|
||||
magLoadLibrary();
|
||||
magLoadVoices();
|
||||
@ -2908,15 +3131,23 @@ root.magDelete = magDelete;
|
||||
root.magCreate = magCreate;
|
||||
root.magIngest = magIngest;
|
||||
root.magReview = magReview;
|
||||
root.magToggleContentsView = magToggleContentsView;
|
||||
root.magContentsToggleSelect = magContentsToggleSelect;
|
||||
root.magContentsCommitTitle = magContentsCommitTitle;
|
||||
root.magGenerateAllAudio = magGenerateAllAudio;
|
||||
root.magContentsToggleArticle = magContentsToggleArticle;
|
||||
root.magContentsSelectAllVisible = magContentsSelectAllVisible;
|
||||
root.magContentsClearSelection = magContentsClearSelection;
|
||||
root.magContentsBulkStatus = magContentsBulkStatus;
|
||||
root.magReviewGoPage = magReviewGoPage;
|
||||
root.magReviewGoMagazinePage = magReviewGoMagazinePage;
|
||||
root.magReviewStepPage = magReviewStepPage;
|
||||
root.magReviewToggleSpread = magReviewToggleSpread;
|
||||
root.magReviewSetActivePage = magReviewSetActivePage;
|
||||
root.magReviewFocusBlock = magReviewFocusBlock;
|
||||
root.magReviewHighlightBox = magReviewHighlightBox;
|
||||
root.magReviewToggleBox = magReviewToggleBox;
|
||||
root.magReviewClearCurrentPage = magReviewClearCurrentPage;
|
||||
root.magReviewRemoveAllBoxes = magReviewRemoveAllBoxes;
|
||||
root.magReocrPage = magReocrPage;
|
||||
root.magRefineFigures = magRefineFigures;
|
||||
root.magReviewToggleImage = magReviewToggleImage;
|
||||
|
||||
@ -58,6 +58,12 @@
|
||||
</button>
|
||||
<h3 id="mag-doc-title">Magazine</h3>
|
||||
<div class="mag-bar-actions">
|
||||
<button type="button" class="btn-sm" id="mag-contents-view-toggle" onclick="magToggleContentsView()" title="Switch between a list and a thumbnail grid of articles">
|
||||
<span class="mdi mdi-view-grid-outline"></span> Thumbnails
|
||||
</button>
|
||||
<button type="button" class="btn-sm" id="mag-contents-select-toggle" aria-pressed="false" onclick="magContentsToggleSelect()" title="Select several articles to approve or reject at once">
|
||||
<span class="mdi mdi-checkbox-multiple-marked-outline"></span> Select
|
||||
</button>
|
||||
<button type="button" class="btn-sm primary" onclick="magPlayAll()">
|
||||
<span class="mdi mdi-playlist-play"></span> Play all articles
|
||||
</button>
|
||||
@ -69,6 +75,19 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mag-contents-bulkbar" class="mag-bulkbar" hidden></div>
|
||||
<div class="mag-audio-export">
|
||||
<label for="mag-audio-voice">Narrator for exported MP3s</label>
|
||||
<select id="mag-audio-voice"></select>
|
||||
<button type="button" class="btn-sm" id="mag-audio-export-btn" onclick="magGenerateAllAudio()"
|
||||
title="Render every approved article to a single MP3 with its title, author, cover image and full text embedded as tags -- for a phone's own music/podcast player or a car head unit">
|
||||
<span class="mdi mdi-file-music-outline"></span> Generate all audio (MP3)
|
||||
</button>
|
||||
<a class="btn-sm" id="mag-audio-zip-link" href="#" hidden download>
|
||||
<span class="mdi mdi-folder-zip-outline"></span> Download all (ZIP)
|
||||
</a>
|
||||
<span id="mag-audio-export-status" class="muted" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<div id="mag-ingest-status" class="mag-status" hidden></div>
|
||||
<!-- Decorative "watch it work" visualisation -- the essential
|
||||
progress information (percentage, page count, done/failed) is
|
||||
|
||||
@ -4916,6 +4916,33 @@ input[list="lang-codes"] {
|
||||
.mag-toc-by { color: var(--subtext); font-size: 13px; margin-left: 8px; }
|
||||
.mag-toc-meta { display: flex; gap: 10px; align-items: center; color: var(--subtext); font-size: 13px; flex-wrap: wrap; }
|
||||
.mag-toc-actions { display: flex; gap: 6px; }
|
||||
.mag-select-check { display: flex; align-items: center; padding-right: 4px; }
|
||||
.mag-select-check input { width: 18px; height: 18px; accent-color: var(--accent); cursor: pointer; }
|
||||
.mag-toc-row { grid-template-columns: auto 1fr auto auto; }
|
||||
.mag-card { position: relative; }
|
||||
.mag-card .mag-select-check { position: absolute; top: 8px; left: 8px; z-index: 1;
|
||||
background: var(--surface); border-radius: 4px; padding: 2px; }
|
||||
|
||||
.mag-bulkbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
padding: 10px 14px; margin-bottom: 12px; border: 1px solid var(--accent); border-radius: var(--radius);
|
||||
background: var(--surface); position: sticky; top: 0; z-index: 25; }
|
||||
.mag-bulkbar-count { font-weight: 600; }
|
||||
.mag-bulkbar-progress { flex: 1; min-width: 0; }
|
||||
.mag-bulkbar-actions { display: flex; gap: 6px; margin-left: auto; }
|
||||
|
||||
.mag-audio-export { display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
padding: 10px 14px; margin-bottom: 12px; border: 1px solid var(--border); border-radius: var(--radius);
|
||||
background: var(--panel); }
|
||||
.mag-audio-export label { color: var(--subtext); font-size: 13px; }
|
||||
.mag-audio-export select { padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--surface); color: var(--text); }
|
||||
|
||||
/* Clicking a title edits it in place -- a dashed underline on hover/focus is
|
||||
the only cue it's editable, since a permanent border on every card title
|
||||
would be noisy on a page listing 50+ articles. */
|
||||
.mag-card-title[contenteditable], .mag-toc-title[contenteditable] { cursor: text; border-radius: 3px; outline: none; }
|
||||
.mag-card-title[contenteditable]:hover, .mag-toc-title[contenteditable]:hover { text-decoration: underline dashed; }
|
||||
.mag-card-title[contenteditable]:focus, .mag-toc-title[contenteditable]:focus { box-shadow: 0 0 0 2px var(--accent); }
|
||||
|
||||
.mag-review-head { display: grid; grid-template-columns: 2fr 1fr auto; gap: 12px; align-items: end; margin-bottom: 10px; }
|
||||
.mag-review-head label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--subtext); }
|
||||
@ -4927,13 +4954,20 @@ input[list="lang-codes"] {
|
||||
.mag-page-head { margin: 16px 0 6px; font-size: 13px; color: var(--subtext); }
|
||||
|
||||
/* ── Review page navigator + "OCR again" ─────────────────────────────────── */
|
||||
.mag-review-pagenav { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; margin-bottom: 12px; }
|
||||
/* Sticky so the page nav and box tools stay reachable on a long page without
|
||||
scrolling back up; tightened gaps/heights so the whole bar has a real
|
||||
chance of fitting one row instead of always wrapping to two. */
|
||||
.mag-review-pagenav { display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
|
||||
position: sticky; top: 0; z-index: 30; background: var(--bg);
|
||||
padding: 8px 0; margin-bottom: 12px; box-shadow: 0 2px 6px rgba(0, 0, 0, .06); }
|
||||
.mag-review-pagenav-group { display: flex; align-items: center; gap: 4px; }
|
||||
.mag-review-page-controls { display: flex; align-items: center; gap: 8px; padding: 4px;
|
||||
border: 1px solid var(--accent); border-radius: var(--radius); background: var(--surface); }
|
||||
.mag-review-page-number { min-width: 100px; text-align: center; font-size: 16px; color: var(--text); }
|
||||
.mag-review-page-number { min-width: 90px; text-align: center; font-size: 14px; color: var(--text); }
|
||||
.mag-review-page-controls .btn-sm { min-width: 44px; min-height: 40px; font-size: 22px; }
|
||||
.mag-review-draw-controls { display: flex; gap: 8px; }
|
||||
.mag-review-draw-controls .btn-sm { min-height: 40px; }
|
||||
.mag-review-pagenav .btn-sm { padding: 6px 10px; min-height: 34px; }
|
||||
.mag-review-draw-controls { display: flex; gap: 6px; }
|
||||
.mag-review-draw-controls .btn-sm { min-height: 34px; }
|
||||
.mag-review-draw-controls .active { background: var(--accent); color: var(--on-accent); }
|
||||
#mag-draw-hint:not(:empty) { padding: 8px 12px; margin-bottom: 12px; border-left: 3px solid var(--accent); }
|
||||
.mag-review-pagenav-spacer { flex: 1; min-width: 0; }
|
||||
|
||||
@ -1825,6 +1825,90 @@ class DeleteRegionTests(unittest.TestCase):
|
||||
self.assertEqual(len(layout["blocks"]), 1)
|
||||
|
||||
|
||||
class InventedVisualRegionScreenTests(unittest.TestCase):
|
||||
"""A semantic-layout call can propose a brand-new picture/chart/table region (block_ids: [])
|
||||
for a graphic OCR never produced a block for. Confirmed on a real issue (c't KI Wissen 2026):
|
||||
it sometimes instead draws that box over a column gap and the text below it, which would bury
|
||||
real paragraphs under an empty "Figure" placeholder. region_looks_like_a_picture and
|
||||
apply_semantic_regions' page_ink screen that out using the page render's own ink density --
|
||||
a real photo/chart/table is solid, a text area is not (confirmed: 0.56-0.97 vs 0.10-0.14 on
|
||||
the actual false positives and true positives found on that issue)."""
|
||||
|
||||
PAGE_W, PAGE_H = 582.0, 678.0
|
||||
PX_W, PX_H = 890, 1037
|
||||
FIGURE_PX = (400, 100, 850, 400)
|
||||
TEXT_PX = (60, 100, 350, 500)
|
||||
|
||||
def setUp(self):
|
||||
from PIL import Image, ImageDraw
|
||||
self.dir = pathlib.Path(tempfile.mkdtemp())
|
||||
(self.dir / "pages").mkdir()
|
||||
page = Image.new("RGB", (self.PX_W, self.PX_H), (255, 255, 255))
|
||||
draw = ImageDraw.Draw(page)
|
||||
draw.rectangle(self.FIGURE_PX, fill=(40, 90, 200))
|
||||
x0, y0, x1, y1 = self.TEXT_PX
|
||||
for y in range(y0, y1, 12):
|
||||
draw.rectangle((x0, y, x1, y + 3), fill=(20, 20, 20))
|
||||
self.png = self.dir / "pages" / "0001.png"
|
||||
page.save(self.png)
|
||||
self.meta = {"page": 1, "width": self.PAGE_W, "height": self.PAGE_H}
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.dir, ignore_errors=True)
|
||||
|
||||
def _pt(self, px_box):
|
||||
sx, sy = self.PAGE_W / self.PX_W, self.PAGE_H / self.PX_H
|
||||
x0, y0, x1, y1 = px_box
|
||||
return [x0 * sx, y0 * sy, (x1 - x0) * sx, (y1 - y0) * sy]
|
||||
|
||||
def test_a_dense_region_looks_like_a_picture(self):
|
||||
from core.magazine import region_looks_like_a_picture, page_ink_grid_points
|
||||
page_ink = page_ink_grid_points(self.png, self.meta)
|
||||
self.assertIsNotNone(page_ink)
|
||||
self.assertTrue(region_looks_like_a_picture(self._pt(self.FIGURE_PX), page_ink))
|
||||
|
||||
def test_a_sparse_text_region_does_not_look_like_a_picture(self):
|
||||
from core.magazine import region_looks_like_a_picture, page_ink_grid_points
|
||||
page_ink = page_ink_grid_points(self.png, self.meta)
|
||||
self.assertFalse(region_looks_like_a_picture(self._pt(self.TEXT_PX), page_ink))
|
||||
|
||||
def test_no_page_ink_always_passes(self):
|
||||
from core.magazine import region_looks_like_a_picture
|
||||
self.assertTrue(region_looks_like_a_picture(self._pt(self.TEXT_PX), None))
|
||||
|
||||
def test_apply_semantic_regions_keeps_a_real_picture_and_drops_a_text_area(self):
|
||||
from core.magazine import apply_semantic_regions, page_ink_grid_points
|
||||
page_ink = page_ink_grid_points(self.png, self.meta)
|
||||
|
||||
def permille(px_box):
|
||||
x, y, w, h = self._pt(px_box)
|
||||
return [x / self.PAGE_W * 1000, y / self.PAGE_H * 1000,
|
||||
(x + w) / self.PAGE_W * 1000, (y + h) / self.PAGE_H * 1000]
|
||||
|
||||
grouped = apply_semantic_regions([], [
|
||||
{"type": "image", "block_ids": [], "bbox": permille(self.FIGURE_PX)},
|
||||
{"type": "image", "block_ids": [], "bbox": permille(self.TEXT_PX)},
|
||||
], self.meta, page_ink=page_ink)
|
||||
self.assertEqual(len(grouped), 1)
|
||||
self.assertEqual(grouped[0]["type"], "figure")
|
||||
for got, want in zip(grouped[0]["bbox"], self._pt(self.FIGURE_PX)):
|
||||
self.assertAlmostEqual(got, want, delta=1.0)
|
||||
|
||||
def test_without_page_ink_both_are_kept_as_before(self):
|
||||
from core.magazine import apply_semantic_regions
|
||||
|
||||
def permille(px_box):
|
||||
x, y, w, h = self._pt(px_box)
|
||||
return [x / self.PAGE_W * 1000, y / self.PAGE_H * 1000,
|
||||
(x + w) / self.PAGE_W * 1000, (y + h) / self.PAGE_H * 1000]
|
||||
|
||||
grouped = apply_semantic_regions([], [
|
||||
{"type": "image", "block_ids": [], "bbox": permille(self.FIGURE_PX)},
|
||||
{"type": "image", "block_ids": [], "bbox": permille(self.TEXT_PX)},
|
||||
], self.meta)
|
||||
self.assertEqual(len(grouped), 2)
|
||||
|
||||
|
||||
class SnapVisualBboxesTests(unittest.TestCase):
|
||||
"""A vision OCR engine grounds a figure loosely; the render is the truth.
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user