diff --git a/CHANGELOG.md b/CHANGELOG.md index 174afa6..68a797c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,32 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.26.4] — 2026-09-09 + +### Added +- **Native Baidu Unlimited-OCR Layout Parsing.** Magazine ingestion now uses Unlimited-OCR's documented + `document parsing.` mode and consumes its `<|det|>` bounding-box regions. Images, non-text graphics, charts and + tables are retained as visual regions instead of being mixed into narratable article prose; captions remain captions. + +### Changed +- **Magazine OCR Preview Uses Its Space.** The source page fills the available preview height while preserving its + aspect ratio. A draggable divider lets readers allocate space between the page and recognised text, and remembers + the choice. +- **Character Sheets Read Like Profiles.** Profile fields are now one-column entries with a bold heading and normal + prose beneath it, rather than compact label/value columns. The live passage/output drawer stays bottom-anchored and + its top grip resizes from the expected edge. + +### Fixed +- **Unlimited-OCR Repetition Guard Was Not Activated.** Requests now include the model's required per-request + n-gram settings and `gundam` image mode. Repeated fabricated LaTex and instruction-echo responses are rejected so + fallback OCR can be used instead of saving poisoned page text. +- **Character Line Totals Missed Aliases.** A profile now totals dialogue credited to its canonical name and recorded + identity aliases (for example, `Larissa` and `Schwester Larissa`) and refreshes the total during casting. +- **Page Furniture Does Not Become Article Text.** Native OCR regions now identify headers, footers and page numbers + as page furniture, keeping them out of narration while preserving their position for review. + +--- + ## [1.26.3] — 2026-09-09 ### Fixed diff --git a/VERSION b/VERSION index f8f7381..ea0928c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.26.3 +1.26.4 diff --git a/core/magazine.py b/core/magazine.py index 451109e..38ee4f5 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -742,6 +742,8 @@ _VISION_LABEL_TYPES = { "title": BLOCK_TITLE, "heading": BLOCK_HEADING, "caption": BLOCK_CAPTION, + "image_caption": BLOCK_CAPTION, + "table_caption": BLOCK_CAPTION, "headline": BLOCK_TITLE, "article_headline": BLOCK_TITLE, "paragraph": BLOCK_BODY, @@ -758,6 +760,8 @@ _VISION_LABEL_TYPES = { "graphic": BLOCK_CHART, "diagram": BLOCK_CHART, "table": BLOCK_TABLE, + "formula": BLOCK_CODE, + "list": BLOCK_BODY, "code": BLOCK_CODE, "code_block": BLOCK_CODE, "advertisement": BLOCK_AD, @@ -769,6 +773,15 @@ _VISION_LABEL_TYPES = { # the next such marker. Confirmed live against Baidu Unlimited-OCR's actual # output shape: "text [241, 90, 588, 249]Battery technology is entering...". _VISION_REGION = re.compile(r"(?m)^(\w+)\s*\[\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\]") +# Native Baidu Unlimited-OCR document-parsing output. The model card emits +# one marker per detected region, such as +# ``<|det|>table [0,0,999,999]<|/det|>...``. Its categories are precisely +# what we need to retain charts/tables/images as visual blocks instead of +# treating their axis labels as article prose. +_UNLIMITED_OCR_REGION = re.compile( + r"(?m)^<\|det\|>\s*(.*?)\s*(?:\[\s*(-?\d+(?:\.\d+)?)\s*,\s*" + r"(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*" + r"(-?\d+(?:\.\d+)?)\s*\])?\s*<\|/det\|>") def ocr_quality_score(text: str) -> float: @@ -848,6 +861,42 @@ def parse_vision_ocr_regions(raw: str) -> list[dict[str, Any]]: return regions +def parse_unlimited_ocr_regions(raw: str) -> list[dict[str, Any]]: + """Parse Unlimited-OCR's native ``<|det|>`` document-layout markers. + + Bounding boxes are in its 0–999-ish image coordinate space; callers mark + them for conversion to the rendered page's pixels before building blocks. + A `[Non-Text]` region is a real visual object even when it has no text. + """ + matches = list(_UNLIMITED_OCR_REGION.finditer(raw or "")) + regions = [] + for i, match in enumerate(matches): + raw_label = (match.group(1) or "").strip().strip("[]") + label = raw_label.lower().replace("-", "_").replace(" ", "_") + coords = match.groups()[1:] + if len(coords) != 4 or any(v is None for v in coords): + continue + x0, y0, x1, y1 = (float(v) for v in coords) + if x1 <= x0 or y1 <= y0: + continue + start = match.end() + end = matches[i + 1].start() if i + 1 < len(matches) else len(raw or "") + text = (raw or "")[start:end].strip() + # The model's generic Non-Text label is an image/figure. It must be + # retained even empty, so crop_visual_regions can preserve it. + if label in {"non_text", "nontext"}: + label = "image" + if not text and _VISION_LABEL_TYPES.get(label) not in VISUAL_TYPES: + continue + regions.append({ + "label": label, + "bbox": [x0, y0, x1 - x0, y1 - y0], + "text": text, + "native_normalized_bbox": True, + }) + return regions + + def _whole_page_region(content: str, png_path: Path) -> list[dict[str, Any]] | None: """Fallback for an engine that answers with plain text and no coordinate markers at all (confirmed live: KDL Frontier Parser nano -- fast, but no @@ -871,10 +920,18 @@ def _whole_page_region(content: str, png_path: Path) -> list[dict[str, Any]] | N escaped_newlines = len(re.findall(r"\\n", text)) equation_tokens = len(re.findall(r"`?\s*\\?equation\b", text, re.IGNORECASE)) prompt_echo = "do not use special symbols" in text.lower() + # Another observed loop starts with genuine page text and then repeats a + # fabricated LaTex fragment (``\\therefore m = \\frac{3}{11}``) until the + # response limit. It has no instruction echo or `equation` token, so it + # needs its own narrow signature. Requiring both uncommon controls many + # times avoids rejecting ordinary prose that merely mentions one formula. + therefore_tokens = len(re.findall(r"\\therefore\b", text, re.IGNORECASE)) + fraction_tokens = len(re.findall(r"\\frac\s*\{", text, re.IGNORECASE)) + latex_loop = therefore_tokens >= 4 and fraction_tokens >= 4 if (escaped_newlines >= 4 and equation_tokens >= 4) or ( prompt_echo and escaped_newlines >= 2 and equation_tokens >= 2 - ): - logging.getLogger(__name__).warning("Vision OCR returned instruction-echo artifact; discarding response") + ) or latex_loop: + logging.getLogger(__name__).warning("Vision OCR returned a repeated formatting artifact; discarding response") return None if len(text) < 20 or ocr_quality_score(text) < _VISION_QUALITY_THRESHOLD: return None @@ -911,22 +968,24 @@ def vision_ocr_page(png_path: Path, base_url: str, model: str, if not urlsplit(base_url).path: base_url += "/v1" + unlimited_ocr = "unlimited-ocr" in model.lower() + prompt = "document parsing." if unlimited_ocr else ( + "Recognise the page layout and transcribe visible text verbatim. " + "Return one region per headline, paragraph, image, chart, table, " + "code box, caption, header, footer or page number. Use the format " + "label [x0, y0, x1, y1]transcription with pixel coordinates in the " + "supplied image, one region starting on each new line. Labels: " + "title, paragraph, image, chart, table, code, caption, header, " + "footer, page_number, aside_text. Enclose a whole chart or table " + "in one box, not separate axis labels or cells. Preserve table " + "rows and columns as Markdown and code indentation and line breaks. " + "Images may have empty transcription; do not invent descriptions. " + "Do not merge neighbouring articles or include commentary." + ) payload = { "model": model, "messages": [{"role": "user", "content": [ - {"type": "text", "text": ( - "Recognise the page layout and transcribe visible text verbatim. " - "Return one region per headline, paragraph, image, chart, table, " - "code box, caption, header, footer or page number. Use the format " - "label [x0, y0, x1, y1]transcription with pixel coordinates in the " - "supplied image, one region starting on each new line. Labels: " - "title, paragraph, image, chart, table, code, caption, header, " - "footer, page_number, aside_text. Enclose a whole chart or table " - "in one box, not separate axis labels or cells. Preserve table " - "rows and columns as Markdown and code indentation and line breaks. " - "Images may have empty transcription; do not invent descriptions. " - "Do not merge neighbouring articles or include commentary." - )}, + {"type": "text", "text": prompt}, {"type": "image_url", "image_url": { "url": f"data:image/png;base64,{image_b64}", }}, @@ -934,6 +993,18 @@ def vision_ocr_page(png_path: Path, base_url: str, model: str, "max_tokens": 4000, "temperature": 0.0, } + # Unlimited-OCR's official vLLM recipe requires its per-request + # NGramPerReqLogitsProcessor settings. The container already exposes the + # processor, but without these fields it does nothing and the model can + # run into a repeated chart/formula loop. Keep the vendor-specific fields + # off generic OpenAI-compatible vision engines. + if unlimited_ocr: + payload.update({ + "skip_special_tokens": False, + "ngram_size": 35, + "window_size": 128, + "images_config": {"image_mode": "gundam"}, + }) try: resp = request_with_retries( "POST", f"{base_url.rstrip('/')}/chat/completions", @@ -952,7 +1023,21 @@ def vision_ocr_page(png_path: Path, base_url: str, model: str, if not isinstance(content, str): return None - regions = parse_vision_ocr_regions(content) + regions = parse_unlimited_ocr_regions(content) if unlimited_ocr else parse_vision_ocr_regions(content) + if regions and unlimited_ocr: + # Native markers use the model input's 0–999-ish coordinate space; + # blocks downstream expect coordinates of the rendered PNG. + try: + from PIL import Image + with Image.open(png_path) as im: + width, height = im.size + for region in regions: + if region.pop("native_normalized_bbox", False): + x, y, w, h = region["bbox"] + region["bbox"] = [x / 1000 * width, y / 1000 * height, + w / 1000 * width, h / 1000 * height] + except Exception: + return None return regions or _whole_page_region(content, png_path) diff --git a/static/css/audiobook.css b/static/css/audiobook.css index 604e6bb..797e122 100644 --- a/static/css/audiobook.css +++ b/static/css/audiobook.css @@ -1409,7 +1409,11 @@ .cs-progress-output { display: flex; flex-direction: column; - flex: 1 1 auto; + /* Keep the live-output sheet immediately above the progress footer. Its + top grip then behaves like a bottom-anchored drawer: changing height + moves the top edge only, never creates/removes a gap below the panel. */ + flex: 0 0 auto; + margin-top: auto; gap: 8px; min-height: 120px; max-height: 42vh; @@ -1552,10 +1556,8 @@ .cs-progress-layout), flex:1 here forced THIS panel to stretch to match, leaving a large empty gap below a short/not-yet-filled sheet (confirmed live with a short card next to a 72-character sidebar). - Shrink-to-content instead; .cs-progress-output below absorbs any - leftover column height since it's the piece meant to grow (it already - scrolls its own passage/live-output text, unlike this preview which - shows one bounded character sheet). */ + Shrink-to-content instead; the bottom-anchored .cs-progress-output + keeps its own scrollable, user-resizable height below it. */ margin: 10px 0 0; padding: 0; min-height: 220px; diff --git a/static/js/character-sheets.js b/static/js/character-sheets.js index 309f2e7..3474fa7 100644 --- a/static/js/character-sheets.js +++ b/static/js/character-sheets.js @@ -761,8 +761,6 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) { const seed = csSeedSheet(seedRaw); const name = String(seed.name || '').trim(); if (!name) return; - const lc = lineCounts.get(name.toLowerCase()); - if (lc != null) seed.line_count = lc; map.set(name.toLowerCase(), seed); establishedKeys.add(name.toLowerCase()); const created = seedRaw?.created ? new Date(seedRaw.created).getTime() : NaN; @@ -777,11 +775,13 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) { const key = name.toLowerCase(); if (!map.has(key)) { const blank = csBlankSheet(name); - const lc = lineCounts.get(key); - if (lc != null) blank.line_count = lc; map.set(key, blank); } }); + // Saved sheets can already have aliases (for example, "Schwester Larissa") + // while the dialogue cast keeps the shorter canonical name ("Larissa"), + // or vice versa. Apply the identity-aware tally after all seeds exist. + csAttachLineCounts([...map.values()], lineCounts); try { for (let i = 0; i < chunks.length; i++) { if (_cs.cancel) break; @@ -876,6 +876,10 @@ async function csGenerate(text, cacheKey, initialRoster, opts = {}) { }); }); const changed = csMerge(map, incomingSheets, { strictRoster: targetMode }); + // A response can reveal an alias only in this passage. Recalculate + // immediately so the live header/sidebar does not keep showing the + // old exact-name-only count until the full run has finished. + csAttachLineCounts([...map.values()], lineCounts); if (changed.length) prog.focus(changed[0].name); if (!targetMode) { (data.characters || []).forEach(n => { if (!roster.some(r => r.toLowerCase() === String(n || '').toLowerCase())) roster.push(n); }); @@ -1769,16 +1773,12 @@ function csOverviewCardHtml(rec) { `; } -// Field/section helpers matching the richer 2-column "lcd-*" layout (same -// classes the audiobook casting Profil panel and the full Library character -// editor use), instead of csCardHtml's old flat