fix(reflow): kill bulge/merge + font-change on edit; add center/right, IME; harden Raw Text

Reflow (primary editor):
- Geometric backstop in reflow_paragraph: adopt any text object fully inside the paragraph
  bbox that the model's objectIndices omitted (PDFium glyph->object map can return -1), so it's
  deleted + its original font is resolved. Fixes both the leftover-glyph "bulge/merge" and the
  font-substitution-on-edit (same root cause). No-op on correctly-indexed paragraphs (gate-proven
  byte-identical: overlay diff unchanged at 1.69%/1.70%).
- Preserve data-fid on edited contentEditable nodes (extractFlatRuns climbs to nearest styled
  ancestor / inherits from adjacent run) so edits keep their real font instead of the dominant.
- Converge preview & commit through one buildReflowData (no preview-OK/commit-wrong drift).
- Center/right alignment: additive emission branch (greedy path only) + heading-align inference.
- IME composition handling (suppress render mid-composition; don't commit on composing Enter).
- Fix WASM document-handle leak (free superseded versions on documentId change/unmount).
- Quiet the backstop instrumentation (warn -> debug) now the root cause is confirmed.

Raw Text / StreamEditor (beta companion):
- Permission-gate both text_objects endpoints behind canModify; label tool "(beta)".
- P1a: preserve TJ kerning numbers for same-length edits (redistribute into original slots),
  fall back to single-string collapse otherwise (never worse than before).
- P1b: reject edits with characters unencodable in the run's font (clear 400 instead of corruption).
- P2a: commit Raw Text edits as a new document version (add_document) and adopt the id via
  pushHistory, so they join undo/redo instead of mutating in place.
- P2b: exact hit-boxes from real font metrics (re-measured once @font-face loads).

WASM preview rebuilt with the reflow changes; cache version bumped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Furqan-14
2026-06-17 17:12:53 +05:30
co-authored by Claude Opus 4.8
parent 21f0bfab9a
commit 660649a562
17 changed files with 707 additions and 98 deletions
+46 -5
View File
@@ -54,6 +54,12 @@ void get_or_throw(std::expected<void, pdfengine::EngineError>&& res) {
#include <serializer/content_serializer.hpp>
#include <serializer/ast_serializer.hpp>
// 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::AstNode>(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<std::pair<pdfengine::AstNode*, std::string>> 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::AstNode>(pdfengine::AstNodeType::String);
newStrNode->stringValue = new_text;
arrNode->arrayItems.push_back(std::move(newStrNode));
}
modified = true;
break;
}
+110
View File
@@ -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 (P1P3)
| 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.72.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 14 are entirely non-breaking. Nothing past step 4 is required for the app to be solid.
@@ -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.
+62 -7
View File
@@ -2683,6 +2683,51 @@ std::expected<void, EngineError> 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<int> 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<EmissionFont> runFonts(runs.size());
auto toCodepoints = [](const std::string& s) {
@@ -2717,7 +2762,7 @@ std::expected<void, EngineError> 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<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
std::unordered_map<std::string, EmissionFont> 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<void, EngineError> 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<void, EngineError> 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>());
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>());
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<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
std::vector<double> 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) {
File diff suppressed because one or more lines are too long
Binary file not shown.
+10 -3
View File
@@ -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}
+11 -4
View File
@@ -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<ThumbnailProps> = ({
}) => {
const [imageUrl, setImageUrl] = useState<string | null>(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<ThumbnailProps> = ({
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);
+1 -1
View File
@@ -35,7 +35,7 @@ const TOOLS: (ToolDef | 'divider')[] = [
},
{
id: 'stream_edit',
label: 'Raw Text',
label: 'Raw Text (beta)',
shortcut: 'Q',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
+3 -3
View File
@@ -33,8 +33,8 @@ const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
underline: { label: 'Underline', icon: <UnderlineIcon size={17} /> },
strikeout: { label: 'Strikeout', icon: <StrikeoutIcon size={17} /> },
squiggly: { label: 'Squiggly', icon: <SquigglyIcon size={17} /> },
stream_edit: {
label: 'Raw Text',
stream_edit: {
label: 'Raw Text (beta)',
icon: (
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 18 22 12 16 6" />
@@ -136,7 +136,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
)}
{activeTool === 'edit_text' && <Hint>Click text to seamlessly re-write paragraphs with automatic reflow.</Hint>}
{activeTool === 'stream_edit' && <Hint>Click a text block to edit the raw stream content directly.</Hint>}
{activeTool === 'stream_edit' && <Hint>Beta · surgical byte-level edit (no reflow). Best for same-length fixes in simple fonts.</Hint>}
{activeTool === 'signature' && (
<>
+8 -3
View File
@@ -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();
}
+1 -1
View File
@@ -23,7 +23,7 @@ function getModule(): Promise<PdfiumModule | null> {
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' }));
+14
View File
@@ -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<PDFViewerRef, PDFViewerProps>(({
onPlaceText,
onEditText,
onReflowParagraph,
onStreamDocumentChanged,
onPlaceStamp,
onPlaceSignature,
onDecorateText,
@@ -97,11 +100,21 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
// swapping page-by-page when ready — so the viewer never blanks/flashes on an edit and
// the annotation overlay never unmounts.
const renderedDocIdRef = useRef<string>('');
// 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<string>(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<PDFViewerRef, PDFViewerProps>(({
width={page.width}
height={page.height}
zoom={zoom}
onDocumentChanged={onStreamDocumentChanged}
onEditSuccess={() => {
// Invalidate the rendered page cache to force a refresh
setRenderedPages((prev) => {
+89 -33
View File
@@ -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<number, number[]>; 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<ParagraphEditorProps> = ({
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<HTMLDivElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
@@ -215,13 +249,17 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
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<ParagraphEditorProps> = ({
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<ParagraphEditorProps> = ({
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<ParagraphEditorProps> = ({
});
} 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<ParagraphEditorProps> = ({
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(); }
}}
+70 -14
View File
@@ -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<string, { width: number; ascent: number; descent: number }>();
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<StreamEditLayerProps> = ({
@@ -19,11 +46,13 @@ export const StreamEditLayer: React.FC<StreamEditLayerProps> = ({
height,
zoom,
onEditSuccess,
onDocumentChanged,
}) => {
const [objects, setObjects] = useState<TextObjectResponse[]>([]);
const [loading, setLoading] = useState(false);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [value, setValue] = useState('');
const [fontsReady, setFontsReady] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -63,15 +92,21 @@ export const StreamEditLayer: React.FC<StreamEditLayerProps> = ({
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<StreamEditLayerProps> = ({
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<unknown> } }).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<StreamEditLayerProps> = ({
// 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 (
<div key={i}>
{!isEditing ? (
+42 -3
View File
@@ -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<TextEditLayerProps> = ({
// 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<TextEditLayerProps> = ({
// 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<TextEditLayerProps> = ({
pushColumnLeft={paraEdit.pushColumnLeft}
leadingOverride={paraEdit.leading}
alignOverride={paraEdit.align}
columnLeftOverride={paraEdit.columnLeft}
columnRightOverride={paraEdit.columnRight}
caretClick={caretClick}
heightPts={heightPts}
+41 -20
View File
@@ -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)