diff --git a/CHANGELOG.md b/CHANGELOG.md index bb22602..c66ad23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Added +- **Magazine review: extraction diff.** A region whose text you edited by hand is marked *Edited*, with *Show changes* (word-level comparison against the text as first recognised; removals struck through and marked "−", additions underlined and marked "+") and *Restore recognised text*. The first reading is kept in a new `ocr_text` field across repeated edits; re-reading the region or typing the original back clears it. - **"Continued from page N" now joins a feature's tail to its head.** Until now only the forward "continued on page N" reference linked pages. A tail that says "Fortsetzung von Seite 10" / "Continued from page 10" (found even when set as small furniture or sidebar text, on lines up to 160 characters) is attached to the single article on that page. Skipped when a contents entry anchors the tail, when the page is shared by several articles, or when the marker points forward or at an empty page. Articles gain a `continued_from` list. - **Magazine import from PNG/JPEG page images.** Choose or drop several scans or phone photos; they are checked by content (not by name), ordered by filename with natural numbering, turned into one scan-style PDF (page size from the image's own resolution, EXIF rotation applied, 72 dpi treated as unknown) and ingested like any scanned issue. Limits: 300 images, 25 MB and 60 megapixels each; a bad image rejects the whole upload and leaves nothing behind; an existing source is never replaced. Verified on real pages: OCR through the image path scored like the original PDF (F1 0.85 / 0.78). - `architecture/ocr-porting-prompt-general.md`: the OCR porting brief rewritten for general documents (letters, papers, forms, invoices). Adds privacy, pre-processing of skewed/photographed scans, document-type handling, field-level accuracy and handwriting, and tags every claim as measured on magazines, a general principle, or untested for these document types. diff --git a/Manual.md b/Manual.md index b23d4ea..5dc7365 100644 --- a/Manual.md +++ b/Manual.md @@ -338,6 +338,26 @@ Open an article and expand **Pronunciation & spelling** under the player. **Mobile / accessibility:** the lists are ordinary buttons with labels ("Replace X with Y"); marks are underlined, not colour-only. **Privacy:** the agent sends only the flagged word and its surrounding sentence to the LLM you configured in Settings; without an LLM nothing leaves the machine. +### Editing recognised text, and seeing what you changed + +**Purpose.** Fix what OCR got wrong and always be able to see, and undo, what you changed. + +**Steps.** In Review, click a region's text (or its pencil) and press *Save text*. A region you edited shows an +**Edited** mark. *Show changes* opens a word-by-word comparison with the text as first recognised: words removed are +struck through and marked with a minus, words added are underlined and marked with a plus (never colour alone). +*Restore recognised text* puts the original back. Typing the original back yourself also clears the mark. + +**Expected result.** The first recognised text is kept through any number of edits; reading the region again (*Re-OCR*) +makes the new reading the original. Edits are reversible with Undo as before. + +**Errors.** Empty text is refused (delete the region instead). Very long texts (over about 1,500 words) are compared as +one removed block and one added block rather than word by word. + +**Mobile / accessibility.** The buttons are real buttons with an expanded/collapsed state; the comparison is a labelled +group that explains the minus/plus convention to a screen reader. + +**Privacy.** The recognised text stays on the server with the magazine; nothing is sent elsewhere. + ### Find an issue online **Purpose.** Pull a magazine issue into the app without leaving for a browser and uploading by hand. diff --git a/TODO.md b/TODO.md index 66eeebd..8adc75c 100644 --- a/TODO.md +++ b/TODO.md @@ -688,8 +688,8 @@ casting. The original PDF remains the visual source of truth while approved arti *(sentence-level; clicking a sentence or switching to the original-page view jumps to its source bbox)* - [x] Show article images or page crops during playback, with optional caption reading. *(images shown, matched to reading position; caption text is not separately spoken)* -- [~] Add confidence warnings, extraction diffs, and manual text correction before audio generation. - *(confidence badges + block-level boundary editing done; no extraction diff view or free-text correction)* +- [x] Add confidence warnings, extraction diffs, and manual text correction before audio generation. + *(confidence badges, boundary editing, per-region text editing, and (2026-09-29) an Edited mark with a word diff against the recognised text and one-click restore)* - [ ] **Redesign the magazine contents view** (`#s-magazine`, the article list for one issue). Today it is a flat list of title + page range + badge + Review button, which shows nothing of the issue itself: no page, no opening text, no figure, no sign of which articles already have audio. Requested directions, none decided: diff --git a/core/magazine.py b/core/magazine.py index 2536823..b0f9ea0 100644 --- a/core/magazine.py +++ b/core/magazine.py @@ -126,6 +126,9 @@ class Block: # (clean_up_regions). Part of the model so every path that rebuilds blocks keeps it -- and does # not crash on it (drawing a region did, "unexpected keyword argument 'original_text'"). original_text: str = "" + # The text as recognised, kept when the reader first edits it by hand (set_region_text), so the + # review can show what changed and put it back. Empty = never edited. + ocr_text: str = "" # box_manual: the reviewer placed or resized this box, so snap_text_boxes never moves it. # box_snapped: snap_text_boxes fitted it to its text. Both are model fields for the same # reason as original_text: every path that rebuilds blocks with Block(**b) must accept them. @@ -134,7 +137,7 @@ class Block: def to_json(self) -> dict[str, Any]: data = asdict(self) - for optional in ("original_text", "box_manual", "box_snapped"): + for optional in ("original_text", "ocr_text", "box_manual", "box_snapped"): if not data[optional]: del data[optional] return data @@ -4484,6 +4487,7 @@ def update_region(doc_dir: Path, layout: dict[str, Any], page_no: int, block_id: if same_top: new_text = prefer_drop_cap_repair(new_text, old_text) block["text"] = new_text + block.pop("ocr_text", None) # freshly read: the new reading is the original blocks[block_idx] = block layout["blocks"] = blocks return layout, new_text @@ -4774,8 +4778,18 @@ def set_region_text(layout: dict[str, Any], page_no: int, region_id: str, text: if not any(b["id"] == region_id and b["page"] == page_no for b in layout.get("blocks", [])): raise ValueError("Region not found on this page") layout = dict(layout) - layout["blocks"] = [dict(b, text=text, confidence=1.0) if b["id"] == region_id else b - for b in layout.get("blocks", [])] + + def edited(b: dict[str, Any]) -> dict[str, Any]: + before = str(b.get("text") or "") + first_read = b.get("ocr_text") or before + out = dict(b, text=text, confidence=1.0) + if text == first_read: + out.pop("ocr_text", None) # typed back to the original: no longer an edit + else: + out["ocr_text"] = first_read + return out + + layout["blocks"] = [edited(b) if b["id"] == region_id else b for b in layout.get("blocks", [])] return layout diff --git a/static/js/magazine.js b/static/js/magazine.js index c6d7b6d..2ec8571 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -1719,7 +1719,8 @@ function magReviewPageColumn(page, wrapId, interactive) { (interactive ? ' onclick="event.preventDefault();event.stopPropagation();magRowEdit(\'' + b.id + '\')" title="Click to edit"' : '') + '>' + (owner ? 'Article ' + owner.index + ': ' + esc(owner.title) + ' ' : '') + (b.continues_from_previous_page ? 'Continued from previous page ' : '') + - '' + magSpellMarked(b.id, b.text) + '' + + '' + magSpellMarked(b.id, b.text) + '' + + (b.ocr_text ? magEditedChip(b.id, interactive) : '') + '' + (interactive ? magRowActions(b.id, i + 1, i + 1 < regions.length) : '') + ''; }); if (!regions.length) listHtml += '

