fix: reflow for bullets

This commit is contained in:
Furqan-14
2026-06-16 14:13:14 +05:30
parent a84ff13170
commit d53f059952
10 changed files with 532 additions and 108 deletions
+11
View File
@@ -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_;
}
+6
View File
@@ -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);
+166 -44
View File
@@ -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_);
+18 -1
View File
@@ -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.
+1 -6
View File
@@ -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' }));
+21 -37
View File
@@ -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}
+35 -8
View File
@@ -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}
-12
View File
@@ -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)
+274
View File
@@ -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}")