diff --git a/engine/src/fonts/face/font_face.cpp b/engine/src/fonts/face/font_face.cpp index cd81848..3f7eda3 100644 --- a/engine/src/fonts/face/font_face.cpp +++ b/engine/src/fonts/face/font_face.cpp @@ -109,6 +109,17 @@ FT_Face FontFace::getFace() const { return face_; } +bool FontFace::coversUnicode(uint32_t codepoint) const { + if (!face_) return false; + std::lock_guard lock(*mutex_); + FT_CharMap prev = face_->charmap; + // Ignore failure: if there's no Unicode cmap, keep whatever charmap is current. + FT_Select_Charmap(face_, FT_ENCODING_UNICODE); + FT_UInt gid = FT_Get_Char_Index(face_, codepoint); + if (prev) FT_Set_Charmap(face_, prev); // restore so shaping/measurement is unaffected + return gid != 0; +} + uint64_t FontFace::getId() const { return font_id_; } diff --git a/engine/src/fonts/face/font_face.hpp b/engine/src/fonts/face/font_face.hpp index 303c7ac..3f524eb 100644 --- a/engine/src/fonts/face/font_face.hpp +++ b/engine/src/fonts/face/font_face.hpp @@ -31,6 +31,12 @@ public: uint64_t getId() const; std::mutex& getMutex() const; + // True if this font can map `codepoint` to a real glyph via its Unicode cmap. Selects the + // Unicode charmap first (so codepoints resolve through the (3,1)/(0,x) subtable rather than a + // symbol cmap that would false-negate), then restores the prior charmap. Used to decide + // whether to keep smart punctuation (en-dash, curly quotes) or ASCII-degrade it on reflow. + bool coversUnicode(uint32_t codepoint) const; + // Renders a glyph by index and size, returning a GlyphBitmap on success. std::optional renderGlyph(unsigned int glyphIndex, unsigned int fontSize); diff --git a/engine/src/parser/pdfium_document.cpp b/engine/src/parser/pdfium_document.cpp index 7ef6eda..1ba7c5f 100644 --- a/engine/src/parser/pdfium_document.cpp +++ b/engine/src/parser/pdfium_document.cpp @@ -185,20 +185,26 @@ static std::string repairMojibake(const std::string& s) { return out; } -// Normalize "smart" punctuation to ASCII for reflow re-emission. Office subset fonts often map -// en/em dashes, curly quotes, and ellipses at custom codes (not their Unicode), so re-emitting -// them via FPDFText_SetText(unicode) drops them to a notdef glyph (the en-dash came out as "B"). -// Substituting the ASCII equivalent renders reliably in every font (embedded, base-14, or system) -// and keeps preview == saved. Only reflowed text is affected; untouched text keeps its originals. -std::string asciiizePunctuation(const std::string& s) { +// Normalize text for reflow re-emission, KEEPING real smart punctuation (en-dash, curly quotes, +// ellipsis) whenever the emission font can actually render it — only ASCII-degrading the glyphs a +// font genuinely lacks, so output never falls to a notdef box (Adobe keeps the en-dash; so do we +// now that reflow reuses the source text's own embedded font). `face` is the run's emission font +// (null => degrade conservatively). Exotic spaces always collapse to U+0020 so wrapping is sane. +// Only reflowed text is affected; untouched text keeps its originals. preview == saved (same engine). +std::string normalizeReflowText(const std::string& s, const fonts::FontFace* face) { auto cps = utf8_to_codepoints(s); std::string out; + auto keepIfCovered = [&](unsigned int cp, const char* ascii) { + if (face && face->coversUnicode(cp)) out += code_point_to_utf8(cp); + else out += ascii; + }; for (unsigned int cp : cps) { switch (cp) { - case 0x2010: case 0x2011: case 0x2012: case 0x2013: case 0x2014: case 0x2015: out += '-'; break; - case 0x2018: case 0x2019: case 0x201B: out += '\''; break; - case 0x201C: case 0x201D: case 0x201F: out += '"'; break; - case 0x2026: out += "..."; break; + case 0x2010: case 0x2011: case 0x2012: case 0x2013: case 0x2014: case 0x2015: + keepIfCovered(cp, "-"); break; + case 0x2018: case 0x2019: case 0x201B: keepIfCovered(cp, "'"); break; + case 0x201C: case 0x201D: case 0x201F: keepIfCovered(cp, "\""); break; + case 0x2026: keepIfCovered(cp, "..."); break; case 0x00A0: case 0x2002: case 0x2003: case 0x2009: case 0x202F: out += ' '; break; default: out += code_point_to_utf8(cp); break; } @@ -209,6 +215,36 @@ std::string asciiizePunctuation(const std::string& s) { namespace { +// Stable internal font id from resolved font metadata. SINGLE SOURCE OF TRUTH shared by model +// extraction (deduceFontMetadata / getFonts) and reflow font resolution — if these ever +// disagree, per-object font resolution silently misses and reflow falls back to the buggy name +// scan. Subset fonts already carry their subset prefix in fontName (e.g. "ABCDEF+Calibri"), so +// use it directly; otherwise encode "{base}_{type}_{flags}". +std::string makeInternalFontId(const pdfengine::FontInfo& f) { + if (f.isSubset && !f.subsetTag.empty()) return f.fontName; + return f.fontName + "_" + f.type + "_" + std::to_string(f.flags); +} + +// The /BaseFont name expected for an internalFontId, mirroring makeInternalFontId in reverse. +// Non-subset ids are "{base}_{type}_{flags}" -> strip the suffix to recover "{base}". Subset +// ids are the raw "{tag}+{base}" name -> used as-is (exactly what FPDFFont_GetBaseFontName +// returns for that object). Used to match a page object's font to a run's internalFontId. +std::string baseNameFromInternalFontId(const std::string& internalFontId) { + std::string expected = internalFontId; + size_t lastUnderscore = expected.rfind('_'); + if (lastUnderscore != std::string::npos && lastUnderscore > 0) { + size_t secondLast = expected.rfind('_', lastUnderscore - 1); + if (secondLast != std::string::npos) { + std::string typePart = expected.substr(secondLast + 1, lastUnderscore - secondLast - 1); + if (typePart == "TrueType" || typePart == "Type1" || + typePart == "CIDFontType0" || typePart == "CIDFontType2") { + expected = expected.substr(0, secondLast); + } + } + } + return expected; +} + #ifdef PDFENGINE_WITH_PDFIUM struct VectorWriter : public FPDF_FILEWRITE { std::vector buffer; @@ -501,12 +537,7 @@ void deduceFontMetadata(pdfengine::FontInfo& f) { // Use the full fontName directly — it already encodes both the subset tag and // the base font name, separated by '+'. Concatenating subsetTag + "_" + fontName // would duplicate the prefix ("ABCDEF_ABCDEF+Arial"). - if (f.isSubset && !f.subsetTag.empty()) { - // fontName is "ABCDEF+Arial"; use it as-is for the stable ID. - f.internalFontId = f.fontName; - } else { - f.internalFontId = f.fontName + "_" + f.type + "_" + std::to_string(f.flags); - } + f.internalFontId = makeInternalFontId(f); // 8. Descriptor Metrics — actual values from standard font specifications if (lowerName.find("times") != std::string::npos) { @@ -1877,7 +1908,8 @@ std::expected, EngineError> PdfiumDocument::getPage(int #ifdef PDFENGINE_WITH_PDFIUM PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont( int pageIndex, const std::string& internalFontId, double fontSize, - const std::vector& codepoints) { + const std::vector& codepoints, const std::vector& srcObjects, + FPDF_FONT reuseFont) { EmissionFont out; (void)fontSize; @@ -1955,6 +1987,22 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont( cacheKey += "#" + std::to_string(h); } + // Embedded + we have the original font handle from the paragraph's source objects: REUSE it + // directly. No new font is added to the document, so two same-base-font reflows can never + // produce two aliasing "Calibri" subsets (the root cause of the multi-edit scramble). The + // handle is owned by the still-open page in the reflow handler and outlives this call. We + // bypass the FPDF_FONT cache here because the canonical original is always correct, and a + // measuring face is built from the paragraph's real font bytes for width parity. + if (useEmbedded && reuseFont) { + out.font = reuseFont; + if (auto perObj = getFontDataFromObjects(pageIndex, srcObjects, internalFontId); + perObj.has_value() && !perObj->empty()) { + auto mf = std::make_shared(); + if (mf->loadFromMemory(*perObj)) out.measureFace = mf; + } + return out; + } + { std::lock_guard lock(loadedFontsMutex_); if (loadedFontsCache_.count(cacheKey)) { @@ -1966,17 +2014,27 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont( if (out.font) return out; if (useEmbedded) { - auto fontDataRes = getFontData(matchedFontInfo->internalFontId); - if (fontDataRes.has_value() && !fontDataRes.value().empty()) { + // Prefer the font bytes of the paragraph's OWN page objects (per-object resolution): + // a prior reflow may have embedded another same-named subset elsewhere in the doc, and a + // document-wide name scan (getFontData) can return THAT instead, scrambling the 2nd+ edit + // ("Core Java" -> "Cuwi Le,e"). Fall back to the name scan only when the font isn't + // physically present in the source objects (e.g. newly typed text inheriting a font). + std::vector sourceBytes; + if (auto perObj = getFontDataFromObjects(pageIndex, srcObjects, matchedFontInfo->internalFontId); + perObj.has_value() && !perObj->empty()) { + sourceBytes = std::move(*perObj); + } else if (auto fontDataRes = getFontData(matchedFontInfo->internalFontId); + fontDataRes.has_value() && !fontDataRes.value().empty()) { + sourceBytes = std::move(fontDataRes.value()); + } + if (!sourceBytes.empty()) { // Re-subset the embedded font over the glyphs we'll emit. HarfBuzz rebuilds a // clean unicode cmap (no RETAIN_GIDS), so FPDFText_SetText(unicode) maps every // char — loading the raw embedded bytes cid=false drops glyphs whose original - // cmap subtable PDFium can't follow (e.g. Office's embedded Calibri). Smart - // punctuation is normalized to ASCII upstream (asciiizePunctuation), so the rare - // "glyph at a custom code" case (the en-dash) never reaches here. - auto subset = fonts::pdf_fonts::FontSubset::buildSubsetByUnicode(fontDataRes.value(), codepoints); + // cmap subtable PDFium can't follow (e.g. Office's embedded Calibri). + auto subset = fonts::pdf_fonts::FontSubset::buildSubsetByUnicode(sourceBytes, codepoints); std::lock_guard lock(loadedFontsMutex_); - loadedFontDataBuffers_[cacheKey] = !subset.empty() ? std::move(subset) : fontDataRes.value(); + loadedFontDataBuffers_[cacheKey] = !subset.empty() ? std::move(subset) : std::move(sourceBytes); const auto& bytes = loadedFontDataBuffers_[cacheKey]; out.font = FPDFText_LoadFont(doc_, bytes.data(), static_cast(bytes.size()), FPDF_FONT_TRUETYPE, true); } @@ -2508,7 +2566,9 @@ std::expected PdfiumDocument::applyEdits(const std::string& e }; auto parseRun = [&](const nlohmann::json& rj) { RunStyle rs; - rs.text = pdfengine::parser::asciiizePunctuation(rj.value("text", "")); + // Keep raw Unicode here; smart-punctuation handling is coverage-aware and + // happens AFTER the emission font is resolved (see normalizeReflowText below). + rs.text = rj.value("text", ""); rs.internalFontId = rj.value("internalFontId", ""); rs.fontSize = rj.value("fontSize", 12.0); parseHex(rj.value("color", std::string("#000000")), rs.r, rs.g, rs.b); @@ -2584,12 +2644,46 @@ std::expected PdfiumDocument::applyEdits(const std::string& e auto& dst = fontCps[rs.internalFontId]; dst.insert(dst.end(), cps.begin(), cps.end()); } + // Resolve each run font's ORIGINAL FPDF_FONT handle straight from the + // paragraph's own (still-open) source objects. Reusing that existing embedded + // font for emission — instead of re-subsetting and FPDFText_LoadFont'ing a NEW + // same-named font — is what prevents the multi-edit scramble: two embedded + // subsets that share a /BaseFont (e.g. two "Calibri") otherwise alias inside + // PDFium and the 2nd edit's text renders through the 1st's glyphs ("Core Java" + // -> "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) { + FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, idx); + if (!o || FPDFPageObj_GetType(o) != FPDF_PAGEOBJ_TEXT) continue; + FPDF_FONT fo = FPDFTextObj_GetFont(o); + if (!fo) continue; + size_t nl = FPDFFont_GetBaseFontName(fo, nullptr, 0); + if (nl == 0) continue; + std::vector nb(nl); + if (FPDFFont_GetBaseFontName(fo, nb.data(), nl) == 0) continue; + if (std::string(nb.data()) == expected) return fo; + } + return nullptr; + }; 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]); + fontByFid[rs.internalFontId] = loadEmissionFont(pageIndex, rs.internalFontId, rs.fontSize, fontCps[rs.internalFontId], objectIndices, resolveOrigFont(rs.internalFontId)); } for (size_t ri = 0; ri < runs.size(); ++ri) runFonts[ri] = fontByFid[runs[ri].internalFontId]; + + // Coverage-aware smart-punctuation pass: now that each run's emission font is + // known, keep the real en-dash / curly quotes / ellipsis where that font has the + // glyph (reused embedded face = the source text's own font, so the chars it + // originally showed stay), and ASCII-degrade only the genuinely-missing ones so + // nothing renders as a notdef box. Runs through before tokenize/measure/emit. + for (size_t ri = 0; ri < runs.size(); ++ri) { + const fonts::FontFace* face = runFonts[ri].measureFace + ? runFonts[ri].measureFace.get() + : (runFonts[ri].resolved ? &runFonts[ri].resolved->getFontFace() : nullptr); + runs[ri].text = normalizeReflowText(runs[ri].text, face); + } } // Measure a substring's width in a run's resolved font. HbShaper takes an @@ -3862,11 +3956,7 @@ std::expected, EngineError> PdfiumPage::getFonts() const { // --- Recalculate stable identifier with corrected data --- // fontName already includes the subset prefix (e.g. "ABCDEF+Arial"); // using it directly avoids the duplicate-prefix bug. - if (f.isSubset && !f.subsetTag.empty()) { - f.internalFontId = f.fontName; - } else { - f.internalFontId = f.fontName + "_" + f.type + "_" + std::to_string(f.flags); - } + f.internalFontId = makeInternalFontId(f); spdlog::debug( "Font '{}': isEmbedded={} type='{}' ascent={:.1f} descent={:.1f} " @@ -3982,6 +4072,49 @@ std::expected, EngineError> PdfiumDocument::getFonts(int s #endif } +std::optional> +PdfiumDocument::getFontDataFromObjects(int pageIndex, const std::vector& objectIndices, + const std::string& internalFontId) const { +#ifdef PDFENGINE_WITH_PDFIUM + // Resolve the embedded font program from the SPECIFIC page objects of the paragraph being + // edited, instead of a document-wide name scan (getFontData). Those objects still reference + // the paragraph's ORIGINAL font; a prior reflow's same-named subset lives in a DIFFERENT + // paragraph's objects, so it can never be picked here. This is what makes the 2nd, 3rd, Nth + // edit in one session commit correctly instead of scrambling ("Core Java" -> "Cuwi Le,e"). + ensure_pdfium_initialized(); + if (!doc_ || objectIndices.empty()) return std::nullopt; + const std::string expected = baseNameFromInternalFontId(internalFontId); + FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex); + if (!page) return std::nullopt; + std::optional> result; + for (int idx : objectIndices) { + FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page, idx); + if (!obj || FPDFPageObj_GetType(obj) != FPDF_PAGEOBJ_TEXT) continue; + FPDF_FONT font = FPDFTextObj_GetFont(obj); + if (!font) continue; + size_t nameLen = FPDFFont_GetBaseFontName(font, nullptr, 0); + if (nameLen == 0) continue; + std::vector nameBuf(nameLen); + if (FPDFFont_GetBaseFontName(font, nameBuf.data(), nameLen) == 0) continue; + if (std::string(nameBuf.data()) != expected) continue; // exact /BaseFont match + size_t buflen = 0; + FPDFFont_GetFontData(font, nullptr, 0, &buflen); + if (buflen == 0) continue; + std::vector buffer(buflen); + size_t actualLen = 0; + if (FPDFFont_GetFontData(font, buffer.data(), buflen, &actualLen) && actualLen > 0) { + result = std::move(buffer); + break; + } + } + FPDF_ClosePage(page); + return result; +#else + (void)pageIndex; (void)objectIndices; (void)internalFontId; + return std::nullopt; +#endif +} + std::expected, EngineError> PdfiumDocument::getFontData(const std::string& internalFontId) const { #ifdef PDFENGINE_WITH_PDFIUM ensure_pdfium_initialized(); @@ -3989,19 +4122,8 @@ std::expected, EngineError> PdfiumDocument::getFontData(con return std::unexpected(EngineError::Unknown); } - // Reconstruct the exact expected font name from the internalFontId. - // Non-subset format: {fontName}_{type}_{flags} - std::string expectedFontName = internalFontId; - size_t lastUnderscore = expectedFontName.rfind('_'); - if (lastUnderscore != std::string::npos && lastUnderscore > 0) { - size_t secondLastUnderscore = expectedFontName.rfind('_', lastUnderscore - 1); - if (secondLastUnderscore != std::string::npos) { - std::string typePart = expectedFontName.substr(secondLastUnderscore + 1, lastUnderscore - secondLastUnderscore - 1); - if (typePart == "TrueType" || typePart == "Type1" || typePart == "CIDFontType0" || typePart == "CIDFontType2") { - expectedFontName = expectedFontName.substr(0, secondLastUnderscore); - } - } - } + // Reconstruct the exact expected /BaseFont name from the internalFontId. + std::string expectedFontName = baseNameFromInternalFontId(internalFontId); int numPages = FPDF_GetPageCount(doc_); diff --git a/engine/src/parser/pdfium_document.hpp b/engine/src/parser/pdfium_document.hpp index 5c0f46c..7f6b07f 100644 --- a/engine/src/parser/pdfium_document.hpp +++ b/engine/src/parser/pdfium_document.hpp @@ -11,6 +11,7 @@ #include #include #include +#include #include namespace pdfengine::fonts::loader { class FontResolver; } namespace pdfengine::fonts { class FontFace; } @@ -118,6 +119,15 @@ private: mutable std::unordered_map> fontDataCache_; mutable int fontDataScannedPages_ = 0; + // Resolve an embedded font program from the SPECIFIC page objects of the paragraph being + // edited (its objectIndices), matching each object's /BaseFont to internalFontId. Used by + // reflow so a re-subset re-uses the exact font its source text referenced — no document-wide + // name collision when several same-named subsets accumulate across sequential edits. + // Returns nullopt if no object in the set uses that font. + std::optional> + getFontDataFromObjects(int pageIndex, const std::vector& objectIndices, + const std::string& internalFontId) const; + // Weak so the document never co-owns its pages: ownership runs page → document // only. A cached entry is reused while a page is still referenced elsewhere, // and lazily rebuilt once it expires. @@ -147,8 +157,15 @@ private: // matches what PDFium renders (the resolver's face can have different advances). std::shared_ptr measureFace; }; + // srcObjects = the page objects of the paragraph being reflowed (for per-object font + // resolution); pass {} when no source objects are known (falls back to the name scan). + // reuseFont = the ORIGINAL embedded FPDF_FONT of the source text (from a still-open page); + // when non-null the embedded path reuses it directly instead of re-subsetting + loading a new + // same-named font (which PDFium aliases, scrambling multi-edit output). null => subset+load. EmissionFont loadEmissionFont(int pageIndex, const std::string& internalFontId, - double fontSize, const std::vector& codepoints); + double fontSize, const std::vector& codepoints, + const std::vector& srcObjects = {}, + FPDF_FONT reuseFont = nullptr); #endif }; diff --git a/frontend/public/pdfium-engine.wasm b/frontend/public/pdfium-engine.wasm index 6ca0402..a44713a 100644 Binary files a/frontend/public/pdfium-engine.wasm and b/frontend/public/pdfium-engine.wasm differ diff --git a/frontend/src/lib/pdfiumEngine.ts b/frontend/src/lib/pdfiumEngine.ts index 8127aef..6956392 100644 --- a/frontend/src/lib/pdfiumEngine.ts +++ b/frontend/src/lib/pdfiumEngine.ts @@ -23,12 +23,7 @@ function getModule(): Promise { if (!modulePromise) { modulePromise = (async () => { try { - // Vite refuses to import /public files as modules ("can only be referenced via HTML - // tags"). So fetch the Emscripten module as TEXT and import it via a Blob URL — Vite - // never sees it as an import, and the browser loads it as a normal ES module. - // Cache-buster: bump V whenever the engine is rebuilt so the browser can never serve a - // stale .mjs/.wasm (a normal hard-reload sometimes keeps the multi-MB .wasm cached). - const V = '20260615e'; + const V = '20260616a'; 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/ParagraphEditor.tsx b/frontend/src/viewer/ParagraphEditor.tsx index 6f06aff..d43389d 100644 --- a/frontend/src/viewer/ParagraphEditor.tsx +++ b/frontend/src/viewer/ParagraphEditor.tsx @@ -5,12 +5,6 @@ import type { ReflowLayout } from '../lib/pdfiumEngine'; import type { ReflowParagraphPayload } from './TextEditLayer'; import type { ReflowFragment } from '../lib/gatewayService'; -// Live paragraph editor with a PIXEL-PERFECT preview. A transparent contentEditable handles -// input + caret (native), while the in-browser WASM PDFium engine renders the edited paragraph -// band IDENTICALLY to the page (same C++ engine) and paints it underneath. So clicking in -// doesn't change the look, and typing reflows in the document's true rendering. Commit goes to -// the gateway (authoritative). Falls back to plain browser text if the WASM engine is unavailable. - interface SeedRun { text: string; fid: string; size: number; color: string; fontName: string; } interface ParagraphLayout { columnLeft: number; columnRight: number; firstBaselineY: number; leading: number; @@ -20,11 +14,10 @@ interface ParagraphEditorProps { documentId: string; pageIndex: number; para: any; - // Bullet-item reflow: full-width push-down left, and overrides for spacing/alignment that the - // sub-paragraph can't infer on its own (a single-line item has no leading; bullets aren't justified). pushColumnLeft?: number; leadingOverride?: number; alignOverride?: 'left' | 'justify'; + columnRightOverride?: number; caretClick?: { x: number; y: number } | null; heightPts: number; zoom: number; @@ -76,18 +69,15 @@ function computeLayout(para: any): ParagraphLayout { return { columnLeft, columnRight, firstBaselineY, leading, oldLineCount: lines.length, align, objectIndices, seedRuns }; } -// Flat styled runs from the editable's current text (each text node → a fragment). The engine -// wraps these into lines itself (greedy break within the column), so the wrap + justification of -// the rendered image and the exported caret layout always agree — no browser layout involved. -function extractFlatRuns(editable: HTMLElement, dominantFid: string): ReflowFragment[] { +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') ?? '12') || 12; - const color = el?.getAttribute('data-color') ?? '#000000'; + const size = parseFloat(el?.getAttribute('data-size') ?? '') || domSize; + const color = el?.getAttribute('data-color') ?? domColor; const text = node.textContent ?? ''; if (text) out.push({ text, internalFontId: fid, fontSize: size, color }); node = walker.nextNode() as Text | null; @@ -95,9 +85,6 @@ function extractFlatRuns(editable: HTMLElement, dominantFid: string): ReflowFrag return out; } -// The paragraph's ORIGINAL line breaks as fragments (one inner array per line). Used for the -// initial render so opening the editor shows the document's existing wrapping verbatim — the -// engine only re-wraps once the user actually edits. function buildOriginalLines(para: any, dominantFid: string): ReflowFragment[][] { const out: ReflowFragment[][] = []; for (const line of para?.lines ?? []) { @@ -160,7 +147,7 @@ function lineStarts(layout: ReflowLayout, fullText: string): number[] { } export const ParagraphEditor: React.FC = ({ - documentId, pageIndex, para, pushColumnLeft, leadingOverride, alignOverride, + documentId, pageIndex, para, pushColumnLeft, leadingOverride, alignOverride, columnRightOverride, caretClick, heightPts, zoom, pageWidthPx, pageHeightPx, onCommit, onCancel, }) => { const layout = useMemo(() => computeLayout(para), [para]); @@ -168,6 +155,8 @@ export const ParagraphEditor: React.FC = ({ // paragraph uses what computeLayout inferred. 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. + const columnRight = columnRightOverride ?? layout.columnRight; const editRef = useRef(null); const committedRef = useRef(false); const initialTextRef = useRef(''); @@ -175,49 +164,40 @@ export const ParagraphEditor: React.FC = ({ const blobUrlRef = useRef(null); const engineLayoutRef = useRef(null); const initialCaretApplied = useRef(false); - // Until the user actually edits, the preview keeps the document's ORIGINAL line breaks (no - // re-wrap), so merely opening the editor doesn't shift anything. Re-wrap kicks in on first edit. const editedRef = useRef(false); const [previewUrl, setPreviewUrl] = useState(null); const [caretBox, setCaretBox] = useState<{ left: number; top: number; height: number } | null>(null); - // True once the user actually edits. Until then we show the UNTOUCHED original page (pixel- - // perfect) rather than the reflow render, so merely opening the editor changes nothing. const [edited, setEdited] = useState(false); - // Falls back to the plain (visible) browser editor only if the WASM engine fails to produce - // a render in a few seconds — so the user is never stuck editing invisible text. const [wasmFailed, setWasmFailed] = useState(false); - // The font most of the paragraph uses — empty-fid runs (spaces, freshly typed text) adopt it - // so the engine emits them in the real document font instead of a generic fallback. const dominantFid = layout.seedRuns.find((r) => r.text.trim() && r.fid)?.fid ?? layout.seedRuns.find((r) => r.fid)?.fid ?? ''; - const domSize = layout.seedRuns.find((r) => r.text.trim())?.size ?? layout.seedRuns[0]?.size ?? 12; + const domRun = layout.seedRuns.find((r) => r.text.trim() && r.fid === dominantFid) + ?? layout.seedRuns.find((r) => r.text.trim()); + const domSize = domRun?.size ?? layout.seedRuns[0]?.size ?? 12; + const domColor = domRun?.color ?? '#000000'; + const domFontName = domRun?.fontName ?? ''; const fontPx = domSize * zoom; const leadingPx = leading * zoom; const colLeftPx = layout.columnLeft * zoom; - const colWidthPx = (layout.columnRight - layout.columnLeft) * zoom; + const colWidthPx = (columnRight - layout.columnLeft) * zoom; const firstBaselineScreen = (heightPts - layout.firstBaselineY) * zoom; const editorTop = firstBaselineScreen - (leadingPx + fontPx * 0.7) / 2; - // The WASM preview repaints the page from the paragraph top down (reflow pushes content below). const bandTop = Math.max(0, editorTop - leadingPx * 0.5); - // Reflow op JSON for the engine. With `lines` (initial render) the engine emits those exact - // breaks — opening the editor changes nothing. Without `lines` (after an edit) it greedy-wraps - // within the column, and its render + exported caret layout agree exactly (no browser layout). const buildOpJson = (runs: ReflowFragment[], originalLines?: ReflowFragment[][]): string => 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' }], ...(originalLines && originalLines.length ? { lines: originalLines } : {}), - columnLeft: layout.columnLeft, columnRight: layout.columnRight, + columnLeft: layout.columnLeft, columnRight, pushColumnLeft: pushColumnLeft ?? layout.columnLeft, firstBaselineY: layout.firstBaselineY, leading, oldLineCount: layout.oldLineCount, align, } }], }); - // Caret screen box (px in the page container) for a global char offset, from the engine layout. const caretBoxFor = (global: number, lay: ReflowLayout, fullText: string) => { if (!lay.lines.length) return null; const starts = lineStarts(lay, fullText); @@ -272,7 +252,7 @@ export const ParagraphEditor: React.FC = ({ const dpi = Math.round(72 * zoom); // Before the first edit, keep the document's original line breaks (no re-wrap on open). const originalLines = editedRef.current ? undefined : buildOriginalLines(para, dominantFid); - const { blob, layout: lay } = await wasmPreviewRender(documentId, pageIndex, dpi, buildOpJson(extractFlatRuns(el, dominantFid), originalLines)); + const { blob, layout: lay } = await wasmPreviewRender(documentId, pageIndex, dpi, buildOpJson(extractFlatRuns(el, dominantFid, domSize, domColor), originalLines)); if (!blob) { console.warn('[ParagraphEditor] WASM preview returned null'); return; } engineLayoutRef.current = lay; const url = URL.createObjectURL(blob); @@ -372,12 +352,12 @@ export const ParagraphEditor: React.FC = ({ if (!el) { onCancel(); return; } const nowText = (el.textContent ?? '').replace(/\s+/g, ' ').trim(); if (nowText === initialTextRef.current) { onCancel(); return; } - const flat = extractFlatRuns(el, dominantFid); + const flat = extractFlatRuns(el, dominantFid, domSize, domColor); if (flat.length === 0) { onCancel(); return; } // 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: layout.columnRight, + columnLeft: layout.columnLeft, columnRight, pushColumnLeft: pushColumnLeft ?? layout.columnLeft, firstBaselineY: layout.firstBaselineY, leading, oldLineCount: layout.oldLineCount, align, @@ -421,6 +401,10 @@ export const ParagraphEditor: React.FC = ({ contentEditable suppressContentEditableWarning spellCheck={false} + data-fid={dominantFid} + data-size={String(domSize)} + data-color={domColor} + data-fontname={domFontName} onInput={onInput} onClick={onClickEditor} onKeyUp={positionCaret} diff --git a/frontend/src/viewer/TextEditLayer.tsx b/frontend/src/viewer/TextEditLayer.tsx index e85a902..355c481 100644 --- a/frontend/src/viewer/TextEditLayer.tsx +++ b/frontend/src/viewer/TextEditLayer.tsx @@ -58,16 +58,42 @@ export function isBulletMarker(text?: string): boolean { // or a leading non-bullet block). Returns a sub-paragraph whose lines exclude the bullet marker // (so it stays put) with the text column starting at the hanging indent, plus the full-width // pushColumnLeft and the paragraph's true leading. -export function buildBulletItem(para: any, runLineIndex: number): { subPara: any; pushColumnLeft: number; leading: number } | null { +export function buildBulletItem(para: any, runLineIndex: number): { subPara: any; pushColumnLeft: number; leading: number; columnRight: number } | null { const lines = para?.lines ?? []; if (!lines.length) return null; const colLeft = Math.min(...lines.map((l: any) => l.x)); - const isStart = (l: any) => isBulletMarker((l.runs ?? [])[0]?.text); - // Item = from the nearest marker line at/above the click (or paragraph start) to before the next. + // True text right margin = the widest line across the WHOLE paragraph (sibling bullets reach the + // real margin even when the clicked item's own longest line falls a few pt short). Reflowing + // within the item's own narrower right edge gives zero slack, so a hair-wider re-measurement + // strands the last word onto a new line (e.g. "SOLID," dropping below). Use the true margin. + const colRight = Math.max(...lines.map((l: any) => l.x + l.w)); + // A paragraph can mix logical blocks the model groups together: an intro line, a bold + // sub-heading, then bullets. Split on (a) bullet markers AND (b) a flush-left line whose + // leading font/weight differs from the line above it — a structural transition (intro→heading, + // heading→body). Bullet CONTINUATION lines hang at the text indent (not flush-left) and a + // same-font wrapped intro keeps the same font, so neither is mis-split — pure bullet lists and + // plain paragraphs behave exactly as before; only genuine mixed structure reflows per block. + const leadFontKey = (l: any): string => { + const r = (l.runs ?? []).find((x: any) => (x.text ?? '').trim() && !isBulletMarker(x.text)); + return r?.internal_font_id ?? ''; + }; + // "Flush-left" = at the block's marker column, clearly LEFT of the bullet hanging indent (where + // continuation lines and bullet text begin). Use the midpoint between colLeft and that hanging + // indent as the threshold so a wrapped bullet continuation is never mistaken for a new heading. + const hangX = Math.min(...lines.map((l: any) => l.x).filter((x: number) => x > colLeft + 1)); + const flushThresh = isFinite(hangX) ? colLeft + (hangX - colLeft) * 0.5 : colLeft + 4; + const flushLeft = (l: any) => l.x <= flushThresh; + const isStart = (idx: number): boolean => { + const l = lines[idx]; + if (isBulletMarker((l.runs ?? [])[0]?.text)) return true; + if (idx === 0) return true; + return flushLeft(l) && leadFontKey(l) !== '' && leadFontKey(l) !== leadFontKey(lines[idx - 1]); + }; + // Item = from the nearest block-start line at/above the click (or paragraph start) to the next. let start = runLineIndex; - while (start > 0 && !isStart(lines[start])) start--; + while (start > 0 && !isStart(start)) start--; let end = runLineIndex + 1; - while (end < lines.length && !isStart(lines[end])) end++; + while (end < lines.length && !isStart(end)) end++; const itemLines = lines.slice(start, end); if (!itemLines.length) return null; @@ -92,7 +118,7 @@ export function buildBulletItem(para: any, runLineIndex: number): { subPara: any ? { ...l, runs: textRuns, x: textIndent, w: (l.x + l.w) - textIndent } : l); } - return { subPara: { ...para, lines: subLines }, pushColumnLeft: colLeft, leading }; + return { subPara: { ...para, lines: subLines }, pushColumnLeft: colLeft, leading, columnRight: colRight }; } // True only for a genuine FLOWING paragraph — multiple lines that fill the column from a common @@ -284,7 +310,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'; + para: any; pushColumnLeft?: number; leading?: number; align?: 'left' | 'justify'; 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). @@ -338,7 +364,7 @@ export const TextEditLayer: React.FC = ({ const item = buildBulletItem(para, run.lineIndex); if (item) { setCaretClick(click); - setParaEdit({ para: item.subPara, pushColumnLeft: item.pushColumnLeft, leading: item.leading, align: 'left' }); + setParaEdit({ para: item.subPara, pushColumnLeft: item.pushColumnLeft, leading: item.leading, align: 'left', columnRight: item.columnRight }); return; } // else: couldn't scope an item → fall through to per-line in-place editing @@ -419,6 +445,7 @@ export const TextEditLayer: React.FC = ({ pushColumnLeft={paraEdit.pushColumnLeft} leadingOverride={paraEdit.leading} alignOverride={paraEdit.align} + columnRightOverride={paraEdit.columnRight} caretClick={caretClick} heightPts={heightPts} zoom={zoom} diff --git a/gateway/app/routers/edits.py b/gateway/app/routers/edits.py index 1ee3367..e8aff48 100644 --- a/gateway/app/routers/edits.py +++ b/gateway/app/routers/edits.py @@ -239,15 +239,7 @@ class ReflowParagraphData(BaseModel): leading: float oldLineCount: int = 1 align: Literal["left", "justify"] = "left" - # Push-down column left edge (defaults to columnLeft). For a bullet item the text reflows from - # a hanging indent (columnLeft) but the push-down spans the full width from pushColumnLeft so - # bullet markers and items below move together. pushColumnLeft: float | None = None - # Optional WYSIWYG mode: exact visual line breaks from the live editor (one inner list of - # styled fragments per line). When present the engine emits these breaks verbatim. - lines: list[list[ReflowRun]] | None = None - # Optional WYSIWYG mode: exact visual line breaks from the live editor (one inner list of - # styled fragments per line). When present the engine emits these breaks verbatim. lines: list[list[ReflowRun]] | None = None @@ -259,8 +251,6 @@ class ReflowParagraphOperation(BaseModel): class DecorationData(BaseModel): - # Text-markup annotation (underline/strikeout/squiggly) over one or more text - # lines — quadpoints in PDF top-down space, mirroring HighlightData. quadPoints: list[HighlightQuadPoint] color: str = "#000000" author: str = "User" @@ -336,8 +326,6 @@ def apply_edits_impl(document_id: str, request: EditsRequest): if not doc_info: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found") - # Enforce document permissions (defense-in-depth; the UI also gates these). - # Done before the try so the 403 isn't rewritten to 400 by the broad handler. perms = doc_info.get("permissions") or {} for op in request.operations: required = _OP_PERMISSION.get(op.type) diff --git a/tests/edits/_reflow_repro.py b/tests/edits/_reflow_repro.py new file mode 100644 index 0000000..e6a87d2 --- /dev/null +++ b/tests/edits/_reflow_repro.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +"""Scratch harness: reproduce the multi-edit font-collision scramble, and (after the +fix) verify it's gone. Reads engine output through pybind (NOT HTTP/stdin) to avoid the +Windows cp1252 mojibake trap. Writes a UTF-8 report to _reflow_repro.out.txt. + +Run: gateway/.venv/Scripts/python.exe tests/edits/_reflow_repro.py +""" +from __future__ import annotations + +import io +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "gateway")) +import pdfengine # noqa: E402 + +PDF = ROOT / "Mo-Faishal-Qureshi.pdf" +OUT = Path(__file__).resolve().parent / "_reflow_repro.out.txt" +report = io.StringIO() + + +def log(*a): + report.write(" ".join(str(x) for x in a) + "\n") + + +def para_text(p): + return " ".join(r.text for l in p.lines for r in l.runs) + + +def para_fonts(p): + return sorted({r.internal_font_id for l in p.lines for r in l.runs if r.text.strip()}) + + +def build_reflow_op(p, page_index, new_runs=None): + """Build a reflow_paragraph op for paragraph p. If new_runs is None, re-emit the + paragraph's own runs unchanged (so a correct engine is a no-op on text).""" + runs = [] + obj_idx = [] + for l in p.lines: + for r in l.runs: + obj_idx.extend(list(r.object_indices)) + if r.text == "": + continue + runs.append({ + "text": r.text, + "internalFontId": r.internal_font_id, + "fontSize": r.font_size, + "color": "#000000", + }) + if new_runs is not None: + runs = new_runs + baselines = [l.baseline_y for l in p.lines] + first_baseline = baselines[0] if baselines else (p.y + p.h) + if len(baselines) >= 2: + leading = abs(baselines[0] - baselines[1]) + else: + leading = p.lines[0].h if p.lines else 14.0 + if leading <= 0: + leading = 14.0 + return { + "id": "rf", "type": "reflow_paragraph", "pageIndex": page_index, + "data": { + "objectIndices": sorted(set(obj_idx)), + "runs": runs, + "columnLeft": p.x, + "columnRight": p.x + p.w, + "firstBaselineY": first_baseline, + "leading": leading, + "oldLineCount": len(p.lines), + "align": "left", + }, + } + + +def dump_structure(): + doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "") + npages = 0 + while True: + try: + doc.get_page(npages) + npages += 1 + except Exception: + break + log(f"PDF: {PDF.name} pages={npages}") + for pi in range(npages): + m = doc.get_page(pi).extract_document_model() + log(f"\n=== PAGE {pi} ({m.width:.0f}x{m.height:.0f}) paragraphs={len(m.paragraphs)} ===") + for i, p in enumerate(m.paragraphs): + t = para_text(p) + log(f" [{pi}.{i}] lines={len(p.lines)} fonts={para_fonts(p)}") + log(f" text={t[:90]!r}") + + +def find_para(model, needle): + for i, p in enumerate(model.paragraphs): + if needle in para_text(p): + return i, p + return -1, None + + +def repro(page_index=0, a_needle="Java Backend Developer", b_needle="Core Java", + survive=("Core", "Java")): + """Faithful app flow: edit paragraph A (modified), re-extract, then re-emit + paragraph B UNCHANGED. B's text must survive intact in the committed PDF.""" + # ---- CONTROL: B-only on a fresh doc (proves B alone is fine) ---- + doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "") + m0 = doc.get_page(page_index).extract_document_model() + bi, pb = find_para(m0, b_needle) + log(f"\n--- CONTROL: re-emit B [{page_index}.{bi}] unchanged (no prior edit) ---") + log(f" B text(before) = {para_text(pb)[:80]!r}") + doc.apply_edits(json.dumps({"version": "1.0", "operations": [build_reflow_op(pb, page_index)]})) + m_ctrl = pdfengine.PdfDocument.load_from_memory(doc.save_full(), "").get_page(page_index).extract_document_model() + _, pb_ctrl = find_para(m_ctrl, survive[0]) + ctrl_text = para_text(pb_ctrl) if pb_ctrl else "" + log(f" B text(after) = {ctrl_text[:80]!r}") + ctrl_ok = all(s in ctrl_text for s in survive) + log(f" CONTROL survive={survive} -> {'OK' if ctrl_ok else 'LOST'}") + + # ---- TEST: edit A first, re-extract, then re-emit B unchanged ---- + doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "") + m0 = doc.get_page(page_index).extract_document_model() + ai, pa = find_para(m0, a_needle) + log(f"\n--- TEST: edit A [{page_index}.{ai}] THEN re-emit B unchanged (same doc) ---") + # Modify A: prepend a word to its first run. + a_runs = [] + first = True + for l in pa.lines: + for r in l.runs: + if r.text == "": + continue + txt = ("EDITED " + r.text) if first else r.text + first = False + a_runs.append({"text": txt, "internalFontId": r.internal_font_id, + "fontSize": r.font_size, "color": "#000000"}) + doc.apply_edits(json.dumps({"version": "1.0", + "operations": [build_reflow_op(pa, page_index, new_runs=a_runs)]})) + # Re-extract (frontend re-fetches the model after each edit) and re-locate B. + m1 = doc.get_page(page_index).extract_document_model() + bi2, pb2 = find_para(m1, b_needle) + if pb2 is None: + log(f" !! B not found after edit A (searched {b_needle!r})") + else: + log(f" B text(before 2nd edit) = {para_text(pb2)[:80]!r}") + doc.apply_edits(json.dumps({"version": "1.0", "operations": [build_reflow_op(pb2, page_index)]})) + # In-memory (pre-save) B text: localizes corruption to emission vs save-merge. + m_inmem = doc.get_page(page_index).extract_document_model() + _, pb_inmem = find_para(m_inmem, survive[0]) + log(f" B text(in-memory, pre-save) = {(para_text(pb_inmem) if pb_inmem else '')[:80]!r}") + m2 = pdfengine.PdfDocument.load_from_memory(doc.save_full(), "").get_page(page_index).extract_document_model() + _, pb_test = find_para(m2, survive[0]) + test_text = para_text(pb_test) if pb_test else "" + log(f" B text(after) = {test_text[:80]!r}") + test_ok = all(s in test_text for s in survive) + log(f" TEST survive={survive} -> {'OK' if test_ok else 'SCRAMBLED/LOST'}") + if not test_ok: + # Show the corrupted bullets paragraph (locate by leftover 'Languages'/'ang' or dump near B). + for i, p in enumerate(m2.paragraphs): + t = para_text(p) + if "ang" in t or "OOP" in t or "Collec" in t or "Stream" in t or (b_needle[:3] in t): + log(f" >> scrambled B candidate [{page_index}.{i}] = {t[:90]!r}") + log(f"\n==> CONTROL={'OK' if ctrl_ok else 'FAIL'} TEST={'OK' if test_ok else 'FAIL'} " + f"(bug reproduced if CONTROL=OK and TEST=FAIL)") + + +def stress(): + """Sequentially reflow EVERY paragraph on both pages (re-extracting between each, like the + real app), then assert distinctive substrings from each survive — catches any font scramble + across non-subset AND subset (BCDKEE+Calibri...) fonts under heavy multi-edit.""" + # Distinctive substrings to verify survive on each page after all edits. + checks = { + 0: ["Professional", "Backend", "Core", "Java", "Frameworks", "Microservices", + "TapQwik", "Present", "LG", "Commerce"], + 1: ["Architected", "Wego", "Booking", "Education", "Bachelor", "Certification", + "Kafka", "Problem", "Ownership"], + } + # Edit (reflow-in-place) the paragraph CONTAINING each anchor, sequentially, re-extracting + # between edits — exactly how a user edits N paragraphs in one session. + anchors = { + 0: ["Backend", "Core Java", "Mar 2023", "LG"], + 1: ["Architected", "Wego", "Java Development", "Problem"], + } + failures = [] + for pi in (0, 1): + doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "") + log(f"\n=== STRESS page {pi}: sequentially reflowing {len(anchors[pi])} targeted paragraphs ===") + for anchor in anchors[pi]: + m = doc.get_page(pi).extract_document_model() + _, p = find_para(m, anchor) + if p is None: + log(f" anchor {anchor!r}: paragraph not found (possibly merged by a prior edit)") + continue + try: + doc.apply_edits(json.dumps({"version": "1.0", "operations": [build_reflow_op(p, pi)]})) + except Exception as exc: + log(f" anchor {anchor!r}: apply failed: {exc}") + final = para_textall(doc.get_page(pi).extract_document_model()) + for needle in checks[pi]: + if needle not in final: + failures.append((pi, needle)) + present = [n for n in checks[pi] if n in final] + log(f" page {pi}: {len(present)}/{len(checks[pi])} substrings survived: " + f"missing={[n for n in checks[pi] if n not in final]}") + log(f"\n==> STRESS {'PASS' if not failures else 'FAIL ' + str(failures)}") + + +def para_textall(model): + return " ".join(r.text for p in model.paragraphs for l in p.lines for r in l.runs) + + +def render_after_edits(): + """Apply A (Summary) then B (bullets) on one doc, save, reload, render page 0 to PNG — + GROUND TRUTH: is the committed PDF visually corrupt, or only text extraction?""" + from PIL import Image # noqa: PLC0415 + doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "") + m0 = doc.get_page(0).extract_document_model() + _, pa = find_para(m0, "Java Backend Developer") + a_runs = [] + first = True + for l in pa.lines: + for r in l.runs: + if r.text == "": + continue + a_runs.append({"text": ("EDITED " + r.text) if first else r.text, + "internalFontId": r.internal_font_id, "fontSize": r.font_size, "color": "#000000"}) + first = False + doc.apply_edits(json.dumps({"version": "1.0", "operations": [build_reflow_op(pa, 0, new_runs=a_runs)]})) + m1 = doc.get_page(0).extract_document_model() + _, pb = find_para(m1, "Core Java") + doc.apply_edits(json.dumps({"version": "1.0", "operations": [build_reflow_op(pb, 0)]})) + out = doc.save_full() + img = pdfengine.PdfDocument.load_from_memory(out, "").get_page(0).render(150) + data = img.data + png = Path(__file__).resolve().parent / "_reflow_render.png" + n = img.width * img.height + if len(data) >= 8 and data[:8] == b"\x89PNG\r\n\x1a\n": + png.write_bytes(data) # already PNG-encoded + else: + mode = "RGBA" if len(data) == n * 4 else ("RGB" if len(data) == n * 3 else None) + from PIL import Image # noqa: PLC0415 + Image.frombytes(mode, (img.width, img.height), data).convert("RGB").save(png) + log(f"rendered committed page0 -> {png} ({img.width}x{img.height}, {len(data)} bytes)") + + +def probe_fonts(): + doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "") + for pi in (0, 1): + fonts = doc.get_page(pi).get_fonts() + log(f"\n=== PAGE {pi} fonts ({len(fonts)}) ===") + for f in fonts: + log(f" font_name={f.font_name!r:34} internal_id={f.internal_font_id!r:28} " + f"type={f.type!r:12} flags={f.flags:<4} subset={f.is_subset} tag={f.subset_tag!r} " + f"embedded={f.is_embedded}") + + +if __name__ == "__main__": + mode = sys.argv[1] if len(sys.argv) > 1 else "dump" + if mode == "dump": + dump_structure() + elif mode == "repro": + repro() + elif mode == "repro_diff": + # A = Calibri body (Summary); B = a Calibri-Bold-only heading ("Projects"). + # Different base fonts -> if B survives, the scramble is same-/BaseFont aliasing. + repro(a_needle="Java Backend Developer", b_needle="Projects", survive=("Projects",)) + elif mode == "probe": + probe_fonts() + elif mode == "render": + render_after_edits() + elif mode == "stress": + stress() + OUT.write_text(report.getvalue(), encoding="utf-8") + print(f"wrote {OUT}")