fix: reflow for bullets
This commit is contained in:
@@ -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<std::mutex> 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_;
|
||||
}
|
||||
|
||||
@@ -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<GlyphBitmap> renderGlyph(unsigned int glyphIndex, unsigned int fontSize);
|
||||
|
||||
|
||||
@@ -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<uint8_t> 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<std::shared_ptr<PdfPage>, EngineError> PdfiumDocument::getPage(int
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont(
|
||||
int pageIndex, const std::string& internalFontId, double fontSize,
|
||||
const std::vector<uint32_t>& codepoints) {
|
||||
const std::vector<uint32_t>& codepoints, const std::vector<int>& 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<fonts::FontFace>();
|
||||
if (mf->loadFromMemory(*perObj)) out.measureFace = mf;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<uint8_t> 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<std::mutex> 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<uint32_t>(bytes.size()), FPDF_FONT_TRUETYPE, true);
|
||||
}
|
||||
@@ -2508,7 +2566,9 @@ std::expected<void, EngineError> 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<void, EngineError> 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<char> nb(nl);
|
||||
if (FPDFFont_GetBaseFontName(fo, nb.data(), nl) == 0) continue;
|
||||
if (std::string(nb.data()) == expected) return fo;
|
||||
}
|
||||
return nullptr;
|
||||
};
|
||||
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]);
|
||||
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<std::vector<FontInfo>, 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<std::vector<FontInfo>, EngineError> PdfiumDocument::getFonts(int s
|
||||
#endif
|
||||
}
|
||||
|
||||
std::optional<std::vector<uint8_t>>
|
||||
PdfiumDocument::getFontDataFromObjects(int pageIndex, const std::vector<int>& 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<std::vector<uint8_t>> 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<char> 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<uint8_t> 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<std::vector<uint8_t>, EngineError> PdfiumDocument::getFontData(const std::string& internalFontId) const {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
ensure_pdfium_initialized();
|
||||
@@ -3989,19 +4122,8 @@ std::expected<std::vector<uint8_t>, 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_);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <unordered_map>
|
||||
namespace pdfengine::fonts::loader { class FontResolver; }
|
||||
namespace pdfengine::fonts { class FontFace; }
|
||||
@@ -118,6 +119,15 @@ private:
|
||||
mutable std::unordered_map<std::string, std::vector<uint8_t>> 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<std::vector<uint8_t>>
|
||||
getFontDataFromObjects(int pageIndex, const std::vector<int>& 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<fonts::FontFace> 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<uint32_t>& codepoints);
|
||||
double fontSize, const std::vector<uint32_t>& codepoints,
|
||||
const std::vector<int>& srcObjects = {},
|
||||
FPDF_FONT reuseFont = nullptr);
|
||||
#endif
|
||||
};
|
||||
|
||||
|
||||
Binary file not shown.
@@ -23,12 +23,7 @@ function getModule(): Promise<PdfiumModule | null> {
|
||||
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' }));
|
||||
|
||||
@@ -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<ParagraphEditorProps> = ({
|
||||
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<ParagraphEditorProps> = ({
|
||||
// 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<HTMLDivElement>(null);
|
||||
const committedRef = useRef(false);
|
||||
const initialTextRef = useRef('');
|
||||
@@ -175,49 +164,40 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
const engineLayoutRef = useRef<ReflowLayout | null>(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<string | null>(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<ParagraphEditorProps> = ({
|
||||
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<ParagraphEditorProps> = ({
|
||||
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<ParagraphEditorProps> = ({
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
spellCheck={false}
|
||||
data-fid={dominantFid}
|
||||
data-size={String(domSize)}
|
||||
data-color={domColor}
|
||||
data-fontname={domFontName}
|
||||
onInput={onInput}
|
||||
onClick={onClickEditor}
|
||||
onKeyUp={positionCaret}
|
||||
|
||||
@@ -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<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';
|
||||
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<TextEditLayerProps> = ({
|
||||
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
|
||||
@@ -395,6 +421,7 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
||||
pushColumnLeft={paraEdit.pushColumnLeft}
|
||||
leadingOverride={paraEdit.leading}
|
||||
alignOverride={paraEdit.align}
|
||||
columnRightOverride={paraEdit.columnRight}
|
||||
caretClick={caretClick}
|
||||
heightPts={heightPts}
|
||||
zoom={zoom}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "<B PARA NOT FOUND>"
|
||||
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 '<NOT FOUND>')[: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 "<B PARA NOT FOUND>"
|
||||
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}")
|
||||
Reference in New Issue
Block a user