Nothing detected on this page.

'; @@ -2755,6 +2756,80 @@ async function magReloadReviewNow(message) { if (message) say(message, 'ok'); } +// Word-level difference between the recognised text and the reader's edit. Whitespace-separated words, +// longest-common-subsequence; returns escaped HTML with (only in the original) and (only in +// the edit). Bounded: two very long texts are shown whole rather than freezing the page. +const MAG_DIFF_MAX_WORDS = 1500; +function magWordDiff(before, after) { + const a = String(before || '').split(/\s+/).filter(Boolean); + const b = String(after || '').split(/\s+/).filter(Boolean); + if (a.length > MAG_DIFF_MAX_WORDS || b.length > MAG_DIFF_MAX_WORDS) { + return '' + esc(a.join(' ')) + ' ' + esc(b.join(' ')) + ''; + } + const n = a.length, m = b.length, w = m + 1; + const t = new Uint16Array((n + 1) * w); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + t[i * w + j] = a[i] === b[j] ? t[(i + 1) * w + j + 1] + 1 : Math.max(t[(i + 1) * w + j], t[i * w + j + 1]); + } + } + const out = []; + let i = 0, j = 0; + const push = (kind, word) => { + const last = out[out.length - 1]; + if (last && last.kind === kind) last.words.push(word); else out.push({ kind, words: [word] }); + }; + while (i < n && j < m) { + if (a[i] === b[j]) { push('same', a[i]); i++; j++; } + else if (t[(i + 1) * w + j] >= t[i * w + j + 1]) { push('del', a[i]); i++; } + else { push('ins', b[j]); j++; } + } + while (i < n) push('del', a[i++]); + while (j < m) push('ins', b[j++]); + return out.map((g) => { + const text = esc(g.words.join(' ')); + return g.kind === 'del' ? '' + text + '' : g.kind === 'ins' ? '' + text + '' : text; + }).join(' '); +} + +// Marks a region the reader edited by hand, with a way to see the change and to put the recognised text back. +// Inside a