diff --git a/bindings/python/pdfengine_py.cpp b/bindings/python/pdfengine_py.cpp index 4737fc9..9f85249 100644 --- a/bindings/python/pdfengine_py.cpp +++ b/bindings/python/pdfengine_py.cpp @@ -54,6 +54,12 @@ void get_or_throw(std::expected&& res) { #include #include +// A TJ array number more negative than this is treated as an inter-word space when flattening a +// TJ run to display/edit text (numbers are thousandths-of-em, subtracted from the X position; a +// word space is a large positive gap = large-negative adjustment). extract and replace MUST share +// this constant so the flattened text they produce stays positionally aligned (see P1a redistribution). +static constexpr double kTjSpaceKern = -500.0; + class StreamEditor { public: StreamEditor(const std::string& filepath) : filepath_(filepath) {} @@ -129,15 +135,50 @@ public: } else if (item->type == pdfengine::AstNodeType::HexString) { combinedText += std::string(item->bytesValue.begin(), item->bytesValue.end()); } else if (item->type == pdfengine::AstNodeType::Number) { - if (item->numberValue < -500.0) combinedText += " "; + if (item->numberValue < kTjSpaceKern) combinedText += " "; } } if (!combinedText.empty()) { if (textCount == object_index) { - arrNode->arrayItems.clear(); - auto newStrNode = std::make_shared(pdfengine::AstNodeType::String); - newStrNode->stringValue = new_text; - arrNode->arrayItems.push_back(std::move(newStrNode)); + // P1a β€” preserve TJ positioning. When the edit keeps the SAME length as the + // extracted text (the typo-fix common case), slice new_text back into the + // original string slots and KEEP every kern number, so glyph spacing/kerning + // survives. We mirror extract's reconstruction (a synthetic space per + // large-negative kern) to keep positions aligned; if a synthetic-space + // position was itself edited (can't map cleanly) or the length changed, we + // fall back to a single string at natural advances β€” exactly the old behaviour, + // so this is never worse than before, only better when it aligns. + bool redistributed = false; + if (new_text.size() == combinedText.size()) { + std::vector> assign; + size_t pos = 0; bool ok = true; + for (const auto& item : arrNode->arrayItems) { + if (item->type == pdfengine::AstNodeType::String || + item->type == pdfengine::AstNodeType::HexString) { + size_t L = (item->type == pdfengine::AstNodeType::HexString) + ? item->bytesValue.size() : item->stringValue.size(); + assign.emplace_back(item.get(), new_text.substr(pos, L)); + pos += L; + } else if (item->type == pdfengine::AstNodeType::Number && + item->numberValue < kTjSpaceKern) { + if (pos >= new_text.size() || new_text[pos] != ' ') { ok = false; break; } + pos += 1; // consume the synthetic space; keep the kern number as-is + } + } + if (ok && pos == new_text.size()) { + for (auto& [node, content] : assign) { + node->type = pdfengine::AstNodeType::String; + node->stringValue = content; + } + redistributed = true; + } + } + if (!redistributed) { + arrNode->arrayItems.clear(); + auto newStrNode = std::make_shared(pdfengine::AstNodeType::String); + newStrNode->stringValue = new_text; + arrNode->arrayItems.push_back(std::move(newStrNode)); + } modified = true; break; } diff --git a/docs/PENDING_WORK_GAP_ANALYSIS.md b/docs/PENDING_WORK_GAP_ANALYSIS.md new file mode 100644 index 0000000..1ba071b --- /dev/null +++ b/docs/PENDING_WORK_GAP_ANALYSIS.md @@ -0,0 +1,110 @@ +# Pending Work β€” Blast-Radius Gap Analysis (will any of it break reflow / existing features?) + +> Status: **review document.** Classifies every PENDING item by whether it can affect the working +> reflow editor or other shipped features. No code changed by this doc. +> +> **Verified facts this analysis rests on:** +> - The Raw Text subsystem (qpdf layer + `Lexer`/`ContentParser`/`AstSerializer`) has **zero +> references in [`pdfium_document.cpp`](../engine/src/parser/pdfium_document.cpp)** (grep: 0 hits). +> Reflow uses the PDFium `FPDF_*` path; Raw Text uses the qpdf path. **Different code, no shared +> functions.** +> - Reflow `align` only special-cases `"justify"` +> ([`pdfium_document.cpp:2969`](../engine/src/parser/pdfium_document.cpp#L2969)); every other value +> already falls through to left. So adding `center`/`right` is **additive** (new branches), not a +> rewrite of the existing path. + +--- + +## Classification key +- 🟒 **Isolated** β€” cannot change a reflow pixel or any other feature; pure enhancement. +- 🟑 **Shared infra** β€” touches code reflow also uses, but additively; low risk with the stated guard. +- 🟠 **Touches reflow code** β€” modifies the reflow path itself; additive but must be gate-verified. +- πŸ”΄ **Changes reflow algorithm** β€” alters wrap/layout output; highest risk; must be opt-in + gated. + +--- + +## A. Raw Text companion (P1–P3) + +| Item | Files | Class | Why | +|------|-------|-------|-----| +| **P1a** Preserve `TJ` positioning | `pdfengine_py.cpp` (StreamEditor), qpdf serializer | 🟒 Isolated | Separate subsystem; reflow never calls it. Pure correctness gain for Raw Text. | +| **P1b** Encoding-aware byte mapping | `pdfengine_py.cpp`, `documents.py` (text_objects endpoints) | 🟒 Isolated | Same β€” Raw Text path only. Reflow does its own font emission independently. | +| **P2b** Exact hit-boxes | `StreamEditLayer.tsx` | 🟒 Isolated | Frontend, Raw Text layer only. No other component imports it. | +| **P3a** Single extract+replace call | `pdfengine_py.cpp` | 🟒 Isolated | Internal Raw Text perf; no external surface change. | +| **P3b** Real space-width threshold | `pdfengine_py.cpp` (TJ heuristic) | 🟒 Isolated | Only affects Raw Text's extracted-text display. | +| **P2a** Undo/redo + version integration | `documents.py`, gateway `document_store` versioning | 🟑 Shared infra | Routes Raw Text saves through the **same** version/undo mechanism reflow uses. It *reuses* that mechanism, doesn't modify it β€” but it touches shared gateway state, so it needs the guard below. | + +**P2a guard.** The shared piece is the gateway's "mint a new documentId + push undo entry" helper. P2a should **call** that helper unchanged (the same way the reflow commit does), not refactor it. As long as the helper's signature/behavior is untouched, reflow's versioning is byte-identical. **Verification:** after P2a, do a reflow edit β†’ undo β†’ redo and confirm it still works exactly as today; then a Raw Text edit β†’ undo β†’ redo. + +**Bottom line for Raw Text:** P1, P2b, P3 are **pure enhancements, zero reflow risk** (proven by the zero-shared-code fact). P2a is the only one touching shared infra, and only by *reusing* it. + +--- + +## B. Reflow editor polish (deferred items) + +| Item | Files | Class | Why | +|------|-------|-------|-----| +| **Center / right alignment** | `pdfium_document.cpp` (emit/justify), `ParagraphEditor.tsx` (align inference) | 🟠 Touches reflow | Adds `align == "center"/"right"` branches alongside the existing justify branch. Existing left/justify paths are unchanged **if** the new branches are purely additive. | +| **IME / composed input** | `ParagraphEditor.tsx` (input handling) | 🟠 Touches reflow | Adds `compositionstart/end` handling to the editor. Risk: interfering with the existing `onInput`/caret logic. Additive, but in the live-edit hot path. | +| **Live-glyph canvas (Tier B)** | `pdfiumEngine.ts`, `ParagraphEditor.tsx`, new WASM export | 🟠 Touches reflow (preview) | A *new* preview render mode. The existing region-RGBA preview must remain the default/fallback; Tier B added behind a flag. If added as a parallel path, the current preview is untouched. | +| **Knuth-Plass + hyphenation (Stage 2)** | `pdfium_document.cpp` (line-break loop) | πŸ”΄ Changes algorithm | Replaces/augments the greedy break ([:2860](../engine/src/parser/pdfium_document.cpp#L2860)). This **changes wrap output** for many paragraphs β€” by design. Highest regression surface. | +| **Pages-panel thumbnail bridging** | `PDFViewer.tsx`/thumbnail component, `CanvasLayer.tsx` | 🟒 Isolated | Applies the existing commit-bridge anti-flash to thumbnails. Doesn't touch layout or the main canvas logic. | + +**Notes on the 🟠/πŸ”΄ items:** +- **Center/right + IME** are *additive* β€” the existing left/justify wrap and the existing input path + stay on their current branches. Risk is "did I accidentally change the shared branch," caught by + the overlay-diff gate (no-op reflow on left/justify paragraphs must stay byte-identical). +- **Tier B** is safe *if* implemented as a parallel render mode with the region preview as fallback. + If it *replaces* the region preview, that's a πŸ”΄ β€” don't do that. +- **Knuth-Plass is the only true πŸ”΄.** It deliberately changes how every paragraph wraps. It must be + **opt-in** (a setting/flag), default off, with the greedy path untouched as the default. Without + that guard it would change the look of every existing reflow. + +--- + +## C. Housekeeping + +| Item | Files | Class | Why | +|------|-------|-------|-----| +| Quiet Item A logs (`warn`β†’`debug`) | `pdfium_document.cpp` | 🟒 Isolated | Log **level** only. Zero behavior change β€” the adoption logic stays; it just stops printing on every edit. | +| Commit the build | git | 🟒 Isolated | Version control only. | + +--- + +## D. Summary β€” what's a pure enhancement vs what needs care + +**Pure enhancements (cannot break reflow or other features):** +- All of Raw Text **P1, P2b, P3** (separate subsystem β€” proven zero shared code) +- **Pages-panel thumbnail bridging** +- **Quiet logs**, **commit** + +**Reuses shared infra (safe with the stated guard):** +- Raw Text **P2a** (undo) β€” *call* the existing version helper, don't modify it + +**Touches reflow code, additive β€” verify with the overlay-diff gate:** +- **Center/right alignment**, **IME**, **Live-glyph canvas (as a parallel mode)** + +**Changes reflow output by design β€” DROPPED (decision 2026-06-17):** +- **Knuth-Plass / hyphenation (Stage 2)** β€” **NOT planned.** It wouldn't corrupt output, but it would + change where lines break (optimal vs greedy), which works *against* this editor's fidelity goal + (edited paragraphs could wrap inconsistently with surrounding untouched text). Greedy wrap already + matches Word/Docs behavior and looks correct for normal documents. Decision: skip it β€” pure added + risk/complexity for a benefit users won't notice. Never started; nothing to remove. + +**The universal guard:** any 🟠/πŸ”΄ item ships only after the overlay-diff gate +([`tests/edits/_reflow_repro.py overlay`](../tests/edits/_reflow_repro.py)) shows the no-op reflow +diff on left/justify paragraphs is unchanged from today's baseline (lines path ~1.7–2.9%). That gate +is exactly what proved B1 safe in the last build. + +--- + +## E. Recommended safe order +1. **Housekeeping** (quiet logs + commit) β€” 🟒, do now. +2. **Raw Text P1** (TJ positioning + encoding) β€” 🟒, highest value, zero reflow risk. +3. **Raw Text P2b + P3** β€” 🟒, finish the companion. +4. **Pages-panel thumbnail bridge** β€” 🟒, quick polish. +5. **Raw Text P2a** (undo) β€” 🟑, with the reuse guard. +6. **Center/right align**, then **IME** β€” 🟠, gate-verified, only if users need them. +7. **Knuth-Plass** β€” πŸ”΄, only if wrap quality is ever a complaint; opt-in flag. + +Steps 1–4 are entirely non-breaking. Nothing past step 4 is required for the app to be solid. diff --git a/docs/REFLOW_BUG_AND_RAWTEXT_HARDENING_PLAN.md b/docs/REFLOW_BUG_AND_RAWTEXT_HARDENING_PLAN.md new file mode 100644 index 0000000..fdebdf0 --- /dev/null +++ b/docs/REFLOW_BUG_AND_RAWTEXT_HARDENING_PLAN.md @@ -0,0 +1,198 @@ +# Reflow bug-fixes + Raw Text hardening β€” Reworked Gap Analysis & Implementation Plan + +> Status: **proposal for review** β€” no code changed yet. +> Covers three problems, two of which share a single root cause: +> 1. Reflow **"bulge / merge"** (original glyphs left under the new text). +> 2. Reflow **font-changes-on-edit** (edited run re-renders in a wrong/substitute font, spacing collapses). +> 3. **Raw Text / StreamEditor** companion is half-baked (keep reflow primary; make this safe). +> +> **Key finding (reworked):** problems #1 and #2 are the *same underlying defect* β€” an +> **incomplete / mismatched `objectIndices`** for the edited (sub-)paragraph. Fixing that one thing +> fixes both. Details in Β§0. + +--- + +## Β§0 β€” Unified root cause (why #1 and #2 are the same bug) + +The `reflow_paragraph` op uses `objectIndices` (the list of page-object indices that make up the +paragraph) for **four** things in [`pdfium_document.cpp`](../engine/src/parser/pdfium_document.cpp): + +| Use | Line | What breaks if an index is missing | +|-----|------|-------------------------------------| +| Resolve the original embedded **font handle** (`resolveOrigFont`) | [2718](../engine/src/parser/pdfium_document.cpp#L2718) | Font not found β†’ `loadEmissionFont` falls to **base-14 substitute** β†’ **font changes** | +| Re-embed from the paragraph's **own font bytes** (`getFontDataFromObjects`) | [2044](../engine/src/parser/pdfium_document.cpp#L2044) | No bytes β†’ same substitute fallback | +| **Push-down** skip test | [2885](../engine/src/parser/pdfium_document.cpp#L2885) | Object treated as "below" and shifted, or left in place | +| **Delete** the old paragraph glyphs | [2902](../engine/src/parser/pdfium_document.cpp#L2902) | Object not deleted β†’ **original glyphs remain under new text = bulge/merge** | + +`objectIndices` is assembled on the frontend in `computeLayout` +([`ParagraphEditor.tsx:80`](../frontend/src/viewer/ParagraphEditor.tsx#L80)) from each run's +`object_indices`, which the engine populates only for glyphs whose **`pageObjectIndex != -1`** +([`pdfium_document.cpp:1254`](../engine/src/parser/pdfium_document.cpp#L1254)). When a glyph maps to +`-1` (it can, depending on PDFium's parse state for that load β€” and that state shifts after a prior +edit re-serializes the stream), its object is omitted β†’ the table above fires. + +**Therefore:** +- A missing index that happened to be a *font-bearing* object of the run β†’ **font substitution** (#2). +- A missing index that was a *glyph* object of the paragraph β†’ **leftover overlap** (#1). +- Both are **intermittent** for the same reason: `pageObjectIndex == -1` is load-dependent. + +There is **one additional, frontend-only trigger for #2**: when `contentEditable` text is edited, the +browser can drop the run's `data-fid`/`data-advances` (split/merged text node). `extractFlatRuns` +then falls back to `dominantFid` +([`ParagraphEditor.tsx:113`](../frontend/src/viewer/ParagraphEditor.tsx#L113)) β€” tagging the body +text with the *label's* font (e.g. the bold "Languages:" font) β†’ wrong font even when indices are +complete. This is why the **whole body** of a bullet can change font, not just the edited chars. + +So #2 has **two** triggers: engine-side (missing index β†’ no original font) and frontend-side +(lost `data-fid` β†’ wrong fid). The plan addresses both. + +--- + +## Β§1 β€” Confirmed secondary defects (found while tracing) + +- **D-LEAK β€” WASM document-handle leak.** `wasmFreeDocument` + ([`pdfiumEngine.ts:135`](../frontend/src/lib/pdfiumEngine.ts#L135)) is **never called**, and a new + `documentId` is minted per edit ([`PDFViewer.tsx:95`](../frontend/src/viewer/PDFViewer.tsx#L95)), + so each edit loads another full PDF copy into the WASM heap that is never freed. Unbounded growth + over a session (a demo) β†’ possible late-allocation corruption. Confirmed defect. +- **D-DIVERGE β€” preview vs commit input divergence.** Preview can send `origLines`; commit always + sends flat runs ([`ParagraphEditor.tsx:297`](../frontend/src/viewer/ParagraphEditor.tsx#L297) vs + [:430](../frontend/src/viewer/ParagraphEditor.tsx#L430)). Designed to converge; structurally fragile. + +--- + +## Β§2 β€” Raw Text / StreamEditor gap analysis (companion, stays secondary) + +Pipeline (confirmed): qpdf extracts the decoded page stream β†’ lex/parse to `Tj`/`TJ`/`'` ops β†’ +replace one op's string bytes β†’ re-serialize β†’ qpdf writes back +([`pdfengine_py.cpp:57`](../bindings/python/pdfengine_py.cpp#L57), +[`documents.py:747`](../gateway/app/routers/documents.py#L747), +[`StreamEditLayer.tsx`](../frontend/src/viewer/StreamEditLayer.tsx)). + +| # | Gap | Where | Sev | Effect | +|---|-----|-------|-----|--------| +| G1 | `TJ` positioning destroyed (kerning numbers dropped) | [`pdfengine_py.cpp:137`](../bindings/python/pdfengine_py.cpp#L137) | High | Kerned runs cram / overrun after edit | +| G2 | No advance/width compensation | engine replace | High | Length change overflows | +| G3 | Encoding via `latin-1` round-trip | [`documents.py:817`](../gateway/app/routers/documents.py#L817) | High | Chars outside font subset β†’ notdef/blank | +| G4 | Bypasses permission/encryption gating | both endpoints | Med | Edits a doc the app would block | +| G5 | Not wired to undo/redo | endpoints | Med | Raw edits invisible to undo | +| G6 | Approx hit-boxes (`lenΓ—sizeΓ—0.5`) | [`StreamEditLayer.tsx:113`](../frontend/src/viewer/StreamEditLayer.tsx#L113) | Low | Misaligned click targets | +| G7 | Full-doc temp round-trip per call | both endpoints | Low | Slow; double parse | +| G8 | TJ space heuristic `num < -500` | [`pdfengine_py.cpp:132`](../bindings/python/pdfengine_py.cpp#L132) | Low | Mis-spaced extracted text | + +--- + +## Β§3 β€” Reworked implementation plan + +Ordered into batches by **blast radius on the working reflow editor**. Batch 1 cannot change a +reflowed pixel. Batch 2 touches reflow code but is engineered + gate-verified to be a no-op on every +paragraph that works today. Batch 3 is the Raw Text companion (separate code path). + +### Blast-radius matrix + +| Item | File(s) | Reflow output affected? | Existing features affected? | +|------|---------|-------------------------|------------------------------| +| **A** Instrument indices | `pdfium_document.cpp` | **No** (log only) | No | +| **C** WASM free-on-change | `PDFViewer.tsx`, `pdfiumEngine.ts` | **No** (memory only) | No | +| **F2** Preserve `data-fid` on edit | `ParagraphEditor.tsx` | Fixes wrong-font; no change to correct runs | No | +| **P0** Raw Text beta + perms | `documents.py`, rail/toolbar | **No** (separate path) | Adds 403 on restricted docs only | +| **B1** Geometric delete + font backstop | `pdfium_document.cpp` | **Only the buggy case** | No regression (gate-proven) | +| **D** Converge preview/commit | `ParagraphEditor.tsx` | Behavior-preserving | No | +| **P1+** Raw Text correctness | engine/gateway | **No** | No | + +--- + +### BATCH 1 β€” fully isolated (ship first) + +**A β€” Instrument the unified root cause.** In the `reflow_paragraph` branch, after `objectIndices` +is parsed and the page is loaded (~[2680](../engine/src/parser/pdfium_document.cpp#L2680)), compute +the union bbox of the supplied indices and `spdlog::warn` (1) every page text object fully inside it +but **not** in `objectIndices` (β†’ confirms #1) and (2) every run whose `resolveOrigFont` returns +`nullptr` (β†’ confirms #2 engine-side). Log only; nothing moves. *Effect on reflow: none.* + +**C β€” Fix the WASM leak (D-LEAK).** Track `prevDocumentIdRef`; in the existing `[documentId]` effect +in [`PDFViewer.tsx`](../frontend/src/viewer/PDFViewer.tsx#L100) call `wasmFreeDocument(prevId)` on +change, and free the current id on unmount. The preview loader re-loads on demand, so this only +reclaims memory. *Effect on reflow output: none.* + +**F2 β€” Preserve `data-fid`/`data-color`/`data-size` on edited text (frontend trigger for #2).** +Today an edited text node can lose its span attributes and `extractFlatRuns` falls back to +`dominantFid` ([`ParagraphEditor.tsx:113`](../frontend/src/viewer/ParagraphEditor.tsx#L113)). +Fix: on `onInput`, normalize the editor so every text node is wrapped in (or re-inherits) its +owning span's `data-fid`/`data-size`/`data-color`; OR in `extractFlatRuns`, walk up to the nearest +ancestor carrying `data-fid` instead of using the first parent only, and only fall back to +`dominantFid` when truly none exists. *Effect on reflow: edited runs keep their true font; runs that +already resolve correctly are unchanged.* This is the **safe half of the font-change fix** and is +frontend-only (no engine risk). + +--- + +### BATCH 2 β€” touches reflow code (engineered no-op on working paragraphs; gate-verified) + +**B1 β€” Geometric backstop for BOTH deletion and font resolution (the core fix for #1 and #2 +engine-side).** Build a `paragraphSet` = supplied `objectIndices` **plus** any *text* object whose +bbox is **fully contained** in the union bbox of the supplied indices. Use `paragraphSet` everywhere +the op currently uses `objectIndices`: +- **Deletion** ([2902](../engine/src/parser/pdfium_document.cpp#L2902)) β†’ removes the stray glyph + (fixes the bulge/merge #1). +- **Push-down skip** ([2885](../engine/src/parser/pdfium_document.cpp#L2885)) β†’ the stray glyph is + no longer pushed. +- **`resolveOrigFont`** ([2718](../engine/src/parser/pdfium_document.cpp#L2718)) and + **`getFontDataFromObjects`** ([2044](../engine/src/parser/pdfium_document.cpp#L2044)) β†’ search the + wider set, so the original embedded font is found even when the model omitted that index + (fixes font-substitution #2 engine-side). + +**Why safe on working paragraphs:** when the supplied indices are already complete (every correct +case today), no extra object is contained in the union bbox β†’ `paragraphSet == objectIndices` +exactly β†’ identical delete-set, push-down, font handle, and output. The set only grows in the +precise failure case being fixed. Containment is **fully-inside-bbox + `FPDF_PAGEOBJ_TEXT` only**, so +we never touch a neighbouring paragraph, image, or rule line. + +**Verification (mandatory before merge):** +1. Overlay-diff gate [`tests/edits/_reflow_repro.py`](../tests/edits/_reflow_repro.py) on all existing + fixtures **before vs after** β†’ diffs identical (proves zero regression). +2. New fixture: drop one entry from `objectIndices` β†’ assert (a) no residual glyph in the band and + (b) the re-emitted run uses the original font (compare against the complete-index render). + +**D β€” Converge preview & commit input (D-DIVERGE).** Route `commit`'s payload through the same +`buildOpJson` (edited branch) the live preview uses, so the committed render equals the last preview +frame by construction. Behavior-preserving (edited preview and commit already send equivalent flat +runs). *Effect on reflow: none expected; reinforces the commit-bridge.* + +--- + +### BATCH 3 β€” Raw Text companion (separate path; reflow stays primary) + +**P0 (do with Batch 1) β€” beta label + permission gating.** In both `text_objects` endpoints +([`documents.py:747`](../gateway/app/routers/documents.py#L747), +[:794](../gateway/app/routers/documents.py#L794)) read `perms = doc_info.get("permissions") or {}` +and `raise HTTPException(403)` when `canModify is False`, mirroring +[`edits.py:336-343`](../gateway/app/routers/edits.py#L336). Label the rail/toolbar tool +"Raw Text (beta)" + add the "no reflow" caveat. *Effect on reflow: none.* + +**P1 β€” correctness:** preserve `TJ` positioning (replace only string elements, keep numbers; adjust +trailing kern on length change) for G1/G2; encoding-aware byte mapping with a clear rejection toast +for out-of-subset chars for G3. + +**P2 β€” integration:** route Raw Text saves through the same document-version/undo path as reflow +(G5); draw exact hit-boxes from the returned `tm`+metrics (G6). + +**P3 β€” polish:** single extract+replace call (G7); real space-width threshold (G8). + +--- + +## Β§4 β€” Recommended execution order + +1. **Batch 1: A + C + F2 + P0.** Isolated, immediately shippable. A starts capturing confirmation + data on the next occurrence; C fixes the leak; **F2 already eliminates the most visible + font-change case (wrong fid on edited bullets) with zero engine risk**; P0 makes Raw Text safe. +2. **When A confirms the engine-side trigger** (leftover object and/or `resolveOrigFont == null`) β†’ + **B1**, gated by overlay-diff before/after. This closes both the bulge (#1) and the engine-side + font substitution (#2). +3. **D** as consolidation. +4. **Raw Text P1+** as capacity allows. + +**Guard rail:** we do not change reflow *layout* code (B1) until Item A's logs show a non-zero +leftover/`null`-font count on a real repro. If they stay zero, F2 + C already cover the observed +symptoms and we pivot to D-LEAK/D-DIVERGE before touching layout. Nothing here changes the reflow +editor's primacy. diff --git a/engine/src/parser/pdfium_document.cpp b/engine/src/parser/pdfium_document.cpp index 37b08d6..15b1dd5 100644 --- a/engine/src/parser/pdfium_document.cpp +++ b/engine/src/parser/pdfium_document.cpp @@ -2683,6 +2683,51 @@ std::expected PdfiumDocument::applyEdits(const std::string& e return std::unexpected(EngineError::Unknown); } + // --- Geometric backstop (fixes BOTH the "bulge/merge" and the font-substitution bug) --- + // The frontend-supplied objectIndices can omit a paragraph object when its glyphs map to + // pageObjectIndex == -1 (load-dependent, and the map shifts after a prior edit re-serializes + // the stream). A missing index means that object is (a) NOT deleted -> its glyphs stay UNDER + // the new text ("bulge/merge"), and (b) NOT seen by resolveOrigFont/getFontDataFromObjects + // -> the run re-emits in a base-14 substitute ("font changes on edit"). Backstop: widen the + // working set to any TEXT object whose bbox is FULLY inside the union bbox of the supplied + // indices. Conservative (contained text only) so a neighbouring paragraph / image / rule line + // is never touched. When the supplied indices are already complete (the correct common case) + // NO extra object is contained -> paragraphSet == objectIndices -> byte-identical behaviour. + std::vector paragraphSet = objectIndices; + { + float ul = 0, ub = 0, ur = 0, ut = 0; bool haveUnion = false; + for (int idx : objectIndices) { + FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx); + if (!o) continue; + float l = 0, b = 0, r = 0, t = 0; + if (!FPDFPageObj_GetBounds(o, &l, &b, &r, &t)) continue; + if (!haveUnion) { ul = l; ub = b; ur = r; ut = t; haveUnion = true; } + else { // plain comparisons (Windows headers #define min/max macros) + if (l < ul) ul = l; if (b < ub) ub = b; + if (r > ur) ur = r; if (t > ut) ut = t; + } + } + if (haveUnion) { + const float eps = 0.5f; // tolerate sub-pixel bbox slop + int nObjs = FPDFPage_CountObjects(page); + int adopted = 0; + for (int k = 0; k < nObjs; ++k) { + if (std::find(objectIndices.begin(), objectIndices.end(), k) != objectIndices.end()) continue; + FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, k); + if (!o || FPDFPageObj_GetType(o) != FPDF_PAGEOBJ_TEXT) continue; + float l = 0, b = 0, r = 0, t = 0; + if (!FPDFPageObj_GetBounds(o, &l, &b, &r, &t)) continue; + if (l >= ul - eps && b >= ub - eps && r <= ur + eps && t <= ut + eps) { + paragraphSet.push_back(k); + adopted++; + spdlog::debug("reflow_paragraph: adopted leftover text object idx={} inside paragraph bbox (not in objectIndices) -> prevents bulge/merge + font substitution", k); + } + } + if (adopted > 0) + spdlog::debug("reflow_paragraph: geometric backstop adopted {} object(s) the model omitted", adopted); + } + } + // Resolve + load each run's emission font (subset over that run's codepoints). std::vector runFonts(runs.size()); auto toCodepoints = [](const std::string& s) { @@ -2717,7 +2762,7 @@ std::expected PdfiumDocument::applyEdits(const std::string& e // -> "Cuwi Le,e"). The handle stays valid because `page` is open through emission. auto resolveOrigFont = [&](const std::string& fid) -> FPDF_FONT { const std::string expected = baseNameFromInternalFontId(fid); - for (int idx : objectIndices) { + for (int idx : paragraphSet) { FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx); if (!o || FPDFPageObj_GetType(o) != FPDF_PAGEOBJ_TEXT) continue; FPDF_FONT fo = FPDFTextObj_GetFont(o); @@ -2733,7 +2778,7 @@ std::expected PdfiumDocument::applyEdits(const std::string& e std::unordered_map fontByFid; for (const auto& rs : runs) { if (!fontByFid.count(rs.internalFontId)) - fontByFid[rs.internalFontId] = loadEmissionFont(pageIndex, rs.internalFontId, rs.fontSize, fontCps[rs.internalFontId], objectIndices, resolveOrigFont(rs.internalFontId)); + fontByFid[rs.internalFontId] = loadEmissionFont(pageIndex, rs.internalFontId, rs.fontSize, fontCps[rs.internalFontId], paragraphSet, resolveOrigFont(rs.internalFontId)); } for (size_t ri = 0; ri < runs.size(); ++ri) runFonts[ri] = fontByFid[runs[ri].internalFontId]; @@ -2882,7 +2927,7 @@ std::expected PdfiumDocument::applyEdits(const std::string& e int nObjs = FPDFPage_CountObjects(page); int pushed = 0; for (int k = 0; k < nObjs; ++k) { - if (std::find(objectIndices.begin(), objectIndices.end(), k) != objectIndices.end()) continue; + if (std::find(paragraphSet.begin(), paragraphSet.end(), k) != paragraphSet.end()) continue; FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, k); if (!o) continue; float l = 0, bo = 0, rr = 0, tt = 0; @@ -2896,10 +2941,12 @@ std::expected PdfiumDocument::applyEdits(const std::string& e spdlog::info("reflow_paragraph: lines {}->{}, deltaH={}, pushed {} objects", oldLineCount, newLineCount, deltaH, pushed); } - // Delete the old paragraph objects (descending so indices stay valid). - std::sort(objectIndices.begin(), objectIndices.end(), std::greater()); - int minIndex = objectIndices.back(); - for (int idx : objectIndices) { + // Delete the old paragraph objects (descending so indices stay valid). Uses paragraphSet + // so any object the model omitted but that the geometric backstop adopted is also removed + // (otherwise its glyphs would remain UNDER the freshly-emitted text -> "bulge/merge"). + std::sort(paragraphSet.begin(), paragraphSet.end(), std::greater()); + int minIndex = paragraphSet.back(); + for (int idx : paragraphSet) { FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx); if (o) { FPDFPage_RemoveObject(page, o); FPDFPageObj_Destroy(o); } } @@ -2927,6 +2974,14 @@ std::expected PdfiumDocument::applyEdits(const std::string& e std::vector adv; // advance (PDF units) of each char in lineText double lineFontSize = 0.0; double x = (li < lineX.size()) ? lineX[li] : columnLeft; // exact source left when provided + // Center/right alignment: shift the whole line within the column. Only on the + // greedy/re-wrapped path (no provided lineX) so the unchanged "lines" path and the + // existing left/justify behaviour are byte-identical. justifyThis is already false + // for center/right (it only triggers on align=="justify"), so no gap distribution. + if (li >= lineX.size()) { + if (align == "right") x = columnLeft + (columnWidth - naturalW); + else if (align == "center") x = columnLeft + (columnWidth - naturalW) / 2.0; + } for (size_t k = 0; k < lw.size(); ++k) { size_t wi = lw[k]; if (k > 0) { diff --git a/frontend/public/pdfengine.mjs b/frontend/public/pdfengine.mjs index e7971e1..374a4b2 100644 --- a/frontend/public/pdfengine.mjs +++ b/frontend/public/pdfengine.mjs @@ -1,2 +1,2 @@ -async function Module(moduleArg={}){var moduleRtn;var Module=moduleArg;var ENVIRONMENT_IS_WEB=!!globalThis.window;var ENVIRONMENT_IS_WORKER=!!globalThis.WorkerGlobalScope;var ENVIRONMENT_IS_NODE=globalThis.process?.versions?.node&&globalThis.process?.type!="renderer";if(ENVIRONMENT_IS_NODE){const{createRequire}=await import("node:module");var require=createRequire(import.meta.url)}var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var _scriptName=import.meta.url;var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require("node:fs");if(_scriptName.startsWith("file:")){scriptDirectory=require("node:path").dirname(require("node:url").fileURLToPath(_scriptName))+"/"}readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename);return ret};readAsync=async(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename,binary?undefined:"utf8");return ret};if(process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{readAsync=async url=>{var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var isFileURI=filename=>filename.startsWith("file://");class EmscriptenEH{}class EmscriptenSjLj extends EmscriptenEH{}var readyPromiseResolve,readyPromiseReject;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;HEAP8=new Int8Array(b);HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);HEAPU16=new Uint16Array(b);HEAP32=new Int32Array(b);HEAPU32=new Uint32Array(b);HEAPF32=new Float32Array(b);HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;wasmExports["e"]()}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(onPostRuns)}function abort(what){Module["onAbort"]?.(what);what=`Aborted(${what})`;err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject?.(e);throw e}var wasmBinaryFile;function findWasmBinary(){if(Module["locateFile"]){return locateFile("pdfengine.wasm")}return new URL("pdfengine.wasm",import.meta.url).href}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){if(!binary&&!ENVIRONMENT_IS_NODE){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation")}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){var imports={a:wasmImports};return imports}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;assignWasmExports(wasmExports);updateMemoryViews();return wasmExports}function receiveInstantiationResult(result){return receiveInstance(result["instance"])}var info=getWasmImports();if(Module["instantiateWasm"]){return new Promise((resolve,reject)=>{Module["instantiateWasm"](info,(inst,mod)=>{resolve(receiveInstance(inst,mod))})})}wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var HEAP16;var HEAP32;var HEAP64;var HEAP8;var HEAPF32;var HEAPF64;var HEAPU16;var HEAPU32;var HEAPU64;var HEAPU8;var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);function getValue(ptr,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":return HEAP8[ptr];case"i8":return HEAP8[ptr];case"i16":return HEAP16[ptr>>1];case"i32":return HEAP32[ptr>>2];case"i64":return HEAP64[ptr>>3];case"float":return HEAPF32[ptr>>2];case"double":return HEAPF64[ptr>>3];case"*":return HEAPU32[ptr>>2];default:abort(`invalid type for getValue: ${type}`)}}var noExitRuntime=true;function setValue(ptr,value,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":HEAP8[ptr]=value;break;case"i8":HEAP8[ptr]=value;break;case"i16":HEAP16[ptr>>1]=value;break;case"i32":HEAP32[ptr>>2]=value;break;case"i64":HEAP64[ptr>>3]=BigInt(value);break;case"float":HEAPF32[ptr>>2]=value;break;case"double":HEAPF64[ptr>>3]=value;break;case"*":HEAPU32[ptr>>2]=value;break;default:abort(`invalid type for setValue: ${type}`)}}var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var uncaughtExceptionCount=0;var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);uncaughtExceptionCount++;abort()};var __abort_js=()=>abort("");var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var oldHeapSize=wasmMemory.buffer.byteLength;var pages=(size-oldHeapSize+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63;i++}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=globalThis.TextDecoder&&new TextDecoder;var findStringEnd=(heapOrArray,idx,maxBytesToRead,ignoreNul)=>{var maxIdx=idx+maxBytesToRead;if(ignoreNul)return maxIdx;while(heapOrArray[idx]&&!(idx>=maxIdx))++idx;return idx};var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead,ignoreNul)=>{var endPtr=findStringEnd(heapOrArray,idx,maxBytesToRead,ignoreNul);if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead,ignoreNul)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead,ignoreNul):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["print"])out=Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].shift()()}}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;Module["setValue"]=setValue;Module["getValue"]=getValue;var _loadDocument,_renderPage,_freeDocument,_engineBuildInfo,_engineHasSkia,_getDocumentFonts,_getPageFonts,_getPageTextJson,_malloc,_free,__emscripten_stack_restore,__emscripten_stack_alloc,_emscripten_stack_get_current,memory,__indirect_function_table,wasmMemory;function assignWasmExports(wasmExports){_loadDocument=Module["_loadDocument"]=wasmExports["f"];_renderPage=Module["_renderPage"]=wasmExports["g"];_freeDocument=Module["_freeDocument"]=wasmExports["h"];_engineBuildInfo=Module["_engineBuildInfo"]=wasmExports["i"];_engineHasSkia=Module["_engineHasSkia"]=wasmExports["j"];_getDocumentFonts=Module["_getDocumentFonts"]=wasmExports["k"];_getPageFonts=Module["_getPageFonts"]=wasmExports["l"];_getPageTextJson=Module["_getPageTextJson"]=wasmExports["m"];_malloc=Module["_malloc"]=wasmExports["n"];_free=Module["_free"]=wasmExports["o"];__emscripten_stack_restore=wasmExports["p"];__emscripten_stack_alloc=wasmExports["q"];_emscripten_stack_get_current=wasmExports["r"];memory=wasmMemory=wasmExports["d"];__indirect_function_table=wasmExports["__indirect_function_table"]}var wasmImports={a:___cxa_throw,c:__abort_js,b:_emscripten_resize_heap};function run(){preRun();function doRun(){Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve?.(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}var wasmExports;wasmExports=await (createWasm());run();if(runtimeInitialized){moduleRtn=Module}else{moduleRtn=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject})} +async function Module(moduleArg={}){var moduleRtn;var Module=moduleArg;var ENVIRONMENT_IS_WEB=!!globalThis.window;var ENVIRONMENT_IS_WORKER=!!globalThis.WorkerGlobalScope;var ENVIRONMENT_IS_NODE=globalThis.process?.versions?.node&&globalThis.process?.type!="renderer";if(ENVIRONMENT_IS_NODE){const{createRequire}=await import("node:module");var require=createRequire(import.meta.url)}var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var _scriptName=import.meta.url;var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require("node:fs");if(_scriptName.startsWith("file:")){scriptDirectory=require("node:path").dirname(require("node:url").fileURLToPath(_scriptName))+"/"}readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename);return ret};readAsync=async(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename,binary?undefined:"utf8");return ret};if(process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{readAsync=async url=>{var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var isFileURI=filename=>filename.startsWith("file://");class EmscriptenEH{}class EmscriptenSjLj extends EmscriptenEH{}var readyPromiseResolve,readyPromiseReject;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;HEAP8=new Int8Array(b);HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);HEAPU16=new Uint16Array(b);HEAP32=new Int32Array(b);HEAPU32=new Uint32Array(b);HEAPF32=new Float32Array(b);HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;wasmExports["o"]()}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(onPostRuns)}function abort(what){Module["onAbort"]?.(what);what=`Aborted(${what})`;err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject?.(e);throw e}var wasmBinaryFile;function findWasmBinary(){if(Module["locateFile"]){return locateFile("pdfengine.wasm")}return new URL("pdfengine.wasm",import.meta.url).href}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){if(!binary&&!ENVIRONMENT_IS_NODE){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation")}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){var imports={a:wasmImports};return imports}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;assignWasmExports(wasmExports);updateMemoryViews();return wasmExports}function receiveInstantiationResult(result){return receiveInstance(result["instance"])}var info=getWasmImports();if(Module["instantiateWasm"]){return new Promise((resolve,reject)=>{Module["instantiateWasm"](info,(inst,mod)=>{resolve(receiveInstance(inst,mod))})})}wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var HEAP16;var HEAP32;var HEAP64;var HEAP8;var HEAPF32;var HEAPF64;var HEAPU16;var HEAPU32;var HEAPU64;var HEAPU8;var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);function getValue(ptr,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":return HEAP8[ptr];case"i8":return HEAP8[ptr];case"i16":return HEAP16[ptr>>1];case"i32":return HEAP32[ptr>>2];case"i64":return HEAP64[ptr>>3];case"float":return HEAPF32[ptr>>2];case"double":return HEAPF64[ptr>>3];case"*":return HEAPU32[ptr>>2];default:abort(`invalid type for getValue: ${type}`)}}var noExitRuntime=true;function setValue(ptr,value,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":HEAP8[ptr]=value;break;case"i8":HEAP8[ptr]=value;break;case"i16":HEAP16[ptr>>1]=value;break;case"i32":HEAP32[ptr>>2]=value;break;case"i64":HEAP64[ptr>>3]=BigInt(value);break;case"float":HEAPF32[ptr>>2]=value;break;case"double":HEAPF64[ptr>>3]=value;break;case"*":HEAPU32[ptr>>2]=value;break;default:abort(`invalid type for setValue: ${type}`)}}var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var uncaughtExceptionCount=0;var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);uncaughtExceptionCount++;abort()};var __abort_js=()=>abort("");var INT53_MAX=9007199254740992;var INT53_MIN=-9007199254740992;var bigintToI53Checked=num=>numINT53_MAX?NaN:Number(num);function __gmtime_js(time,tmPtr){time=bigintToI53Checked(time);var date=new Date(time*1e3);HEAP32[tmPtr>>2]=date.getUTCSeconds();HEAP32[tmPtr+4>>2]=date.getUTCMinutes();HEAP32[tmPtr+8>>2]=date.getUTCHours();HEAP32[tmPtr+12>>2]=date.getUTCDate();HEAP32[tmPtr+16>>2]=date.getUTCMonth();HEAP32[tmPtr+20>>2]=date.getUTCFullYear()-1900;HEAP32[tmPtr+24>>2]=date.getUTCDay();var start=Date.UTC(date.getUTCFullYear(),0,1,0,0,0,0);var yday=(date.getTime()-start)/(1e3*60*60*24)|0;HEAP32[tmPtr+28>>2]=yday}var isLeapYear=year=>year%4===0&&(year%100!==0||year%400===0);var MONTH_DAYS_LEAP_CUMULATIVE=[0,31,60,91,121,152,182,213,244,274,305,335];var MONTH_DAYS_REGULAR_CUMULATIVE=[0,31,59,90,120,151,181,212,243,273,304,334];var ydayFromDate=date=>{var leap=isLeapYear(date.getFullYear());var monthDaysCumulative=leap?MONTH_DAYS_LEAP_CUMULATIVE:MONTH_DAYS_REGULAR_CUMULATIVE;var yday=monthDaysCumulative[date.getMonth()]+date.getDate()-1;return yday};function __localtime_js(time,tmPtr){time=bigintToI53Checked(time);var date=new Date(time*1e3);HEAP32[tmPtr>>2]=date.getSeconds();HEAP32[tmPtr+4>>2]=date.getMinutes();HEAP32[tmPtr+8>>2]=date.getHours();HEAP32[tmPtr+12>>2]=date.getDate();HEAP32[tmPtr+16>>2]=date.getMonth();HEAP32[tmPtr+20>>2]=date.getFullYear()-1900;HEAP32[tmPtr+24>>2]=date.getDay();var yday=ydayFromDate(date)|0;HEAP32[tmPtr+28>>2]=yday;HEAP32[tmPtr+36>>2]=-(date.getTimezoneOffset()*60);var start=new Date(date.getFullYear(),0,1);var summerOffset=new Date(date.getFullYear(),6,1).getTimezoneOffset();var winterOffset=start.getTimezoneOffset();var dst=(summerOffset!=winterOffset&&date.getTimezoneOffset()==Math.min(winterOffset,summerOffset))|0;HEAP32[tmPtr+32>>2]=dst}var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63;i++}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var __tzset_js=(timezone,daylight,std_name,dst_name)=>{var currentYear=(new Date).getFullYear();var winter=new Date(currentYear,0,1);var summer=new Date(currentYear,6,1);var winterOffset=winter.getTimezoneOffset();var summerOffset=summer.getTimezoneOffset();var stdTimezoneOffset=Math.max(winterOffset,summerOffset);HEAPU32[timezone>>2]=stdTimezoneOffset*60;HEAP32[daylight>>2]=Number(winterOffset!=summerOffset);var extractZone=timezoneOffset=>{var sign=timezoneOffset>=0?"-":"+";var absOffset=Math.abs(timezoneOffset);var hours=String(Math.floor(absOffset/60)).padStart(2,"0");var minutes=String(absOffset%60).padStart(2,"0");return`UTC${sign}${hours}${minutes}`};var winterName=extractZone(winterOffset);var summerName=extractZone(summerOffset);if(summerOffsetperformance.now();var _emscripten_date_now=()=>Date.now();var nowIsMonotonic=1;var checkWasiClock=clock_id=>clock_id>=0&&clock_id<=3;function _clock_time_get(clk_id,ignored_precision,ptime){ignored_precision=bigintToI53Checked(ignored_precision);if(!checkWasiClock(clk_id)){return 28}var now;if(clk_id===0){now=_emscripten_date_now()}else if(nowIsMonotonic){now=_emscripten_get_now()}else{return 52}var nsec=Math.round(now*1e3*1e3);HEAP64[ptime>>3]=BigInt(nsec);return 0}var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var oldHeapSize=wasmMemory.buffer.byteLength;var pages=(size-oldHeapSize+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var ENV={};var getExecutableName=()=>thisProgram||"./this.program";var getEnvStrings=()=>{if(!getEnvStrings.strings){var lang=(globalThis.navigator?.language??"C").replace("-","_")+".UTF-8";var env={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:lang,_:getExecutableName()};for(var x in ENV){if(ENV[x]===undefined)delete env[x];else env[x]=ENV[x]}var strings=[];for(var x in env){strings.push(`${x}=${env[x]}`)}getEnvStrings.strings=strings}return getEnvStrings.strings};var _environ_get=(__environ,environ_buf)=>{var bufSize=0;var envp=0;for(var string of getEnvStrings()){var ptr=environ_buf+bufSize;HEAPU32[__environ+envp>>2]=ptr;bufSize+=stringToUTF8(string,ptr,Infinity)+1;envp+=4}return 0};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var _environ_sizes_get=(penviron_count,penviron_buf_size)=>{var strings=getEnvStrings();HEAPU32[penviron_count>>2]=strings.length;var bufSize=0;for(var string of strings){bufSize+=lengthBytesUTF8(string)+1}HEAPU32[penviron_buf_size>>2]=bufSize;return 0};var _fd_close=fd=>52;var UTF8Decoder=globalThis.TextDecoder&&new TextDecoder;var findStringEnd=(heapOrArray,idx,maxBytesToRead,ignoreNul)=>{var maxIdx=idx+maxBytesToRead;if(ignoreNul)return maxIdx;while(heapOrArray[idx]&&!(idx>=maxIdx))++idx;return idx};var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead,ignoreNul)=>{var endPtr=findStringEnd(heapOrArray,idx,maxBytesToRead,ignoreNul);if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead,ignoreNul)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead,ignoreNul):"";var _fd_fdstat_get=(fd,pbuf)=>{var rightsBase=0;var rightsInheriting=0;var flags=0;{var type=2;if(fd==0){rightsBase=2}else if(fd==1||fd==2){rightsBase=64}flags=1}HEAP8[pbuf]=type;HEAP16[pbuf+2>>1]=flags;HEAP64[pbuf+8>>3]=BigInt(rightsBase);HEAP64[pbuf+16>>3]=BigInt(rightsInheriting);return 0};function _fd_seek(fd,offset,whence,newOffset){offset=bigintToI53Checked(offset);return 70}var printCharBuffers=[null,[],[]];var printChar=(stream,curr)=>{var buffer=printCharBuffers[stream];if(curr===0||curr===10){(stream===1?out:err)(UTF8ArrayToString(buffer));buffer.length=0}else{buffer.push(curr)}};var _fd_write=(fd,iov,iovcnt,pnum)=>{var num=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;for(var j=0;j>2]=num;return 0};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["print"])out=Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].shift()()}}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;Module["setValue"]=setValue;Module["getValue"]=getValue;var _loadDocument,_pageCount,_renderPagePng,_previewRender,_previewRenderRegion,_lastRenderPtr,_lastRenderW,_lastRenderH,_lastLayoutJson,_freeDocument,_engineBuildInfo,_free,_malloc,__emscripten_stack_restore,__emscripten_stack_alloc,_emscripten_stack_get_current,memory,__indirect_function_table,wasmMemory;function assignWasmExports(wasmExports){_loadDocument=Module["_loadDocument"]=wasmExports["p"];_pageCount=Module["_pageCount"]=wasmExports["q"];_renderPagePng=Module["_renderPagePng"]=wasmExports["r"];_previewRender=Module["_previewRender"]=wasmExports["s"];_previewRenderRegion=Module["_previewRenderRegion"]=wasmExports["t"];_lastRenderPtr=Module["_lastRenderPtr"]=wasmExports["u"];_lastRenderW=Module["_lastRenderW"]=wasmExports["v"];_lastRenderH=Module["_lastRenderH"]=wasmExports["w"];_lastLayoutJson=Module["_lastLayoutJson"]=wasmExports["x"];_freeDocument=Module["_freeDocument"]=wasmExports["y"];_engineBuildInfo=Module["_engineBuildInfo"]=wasmExports["z"];_free=Module["_free"]=wasmExports["A"];_malloc=Module["_malloc"]=wasmExports["B"];__emscripten_stack_restore=wasmExports["C"];__emscripten_stack_alloc=wasmExports["D"];_emscripten_stack_get_current=wasmExports["E"];memory=wasmMemory=wasmExports["n"];__indirect_function_table=wasmExports["__indirect_function_table"]}var wasmImports={a:___cxa_throw,i:__abort_js,l:__gmtime_js,c:__localtime_js,d:__tzset_js,j:_clock_time_get,k:_emscripten_resize_heap,g:_environ_get,h:_environ_sizes_get,m:_fd_close,f:_fd_fdstat_get,e:_fd_seek,b:_fd_write};function run(){preRun();function doRun(){Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve?.(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}var wasmExports;wasmExports=await (createWasm());run();if(runtimeInitialized){moduleRtn=Module}else{moduleRtn=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject})} ;return moduleRtn}export default Module; diff --git a/frontend/public/pdfengine.wasm b/frontend/public/pdfengine.wasm index 64185a5..9293bd2 100644 Binary files a/frontend/public/pdfengine.wasm and b/frontend/public/pdfengine.wasm differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index afeb03c..0e330e0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -224,15 +224,21 @@ function App() { }, [canUndo, canRedo, pendingSignature]); /* -------------------------------------------------------- edit pipeline */ + // Adopt a new document version produced by an edit (reflow, annotations, AND Raw Text via P2a): + // keep the current page visible, push it onto the undo/redo history, and refresh the doc list. + const adoptNewDocument = (newDocumentId: string) => { + preservePageRef.current = true; + pushHistory(newDocumentId); + gatewayService.listDocuments().then(setDocuments).catch(() => {}); + }; + const applyOps = async (ops: EditOperation[], successMsg?: string) => { if (!selectedDocId) return; setIsSaving(true); try { const result = await gatewayService.applyEdits(selectedDocId, ops); if (result.success) { - preservePageRef.current = true; - pushHistory(result.newDocumentId); - gatewayService.listDocuments().then(setDocuments).catch(() => {}); + adoptNewDocument(result.newDocumentId); if (successMsg) toast(successMsg, 'success'); } } catch (e) { @@ -617,6 +623,7 @@ function App() { onPlaceText={handlePlaceText} onEditText={handleEditText} onReflowParagraph={handleReflowParagraph} + onStreamDocumentChanged={adoptNewDocument} onPlaceStamp={handlePlaceStamp} onPlaceSignature={handlePlaceSignature} onDecorateText={handleDecorateText} diff --git a/frontend/src/components/Thumbnail.tsx b/frontend/src/components/Thumbnail.tsx index c9fc75a..d7f4e38 100644 --- a/frontend/src/components/Thumbnail.tsx +++ b/frontend/src/components/Thumbnail.tsx @@ -1,5 +1,5 @@ import { CustomButton } from './custom/CustomButton'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { gatewayService } from '../lib/gatewayService'; interface ThumbnailProps { @@ -23,13 +23,19 @@ export const Thumbnail: React.FC = ({ }) => { const [imageUrl, setImageUrl] = useState(null); const [loading, setLoading] = useState(true); + // Whether we already have a rendered thumbnail. Used to KEEP the current image visible during a + // re-fetch (e.g. after an edit mints a new documentId) instead of flashing back to "Loading…". + // A ref (not state) so the effect can read it without re-running when the image swaps. + const hasImageRef = useRef(false); useEffect(() => { let active = true; - + const fetchThumbnail = async () => { try { - setLoading(true); + // Only show the spinner on the very first load. On a re-fetch after an edit, keep the + // previous thumbnail on screen and swap it for the new one when it arrives (no flash). + if (!hasImageRef.current) setLoading(true); // Request a lower resolution/zoom image for the thumbnail const url = await gatewayService.renderPage({ documentId, @@ -37,9 +43,10 @@ export const Thumbnail: React.FC = ({ zoom: 0.2, // Small zoom for thumbnail size rotation: 0, }); - + if (active) { setImageUrl(url); + hasImageRef.current = true; } } catch (err) { console.error(`Failed to load thumbnail for page ${pageIndex}`, err); diff --git a/frontend/src/components/ToolRail.tsx b/frontend/src/components/ToolRail.tsx index 9d4f36e..55153f9 100644 --- a/frontend/src/components/ToolRail.tsx +++ b/frontend/src/components/ToolRail.tsx @@ -35,7 +35,7 @@ const TOOLS: (ToolDef | 'divider')[] = [ }, { id: 'stream_edit', - label: 'Raw Text', + label: 'Raw Text (beta)', shortcut: 'Q', icon: ( diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 0b54e6f..52bd034 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -33,8 +33,8 @@ const TOOL_META: Record = { underline: { label: 'Underline', icon: }, strikeout: { label: 'Strikeout', icon: }, squiggly: { label: 'Squiggly', icon: }, - stream_edit: { - label: 'Raw Text', + stream_edit: { + label: 'Raw Text (beta)', icon: ( @@ -136,7 +136,7 @@ export const Toolbar: React.FC = ({ )} {activeTool === 'edit_text' && Click text to seamlessly re-write paragraphs with automatic reflow.} - {activeTool === 'stream_edit' && Click a text block to edit the raw stream content directly.} + {activeTool === 'stream_edit' && Beta Β· surgical byte-level edit (no reflow). Best for same-length fixes in simple fonts.} {activeTool === 'signature' && ( <> diff --git a/frontend/src/lib/gatewayService.ts b/frontend/src/lib/gatewayService.ts index dcbed66..a5e3613 100644 --- a/frontend/src/lib/gatewayService.ts +++ b/frontend/src/lib/gatewayService.ts @@ -264,7 +264,7 @@ export interface ReflowParagraphData { firstBaselineY: number; leading: number; oldLineCount: number; - align: 'left' | 'justify'; + align: 'left' | 'justify' | 'center' | 'right'; // Push-down column left (defaults to columnLeft). Lets a bullet item reflow its text from a // hanging indent while the push-down still moves markers + items below by the full width. pushColumnLeft?: number; @@ -530,13 +530,18 @@ class GatewayService { return response.json(); } - async updateTextObject(documentId: string, pageIndex: number, objectIndex: number, newText: string): Promise<{ success: boolean }> { + async updateTextObject(documentId: string, pageIndex: number, objectIndex: number, newText: string): Promise<{ success: boolean; newDocumentId?: string }> { const response = await fetch(`${this.baseUrl}/documents/${documentId}/pages/${pageIndex}/text_objects/${objectIndex}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ new_text: newText }), }); - if (!response.ok) throw new Error(`Failed to update text object: ${response.statusText}`); + if (!response.ok) { + // Surface the gateway's detail (e.g. 400 encoding-reject / 403 permission) so the toast is useful. + let detail = response.statusText; + try { const j = await response.json(); if (j?.detail) detail = j.detail; } catch { /* ignore */ } + throw new Error(detail); + } return response.json(); } diff --git a/frontend/src/lib/pdfiumEngine.ts b/frontend/src/lib/pdfiumEngine.ts index 2aff0a5..db0b17a 100644 --- a/frontend/src/lib/pdfiumEngine.ts +++ b/frontend/src/lib/pdfiumEngine.ts @@ -23,7 +23,7 @@ function getModule(): Promise { if (!modulePromise) { modulePromise = (async () => { try { - const V = '20260616d'; + const V = '20260617b'; const resp = await fetch(`/pdfium-engine.mjs?v=${V}`, { cache: 'no-store' }); if (!resp.ok) throw new Error(`pdfium-engine.mjs ${resp.status}`); const blobUrl = URL.createObjectURL(new Blob([await resp.text()], { type: 'text/javascript' })); diff --git a/frontend/src/viewer/PDFViewer.tsx b/frontend/src/viewer/PDFViewer.tsx index 28541ec..3e008cd 100644 --- a/frontend/src/viewer/PDFViewer.tsx +++ b/frontend/src/viewer/PDFViewer.tsx @@ -14,6 +14,7 @@ import type { ToolSettings } from '../lib/tools'; import { toast } from '../lib/toast'; import { RedactionLayer } from './RedactionLayer'; import { StreamEditLayer } from './StreamEditLayer'; +import { wasmFreeDocument } from '../lib/pdfiumEngine'; interface PDFViewerProps { documentId: string; @@ -38,6 +39,7 @@ interface PDFViewerProps { onPlaceText?: (pageIndex: number, rectPts: Rect, text: string) => void; onEditText?: (pageIndex: number, run: EditableRun, newText: string) => void; onReflowParagraph?: (pageIndex: number, payload: ReflowParagraphPayload) => void; + onStreamDocumentChanged?: (newDocumentId: string) => void; onPlaceStamp?: (pageIndex: number, pointPts: { x: number; y: number }) => void; onPlaceSignature?: (pageIndex: number, pointPts: { x: number; y: number }) => void; onDecorateText?: (pageIndex: number, lines: Rect[], type: 'underline' | 'strikeout' | 'squiggly', color: string) => void; @@ -81,6 +83,7 @@ export const PDFViewer = React.forwardRef(({ onPlaceText, onEditText, onReflowParagraph, + onStreamDocumentChanged, onPlaceStamp, onPlaceSignature, onDecorateText, @@ -97,11 +100,21 @@ export const PDFViewer = React.forwardRef(({ // swapping page-by-page when ready β€” so the viewer never blanks/flashes on an edit and // the annotation overlay never unmounts. const renderedDocIdRef = useRef(''); + // Previous documentId so we can free its WASM copy when a new edit mints a new id. Each edit + // creates a new documentId and the live-preview engine loads a FULL doc copy per id; without this + // they accumulate in the WASM heap for the whole session (never freed) β†’ unbounded growth over a + // demo. The preview re-loads on demand, so freeing the superseded version only reclaims memory. + const prevDocumentIdRef = useRef(documentId); useEffect(() => { // Text/model caches ARE document-specific and cheap to refetch β€” reset them. // renderedPages is intentionally NOT cleared here (see renderedDocIdRef above). setPageTexts({}); + const prev = prevDocumentIdRef.current; + if (prev && prev !== documentId) wasmFreeDocument(prev); + prevDocumentIdRef.current = documentId; }, [documentId]); + // Free the active document's WASM copy when the viewer unmounts. + useEffect(() => () => { wasmFreeDocument(prevDocumentIdRef.current); }, []); const [bridgeFrame, setBridgeFrame] = useState<(CommitFrame & { docId: string }) | null>(null); const bridgeFrameRef = useRef(bridgeFrame); @@ -481,6 +494,7 @@ export const PDFViewer = React.forwardRef(({ width={page.width} height={page.height} zoom={zoom} + onDocumentChanged={onStreamDocumentChanged} onEditSuccess={() => { // Invalidate the rendered page cache to force a refresh setRenderedPages((prev) => { diff --git a/frontend/src/viewer/ParagraphEditor.tsx b/frontend/src/viewer/ParagraphEditor.tsx index cceecf7..dedb6cc 100644 --- a/frontend/src/viewer/ParagraphEditor.tsx +++ b/frontend/src/viewer/ParagraphEditor.tsx @@ -9,9 +9,10 @@ interface SeedRun { text: string; fid: string; size: number; color: string; font interface OrigLine { frags: { text: string; fid: string; size: number; color: string; advances?: number[] }[]; x: number; baselineY: number; } interface ParagraphLayout { columnLeft: number; columnRight: number; firstBaselineY: number; leading: number; - oldLineCount: number; align: 'left' | 'justify'; objectIndices: number[]; seedRuns: SeedRun[]; + oldLineCount: number; align: ReflowAlign; objectIndices: number[]; seedRuns: SeedRun[]; origLines: OrigLine[]; } +export type ReflowAlign = 'left' | 'justify' | 'center' | 'right'; function lineAdvances(line: any): { perRun: Record; anchorX: number } { const runs = line?.runs ?? []; @@ -43,7 +44,8 @@ interface ParagraphEditorProps { para: any; pushColumnLeft?: number; leadingOverride?: number; - alignOverride?: 'left' | 'justify'; + alignOverride?: ReflowAlign; + columnLeftOverride?: number; columnRightOverride?: number; caretClick?: { x: number; y: number } | null; heightPts: number; @@ -104,22 +106,51 @@ function computeLayout(para: any): ParagraphLayout { return { columnLeft, columnRight, firstBaselineY, leading, oldLineCount: lines.length, align, objectIndices, seedRuns, origLines }; } +// Resolve the styled span that owns a text node. Editing a contentEditable can split/merge text +// nodes or drop a node bare directly under the editor (no [data-fid] wrapper); the OLD code then +// fell back to `dominantFid` for that text β€” re-tagging e.g. a bullet's body with the bold LABEL +// font, so the whole run changed font on edit. Here we: (1) climb to the nearest ancestor span that +// carries data-fid (the exact wrapper when typing inside a run β€” the common case), then (2) for a +// bare node, INHERIT from the nearest styled sibling (preceding first, then following) so typed text +// continues the adjacent run's real font instead of the dominant one. `root` (the editor) carries +// data-fid too, so it's explicitly excluded from the ancestor climb. +function resolveStyleEl(node: Text, root: HTMLElement): HTMLElement | null { + let el: HTMLElement | null = node.parentElement; + while (el && el !== root) { + if (el.hasAttribute('data-fid')) return el; + el = el.parentElement; + } + // Does a single sibling node carry (or contain) a styled span? + const styledFrom = (n: Node): HTMLElement | null => { + if (n.nodeType !== 1) return null; + const e = n as HTMLElement; + if (e.hasAttribute('data-fid')) return e; + return (e.querySelector?.('[data-fid]') as HTMLElement | null) ?? null; + }; + // preceding siblings (nearest first), then following siblings + for (let s = node.previousSibling; s; s = s.previousSibling) { const r = styledFrom(s); if (r) return r; } + for (let s = node.nextSibling; s; s = s.nextSibling) { const r = styledFrom(s); if (r) return r; } + return null; +} + function extractFlatRuns(editable: HTMLElement, dominantFid: string, domSize: number, domColor: string): ReflowFragment[] { const out: ReflowFragment[] = []; const walker = document.createTreeWalker(editable, NodeFilter.SHOW_TEXT); let node = walker.nextNode() as Text | null; while (node) { - const el = node.parentElement; - const fid = el?.getAttribute('data-fid') || dominantFid; - const size = parseFloat(el?.getAttribute('data-size') ?? '') || domSize; - const color = el?.getAttribute('data-color') ?? domColor; + const styleEl = resolveStyleEl(node, editable); + const fid = styleEl?.getAttribute('data-fid') || dominantFid; + const size = parseFloat(styleEl?.getAttribute('data-size') ?? '') || domSize; + const color = styleEl?.getAttribute('data-color') ?? domColor; const text = node.textContent ?? ''; if (text) { const frag: ReflowFragment = { text, internalFontId: fid, fontSize: size, color }; // Carry the source advances ONLY if this text node still exactly matches its seed span // (unchanged): data-advances aligns 1:1 with the original chars, so a length match means - // the user hasn't edited it. Edited text drops them and the engine re-measures. - const aRaw = el?.getAttribute('data-advances'); + // the user hasn't edited it. Read them ONLY from the EXACT wrapping span (node.parentElement), + // never an inherited sibling β€” an inherited advances array of coincidentally-equal length would + // mis-space the text. Edited text drops them and the engine re-measures. + const aRaw = node.parentElement === styleEl ? styleEl?.getAttribute('data-advances') : null; if (aRaw) { try { const a = JSON.parse(aRaw) as number[]; @@ -182,13 +213,16 @@ function lineStarts(layout: ReflowLayout, fullText: string): number[] { } export const ParagraphEditor: React.FC = ({ - documentId, pageIndex, para, pushColumnLeft, leadingOverride, alignOverride, columnRightOverride, + documentId, pageIndex, para, pushColumnLeft, leadingOverride, alignOverride, columnLeftOverride, columnRightOverride, caretClick, heightPts, zoom, pageWidthPx, pageHeightPx, onCommit, onCommitPreview, onCancel, }) => { const layout = useMemo(() => computeLayout(para), [para]); const leading = leadingOverride ?? layout.leading; const align = alignOverride ?? layout.align; - // Wrap/commit against the true right margin when provided (bullet items), else the inferred one. + // Wrap/commit against the true column bounds when provided (bullet items, centered/right headings), + // else the inferred ones. columnLeft override matters for center/right alignment so the engine + // centers/right-aligns within the real page content box, not the text's own extent. + const columnLeft = columnLeftOverride ?? layout.columnLeft; const columnRight = columnRightOverride ?? layout.columnRight; const editRef = useRef(null); const previewCanvasRef = useRef(null); @@ -215,13 +249,17 @@ export const ParagraphEditor: React.FC = ({ const domFontName = domRun?.fontName ?? ''; const fontPx = domSize * zoom; const leadingPx = leading * zoom; - const colLeftPx = layout.columnLeft * zoom; - const colWidthPx = (columnRight - layout.columnLeft) * zoom; + const colLeftPx = columnLeft * zoom; + const colWidthPx = (columnRight - columnLeft) * zoom; const firstBaselineScreen = (heightPts - layout.firstBaselineY) * zoom; const editorTop = firstBaselineScreen - (leadingPx + fontPx * 0.7) / 2; const bandTop = Math.max(0, editorTop - leadingPx * 0.5); - const buildOpJson = (runs: ReflowFragment[], origLines?: OrigLine[]): string => { + // Single source of truth for the reflow op `data` payload. BOTH the live preview (buildOpJson β†’ + // WASM) and the final commit (β†’ gateway) build from this, so the committed render is guaranteed to + // match the last preview frame (no preview-OK/commit-wrong drift). origLines is preview-only (exact + // source layout while unedited); commit never passes it. + const buildReflowData = (runs: ReflowFragment[], origLines?: OrigLine[]) => { const linesData = origLines && origLines.length ? { lines: origLines.map((l) => l.frags.map((f) => ({ text: f.text, internalFontId: f.fid, fontSize: f.size, color: f.color, @@ -230,20 +268,23 @@ export const ParagraphEditor: React.FC = ({ lineX: origLines.map((l) => l.x), lineBaselineY: origLines.map((l) => l.baselineY), } : {}; - return JSON.stringify({ - version: '1.0', - operations: [{ id: 'preview', type: 'reflow_paragraph', pageIndex, data: { - objectIndices: layout.objectIndices, - runs: runs.length ? runs : [{ text: ' ', internalFontId: dominantFid, fontSize: domSize, color: '#000000' }], - ...linesData, - columnLeft: layout.columnLeft, columnRight, - pushColumnLeft: pushColumnLeft ?? layout.columnLeft, - firstBaselineY: layout.firstBaselineY, leading, - oldLineCount: layout.oldLineCount, align, - } }], - }); + return { + objectIndices: layout.objectIndices, + runs: runs.length ? runs : [{ text: ' ', internalFontId: dominantFid, fontSize: domSize, color: '#000000' }], + ...linesData, + columnLeft, columnRight, + pushColumnLeft: pushColumnLeft ?? columnLeft, + firstBaselineY: layout.firstBaselineY, leading, + oldLineCount: layout.oldLineCount, align, + }; }; + const buildOpJson = (runs: ReflowFragment[], origLines?: OrigLine[]): string => + JSON.stringify({ + version: '1.0', + operations: [{ id: 'preview', type: 'reflow_paragraph', pageIndex, data: buildReflowData(runs, origLines) }], + }); + const caretBoxFor = (global: number, lay: ReflowLayout, fullText: string) => { if (!lay.lines.length) return null; const starts = lineStarts(lay, fullText); @@ -392,13 +433,28 @@ export const ParagraphEditor: React.FC = ({ return () => window.clearTimeout(t); }, [hasPreview]); + // IME composition (CJK/accents): the browser fires `input` for every intermediate composition + // keystroke, but the text isn't final until `compositionend`. Re-extracting/re-rendering mid- + // composition rewrites the DOM under the IME and aborts it. So we suppress rendering while + // composing and do a single render when composition ends. + const composingRef = useRef(false); + // Re-render every frame on the latest keystroke (rAF-coalesced, no fixed debounce) for real-time feel. const onInput = () => { editedRef.current = true; // now the engine may re-wrap (the user is actually editing) if (!edited) setEdited(true); // swap from the untouched original page to the reflow render + if (composingRef.current) return; // defer to compositionend (don't disturb the IME) positionCaret(); // immediate (approximate, from the prior layout) for responsiveness scheduleRender(); }; + const onCompositionStart = () => { composingRef.current = true; }; + const onCompositionEnd = () => { + composingRef.current = false; + editedRef.current = true; + if (!edited) setEdited(true); + positionCaret(); + scheduleRender(); + }; const onClickEditor = (e: React.MouseEvent) => { const lay = engineLayoutRef.current, el = editRef.current; @@ -426,14 +482,9 @@ export const ParagraphEditor: React.FC = ({ }); } catch { /* tainted/0-size canvas β†’ skip the bridge (worst case = today's flash) */ } } - // Commit with FLAT runs (no lines) so the gateway wraps identically to the live preview. - onCommit({ - objectIndices: layout.objectIndices, runs: flat, - columnLeft: layout.columnLeft, columnRight, - pushColumnLeft: pushColumnLeft ?? layout.columnLeft, - firstBaselineY: layout.firstBaselineY, leading, - oldLineCount: layout.oldLineCount, align, - }); + // Commit with FLAT runs (no lines) via the SAME builder the live preview used, so the gateway + // wraps identically to the last preview frame (single source of truth β€” see buildReflowData). + onCommit(buildReflowData(flat) as ReflowParagraphPayload); }; const cancel = () => { committedRef.current = true; onCancel(); }; @@ -479,9 +530,14 @@ export const ParagraphEditor: React.FC = ({ data-color={domColor} data-fontname={domFontName} onInput={onInput} + onCompositionStart={onCompositionStart} + onCompositionEnd={onCompositionEnd} onClick={onClickEditor} onKeyUp={positionCaret} onKeyDown={(e) => { + // Don't treat Enter/Escape as commit/cancel while an IME composition is active β€” that + // Enter is confirming the composition, not finishing the edit (isComposing covers it). + if (e.nativeEvent.isComposing || composingRef.current) return; if (e.key === 'Enter') { e.preventDefault(); commit(); } if (e.key === 'Escape') { e.preventDefault(); cancel(); } }} diff --git a/frontend/src/viewer/StreamEditLayer.tsx b/frontend/src/viewer/StreamEditLayer.tsx index b8f059f..9da7c13 100644 --- a/frontend/src/viewer/StreamEditLayer.tsx +++ b/frontend/src/viewer/StreamEditLayer.tsx @@ -3,6 +3,30 @@ import { gatewayService } from '../lib/gatewayService'; import type { TextObjectResponse } from '../lib/gatewayService'; import { toast } from '../lib/toast'; +// Measure a run's width + ascent/descent in its actual font (memoized). Sizes are returned in the +// same units as `sizePx`, so callers scale by the text matrix + zoom. Before the embedded @font-face +// loads this measures the fallback chain (still far better than a char-count guess); clearMeasureCache +// is called once the real fonts load so boxes become exact. +const _measureCache = new Map(); +let _measureCtx: CanvasRenderingContext2D | null = null; +function clearMeasureCache() { _measureCache.clear(); } +function measureText(text: string, fontFamily: string, sizePx: number): { width: number; ascent: number; descent: number } | null { + const key = `${sizePx}|${fontFamily}|${text}`; + const hit = _measureCache.get(key); + if (hit) return hit; + if (!_measureCtx) _measureCtx = document.createElement('canvas').getContext('2d'); + if (!_measureCtx) return null; + _measureCtx.font = `${sizePx}px ${fontFamily}`; + const tm = _measureCtx.measureText(text); + const res = { + width: tm.width, + ascent: tm.actualBoundingBoxAscent || sizePx * 0.8, + descent: tm.actualBoundingBoxDescent || sizePx * 0.2, + }; + _measureCache.set(key, res); + return res; +} + interface StreamEditLayerProps { documentId: string; pageIndex: number; @@ -10,6 +34,9 @@ interface StreamEditLayerProps { height: number; // page height in ZOOMED px zoom: number; onEditSuccess: () => void; + // P2a β€” a Raw Text edit now commits as a new document version; adopting that id (like a reflow + // edit) joins the shared undo/redo history. Falls back to onEditSuccess if not provided. + onDocumentChanged?: (newDocumentId: string) => void; } export const StreamEditLayer: React.FC = ({ @@ -19,11 +46,13 @@ export const StreamEditLayer: React.FC = ({ height, zoom, onEditSuccess, + onDocumentChanged, }) => { const [objects, setObjects] = useState([]); const [loading, setLoading] = useState(false); const [editingIndex, setEditingIndex] = useState(null); const [value, setValue] = useState(''); + const [fontsReady, setFontsReady] = useState(false); const inputRef = useRef(null); useEffect(() => { @@ -63,15 +92,21 @@ export const StreamEditLayer: React.FC = ({ if (newText === obj.text) return; try { - await gatewayService.updateTextObject(documentId, pageIndex, idx, newText); + const res = await gatewayService.updateTextObject(documentId, pageIndex, idx, newText); toast('Text updated successfully', 'success'); - // Update local state optimistically - setObjects(prev => { - const next = [...prev]; - next[idx] = { ...next[idx], text: newText }; - return next; - }); - onEditSuccess(); + if (res.newDocumentId && onDocumentChanged) { + // P2a β€” adopt the new version (joins undo/redo); the viewer reloads on the new id and the + // layer re-fetches objects, so no local optimistic patch is needed. + onDocumentChanged(res.newDocumentId); + } else { + // Fallback (older gateway / no handler): optimistic local patch + cache-bust on same id. + setObjects(prev => { + const next = [...prev]; + next[idx] = { ...next[idx], text: newText }; + return next; + }); + onEditSuccess(); + } } catch (e: any) { toast(`Failed to update text: ${e.message}`, 'error'); } @@ -83,6 +118,20 @@ export const StreamEditLayer: React.FC = ({ const uniqueFonts = Array.from(new Set(objects.map(o => o.fontName).filter(Boolean))); + // Once the embedded @font-face fonts actually load, drop the (fallback-measured) cache and + // re-render so the hit-boxes are measured in the real fonts β†’ exact (P2b). + const uniqueFontsKey = uniqueFonts.join('|'); + useEffect(() => { + if (!uniqueFonts.length) return; + let active = true; + const fonts = (document as unknown as { fonts?: { load: (f: string) => Promise } }).fonts; + if (!fonts) return; + Promise.all(uniqueFonts.map(fn => fonts.load(`16px 'PDF_${fn}'`).catch(() => {}))) + .then(() => { if (active) { clearMeasureCache(); setFontsReady(v => !v); } }); + return () => { active = false; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [uniqueFontsKey]); + if (loading) { return null; } @@ -101,22 +150,29 @@ export const StreamEditLayer: React.FC = ({ // tm[4] is X, tm[5] is Y (baseline, bottom-left origin) const pdfX = obj.tm[4]; const pdfY = obj.tm[5]; - + // Convert to screen coordinates const left = pdfX * zoom; // pdfY is baseline. So baseline in screen coords from top: const baselineScreenTop = (heightPts - pdfY) * zoom; + const scaleX = obj.tm ? Math.abs(obj.tm[0]) : 1; const scaleY = obj.tm ? Math.abs(obj.tm[3]) : 1; const fontSizeScreen = obj.fontSize * scaleY * zoom; - - // Approximate box - const top = baselineScreenTop - (fontSizeScreen * 0.8); // 80% ascent - const boxHeight = fontSizeScreen; - const boxWidth = Math.max(obj.text.length * fontSizeScreen * 0.5, 20); // estimate width const isEditing = editingIndex === i; const fontFamily = obj.fontName ? `'PDF_${obj.fontName}', sans-serif` : 'sans-serif'; + // P2b β€” EXACT hit-box from real font metrics (measured in the actual embedded font once + // it loads; falls back to the sans-serif chain before then) instead of the old + // lenΓ—sizeΓ—0.5 guess. `fontsReady` forces a re-measure after @font-face loads. + void fontsReady; + const m = measureText(obj.text, fontFamily, obj.fontSize); + const boxWidth = m ? Math.max(m.width * scaleX * zoom, 6) : Math.max(obj.text.length * fontSizeScreen * 0.5, 20); + const ascentScreen = (m ? m.ascent : obj.fontSize * 0.8) * scaleY * zoom; + const descentScreen = (m ? m.descent : obj.fontSize * 0.2) * scaleY * zoom; + const top = baselineScreenTop - ascentScreen; + const boxHeight = ascentScreen + descentScreen; + return (
{!isEditing ? ( diff --git a/frontend/src/viewer/TextEditLayer.tsx b/frontend/src/viewer/TextEditLayer.tsx index d4fb2c9..d094162 100644 --- a/frontend/src/viewer/TextEditLayer.tsx +++ b/frontend/src/viewer/TextEditLayer.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { gatewayService } from '../lib/gatewayService'; import { loadPdfFont, releaseDocumentFonts } from '../lib/fontFaceLoader'; import { ParagraphEditor } from './ParagraphEditor'; +import type { ReflowAlign } from './ParagraphEditor'; // A single editable run. Geometry (x/y/w/h, baselineY) is PDF BOTTOM-LEFT (as // getPageModel returns) and is used only to position the inline editor. The @@ -48,7 +49,7 @@ export interface ReflowParagraphPayload { firstBaselineY: number; leading: number; oldLineCount: number; - align: 'left' | 'justify'; + align: ReflowAlign; pushColumnLeft?: number; // full-width push-down left for bullet items (see engine) // WYSIWYG: exact visual line breaks from the live editor (one inner array per line). lines?: ReflowFragment[][]; @@ -144,6 +145,32 @@ function pageContentRight(model: any): number { return isFinite(right) ? right : 0; } +function pageContentLeft(model: any): number { + let left = Infinity; + for (const p of model?.paragraphs ?? []) { + for (const l of p.lines ?? []) { + if ((l.runs ?? []).some((r: any) => (r.text ?? '').trim())) left = Math.min(left, l.x); + } + } + return isFinite(left) ? left : 0; +} + +// Infer a single heading line's alignment from its position within the page's content box. Returns +// 'center'/'right' only when clearly so; otherwise 'left' (so the existing left-aligned heading path +// is unchanged). Lets a centered title (e.g. "Software Engineer | 3.2 Years Experience") stay +// centered as it grows, instead of growing rightward from a fixed left. +function headingAlign(line: any, model: any): ReflowAlign { + const pl = pageContentLeft(model), pr = pageContentRight(model); + const w = pr - pl; + if (w <= 1 || !line) return 'left'; + const leftGap = line.x - pl; + const rightGap = pr - (line.x + line.w); + const tol = w * 0.06; + if (Math.abs(leftGap - rightGap) <= tol && leftGap > tol && rightGap > tol) return 'center'; + if (rightGap <= tol && leftGap > tol * 2) return 'right'; + return 'left'; +} + // True only for a genuine FLOWING paragraph β€” multiple lines that fill the column from a common // left edge (e.g. the Professional Summary). Bullet lists / structured blocks get grouped into a // single "paragraph" by the model too, but they must NOT be reflowed (it would merge the bullets @@ -335,7 +362,7 @@ export const TextEditLayer: React.FC = ({ // When set, the live reflow editor is open. `para` is the (sub-)paragraph to reflow β€” the whole // paragraph for a flowing block, or a single bullet item (marker stripped) for a list. const [paraEdit, setParaEdit] = useState<{ - para: any; pushColumnLeft?: number; leading?: number; align?: 'left' | 'justify'; columnRight?: number; + para: any; pushColumnLeft?: number; leading?: number; align?: ReflowAlign; columnLeft?: number; columnRight?: number; } | null>(null); // Screen coords of the click that opened the paragraph editor, so the caret lands there // (instead of jumping to the paragraph start). @@ -405,7 +432,18 @@ export const TextEditLayer: React.FC = ({ // would wrap immediately as the user types. Use the page's true text margin so a heading // can grow across the full width (and only wrap when it genuinely needs to). setCaretClick(click); - setParaEdit({ para, columnRight: pageContentRight(modelRef.current) }); + const al = headingAlign(para.lines[0], modelRef.current); + if (al === 'center' || al === 'right') { + // Centered/right headings: align within the full page content box so the text re-centers + // (or stays flush-right) as it grows, instead of growing rightward from a fixed left. + setParaEdit({ + para, align: al, + columnLeft: pageContentLeft(modelRef.current), + columnRight: pageContentRight(modelRef.current), + }); + } else { + setParaEdit({ para, columnRight: pageContentRight(modelRef.current) }); + } return; } } @@ -486,6 +524,7 @@ export const TextEditLayer: React.FC = ({ pushColumnLeft={paraEdit.pushColumnLeft} leadingOverride={paraEdit.leading} alignOverride={paraEdit.align} + columnLeftOverride={paraEdit.columnLeft} columnRightOverride={paraEdit.columnRight} caretClick={caretClick} heightPts={heightPts} diff --git a/gateway/app/routers/documents.py b/gateway/app/routers/documents.py index 0c229e3..6be4e63 100644 --- a/gateway/app/routers/documents.py +++ b/gateway/app/routers/documents.py @@ -752,9 +752,15 @@ def get_text_objects(document_id: str, page_index: int) -> list[TextObjectRespon doc_info = document_store.get_document(document_id) if not doc_info: raise HTTPException(status_code=404, detail="Document not found") - + + # Raw Text edits content streams directly (canModify), matching the reflow/replace_text gating + # in edits.py. Reading the objects is a prerequisite for editing, so gate the read on canModify too. + _perms = doc_info.get("permissions") or {} + if _perms.get("canModify", True) is False: + raise HTTPException(status_code=403, detail="Raw Text editing is not permitted by this document's restrictions (canModify).") + pdfengine = engine.require() - + import os # For StreamEditor we need a real file path. If it's loaded from memory, we need to save it to a temp file. # In this MVP, we assume the file was saved somewhere, but actually document_store keeps it in memory. @@ -799,40 +805,55 @@ def replace_text_object(document_id: str, page_index: int, object_index: int, re doc_info = document_store.get_document(document_id) if not doc_info: raise HTTPException(status_code=404, detail="Document not found") - + + # Gate raw-stream edits behind canModify (same as reflow/replace_text in edits.py). + _perms = doc_info.get("permissions") or {} + if _perms.get("canModify", True) is False: + raise HTTPException(status_code=403, detail="Raw Text editing is not permitted by this document's restrictions (canModify).") + pdfengine = engine.require() - + import tempfile import os - + with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp: tmp.write(doc_info["bytes_data"]) tmp_path = tmp.name - + out_path = tmp_path + ".out.pdf" try: editor = pdfengine.StreamEditor(tmp_path) - # Convert frontend string back to exact bytes using latin-1 - new_text_bytes = req.new_text.encode("latin-1") + # P1b β€” the content-stream bytes are in the font's own encoding, which we surface to the + # frontend as latin-1. An edit that introduces a character outside that range can't be + # encoded safely (it would emit a wrong glyph / notdef), so reject it with a clear message + # instead of corrupting the run. (Use the reflow "Edit text" tool to add new characters.) + try: + new_text_bytes = req.new_text.encode("latin-1") + except UnicodeEncodeError as enc_err: + raise HTTPException( + status_code=400, + detail="Some characters can't be encoded in this run's font. Raw Text supports same-charset edits only β€” use Edit text to add new characters.", + ) from enc_err success = editor.replace_text_object(page_index, object_index, new_text_bytes, out_path) if not success: raise HTTPException(status_code=400, detail="Failed to replace text object (not found or identical)") - - # Update the document store with the new bytes + + # P2a β€” commit as a NEW document version (mirrors the reflow/edits path in edits.py): + # carry permissions forward and return a newDocumentId so the Raw Text edit joins the same + # undo/redo history, instead of mutating doc_info in place (which was invisible to undo). with open(out_path, "rb") as f: new_bytes = f.read() - - # Reload doc instance - doc_instance = pdfengine.PdfDocument.load_from_memory(new_bytes, "") - - # Update the store - doc_info["bytes_data"] = new_bytes - doc_info["doc_instance"] = doc_instance - - return {"success": True} + new_doc = pdfengine.PdfDocument.load_from_memory(new_bytes, "") + new_info = document_store.add_document( + filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc, + permissions=doc_info.get("permissions"), + ) + return {"success": True, "newDocumentId": new_info["id"]} + except HTTPException: + raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=str(e)) from e finally: if os.path.exists(tmp_path): os.remove(tmp_path)