diff --git a/bindings/python/pdfengine_py.cpp b/bindings/python/pdfengine_py.cpp index 9f85249..0005166 100644 --- a/bindings/python/pdfengine_py.cpp +++ b/bindings/python/pdfengine_py.cpp @@ -54,10 +54,6 @@ void get_or_throw(std::expected&& res) { #include #include -// A TJ array number more negative than this is treated as an inter-word space when flattening a -// TJ run to display/edit text (numbers are thousandths-of-em, subtracted from the X position; a -// word space is a large positive gap = large-negative adjustment). extract and replace MUST share -// this constant so the flattened text they produce stays positionally aligned (see P1a redistribution). static constexpr double kTjSpaceKern = -500.0; class StreamEditor { @@ -140,14 +136,6 @@ public: } if (!combinedText.empty()) { if (textCount == object_index) { - // P1a — preserve TJ positioning. When the edit keeps the SAME length as the - // extracted text (the typo-fix common case), slice new_text back into the - // original string slots and KEEP every kern number, so glyph spacing/kerning - // survives. We mirror extract's reconstruction (a synthetic space per - // large-negative kern) to keep positions aligned; if a synthetic-space - // position was itself edited (can't map cleanly) or the length changed, we - // fall back to a single string at natural advances — exactly the old behaviour, - // so this is never worse than before, only better when it aligns. bool redistributed = false; if (new_text.size() == combinedText.size()) { std::vector> assign; @@ -162,7 +150,7 @@ public: } else if (item->type == pdfengine::AstNodeType::Number && item->numberValue < kTjSpaceKern) { if (pos >= new_text.size() || new_text[pos] != ' ') { ok = false; break; } - pos += 1; // consume the synthetic space; keep the kern number as-is + pos += 1; } } if (ok && pos == new_text.size()) { @@ -316,7 +304,8 @@ PYBIND11_MODULE(pdfengine, m) { .def_readonly("w", &pdfengine::TextRun::w) .def_readonly("h", &pdfengine::TextRun::h) .def_readonly("object_indices", &pdfengine::TextRun::objectIndices) - .def_readonly("fill_color", &pdfengine::TextRun::fillColor); + .def_readonly("fill_color", &pdfengine::TextRun::fillColor) + .def_readonly("para_id", &pdfengine::TextRun::paraId); py::class_(m, "TextLine") .def_readonly("runs", &pdfengine::TextLine::runs) @@ -365,8 +354,6 @@ PYBIND11_MODULE(pdfengine, m) { return get_or_throw(self.render(dpi)); }, py::arg("dpi") = 96) .def("render_region_raw", [](const pdfengine::PdfPage& self, int dpi, double y_top_pt, double height_pt) { - // Returns (width, height, raw RGBA bytes) of the band [y_top_pt, y_top_pt+height_pt] - // (points from page top); height_pt<=0 → to page bottom. No PNG. For preview/perf tests. auto img = get_or_throw(self.renderRegionRaw(dpi, y_top_pt, height_pt)); return py::make_tuple(img.width, img.height, py::bytes(reinterpret_cast(img.data.data()), img.data.size())); @@ -474,8 +461,6 @@ PYBIND11_MODULE(pdfengine, m) { return get_or_throw(self.getFonts(start_page, end_page)); }, py::arg("start_page") = 0, py::arg("end_page") = -1) .def("get_font_data", [](const pdfengine::PdfDocument& self, const std::string& internal_font_id) { - // Raw embedded font bytes for the given run font; empty bytes when the font - // is not embedded / not found (the gateway maps that to a 404 + CSS fallback). auto res = self.getFontData(internal_font_id); if (!res || res->empty()) { return py::bytes(); diff --git a/engine/include/pdfengine/pdf_document.hpp b/engine/include/pdfengine/pdf_document.hpp index c86e654..c44c709 100644 --- a/engine/include/pdfengine/pdf_document.hpp +++ b/engine/include/pdfengine/pdf_document.hpp @@ -28,23 +28,20 @@ struct DocumentMetadata { std::string modificationDate; }; -// Encryption + permission state of a loaded document. Booleans are the EFFECTIVE -// permissions for how the doc was opened (an owner-unlocked doc reports all true). -// The engine only surfaces these — enforcement happens in the gateway/app layers. struct DocumentPermissions { bool isEncrypted = false; - std::string encryption = "None"; // "None", "RC4-40", "RC4-128", "AES-128", "AES-256" - int securityRevision = -1; // FPDF_GetSecurityHandlerRevision (-1 if unencrypted) - bool ownerUnlocked = false; // encrypted but opened with full (owner) access + std::string encryption = "None"; + int securityRevision = -1; + bool ownerUnlocked = false; bool canPrint = true; bool canPrintHighRes = true; bool canModify = true; - bool canCopy = true; // extract text / graphics - bool canAnnotate = true; // add/modify annotations + bool canCopy = true; + bool canAnnotate = true; bool canFillForms = true; bool canExtractForAccessibility = true; - bool canAssemble = true; // insert / rotate / delete pages + bool canAssemble = true; }; struct PageImage { @@ -72,45 +69,40 @@ struct GlyphBounds { double fontSize; }; -// Result of a point hit-test against a page's glyphs (page-point space, top-left -// origin — the same space GlyphBounds use). struct HitResult { - int glyphIndex = -1; // glyph directly under the point in reading order, -1 if none - int caret = 0; // nearest caret position (0..N) for selection anchoring - int line = -1; // line band the point resolved to, -1 if the page has no text + int glyphIndex = -1; + int caret = 0; + int line = -1; }; -// A resolved text selection: a half-open glyph range plus its reconstructed text -// and per-line union rectangles (for drawing the selection). struct TextSelection { - int startGlyph = 0; // inclusive, reading order - int endGlyph = 0; // exclusive + int startGlyph = 0; + int endGlyph = 0; std::string text; - std::vector rects; // per-line union rects (text field left empty) + std::vector rects; }; struct FontInfo { std::string fontName; - std::string type; // "TrueType", "Type1", "CIDFontType0", "CIDFontType2" + std::string type; bool isEmbedded = false; bool isSubset = false; bool isVertical = false; - // Advanced Introspection & Diagnostics - std::string encoding; // "WinAnsiEncoding", "MacRomanEncoding", "Identity-H", "Identity-V", "Symbol", "Custom", "None" - bool hasToUnicode = false; // True if font has an active /ToUnicode map - std::string cmapName; // e.g. "Identity-H", "Identity-V", "UniJIS-UTF16-H" - std::string cidSystemInfo; // e.g. "Adobe-Japan1", "Adobe-GB1", "Adobe-Korea1" - std::string subsetTag; // e.g. "ABCDEE" (6-character uppercase tag) - std::string sourceType; // "Embedded", "SystemFallback", "Substituted" - std::string substitutedFrom; // e.g. "Helvetica" (Original requested font) - std::string substitutedTo; // e.g. "Liberation Sans" (Actual fallback font used) - std::string normalizedFamily; // e.g. "Arial" (Normalized family grouping name) - std::string internalFontId; // Unique stable identifier for internal tracking - uint32_t flags = 0; // PDF font descriptor flags - double ascent = 0.0; // Font descriptor Ascent metric - double descent = 0.0; // Font descriptor Descent metric - double capHeight = 0.0; // Font descriptor CapHeight metric + std::string encoding; + bool hasToUnicode = false; + std::string cmapName; + std::string cidSystemInfo; + std::string subsetTag; + std::string sourceType; + std::string substitutedFrom; + std::string substitutedTo; + std::string normalizedFamily; + std::string internalFontId; + uint32_t flags = 0; + double ascent = 0.0; + double descent = 0.0; + double capHeight = 0.0; }; struct Glyph { @@ -139,7 +131,8 @@ struct TextRun { std::vector glyphs; std::vector objectIndices; double x = 0.0, y = 0.0, w = 0.0, h = 0.0; - std::string fillColor = "#000000"; // hex of the run's fill (or stroke) color, for in-place editing + std::string fillColor = "#000000"; + std::string paraId; }; struct TextLine { @@ -183,10 +176,8 @@ public: [[nodiscard]] std::expected, EngineError> orderedGlyphs() const; - // Nearest glyph/caret to a point. [[nodiscard]] std::expected hitGlyph(double x, double y) const; - // Reading-order selection between two points (e.g. drag anchor → focus). [[nodiscard]] std::expected selectRange(double ax, double ay, double bx, double by) const; @@ -197,7 +188,7 @@ public: struct AnnotationInfo { std::string id; - std::string type; // "highlight", "comment", "ink", "strikeout", "signature", "widget" + std::string type; double x = 0.0, y = 0.0, width = 0.0, height = 0.0; std::string color; std::string author; @@ -205,8 +196,7 @@ public: std::string timestamp; int pageIndex = 0; std::vector> paths; - - // Form field specific properties + std::string fieldName; std::string fieldValue; std::string fieldType; @@ -237,11 +227,10 @@ public: [[nodiscard]] virtual DocumentMetadata metadata() const noexcept = 0; [[nodiscard]] virtual DocumentPermissions permissions() const noexcept = 0; - // A single entry in the document outline (bookmarks), flattened with a depth level. struct OutlineItem { std::string title; - int pageIndex = -1; // -1 if the destination page can't be resolved - int level = 0; // 0 = top-level + int pageIndex = -1; + int level = 0; }; [[nodiscard]] virtual std::expected, EngineError> extractOutline() const = 0; @@ -259,10 +248,10 @@ public: virtual std::expected applyEdits(const std::string& editsJson) = 0; - // Per-character caret layout (JSON, PDF coords) of the most recent reflow_paragraph op, or - // empty if none. Lets the WASM live preview align a caret exactly to the rendered glyphs. [[nodiscard]] virtual std::string lastReflowLayout() const { return {}; } + [[nodiscard]] virtual bool lastReflowOverflowed() const { return false; } + [[nodiscard]] virtual std::expected, EngineError> saveIncremental() const = 0; diff --git a/engine/src/parser/pdfium_document.cpp b/engine/src/parser/pdfium_document.cpp index e62e472..e010b57 100644 --- a/engine/src/parser/pdfium_document.cpp +++ b/engine/src/parser/pdfium_document.cpp @@ -69,12 +69,12 @@ std::string utf16le_to_utf8(const char16_t* utf16, size_t length) { if (c == 0) break; uint32_t cp = c; - if (c >= 0xD800 && c <= 0xDBFF) { // High surrogate + if (c >= 0xD800 && c <= 0xDBFF) { if (i + 1 < length) { char16_t low = utf16[i + 1]; - if (low >= 0xDC00 && low <= 0xDFFF) { // Low surrogate + if (low >= 0xDC00 && low <= 0xDFFF) { cp = 0x10000 + (((c - 0xD800) << 10) | (low - 0xDC00)); - i++; // Consume the low surrogate + i++; } } } @@ -119,12 +119,9 @@ std::string code_point_to_utf8(unsigned int cp) { return utf8; } -// Reverse of the Windows-1252 (cp1252) byte→codepoint map: a unicode codepoint back to its -// single cp1252 byte, or -1 if it isn't representable in cp1252. Used to undo "double-encoded" -// ToUnicode maps (see repairMojibake). static int cp1252ToByte(unsigned int cp) { if (cp <= 0x7F) return static_cast(cp); - if (cp >= 0xA0 && cp <= 0xFF) return static_cast(cp); // Latin-1 high range is identity + if (cp >= 0xA0 && cp <= 0xFF) return static_cast(cp); switch (cp) { case 0x20AC: return 0x80; case 0x201A: return 0x82; case 0x0192: return 0x83; case 0x201E: return 0x84; case 0x2026: return 0x85; case 0x2020: return 0x86; @@ -139,7 +136,6 @@ static int cp1252ToByte(unsigned int cp) { } } -// Decode a UTF-8 string into codepoints. static std::vector utf8_to_codepoints(const std::string& s) { std::vector cps; for (size_t i = 0; i < s.size();) { @@ -159,11 +155,6 @@ static std::vector utf8_to_codepoints(const std::string& s) { return cps; } -// Repair "double-encoded" text. Some PDFs ship a ToUnicode CMap whose multi-byte UTF-8 sequences -// were themselves decoded through cp1252, so a single char like the en-dash (U+2013, UTF-8 bytes -// E2 80 93) is extracted as three garbage chars (â € " = U+00E2 U+20AC U+201C). We map each char -// back to its cp1252 byte and, where those bytes form a valid UTF-8 lead+continuation sequence, -// re-decode them to the intended codepoint. Plain text is left untouched. static std::string repairMojibake(const std::string& s) { auto cps = utf8_to_codepoints(s); std::string out; @@ -185,12 +176,6 @@ static std::string repairMojibake(const std::string& s) { return out; } -// 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; @@ -211,24 +196,15 @@ std::string normalizeReflowText(const std::string& s, const fonts::FontFace* fac } return out; } -} // namespace pdfengine::parser +} 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('_'); @@ -347,19 +323,16 @@ void ensure_pdfium_initialized() { } std::string normalizeFamilyName(const std::string& fontName) { - // 1. Remove subset tag if present std::string name = fontName; if (name.size() > 7 && name[6] == '+') { name = name.substr(7); } - // 2. Strip standard suffixes size_t sep = name.find_first_of("-,"); if (sep != std::string::npos) { name = name.substr(0, sep); } - // 3. Clean up common postfixes auto cleanName = name; auto lower = name; std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return static_cast(::tolower(c)); }); @@ -373,7 +346,6 @@ std::string normalizeFamilyName(const std::string& fontName) { } } - // Strip trailing punctuation while (!cleanName.empty() && (cleanName.back() == '-' || cleanName.back() == ' ' || cleanName.back() == '_')) { cleanName.pop_back(); } @@ -386,11 +358,9 @@ void deduceFontMetadata(pdfengine::FontInfo& f) { auto lowerName = f.fontName; std::transform(lowerName.begin(), lowerName.end(), lowerName.begin(), [](unsigned char c) { return static_cast(::tolower(c)); }); - // 1. Subset Tag & Family Normalization if (f.fontName.size() > 7 && f.fontName[6] == '+') { f.isSubset = true; f.subsetTag = f.fontName.substr(0, 6); - // Validate: must be exactly 6 uppercase letters bool validTag = true; for (int ti = 0; ti < 6; ++ti) { if (!std::isupper(static_cast(f.fontName[ti]))) { @@ -404,7 +374,6 @@ void deduceFontMetadata(pdfengine::FontInfo& f) { } f.normalizedFamily = normalizeFamilyName(f.fontName); - // 2. Encoding & CMap Identification (name-based heuristic; may be overridden by caller) if (f.fontName.find("Identity-H") != std::string::npos) { f.encoding = "Identity-H"; f.cmapName = "Identity-H"; @@ -420,15 +389,12 @@ void deduceFontMetadata(pdfengine::FontInfo& f) { f.cmapName = "None"; } - // 3. ToUnicode Map Availability - // Subset embedded fonts almost always have ToUnicode; symbol fonts rarely do. if (lowerName.find("symbol") != std::string::npos) { f.hasToUnicode = false; } else { f.hasToUnicode = true; } - // 4. CID System Info Registry — expanded patterns if (lowerName.find("simsun") != std::string::npos || lowerName.find("simhei") != std::string::npos || lowerName.find("heiti") != std::string::npos || @@ -469,7 +435,6 @@ void deduceFontMetadata(pdfengine::FontInfo& f) { f.cmapName = f.isVertical ? "UniJIS-UTF16-V" : "Identity-H"; } - // 5. Font Type Identification — improved heuristics bool isCid = (f.encoding == "Identity-H" || f.encoding == "Identity-V" || f.cidSystemInfo != "None"); if (isCid) { if (lowerName.find("bold") != std::string::npos || lowerName.find("italic") != std::string::npos) { @@ -478,7 +443,6 @@ void deduceFontMetadata(pdfengine::FontInfo& f) { f.type = "CIDFontType2"; } } else { - // Standard PDF Type 1 fonts static const std::vector type1Names = { "times", "helvetica", "courier", "symbol", "zapfdingbats", "liberation", "palatino", "bookman", "new century", "avant garde" @@ -490,17 +454,12 @@ void deduceFontMetadata(pdfengine::FontInfo& f) { f.type = isType1 ? "Type1" : "TrueType"; } - // 6. Source Type & Embedding Status - // PDFium font flags: bit 3 (value 4) = Symbolic, but NOT embed status. - // Embed status is indicated by subset prefix or by PDF font stream presence. - // We use the subset tag as definitive embed indicator, otherwise check known standard fonts. if (f.isSubset) { f.isEmbedded = true; f.sourceType = "Embedded"; f.substitutedFrom = ""; f.substitutedTo = ""; } else { - // PDF standard 14 fonts are NEVER embedded static const std::vector standard14 = { "helvetica", "times", "courier", "symbol", "zapfdingbats" }; @@ -508,7 +467,6 @@ void deduceFontMetadata(pdfengine::FontInfo& f) { for (const auto& s14 : standard14) { if (lowerName.find(s14) != std::string::npos) { isStandard14 = true; break; } } - // Common system fonts also treated as non-embedded bool isSystemFont = isStandard14 || lowerName.find("arial") != std::string::npos || lowerName.find("liberation") != std::string::npos || @@ -532,14 +490,8 @@ void deduceFontMetadata(pdfengine::FontInfo& f) { } } - // 7. Stable Internal Font Identifier - // For subset fonts, fontName already contains the subset prefix (e.g. "ABCDEF+Arial"). - // 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"). f.internalFontId = makeInternalFontId(f); - // 8. Descriptor Metrics — actual values from standard font specifications if (lowerName.find("times") != std::string::npos) { f.ascent = 891.0; f.descent = -216.0; @@ -557,46 +509,26 @@ void deduceFontMetadata(pdfengine::FontInfo& f) { f.descent = -211.0; f.capHeight = 728.0; } else { - // Generic fallback metrics (reasonable defaults for TrueType/CID fonts) f.ascent = 905.0; f.descent = -211.0; f.capHeight = 728.0; } } -// --------------------------------------------------------------------------- -// FontPdfData — real values extracted from the PDF font dictionary via PDFium. -// Populated by buildFontPdfDataMap() and used to override deduceFontMetadata() -// results in PdfiumPage::getFonts(). -// --------------------------------------------------------------------------- struct FontPdfData { bool valid = false; bool isEmbedded = false; - int flags = 0; // PDF /Flags from FontDescriptor - double ascent = 0.0; // in PDF 1000-unit space - double descent = 0.0; // in PDF 1000-unit space - bool hasUnicodeMapping = false; // at least one char decoded to valid Unicode + int flags = 0; + double ascent = 0.0; + double descent = 0.0; + bool hasUnicodeMapping = false; }; -// Builds a map from font-name to FontPdfData by scanning page text objects. -// -// Phase 1 — page object walk: -// Uses FPDFTextObj_GetFont() + FPDFFont_Get*() to read the actual PDF font -// dictionary fields: embedded status, descriptor flags and metrics. -// These values replace the corresponding fields that deduceFontMetadata() -// previously guessed from the font name string. -// -// Phase 2 — text-page character scan: -// For each font encountered in Phase 1, tests whether FPDFText_GetUnicode() -// returns a valid codepoint for at least one character belonging to that font. -// If yes, the font has an active Unicode mapping (ToUnicode CMap or built-in -// encoding). This replaces the previous always-true heuristic. static std::unordered_map buildFontPdfDataMap(FPDF_PAGE page, FPDF_TEXTPAGE textPage) { std::unordered_map result; if (!page) return result; - // ---- Phase 1: page object walk to collect real font properties ---- int objectCount = FPDFPage_CountObjects(page); for (int i = 0; i < objectCount; ++i) { FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page, i); @@ -606,7 +538,6 @@ buildFontPdfDataMap(FPDF_PAGE page, FPDF_TEXTPAGE textPage) { FPDF_FONT font = FPDFTextObj_GetFont(obj); if (!font) continue; - // Retrieve font name — first call returns required buffer size. size_t nameLen = FPDFFont_GetBaseFontName(font, nullptr, 0); if (nameLen == 0) continue; std::vector nameBuf(nameLen); @@ -614,20 +545,13 @@ buildFontPdfDataMap(FPDF_PAGE page, FPDF_TEXTPAGE textPage) { std::string fontName(nameBuf.data()); if (fontName.empty()) continue; - // Each font name is processed only once. if (result.count(fontName)) continue; FontPdfData data; data.valid = true; - // FPDFFont_GetIsEmbedded returns 1 when the PDF contains an embedded - // font stream (/FontFile, /FontFile2, or /FontFile3 in the descriptor). data.isEmbedded = (FPDFFont_GetIsEmbedded(font) != 0); - // FPDFFont_GetFlags returns the actual /Flags integer from the PDF - // FontDescriptor dictionary (not the text-rendering flags). data.flags = FPDFFont_GetFlags(font); - // FPDFFont_GetAscent/Descent are scaled by font_size. - // Passing 1000.0 recovers the raw PDF 1000-unit-space values. float rawAscent = 0.0f; if (FPDFFont_GetAscent(font, 1000.0f, &rawAscent) && rawAscent != 0.0f) { data.ascent = static_cast(rawAscent); @@ -640,10 +564,6 @@ buildFontPdfDataMap(FPDF_PAGE page, FPDF_TEXTPAGE textPage) { result[fontName] = data; } - // ---- Phase 2: Unicode mapping probe via text-page character scan ---- - // For each known font, test whether PDFium can return a non-trivial Unicode - // value for at least one character that belongs to it. A valid decode - // implies the font has an active ToUnicode CMap or a built-in encoding map. if (textPage && !result.empty()) { int charCount = FPDFText_CountChars(textPage); if (charCount > 0 && charCount < 1000000) { @@ -658,12 +578,9 @@ buildFontPdfDataMap(FPDF_PAGE page, FPDF_TEXTPAGE textPage) { std::string fname(fbuf.data()); auto it = result.find(fname); - // Skip fonts not found in Phase 1, or already confirmed. if (it == result.end() || it->second.hasUnicodeMapping) continue; unsigned int cp = FPDFText_GetUnicode(textPage, ci); - // Require a printable codepoint — exclude NUL, control chars, - // and the Unicode replacement character (U+FFFD). if (cp > 0x0020 && cp != 0xFFFD) { it->second.hasUnicodeMapping = true; } @@ -747,8 +664,6 @@ PdfDocument::loadFromMemory(const std::vector& data, const std::string& if (data.empty()) { return std::unexpected(EngineError::InvalidFormat); } - // Hardened load-path guard (MAX_STREAM_SIZE): refuse absurdly large inputs - // before handing them to the parser. if (!limits::documentSizeOk(data.size())) { spdlog::error("Refusing to load PDF: {} bytes exceeds hardened limit ({} bytes)", data.size(), limits::kMaxDocumentBytes); @@ -762,7 +677,6 @@ PdfDocument::loadFromMemory(const std::vector& data, const std::string& spdlog::error("Failed to load PDF from memory (error code: {})", err); return std::unexpected(mapPdfiumError(err, !password.empty())); } - // Reject documents with an implausible page count (runaway iteration guard). if (!limits::pageCountOk(FPDF_GetPageCount(doc))) { spdlog::error("Refusing to load PDF: page count exceeds hardened limit ({})", limits::kMaxPageCount); @@ -846,13 +760,10 @@ std::expected PdfiumPage::render(int dpi) const { return std::unexpected(EngineError::Unknown); } - // Reject pathological page sizes before sizing the raster, so a malicious - // page can't overflow w*h*4 or trigger a multi-gigabyte allocation. if (!limits::pageDimensionsOk(width(), height())) { return std::unexpected(EngineError::RenderFailed); } - // MAX_OBJECTS guard: refuse to render a content-stream "object bomb". if (!limits::objectCountOk(FPDFPage_CountObjects(page_))) { spdlog::error("Refusing to render page: object count exceeds hardened limit ({})", limits::kMaxObjects); @@ -876,10 +787,6 @@ std::expected PdfiumPage::render(int dpi) const { FPDF_RenderPageBitmap(bitmap, page_, 0, 0, w, h, 0, 0); - // Note: form-field widgets are rendered as an interactive HTML overlay in the - // frontend (AnnotationLayer), not baked here — keeps them editable and avoids - // double-rendering. `update_field` regenerates the field /AP so exported PDFs - // (and external viewers) still show filled values. const auto* buffer = static_cast(FPDFBitmap_GetBuffer(bitmap)); int stride = FPDFBitmap_GetStride(bitmap); @@ -911,29 +818,26 @@ std::expected PdfiumPage::renderRegionRaw(int dpi, doubl if (yTopPx < 0) yTopPx = 0; if (yTopPx > fullH) yTopPx = fullH; int regionH = heightPt > 0 ? static_cast(heightPt * scale) : (fullH - yTopPx); - if (regionH > fullH - yTopPx) regionH = fullH - yTopPx; // clamp to page bottom + if (regionH > fullH - yTopPx) regionH = fullH - yTopPx; if (w <= 0 || regionH <= 0) return std::unexpected(EngineError::RenderFailed); if (!limits::rasterSizeOk(w, regionH)) return std::unexpected(EngineError::RenderFailed); FPDF_BITMAP bitmap = FPDFBitmap_Create(w, regionH, 1); if (!bitmap) return std::unexpected(EngineError::RenderFailed); FPDFBitmap_FillRect(bitmap, 0, 0, w, regionH, 0xFFFFFFFF); - // Render the FULL page into a band-height bitmap, shifted up by yTopPx, so only the band - // [yTopPx, yTopPx+regionH] is rasterized (everything above/below is clipped by the bitmap). FPDF_RenderPageBitmap(bitmap, page_, 0, -yTopPx, w, fullH, 0, 0); const auto* bgra = static_cast(FPDFBitmap_GetBuffer(bitmap)); const int stride = FPDFBitmap_GetStride(bitmap); - // BGRA (PDFium) -> RGBA (canvas ImageData), tightly packed. std::vector rgba(static_cast(w) * regionH * 4); for (int y = 0; y < regionH; ++y) { const uint8_t* src = bgra + static_cast(y) * stride; uint8_t* dst = rgba.data() + static_cast(y) * w * 4; for (int x = 0; x < w; ++x) { - dst[x * 4 + 0] = src[x * 4 + 2]; // R <- B - dst[x * 4 + 1] = src[x * 4 + 1]; // G - dst[x * 4 + 2] = src[x * 4 + 0]; // B <- R - dst[x * 4 + 3] = src[x * 4 + 3]; // A + dst[x * 4 + 0] = src[x * 4 + 2]; + dst[x * 4 + 1] = src[x * 4 + 1]; + dst[x * 4 + 2] = src[x * 4 + 0]; + dst[x * 4 + 3] = src[x * 4 + 3]; } } FPDFBitmap_Destroy(bitmap); @@ -1046,6 +950,40 @@ std::expected, EngineError> PdfiumPage::extractTextWith #endif } +namespace { +inline void repagSetParaId(FPDF_DOCUMENT doc, FPDF_PAGEOBJECT obj, const std::string& paraId) { + if (paraId.empty() || !obj) return; + FPDF_PAGEOBJECTMARK mark = FPDFPageObj_AddMark(obj, "PDFPARA"); + if (mark) FPDFPageObjMark_SetStringParam(doc, obj, mark, "id", paraId.c_str()); +} + +inline std::string repagGetParaId(FPDF_PAGEOBJECT obj) { + if (!obj) return ""; + int n = FPDFPageObj_CountMarks(obj); + for (int i = 0; i < n; ++i) { + FPDF_PAGEOBJECTMARK mark = FPDFPageObj_GetMark(obj, i); + if (!mark) continue; + unsigned long nl = 0; + FPDFPageObjMark_GetName(mark, nullptr, 0, &nl); + if (nl < 2) continue; + std::vector nb(nl / 2); + FPDFPageObjMark_GetName(mark, reinterpret_cast(nb.data()), nl, &nl); + std::string name = utf16le_to_utf8(reinterpret_cast(nb.data()), nb.size()); + while (!name.empty() && name.back() == '\0') name.pop_back(); + if (name != "PDFPARA") continue; + unsigned long vl = 0; + if (!FPDFPageObjMark_GetParamStringValue(mark, "id", nullptr, 0, &vl) || vl < 2) continue; + std::vector vb(vl / 2); + if (!FPDFPageObjMark_GetParamStringValue(mark, "id", reinterpret_cast(vb.data()), vl, &vl)) + continue; + std::string val = utf16le_to_utf8(reinterpret_cast(vb.data()), vb.size()); + while (!val.empty() && val.back() == '\0') val.pop_back(); + return val; + } + return ""; +} +} + std::expected PdfiumPage::extractDocumentModel() const { #ifdef PDFENGINE_WITH_PDFIUM if (!page_) { @@ -1082,12 +1020,10 @@ std::expected PdfiumPage::extractDocumentModel() const { objToIndex[FPDFPage_GetObject(page_, i)] = i; } - // Pass 1: Extract all glyphs for (int i = 0; i < charCount; ++i) { unsigned int codeUnit = FPDFText_GetUnicode(textPage_, i); unsigned int cp = codeUnit; - // Handle surrogate pairs if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF && i + 1 < charCount) { unsigned int nextUnit = FPDFText_GetUnicode(textPage_, i + 1); if (nextUnit >= 0xDC00 && nextUnit <= 0xDFFF) { @@ -1097,7 +1033,7 @@ std::expected PdfiumPage::extractDocumentModel() const { std::string utf8_char = code_point_to_utf8(cp); if (utf8_char.empty() || cp == '\r' || cp == '\n') { - if (cp > 0xFFFF) ++i; // Skip low surrogate + if (cp > 0xFFFF) ++i; continue; } @@ -1105,7 +1041,6 @@ std::expected PdfiumPage::extractDocumentModel() const { g.text = std::move(utf8_char); g.unicode = cp; - // Geometry double left, right, bottom, top; FPDFText_GetCharBox(textPage_, i, &left, &right, &bottom, &top); g.bboxX = (std::min)(left, right); @@ -1116,7 +1051,6 @@ std::expected PdfiumPage::extractDocumentModel() const { FPDFText_GetCharOrigin(textPage_, i, &g.originX, &g.originY); g.angle = FPDFText_GetCharAngle(textPage_, i); - // Style g.fontSize = FPDFText_GetFontSize(textPage_, i); int flags = 0; unsigned long len = FPDFText_GetFontInfo(textPage_, i, nullptr, 0, &flags); @@ -1138,15 +1072,14 @@ std::expected PdfiumPage::extractDocumentModel() const { documentGlyphs.push_back(g); - if (cp > 0xFFFF) ++i; // Skip low surrogate + if (cp > 0xFFFF) ++i; } - // Phase 5E: Line Builder std::sort(documentGlyphs.begin(), documentGlyphs.end(), [](const Glyph& a, const Glyph& b) { if (std::abs(a.originY - b.originY) > 1.0) { - return a.originY > b.originY; // Top to bottom + return a.originY > b.originY; } - return a.originX < b.originX; // Left to right + return a.originX < b.originX; }); std::vector lines; @@ -1189,18 +1122,12 @@ std::expected PdfiumPage::extractDocumentModel() const { gaps.push_back(gap); } } - // Robust intra-word gap estimate. Use a LOW percentile (25th), NOT the median: on lines - // with many short words (or justified text), positive gaps are dominated by word-spaces, - // which inflates the median and makes the space threshold below swallow real spaces — - // gluing whole words together ("backend systems for" -> "backendsystemsfor"). The 25th - // percentile stays inside the small intra-word gap cluster even when spaces dominate. double p25Gap = 0.0; if (!gaps.empty()) { std::sort(gaps.begin(), gaps.end()); p25Gap = gaps[gaps.size() / 4]; } - // Phase 5F: Run Builder TextRun currentRun; if (!line.glyphs.empty()) { const Glyph* firstG = &line.glyphs[0]; @@ -1220,10 +1147,6 @@ std::expected PdfiumPage::extractDocumentModel() const { const auto& currG = line.glyphs[i]; double gap = currG.bboxX - (prevG.bboxX + prevG.bboxW); - // A word space is a clear outlier above intra-word tracking. Anchor on a - // fontSize-relative floor (reliable for normal AND justified body text), and only - // raise it for unusually loose tracking — capped so it can never grow large - // enough to drop real word-spaces (the previous medianGap*2.0 bug). double spaceThreshold = currG.fontSize * 0.2; if (p25Gap > spaceThreshold) { spaceThreshold = (std::min)(p25Gap * 1.5, currG.fontSize * 0.38); @@ -1300,8 +1223,6 @@ std::expected PdfiumPage::extractDocumentModel() const { } } - // Phase 5F.5: Repair double-encoded text (e.g. an en-dash extracted as "â€"" from a malformed - // ToUnicode CMap) so both display and reflow re-emission use the correct codepoints. for (auto& line : lines) { for (auto& run : line.runs) { std::string repaired = pdfengine::parser::repairMojibake(run.text); @@ -1309,7 +1230,6 @@ std::expected PdfiumPage::extractDocumentModel() const { } } - // Phase 5G: Paragraph Builder std::vector paragraphs; if (!lines.empty()) { Paragraph currentPara; @@ -1336,7 +1256,6 @@ std::expected PdfiumPage::extractDocumentModel() const { } } - // Phase 5H: Geometry Rollup auto computeRunBBox = [](TextRun& r) { if (r.glyphs.empty()) return; double minX = r.glyphs[0].bboxX; @@ -1391,9 +1310,6 @@ std::expected PdfiumPage::extractDocumentModel() const { p.h = maxY - minY; }; - // Fill color of a run, read from its lowest-index page object (the same object - // the replace_text commit path uses, so preview color == committed color). Falls - // back to stroke color for stroke-only text, else keeps the default "#000000". auto hex2 = [](unsigned int c) -> std::string { static const char* h = "0123456789abcdef"; c &= 0xFF; @@ -1415,13 +1331,21 @@ std::expected PdfiumPage::extractDocumentModel() const { } }; + auto computeRunParaId = [&](TextRun& r) { + if (r.objectIndices.empty()) return; + int minIdx = r.objectIndices[0]; + for (int idx : r.objectIndices) if (idx < minIdx) minIdx = idx; + FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page_, minIdx); + if (obj) r.paraId = repagGetParaId(obj); + }; + for (auto& p : paragraphs) { for (auto& l : p.lines) { - // PDF text baseline of the line (glyphs in a line share ~one originY). l.baselineY = l.glyphs.empty() ? 0.0 : l.glyphs[0].originY; for (auto& r : l.runs) { computeRunBBox(r); computeRunColor(r); + computeRunParaId(r); } computeLineBBox(l); } @@ -1474,7 +1398,6 @@ std::expected, EngineError> PdfiumPage::ext return std::unexpected(EngineError::Unknown); } - // Initialize form fill environment locally to extract form fields FPDF_FORMFILLINFO formInfo; memset(&formInfo, 0, sizeof(formInfo)); formInfo.version = 1; @@ -1489,7 +1412,6 @@ std::expected, EngineError> PdfiumPage::ext PdfPage::AnnotationInfo info; info.pageIndex = pageIndex_; - // ID unsigned long len = FPDFAnnot_GetStringValue(annot, "NM", nullptr, 0); if (len > 2) { std::vector buf(len); @@ -1502,7 +1424,6 @@ std::expected, EngineError> PdfiumPage::ext info.id = "anno_" + std::to_string(pageIndex_) + "_" + std::to_string(i); } - // Subtype int subtype = FPDFAnnot_GetSubtype(annot); if (subtype == FPDF_ANNOT_HIGHLIGHT) { info.type = "highlight"; @@ -1519,11 +1440,8 @@ std::expected, EngineError> PdfiumPage::ext } else if (subtype == FPDF_ANNOT_WIDGET) { info.type = "widget"; - // Basic field properties using PDFium APIs int fieldType = FPDFAnnot_GetFormFieldType(formHandle, annot); - // Map FPDF_FORMFIELD_* to our types - // 1: PUSHBUTTON, 2: CHECKBOX, 3: RADIOBUTTON, 4: COMBOBOX, 5: LISTBOX, 6: TEXTFIELD, 7: SIGNATURE if (fieldType == 1 || fieldType == 2 || fieldType == 3) { info.fieldType = "Btn"; } else if (fieldType == 4 || fieldType == 5) { @@ -1538,7 +1456,6 @@ std::expected, EngineError> PdfiumPage::ext info.fieldFlags = FPDFAnnot_GetFormFieldFlags(formHandle, annot); - // Extract Field Name (/T) unsigned long nameLen = FPDFAnnot_GetFormFieldName(formHandle, annot, nullptr, 0); if (nameLen > 2) { std::vector nameBuf(nameLen / 2); @@ -1548,7 +1465,6 @@ std::expected, EngineError> PdfiumPage::ext info.fieldName = text; } - // Extract Field Value (/V) unsigned long valLen = FPDFAnnot_GetFormFieldValue(formHandle, annot, nullptr, 0); if (valLen > 2) { std::vector valBuf(valLen / 2); @@ -1558,7 +1474,6 @@ std::expected, EngineError> PdfiumPage::ext info.fieldValue = text; } - // Extract Options for Choice fields (/Opt) if (info.fieldType == "Ch") { int optCount = FPDFAnnot_GetOptionCount(formHandle, annot); for (int o = 0; o < optCount; ++o) { @@ -1578,7 +1493,6 @@ std::expected, EngineError> PdfiumPage::ext } if (info.type != "unknown") { - // Rect FS_RECTF rect; if (FPDFAnnot_GetRect(annot, &rect)) { int dw = static_cast(std::round(width())); @@ -1592,7 +1506,6 @@ std::expected, EngineError> PdfiumPage::ext info.height = std::abs(bottomRight.y - topLeft.y); } - // Author len = FPDFAnnot_GetStringValue(annot, "T", nullptr, 0); if (len > 2) { std::vector buf(len); @@ -1602,7 +1515,6 @@ std::expected, EngineError> PdfiumPage::ext info.author = text; } - // Content len = FPDFAnnot_GetStringValue(annot, "Contents", nullptr, 0); if (len > 2) { std::vector buf(len); @@ -1612,7 +1524,6 @@ std::expected, EngineError> PdfiumPage::ext info.content = text; } - // Timestamp (Modification Date or Creation Date) len = FPDFAnnot_GetStringValue(annot, "M", nullptr, 0); if (len <= 2) { len = FPDFAnnot_GetStringValue(annot, "CreationDate", nullptr, 0); @@ -1631,7 +1542,6 @@ std::expected, EngineError> PdfiumPage::ext info.timestamp = text; } - // Color unsigned int R = 0, G = 0, B = 0, A = 0; if (FPDFAnnot_GetColor(annot, FPDFANNOT_COLORTYPE_Color, &R, &G, &B, &A)) { char hex[10]; @@ -1639,10 +1549,6 @@ std::expected, EngineError> PdfiumPage::ext info.color = hex; } - // Ink geometry: read the /InkList strokes so the frontend can redraw - // them as an interactive overlay (Adobe-style, non-destructive). - // Points are converted from PDF (bottom-up) to top-left page-point space - // to match the frontend's coordinate convention (same as the rect above). if (subtype == FPDF_ANNOT_INK) { const double pageH = height(); unsigned long strokeCount = FPDFAnnot_GetInkListCount(annot); @@ -1806,7 +1712,7 @@ DocumentMetadata PdfiumDocument::metadata() const noexcept { auto fetchMeta = [this](const char* key) -> std::string { unsigned long len = FPDF_GetMetaText(doc_, key, nullptr, 0); - if (len <= 2) return ""; // 2 bytes or less is just null terminator in UTF-16 + if (len <= 2) return ""; std::vector buf(len / 2); FPDF_GetMetaText(doc_, key, buf.data(), len); return utf16le_to_utf8(reinterpret_cast(buf.data()), buf.size()); @@ -1823,7 +1729,7 @@ DocumentMetadata PdfiumDocument::metadata() const noexcept { } DocumentPermissions PdfiumDocument::permissions() const noexcept { - DocumentPermissions perms; // defaults: unencrypted, everything allowed + DocumentPermissions perms; #ifdef PDFENGINE_WITH_PDFIUM if (!doc_) return perms; @@ -1831,43 +1737,37 @@ DocumentPermissions PdfiumDocument::permissions() const noexcept { perms.securityRevision = rev; perms.isEncrypted = (rev != -1); if (!perms.isEncrypted) { - return perms; // "None", all true + return perms; } switch (rev) { case 2: perms.encryption = "RC4-40"; break; case 3: perms.encryption = "RC4-128"; break; - case 4: perms.encryption = "AES-128"; break; // R4 may be RC4-128; PDFium hides /CFM + case 4: perms.encryption = "AES-128"; break; case 5: case 6: perms.encryption = "AES-256"; break; default: perms.encryption = "Unknown"; break; } - // FPDF_GetDocPermissions returns the EFFECTIVE /P flags for how the doc was - // opened; FPDF_GetDocUserPermissions always returns the user-level flags. When - // they differ, the doc was unlocked with the owner password (full access). - // (PDFium grants full effective perms without always returning exactly - // 0xFFFFFFFF, so a direct equality check is unreliable.) const unsigned long p = FPDF_GetDocPermissions(doc_); const unsigned long up = FPDF_GetDocUserPermissions(doc_); perms.ownerUnlocked = (p != up); auto allowed = [p](unsigned long bit) { return (p & bit) != 0ul; }; - perms.canPrint = allowed(0x4); // bit 3 - perms.canModify = allowed(0x8); // bit 4 - perms.canCopy = allowed(0x10); // bit 5 - perms.canAnnotate = allowed(0x20); // bit 6 - perms.canFillForms = allowed(0x100); // bit 9 - perms.canExtractForAccessibility = allowed(0x200);// bit 10 - perms.canAssemble = allowed(0x400); // bit 11 - perms.canPrintHighRes = allowed(0x800); // bit 12 + perms.canPrint = allowed(0x4); + perms.canModify = allowed(0x8); + perms.canCopy = allowed(0x10); + perms.canAnnotate = allowed(0x20); + perms.canFillForms = allowed(0x100); + perms.canExtractForAccessibility = allowed(0x200); + perms.canAssemble = allowed(0x400); + perms.canPrintHighRes = allowed(0x800); #endif return perms; } #ifdef PDFENGINE_WITH_PDFIUM namespace { -// Depth-first flatten of the bookmark tree into OutlineItems with a depth level. void walkOutline(FPDF_DOCUMENT doc, FPDF_BOOKMARK bm, int level, std::vector& out) { while (bm && level <= 32 && out.size() < 5000) { @@ -1896,7 +1796,231 @@ void walkOutline(FPDF_DOCUMENT doc, FPDF_BOOKMARK bm, int level, bm = FPDFBookmark_GetNextSibling(doc, bm); } } -} // namespace + +struct PageBand { + double pageW = 0.0, pageH = 0.0; + double placementTopY = 0.0; + double bottomLimitY = 0.0; + bool valid = false; +}; + +inline bool repagInColumn(FPDF_PAGEOBJECT o, double pushColLeft, double colRight) { + float l = 0, b = 0, r = 0, t = 0; + if (!FPDFPageObj_GetBounds(o, &l, &b, &r, &t)) return false; + return (r > pushColLeft && l < colRight); +} + +PageBand repagComputeBand(FPDF_PAGE page, double pushColLeft, double colRight) { + PageBand band; + band.pageW = FPDF_GetPageWidthF(page); + band.pageH = FPDF_GetPageHeightF(page); + double maxTop = -1e18; bool any = false; + int n = FPDFPage_CountObjects(page); + for (int k = 0; k < n; ++k) { + FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, k); + if (!o) continue; + float l = 0, b = 0, r = 0, t = 0; + if (!FPDFPageObj_GetBounds(o, &l, &b, &r, &t)) continue; + if (r > pushColLeft && l < colRight) { if (t > maxTop) { maxTop = t; } any = true; } + } + if (!any) return band; + band.placementTopY = maxTop; + double limit = band.pageH - maxTop; + if (limit < 18.0) limit = 18.0; + if (limit > band.pageH * 0.25) limit = band.pageH * 0.25; + band.bottomLimitY = limit; + band.valid = true; + return band; +} + +std::vector repagAnchoredCenters(FPDF_PAGE page, double pushColLeft, double colRight, + double bottomLimitY) { + std::vector out; + int n = FPDFPage_CountObjects(page); + for (int k = 0; k < n; ++k) { + FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, k); + if (!o || !repagInColumn(o, pushColLeft, colRight)) continue; + float l = 0, b = 0, r = 0, t = 0; + if (!FPDFPageObj_GetBounds(o, &l, &b, &r, &t)) continue; + double cy = (b + t) / 2.0; + if (cy < bottomLimitY) out.push_back(cy); + } + return out; +} + +inline bool repagIsAnchored(double cy, const std::vector& anchored, double tol = 1.0) { + for (double ac : anchored) if (std::abs(ac - cy) < tol) return true; + return false; +} + +int repaginateForward(FPDF_DOCUMENT doc, int startPage, + double colLeft, double colRight, double pushColLeft, double leading, + const std::vector& anchoredCentersStart, int* movedOut = nullptr) { + (void)colLeft; + const int kMaxPagesAdded = 200; + int pagesAdded = 0; + int movedTotal = 0; + int curPage = startPage; + std::vector anchored = anchoredCentersStart; + + while (true) { + FPDF_PAGE P = FPDF_LoadPage(doc, curPage); + if (!P) break; + PageBand band = repagComputeBand(P, pushColLeft, colRight); + if (!band.valid) { FPDF_ClosePage(P); break; } + + std::vector overflow; + double blockTop = -1e18, blockBottom = 1e18; + int n = FPDFPage_CountObjects(P); + for (int k = 0; k < n; ++k) { + FPDF_PAGEOBJECT o = FPDFPage_GetObject(P, k); + if (!o || !repagInColumn(o, pushColLeft, colRight)) continue; + float l = 0, b = 0, r = 0, t = 0; + if (!FPDFPageObj_GetBounds(o, &l, &b, &r, &t)) continue; + double cy = (b + t) / 2.0; + if (cy >= band.bottomLimitY) continue; + if (repagIsAnchored(cy, anchored)) continue; + overflow.push_back(o); + if (t > blockTop) blockTop = t; + if (b < blockBottom) blockBottom = b; + } + if (overflow.empty()) { FPDF_ClosePage(P); break; } + + int total = FPDF_GetPageCount(doc); + FPDF_PAGE Q = nullptr; + bool createdNew = false; + if (curPage + 1 >= total) { + if (pagesAdded + 1 > kMaxPagesAdded) { + spdlog::warn("repaginateForward: hit page-add cap ({}), stopping", kMaxPagesAdded); + FPDF_ClosePage(P); break; + } + Q = FPDFPage_New(doc, curPage + 1, band.pageW, band.pageH); + createdNew = true; + pagesAdded++; + } else { + Q = FPDF_LoadPage(doc, curPage + 1); + } + if (!Q) { FPDF_ClosePage(P); break; } + + PageBand bandQ = createdNew ? band : repagComputeBand(Q, pushColLeft, colRight); + double placeTopQ = bandQ.valid ? bandQ.placementTopY : band.placementTopY; + double limitQ = bandQ.valid ? bandQ.bottomLimitY : band.bottomLimitY; + + double blockH = blockTop - blockBottom; + if (blockH < 0) blockH = 0; + + std::vector anchoredQ = + createdNew ? std::vector{} + : repagAnchoredCenters(Q, pushColLeft, colRight, limitQ); + + if (!createdNew) { + double shift = blockH + leading; + int nq = FPDFPage_CountObjects(Q); + for (int k = 0; k < nq; ++k) { + FPDF_PAGEOBJECT o = FPDFPage_GetObject(Q, k); + if (!o || !repagInColumn(o, pushColLeft, colRight)) continue; + float l = 0, b = 0, r = 0, t = 0; + if (!FPDFPageObj_GetBounds(o, &l, &b, &r, &t)) continue; + double cy = (b + t) / 2.0; + if (repagIsAnchored(cy, anchoredQ)) continue; + FPDFPageObj_Transform(o, 1.0, 0.0, 0.0, 1.0, 0.0, -shift); + } + } + + double translateDy = placeTopQ - blockTop; + for (FPDF_PAGEOBJECT o : overflow) { + FPDFPage_RemoveObject(P, o); + FPDFPageObj_Transform(o, 1.0, 0.0, 0.0, 1.0, 0.0, translateDy); + FPDFPage_InsertObject(Q, o); + } + + FPDFPage_GenerateContent(P); + FPDFPage_GenerateContent(Q); + spdlog::info("repaginateForward: page {} -> {} moved {} object(s), blockH={:.1f}{}", + curPage, curPage + 1, overflow.size(), blockH, createdNew ? " (new page)" : ""); + FPDF_ClosePage(P); + FPDF_ClosePage(Q); + + movedTotal += static_cast(overflow.size()); + + anchored = anchoredQ; + curPage += 1; + } + if (movedOut) *movedOut = movedTotal; + return pagesAdded; +} + +int repaginateBackward(FPDF_DOCUMENT doc, int startPage, + double colLeft, double colRight, double pushColLeft, double leading) { + (void)colLeft; (void)colRight; (void)pushColLeft; (void)leading; + int pagesRemoved = 0; + for (int pg = FPDF_GetPageCount(doc) - 1; pg > startPage; --pg) { + FPDF_PAGE p = FPDF_LoadPage(doc, pg); + if (!p) break; + int n = FPDFPage_CountObjects(p); + FPDF_ClosePage(p); + if (n == 0) { FPDFPage_Delete(doc, pg); pagesRemoved++; } + else break; + } + return pagesRemoved; +} + +void repagCompactToTop(FPDF_DOCUMENT doc, int pageIdx, double pushColLeft, double colRight) { + FPDF_PAGE p = FPDF_LoadPage(doc, pageIdx); + if (!p) return; + PageBand band = repagComputeBand(p, pushColLeft, colRight); + if (!band.valid) { FPDF_ClosePage(p); return; } + std::vector anchored = repagAnchoredCenters(p, pushColLeft, colRight, band.bottomLimitY); + double curTop = -1e18; bool any = false; + int n = FPDFPage_CountObjects(p); + for (int k = 0; k < n; ++k) { + FPDF_PAGEOBJECT o = FPDFPage_GetObject(p, k); + if (!o || !repagInColumn(o, pushColLeft, colRight)) continue; + float l=0,b=0,r=0,t=0; if (!FPDFPageObj_GetBounds(o,&l,&b,&r,&t)) continue; + double cy=(b+t)/2.0; if (repagIsAnchored(cy, anchored)) continue; + if (t > curTop) { curTop = t; any = true; } + } + if (any) { + double up = band.placementTopY - curTop; + if (up > 0.01) { + for (int k = 0; k < n; ++k) { + FPDF_PAGEOBJECT o = FPDFPage_GetObject(p, k); + if (!o || !repagInColumn(o, pushColLeft, colRight)) continue; + float l=0,b=0,r=0,t=0; if (!FPDFPageObj_GetBounds(o,&l,&b,&r,&t)) continue; + double cy=(b+t)/2.0; if (repagIsAnchored(cy, anchored)) continue; + FPDFPageObj_Transform(o, 1.0, 0.0, 0.0, 1.0, 0.0, up); + } + FPDFPage_GenerateContent(p); + } + } + FPDF_ClosePage(p); +} + +int repagRemoveContinuations(FPDF_DOCUMENT doc, int anchorPage, const std::string& paraId, + double pushColLeft, double colRight) { + if (paraId.empty()) return 0; + int touched = 0; + int total = FPDF_GetPageCount(doc); + for (int pg = 0; pg < total; ++pg) { + if (pg == anchorPage) continue; + FPDF_PAGE p = FPDF_LoadPage(doc, pg); + if (!p) continue; + std::vector toDel; + int n = FPDFPage_CountObjects(p); + for (int k = 0; k < n; ++k) { + FPDF_PAGEOBJECT o = FPDFPage_GetObject(p, k); + if (o && repagGetParaId(o) == paraId) toDel.push_back(o); + } + if (toDel.empty()) { FPDF_ClosePage(p); continue; } + for (auto o : toDel) { FPDFPage_RemoveObject(p, o); FPDFPageObj_Destroy(o); } + FPDFPage_GenerateContent(p); + FPDF_ClosePage(p); + repagCompactToTop(doc, pg, pushColLeft, colRight); + touched++; + } + return touched; +} +} #endif std::expected, EngineError> PdfiumDocument::extractOutline() const { @@ -1935,8 +2059,6 @@ std::expected, EngineError> PdfiumDocument::getPage(int return std::unexpected(EngineError::Unknown); } - // The page co-owns this document (shared_from_this) so the native - // FPDF_DOCUMENT outlives every page derived from it. auto pageObj = std::make_shared( doc_, pageHandle, pageIndex, std::static_pointer_cast(shared_from_this())); @@ -1959,7 +2081,6 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont( EmissionFont out; (void)fontSize; - // Derive a base-14 name + bold/italic from the internalFontId (mirrors replace_text). std::string fontName = "Helvetica"; bool bold = false, italic = false; { @@ -1979,7 +2100,6 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont( } } - // Match the FontInfo by internalFontId + resolve a FontFace (for shaping/width). std::optional matchedFontInfo; auto fontsRes = getFonts(pageIndex, pageIndex); if (fontsRes.has_value()) { @@ -1992,7 +2112,6 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont( if (r.has_value()) out.resolved = *r; } - // Glyph coverage → pick the embedding tier. bool fontSupportsAll = true, subsetLacksGlyphs = false; bool isSubsetFont = matchedFontInfo && matchedFontInfo->isSubset; if (out.resolved) { @@ -2008,11 +2127,6 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont( std::string cacheKey; bool useEmbedded = false, useSystem = false; if (matchedFontInfo && matchedFontInfo->isEmbedded) { - // Re-embed from the PDF's OWN font bytes (getFontData below). This path does NOT need the - // resolver's FontFace, so it works under WASM's no-filesystem build where getResolvedFont - // can return null — the reflowed text then keeps the document's real font (e.g. Calibri) - // instead of falling back to a generic Helvetica/Liberation. We only divert to a system - // substitute when we have a resolved face that PROVES the subset lacks a needed glyph. bool subsetProvenLacking = out.resolved && isSubsetFont && subsetLacksGlyphs; bool fullProvenLacking = out.resolved && !isSubsetFont && !fontSupportsAll; if (!subsetProvenLacking && !fullProvenLacking) { cacheKey = matchedFontInfo->internalFontId; useEmbedded = true; } @@ -2023,22 +2137,12 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont( cacheKey = "standard_" + fontName; } - // Make the cache key CODEPOINT-AWARE. A subset is built for an exact glyph set, so a font - // reused with different characters (e.g. the Summary's text cached, then a bullet reflowed) - // must NOT reuse the prior subset — its cmap would map the new chars to the wrong glyphs, - // producing scrambled text ("Core Java" -> "Cuwi Le,e"). Hashing the codepoints fixes it. { uint64_t h = 1469598103934665603ull; for (uint32_t cp : codepoints) { h ^= cp; h *= 1099511628211ull; } 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); @@ -2060,11 +2164,6 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont( if (out.font) return out; if (useEmbedded) { - // Prefer the font bytes of the paragraph's OWN page objects (per-object resolution): - // a prior reflow may have embedded another same-named subset elsewhere in the doc, and a - // document-wide name scan (getFontData) can return THAT instead, scrambling the 2nd+ edit - // ("Core Java" -> "Cuwi Le,e"). Fall back to the name scan only when the font isn't - // physically present in the source objects (e.g. newly typed text inheriting a font). std::vector sourceBytes; if (auto perObj = getFontDataFromObjects(pageIndex, srcObjects, matchedFontInfo->internalFontId); perObj.has_value() && !perObj->empty()) { @@ -2074,10 +2173,6 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont( 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). auto subset = fonts::pdf_fonts::FontSubset::buildSubsetByUnicode(sourceBytes, codepoints); std::lock_guard lock(loadedFontsMutex_); loadedFontDataBuffers_[cacheKey] = !subset.empty() ? std::move(subset) : std::move(sourceBytes); @@ -2106,9 +2201,6 @@ PdfiumDocument::EmissionFont PdfiumDocument::loadEmissionFont( if (out.font) { std::lock_guard lock(loadedFontsMutex_); loadedFontsCache_[cacheKey] = out.font; - // Build a measuring face from the exact bytes we loaded (embedded/system paths) so - // width measurement matches what PDFium renders. Standard base-14 fonts have no bytes - // here → measurement falls back to the resolver face. auto bit = loadedFontDataBuffers_.find(cacheKey); if (bit != loadedFontDataBuffers_.end() && !bit->second.empty()) { auto mf = std::make_shared(); @@ -2125,7 +2217,7 @@ std::expected PdfiumDocument::applyEdits(const std::string& e return std::unexpected(EngineError::Unknown); } - lastReflowLayout_.clear(); // repopulated only if a reflow_paragraph op runs + lastReflowLayout_.clear(); try { auto root = nlohmann::json::parse(editsJson); @@ -2188,12 +2280,10 @@ std::expected PdfiumDocument::applyEdits(const std::string& e return std::unexpected(EngineError::Unknown); } - // 1. Sort descending to prevent index shift during removal std::sort(objectIndices.begin(), objectIndices.end(), std::greater()); int minIndex = objectIndices.back(); - // 2. Fetch the lowest indexed original object to copy styling FPDF_PAGEOBJECT origObj = FPDFPage_GetObject(page, minIndex); if (!origObj) { spdlog::error("Failed to get original text object at index {}", minIndex); @@ -2215,7 +2305,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e unsigned int r = 0, g = 0, b_color = 0, a_color = 255; FPDFPageObj_GetFillColor(origObj, &r, &g, &b_color, &a_color); - // Get font size if not provided if (fontSize < 0.0) { float sizeVal = 12.0f; if (FPDFTextObj_GetFontSize(origObj, &sizeVal)) { @@ -2225,10 +2314,8 @@ std::expected PdfiumDocument::applyEdits(const std::string& e } } - // Get text rendering mode FPDF_TEXT_RENDERMODE renderMode = static_cast(FPDFTextObj_GetTextRenderMode(origObj)); - // Map to a standard 14 font name std::string fontName = "Helvetica"; std::string origFontName = ""; bool bold = false; @@ -2297,7 +2384,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e } } - // --- FONT ENGINE INTEGRATION --- std::optional matchedFontInfo; auto fontsRes = getFonts(pageIndex, pageIndex); if (fontsRes.has_value()) { @@ -2345,7 +2431,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e bool isSubsetFont = matchedFontInfo && matchedFontInfo->isSubset; bool subsetLacksGlyphs = false; - // Perform glyph check if (resolvedFont) { for (uint32_t cp : unicodeCodepoints) { if (!resolvedFont->hasGlyph(cp)) { @@ -2361,7 +2446,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e fontSupportsAll = false; } - // Stage 7: HarfBuzz Shaping bool shapedSuccessful = false; if (resolvedFont) { try { @@ -2392,7 +2476,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e spdlog::info("Font Engine: FreeType fallback total advance width = {}", totalWidth); } - // Stage 8: Reflow Engine (bounds calculation and shift) float origLeft = 999999.0f, origRight = -999999.0f; float origBottom = 999999.0f, origTop = -999999.0f; bool hasOrigBounds = false; @@ -2429,7 +2512,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e int reflowedCount = 0; for (int k = 0; k < pageObjCount; ++k) { - // Skip if it is one of the replaced objects if (std::find(objectIndices.begin(), objectIndices.end(), k) != objectIndices.end()) { continue; } @@ -2439,9 +2521,7 @@ std::expected PdfiumDocument::applyEdits(const std::string& e float otherLeft = 0.0f, otherBottom = 0.0f, otherRight = 0.0f, otherTop = 0.0f; if (FPDFPageObj_GetBounds(otherObj, &otherLeft, &otherBottom, &otherRight, &otherTop)) { double otherCenterY = (otherBottom + otherTop) / 2.0; - // Check if on the same horizontal line if (std::abs(otherCenterY - origCenterY) <= tolerance) { - // Check if it is to the right of the replaced text run if (otherLeft >= (origRight - 2.0f)) { FPDFPageObj_Transform(otherObj, 1.0, 0.0, 0.0, 1.0, deltaX, 0.0); reflowedCount++; @@ -2453,7 +2533,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e spdlog::info("Reflow Engine: shifted {} subsequent text objects on the same line by {}", reflowedCount, deltaX); } - // 3. Delete old objects for (int idx : objectIndices) { FPDF_PAGEOBJECT objToRemove = FPDFPage_GetObject(page, idx); if (objToRemove) { @@ -2462,7 +2541,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e } } - // 4. Create new text object using standard, embedded, or system font FPDF_FONT font = nullptr; std::string cacheKey = ""; bool useEmbedded = false; @@ -2476,15 +2554,8 @@ std::expected PdfiumDocument::applyEdits(const std::string& e cacheKey = matchedFontInfo->internalFontId; useEmbedded = true; } else if (!matchedFontInfo->isEmbedded) { - // Tier 1: the original font is NOT embedded — by definition it's - // one of the standard base-14 fonts the viewer provides. Keep it - // that way: use the standard PDF-14 font, embed nothing (exactly - // what Acrobat does). Avoids ballooning the file with a full - // substitute font for a base-14 edit. cacheKey = "standard_" + fontName; } else { - // Embedded subset that can't render the new glyphs — substitute, - // but embed only a SUBSET (Tier 2, in the load path below). cacheKey = "system_embed_" + matchedFontInfo->fontName + "_" + (bold ? "B" : "") + (italic ? "I" : ""); useSystem = true; } @@ -2492,7 +2563,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e cacheKey = "standard_" + fontName; } - // Check cache first { std::lock_guard lock(loadedFontsMutex_); if (loadedFontsCache_.count(cacheKey)) { @@ -2523,8 +2593,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e if (fs) { std::vector fileBytes((std::istreambuf_iterator(fs)), std::istreambuf_iterator()); if (!fileBytes.empty()) { - // Tier 2: embed only a SUBSET covering the glyphs actually - // used, not the whole multi-MB font file (Acrobat-style). std::vector subsetBytes = fonts::pdf_fonts::FontSubset::buildSubsetByUnicode(fileBytes, unicodeCodepoints); const size_t fullSize = fileBytes.size(); @@ -2532,11 +2600,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e loadedFontDataBuffers_[cacheKey] = (!subsetBytes.empty()) ? std::move(subsetBytes) : std::move(fileBytes); const auto& bytes = loadedFontDataBuffers_[cacheKey]; - // Load as a composite CID font (cid=true): 2-byte codes - // + auto-generated ToUnicode, so full-Unicode edited text - // (€, ™, accents, CJK) stays searchable/extractable. A - // simple 1-byte font can only encode 256 codes and breaks - // text extraction for anything outside that range. font = FPDFText_LoadFont(doc_, bytes.data(), static_cast(bytes.size()), FPDF_FONT_TRUETYPE, true); if (font) { spdlog::info("Font Engine: Embedded {} CID system font '{}' ({} -> {} bytes) from '{}'", @@ -2549,7 +2612,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e } } - // Fallback to standard PDF 14 font if loading failed if (!font) { spdlog::info("Font Engine: Loading standard PDF font for replace_text: {}", fontName); font = FPDFText_LoadStandardFont(doc_, fontName.c_str()); @@ -2558,7 +2620,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e } } - // Cache the loaded font if (font) { std::lock_guard lock(loadedFontsMutex_); loadedFontsCache_[cacheKey] = font; @@ -2585,21 +2646,12 @@ std::expected PdfiumDocument::applyEdits(const std::string& e FPDF_ClosePage(page); } else if (type == "reflow_paragraph") { - // Word/Adobe-style paragraph reflow: re-wrap the paragraph's styled runs - // within its column width, re-justify, emit the new lines, and push the - // following in-column content down/up by the line-count delta. if (!op.contains("data") || !op["data"].is_object()) { spdlog::error("reflow_paragraph missing 'data'"); return std::unexpected(EngineError::InvalidFormat); } auto data = op["data"]; - // `advances` = the run's ORIGINAL per-character advance (PDF units), captured from - // the source glyph positions by the frontend. When present (and aligned 1:1 with the - // run's bytes — i.e. unchanged pure-ASCII text), layout uses these EXACT advances - // instead of re-measuring, so untouched text reproduces the source pixel-for-pixel - // and doesn't drift while you edit elsewhere. Edited/new/non-ASCII runs omit it and - // fall back to HarfBuzz measurement. struct RunStyle { std::string text; std::string internalFontId; double fontSize; unsigned int r, g, b; std::vector advances; }; std::vector runs; auto parseHex = [](const std::string& hex, unsigned int& r, unsigned int& g, unsigned int& b) { @@ -2618,8 +2670,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e }; auto parseRun = [&](const nlohmann::json& rj) { RunStyle rs; - // 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); @@ -2629,11 +2679,7 @@ std::expected PdfiumDocument::applyEdits(const std::string& e } runs.push_back(std::move(rs)); }; - // Optional WYSIWYG mode: the frontend provides the exact visual line breaks - // (one inner array of styled fragments per line). When present we emit those - // breaks verbatim and skip our greedy line-breaker, so the saved page matches - // the live preview. Otherwise we break the flat `runs` ourselves. - std::vector> providedLines; // run indices per provided line + std::vector> providedLines; bool hasProvidedLines = data.contains("lines") && data["lines"].is_array() && !data["lines"].empty(); if (hasProvidedLines) { for (const auto& lineJson : data["lines"]) { @@ -2656,21 +2702,16 @@ std::expected PdfiumDocument::applyEdits(const std::string& e double leading = data.value("leading", 0.0); int oldLineCount = data.value("oldLineCount", 1); std::string align = data.value("align", std::string("left")); - // Original per-line baseline + left x (parallel to providedLines). When present, the - // lines path emits each line at its EXACT source baseline/x instead of the uniform - // firstBaselineY - i*leading, so unchanged text reproduces the source's actual - // (possibly non-uniform) line spacing pixel-for-pixel — no cumulative vertical drift. std::vector lineBaselineY, lineX; if (data.contains("lineBaselineY") && data["lineBaselineY"].is_array()) for (const auto& v : data["lineBaselineY"]) lineBaselineY.push_back(v.get()); if (data.contains("lineX") && data["lineX"].is_array()) for (const auto& v : data["lineX"]) lineX.push_back(v.get()); double columnWidth = columnRight - columnLeft; - // For a bullet/list item the TEXT reflows from a hanging indent (columnLeft), but - // the push-down of content below must span the FULL block width so the bullet - // markers (which sit left of the text indent) and other items move together. - // Defaults to columnLeft for ordinary paragraphs. double pushColumnLeft = data.value("pushColumnLeft", columnLeft); + std::string paraId; + if (data.contains("paraId") && data["paraId"].is_string()) + paraId = data["paraId"].get(); if (runs.empty() || objectIndices.empty() || columnWidth <= 1.0 || leading <= 0.0) { spdlog::warn("reflow_paragraph: insufficient layout data, skipping"); @@ -2683,16 +2724,18 @@ std::expected PdfiumDocument::applyEdits(const std::string& e return std::unexpected(EngineError::Unknown); } - // --- Geometric backstop (fixes BOTH the "bulge/merge" and the font-substitution bug) --- - // The frontend-supplied objectIndices can omit a paragraph object when its glyphs map to - // pageObjectIndex == -1 (load-dependent, and the map shifts after a prior edit re-serializes - // the stream). A missing index means that object is (a) NOT deleted -> its glyphs stay UNDER - // the new text ("bulge/merge"), and (b) NOT seen by resolveOrigFont/getFontDataFromObjects - // -> the run re-emits in a base-14 substitute ("font changes on edit"). Backstop: widen the - // working set to any TEXT object whose bbox is FULLY inside the union bbox of the supplied - // indices. Conservative (contained text only) so a neighbouring paragraph / image / rule line - // is never touched. When the supplied indices are already complete (the correct common case) - // NO extra object is contained -> paragraphSet == objectIndices -> byte-identical behaviour. + PageBand startBand = repagComputeBand(page, pushColumnLeft, columnRight); + double bottomLimitY = startBand.valid ? startBand.bottomLimitY : -1e18; + std::vector anchoredCentersStart = + startBand.valid ? repagAnchoredCenters(page, pushColumnLeft, columnRight, bottomLimitY) + : std::vector{}; + + if (!paraId.empty()) { + int cont = repagRemoveContinuations(doc_, pageIndex, paraId, pushColumnLeft, columnRight); + if (cont > 0) + spdlog::info("reflow_paragraph: removed continuation of paraId={} on {} page(s)", paraId, cont); + } + std::vector paragraphSet = objectIndices; { float ul = 0, ub = 0, ur = 0, ut = 0; bool haveUnion = false; @@ -2702,13 +2745,13 @@ std::expected PdfiumDocument::applyEdits(const std::string& e float l = 0, b = 0, r = 0, t = 0; if (!FPDFPageObj_GetBounds(o, &l, &b, &r, &t)) continue; if (!haveUnion) { ul = l; ub = b; ur = r; ut = t; haveUnion = true; } - else { // plain comparisons (Windows headers #define min/max macros) + else { if (l < ul) ul = l; if (b < ub) ub = b; if (r > ur) ur = r; if (t > ut) ut = t; } } if (haveUnion) { - const float eps = 0.5f; // tolerate sub-pixel bbox slop + const float eps = 0.5f; int nObjs = FPDFPage_CountObjects(page); int adopted = 0; for (int k = 0; k < nObjs; ++k) { @@ -2728,7 +2771,18 @@ std::expected PdfiumDocument::applyEdits(const std::string& e } } - // Resolve + load each run's emission font (subset over that run's codepoints). + if (!paraId.empty()) { + int nObjs = FPDFPage_CountObjects(page); + int adoptedById = 0; + for (int k = 0; k < nObjs; ++k) { + if (std::find(paragraphSet.begin(), paragraphSet.end(), k) != paragraphSet.end()) continue; + FPDF_PAGEOBJECT o = FPDFPage_GetObject(page, k); + if (o && repagGetParaId(o) == paraId) { paragraphSet.push_back(k); adoptedById++; } + } + if (adoptedById > 0) + spdlog::debug("reflow_paragraph: adopted {} anchor-page object(s) by paraId", adoptedById); + } + std::vector runFonts(runs.size()); auto toCodepoints = [](const std::string& s) { auto u16 = utf8_to_utf16le(s); @@ -2744,8 +2798,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e } return cps; }; - // Load each unique font ONCE, subset over the UNION of all its runs' glyphs - // (a font shared by several runs must cover every char it will emit). { std::unordered_map> fontCps; for (const auto& rs : runs) { @@ -2753,13 +2805,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e auto& dst = fontCps[rs.internalFontId]; dst.insert(dst.end(), cps.begin(), cps.end()); } - // Resolve each run font's ORIGINAL FPDF_FONT handle straight from the - // paragraph's own (still-open) source objects. Reusing that existing embedded - // font for emission — instead of re-subsetting and FPDFText_LoadFont'ing a NEW - // same-named font — is what prevents the multi-edit scramble: two embedded - // subsets that share a /BaseFont (e.g. two "Calibri") otherwise alias inside - // PDFium and the 2nd edit's text renders through the 1st's glyphs ("Core Java" - // -> "Cuwi Le,e"). The handle stays valid because `page` is open through emission. auto resolveOrigFont = [&](const std::string& fid) -> FPDF_FONT { const std::string expected = baseNameFromInternalFontId(fid); for (int idx : paragraphSet) { @@ -2782,11 +2827,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e } 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() @@ -2795,15 +2835,8 @@ std::expected PdfiumDocument::applyEdits(const std::string& e } } - // Measure a substring's width in a run's resolved font. HbShaper takes an - // INTEGER pixel size, so shape at a large reference size and scale to the real - // (fractional) font size — otherwise 9.96px rounds to 9px, every word measures - // ~10% narrow, and PDFium then renders them wider, collapsing the spaces. fonts::HbShaper shaper; constexpr unsigned int kRefSize = 1000; - // Per-character advances (PDF units) for a run substring — used to build the - // caret layout so the live preview can place a caret exactly on each glyph. For - // ASCII (one glyph per byte) this is exact; otherwise the width is spread evenly. auto perCharAdvances = [&](size_t runIdx, const std::string& text) -> std::vector { std::vector out(text.size(), 0.0); if (text.empty()) return out; @@ -2827,29 +2860,20 @@ std::expected PdfiumDocument::applyEdits(const std::string& e return out; }; - // Per-character advance for EVERY run, computed once: the source's ORIGINAL advances - // when the run carries them aligned 1:1 with its bytes (unchanged pure-ASCII text — - // reproduces the source exactly), otherwise HarfBuzz-measured (edited/new/non-ASCII). - // All width/gap/caret math below reads from here, so unchanged words never drift. std::vector> runCharAdv(runs.size()); - std::vector runPerChar(runs.size(), 0); // 1 = emit per-character (exact placement) + std::vector runPerChar(runs.size(), 0); for (size_t ri = 0; ri < runs.size(); ++ri) { if (runs[ri].advances.size() == runs[ri].text.size() && !runs[ri].text.empty()) { - runCharAdv[ri] = runs[ri].advances; // pinned to source positions - // Only emit per-character where the SOURCE advances actually diverge from the - // font's natural advances — there a single text object would re-space glyphs - // and smear (bold/tracked text). Where they match (most regular text), keep - // ONE object per segment: it renders identically AND avoids per-glyph bloat. + runCharAdv[ri] = runs[ri].advances; auto natural = perCharAdvances(ri, runs[ri].text); bool diverges = natural.size() != runCharAdv[ri].size(); for (size_t c = 0; !diverges && c < natural.size(); ++c) if (std::abs(natural[c] - runCharAdv[ri][c]) > 0.05) diverges = true; runPerChar[ri] = diverges ? 1 : 0; } else { - runCharAdv[ri] = perCharAdvances(ri, runs[ri].text); // re-measured (per-segment) + runCharAdv[ri] = perCharAdvances(ri, runs[ri].text); } } - // Advance of run `ri`'s byte at offset `off` (0 if out of range). auto charAdvAt = [&](int ri, size_t off) -> double { const auto& v = runCharAdv[static_cast(ri)]; return off < v.size() ? v[off] : 0.0; @@ -2860,11 +2884,8 @@ std::expected PdfiumDocument::applyEdits(const std::string& e struct Word { std::vector segs; double width; double spaceAfter; }; std::vector words; - // Tokenize a sequence of runs into words (non-space runs split into same-style - // segments), append to `words`, return the new word indices. Widths and the trailing - // space gap come from runCharAdv, so unchanged words keep their exact source metrics. auto tokenize = [&](const std::vector& runIdxs) -> std::vector { - std::string ft; std::vector sof; std::vector cof; // char-offset within its run + std::string ft; std::vector sof; std::vector cof; for (int ri : runIdxs) { size_t off = 0; for (char ch : runs[ri].text) { ft.push_back(ch); sof.push_back(ri); cof.push_back(off++); } @@ -2879,10 +2900,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e while (i < ft.size() && !isSpace(ft[i]) && sof[i] == st) { frag.push_back(ft[i]); fw += charAdvAt(st, cof[i]); i++; } w.segs.push_back({st, frag, fw, segOff}); w.width += fw; } - // Trailing gap = sum of the ORIGINAL advances of the run of space chars - // (the source/model may use several space glyphs per visual gap), so the - // inter-word spacing matches the source exactly for unchanged text. Consume - // them here (the loop top no longer needs to skip them). while (i < ft.size() && isSpace(ft[i])) { w.spaceAfter += charAdvAt(sof[i], cof[i]); i++; } out.push_back(words.size()); words.push_back(std::move(w)); @@ -2892,13 +2909,11 @@ std::expected PdfiumDocument::applyEdits(const std::string& e std::vector> lines; if (hasProvidedLines) { - // WYSIWYG: emit the frontend's exact visual line breaks. for (const auto& lineRunIdxs : providedLines) { auto wi = tokenize(lineRunIdxs); if (!wi.empty()) lines.push_back(std::move(wi)); } } else { - // Greedy-break the whole paragraph within the column. std::vector allRuns(runs.size()); for (size_t ri = 0; ri < runs.size(); ++ri) allRuns[ri] = static_cast(ri); auto allWords = tokenize(allRuns); @@ -2918,8 +2933,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e if (words.empty() || lines.empty()) { FPDF_ClosePage(page); continue; } int newLineCount = static_cast(lines.size()); - // Push following in-column content down/up by the height delta (do this BEFORE - // deleting the old objects so indices stay valid and new objects aren't moved). double deltaH = (newLineCount - oldLineCount) * leading; double paragraphBottomBaseline = firstBaselineY - (oldLineCount - 1) * leading; if (std::abs(deltaH) > 0.01) { @@ -2933,7 +2946,7 @@ std::expected PdfiumDocument::applyEdits(const std::string& e float l = 0, bo = 0, rr = 0, tt = 0; if (!FPDFPageObj_GetBounds(o, &l, &bo, &rr, &tt)) continue; double cY = (bo + tt) / 2.0; - if (cY < threshold && rr > pushColumnLeft && l < columnRight) { + if (cY < threshold && cY >= bottomLimitY && rr > pushColumnLeft && l < columnRight) { FPDFPageObj_Transform(o, 1.0, 0.0, 0.0, 1.0, 0.0, -deltaH); pushed++; } @@ -2941,9 +2954,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e spdlog::info("reflow_paragraph: lines {}->{}, deltaH={}, pushed {} objects", oldLineCount, newLineCount, deltaH, pushed); } - // Delete the old paragraph objects (descending so indices stay valid). Uses paragraphSet - // so any object the model omitted but that the geometric backstop adopted is also removed - // (otherwise its glyphs would remain UNDER the freshly-emitted text -> "bulge/merge"). std::sort(paragraphSet.begin(), paragraphSet.end(), std::greater()); int minIndex = paragraphSet.back(); for (int idx : paragraphSet) { @@ -2951,13 +2961,8 @@ std::expected PdfiumDocument::applyEdits(const std::string& e if (o) { FPDFPage_RemoveObject(page, o); FPDFPageObj_Destroy(o); } } - // Emit the new lines (one text object per styled segment, positioned explicitly). - // While positioning, also capture a per-character caret layout (PDF coords) so the - // live preview can place a caret exactly on the rendered glyphs. nlohmann::json layoutLines = nlohmann::json::array(); for (size_t li = 0; li < lines.size(); ++li) { - // Exact source baseline for this line when provided (lines path, unchanged - // content); else the uniform fallback (greedy path / re-wrapped lines). double baselineY = (li < lineBaselineY.size()) ? lineBaselineY[li] : firstBaselineY - static_cast(li) * leading; auto& lw = lines[li]; @@ -2971,22 +2976,13 @@ std::expected PdfiumDocument::applyEdits(const std::string& e if (justifyThis) { double slack = columnWidth - naturalW; if (slack > 0) extraPerGap = slack / static_cast(lw.size() - 1); } std::string lineText; - std::vector adv; // advance (PDF units) of each char in lineText + std::vector adv; double lineFontSize = 0.0; - double x = (li < lineX.size()) ? lineX[li] : columnLeft; // exact source left when provided - // Center/right alignment: shift the whole line within the column. Only on the - // greedy/re-wrapped path (no provided lineX) so the unchanged "lines" path and the - // existing left/justify behaviour are byte-identical. justifyThis is already false - // for center/right (it only triggers on align=="justify"), so no gap distribution. + double x = (li < lineX.size()) ? lineX[li] : columnLeft; if (li >= lineX.size()) { if (align == "right") x = columnLeft + (columnWidth - naturalW); else if (align == "center") x = columnLeft + (columnWidth - naturalW) / 2.0; } - // The line's TRUE starting x (after lineX/center/right). The caret layout below - // records this as x0 so the frontend places the caret on the actual glyphs. (It - // used to hardcode columnLeft, which only matched left-aligned lines — center/right - // headings then mismapped clicks. x0 is caret-only metadata; it does not affect - // glyph emission or the rendered page.) const double lineStartX = x; for (size_t k = 0; k < lw.size(); ++k) { size_t wi = lw[k]; @@ -3008,13 +3004,10 @@ std::expected PdfiumDocument::applyEdits(const std::string& e auto u16 = utf8_to_utf16le(s); u16.push_back(0); FPDFText_SetText(obj, reinterpret_cast(u16.data())); FPDFPageObj_Transform(obj, 1.0, 0.0, 0.0, 1.0, atX, baselineY); + repagSetParaId(doc_, obj, paraId); FPDFPage_InsertObjectAtIndex(page, obj, minIndex); }; if (runPerChar[seg.runIdx]) { - // Source advances diverge from natural: emit ONE object per character at - // its EXACT original x so the glyphs land pixel-for-pixel on the source — - // FPDFText_SetText would otherwise re-space them at the font's natural - // advances and smear within a word (worst on bold/tracked text). double gx = segX; for (size_t c = 0; c < seg.text.size(); ++c) { double a = charAdvAt(seg.runIdx, seg.off + c); @@ -3023,7 +3016,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e gx += a; } } else { - // Edited / non-ASCII run: emit the whole segment (natural advances). emitObj(seg.text, segX); for (size_t c = 0; c < seg.text.size(); ++c) { lineText.push_back(seg.text[c]); adv.push_back(charAdvAt(seg.runIdx, seg.off + c)); } } @@ -3037,12 +3029,41 @@ std::expected PdfiumDocument::applyEdits(const std::string& e {"text", lineText}, {"adv", adv}, }); } - lastReflowLayout_ = nlohmann::json{{"columnLeft", columnLeft}, {"lines", layoutLines}}.dump(); + + { + int pageOff = 0; double cur = 0.0; bool flowing = false; + for (auto& ln : layoutLines) { + double b = ln["baselineY"].get(); + if (!flowing && startBand.valid && b < startBand.bottomLimitY - 0.01) { + flowing = true; pageOff = 1; cur = startBand.placementTopY; + } + if (flowing) { + if (cur < startBand.bottomLimitY - 0.01) { pageOff++; cur = startBand.placementTopY; } + ln["baselineY"] = cur; cur -= leading; + } + ln["pageIndex"] = pageIndex + pageOff; + } + } + lastReflowLayout_ = nlohmann::json{ + {"columnLeft", columnLeft}, {"anchorPage", pageIndex}, {"lines", layoutLines}}.dump(); if (!FPDFPage_GenerateContent(page)) { spdlog::error("Failed to generate page content after reflow_paragraph"); } FPDF_ClosePage(page); + + lastReflowOverflowed_ = false; + if (startBand.valid) { + int moved = 0; + int added = repaginateForward(doc_, pageIndex, columnLeft, columnRight, + pushColumnLeft, leading, anchoredCentersStart, &moved); + int removed = repaginateBackward(doc_, pageIndex, columnLeft, columnRight, + pushColumnLeft, leading); + lastReflowOverflowed_ = (moved > 0 || added > 0 || removed > 0); + if (added > 0 || removed > 0) + spdlog::info("reflow_paragraph: cross-page reflow added {} / removed {} page(s)", + added, removed); + } } else if (type == "text_overlay" || type == "add_text") { if (!op.contains("data") || !op["data"].is_object()) { spdlog::error("text_overlay/add_text operation missing 'data' object"); @@ -3068,7 +3089,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e return std::unexpected(EngineError::Unknown); } - // 1. Cover old text with solid white rectangle FPDF_PAGEOBJECT rectObj = FPDFPageObj_CreateNewRect( static_cast(x), static_cast(y), @@ -3084,7 +3104,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e FPDFPath_SetDrawMode(rectObj, FPDF_FILLMODE_WINDING, 0); FPDFPage_InsertObject(page, rectObj); - // 2. Load standard font std::string standardFontName = "Helvetica"; if (fontFamily == "Times New Roman" || fontFamily == "Times-Roman") { standardFontName = "Times-Roman"; @@ -3116,7 +3135,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e font = FPDFText_LoadStandardFont(doc_, "Helvetica"); } - // 3. Create text page object FPDF_PAGEOBJECT textObj = FPDFPageObj_CreateTextObj(doc_, font, static_cast(fontSize)); if (!textObj) { spdlog::error("Failed to create text object"); @@ -3124,12 +3142,10 @@ std::expected PdfiumDocument::applyEdits(const std::string& e return std::unexpected(EngineError::Unknown); } - // Set text color unsigned int r = 0, g = 0, b = 0; parseHexColor(color, r, g, b); FPDFPageObj_SetFillColor(textObj, r, g, b, 255); - // Set text contents auto utf16 = utf8_to_utf16le(text); if (!FPDFText_SetText(textObj, reinterpret_cast(utf16.data()))) { spdlog::error("Failed to set text object text"); @@ -3138,13 +3154,10 @@ std::expected PdfiumDocument::applyEdits(const std::string& e return std::unexpected(EngineError::Unknown); } - // Position the text object at x, y FPDFPageObj_Transform(textObj, 1.0, 0.0, 0.0, 1.0, x, y); - // Insert text object into page FPDFPage_InsertObject(page, textObj); - // Regenerate page visual representation if needed if (!FPDFPage_GenerateContent(page)) { spdlog::error("Failed to generate page content after editing"); FPDF_ClosePage(page); @@ -3165,9 +3178,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e return std::unexpected(EngineError::Unknown); } - // Adobe-grade: create a real text-markup annotation (editable, deletable, - // round-trips, and positioned within the text quad by PDFium's appearance - // generator) instead of baking a fixed-offset path. Mirrors the highlight handler. int subtype = FPDF_ANNOT_UNDERLINE; if (type == "strikeout") subtype = FPDF_ANNOT_STRIKEOUT; else if (type == "squiggly") subtype = FPDF_ANNOT_SQUIGGLY; @@ -3193,7 +3203,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e for (const auto& quad : data["quadPoints"]) { FS_QUADPOINTSF points; - // Frontend is top-down; backend is bottom-up. points.x1 = static_cast(quad.value("x1", 0.0)); points.y1 = static_cast(pageHeight - quad.value("y1", 0.0)); points.x2 = static_cast(quad.value("x2", 0.0)); @@ -3245,14 +3254,12 @@ std::expected PdfiumDocument::applyEdits(const std::string& e return std::unexpected(EngineError::Unknown); } - // 1. Permanently remove underlying page objects overlapping with selection int count = FPDFPage_CountObjects(page); for (int i = count - 1; i >= 0; --i) { FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page, i); if (obj) { float left = 0, bottom = 0, right = 0, top = 0; if (FPDFPageObj_GetBounds(obj, &left, &bottom, &right, &top)) { - // Check overlap if (!(left > x + width || right < x || bottom > y + height || top < y)) { FPDFPage_RemoveObject(page, obj); FPDFPageObj_Destroy(obj); @@ -3261,7 +3268,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e } } - // 2. Place white cover rectangle over region FPDF_PAGEOBJECT rectObj = FPDFPageObj_CreateNewRect( static_cast(x), static_cast(y), @@ -3281,7 +3287,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e FPDFPath_SetDrawMode(rectObj, FPDF_FILLMODE_WINDING, 0); FPDFPage_InsertObject(page, rectObj); - // 3. Regenerate page content stream if (!FPDFPage_GenerateContent(page)) { spdlog::error("Failed to generate page content after redaction"); FPDF_ClosePage(page); @@ -3304,8 +3309,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e return std::unexpected(EngineError::InvalidFormat); } - // Field identity: prefer data.annotationId (NM or anno__), - // fall back to the op id (legacy) for compatibility. std::string targetId = data.value("annotationId", op.value("id", "")); FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex); @@ -3341,15 +3344,12 @@ std::expected PdfiumDocument::applyEdits(const std::string& e if (form) FORM_OnAfterLoadPage(page, form); if (fieldType == 2 || fieldType == 3) { - // Checkbox / radio: /V + /AS (predefined on/off appearances render directly). - // NB: "Yes" is the common on-state; custom export values are a known v1 limitation. std::string state = boolVal ? "Yes" : "Off"; auto u = utf8_to_utf16le(state); FPDFAnnot_SetStringValue(target, "V", reinterpret_cast(u.data())); FPDFAnnot_SetStringValue(target, "AS", reinterpret_cast(u.data())); } else if (form && FORM_SetFocusedAnnot(form, target)) { if (fieldType == 4 || fieldType == 5) { - // Choice (combo/listbox): select the option whose label matches the value. int optCount = FPDFAnnot_GetOptionCount(form, target); int sel = -1; for (int o = 0; o < optCount; ++o) { @@ -3368,14 +3368,12 @@ std::expected PdfiumDocument::applyEdits(const std::string& e FPDFAnnot_SetStringValue(target, "V", reinterpret_cast(u.data())); } } else { - // Text field: select-all + replace → form module regenerates the appearance. FORM_SelectAllText(form, page); auto u = utf8_to_utf16le(strVal); FORM_ReplaceSelection(form, page, reinterpret_cast(u.data())); } FORM_ForceToKillFocus(form); } else { - // Fallback (no form env): best-effort value set. std::string v = isBool ? (boolVal ? "Yes" : "Off") : strVal; auto u = utf8_to_utf16le(v); FPDFAnnot_SetStringValue(target, "V", reinterpret_cast(u.data())); @@ -3448,7 +3446,7 @@ std::expected PdfiumDocument::applyEdits(const std::string& e return std::unexpected(EngineError::Unknown); } - FPDF_BITMAP bitmap = FPDFBitmap_Create(pixelWidth, pixelHeight, 4); // 4 = FPDFBitmap_BGRA + FPDF_BITMAP bitmap = FPDFBitmap_Create(pixelWidth, pixelHeight, 4); if (!bitmap) { spdlog::error("Failed to create FPDF_BITMAP"); FPDFPageObj_Destroy(imgObj); @@ -3467,13 +3465,10 @@ std::expected PdfiumDocument::applyEdits(const std::string& e return std::unexpected(EngineError::Unknown); } - // Apply positioning and scaling to the image object FPDFPageObj_Transform(imgObj, width, 0.0, 0.0, height, x, y); - // Insert into page FPDFPage_InsertObject(page, imgObj); - // Regenerate page contents if (!FPDFPage_GenerateContent(page)) { spdlog::error("Failed to generate page content after image insertion"); FPDFBitmap_Destroy(bitmap); @@ -3508,20 +3503,17 @@ std::expected PdfiumDocument::applyEdits(const std::string& e std::string colorStr = data.value("color", "#ffff00"); unsigned int r = 255, g = 255, b = 0; parseHexColor(colorStr, r, g, b); - // Note: PDFium doesn't seem to expose opacity for annotations easily, - // but standard viewers apply Multiply blend mode for highlights. FPDFAnnot_SetColor(annot, FPDFANNOT_COLORTYPE_Color, r, g, b, 255); if (data.contains("quadPoints") && data["quadPoints"].is_array()) { FS_RECTF boundingBox; boundingBox.left = 99999.0f; boundingBox.right = -99999.0f; - boundingBox.top = -99999.0f; // top means higher Y in PDF coordinates (bottom-up) - boundingBox.bottom = 99999.0f; // bottom means lower Y + boundingBox.top = -99999.0f; + boundingBox.bottom = 99999.0f; for (const auto& quad : data["quadPoints"]) { FS_QUADPOINTSF points; - // Frontend is top-down. Backend is bottom-up. points.x1 = static_cast(quad.value("x1", 0.0)); points.y1 = static_cast(pageHeight - quad.value("y1", 0.0)); points.x2 = static_cast(quad.value("x2", 0.0)); @@ -3533,7 +3525,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e FPDFAnnot_AppendAttachmentPoints(annot, &points); - // Expand bounding box float minX = (std::min)({points.x1, points.x2, points.x3, points.x4}); float maxX = (std::max)({points.x1, points.x2, points.x3, points.x4}); float minY = (std::min)({points.y1, points.y2, points.y3, points.y4}); @@ -3591,8 +3582,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e double x = data.value("x", 0.0); double y = data.value("y", 0.0); - // Set the bounding box for the sticky note icon (e.g. 24x24) - // x, y is from frontend (top-down). Convert to bottom-up. FS_RECTF rect; rect.left = static_cast(x); rect.right = static_cast(x + 24.0); @@ -3601,7 +3590,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e FPDFAnnot_SetRect(annot, &rect); - // Color (yellow for comment by default) std::string colorStr = data.value("color", "#ffeb3b"); unsigned int r = 255, g = 235, b = 59; parseHexColor(colorStr, r, g, b); @@ -3657,7 +3645,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e float thickness = static_cast(data.value("thickness", 2.0)); FPDFAnnot_SetBorder(annot, 0.0f, 0.0f, thickness); - // Accumulate bounding box in PDF (bottom-up) space. float minX = 1e9f, minY = 1e9f, maxX = -1e9f, maxY = -1e9f; bool anyPoints = false; @@ -3668,7 +3655,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e pts.reserve(path.size()); for (const auto& pt : path) { float px = static_cast(pt.value("x", 0.0)); - // Frontend stores ink points top-down in page points; flip to PDF bottom-up. float py = static_cast(pageHeight - pt.value("y", 0.0)); pts.push_back(FS_POINTF{px, py}); anyPoints = true; @@ -3765,7 +3751,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e FPDF_ANNOTATION annot = FPDFPage_GetAnnot(page, i); if (!annot) continue; - // Reconstruct the same id extractAnnotations() exposes (NM, else anno__). std::string id; unsigned long len = FPDFAnnot_GetStringValue(annot, "NM", nullptr, 0); if (len > 2) { @@ -3834,7 +3819,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e } if (targetAnnot) { - // Update Rect if (data.contains("x") && data.contains("y") && data.contains("width") && data.contains("height")) { double pageHeight = FPDF_GetPageHeightF(page); float x = static_cast(data["x"].get()); @@ -3850,7 +3834,6 @@ std::expected PdfiumDocument::applyEdits(const std::string& e FPDFAnnot_SetRect(targetAnnot, &rect); } - // Update Color if (data.contains("color")) { std::string colorStr = data["color"].get(); unsigned int r = 0, g = 0, b = 0; @@ -3858,13 +3841,11 @@ std::expected PdfiumDocument::applyEdits(const std::string& e FPDFAnnot_SetColor(targetAnnot, FPDFANNOT_COLORTYPE_Color, r, g, b, 255); } - // Update Thickness if (data.contains("thickness")) { float thickness = static_cast(data["thickness"].get()); FPDFAnnot_SetBorder(targetAnnot, 0.0f, 0.0f, thickness); } - // Update Text if (data.contains("text")) { std::string text = data["text"].get(); auto utf16 = utf8_to_utf16le(text); @@ -3936,15 +3917,11 @@ std::expected, EngineError> PdfiumPage::getFonts() const { return std::unexpected(EngineError::Unknown); } - // Build real font data from the PDF font dictionary via page object walk. - // This provides accurate values for isEmbedded, subtype, flags, ascent, - // descent, and hasUnicodeMapping — replacing name-based heuristics. auto fontPdfDataMap = buildFontPdfDataMap(page_, textPage_); std::vector pageFonts; int charCount = FPDFText_CountChars(textPage_); - // Security Guard: prevent excessive traversal on extremely corrupted text pages if (charCount < 0 || charCount > 1000000) { spdlog::error("Invalid or excessive character count in page ({}): aborting font extraction", charCount); return pageFonts; @@ -3963,9 +3940,6 @@ std::expected, EngineError> PdfiumPage::getFonts() const { return ""; }; - // Detect vertical writing mode per font by analyzing character origin positions. - // We filter out large spatial jumps (e.g. line breaks or paragraph changes) - // by comparing the coordinate delta to the character's font size. struct FontPositionStats { int horizontalSteps = 0; int verticalSteps = 0; @@ -3976,7 +3950,6 @@ std::expected, EngineError> PdfiumPage::getFonts() const { }; std::unordered_map fontStats; - // First pass: collect font names, flags, and position statistics struct CharInfo { std::string fontName; int flags; @@ -3997,9 +3970,6 @@ std::expected, EngineError> PdfiumPage::getFonts() const { double dy = std::abs(oy - stats.prevY); double fontSize = FPDFText_GetFontSize(textPage_, i); - // Filter out non-consecutive jumps (e.g., line or paragraph breaks). - // We allow a generously large multiplier (3.0) for wide tracking, - // but enforce a minimum of 30.0 units for very small text. double maxJump = (std::max)(fontSize * 3.0, 30.0); if (dx < maxJump && dy < maxJump) { @@ -4020,16 +3990,13 @@ std::expected, EngineError> PdfiumPage::getFonts() const { } } - // Determine which fonts are vertical std::unordered_map fontIsVertical; for (const auto& [fname, stats] : fontStats) { bool vertical = false; - // Primary heuristic: explicit step counts for consecutive characters if (stats.verticalSteps > 0 || stats.horizontalSteps > 0) { vertical = stats.verticalSteps > stats.horizontalSteps; } else { - // Fallback: if no clear steps were isolated, rely on filtered totals vertical = (stats.totalFilteredDeltaY > 2.0 * stats.totalFilteredDeltaX) && (stats.totalFilteredDeltaY > 0.5); } @@ -4037,14 +4004,12 @@ std::expected, EngineError> PdfiumPage::getFonts() const { fontIsVertical[fname] = vertical; } - // Second pass: build FontInfo list for (int i = 0; i < charCount; ++i) { const std::string& fontName = charInfos[i].fontName; if (fontName.empty()) { continue; } - // Deduplicate locally by fontName auto it = std::find_if(pageFonts.begin(), pageFonts.end(), [&](const FontInfo& f) { return f.fontName == fontName; }); @@ -4056,12 +4021,8 @@ std::expected, EngineError> PdfiumPage::getFonts() const { f.fontName = fontName; f.flags = static_cast(charInfos[i].flags); - // Phase A: heuristic baseline — fills encoding, cmapName, cidSystemInfo, - // normalizedFamily, subset tag, and initial values for all other fields. deduceFontMetadata(f); - // Phase B: override heuristic fields with real PDF dictionary values - // wherever buildFontPdfDataMap() was able to extract them. auto pdfDataIt = fontPdfDataMap.find(fontName); if (pdfDataIt != fontPdfDataMap.end()) { const FontPdfData& pd = pdfDataIt->second; @@ -4069,20 +4030,13 @@ std::expected, EngineError> PdfiumPage::getFonts() const { auto lname = fontName; std::transform(lname.begin(), lname.end(), lname.begin(), [](unsigned char c) { return static_cast(::tolower(c)); }); - // --- Embedding status (replaces subset-tag heuristic) --- f.isEmbedded = pd.isEmbedded; - // --- Font descriptor flags (real /Flags value) --- f.flags = static_cast(pd.flags); - // --- Descriptor metrics (real ascent/descent) --- if (pd.ascent != 0.0) f.ascent = pd.ascent; if (pd.descent != 0.0) f.descent = pd.descent; - // CapHeight is not exposed by the PDFium public API. - // Use the exact specification values for the standard 14 fonts; - // for everything else, estimate from real ascent (71% is the - // empirical Latin cap-height ratio across common typefaces). if (f.ascent > 0.0) { if (lname.find("times") != std::string::npos) f.capHeight = 662.0; else if (lname.find("courier") != std::string::npos) f.capHeight = 562.0; @@ -4091,26 +4045,18 @@ std::expected, EngineError> PdfiumPage::getFonts() const { else f.capHeight = f.ascent * 0.71; } - // --- Unicode mapping (replaces always-true heuristic) --- if (lname.find("symbol") != std::string::npos) { f.hasToUnicode = false; } else { f.hasToUnicode = pd.hasUnicodeMapping; } - // --- sourceType derived from real embedding status --- if (f.isEmbedded) { - // Font stream is present in the PDF — always report as Embedded. f.sourceType = "Embedded"; f.substitutedFrom = ""; f.substitutedTo = ""; } - // If not embedded, keep the substitution info already set by - // deduceFontMetadata() (SystemFallback or Substituted). - // --- Recalculate stable identifier with corrected data --- - // fontName already includes the subset prefix (e.g. "ABCDEF+Arial"); - // using it directly avoids the duplicate-prefix bug. f.internalFontId = makeInternalFontId(f); spdlog::debug( @@ -4120,12 +4066,10 @@ std::expected, EngineError> PdfiumPage::getFonts() const { f.capHeight, f.hasToUnicode, f.flags); } - // Override isVertical with position-based detection if not already set by name heuristic if (!f.isVertical) { auto vit = fontIsVertical.find(fontName); if (vit != fontIsVertical.end() && vit->second) { f.isVertical = true; - // Update encoding to reflect vertical writing mode if (f.encoding == "WinAnsiEncoding" || f.encoding == "Identity-H") { f.encoding = "Identity-V"; f.cmapName = "Identity-V"; @@ -4137,7 +4081,6 @@ std::expected, EngineError> PdfiumPage::getFonts() const { pageFonts.push_back(f); } - // Deterministic Sorting: normalizedFamily -> fontName -> encoding -> type std::sort(pageFonts.begin(), pageFonts.end(), [](const FontInfo& a, const FontInfo& b) { if (a.normalizedFamily != b.normalizedFamily) { return a.normalizedFamily < b.normalizedFamily; @@ -4173,7 +4116,6 @@ std::expected, EngineError> PdfiumDocument::getFonts(int s } - // Return full document-level cache if available and full range is requested if (startPage == 0 && endPage == total - 1 && hasCachedFonts_) { return cachedFonts_; } @@ -4199,7 +4141,6 @@ std::expected, EngineError> PdfiumDocument::getFonts(int s } } - // Sort the aggregated list deterministically std::sort(aggregated.begin(), aggregated.end(), [](const FontInfo& a, const FontInfo& b) { if (a.normalizedFamily != b.normalizedFamily) { return a.normalizedFamily < b.normalizedFamily; @@ -4213,7 +4154,6 @@ std::expected, EngineError> PdfiumDocument::getFonts(int s return a.type < b.type; }); - // Cache if full scan was requested if (startPage == 0 && endPage == total - 1) { cachedFonts_ = aggregated; hasCachedFonts_ = true; @@ -4231,11 +4171,6 @@ std::optional> PdfiumDocument::getFontDataFromObjects(int pageIndex, const std::vector& objectIndices, const std::string& internalFontId) const { #ifdef PDFENGINE_WITH_PDFIUM - // Resolve the embedded font program from the SPECIFIC page objects of the paragraph being - // edited, instead of a document-wide name scan (getFontData). Those objects still reference - // the paragraph's ORIGINAL font; a prior reflow's same-named subset lives in a DIFFERENT - // paragraph's objects, so it can never be picked here. This is what makes the 2nd, 3rd, Nth - // edit in one session commit correctly instead of scrambling ("Core Java" -> "Cuwi Le,e"). ensure_pdfium_initialized(); if (!doc_ || objectIndices.empty()) return std::nullopt; const std::string expected = baseNameFromInternalFontId(internalFontId); @@ -4251,7 +4186,7 @@ PdfiumDocument::getFontDataFromObjects(int pageIndex, const std::vector& ob if (nameLen == 0) continue; std::vector nameBuf(nameLen); if (FPDFFont_GetBaseFontName(font, nameBuf.data(), nameLen) == 0) continue; - if (std::string(nameBuf.data()) != expected) continue; // exact /BaseFont match + if (std::string(nameBuf.data()) != expected) continue; size_t buflen = 0; FPDFFont_GetFontData(font, nullptr, 0, &buflen); if (buflen == 0) continue; @@ -4277,17 +4212,14 @@ std::expected, EngineError> PdfiumDocument::getFontData(con return std::unexpected(EngineError::Unknown); } - // Reconstruct the exact expected /BaseFont name from the internalFontId. std::string expectedFontName = baseNameFromInternalFontId(internalFontId); int numPages = FPDF_GetPageCount(doc_); - // Resume scanning from where we left off int startPage = 0; { std::lock_guard lock(fontsMutex_); - // Return immediately if already cached if (fontDataCache_.count(expectedFontName)) { const auto& cachedBuf = fontDataCache_[expectedFontName]; if (!cachedBuf.empty()) { @@ -4323,7 +4255,6 @@ std::expected, EngineError> PdfiumDocument::getFontData(con std::lock_guard lock(fontsMutex_); - // Always cache every font we encounter to avoid rescanning if (fontDataCache_.find(fontName) == fontDataCache_.end()) { size_t buflen = 0; FPDFFont_GetFontData(font, nullptr, 0, &buflen); @@ -4340,12 +4271,11 @@ std::expected, EngineError> PdfiumDocument::getFontData(con } } - // Use exact match instead of prefix match to avoid false positives if (fontName == expectedFontName) { const auto& cachedBuf = fontDataCache_[fontName]; if (!cachedBuf.empty()) { FPDF_ClosePage(page); - fontDataScannedPages_ = i; // can resume from the same page later + fontDataScannedPages_ = i; return cachedBuf; } } @@ -4358,10 +4288,8 @@ std::expected, EngineError> PdfiumDocument::getFontData(con fontDataScannedPages_ = i + 1; } - // We finished scanning all pages and still didn't find it (or extraction failed) { std::lock_guard lock(fontsMutex_); - // Mark as failed so we don't try to rescan for it if (fontDataCache_.find(expectedFontName) == fontDataCache_.end()) { fontDataCache_[expectedFontName] = std::vector(); } @@ -4377,23 +4305,19 @@ std::expected, std::string> PdfiumDocume #ifdef PDFENGINE_WITH_PDFIUM std::lock_guard lock(resolvedFontsMutex_); - // Check Cache if (resolvedFontsCache_.count(fontInfo.internalFontId)) { return resolvedFontsCache_[fontInfo.internalFontId]; } if (!fontResolver_) { - // We pass a shared_ptr to 'this' fontResolver_ = std::make_unique<::pdfengine::fonts::loader::FontResolver>(shared_from_this()); } - // Resolve font auto result = fontResolver_->resolveFont(fontInfo); if (!result) { return std::unexpected(result.error()); } - // Cache and return std::shared_ptr sharedFont(std::move(result.value())); resolvedFontsCache_[fontInfo.internalFontId] = sharedFont; return sharedFont; diff --git a/engine/src/parser/pdfium_document.hpp b/engine/src/parser/pdfium_document.hpp index 9ff9fa6..d458d3f 100644 --- a/engine/src/parser/pdfium_document.hpp +++ b/engine/src/parser/pdfium_document.hpp @@ -18,7 +18,7 @@ namespace pdfengine::fonts { class FontFace; } namespace pdfengine::parser { -class PdfiumDocument; // a page keeps its owning document alive (see ownerDoc_) +class PdfiumDocument; #ifdef PDFENGINE_WITH_PDFIUM using NativeDocHandle = FPDF_DOCUMENT; @@ -64,9 +64,6 @@ private: mutable NativeTextHandle textPage_ = nullptr; int pageIndex_ = 0; mutable std::mutex textMutex_; - // Keeps the owning document (and thus the native FPDF_DOCUMENT) alive for as - // long as this page exists, so page_/textPage_ can never dangle. Declared - // here (destroyed last) so it outlives the handle teardown in the destructor. std::shared_ptr ownerDoc_; #ifdef PDFENGINE_WITH_PDFIUM mutable std::unordered_map fontHandleCache_; @@ -102,13 +99,12 @@ public: std::expected, EngineError> saveIncremental() const override; std::expected, EngineError> saveFull() const override; - // Per-character layout (PDF coords) of the LAST reflow_paragraph op, as JSON. The WASM - // preview uses it to align a custom caret exactly to the rendered glyphs. Empty if the - // most recent applyEdits contained no reflow_paragraph operation. std::string lastReflowLayout() const { return lastReflowLayout_; } + bool lastReflowOverflowed() const { return lastReflowOverflowed_; } private: mutable std::string lastReflowLayout_; + mutable bool lastReflowOverflowed_ = false; NativeDocHandle doc_ = nullptr; std::vector memoryBuffer_; @@ -116,53 +112,31 @@ private: mutable bool hasCachedFonts_ = false; mutable std::mutex fontsMutex_; - // Cache to prevent O(N*M) full-document scans for font data extraction mutable std::unordered_map> fontDataCache_; mutable int fontDataScannedPages_ = 0; - // Resolve an embedded font program from the SPECIFIC page objects of the paragraph being - // edited (its objectIndices), matching each object's /BaseFont to internalFontId. Used by - // reflow so a re-subset re-uses the exact font its source text referenced — no document-wide - // name collision when several same-named subsets accumulate across sequential edits. - // Returns nullopt if no object in the set uses that font. std::optional> getFontDataFromObjects(int pageIndex, const std::vector& objectIndices, const std::string& internalFontId) const; - // Weak so the document never co-owns its pages: ownership runs page → document - // only. A cached entry is reused while a page is still referenced elsewhere, - // and lazily rebuilt once it expires. mutable std::unordered_map> pageCache_; mutable std::mutex pageCacheMutex_; - // Font Engine Bridge std::unique_ptr fontResolver_; std::unordered_map> resolvedFontsCache_; std::mutex resolvedFontsMutex_; - // Loaded PDFium Font Cache for Font Reuse #ifdef PDFENGINE_WITH_PDFIUM mutable std::unordered_map loadedFontsCache_; mutable std::unordered_map> loadedFontDataBuffers_; mutable std::unordered_map> loadedMeasureFaces_; mutable std::mutex loadedFontsMutex_; - // Resolve + load a PDFium font for emitting NEW text (mirrors the replace_text tiers: - // embedded full/subset reuse, base-14 standard, or system-font subset embed). Returns - // the FPDF_FONT (for FPDFPageObj_CreateTextObj) and the resolved font (FontFace for - // HarfBuzz width measurement during line-breaking). Either field may be null on failure. struct EmissionFont { FPDF_FONT font = nullptr; std::shared_ptr resolved; - // A FontFace built from the EXACT bytes loaded into PDFium, so width measurement - // matches what PDFium renders (the resolver's face can have different advances). std::shared_ptr measureFace; }; - // srcObjects = the page objects of the paragraph being reflowed (for per-object font - // resolution); pass {} when no source objects are known (falls back to the name scan). - // reuseFont = the ORIGINAL embedded FPDF_FONT of the source text (from a still-open page); - // when non-null the embedded path reuses it directly instead of re-subsetting + loading a new - // same-named font (which PDFium aliases, scrambling multi-edit output). null => subset+load. EmissionFont loadEmissionFont(int pageIndex, const std::string& internalFontId, double fontSize, const std::vector& codepoints, const std::vector& srcObjects = {}, @@ -170,7 +144,6 @@ private: #endif }; -// Exposed for testing std::vector utf8_to_utf16le(const std::string& utf8); std::string utf16le_to_utf8(const char16_t* utf16, size_t length); std::string code_point_to_utf8(unsigned int cp); diff --git a/frontend/public/pdfium-engine.mjs b/frontend/public/pdfium-engine.mjs index 0ad6cdd..7597547 100644 --- a/frontend/public/pdfium-engine.mjs +++ b/frontend/public/pdfium-engine.mjs @@ -1,2 +1,2 @@ -async function Module(moduleArg={}){var moduleRtn;var Module=moduleArg;var ENVIRONMENT_IS_WEB=!!globalThis.window;var ENVIRONMENT_IS_WORKER=!!globalThis.WorkerGlobalScope;var ENVIRONMENT_IS_NODE=globalThis.process?.versions?.node&&globalThis.process?.type!="renderer";if(ENVIRONMENT_IS_NODE){const{createRequire}=await import("node:module");var require=createRequire(import.meta.url)}var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var _scriptName=import.meta.url;var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require("node:fs");if(_scriptName.startsWith("file:")){scriptDirectory=require("node:path").dirname(require("node:url").fileURLToPath(_scriptName))+"/"}readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename);return ret};readAsync=async(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename,binary?undefined:"utf8");return ret};if(process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{readAsync=async url=>{var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var EXITSTATUS;var isFileURI=filename=>filename.startsWith("file://");class EmscriptenEH{}class EmscriptenSjLj extends EmscriptenEH{}var readyPromiseResolve,readyPromiseReject;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;HEAP8=new Int8Array(b);HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);HEAPU16=new Uint16Array(b);HEAP32=new Int32Array(b);HEAPU32=new Uint32Array(b);HEAPF32=new Float32Array(b);HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;if(!Module["noFSInit"]&&!FS.initialized)FS.init();TTY.init();wasmExports["R"]();FS.ignorePermissions=false}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(onPostRuns)}function abort(what){Module["onAbort"]?.(what);what=`Aborted(${what})`;err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject?.(e);throw e}var wasmBinaryFile;function findWasmBinary(){if(Module["locateFile"]){return locateFile("pdfengine.wasm")}return new URL("pdfengine.wasm",import.meta.url).href}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){if(!binary&&!ENVIRONMENT_IS_NODE){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation")}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){var imports={a:wasmImports};return imports}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;assignWasmExports(wasmExports);updateMemoryViews();return wasmExports}function receiveInstantiationResult(result){return receiveInstance(result["instance"])}var info=getWasmImports();if(Module["instantiateWasm"]){return new Promise((resolve,reject)=>{Module["instantiateWasm"](info,(inst,mod)=>{resolve(receiveInstance(inst,mod))})})}wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var HEAP16;var HEAP32;var HEAP64;var HEAP8;var HEAPF32;var HEAPF64;var HEAPU16;var HEAPU32;var HEAPU64;var HEAPU8;var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);function getValue(ptr,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":return HEAP8[ptr];case"i8":return HEAP8[ptr];case"i16":return HEAP16[ptr>>1];case"i32":return HEAP32[ptr>>2];case"i64":return HEAP64[ptr>>3];case"float":return HEAPF32[ptr>>2];case"double":return HEAPF64[ptr>>3];case"*":return HEAPU32[ptr>>2];default:abort(`invalid type for getValue: ${type}`)}}var noExitRuntime=true;function setValue(ptr,value,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":HEAP8[ptr]=value;break;case"i8":HEAP8[ptr]=value;break;case"i16":HEAP16[ptr>>1]=value;break;case"i32":HEAP32[ptr>>2]=value;break;case"i64":HEAP64[ptr>>3]=BigInt(value);break;case"float":HEAPF32[ptr>>2]=value;break;case"double":HEAPF64[ptr>>3]=value;break;case"*":HEAPU32[ptr>>2]=value;break;default:abort(`invalid type for setValue: ${type}`)}}var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var uncaughtExceptionCount=0;var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);uncaughtExceptionCount++;abort()};var syscallGetVarargI=()=>{var ret=HEAP32[+SYSCALLS.varargs>>2];SYSCALLS.varargs+=4;return ret};var syscallGetVarargP=syscallGetVarargI;var PATH={isAbs:path=>path.charAt(0)==="/",splitPath:filename=>{var splitPathRe=/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/;return splitPathRe.exec(filename).slice(1)},normalizeArray:(parts,allowAboveRoot)=>{var up=0;for(var i=parts.length-1;i>=0;i--){var last=parts[i];if(last==="."){parts.splice(i,1)}else if(last===".."){parts.splice(i,1);up++}else if(up){parts.splice(i,1);up--}}if(allowAboveRoot){for(;up;up--){parts.unshift("..")}}return parts},normalize:path=>{var isAbsolute=PATH.isAbs(path),trailingSlash=path.slice(-1)==="/";path=PATH.normalizeArray(path.split("/").filter(p=>!!p),!isAbsolute).join("/");if(!path&&!isAbsolute){path="."}if(path&&trailingSlash){path+="/"}return(isAbsolute?"/":"")+path},dirname:path=>{var result=PATH.splitPath(path),root=result[0],dir=result[1];if(!root&&!dir){return"."}if(dir){dir=dir.slice(0,-1)}return root+dir},basename:path=>path&&path.match(/([^\/]+|\/)\/*$/)[1],join:(...paths)=>PATH.normalize(paths.join("/")),join2:(l,r)=>PATH.normalize(l+"/"+r)};var initRandomFill=()=>{if(ENVIRONMENT_IS_NODE){var nodeCrypto=require("node:crypto");return view=>nodeCrypto.randomFillSync(view)}return view=>(crypto.getRandomValues(view),0)};var randomFill=view=>(randomFill=initRandomFill())(view);var PATH_FS={resolve:(...args)=>{var resolvedPath="",resolvedAbsolute=false;for(var i=args.length-1;i>=-1&&!resolvedAbsolute;i--){var path=i>=0?args[i]:FS.cwd();if(typeof path!="string"){throw new TypeError("Arguments to path.resolve must be strings")}else if(!path){return""}resolvedPath=path+"/"+resolvedPath;resolvedAbsolute=PATH.isAbs(path)}resolvedPath=PATH.normalizeArray(resolvedPath.split("/").filter(p=>!!p),!resolvedAbsolute).join("/");return(resolvedAbsolute?"/":"")+resolvedPath||"."},relative:(from,to)=>{from=PATH_FS.resolve(from).slice(1);to=PATH_FS.resolve(to).slice(1);function trim(arr){var start=0;for(;start=0;end--){if(arr[end]!=="")break}if(start>end)return[];return arr.slice(start,end-start+1)}var fromParts=trim(from.split("/"));var toParts=trim(to.split("/"));var length=Math.min(fromParts.length,toParts.length);var samePartsLength=length;for(var i=0;i{var maxIdx=idx+maxBytesToRead;if(ignoreNul)return maxIdx;while(heapOrArray[idx]&&!(idx>=maxIdx))++idx;return idx};var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead,ignoreNul)=>{var endPtr=findStringEnd(heapOrArray,idx,maxBytesToRead,ignoreNul);if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var FS_stdin_getChar_buffer=[];var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63;i++}}heap[outIdx]=0;return outIdx-startIdx};var intArrayFromString=(stringy,dontAddNull,length)=>{var len=length>0?length:lengthBytesUTF8(stringy)+1;var u8array=new Array(len);var numBytesWritten=stringToUTF8Array(stringy,u8array,0,u8array.length);if(dontAddNull)u8array.length=numBytesWritten;return u8array};var FS_stdin_getChar=()=>{if(!FS_stdin_getChar_buffer.length){var result=null;if(ENVIRONMENT_IS_NODE){var BUFSIZE=256;var buf=Buffer.alloc(BUFSIZE);var bytesRead=0;var fd=process.stdin.fd;try{bytesRead=fs.readSync(fd,buf,0,BUFSIZE)}catch(e){if(e.toString().includes("EOF"))bytesRead=0;else throw e}if(bytesRead>0){result=buf.slice(0,bytesRead).toString("utf-8")}}else if(globalThis.window?.prompt){result=window.prompt("Input: ");if(result!==null){result+="\n"}}else{}if(!result){return null}FS_stdin_getChar_buffer=intArrayFromString(result,true)}return FS_stdin_getChar_buffer.shift()};var TTY={ttys:[],init(){},shutdown(){},register(dev,ops){TTY.ttys[dev]={input:[],output:[],ops};FS.registerDevice(dev,TTY.stream_ops)},stream_ops:{open(stream){var tty=TTY.ttys[stream.node.rdev];if(!tty){throw new FS.ErrnoError(43)}stream.tty=tty;stream.seekable=false},close(stream){stream.tty.ops.fsync(stream.tty)},fsync(stream){stream.tty.ops.fsync(stream.tty)},read(stream,buffer,offset,length,pos){if(!stream.tty||!stream.tty.ops.get_char){throw new FS.ErrnoError(60)}var bytesRead=0;for(var i=0;i0){out(UTF8ArrayToString(tty.output));tty.output=[]}},ioctl_tcgets(tty){return{c_iflag:25856,c_oflag:5,c_cflag:191,c_lflag:35387,c_cc:[3,28,127,21,4,0,1,0,17,19,26,0,18,15,23,22,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}},ioctl_tcsets(tty,optional_actions,data){return 0},ioctl_tiocgwinsz(tty){return[24,80]}},default_tty1_ops:{put_char(tty,val){if(val===null||val===10){err(UTF8ArrayToString(tty.output));tty.output=[]}else{if(val!=0)tty.output.push(val)}},fsync(tty){if(tty.output?.length>0){err(UTF8ArrayToString(tty.output));tty.output=[]}}}};var zeroMemory=(ptr,size)=>HEAPU8.fill(0,ptr,ptr+size);var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var mmapAlloc=size=>{size=alignMemory(size,65536);var ptr=_emscripten_builtin_memalign(65536,size);if(ptr)zeroMemory(ptr,size);return ptr};var MEMFS={ops_table:null,mount(mount){return MEMFS.createNode(null,"/",16895,0)},createNode(parent,name,mode,dev){if(FS.isBlkdev(mode)||FS.isFIFO(mode)){throw new FS.ErrnoError(63)}MEMFS.ops_table||={dir:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,lookup:MEMFS.node_ops.lookup,mknod:MEMFS.node_ops.mknod,rename:MEMFS.node_ops.rename,unlink:MEMFS.node_ops.unlink,rmdir:MEMFS.node_ops.rmdir,readdir:MEMFS.node_ops.readdir,symlink:MEMFS.node_ops.symlink},stream:{llseek:MEMFS.stream_ops.llseek}},file:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:{llseek:MEMFS.stream_ops.llseek,read:MEMFS.stream_ops.read,write:MEMFS.stream_ops.write,mmap:MEMFS.stream_ops.mmap,msync:MEMFS.stream_ops.msync}},link:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,readlink:MEMFS.node_ops.readlink},stream:{}},chrdev:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:FS.chrdev_stream_ops}};var node=FS.createNode(parent,name,mode,dev);if(FS.isDir(node.mode)){node.node_ops=MEMFS.ops_table.dir.node;node.stream_ops=MEMFS.ops_table.dir.stream;node.contents={}}else if(FS.isFile(node.mode)){node.node_ops=MEMFS.ops_table.file.node;node.stream_ops=MEMFS.ops_table.file.stream;node.usedBytes=0;node.contents=MEMFS.emptyFileContents??=new Uint8Array(0)}else if(FS.isLink(node.mode)){node.node_ops=MEMFS.ops_table.link.node;node.stream_ops=MEMFS.ops_table.link.stream}else if(FS.isChrdev(node.mode)){node.node_ops=MEMFS.ops_table.chrdev.node;node.stream_ops=MEMFS.ops_table.chrdev.stream}node.atime=node.mtime=node.ctime=Date.now();if(parent){parent.contents[name]=node;parent.atime=parent.mtime=parent.ctime=node.atime}return node},getFileDataAsTypedArray(node){return node.contents.subarray(0,node.usedBytes)},expandFileStorage(node,newCapacity){var prevCapacity=node.contents.length;if(prevCapacity>=newCapacity)return;var CAPACITY_DOUBLING_MAX=1024*1024;newCapacity=Math.max(newCapacity,prevCapacity*(prevCapacity>>0);if(prevCapacity)newCapacity=Math.max(newCapacity,256);var oldContents=MEMFS.getFileDataAsTypedArray(node);node.contents=new Uint8Array(newCapacity);node.contents.set(oldContents)},resizeFileStorage(node,newSize){if(node.usedBytes==newSize)return;var oldContents=node.contents;node.contents=new Uint8Array(newSize);node.contents.set(oldContents.subarray(0,Math.min(newSize,node.usedBytes)));node.usedBytes=newSize},node_ops:{getattr(node){var attr={};attr.dev=FS.isChrdev(node.mode)?node.id:1;attr.ino=node.id;attr.mode=node.mode;attr.nlink=1;attr.uid=0;attr.gid=0;attr.rdev=node.rdev;if(FS.isDir(node.mode)){attr.size=4096}else if(FS.isFile(node.mode)){attr.size=node.usedBytes}else if(FS.isLink(node.mode)){attr.size=node.link.length}else{attr.size=0}attr.atime=new Date(node.atime);attr.mtime=new Date(node.mtime);attr.ctime=new Date(node.ctime);attr.blksize=4096;attr.blocks=Math.ceil(attr.size/attr.blksize);return attr},setattr(node,attr){for(const key of["mode","atime","mtime","ctime"]){if(attr[key]!=null){node[key]=attr[key]}}if(attr.size!==undefined){MEMFS.resizeFileStorage(node,attr.size)}},lookup(parent,name){if(!MEMFS.doesNotExistError){MEMFS.doesNotExistError=new FS.ErrnoError(44);MEMFS.doesNotExistError.stack=""}throw MEMFS.doesNotExistError},mknod(parent,name,mode,dev){return MEMFS.createNode(parent,name,mode,dev)},rename(old_node,new_dir,new_name){var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(new_node){if(FS.isDir(old_node.mode)){for(var i in new_node.contents){throw new FS.ErrnoError(55)}}FS.hashRemoveNode(new_node)}delete old_node.parent.contents[old_node.name];new_dir.contents[new_name]=old_node;old_node.name=new_name;new_dir.ctime=new_dir.mtime=old_node.parent.ctime=old_node.parent.mtime=Date.now()},unlink(parent,name){delete parent.contents[name];parent.ctime=parent.mtime=Date.now()},rmdir(parent,name){var node=FS.lookupNode(parent,name);for(var i in node.contents){throw new FS.ErrnoError(55)}delete parent.contents[name];parent.ctime=parent.mtime=Date.now()},readdir(node){return[".","..",...Object.keys(node.contents)]},symlink(parent,newname,oldpath){var node=MEMFS.createNode(parent,newname,511|40960,0);node.link=oldpath;return node},readlink(node){if(!FS.isLink(node.mode)){throw new FS.ErrnoError(28)}return node.link}},stream_ops:{read(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=stream.node.usedBytes)return 0;var size=Math.min(stream.node.usedBytes-position,length);buffer.set(contents.subarray(position,position+size),offset);return size},write(stream,buffer,offset,length,position,canOwn){if(buffer.buffer===HEAP8.buffer){canOwn=false}if(!length)return 0;var node=stream.node;node.mtime=node.ctime=Date.now();if(canOwn){node.contents=buffer.subarray(offset,offset+length);node.usedBytes=length}else if(node.usedBytes===0&&position===0){node.contents=buffer.slice(offset,offset+length);node.usedBytes=length}else{MEMFS.expandFileStorage(node,position+length);node.contents.set(buffer.subarray(offset,offset+length),position);node.usedBytes=Math.max(node.usedBytes,position+length)}return length},llseek(stream,offset,whence){var position=offset;if(whence===1){position+=stream.position}else if(whence===2){if(FS.isFile(stream.node.mode)){position+=stream.node.usedBytes}}if(position<0){throw new FS.ErrnoError(28)}return position},mmap(stream,length,position,prot,flags){if(!FS.isFile(stream.node.mode)){throw new FS.ErrnoError(43)}var ptr;var allocated;var contents=stream.node.contents;if(!(flags&2)&&contents.buffer===HEAP8.buffer){allocated=false;ptr=contents.byteOffset}else{allocated=true;ptr=mmapAlloc(length);if(!ptr){throw new FS.ErrnoError(48)}if(contents){if(position>0||position+length{if(typeof str!="string")return str;var flagModes={r:0,"r+":2,w:512|64|1,"w+":512|64|2,a:1024|64|1,"a+":1024|64|2};var flags=flagModes[str];if(typeof flags=="undefined"){throw new Error(`Unknown file open mode: ${str}`)}return flags};var FS_fileDataToTypedArray=data=>{if(typeof data=="string"){data=intArrayFromString(data,true)}if(!data.subarray){data=new Uint8Array(data)}return data};var FS_getMode=(canRead,canWrite)=>{var mode=0;if(canRead)mode|=292|73;if(canWrite)mode|=146;return mode};var asyncLoad=async url=>{var arrayBuffer=await readAsync(url);return new Uint8Array(arrayBuffer)};var FS_createDataFile=(...args)=>FS.createDataFile(...args);var getUniqueRunDependency=id=>id;var runDependencies=0;var dependenciesFulfilled=null;var removeRunDependency=id=>{runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}};var addRunDependency=id=>{runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)};var preloadPlugins=[];var FS_handledByPreloadPlugin=async(byteArray,fullname)=>{if(typeof Browser!="undefined")Browser.init();for(var plugin of preloadPlugins){if(plugin["canHandle"](fullname)){return plugin["handle"](byteArray,fullname)}}return byteArray};var FS_preloadFile=async(parent,name,url,canRead,canWrite,dontCreateFile,canOwn,preFinish)=>{var fullname=name?PATH_FS.resolve(PATH.join2(parent,name)):parent;var dep=getUniqueRunDependency(`cp ${fullname}`);addRunDependency(dep);try{var byteArray=url;if(typeof url=="string"){byteArray=await asyncLoad(url)}byteArray=await FS_handledByPreloadPlugin(byteArray,fullname);preFinish?.();if(!dontCreateFile){FS_createDataFile(parent,name,byteArray,canRead,canWrite,canOwn)}}finally{removeRunDependency(dep)}};var FS_createPreloadedFile=(parent,name,url,canRead,canWrite,onload,onerror,dontCreateFile,canOwn,preFinish)=>{FS_preloadFile(parent,name,url,canRead,canWrite,dontCreateFile,canOwn,preFinish).then(onload).catch(onerror)};var FS={root:null,mounts:[],devices:{},streams:[],nextInode:1,nameTable:null,currentPath:"/",initialized:false,ignorePermissions:true,filesystems:null,syncFSRequests:0,ErrnoError:class{name="ErrnoError";constructor(errno){this.errno=errno}},FSStream:class{shared={};get object(){return this.node}set object(val){this.node=val}get isRead(){return(this.flags&2097155)!==1}get isWrite(){return(this.flags&2097155)!==0}get isAppend(){return this.flags&1024}get flags(){return this.shared.flags}set flags(val){this.shared.flags=val}get position(){return this.shared.position}set position(val){this.shared.position=val}},FSNode:class{node_ops={};stream_ops={};readMode=292|73;writeMode=146;mounted=null;constructor(parent,name,mode,rdev){if(!parent){parent=this}this.parent=parent;this.mount=parent.mount;this.id=FS.nextInode++;this.name=name;this.mode=mode;this.rdev=rdev;this.atime=this.mtime=this.ctime=Date.now()}get read(){return(this.mode&this.readMode)===this.readMode}set read(val){val?this.mode|=this.readMode:this.mode&=~this.readMode}get write(){return(this.mode&this.writeMode)===this.writeMode}set write(val){val?this.mode|=this.writeMode:this.mode&=~this.writeMode}get isFolder(){return FS.isDir(this.mode)}get isDevice(){return FS.isChrdev(this.mode)}},lookupPath(path,opts={}){if(!path){throw new FS.ErrnoError(44)}opts.follow_mount??=true;if(!PATH.isAbs(path)){path=FS.cwd()+"/"+path}linkloop:for(var nlinks=0;nlinks<40;nlinks++){var parts=path.split("/").filter(p=>!!p);var current=FS.root;var current_path="/";for(var i=0;i>>0)%FS.nameTable.length},hashAddNode(node){var hash=FS.hashName(node.parent.id,node.name);node.name_next=FS.nameTable[hash];FS.nameTable[hash]=node},hashRemoveNode(node){var hash=FS.hashName(node.parent.id,node.name);if(FS.nameTable[hash]===node){FS.nameTable[hash]=node.name_next}else{var current=FS.nameTable[hash];while(current){if(current.name_next===node){current.name_next=node.name_next;break}current=current.name_next}}},lookupNode(parent,name){var errCode=FS.mayLookup(parent);if(errCode){throw new FS.ErrnoError(errCode)}var hash=FS.hashName(parent.id,name);for(var node=FS.nameTable[hash];node;node=node.name_next){var nodeName=node.name;if(node.parent.id===parent.id&&nodeName===name){return node}}return FS.lookup(parent,name)},createNode(parent,name,mode,rdev){var node=new FS.FSNode(parent,name,mode,rdev);FS.hashAddNode(node);return node},destroyNode(node){FS.hashRemoveNode(node)},isRoot(node){return node===node.parent},isMountpoint(node){return!!node.mounted},isFile(mode){return(mode&61440)===32768},isDir(mode){return(mode&61440)===16384},isLink(mode){return(mode&61440)===40960},isChrdev(mode){return(mode&61440)===8192},isBlkdev(mode){return(mode&61440)===24576},isFIFO(mode){return(mode&61440)===4096},isSocket(mode){return(mode&49152)===49152},flagsToPermissionString(flag){var perms=["r","w","rw"][flag&3];if(flag&512){perms+="w"}return perms},nodePermissions(node,perms){if(FS.ignorePermissions){return 0}if(perms.includes("r")&&!(node.mode&292)){return 2}if(perms.includes("w")&&!(node.mode&146)){return 2}if(perms.includes("x")&&!(node.mode&73)){return 2}return 0},mayLookup(dir){if(!FS.isDir(dir.mode))return 54;var errCode=FS.nodePermissions(dir,"x");if(errCode)return errCode;if(!dir.node_ops.lookup)return 2;return 0},mayCreate(dir,name){if(!FS.isDir(dir.mode)){return 54}try{var node=FS.lookupNode(dir,name);return 20}catch(e){}return FS.nodePermissions(dir,"wx")},mayDelete(dir,name,isdir){var node;try{node=FS.lookupNode(dir,name)}catch(e){return e.errno}var errCode=FS.nodePermissions(dir,"wx");if(errCode){return errCode}if(isdir){if(!FS.isDir(node.mode)){return 54}if(FS.isRoot(node)||FS.getPath(node)===FS.cwd()){return 10}}else if(FS.isDir(node.mode)){return 31}return 0},mayOpen(node,flags){if(!node){return 44}if(FS.isLink(node.mode)){return 32}var mode=FS.flagsToPermissionString(flags);if(FS.isDir(node.mode)){if(mode!=="r"||flags&(512|64)){return 31}}return FS.nodePermissions(node,mode)},checkOpExists(op,err){if(!op){throw new FS.ErrnoError(err)}return op},MAX_OPEN_FDS:4096,nextfd(){for(var fd=0;fd<=FS.MAX_OPEN_FDS;fd++){if(!FS.streams[fd]){return fd}}throw new FS.ErrnoError(33)},getStreamChecked(fd){var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}return stream},getStream:fd=>FS.streams[fd],createStream(stream,fd=-1){stream=Object.assign(new FS.FSStream,stream);if(fd==-1){fd=FS.nextfd()}stream.fd=fd;FS.streams[fd]=stream;return stream},closeStream(fd){FS.streams[fd]=null},dupStream(origStream,fd=-1){var stream=FS.createStream(origStream,fd);stream.stream_ops?.dup?.(stream);return stream},doSetAttr(stream,node,attr){var setattr=stream?.stream_ops.setattr;var arg=setattr?stream:node;setattr??=node.node_ops.setattr;FS.checkOpExists(setattr,63);try{setattr(arg,attr)}catch(e){if(e instanceof RangeError){throw new FS.ErrnoError(22)}throw e}},chrdev_stream_ops:{open(stream){var device=FS.getDevice(stream.node.rdev);stream.stream_ops=device.stream_ops;stream.stream_ops.open?.(stream)},llseek(){throw new FS.ErrnoError(70)}},major:dev=>dev>>8,minor:dev=>dev&255,makedev:(ma,mi)=>ma<<8|mi,registerDevice(dev,ops){FS.devices[dev]={stream_ops:ops}},getDevice:dev=>FS.devices[dev],getMounts(mount){var mounts=[];var check=[mount];while(check.length){var m=check.pop();mounts.push(m);check.push(...m.mounts)}return mounts},syncfs(populate,callback){if(typeof populate=="function"){callback=populate;populate=false}FS.syncFSRequests++;if(FS.syncFSRequests>1){err(`warning: ${FS.syncFSRequests} FS.syncfs operations in flight at once, probably just doing extra work`)}var mounts=FS.getMounts(FS.root.mount);var completed=0;function doCallback(errCode){FS.syncFSRequests--;return callback(errCode)}function done(errCode){if(errCode){if(!done.errored){done.errored=true;return doCallback(errCode)}return}if(++completed>=mounts.length){doCallback(null)}}for(var mount of mounts){if(mount.type.syncfs){mount.type.syncfs(mount,populate,done)}else{done(null)}}},mount(type,opts,mountpoint){var root=mountpoint==="/";var pseudo=!mountpoint;var node;if(root&&FS.root){throw new FS.ErrnoError(10)}else if(!root&&!pseudo){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});mountpoint=lookup.path;node=lookup.node;if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}if(!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}}var mount={type,opts,mountpoint,mounts:[]};var mountRoot=type.mount(mount);mountRoot.mount=mount;mount.root=mountRoot;if(root){FS.root=mountRoot}else if(node){node.mounted=mount;if(node.mount){node.mount.mounts.push(mount)}}return mountRoot},unmount(mountpoint){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});if(!FS.isMountpoint(lookup.node)){throw new FS.ErrnoError(28)}var node=lookup.node;var mount=node.mounted;var mounts=FS.getMounts(mount);for(var[hash,current]of Object.entries(FS.nameTable)){while(current){var next=current.name_next;if(mounts.includes(current.mount)){FS.destroyNode(current)}current=next}}node.mounted=null;var idx=node.mount.mounts.indexOf(mount);node.mount.mounts.splice(idx,1)},lookup(parent,name){return parent.node_ops.lookup(parent,name)},mknod(path,mode,dev){var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);if(!name){throw new FS.ErrnoError(28)}if(name==="."||name===".."){throw new FS.ErrnoError(20)}var errCode=FS.mayCreate(parent,name);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.mknod){throw new FS.ErrnoError(63)}return parent.node_ops.mknod(parent,name,mode,dev)},statfs(path){return FS.statfsNode(FS.lookupPath(path,{follow:true}).node)},statfsStream(stream){return FS.statfsNode(stream.node)},statfsNode(node){var rtn={bsize:4096,frsize:4096,blocks:1e6,bfree:5e5,bavail:5e5,files:FS.nextInode,ffree:FS.nextInode-1,fsid:42,flags:2,namelen:255};if(node.node_ops.statfs){Object.assign(rtn,node.node_ops.statfs(node.mount.opts.root))}return rtn},create(path,mode=438){mode&=4095;mode|=32768;return FS.mknod(path,mode,0)},mkdir(path,mode=511){mode&=511|512;mode|=16384;return FS.mknod(path,mode,0)},mkdirTree(path,mode){var dirs=path.split("/");var d="";for(var dir of dirs){if(!dir)continue;if(d||PATH.isAbs(path))d+="/";d+=dir;try{FS.mkdir(d,mode)}catch(e){if(e.errno!=20)throw e}}},mkdev(path,mode,dev){if(typeof dev=="undefined"){dev=mode;mode=438}mode|=8192;return FS.mknod(path,mode,dev)},symlink(oldpath,newpath){if(!PATH_FS.resolve(oldpath)){throw new FS.ErrnoError(44)}var lookup=FS.lookupPath(newpath,{parent:true});var parent=lookup.node;if(!parent){throw new FS.ErrnoError(44)}var newname=PATH.basename(newpath);var errCode=FS.mayCreate(parent,newname);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.symlink){throw new FS.ErrnoError(63)}return parent.node_ops.symlink(parent,newname,oldpath)},rename(old_path,new_path){var old_dirname=PATH.dirname(old_path);var new_dirname=PATH.dirname(new_path);var old_name=PATH.basename(old_path);var new_name=PATH.basename(new_path);var lookup,old_dir,new_dir;lookup=FS.lookupPath(old_path,{parent:true});old_dir=lookup.node;lookup=FS.lookupPath(new_path,{parent:true});new_dir=lookup.node;if(!old_dir||!new_dir)throw new FS.ErrnoError(44);if(old_dir.mount!==new_dir.mount){throw new FS.ErrnoError(75)}var old_node=FS.lookupNode(old_dir,old_name);var relative=PATH_FS.relative(old_path,new_dirname);if(relative.charAt(0)!=="."){throw new FS.ErrnoError(28)}relative=PATH_FS.relative(new_path,old_dirname);if(relative.charAt(0)!=="."){throw new FS.ErrnoError(55)}var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(old_node===new_node){return}var isdir=FS.isDir(old_node.mode);var errCode=FS.mayDelete(old_dir,old_name,isdir);if(errCode){throw new FS.ErrnoError(errCode)}errCode=new_node?FS.mayDelete(new_dir,new_name,isdir):FS.mayCreate(new_dir,new_name);if(errCode){throw new FS.ErrnoError(errCode)}if(!old_dir.node_ops.rename){throw new FS.ErrnoError(63)}if(FS.isMountpoint(old_node)||new_node&&FS.isMountpoint(new_node)){throw new FS.ErrnoError(10)}if(new_dir!==old_dir){errCode=FS.nodePermissions(old_dir,"w");if(errCode){throw new FS.ErrnoError(errCode)}}FS.hashRemoveNode(old_node);try{old_dir.node_ops.rename(old_node,new_dir,new_name);old_node.parent=new_dir}catch(e){throw e}finally{FS.hashAddNode(old_node)}},rmdir(path){var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);var node=FS.lookupNode(parent,name);var errCode=FS.mayDelete(parent,name,true);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.rmdir){throw new FS.ErrnoError(63)}if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}parent.node_ops.rmdir(parent,name);FS.destroyNode(node)},readdir(path){var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;var readdir=FS.checkOpExists(node.node_ops.readdir,54);return readdir(node)},unlink(path){var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;if(!parent){throw new FS.ErrnoError(44)}var name=PATH.basename(path);var node=FS.lookupNode(parent,name);var errCode=FS.mayDelete(parent,name,false);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.unlink){throw new FS.ErrnoError(63)}if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}parent.node_ops.unlink(parent,name);FS.destroyNode(node)},readlink(path){var lookup=FS.lookupPath(path);var link=lookup.node;if(!link){throw new FS.ErrnoError(44)}if(!link.node_ops.readlink){throw new FS.ErrnoError(28)}return link.node_ops.readlink(link)},stat(path,dontFollow){var lookup=FS.lookupPath(path,{follow:!dontFollow});var node=lookup.node;var getattr=FS.checkOpExists(node.node_ops.getattr,63);return getattr(node)},fstat(fd){var stream=FS.getStreamChecked(fd);var node=stream.node;var getattr=stream.stream_ops.getattr;var arg=getattr?stream:node;getattr??=node.node_ops.getattr;FS.checkOpExists(getattr,63);return getattr(arg)},lstat(path){return FS.stat(path,true)},doChmod(stream,node,mode,dontFollow){FS.doSetAttr(stream,node,{mode:mode&4095|node.mode&~4095,ctime:Date.now(),dontFollow})},chmod(path,mode,dontFollow){var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:!dontFollow});node=lookup.node}else{node=path}FS.doChmod(null,node,mode,dontFollow)},lchmod(path,mode){FS.chmod(path,mode,true)},fchmod(fd,mode){var stream=FS.getStreamChecked(fd);FS.doChmod(stream,stream.node,mode,false)},doChown(stream,node,dontFollow){FS.doSetAttr(stream,node,{timestamp:Date.now(),dontFollow})},chown(path,uid,gid,dontFollow){var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:!dontFollow});node=lookup.node}else{node=path}FS.doChown(null,node,dontFollow)},lchown(path,uid,gid){FS.chown(path,uid,gid,true)},fchown(fd,uid,gid){var stream=FS.getStreamChecked(fd);FS.doChown(stream,stream.node,false)},doTruncate(stream,node,len){if(FS.isDir(node.mode)){throw new FS.ErrnoError(31)}if(!FS.isFile(node.mode)){throw new FS.ErrnoError(28)}var errCode=FS.nodePermissions(node,"w");if(errCode){throw new FS.ErrnoError(errCode)}FS.doSetAttr(stream,node,{size:len,timestamp:Date.now()})},truncate(path,len){if(len<0){throw new FS.ErrnoError(28)}var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:true});node=lookup.node}else{node=path}FS.doTruncate(null,node,len)},ftruncate(fd,len){var stream=FS.getStreamChecked(fd);if(len<0||(stream.flags&2097155)===0){throw new FS.ErrnoError(28)}FS.doTruncate(stream,stream.node,len)},utime(path,atime,mtime){var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;var setattr=FS.checkOpExists(node.node_ops.setattr,63);setattr(node,{atime,mtime})},open(path,flags,mode=438){if(path===""){throw new FS.ErrnoError(44)}flags=FS_modeStringToFlags(flags);if(flags&64){mode=mode&4095|32768}else{mode=0}var node;var isDirPath;if(typeof path=="object"){node=path}else{isDirPath=path.endsWith("/");var lookup=FS.lookupPath(path,{follow:!(flags&131072),noent_okay:true});node=lookup.node;path=lookup.path}var created=false;if(flags&64){if(node){if(flags&128){throw new FS.ErrnoError(20)}}else if(isDirPath){throw new FS.ErrnoError(31)}else{node=FS.mknod(path,mode|511,0);created=true}}if(!node){throw new FS.ErrnoError(44)}if(FS.isChrdev(node.mode)){flags&=~512}if(flags&65536&&!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}if(!created){var errCode=FS.mayOpen(node,flags);if(errCode){throw new FS.ErrnoError(errCode)}}if(flags&512&&!created){FS.truncate(node,0)}flags&=~(128|512|131072);var stream=FS.createStream({node,path:FS.getPath(node),flags,seekable:true,position:0,stream_ops:node.stream_ops,ungotten:[],error:false});if(stream.stream_ops.open){stream.stream_ops.open(stream)}if(created){FS.chmod(node,mode&511)}return stream},close(stream){if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(stream.getdents)stream.getdents=null;try{if(stream.stream_ops.close){stream.stream_ops.close(stream)}}catch(e){throw e}finally{FS.closeStream(stream.fd)}stream.fd=null},isClosed(stream){return stream.fd===null},llseek(stream,offset,whence){if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(!stream.seekable||!stream.stream_ops.llseek){throw new FS.ErrnoError(70)}if(whence!=0&&whence!=1&&whence!=2){throw new FS.ErrnoError(28)}stream.position=stream.stream_ops.llseek(stream,offset,whence);stream.ungotten=[];return stream.position},read(stream,buffer,offset,length,position){if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.read){throw new FS.ErrnoError(28)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesRead=stream.stream_ops.read(stream,buffer,offset,length,position);if(!seeking)stream.position+=bytesRead;return bytesRead},write(stream,buffer,offset,length,position,canOwn){if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.write){throw new FS.ErrnoError(28)}if(stream.seekable&&stream.flags&1024){FS.llseek(stream,0,2)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesWritten=stream.stream_ops.write(stream,buffer,offset,length,position,canOwn);if(!seeking)stream.position+=bytesWritten;return bytesWritten},mmap(stream,length,position,prot,flags){if((prot&2)!==0&&(flags&2)===0&&(stream.flags&2097155)!==2){throw new FS.ErrnoError(2)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(2)}if(!stream.stream_ops.mmap){throw new FS.ErrnoError(43)}if(!length){throw new FS.ErrnoError(28)}return stream.stream_ops.mmap(stream,length,position,prot,flags)},msync(stream,buffer,offset,length,mmapFlags){if(!stream.stream_ops.msync){return 0}return stream.stream_ops.msync(stream,buffer,offset,length,mmapFlags)},ioctl(stream,cmd,arg){if(!stream.stream_ops.ioctl){throw new FS.ErrnoError(59)}return stream.stream_ops.ioctl(stream,cmd,arg)},readFile(path,opts={}){opts.flags=opts.flags||0;opts.encoding=opts.encoding||"binary";if(opts.encoding!=="utf8"&&opts.encoding!=="binary"){abort(`Invalid encoding type "${opts.encoding}"`)}var stream=FS.open(path,opts.flags);var stat=FS.stat(path);var length=stat.size;var buf=new Uint8Array(length);FS.read(stream,buf,0,length,0);if(opts.encoding==="utf8"){buf=UTF8ArrayToString(buf)}FS.close(stream);return buf},writeFile(path,data,opts={}){opts.flags=opts.flags||577;var stream=FS.open(path,opts.flags,opts.mode);data=FS_fileDataToTypedArray(data);FS.write(stream,data,0,data.byteLength,undefined,opts.canOwn);FS.close(stream)},cwd:()=>FS.currentPath,chdir(path){var lookup=FS.lookupPath(path,{follow:true});if(lookup.node===null){throw new FS.ErrnoError(44)}if(!FS.isDir(lookup.node.mode)){throw new FS.ErrnoError(54)}var errCode=FS.nodePermissions(lookup.node,"x");if(errCode){throw new FS.ErrnoError(errCode)}FS.currentPath=lookup.path},createDefaultDirectories(){FS.mkdir("/tmp");FS.mkdir("/home");FS.mkdir("/home/web_user")},createDefaultDevices(){FS.mkdir("/dev");FS.registerDevice(FS.makedev(1,3),{read:()=>0,write:(stream,buffer,offset,length,pos)=>length,llseek:()=>0});FS.mkdev("/dev/null",FS.makedev(1,3));TTY.register(FS.makedev(5,0),TTY.default_tty_ops);TTY.register(FS.makedev(6,0),TTY.default_tty1_ops);FS.mkdev("/dev/tty",FS.makedev(5,0));FS.mkdev("/dev/tty1",FS.makedev(6,0));var randomBuffer=new Uint8Array(1024),randomLeft=0;var randomByte=()=>{if(randomLeft===0){randomFill(randomBuffer);randomLeft=randomBuffer.byteLength}return randomBuffer[--randomLeft]};FS.createDevice("/dev","random",randomByte);FS.createDevice("/dev","urandom",randomByte);FS.mkdir("/dev/shm");FS.mkdir("/dev/shm/tmp")},createSpecialDirectories(){FS.mkdir("/proc");var proc_self=FS.mkdir("/proc/self");FS.mkdir("/proc/self/fd");FS.mount({mount(){var node=FS.createNode(proc_self,"fd",16895,73);node.stream_ops={llseek:MEMFS.stream_ops.llseek};node.node_ops={lookup(parent,name){var fd=+name;var stream=FS.getStreamChecked(fd);var ret={parent:null,mount:{mountpoint:"fake"},node_ops:{readlink:()=>stream.path},id:fd+1};ret.parent=ret;return ret},readdir(){return Array.from(FS.streams.entries()).filter(([k,v])=>v).map(([k,v])=>k.toString())}};return node}},{},"/proc/self/fd")},createStandardStreams(input,output,error){if(input){FS.createDevice("/dev","stdin",input)}else{FS.symlink("/dev/tty","/dev/stdin")}if(output){FS.createDevice("/dev","stdout",null,output)}else{FS.symlink("/dev/tty","/dev/stdout")}if(error){FS.createDevice("/dev","stderr",null,error)}else{FS.symlink("/dev/tty1","/dev/stderr")}var stdin=FS.open("/dev/stdin",0);var stdout=FS.open("/dev/stdout",1);var stderr=FS.open("/dev/stderr",1)},staticInit(){FS.nameTable=new Array(4096);FS.mount(MEMFS,{},"/");FS.createDefaultDirectories();FS.createDefaultDevices();FS.createSpecialDirectories();FS.filesystems={MEMFS}},init(input,output,error){FS.initialized=true;input??=Module["stdin"];output??=Module["stdout"];error??=Module["stderr"];FS.createStandardStreams(input,output,error)},quit(){FS.initialized=false;for(var stream of FS.streams){if(stream){FS.close(stream)}}},findObject(path,dontResolveLastLink){var ret=FS.analyzePath(path,dontResolveLastLink);if(!ret.exists){return null}return ret.object},analyzePath(path,dontResolveLastLink){try{var lookup=FS.lookupPath(path,{follow:!dontResolveLastLink});path=lookup.path}catch(e){}var ret={isRoot:false,exists:false,error:0,name:null,path:null,object:null,parentExists:false,parentPath:null,parentObject:null};try{var lookup=FS.lookupPath(path,{parent:true});ret.parentExists=true;ret.parentPath=lookup.path;ret.parentObject=lookup.node;ret.name=PATH.basename(path);lookup=FS.lookupPath(path,{follow:!dontResolveLastLink});ret.exists=true;ret.path=lookup.path;ret.object=lookup.node;ret.name=lookup.node.name;ret.isRoot=lookup.path==="/"}catch(e){ret.error=e.errno}return ret},createPath(parent,path,canRead,canWrite){parent=typeof parent=="string"?parent:FS.getPath(parent);var parts=path.split("/").reverse();while(parts.length){var part=parts.pop();if(!part)continue;var current=PATH.join2(parent,part);try{FS.mkdir(current)}catch(e){if(e.errno!=20)throw e}parent=current}return current},createFile(parent,name,properties,canRead,canWrite){var path=PATH.join2(typeof parent=="string"?parent:FS.getPath(parent),name);var mode=FS_getMode(canRead,canWrite);return FS.create(path,mode)},createDataFile(parent,name,data,canRead,canWrite,canOwn){var path=name;if(parent){parent=typeof parent=="string"?parent:FS.getPath(parent);path=name?PATH.join2(parent,name):parent}var mode=FS_getMode(canRead,canWrite);var node=FS.create(path,mode);if(data){data=FS_fileDataToTypedArray(data);FS.chmod(node,mode|146);var stream=FS.open(node,577);FS.write(stream,data,0,data.length,0,canOwn);FS.close(stream);FS.chmod(node,mode)}},createDevice(parent,name,input,output){var path=PATH.join2(typeof parent=="string"?parent:FS.getPath(parent),name);var mode=FS_getMode(!!input,!!output);FS.createDevice.major??=64;var dev=FS.makedev(FS.createDevice.major++,0);FS.registerDevice(dev,{open(stream){stream.seekable=false},close(stream){if(output?.buffer?.length){output(10)}},read(stream,buffer,offset,length,pos){var bytesRead=0;for(var i=0;ithis.length-1||idx<0){return undefined}var chunkOffset=idx%this.chunkSize;var chunkNum=idx/this.chunkSize|0;return this.getter(chunkNum)[chunkOffset]}setDataGetter(getter){this.getter=getter}cacheLength(){var xhr=new XMLHttpRequest;xhr.open("HEAD",url,false);xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))abort("Couldn't load "+url+". Status: "+xhr.status);var datalength=Number(xhr.getResponseHeader("Content-length"));var header;var hasByteServing=(header=xhr.getResponseHeader("Accept-Ranges"))&&header==="bytes";var usesGzip=(header=xhr.getResponseHeader("Content-Encoding"))&&header==="gzip";var chunkSize=1024*1024;if(!hasByteServing)chunkSize=datalength;var doXHR=(from,to)=>{if(from>to)abort("invalid range ("+from+", "+to+") or no bytes requested!");if(to>datalength-1)abort("only "+datalength+" bytes available! programmer error!");var xhr=new XMLHttpRequest;xhr.open("GET",url,false);if(datalength!==chunkSize)xhr.setRequestHeader("Range","bytes="+from+"-"+to);xhr.responseType="arraybuffer";if(xhr.overrideMimeType){xhr.overrideMimeType("text/plain; charset=x-user-defined")}xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))abort("Couldn't load "+url+". Status: "+xhr.status);if(xhr.response!==undefined){return new Uint8Array(xhr.response||[])}return intArrayFromString(xhr.responseText||"",true)};var lazyArray=this;lazyArray.setDataGetter(chunkNum=>{var start=chunkNum*chunkSize;var end=(chunkNum+1)*chunkSize-1;end=Math.min(end,datalength-1);if(typeof lazyArray.chunks[chunkNum]=="undefined"){lazyArray.chunks[chunkNum]=doXHR(start,end)}if(typeof lazyArray.chunks[chunkNum]=="undefined")abort("doXHR failed!");return lazyArray.chunks[chunkNum]});if(usesGzip||!datalength){chunkSize=datalength=1;datalength=this.getter(0).length;chunkSize=datalength;out("LazyFiles on gzip forces download of the whole file when length is accessed")}this._length=datalength;this._chunkSize=chunkSize;this.lengthKnown=true}get length(){if(!this.lengthKnown){this.cacheLength()}return this._length}get chunkSize(){if(!this.lengthKnown){this.cacheLength()}return this._chunkSize}}if(globalThis.XMLHttpRequest){if(!ENVIRONMENT_IS_WORKER)abort("Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc");var lazyArray=new LazyUint8Array;var properties={isDevice:false,contents:lazyArray}}else{var properties={isDevice:false,url}}var node=FS.createFile(parent,name,properties,canRead,canWrite);if(properties.contents){node.contents=properties.contents}else if(properties.url){node.contents=null;node.url=properties.url}Object.defineProperties(node,{usedBytes:{get:function(){return this.contents.length}}});var stream_ops={};for(const[key,fn]of Object.entries(node.stream_ops)){stream_ops[key]=(...args)=>{FS.forceLoadFile(node);return fn(...args)}}function writeChunks(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=contents.length)return 0;var size=Math.min(contents.length-position,length);if(contents.slice){for(var i=0;i{FS.forceLoadFile(node);return writeChunks(stream,buffer,offset,length,position)};stream_ops.mmap=(stream,length,position,prot,flags)=>{FS.forceLoadFile(node);var ptr=mmapAlloc(length);if(!ptr){throw new FS.ErrnoError(48)}writeChunks(stream,HEAP8,ptr,length,position);return{ptr,allocated:true}};node.stream_ops=stream_ops;return node}};var UTF8ToString=(ptr,maxBytesToRead,ignoreNul)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead,ignoreNul):"";var SYSCALLS={currentUmask:18,calculateAt(dirfd,path,allowEmpty){if(PATH.isAbs(path)){return path}var dir;if(dirfd===-100){dir=FS.cwd()}else{var dirstream=SYSCALLS.getStreamFromFD(dirfd);dir=dirstream.path}if(path.length==0){if(!allowEmpty){throw new FS.ErrnoError(44)}return dir}return dir+"/"+path},writeStat(buf,stat){HEAPU32[buf>>2]=stat.dev;HEAPU32[buf+4>>2]=stat.mode;HEAPU32[buf+8>>2]=stat.nlink;HEAPU32[buf+12>>2]=stat.uid;HEAPU32[buf+16>>2]=stat.gid;HEAPU32[buf+20>>2]=stat.rdev;HEAP64[buf+24>>3]=BigInt(stat.size);HEAP32[buf+32>>2]=4096;HEAP32[buf+36>>2]=stat.blocks;var atime=stat.atime.getTime();var mtime=stat.mtime.getTime();var ctime=stat.ctime.getTime();HEAP64[buf+40>>3]=BigInt(Math.floor(atime/1e3));HEAPU32[buf+48>>2]=atime%1e3*1e3*1e3;HEAP64[buf+56>>3]=BigInt(Math.floor(mtime/1e3));HEAPU32[buf+64>>2]=mtime%1e3*1e3*1e3;HEAP64[buf+72>>3]=BigInt(Math.floor(ctime/1e3));HEAPU32[buf+80>>2]=ctime%1e3*1e3*1e3;HEAP64[buf+88>>3]=BigInt(stat.ino);return 0},writeStatFs(buf,stats){HEAPU32[buf+4>>2]=stats.bsize;HEAPU32[buf+60>>2]=stats.bsize;HEAP64[buf+8>>3]=BigInt(stats.blocks);HEAP64[buf+16>>3]=BigInt(stats.bfree);HEAP64[buf+24>>3]=BigInt(stats.bavail);HEAP64[buf+32>>3]=BigInt(stats.files);HEAP64[buf+40>>3]=BigInt(stats.ffree);HEAPU32[buf+48>>2]=stats.fsid;HEAPU32[buf+64>>2]=stats.flags;HEAPU32[buf+56>>2]=stats.namelen},doMsync(addr,stream,len,flags,offset){if(!FS.isFile(stream.node.mode)){throw new FS.ErrnoError(43)}if(flags&2){return 0}var buffer=HEAPU8.slice(addr,addr+len);FS.msync(stream,buffer,offset,len,flags)},getStreamFromFD(fd){var stream=FS.getStreamChecked(fd);return stream},varargs:undefined,getStr(ptr){var ret=UTF8ToString(ptr);return ret}};function ___syscall_fcntl64(fd,cmd,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(cmd){case 0:{var arg=syscallGetVarargI();if(arg<0){return-28}while(FS.streams[arg]){arg++}var newStream;newStream=FS.dupStream(stream,arg);return newStream.fd}case 1:case 2:return 0;case 3:return stream.flags;case 4:{var arg=syscallGetVarargI();var mask=289792;stream.flags=stream.flags&~mask|arg&mask;return 0}case 12:{var arg=syscallGetVarargP();var offset=0;HEAP16[arg+offset>>1]=2;return 0}case 13:case 14:return 0}return-28}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_fstat64(fd,buf){try{return SYSCALLS.writeStat(buf,FS.fstat(fd))}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);function ___syscall_getdents64(fd,dirp,count){try{var stream=SYSCALLS.getStreamFromFD(fd);stream.getdents||=FS.readdir(stream.path);var struct_size=280;var pos=0;var off=FS.llseek(stream,0,1);var startIdx=Math.floor(off/struct_size);var endIdx=Math.min(stream.getdents.length,startIdx+Math.floor(count/struct_size));for(var idx=startIdx;idx>3]=BigInt(id);HEAP64[dirp+pos+8>>3]=BigInt((idx+1)*struct_size);HEAP16[dirp+pos+16>>1]=280;HEAP8[dirp+pos+18]=type;stringToUTF8(name,dirp+pos+19,256);pos+=struct_size}FS.llseek(stream,idx*struct_size,0);return pos}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_ioctl(fd,op,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(op){case 21509:{if(!stream.tty)return-59;return 0}case 21505:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tcgets){var termios=stream.tty.ops.ioctl_tcgets(stream);var argp=syscallGetVarargP();HEAP32[argp>>2]=termios.c_iflag||0;HEAP32[argp+4>>2]=termios.c_oflag||0;HEAP32[argp+8>>2]=termios.c_cflag||0;HEAP32[argp+12>>2]=termios.c_lflag||0;for(var i=0;i<32;i++){HEAP8[argp+i+17]=termios.c_cc[i]||0}return 0}return 0}case 21510:case 21511:case 21512:{if(!stream.tty)return-59;return 0}case 21506:case 21507:case 21508:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tcsets){var argp=syscallGetVarargP();var c_iflag=HEAP32[argp>>2];var c_oflag=HEAP32[argp+4>>2];var c_cflag=HEAP32[argp+8>>2];var c_lflag=HEAP32[argp+12>>2];var c_cc=[];for(var i=0;i<32;i++){c_cc.push(HEAP8[argp+i+17])}return stream.tty.ops.ioctl_tcsets(stream.tty,op,{c_iflag,c_oflag,c_cflag,c_lflag,c_cc})}return 0}case 21519:{if(!stream.tty)return-59;var argp=syscallGetVarargP();HEAP32[argp>>2]=0;return 0}case 21520:{if(!stream.tty)return-59;return-28}case 21537:case 21531:{var argp=syscallGetVarargP();return FS.ioctl(stream,op,argp)}case 21523:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tiocgwinsz){var winsize=stream.tty.ops.ioctl_tiocgwinsz(stream.tty);var argp=syscallGetVarargP();HEAP16[argp>>1]=winsize[0];HEAP16[argp+2>>1]=winsize[1]}return 0}case 21524:{if(!stream.tty)return-59;return 0}case 21515:{if(!stream.tty)return-59;return 0}default:return-28}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_lstat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.writeStat(buf,FS.lstat(path))}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_newfstatat(dirfd,path,buf,flags){try{path=SYSCALLS.getStr(path);var nofollow=flags&256;var allowEmpty=flags&4096;flags=flags&~6400;path=SYSCALLS.calculateAt(dirfd,path,allowEmpty);return SYSCALLS.writeStat(buf,nofollow?FS.lstat(path):FS.stat(path))}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_openat(dirfd,path,flags,varargs){SYSCALLS.varargs=varargs;try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);var mode=varargs?syscallGetVarargI():0;if(flags&64){mode&=~SYSCALLS.currentUmask}return FS.open(path,flags,mode).fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_rmdir(path){try{path=SYSCALLS.getStr(path);FS.rmdir(path);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_stat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.writeStat(buf,FS.stat(path))}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_unlinkat(dirfd,path,flags){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);if(!flags){FS.unlink(path)}else if(flags===512){FS.rmdir(path)}else{return-28}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var __abort_js=()=>abort("");var __emscripten_throw_longjmp=()=>{throw new EmscriptenSjLj};var INT53_MAX=9007199254740992;var INT53_MIN=-9007199254740992;var bigintToI53Checked=num=>numINT53_MAX?NaN:Number(num);function __gmtime_js(time,tmPtr){time=bigintToI53Checked(time);var date=new Date(time*1e3);HEAP32[tmPtr>>2]=date.getUTCSeconds();HEAP32[tmPtr+4>>2]=date.getUTCMinutes();HEAP32[tmPtr+8>>2]=date.getUTCHours();HEAP32[tmPtr+12>>2]=date.getUTCDate();HEAP32[tmPtr+16>>2]=date.getUTCMonth();HEAP32[tmPtr+20>>2]=date.getUTCFullYear()-1900;HEAP32[tmPtr+24>>2]=date.getUTCDay();var start=Date.UTC(date.getUTCFullYear(),0,1,0,0,0,0);var yday=(date.getTime()-start)/(1e3*60*60*24)|0;HEAP32[tmPtr+28>>2]=yday}var isLeapYear=year=>year%4===0&&(year%100!==0||year%400===0);var MONTH_DAYS_LEAP_CUMULATIVE=[0,31,60,91,121,152,182,213,244,274,305,335];var MONTH_DAYS_REGULAR_CUMULATIVE=[0,31,59,90,120,151,181,212,243,273,304,334];var ydayFromDate=date=>{var leap=isLeapYear(date.getFullYear());var monthDaysCumulative=leap?MONTH_DAYS_LEAP_CUMULATIVE:MONTH_DAYS_REGULAR_CUMULATIVE;var yday=monthDaysCumulative[date.getMonth()]+date.getDate()-1;return yday};function __localtime_js(time,tmPtr){time=bigintToI53Checked(time);var date=new Date(time*1e3);HEAP32[tmPtr>>2]=date.getSeconds();HEAP32[tmPtr+4>>2]=date.getMinutes();HEAP32[tmPtr+8>>2]=date.getHours();HEAP32[tmPtr+12>>2]=date.getDate();HEAP32[tmPtr+16>>2]=date.getMonth();HEAP32[tmPtr+20>>2]=date.getFullYear()-1900;HEAP32[tmPtr+24>>2]=date.getDay();var yday=ydayFromDate(date)|0;HEAP32[tmPtr+28>>2]=yday;HEAP32[tmPtr+36>>2]=-(date.getTimezoneOffset()*60);var start=new Date(date.getFullYear(),0,1);var summerOffset=new Date(date.getFullYear(),6,1).getTimezoneOffset();var winterOffset=start.getTimezoneOffset();var dst=(summerOffset!=winterOffset&&date.getTimezoneOffset()==Math.min(winterOffset,summerOffset))|0;HEAP32[tmPtr+32>>2]=dst}function __mmap_js(len,prot,flags,fd,offset,allocated,addr){offset=bigintToI53Checked(offset);try{var stream=SYSCALLS.getStreamFromFD(fd);var res=FS.mmap(stream,len,offset,prot,flags);var ptr=res.ptr;HEAP32[allocated>>2]=res.allocated;HEAPU32[addr>>2]=ptr;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function __munmap_js(addr,len,prot,flags,fd,offset){offset=bigintToI53Checked(offset);try{var stream=SYSCALLS.getStreamFromFD(fd);if(prot&2){SYSCALLS.doMsync(addr,stream,len,flags,offset)}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var __tzset_js=(timezone,daylight,std_name,dst_name)=>{var currentYear=(new Date).getFullYear();var winter=new Date(currentYear,0,1);var summer=new Date(currentYear,6,1);var winterOffset=winter.getTimezoneOffset();var summerOffset=summer.getTimezoneOffset();var stdTimezoneOffset=Math.max(winterOffset,summerOffset);HEAPU32[timezone>>2]=stdTimezoneOffset*60;HEAP32[daylight>>2]=Number(winterOffset!=summerOffset);var extractZone=timezoneOffset=>{var sign=timezoneOffset>=0?"-":"+";var absOffset=Math.abs(timezoneOffset);var hours=String(Math.floor(absOffset/60)).padStart(2,"0");var minutes=String(absOffset%60).padStart(2,"0");return`UTC${sign}${hours}${minutes}`};var winterName=extractZone(winterOffset);var summerName=extractZone(summerOffset);if(summerOffsetperformance.now();var _emscripten_date_now=()=>Date.now();var nowIsMonotonic=1;var checkWasiClock=clock_id=>clock_id>=0&&clock_id<=3;function _clock_time_get(clk_id,ignored_precision,ptime){ignored_precision=bigintToI53Checked(ignored_precision);if(!checkWasiClock(clk_id)){return 28}var now;if(clk_id===0){now=_emscripten_date_now()}else if(nowIsMonotonic){now=_emscripten_get_now()}else{return 52}var nsec=Math.round(now*1e3*1e3);HEAP64[ptime>>3]=BigInt(nsec);return 0}var getHeapMax=()=>2147483648;var _emscripten_get_heap_max=()=>getHeapMax();var growMemory=size=>{var oldHeapSize=wasmMemory.buffer.byteLength;var pages=(size-oldHeapSize+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var ENV={};var getExecutableName=()=>thisProgram||"./this.program";var getEnvStrings=()=>{if(!getEnvStrings.strings){var lang=(globalThis.navigator?.language??"C").replace("-","_")+".UTF-8";var env={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:lang,_:getExecutableName()};for(var x in ENV){if(ENV[x]===undefined)delete env[x];else env[x]=ENV[x]}var strings=[];for(var x in env){strings.push(`${x}=${env[x]}`)}getEnvStrings.strings=strings}return getEnvStrings.strings};var _environ_get=(__environ,environ_buf)=>{var bufSize=0;var envp=0;for(var string of getEnvStrings()){var ptr=environ_buf+bufSize;HEAPU32[__environ+envp>>2]=ptr;bufSize+=stringToUTF8(string,ptr,Infinity)+1;envp+=4}return 0};var _environ_sizes_get=(penviron_count,penviron_buf_size)=>{var strings=getEnvStrings();HEAPU32[penviron_count>>2]=strings.length;var bufSize=0;for(var string of strings){bufSize+=lengthBytesUTF8(string)+1}HEAPU32[penviron_buf_size>>2]=bufSize;return 0};var runtimeKeepaliveCounter=0;var keepRuntimeAlive=()=>noExitRuntime||runtimeKeepaliveCounter>0;var _proc_exit=code=>{EXITSTATUS=code;if(!keepRuntimeAlive()){Module["onExit"]?.(code);ABORT=true}quit_(code,new ExitStatus(code))};var exitJS=(status,implicit)=>{EXITSTATUS=status;_proc_exit(status)};var _exit=exitJS;function _fd_close(fd){try{var stream=SYSCALLS.getStreamFromFD(fd);FS.close(stream);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _fd_fdstat_get(fd,pbuf){try{var rightsBase=0;var rightsInheriting=0;var flags=0;{var stream=SYSCALLS.getStreamFromFD(fd);var type=stream.tty?2:FS.isDir(stream.mode)?3:FS.isLink(stream.mode)?7:4}HEAP8[pbuf]=type;HEAP16[pbuf+2>>1]=flags;HEAP64[pbuf+8>>3]=BigInt(rightsBase);HEAP64[pbuf+16>>3]=BigInt(rightsInheriting);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var doReadv=(stream,iov,iovcnt,offset)=>{var ret=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.read(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(curr>2]=num;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _fd_seek(fd,offset,whence,newOffset){offset=bigintToI53Checked(offset);try{if(isNaN(offset))return 22;var stream=SYSCALLS.getStreamFromFD(fd);FS.llseek(stream,offset,whence);HEAP64[newOffset>>3]=BigInt(stream.position);if(stream.getdents&&offset===0&&whence===0)stream.getdents=null;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var doWritev=(stream,iov,iovcnt,offset)=>{var ret=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.write(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(curr>2]=num;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var wasmTableMirror=[];var getWasmTableEntry=funcPtr=>{var func=wasmTableMirror[funcPtr];if(!func){wasmTableMirror[funcPtr]=func=wasmTable.get(funcPtr)}return func};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};FS.createPreloadedFile=FS_createPreloadedFile;FS.preloadFile=FS_preloadFile;FS.staticInit();{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["preloadPlugins"])preloadPlugins=Module["preloadPlugins"];if(Module["print"])out=Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].shift()()}}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;Module["setValue"]=setValue;Module["getValue"]=getValue;var _loadDocument,_pageCount,_renderPagePng,_previewRender,_previewRenderRegion,_lastRenderPtr,_lastRenderW,_lastRenderH,_lastLayoutJson,_freeDocument,_engineBuildInfo,_free,_malloc,_emscripten_builtin_memalign,_setThrew,__emscripten_stack_restore,__emscripten_stack_alloc,_emscripten_stack_get_current,memory,__indirect_function_table,wasmMemory,wasmTable;function assignWasmExports(wasmExports){_loadDocument=Module["_loadDocument"]=wasmExports["T"];_pageCount=Module["_pageCount"]=wasmExports["U"];_renderPagePng=Module["_renderPagePng"]=wasmExports["V"];_previewRender=Module["_previewRender"]=wasmExports["W"];_previewRenderRegion=Module["_previewRenderRegion"]=wasmExports["X"];_lastRenderPtr=Module["_lastRenderPtr"]=wasmExports["Y"];_lastRenderW=Module["_lastRenderW"]=wasmExports["Z"];_lastRenderH=Module["_lastRenderH"]=wasmExports["_"];_lastLayoutJson=Module["_lastLayoutJson"]=wasmExports["$"];_freeDocument=Module["_freeDocument"]=wasmExports["aa"];_engineBuildInfo=Module["_engineBuildInfo"]=wasmExports["ba"];_free=Module["_free"]=wasmExports["ca"];_malloc=Module["_malloc"]=wasmExports["da"];_emscripten_builtin_memalign=wasmExports["ea"];_setThrew=wasmExports["fa"];__emscripten_stack_restore=wasmExports["ga"];__emscripten_stack_alloc=wasmExports["ha"];_emscripten_stack_get_current=wasmExports["ia"];memory=wasmMemory=wasmExports["Q"];__indirect_function_table=wasmTable=wasmExports["S"]}var wasmImports={a:___cxa_throw,i:___syscall_fcntl64,J:___syscall_fstat64,x:___syscall_getdents64,N:___syscall_ioctl,G:___syscall_lstat64,H:___syscall_newfstatat,k:___syscall_openat,v:___syscall_rmdir,I:___syscall_stat64,w:___syscall_unlinkat,L:__abort_js,s:__emscripten_throw_longjmp,A:__gmtime_js,B:__localtime_js,y:__mmap_js,z:__munmap_js,M:__tzset_js,K:_clock_time_get,l:_emscripten_date_now,u:_emscripten_get_heap_max,t:_emscripten_resize_heap,E:_environ_get,F:_environ_sizes_get,O:_exit,g:_fd_close,D:_fd_fdstat_get,n:_fd_read,C:_fd_seek,m:_fd_write,d:invoke_ii,f:invoke_iii,h:invoke_iiii,j:invoke_iiiii,P:invoke_iiiiiii,o:invoke_iiiiiiiiii,p:invoke_v,c:invoke_vi,b:invoke_vii,e:invoke_viii,r:invoke_viiii,q:invoke_viiiiiiiii};function invoke_iiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_ii(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_iiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_vii(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_viiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_viiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_vi(index,a1){var sp=stackSave();try{getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_v(index){var sp=stackSave();try{getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_iii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_viii(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_iiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_iiiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function run(){if(runDependencies>0){dependenciesFulfilled=run;return}preRun();if(runDependencies>0){dependenciesFulfilled=run;return}function doRun(){Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve?.(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}var wasmExports;wasmExports=await (createWasm());run();if(runtimeInitialized){moduleRtn=Module}else{moduleRtn=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject})} +async function Module(moduleArg={}){var moduleRtn;var Module=moduleArg;var ENVIRONMENT_IS_WEB=!!globalThis.window;var ENVIRONMENT_IS_WORKER=!!globalThis.WorkerGlobalScope;var ENVIRONMENT_IS_NODE=globalThis.process?.versions?.node&&globalThis.process?.type!="renderer";if(ENVIRONMENT_IS_NODE){const{createRequire}=await import("node:module");var require=createRequire(import.meta.url)}var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var _scriptName=import.meta.url;var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require("node:fs");if(_scriptName.startsWith("file:")){scriptDirectory=require("node:path").dirname(require("node:url").fileURLToPath(_scriptName))+"/"}readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename);return ret};readAsync=async(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename,binary?undefined:"utf8");return ret};if(process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{readAsync=async url=>{var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var EXITSTATUS;var isFileURI=filename=>filename.startsWith("file://");class EmscriptenEH{}class EmscriptenSjLj extends EmscriptenEH{}var readyPromiseResolve,readyPromiseReject;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;HEAP8=new Int8Array(b);HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);HEAPU16=new Uint16Array(b);HEAP32=new Int32Array(b);HEAPU32=new Uint32Array(b);HEAPF32=new Float32Array(b);HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;if(!Module["noFSInit"]&&!FS.initialized)FS.init();TTY.init();wasmExports["R"]();FS.ignorePermissions=false}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(onPostRuns)}function abort(what){Module["onAbort"]?.(what);what=`Aborted(${what})`;err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject?.(e);throw e}var wasmBinaryFile;function findWasmBinary(){if(Module["locateFile"]){return locateFile("pdfengine.wasm")}return new URL("pdfengine.wasm",import.meta.url).href}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){if(!binary&&!ENVIRONMENT_IS_NODE){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation")}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){var imports={a:wasmImports};return imports}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;assignWasmExports(wasmExports);updateMemoryViews();return wasmExports}function receiveInstantiationResult(result){return receiveInstance(result["instance"])}var info=getWasmImports();if(Module["instantiateWasm"]){return new Promise((resolve,reject)=>{Module["instantiateWasm"](info,(inst,mod)=>{resolve(receiveInstance(inst,mod))})})}wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var HEAP16;var HEAP32;var HEAP64;var HEAP8;var HEAPF32;var HEAPF64;var HEAPU16;var HEAPU32;var HEAPU64;var HEAPU8;var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);function getValue(ptr,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":return HEAP8[ptr];case"i8":return HEAP8[ptr];case"i16":return HEAP16[ptr>>1];case"i32":return HEAP32[ptr>>2];case"i64":return HEAP64[ptr>>3];case"float":return HEAPF32[ptr>>2];case"double":return HEAPF64[ptr>>3];case"*":return HEAPU32[ptr>>2];default:abort(`invalid type for getValue: ${type}`)}}var noExitRuntime=true;function setValue(ptr,value,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":HEAP8[ptr]=value;break;case"i8":HEAP8[ptr]=value;break;case"i16":HEAP16[ptr>>1]=value;break;case"i32":HEAP32[ptr>>2]=value;break;case"i64":HEAP64[ptr>>3]=BigInt(value);break;case"float":HEAPF32[ptr>>2]=value;break;case"double":HEAPF64[ptr>>3]=value;break;case"*":HEAPU32[ptr>>2]=value;break;default:abort(`invalid type for setValue: ${type}`)}}var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var uncaughtExceptionCount=0;var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);uncaughtExceptionCount++;abort()};var syscallGetVarargI=()=>{var ret=HEAP32[+SYSCALLS.varargs>>2];SYSCALLS.varargs+=4;return ret};var syscallGetVarargP=syscallGetVarargI;var PATH={isAbs:path=>path.charAt(0)==="/",splitPath:filename=>{var splitPathRe=/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/;return splitPathRe.exec(filename).slice(1)},normalizeArray:(parts,allowAboveRoot)=>{var up=0;for(var i=parts.length-1;i>=0;i--){var last=parts[i];if(last==="."){parts.splice(i,1)}else if(last===".."){parts.splice(i,1);up++}else if(up){parts.splice(i,1);up--}}if(allowAboveRoot){for(;up;up--){parts.unshift("..")}}return parts},normalize:path=>{var isAbsolute=PATH.isAbs(path),trailingSlash=path.slice(-1)==="/";path=PATH.normalizeArray(path.split("/").filter(p=>!!p),!isAbsolute).join("/");if(!path&&!isAbsolute){path="."}if(path&&trailingSlash){path+="/"}return(isAbsolute?"/":"")+path},dirname:path=>{var result=PATH.splitPath(path),root=result[0],dir=result[1];if(!root&&!dir){return"."}if(dir){dir=dir.slice(0,-1)}return root+dir},basename:path=>path&&path.match(/([^\/]+|\/)\/*$/)[1],join:(...paths)=>PATH.normalize(paths.join("/")),join2:(l,r)=>PATH.normalize(l+"/"+r)};var initRandomFill=()=>{if(ENVIRONMENT_IS_NODE){var nodeCrypto=require("node:crypto");return view=>nodeCrypto.randomFillSync(view)}return view=>(crypto.getRandomValues(view),0)};var randomFill=view=>(randomFill=initRandomFill())(view);var PATH_FS={resolve:(...args)=>{var resolvedPath="",resolvedAbsolute=false;for(var i=args.length-1;i>=-1&&!resolvedAbsolute;i--){var path=i>=0?args[i]:FS.cwd();if(typeof path!="string"){throw new TypeError("Arguments to path.resolve must be strings")}else if(!path){return""}resolvedPath=path+"/"+resolvedPath;resolvedAbsolute=PATH.isAbs(path)}resolvedPath=PATH.normalizeArray(resolvedPath.split("/").filter(p=>!!p),!resolvedAbsolute).join("/");return(resolvedAbsolute?"/":"")+resolvedPath||"."},relative:(from,to)=>{from=PATH_FS.resolve(from).slice(1);to=PATH_FS.resolve(to).slice(1);function trim(arr){var start=0;for(;start=0;end--){if(arr[end]!=="")break}if(start>end)return[];return arr.slice(start,end-start+1)}var fromParts=trim(from.split("/"));var toParts=trim(to.split("/"));var length=Math.min(fromParts.length,toParts.length);var samePartsLength=length;for(var i=0;i{var maxIdx=idx+maxBytesToRead;if(ignoreNul)return maxIdx;while(heapOrArray[idx]&&!(idx>=maxIdx))++idx;return idx};var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead,ignoreNul)=>{var endPtr=findStringEnd(heapOrArray,idx,maxBytesToRead,ignoreNul);if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var FS_stdin_getChar_buffer=[];var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63;i++}}heap[outIdx]=0;return outIdx-startIdx};var intArrayFromString=(stringy,dontAddNull,length)=>{var len=length>0?length:lengthBytesUTF8(stringy)+1;var u8array=new Array(len);var numBytesWritten=stringToUTF8Array(stringy,u8array,0,u8array.length);if(dontAddNull)u8array.length=numBytesWritten;return u8array};var FS_stdin_getChar=()=>{if(!FS_stdin_getChar_buffer.length){var result=null;if(ENVIRONMENT_IS_NODE){var BUFSIZE=256;var buf=Buffer.alloc(BUFSIZE);var bytesRead=0;var fd=process.stdin.fd;try{bytesRead=fs.readSync(fd,buf,0,BUFSIZE)}catch(e){if(e.toString().includes("EOF"))bytesRead=0;else throw e}if(bytesRead>0){result=buf.slice(0,bytesRead).toString("utf-8")}}else if(globalThis.window?.prompt){result=window.prompt("Input: ");if(result!==null){result+="\n"}}else{}if(!result){return null}FS_stdin_getChar_buffer=intArrayFromString(result,true)}return FS_stdin_getChar_buffer.shift()};var TTY={ttys:[],init(){},shutdown(){},register(dev,ops){TTY.ttys[dev]={input:[],output:[],ops};FS.registerDevice(dev,TTY.stream_ops)},stream_ops:{open(stream){var tty=TTY.ttys[stream.node.rdev];if(!tty){throw new FS.ErrnoError(43)}stream.tty=tty;stream.seekable=false},close(stream){stream.tty.ops.fsync(stream.tty)},fsync(stream){stream.tty.ops.fsync(stream.tty)},read(stream,buffer,offset,length,pos){if(!stream.tty||!stream.tty.ops.get_char){throw new FS.ErrnoError(60)}var bytesRead=0;for(var i=0;i0){out(UTF8ArrayToString(tty.output));tty.output=[]}},ioctl_tcgets(tty){return{c_iflag:25856,c_oflag:5,c_cflag:191,c_lflag:35387,c_cc:[3,28,127,21,4,0,1,0,17,19,26,0,18,15,23,22,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}},ioctl_tcsets(tty,optional_actions,data){return 0},ioctl_tiocgwinsz(tty){return[24,80]}},default_tty1_ops:{put_char(tty,val){if(val===null||val===10){err(UTF8ArrayToString(tty.output));tty.output=[]}else{if(val!=0)tty.output.push(val)}},fsync(tty){if(tty.output?.length>0){err(UTF8ArrayToString(tty.output));tty.output=[]}}}};var zeroMemory=(ptr,size)=>HEAPU8.fill(0,ptr,ptr+size);var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var mmapAlloc=size=>{size=alignMemory(size,65536);var ptr=_emscripten_builtin_memalign(65536,size);if(ptr)zeroMemory(ptr,size);return ptr};var MEMFS={ops_table:null,mount(mount){return MEMFS.createNode(null,"/",16895,0)},createNode(parent,name,mode,dev){if(FS.isBlkdev(mode)||FS.isFIFO(mode)){throw new FS.ErrnoError(63)}MEMFS.ops_table||={dir:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,lookup:MEMFS.node_ops.lookup,mknod:MEMFS.node_ops.mknod,rename:MEMFS.node_ops.rename,unlink:MEMFS.node_ops.unlink,rmdir:MEMFS.node_ops.rmdir,readdir:MEMFS.node_ops.readdir,symlink:MEMFS.node_ops.symlink},stream:{llseek:MEMFS.stream_ops.llseek}},file:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:{llseek:MEMFS.stream_ops.llseek,read:MEMFS.stream_ops.read,write:MEMFS.stream_ops.write,mmap:MEMFS.stream_ops.mmap,msync:MEMFS.stream_ops.msync}},link:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr,readlink:MEMFS.node_ops.readlink},stream:{}},chrdev:{node:{getattr:MEMFS.node_ops.getattr,setattr:MEMFS.node_ops.setattr},stream:FS.chrdev_stream_ops}};var node=FS.createNode(parent,name,mode,dev);if(FS.isDir(node.mode)){node.node_ops=MEMFS.ops_table.dir.node;node.stream_ops=MEMFS.ops_table.dir.stream;node.contents={}}else if(FS.isFile(node.mode)){node.node_ops=MEMFS.ops_table.file.node;node.stream_ops=MEMFS.ops_table.file.stream;node.usedBytes=0;node.contents=MEMFS.emptyFileContents??=new Uint8Array(0)}else if(FS.isLink(node.mode)){node.node_ops=MEMFS.ops_table.link.node;node.stream_ops=MEMFS.ops_table.link.stream}else if(FS.isChrdev(node.mode)){node.node_ops=MEMFS.ops_table.chrdev.node;node.stream_ops=MEMFS.ops_table.chrdev.stream}node.atime=node.mtime=node.ctime=Date.now();if(parent){parent.contents[name]=node;parent.atime=parent.mtime=parent.ctime=node.atime}return node},getFileDataAsTypedArray(node){return node.contents.subarray(0,node.usedBytes)},expandFileStorage(node,newCapacity){var prevCapacity=node.contents.length;if(prevCapacity>=newCapacity)return;var CAPACITY_DOUBLING_MAX=1024*1024;newCapacity=Math.max(newCapacity,prevCapacity*(prevCapacity>>0);if(prevCapacity)newCapacity=Math.max(newCapacity,256);var oldContents=MEMFS.getFileDataAsTypedArray(node);node.contents=new Uint8Array(newCapacity);node.contents.set(oldContents)},resizeFileStorage(node,newSize){if(node.usedBytes==newSize)return;var oldContents=node.contents;node.contents=new Uint8Array(newSize);node.contents.set(oldContents.subarray(0,Math.min(newSize,node.usedBytes)));node.usedBytes=newSize},node_ops:{getattr(node){var attr={};attr.dev=FS.isChrdev(node.mode)?node.id:1;attr.ino=node.id;attr.mode=node.mode;attr.nlink=1;attr.uid=0;attr.gid=0;attr.rdev=node.rdev;if(FS.isDir(node.mode)){attr.size=4096}else if(FS.isFile(node.mode)){attr.size=node.usedBytes}else if(FS.isLink(node.mode)){attr.size=node.link.length}else{attr.size=0}attr.atime=new Date(node.atime);attr.mtime=new Date(node.mtime);attr.ctime=new Date(node.ctime);attr.blksize=4096;attr.blocks=Math.ceil(attr.size/attr.blksize);return attr},setattr(node,attr){for(const key of["mode","atime","mtime","ctime"]){if(attr[key]!=null){node[key]=attr[key]}}if(attr.size!==undefined){MEMFS.resizeFileStorage(node,attr.size)}},lookup(parent,name){if(!MEMFS.doesNotExistError){MEMFS.doesNotExistError=new FS.ErrnoError(44);MEMFS.doesNotExistError.stack=""}throw MEMFS.doesNotExistError},mknod(parent,name,mode,dev){return MEMFS.createNode(parent,name,mode,dev)},rename(old_node,new_dir,new_name){var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(new_node){if(FS.isDir(old_node.mode)){for(var i in new_node.contents){throw new FS.ErrnoError(55)}}FS.hashRemoveNode(new_node)}delete old_node.parent.contents[old_node.name];new_dir.contents[new_name]=old_node;old_node.name=new_name;new_dir.ctime=new_dir.mtime=old_node.parent.ctime=old_node.parent.mtime=Date.now()},unlink(parent,name){delete parent.contents[name];parent.ctime=parent.mtime=Date.now()},rmdir(parent,name){var node=FS.lookupNode(parent,name);for(var i in node.contents){throw new FS.ErrnoError(55)}delete parent.contents[name];parent.ctime=parent.mtime=Date.now()},readdir(node){return[".","..",...Object.keys(node.contents)]},symlink(parent,newname,oldpath){var node=MEMFS.createNode(parent,newname,511|40960,0);node.link=oldpath;return node},readlink(node){if(!FS.isLink(node.mode)){throw new FS.ErrnoError(28)}return node.link}},stream_ops:{read(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=stream.node.usedBytes)return 0;var size=Math.min(stream.node.usedBytes-position,length);buffer.set(contents.subarray(position,position+size),offset);return size},write(stream,buffer,offset,length,position,canOwn){if(buffer.buffer===HEAP8.buffer){canOwn=false}if(!length)return 0;var node=stream.node;node.mtime=node.ctime=Date.now();if(canOwn){node.contents=buffer.subarray(offset,offset+length);node.usedBytes=length}else if(node.usedBytes===0&&position===0){node.contents=buffer.slice(offset,offset+length);node.usedBytes=length}else{MEMFS.expandFileStorage(node,position+length);node.contents.set(buffer.subarray(offset,offset+length),position);node.usedBytes=Math.max(node.usedBytes,position+length)}return length},llseek(stream,offset,whence){var position=offset;if(whence===1){position+=stream.position}else if(whence===2){if(FS.isFile(stream.node.mode)){position+=stream.node.usedBytes}}if(position<0){throw new FS.ErrnoError(28)}return position},mmap(stream,length,position,prot,flags){if(!FS.isFile(stream.node.mode)){throw new FS.ErrnoError(43)}var ptr;var allocated;var contents=stream.node.contents;if(!(flags&2)&&contents.buffer===HEAP8.buffer){allocated=false;ptr=contents.byteOffset}else{allocated=true;ptr=mmapAlloc(length);if(!ptr){throw new FS.ErrnoError(48)}if(contents){if(position>0||position+length{if(typeof str!="string")return str;var flagModes={r:0,"r+":2,w:512|64|1,"w+":512|64|2,a:1024|64|1,"a+":1024|64|2};var flags=flagModes[str];if(typeof flags=="undefined"){throw new Error(`Unknown file open mode: ${str}`)}return flags};var FS_fileDataToTypedArray=data=>{if(typeof data=="string"){data=intArrayFromString(data,true)}if(!data.subarray){data=new Uint8Array(data)}return data};var FS_getMode=(canRead,canWrite)=>{var mode=0;if(canRead)mode|=292|73;if(canWrite)mode|=146;return mode};var asyncLoad=async url=>{var arrayBuffer=await readAsync(url);return new Uint8Array(arrayBuffer)};var FS_createDataFile=(...args)=>FS.createDataFile(...args);var getUniqueRunDependency=id=>id;var runDependencies=0;var dependenciesFulfilled=null;var removeRunDependency=id=>{runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}};var addRunDependency=id=>{runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)};var preloadPlugins=[];var FS_handledByPreloadPlugin=async(byteArray,fullname)=>{if(typeof Browser!="undefined")Browser.init();for(var plugin of preloadPlugins){if(plugin["canHandle"](fullname)){return plugin["handle"](byteArray,fullname)}}return byteArray};var FS_preloadFile=async(parent,name,url,canRead,canWrite,dontCreateFile,canOwn,preFinish)=>{var fullname=name?PATH_FS.resolve(PATH.join2(parent,name)):parent;var dep=getUniqueRunDependency(`cp ${fullname}`);addRunDependency(dep);try{var byteArray=url;if(typeof url=="string"){byteArray=await asyncLoad(url)}byteArray=await FS_handledByPreloadPlugin(byteArray,fullname);preFinish?.();if(!dontCreateFile){FS_createDataFile(parent,name,byteArray,canRead,canWrite,canOwn)}}finally{removeRunDependency(dep)}};var FS_createPreloadedFile=(parent,name,url,canRead,canWrite,onload,onerror,dontCreateFile,canOwn,preFinish)=>{FS_preloadFile(parent,name,url,canRead,canWrite,dontCreateFile,canOwn,preFinish).then(onload).catch(onerror)};var FS={root:null,mounts:[],devices:{},streams:[],nextInode:1,nameTable:null,currentPath:"/",initialized:false,ignorePermissions:true,filesystems:null,syncFSRequests:0,ErrnoError:class{name="ErrnoError";constructor(errno){this.errno=errno}},FSStream:class{shared={};get object(){return this.node}set object(val){this.node=val}get isRead(){return(this.flags&2097155)!==1}get isWrite(){return(this.flags&2097155)!==0}get isAppend(){return this.flags&1024}get flags(){return this.shared.flags}set flags(val){this.shared.flags=val}get position(){return this.shared.position}set position(val){this.shared.position=val}},FSNode:class{node_ops={};stream_ops={};readMode=292|73;writeMode=146;mounted=null;constructor(parent,name,mode,rdev){if(!parent){parent=this}this.parent=parent;this.mount=parent.mount;this.id=FS.nextInode++;this.name=name;this.mode=mode;this.rdev=rdev;this.atime=this.mtime=this.ctime=Date.now()}get read(){return(this.mode&this.readMode)===this.readMode}set read(val){val?this.mode|=this.readMode:this.mode&=~this.readMode}get write(){return(this.mode&this.writeMode)===this.writeMode}set write(val){val?this.mode|=this.writeMode:this.mode&=~this.writeMode}get isFolder(){return FS.isDir(this.mode)}get isDevice(){return FS.isChrdev(this.mode)}},lookupPath(path,opts={}){if(!path){throw new FS.ErrnoError(44)}opts.follow_mount??=true;if(!PATH.isAbs(path)){path=FS.cwd()+"/"+path}linkloop:for(var nlinks=0;nlinks<40;nlinks++){var parts=path.split("/").filter(p=>!!p);var current=FS.root;var current_path="/";for(var i=0;i>>0)%FS.nameTable.length},hashAddNode(node){var hash=FS.hashName(node.parent.id,node.name);node.name_next=FS.nameTable[hash];FS.nameTable[hash]=node},hashRemoveNode(node){var hash=FS.hashName(node.parent.id,node.name);if(FS.nameTable[hash]===node){FS.nameTable[hash]=node.name_next}else{var current=FS.nameTable[hash];while(current){if(current.name_next===node){current.name_next=node.name_next;break}current=current.name_next}}},lookupNode(parent,name){var errCode=FS.mayLookup(parent);if(errCode){throw new FS.ErrnoError(errCode)}var hash=FS.hashName(parent.id,name);for(var node=FS.nameTable[hash];node;node=node.name_next){var nodeName=node.name;if(node.parent.id===parent.id&&nodeName===name){return node}}return FS.lookup(parent,name)},createNode(parent,name,mode,rdev){var node=new FS.FSNode(parent,name,mode,rdev);FS.hashAddNode(node);return node},destroyNode(node){FS.hashRemoveNode(node)},isRoot(node){return node===node.parent},isMountpoint(node){return!!node.mounted},isFile(mode){return(mode&61440)===32768},isDir(mode){return(mode&61440)===16384},isLink(mode){return(mode&61440)===40960},isChrdev(mode){return(mode&61440)===8192},isBlkdev(mode){return(mode&61440)===24576},isFIFO(mode){return(mode&61440)===4096},isSocket(mode){return(mode&49152)===49152},flagsToPermissionString(flag){var perms=["r","w","rw"][flag&3];if(flag&512){perms+="w"}return perms},nodePermissions(node,perms){if(FS.ignorePermissions){return 0}if(perms.includes("r")&&!(node.mode&292)){return 2}if(perms.includes("w")&&!(node.mode&146)){return 2}if(perms.includes("x")&&!(node.mode&73)){return 2}return 0},mayLookup(dir){if(!FS.isDir(dir.mode))return 54;var errCode=FS.nodePermissions(dir,"x");if(errCode)return errCode;if(!dir.node_ops.lookup)return 2;return 0},mayCreate(dir,name){if(!FS.isDir(dir.mode)){return 54}try{var node=FS.lookupNode(dir,name);return 20}catch(e){}return FS.nodePermissions(dir,"wx")},mayDelete(dir,name,isdir){var node;try{node=FS.lookupNode(dir,name)}catch(e){return e.errno}var errCode=FS.nodePermissions(dir,"wx");if(errCode){return errCode}if(isdir){if(!FS.isDir(node.mode)){return 54}if(FS.isRoot(node)||FS.getPath(node)===FS.cwd()){return 10}}else if(FS.isDir(node.mode)){return 31}return 0},mayOpen(node,flags){if(!node){return 44}if(FS.isLink(node.mode)){return 32}var mode=FS.flagsToPermissionString(flags);if(FS.isDir(node.mode)){if(mode!=="r"||flags&(512|64)){return 31}}return FS.nodePermissions(node,mode)},checkOpExists(op,err){if(!op){throw new FS.ErrnoError(err)}return op},MAX_OPEN_FDS:4096,nextfd(){for(var fd=0;fd<=FS.MAX_OPEN_FDS;fd++){if(!FS.streams[fd]){return fd}}throw new FS.ErrnoError(33)},getStreamChecked(fd){var stream=FS.getStream(fd);if(!stream){throw new FS.ErrnoError(8)}return stream},getStream:fd=>FS.streams[fd],createStream(stream,fd=-1){stream=Object.assign(new FS.FSStream,stream);if(fd==-1){fd=FS.nextfd()}stream.fd=fd;FS.streams[fd]=stream;return stream},closeStream(fd){FS.streams[fd]=null},dupStream(origStream,fd=-1){var stream=FS.createStream(origStream,fd);stream.stream_ops?.dup?.(stream);return stream},doSetAttr(stream,node,attr){var setattr=stream?.stream_ops.setattr;var arg=setattr?stream:node;setattr??=node.node_ops.setattr;FS.checkOpExists(setattr,63);try{setattr(arg,attr)}catch(e){if(e instanceof RangeError){throw new FS.ErrnoError(22)}throw e}},chrdev_stream_ops:{open(stream){var device=FS.getDevice(stream.node.rdev);stream.stream_ops=device.stream_ops;stream.stream_ops.open?.(stream)},llseek(){throw new FS.ErrnoError(70)}},major:dev=>dev>>8,minor:dev=>dev&255,makedev:(ma,mi)=>ma<<8|mi,registerDevice(dev,ops){FS.devices[dev]={stream_ops:ops}},getDevice:dev=>FS.devices[dev],getMounts(mount){var mounts=[];var check=[mount];while(check.length){var m=check.pop();mounts.push(m);check.push(...m.mounts)}return mounts},syncfs(populate,callback){if(typeof populate=="function"){callback=populate;populate=false}FS.syncFSRequests++;if(FS.syncFSRequests>1){err(`warning: ${FS.syncFSRequests} FS.syncfs operations in flight at once, probably just doing extra work`)}var mounts=FS.getMounts(FS.root.mount);var completed=0;function doCallback(errCode){FS.syncFSRequests--;return callback(errCode)}function done(errCode){if(errCode){if(!done.errored){done.errored=true;return doCallback(errCode)}return}if(++completed>=mounts.length){doCallback(null)}}for(var mount of mounts){if(mount.type.syncfs){mount.type.syncfs(mount,populate,done)}else{done(null)}}},mount(type,opts,mountpoint){var root=mountpoint==="/";var pseudo=!mountpoint;var node;if(root&&FS.root){throw new FS.ErrnoError(10)}else if(!root&&!pseudo){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});mountpoint=lookup.path;node=lookup.node;if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}if(!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}}var mount={type,opts,mountpoint,mounts:[]};var mountRoot=type.mount(mount);mountRoot.mount=mount;mount.root=mountRoot;if(root){FS.root=mountRoot}else if(node){node.mounted=mount;if(node.mount){node.mount.mounts.push(mount)}}return mountRoot},unmount(mountpoint){var lookup=FS.lookupPath(mountpoint,{follow_mount:false});if(!FS.isMountpoint(lookup.node)){throw new FS.ErrnoError(28)}var node=lookup.node;var mount=node.mounted;var mounts=FS.getMounts(mount);for(var[hash,current]of Object.entries(FS.nameTable)){while(current){var next=current.name_next;if(mounts.includes(current.mount)){FS.destroyNode(current)}current=next}}node.mounted=null;var idx=node.mount.mounts.indexOf(mount);node.mount.mounts.splice(idx,1)},lookup(parent,name){return parent.node_ops.lookup(parent,name)},mknod(path,mode,dev){var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);if(!name){throw new FS.ErrnoError(28)}if(name==="."||name===".."){throw new FS.ErrnoError(20)}var errCode=FS.mayCreate(parent,name);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.mknod){throw new FS.ErrnoError(63)}return parent.node_ops.mknod(parent,name,mode,dev)},statfs(path){return FS.statfsNode(FS.lookupPath(path,{follow:true}).node)},statfsStream(stream){return FS.statfsNode(stream.node)},statfsNode(node){var rtn={bsize:4096,frsize:4096,blocks:1e6,bfree:5e5,bavail:5e5,files:FS.nextInode,ffree:FS.nextInode-1,fsid:42,flags:2,namelen:255};if(node.node_ops.statfs){Object.assign(rtn,node.node_ops.statfs(node.mount.opts.root))}return rtn},create(path,mode=438){mode&=4095;mode|=32768;return FS.mknod(path,mode,0)},mkdir(path,mode=511){mode&=511|512;mode|=16384;return FS.mknod(path,mode,0)},mkdirTree(path,mode){var dirs=path.split("/");var d="";for(var dir of dirs){if(!dir)continue;if(d||PATH.isAbs(path))d+="/";d+=dir;try{FS.mkdir(d,mode)}catch(e){if(e.errno!=20)throw e}}},mkdev(path,mode,dev){if(typeof dev=="undefined"){dev=mode;mode=438}mode|=8192;return FS.mknod(path,mode,dev)},symlink(oldpath,newpath){if(!PATH_FS.resolve(oldpath)){throw new FS.ErrnoError(44)}var lookup=FS.lookupPath(newpath,{parent:true});var parent=lookup.node;if(!parent){throw new FS.ErrnoError(44)}var newname=PATH.basename(newpath);var errCode=FS.mayCreate(parent,newname);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.symlink){throw new FS.ErrnoError(63)}return parent.node_ops.symlink(parent,newname,oldpath)},rename(old_path,new_path){var old_dirname=PATH.dirname(old_path);var new_dirname=PATH.dirname(new_path);var old_name=PATH.basename(old_path);var new_name=PATH.basename(new_path);var lookup,old_dir,new_dir;lookup=FS.lookupPath(old_path,{parent:true});old_dir=lookup.node;lookup=FS.lookupPath(new_path,{parent:true});new_dir=lookup.node;if(!old_dir||!new_dir)throw new FS.ErrnoError(44);if(old_dir.mount!==new_dir.mount){throw new FS.ErrnoError(75)}var old_node=FS.lookupNode(old_dir,old_name);var relative=PATH_FS.relative(old_path,new_dirname);if(relative.charAt(0)!=="."){throw new FS.ErrnoError(28)}relative=PATH_FS.relative(new_path,old_dirname);if(relative.charAt(0)!=="."){throw new FS.ErrnoError(55)}var new_node;try{new_node=FS.lookupNode(new_dir,new_name)}catch(e){}if(old_node===new_node){return}var isdir=FS.isDir(old_node.mode);var errCode=FS.mayDelete(old_dir,old_name,isdir);if(errCode){throw new FS.ErrnoError(errCode)}errCode=new_node?FS.mayDelete(new_dir,new_name,isdir):FS.mayCreate(new_dir,new_name);if(errCode){throw new FS.ErrnoError(errCode)}if(!old_dir.node_ops.rename){throw new FS.ErrnoError(63)}if(FS.isMountpoint(old_node)||new_node&&FS.isMountpoint(new_node)){throw new FS.ErrnoError(10)}if(new_dir!==old_dir){errCode=FS.nodePermissions(old_dir,"w");if(errCode){throw new FS.ErrnoError(errCode)}}FS.hashRemoveNode(old_node);try{old_dir.node_ops.rename(old_node,new_dir,new_name);old_node.parent=new_dir}catch(e){throw e}finally{FS.hashAddNode(old_node)}},rmdir(path){var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;var name=PATH.basename(path);var node=FS.lookupNode(parent,name);var errCode=FS.mayDelete(parent,name,true);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.rmdir){throw new FS.ErrnoError(63)}if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}parent.node_ops.rmdir(parent,name);FS.destroyNode(node)},readdir(path){var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;var readdir=FS.checkOpExists(node.node_ops.readdir,54);return readdir(node)},unlink(path){var lookup=FS.lookupPath(path,{parent:true});var parent=lookup.node;if(!parent){throw new FS.ErrnoError(44)}var name=PATH.basename(path);var node=FS.lookupNode(parent,name);var errCode=FS.mayDelete(parent,name,false);if(errCode){throw new FS.ErrnoError(errCode)}if(!parent.node_ops.unlink){throw new FS.ErrnoError(63)}if(FS.isMountpoint(node)){throw new FS.ErrnoError(10)}parent.node_ops.unlink(parent,name);FS.destroyNode(node)},readlink(path){var lookup=FS.lookupPath(path);var link=lookup.node;if(!link){throw new FS.ErrnoError(44)}if(!link.node_ops.readlink){throw new FS.ErrnoError(28)}return link.node_ops.readlink(link)},stat(path,dontFollow){var lookup=FS.lookupPath(path,{follow:!dontFollow});var node=lookup.node;var getattr=FS.checkOpExists(node.node_ops.getattr,63);return getattr(node)},fstat(fd){var stream=FS.getStreamChecked(fd);var node=stream.node;var getattr=stream.stream_ops.getattr;var arg=getattr?stream:node;getattr??=node.node_ops.getattr;FS.checkOpExists(getattr,63);return getattr(arg)},lstat(path){return FS.stat(path,true)},doChmod(stream,node,mode,dontFollow){FS.doSetAttr(stream,node,{mode:mode&4095|node.mode&~4095,ctime:Date.now(),dontFollow})},chmod(path,mode,dontFollow){var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:!dontFollow});node=lookup.node}else{node=path}FS.doChmod(null,node,mode,dontFollow)},lchmod(path,mode){FS.chmod(path,mode,true)},fchmod(fd,mode){var stream=FS.getStreamChecked(fd);FS.doChmod(stream,stream.node,mode,false)},doChown(stream,node,dontFollow){FS.doSetAttr(stream,node,{timestamp:Date.now(),dontFollow})},chown(path,uid,gid,dontFollow){var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:!dontFollow});node=lookup.node}else{node=path}FS.doChown(null,node,dontFollow)},lchown(path,uid,gid){FS.chown(path,uid,gid,true)},fchown(fd,uid,gid){var stream=FS.getStreamChecked(fd);FS.doChown(stream,stream.node,false)},doTruncate(stream,node,len){if(FS.isDir(node.mode)){throw new FS.ErrnoError(31)}if(!FS.isFile(node.mode)){throw new FS.ErrnoError(28)}var errCode=FS.nodePermissions(node,"w");if(errCode){throw new FS.ErrnoError(errCode)}FS.doSetAttr(stream,node,{size:len,timestamp:Date.now()})},truncate(path,len){if(len<0){throw new FS.ErrnoError(28)}var node;if(typeof path=="string"){var lookup=FS.lookupPath(path,{follow:true});node=lookup.node}else{node=path}FS.doTruncate(null,node,len)},ftruncate(fd,len){var stream=FS.getStreamChecked(fd);if(len<0||(stream.flags&2097155)===0){throw new FS.ErrnoError(28)}FS.doTruncate(stream,stream.node,len)},utime(path,atime,mtime){var lookup=FS.lookupPath(path,{follow:true});var node=lookup.node;var setattr=FS.checkOpExists(node.node_ops.setattr,63);setattr(node,{atime,mtime})},open(path,flags,mode=438){if(path===""){throw new FS.ErrnoError(44)}flags=FS_modeStringToFlags(flags);if(flags&64){mode=mode&4095|32768}else{mode=0}var node;var isDirPath;if(typeof path=="object"){node=path}else{isDirPath=path.endsWith("/");var lookup=FS.lookupPath(path,{follow:!(flags&131072),noent_okay:true});node=lookup.node;path=lookup.path}var created=false;if(flags&64){if(node){if(flags&128){throw new FS.ErrnoError(20)}}else if(isDirPath){throw new FS.ErrnoError(31)}else{node=FS.mknod(path,mode|511,0);created=true}}if(!node){throw new FS.ErrnoError(44)}if(FS.isChrdev(node.mode)){flags&=~512}if(flags&65536&&!FS.isDir(node.mode)){throw new FS.ErrnoError(54)}if(!created){var errCode=FS.mayOpen(node,flags);if(errCode){throw new FS.ErrnoError(errCode)}}if(flags&512&&!created){FS.truncate(node,0)}flags&=~(128|512|131072);var stream=FS.createStream({node,path:FS.getPath(node),flags,seekable:true,position:0,stream_ops:node.stream_ops,ungotten:[],error:false});if(stream.stream_ops.open){stream.stream_ops.open(stream)}if(created){FS.chmod(node,mode&511)}return stream},close(stream){if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(stream.getdents)stream.getdents=null;try{if(stream.stream_ops.close){stream.stream_ops.close(stream)}}catch(e){throw e}finally{FS.closeStream(stream.fd)}stream.fd=null},isClosed(stream){return stream.fd===null},llseek(stream,offset,whence){if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if(!stream.seekable||!stream.stream_ops.llseek){throw new FS.ErrnoError(70)}if(whence!=0&&whence!=1&&whence!=2){throw new FS.ErrnoError(28)}stream.position=stream.stream_ops.llseek(stream,offset,whence);stream.ungotten=[];return stream.position},read(stream,buffer,offset,length,position){if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.read){throw new FS.ErrnoError(28)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesRead=stream.stream_ops.read(stream,buffer,offset,length,position);if(!seeking)stream.position+=bytesRead;return bytesRead},write(stream,buffer,offset,length,position,canOwn){if(length<0||position<0){throw new FS.ErrnoError(28)}if(FS.isClosed(stream)){throw new FS.ErrnoError(8)}if((stream.flags&2097155)===0){throw new FS.ErrnoError(8)}if(FS.isDir(stream.node.mode)){throw new FS.ErrnoError(31)}if(!stream.stream_ops.write){throw new FS.ErrnoError(28)}if(stream.seekable&&stream.flags&1024){FS.llseek(stream,0,2)}var seeking=typeof position!="undefined";if(!seeking){position=stream.position}else if(!stream.seekable){throw new FS.ErrnoError(70)}var bytesWritten=stream.stream_ops.write(stream,buffer,offset,length,position,canOwn);if(!seeking)stream.position+=bytesWritten;return bytesWritten},mmap(stream,length,position,prot,flags){if((prot&2)!==0&&(flags&2)===0&&(stream.flags&2097155)!==2){throw new FS.ErrnoError(2)}if((stream.flags&2097155)===1){throw new FS.ErrnoError(2)}if(!stream.stream_ops.mmap){throw new FS.ErrnoError(43)}if(!length){throw new FS.ErrnoError(28)}return stream.stream_ops.mmap(stream,length,position,prot,flags)},msync(stream,buffer,offset,length,mmapFlags){if(!stream.stream_ops.msync){return 0}return stream.stream_ops.msync(stream,buffer,offset,length,mmapFlags)},ioctl(stream,cmd,arg){if(!stream.stream_ops.ioctl){throw new FS.ErrnoError(59)}return stream.stream_ops.ioctl(stream,cmd,arg)},readFile(path,opts={}){opts.flags=opts.flags||0;opts.encoding=opts.encoding||"binary";if(opts.encoding!=="utf8"&&opts.encoding!=="binary"){abort(`Invalid encoding type "${opts.encoding}"`)}var stream=FS.open(path,opts.flags);var stat=FS.stat(path);var length=stat.size;var buf=new Uint8Array(length);FS.read(stream,buf,0,length,0);if(opts.encoding==="utf8"){buf=UTF8ArrayToString(buf)}FS.close(stream);return buf},writeFile(path,data,opts={}){opts.flags=opts.flags||577;var stream=FS.open(path,opts.flags,opts.mode);data=FS_fileDataToTypedArray(data);FS.write(stream,data,0,data.byteLength,undefined,opts.canOwn);FS.close(stream)},cwd:()=>FS.currentPath,chdir(path){var lookup=FS.lookupPath(path,{follow:true});if(lookup.node===null){throw new FS.ErrnoError(44)}if(!FS.isDir(lookup.node.mode)){throw new FS.ErrnoError(54)}var errCode=FS.nodePermissions(lookup.node,"x");if(errCode){throw new FS.ErrnoError(errCode)}FS.currentPath=lookup.path},createDefaultDirectories(){FS.mkdir("/tmp");FS.mkdir("/home");FS.mkdir("/home/web_user")},createDefaultDevices(){FS.mkdir("/dev");FS.registerDevice(FS.makedev(1,3),{read:()=>0,write:(stream,buffer,offset,length,pos)=>length,llseek:()=>0});FS.mkdev("/dev/null",FS.makedev(1,3));TTY.register(FS.makedev(5,0),TTY.default_tty_ops);TTY.register(FS.makedev(6,0),TTY.default_tty1_ops);FS.mkdev("/dev/tty",FS.makedev(5,0));FS.mkdev("/dev/tty1",FS.makedev(6,0));var randomBuffer=new Uint8Array(1024),randomLeft=0;var randomByte=()=>{if(randomLeft===0){randomFill(randomBuffer);randomLeft=randomBuffer.byteLength}return randomBuffer[--randomLeft]};FS.createDevice("/dev","random",randomByte);FS.createDevice("/dev","urandom",randomByte);FS.mkdir("/dev/shm");FS.mkdir("/dev/shm/tmp")},createSpecialDirectories(){FS.mkdir("/proc");var proc_self=FS.mkdir("/proc/self");FS.mkdir("/proc/self/fd");FS.mount({mount(){var node=FS.createNode(proc_self,"fd",16895,73);node.stream_ops={llseek:MEMFS.stream_ops.llseek};node.node_ops={lookup(parent,name){var fd=+name;var stream=FS.getStreamChecked(fd);var ret={parent:null,mount:{mountpoint:"fake"},node_ops:{readlink:()=>stream.path},id:fd+1};ret.parent=ret;return ret},readdir(){return Array.from(FS.streams.entries()).filter(([k,v])=>v).map(([k,v])=>k.toString())}};return node}},{},"/proc/self/fd")},createStandardStreams(input,output,error){if(input){FS.createDevice("/dev","stdin",input)}else{FS.symlink("/dev/tty","/dev/stdin")}if(output){FS.createDevice("/dev","stdout",null,output)}else{FS.symlink("/dev/tty","/dev/stdout")}if(error){FS.createDevice("/dev","stderr",null,error)}else{FS.symlink("/dev/tty1","/dev/stderr")}var stdin=FS.open("/dev/stdin",0);var stdout=FS.open("/dev/stdout",1);var stderr=FS.open("/dev/stderr",1)},staticInit(){FS.nameTable=new Array(4096);FS.mount(MEMFS,{},"/");FS.createDefaultDirectories();FS.createDefaultDevices();FS.createSpecialDirectories();FS.filesystems={MEMFS}},init(input,output,error){FS.initialized=true;input??=Module["stdin"];output??=Module["stdout"];error??=Module["stderr"];FS.createStandardStreams(input,output,error)},quit(){FS.initialized=false;for(var stream of FS.streams){if(stream){FS.close(stream)}}},findObject(path,dontResolveLastLink){var ret=FS.analyzePath(path,dontResolveLastLink);if(!ret.exists){return null}return ret.object},analyzePath(path,dontResolveLastLink){try{var lookup=FS.lookupPath(path,{follow:!dontResolveLastLink});path=lookup.path}catch(e){}var ret={isRoot:false,exists:false,error:0,name:null,path:null,object:null,parentExists:false,parentPath:null,parentObject:null};try{var lookup=FS.lookupPath(path,{parent:true});ret.parentExists=true;ret.parentPath=lookup.path;ret.parentObject=lookup.node;ret.name=PATH.basename(path);lookup=FS.lookupPath(path,{follow:!dontResolveLastLink});ret.exists=true;ret.path=lookup.path;ret.object=lookup.node;ret.name=lookup.node.name;ret.isRoot=lookup.path==="/"}catch(e){ret.error=e.errno}return ret},createPath(parent,path,canRead,canWrite){parent=typeof parent=="string"?parent:FS.getPath(parent);var parts=path.split("/").reverse();while(parts.length){var part=parts.pop();if(!part)continue;var current=PATH.join2(parent,part);try{FS.mkdir(current)}catch(e){if(e.errno!=20)throw e}parent=current}return current},createFile(parent,name,properties,canRead,canWrite){var path=PATH.join2(typeof parent=="string"?parent:FS.getPath(parent),name);var mode=FS_getMode(canRead,canWrite);return FS.create(path,mode)},createDataFile(parent,name,data,canRead,canWrite,canOwn){var path=name;if(parent){parent=typeof parent=="string"?parent:FS.getPath(parent);path=name?PATH.join2(parent,name):parent}var mode=FS_getMode(canRead,canWrite);var node=FS.create(path,mode);if(data){data=FS_fileDataToTypedArray(data);FS.chmod(node,mode|146);var stream=FS.open(node,577);FS.write(stream,data,0,data.length,0,canOwn);FS.close(stream);FS.chmod(node,mode)}},createDevice(parent,name,input,output){var path=PATH.join2(typeof parent=="string"?parent:FS.getPath(parent),name);var mode=FS_getMode(!!input,!!output);FS.createDevice.major??=64;var dev=FS.makedev(FS.createDevice.major++,0);FS.registerDevice(dev,{open(stream){stream.seekable=false},close(stream){if(output?.buffer?.length){output(10)}},read(stream,buffer,offset,length,pos){var bytesRead=0;for(var i=0;ithis.length-1||idx<0){return undefined}var chunkOffset=idx%this.chunkSize;var chunkNum=idx/this.chunkSize|0;return this.getter(chunkNum)[chunkOffset]}setDataGetter(getter){this.getter=getter}cacheLength(){var xhr=new XMLHttpRequest;xhr.open("HEAD",url,false);xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))abort("Couldn't load "+url+". Status: "+xhr.status);var datalength=Number(xhr.getResponseHeader("Content-length"));var header;var hasByteServing=(header=xhr.getResponseHeader("Accept-Ranges"))&&header==="bytes";var usesGzip=(header=xhr.getResponseHeader("Content-Encoding"))&&header==="gzip";var chunkSize=1024*1024;if(!hasByteServing)chunkSize=datalength;var doXHR=(from,to)=>{if(from>to)abort("invalid range ("+from+", "+to+") or no bytes requested!");if(to>datalength-1)abort("only "+datalength+" bytes available! programmer error!");var xhr=new XMLHttpRequest;xhr.open("GET",url,false);if(datalength!==chunkSize)xhr.setRequestHeader("Range","bytes="+from+"-"+to);xhr.responseType="arraybuffer";if(xhr.overrideMimeType){xhr.overrideMimeType("text/plain; charset=x-user-defined")}xhr.send(null);if(!(xhr.status>=200&&xhr.status<300||xhr.status===304))abort("Couldn't load "+url+". Status: "+xhr.status);if(xhr.response!==undefined){return new Uint8Array(xhr.response||[])}return intArrayFromString(xhr.responseText||"",true)};var lazyArray=this;lazyArray.setDataGetter(chunkNum=>{var start=chunkNum*chunkSize;var end=(chunkNum+1)*chunkSize-1;end=Math.min(end,datalength-1);if(typeof lazyArray.chunks[chunkNum]=="undefined"){lazyArray.chunks[chunkNum]=doXHR(start,end)}if(typeof lazyArray.chunks[chunkNum]=="undefined")abort("doXHR failed!");return lazyArray.chunks[chunkNum]});if(usesGzip||!datalength){chunkSize=datalength=1;datalength=this.getter(0).length;chunkSize=datalength;out("LazyFiles on gzip forces download of the whole file when length is accessed")}this._length=datalength;this._chunkSize=chunkSize;this.lengthKnown=true}get length(){if(!this.lengthKnown){this.cacheLength()}return this._length}get chunkSize(){if(!this.lengthKnown){this.cacheLength()}return this._chunkSize}}if(globalThis.XMLHttpRequest){if(!ENVIRONMENT_IS_WORKER)abort("Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc");var lazyArray=new LazyUint8Array;var properties={isDevice:false,contents:lazyArray}}else{var properties={isDevice:false,url}}var node=FS.createFile(parent,name,properties,canRead,canWrite);if(properties.contents){node.contents=properties.contents}else if(properties.url){node.contents=null;node.url=properties.url}Object.defineProperties(node,{usedBytes:{get:function(){return this.contents.length}}});var stream_ops={};for(const[key,fn]of Object.entries(node.stream_ops)){stream_ops[key]=(...args)=>{FS.forceLoadFile(node);return fn(...args)}}function writeChunks(stream,buffer,offset,length,position){var contents=stream.node.contents;if(position>=contents.length)return 0;var size=Math.min(contents.length-position,length);if(contents.slice){for(var i=0;i{FS.forceLoadFile(node);return writeChunks(stream,buffer,offset,length,position)};stream_ops.mmap=(stream,length,position,prot,flags)=>{FS.forceLoadFile(node);var ptr=mmapAlloc(length);if(!ptr){throw new FS.ErrnoError(48)}writeChunks(stream,HEAP8,ptr,length,position);return{ptr,allocated:true}};node.stream_ops=stream_ops;return node}};var UTF8ToString=(ptr,maxBytesToRead,ignoreNul)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead,ignoreNul):"";var SYSCALLS={currentUmask:18,calculateAt(dirfd,path,allowEmpty){if(PATH.isAbs(path)){return path}var dir;if(dirfd===-100){dir=FS.cwd()}else{var dirstream=SYSCALLS.getStreamFromFD(dirfd);dir=dirstream.path}if(path.length==0){if(!allowEmpty){throw new FS.ErrnoError(44)}return dir}return dir+"/"+path},writeStat(buf,stat){HEAPU32[buf>>2]=stat.dev;HEAPU32[buf+4>>2]=stat.mode;HEAPU32[buf+8>>2]=stat.nlink;HEAPU32[buf+12>>2]=stat.uid;HEAPU32[buf+16>>2]=stat.gid;HEAPU32[buf+20>>2]=stat.rdev;HEAP64[buf+24>>3]=BigInt(stat.size);HEAP32[buf+32>>2]=4096;HEAP32[buf+36>>2]=stat.blocks;var atime=stat.atime.getTime();var mtime=stat.mtime.getTime();var ctime=stat.ctime.getTime();HEAP64[buf+40>>3]=BigInt(Math.floor(atime/1e3));HEAPU32[buf+48>>2]=atime%1e3*1e3*1e3;HEAP64[buf+56>>3]=BigInt(Math.floor(mtime/1e3));HEAPU32[buf+64>>2]=mtime%1e3*1e3*1e3;HEAP64[buf+72>>3]=BigInt(Math.floor(ctime/1e3));HEAPU32[buf+80>>2]=ctime%1e3*1e3*1e3;HEAP64[buf+88>>3]=BigInt(stat.ino);return 0},writeStatFs(buf,stats){HEAPU32[buf+4>>2]=stats.bsize;HEAPU32[buf+60>>2]=stats.bsize;HEAP64[buf+8>>3]=BigInt(stats.blocks);HEAP64[buf+16>>3]=BigInt(stats.bfree);HEAP64[buf+24>>3]=BigInt(stats.bavail);HEAP64[buf+32>>3]=BigInt(stats.files);HEAP64[buf+40>>3]=BigInt(stats.ffree);HEAPU32[buf+48>>2]=stats.fsid;HEAPU32[buf+64>>2]=stats.flags;HEAPU32[buf+56>>2]=stats.namelen},doMsync(addr,stream,len,flags,offset){if(!FS.isFile(stream.node.mode)){throw new FS.ErrnoError(43)}if(flags&2){return 0}var buffer=HEAPU8.slice(addr,addr+len);FS.msync(stream,buffer,offset,len,flags)},getStreamFromFD(fd){var stream=FS.getStreamChecked(fd);return stream},varargs:undefined,getStr(ptr){var ret=UTF8ToString(ptr);return ret}};function ___syscall_fcntl64(fd,cmd,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(cmd){case 0:{var arg=syscallGetVarargI();if(arg<0){return-28}while(FS.streams[arg]){arg++}var newStream;newStream=FS.dupStream(stream,arg);return newStream.fd}case 1:case 2:return 0;case 3:return stream.flags;case 4:{var arg=syscallGetVarargI();var mask=289792;stream.flags=stream.flags&~mask|arg&mask;return 0}case 12:{var arg=syscallGetVarargP();var offset=0;HEAP16[arg+offset>>1]=2;return 0}case 13:case 14:return 0}return-28}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_fstat64(fd,buf){try{return SYSCALLS.writeStat(buf,FS.fstat(fd))}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);function ___syscall_getdents64(fd,dirp,count){try{var stream=SYSCALLS.getStreamFromFD(fd);stream.getdents||=FS.readdir(stream.path);var struct_size=280;var pos=0;var off=FS.llseek(stream,0,1);var startIdx=Math.floor(off/struct_size);var endIdx=Math.min(stream.getdents.length,startIdx+Math.floor(count/struct_size));for(var idx=startIdx;idx>3]=BigInt(id);HEAP64[dirp+pos+8>>3]=BigInt((idx+1)*struct_size);HEAP16[dirp+pos+16>>1]=280;HEAP8[dirp+pos+18]=type;stringToUTF8(name,dirp+pos+19,256);pos+=struct_size}FS.llseek(stream,idx*struct_size,0);return pos}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_ioctl(fd,op,varargs){SYSCALLS.varargs=varargs;try{var stream=SYSCALLS.getStreamFromFD(fd);switch(op){case 21509:{if(!stream.tty)return-59;return 0}case 21505:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tcgets){var termios=stream.tty.ops.ioctl_tcgets(stream);var argp=syscallGetVarargP();HEAP32[argp>>2]=termios.c_iflag||0;HEAP32[argp+4>>2]=termios.c_oflag||0;HEAP32[argp+8>>2]=termios.c_cflag||0;HEAP32[argp+12>>2]=termios.c_lflag||0;for(var i=0;i<32;i++){HEAP8[argp+i+17]=termios.c_cc[i]||0}return 0}return 0}case 21510:case 21511:case 21512:{if(!stream.tty)return-59;return 0}case 21506:case 21507:case 21508:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tcsets){var argp=syscallGetVarargP();var c_iflag=HEAP32[argp>>2];var c_oflag=HEAP32[argp+4>>2];var c_cflag=HEAP32[argp+8>>2];var c_lflag=HEAP32[argp+12>>2];var c_cc=[];for(var i=0;i<32;i++){c_cc.push(HEAP8[argp+i+17])}return stream.tty.ops.ioctl_tcsets(stream.tty,op,{c_iflag,c_oflag,c_cflag,c_lflag,c_cc})}return 0}case 21519:{if(!stream.tty)return-59;var argp=syscallGetVarargP();HEAP32[argp>>2]=0;return 0}case 21520:{if(!stream.tty)return-59;return-28}case 21537:case 21531:{var argp=syscallGetVarargP();return FS.ioctl(stream,op,argp)}case 21523:{if(!stream.tty)return-59;if(stream.tty.ops.ioctl_tiocgwinsz){var winsize=stream.tty.ops.ioctl_tiocgwinsz(stream.tty);var argp=syscallGetVarargP();HEAP16[argp>>1]=winsize[0];HEAP16[argp+2>>1]=winsize[1]}return 0}case 21524:{if(!stream.tty)return-59;return 0}case 21515:{if(!stream.tty)return-59;return 0}default:return-28}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_lstat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.writeStat(buf,FS.lstat(path))}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_newfstatat(dirfd,path,buf,flags){try{path=SYSCALLS.getStr(path);var nofollow=flags&256;var allowEmpty=flags&4096;flags=flags&~6400;path=SYSCALLS.calculateAt(dirfd,path,allowEmpty);return SYSCALLS.writeStat(buf,nofollow?FS.lstat(path):FS.stat(path))}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_openat(dirfd,path,flags,varargs){SYSCALLS.varargs=varargs;try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);var mode=varargs?syscallGetVarargI():0;if(flags&64){mode&=~SYSCALLS.currentUmask}return FS.open(path,flags,mode).fd}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_rmdir(path){try{path=SYSCALLS.getStr(path);FS.rmdir(path);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_stat64(path,buf){try{path=SYSCALLS.getStr(path);return SYSCALLS.writeStat(buf,FS.stat(path))}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function ___syscall_unlinkat(dirfd,path,flags){try{path=SYSCALLS.getStr(path);path=SYSCALLS.calculateAt(dirfd,path);if(!flags){FS.unlink(path)}else if(flags===512){FS.rmdir(path)}else{return-28}return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var __abort_js=()=>abort("");var __emscripten_throw_longjmp=()=>{throw new EmscriptenSjLj};var INT53_MAX=9007199254740992;var INT53_MIN=-9007199254740992;var bigintToI53Checked=num=>numINT53_MAX?NaN:Number(num);function __gmtime_js(time,tmPtr){time=bigintToI53Checked(time);var date=new Date(time*1e3);HEAP32[tmPtr>>2]=date.getUTCSeconds();HEAP32[tmPtr+4>>2]=date.getUTCMinutes();HEAP32[tmPtr+8>>2]=date.getUTCHours();HEAP32[tmPtr+12>>2]=date.getUTCDate();HEAP32[tmPtr+16>>2]=date.getUTCMonth();HEAP32[tmPtr+20>>2]=date.getUTCFullYear()-1900;HEAP32[tmPtr+24>>2]=date.getUTCDay();var start=Date.UTC(date.getUTCFullYear(),0,1,0,0,0,0);var yday=(date.getTime()-start)/(1e3*60*60*24)|0;HEAP32[tmPtr+28>>2]=yday}var isLeapYear=year=>year%4===0&&(year%100!==0||year%400===0);var MONTH_DAYS_LEAP_CUMULATIVE=[0,31,60,91,121,152,182,213,244,274,305,335];var MONTH_DAYS_REGULAR_CUMULATIVE=[0,31,59,90,120,151,181,212,243,273,304,334];var ydayFromDate=date=>{var leap=isLeapYear(date.getFullYear());var monthDaysCumulative=leap?MONTH_DAYS_LEAP_CUMULATIVE:MONTH_DAYS_REGULAR_CUMULATIVE;var yday=monthDaysCumulative[date.getMonth()]+date.getDate()-1;return yday};function __localtime_js(time,tmPtr){time=bigintToI53Checked(time);var date=new Date(time*1e3);HEAP32[tmPtr>>2]=date.getSeconds();HEAP32[tmPtr+4>>2]=date.getMinutes();HEAP32[tmPtr+8>>2]=date.getHours();HEAP32[tmPtr+12>>2]=date.getDate();HEAP32[tmPtr+16>>2]=date.getMonth();HEAP32[tmPtr+20>>2]=date.getFullYear()-1900;HEAP32[tmPtr+24>>2]=date.getDay();var yday=ydayFromDate(date)|0;HEAP32[tmPtr+28>>2]=yday;HEAP32[tmPtr+36>>2]=-(date.getTimezoneOffset()*60);var start=new Date(date.getFullYear(),0,1);var summerOffset=new Date(date.getFullYear(),6,1).getTimezoneOffset();var winterOffset=start.getTimezoneOffset();var dst=(summerOffset!=winterOffset&&date.getTimezoneOffset()==Math.min(winterOffset,summerOffset))|0;HEAP32[tmPtr+32>>2]=dst}function __mmap_js(len,prot,flags,fd,offset,allocated,addr){offset=bigintToI53Checked(offset);try{var stream=SYSCALLS.getStreamFromFD(fd);var res=FS.mmap(stream,len,offset,prot,flags);var ptr=res.ptr;HEAP32[allocated>>2]=res.allocated;HEAPU32[addr>>2]=ptr;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}function __munmap_js(addr,len,prot,flags,fd,offset){offset=bigintToI53Checked(offset);try{var stream=SYSCALLS.getStreamFromFD(fd);if(prot&2){SYSCALLS.doMsync(addr,stream,len,flags,offset)}}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return-e.errno}}var __tzset_js=(timezone,daylight,std_name,dst_name)=>{var currentYear=(new Date).getFullYear();var winter=new Date(currentYear,0,1);var summer=new Date(currentYear,6,1);var winterOffset=winter.getTimezoneOffset();var summerOffset=summer.getTimezoneOffset();var stdTimezoneOffset=Math.max(winterOffset,summerOffset);HEAPU32[timezone>>2]=stdTimezoneOffset*60;HEAP32[daylight>>2]=Number(winterOffset!=summerOffset);var extractZone=timezoneOffset=>{var sign=timezoneOffset>=0?"-":"+";var absOffset=Math.abs(timezoneOffset);var hours=String(Math.floor(absOffset/60)).padStart(2,"0");var minutes=String(absOffset%60).padStart(2,"0");return`UTC${sign}${hours}${minutes}`};var winterName=extractZone(winterOffset);var summerName=extractZone(summerOffset);if(summerOffsetperformance.now();var _emscripten_date_now=()=>Date.now();var nowIsMonotonic=1;var checkWasiClock=clock_id=>clock_id>=0&&clock_id<=3;function _clock_time_get(clk_id,ignored_precision,ptime){ignored_precision=bigintToI53Checked(ignored_precision);if(!checkWasiClock(clk_id)){return 28}var now;if(clk_id===0){now=_emscripten_date_now()}else if(nowIsMonotonic){now=_emscripten_get_now()}else{return 52}var nsec=Math.round(now*1e3*1e3);HEAP64[ptime>>3]=BigInt(nsec);return 0}var getHeapMax=()=>2147483648;var _emscripten_get_heap_max=()=>getHeapMax();var growMemory=size=>{var oldHeapSize=wasmMemory.buffer.byteLength;var pages=(size-oldHeapSize+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var ENV={};var getExecutableName=()=>thisProgram||"./this.program";var getEnvStrings=()=>{if(!getEnvStrings.strings){var lang=(globalThis.navigator?.language??"C").replace("-","_")+".UTF-8";var env={USER:"web_user",LOGNAME:"web_user",PATH:"/",PWD:"/",HOME:"/home/web_user",LANG:lang,_:getExecutableName()};for(var x in ENV){if(ENV[x]===undefined)delete env[x];else env[x]=ENV[x]}var strings=[];for(var x in env){strings.push(`${x}=${env[x]}`)}getEnvStrings.strings=strings}return getEnvStrings.strings};var _environ_get=(__environ,environ_buf)=>{var bufSize=0;var envp=0;for(var string of getEnvStrings()){var ptr=environ_buf+bufSize;HEAPU32[__environ+envp>>2]=ptr;bufSize+=stringToUTF8(string,ptr,Infinity)+1;envp+=4}return 0};var _environ_sizes_get=(penviron_count,penviron_buf_size)=>{var strings=getEnvStrings();HEAPU32[penviron_count>>2]=strings.length;var bufSize=0;for(var string of strings){bufSize+=lengthBytesUTF8(string)+1}HEAPU32[penviron_buf_size>>2]=bufSize;return 0};var runtimeKeepaliveCounter=0;var keepRuntimeAlive=()=>noExitRuntime||runtimeKeepaliveCounter>0;var _proc_exit=code=>{EXITSTATUS=code;if(!keepRuntimeAlive()){Module["onExit"]?.(code);ABORT=true}quit_(code,new ExitStatus(code))};var exitJS=(status,implicit)=>{EXITSTATUS=status;_proc_exit(status)};var _exit=exitJS;function _fd_close(fd){try{var stream=SYSCALLS.getStreamFromFD(fd);FS.close(stream);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _fd_fdstat_get(fd,pbuf){try{var rightsBase=0;var rightsInheriting=0;var flags=0;{var stream=SYSCALLS.getStreamFromFD(fd);var type=stream.tty?2:FS.isDir(stream.mode)?3:FS.isLink(stream.mode)?7:4}HEAP8[pbuf]=type;HEAP16[pbuf+2>>1]=flags;HEAP64[pbuf+8>>3]=BigInt(rightsBase);HEAP64[pbuf+16>>3]=BigInt(rightsInheriting);return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var doReadv=(stream,iov,iovcnt,offset)=>{var ret=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.read(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(curr>2]=num;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}function _fd_seek(fd,offset,whence,newOffset){offset=bigintToI53Checked(offset);try{if(isNaN(offset))return 22;var stream=SYSCALLS.getStreamFromFD(fd);FS.llseek(stream,offset,whence);HEAP64[newOffset>>3]=BigInt(stream.position);if(stream.getdents&&offset===0&&whence===0)stream.getdents=null;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var doWritev=(stream,iov,iovcnt,offset)=>{var ret=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;var curr=FS.write(stream,HEAP8,ptr,len,offset);if(curr<0)return-1;ret+=curr;if(curr>2]=num;return 0}catch(e){if(typeof FS=="undefined"||!(e.name==="ErrnoError"))throw e;return e.errno}}var wasmTableMirror=[];var getWasmTableEntry=funcPtr=>{var func=wasmTableMirror[funcPtr];if(!func){wasmTableMirror[funcPtr]=func=wasmTable.get(funcPtr)}return func};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};FS.createPreloadedFile=FS_createPreloadedFile;FS.preloadFile=FS_preloadFile;FS.staticInit();{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["preloadPlugins"])preloadPlugins=Module["preloadPlugins"];if(Module["print"])out=Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].shift()()}}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;Module["setValue"]=setValue;Module["getValue"]=getValue;var _loadDocument,_pageCount,_renderPagePng,_previewRender,_previewRenderRegion,_previewRenderPaginated,_lastRegionCount,_lastRegionPtr,_lastRegionW,_lastRegionH,_lastRegionPage,_lastRegionYTop,_lastRenderPtr,_lastRenderW,_lastRenderH,_lastLayoutJson,_freeDocument,_engineBuildInfo,_free,_malloc,_emscripten_builtin_memalign,_setThrew,__emscripten_stack_restore,__emscripten_stack_alloc,_emscripten_stack_get_current,memory,__indirect_function_table,wasmMemory,wasmTable;function assignWasmExports(wasmExports){_loadDocument=Module["_loadDocument"]=wasmExports["T"];_pageCount=Module["_pageCount"]=wasmExports["U"];_renderPagePng=Module["_renderPagePng"]=wasmExports["V"];_previewRender=Module["_previewRender"]=wasmExports["W"];_previewRenderRegion=Module["_previewRenderRegion"]=wasmExports["X"];_previewRenderPaginated=Module["_previewRenderPaginated"]=wasmExports["Y"];_lastRegionCount=Module["_lastRegionCount"]=wasmExports["Z"];_lastRegionPtr=Module["_lastRegionPtr"]=wasmExports["_"];_lastRegionW=Module["_lastRegionW"]=wasmExports["$"];_lastRegionH=Module["_lastRegionH"]=wasmExports["aa"];_lastRegionPage=Module["_lastRegionPage"]=wasmExports["ba"];_lastRegionYTop=Module["_lastRegionYTop"]=wasmExports["ca"];_lastRenderPtr=Module["_lastRenderPtr"]=wasmExports["da"];_lastRenderW=Module["_lastRenderW"]=wasmExports["ea"];_lastRenderH=Module["_lastRenderH"]=wasmExports["fa"];_lastLayoutJson=Module["_lastLayoutJson"]=wasmExports["ga"];_freeDocument=Module["_freeDocument"]=wasmExports["ha"];_engineBuildInfo=Module["_engineBuildInfo"]=wasmExports["ia"];_free=Module["_free"]=wasmExports["ja"];_malloc=Module["_malloc"]=wasmExports["ka"];_emscripten_builtin_memalign=wasmExports["la"];_setThrew=wasmExports["ma"];__emscripten_stack_restore=wasmExports["na"];__emscripten_stack_alloc=wasmExports["oa"];_emscripten_stack_get_current=wasmExports["pa"];memory=wasmMemory=wasmExports["Q"];__indirect_function_table=wasmTable=wasmExports["S"]}var wasmImports={a:___cxa_throw,i:___syscall_fcntl64,J:___syscall_fstat64,x:___syscall_getdents64,N:___syscall_ioctl,G:___syscall_lstat64,H:___syscall_newfstatat,k:___syscall_openat,v:___syscall_rmdir,I:___syscall_stat64,w:___syscall_unlinkat,L:__abort_js,s:__emscripten_throw_longjmp,A:__gmtime_js,B:__localtime_js,y:__mmap_js,z:__munmap_js,M:__tzset_js,K:_clock_time_get,l:_emscripten_date_now,u:_emscripten_get_heap_max,t:_emscripten_resize_heap,E:_environ_get,F:_environ_sizes_get,O:_exit,g:_fd_close,D:_fd_fdstat_get,n:_fd_read,C:_fd_seek,m:_fd_write,d:invoke_ii,f:invoke_iii,h:invoke_iiii,j:invoke_iiiii,P:invoke_iiiiiii,o:invoke_iiiiiiiiii,p:invoke_v,c:invoke_vi,b:invoke_vii,e:invoke_viii,r:invoke_viiii,q:invoke_viiiiiiiii};function invoke_iiiii(index,a1,a2,a3,a4){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_ii(index,a1){var sp=stackSave();try{return getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_iiii(index,a1,a2,a3){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_vii(index,a1,a2){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_viiii(index,a1,a2,a3,a4){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_viiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_vi(index,a1){var sp=stackSave();try{getWasmTableEntry(index)(a1)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_v(index){var sp=stackSave();try{getWasmTableEntry(index)()}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_iii(index,a1,a2){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_viii(index,a1,a2,a3){var sp=stackSave();try{getWasmTableEntry(index)(a1,a2,a3)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_iiiiiiiiii(index,a1,a2,a3,a4,a5,a6,a7,a8,a9){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6,a7,a8,a9)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function invoke_iiiiiii(index,a1,a2,a3,a4,a5,a6){var sp=stackSave();try{return getWasmTableEntry(index)(a1,a2,a3,a4,a5,a6)}catch(e){stackRestore(sp);if(!(e instanceof EmscriptenEH))throw e;_setThrew(1,0)}}function run(){if(runDependencies>0){dependenciesFulfilled=run;return}preRun();if(runDependencies>0){dependenciesFulfilled=run;return}function doRun(){Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve?.(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}var wasmExports;wasmExports=await (createWasm());run();if(runtimeInitialized){moduleRtn=Module}else{moduleRtn=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject})} ;return moduleRtn}export default Module; diff --git a/frontend/public/pdfium-engine.wasm b/frontend/public/pdfium-engine.wasm index af5b602..2784a3b 100644 Binary files a/frontend/public/pdfium-engine.wasm and b/frontend/public/pdfium-engine.wasm differ diff --git a/frontend/src/lib/gatewayService.ts b/frontend/src/lib/gatewayService.ts index 972f8d3..49078f2 100644 --- a/frontend/src/lib/gatewayService.ts +++ b/frontend/src/lib/gatewayService.ts @@ -4,7 +4,6 @@ export interface PageInfo { height: number; } -// Thrown by uploadDocument when the PDF needs a password (missing or wrong). export class PasswordError extends Error { detail: string; constructor(detail: string) { @@ -16,7 +15,7 @@ export class PasswordError extends Error { export interface PDFPermissions { isEncrypted: boolean; - encryption: string; // "None" | "RC4-40" | "RC4-128" | "AES-128" | "AES-256" + encryption: string; securityRevision: number; ownerUnlocked: boolean; canPrint: boolean; @@ -226,8 +225,6 @@ export type EditOperationDataMap = { squiggly: DecorationData; }; -// Text decoration (underline / strikeout / squiggly) as a markup annotation, -// positioned by quadpoints over the text lines (mirrors HighlightData). export interface DecorationData { quadPoints: HighlightQuadPoint[]; color: string; @@ -235,9 +232,6 @@ export interface DecorationData { content?: string; } -// In-place rewrite of existing page text. Targets stable page-object indices -// (from getPageModel's run.object_indices) — no coordinates, so no Y-flip and no -// bbox ambiguity. The engine reflows subsequent same-line text by the width delta. export interface ReplaceTextData { objectIndices: number[]; text: string; @@ -245,15 +239,12 @@ export interface ReplaceTextData { fontSize: number; } -// Whole-paragraph re-layout (Word-style wrap + push-down). The engine line-breaks the -// styled runs within [columnLeft, columnRight], re-justifies, and shifts following -// in-column content by the line-count delta. export interface ReflowFragment { text: string; internalFontId: string; fontSize: number; color: string; - advances?: number[]; // original per-char advance of unchanged text → pixel-perfect spacing + advances?: number[]; } export interface ReflowParagraphData { @@ -265,13 +256,11 @@ export interface ReflowParagraphData { leading: number; oldLineCount: number; align: 'left' | 'justify' | 'center' | 'right'; - // Push-down column left (defaults to columnLeft). Lets a bullet item reflow its text from a - // hanging indent while the push-down still moves markers + items below by the full width. pushColumnLeft?: number; - // WYSIWYG mode: exact visual line breaks from the live editor (one inner array per line). + paraId?: string; lines?: ReflowFragment[][]; - lineBaselineY?: number[]; // original per-line baseline (parallel to `lines`) - lineX?: number[]; // original per-line left anchor (parallel to `lines`) + lineBaselineY?: number[]; + lineX?: number[]; } export interface DeleteAnnotationData { @@ -324,7 +313,6 @@ class GatewayService { public baseUrl: string; constructor() { - // In dev environment, FastAPI gateway runs on port 8000 by default. this.baseUrl = import.meta.env.VITE_GATEWAY_URL || 'http://127.0.0.1:8000'; } @@ -343,7 +331,6 @@ class GatewayService { if (!response.ok) throw new Error(`Failed to list documents: ${response.statusText}`); return response.json(); } catch (err) { - // Fallback if backend is not running at all return this.getMockDocuments(); } } @@ -360,22 +347,19 @@ class GatewayService { body: formData, }); - // 401 = the PDF needs a password (missing or wrong) — surface a typed error - // so the UI can prompt and retry. if (response.status === 401) { const body = await response.json().catch(() => ({ detail: 'Password required' })); throw new PasswordError(body.detail || 'Password required'); } if (response.status === 501) { - // Simulate upload for Phase 0 scaffolding return new Promise((resolve) => { setTimeout(() => { resolve({ id: `doc_${Math.random().toString(36).substr(2, 9)}`, filename: file.name, sizeBytes: file.size, - totalPages: 5, // Mocked total pages + totalPages: 5, pageWidth: 612, pageHeight: 792, uploadedAt: new Date().toISOString(), @@ -460,7 +444,6 @@ class GatewayService { return response.json(); } - // Raw PDF bytes of the current document version (for the in-browser WASM preview engine). async getDocumentRaw(documentId: string): Promise { try { const r = await fetch(`${this.baseUrl}/documents/${documentId}/raw`); @@ -471,9 +454,6 @@ class GatewayService { } } - // Raw embedded/substitute font bytes for an in-place-editing preview. Returns null - // when the font isn't browser-loadable (Type1/non-sfnt → 404) so callers fall back - // to a CSS font. async getFontData(documentId: string, internalFontId: string): Promise { try { const url = `${this.baseUrl}/documents/${documentId}/font?internal_font_id=${encodeURIComponent(internalFontId)}`; @@ -494,25 +474,19 @@ class GatewayService { body: JSON.stringify({ version: '1.0', operations } as EditOperationEnvelope), }); } catch { - // GENUINE network failure (server unreachable) → offline mock fallback. return { success: true, newDocumentId: `${documentId}_edited` }; } - // 501 = engine bridge not available → offline mock fallback. if (response.status === 501) { return { success: true, newDocumentId: `${documentId}_edited` }; } - // A real HTTP error (422 validation, 403 permission, 500, …) must SURFACE — previously the - // catch-all returned fake success with a phantom `${id}_edited` id, so the app navigated to a - // non-existent document (404 cascade) and the edit silently reverted. Throw the gateway's detail - // so the caller shows a clear toast and keeps the current document. if (!response.ok) { let detail = response.statusText; try { const j = await response.json(); if (j?.detail) detail = typeof j.detail === 'string' ? j.detail : JSON.stringify(j.detail); - } catch { /* non-JSON error body */ } + } catch { } throw new Error(detail); } return response.json(); @@ -521,7 +495,7 @@ class GatewayService { async searchDocument(documentId: string, query: string, caseSensitive: boolean = false, wholeWords: boolean = false): Promise { if (!query) return []; - const urlParams = new URLSearchParams({ + const urlParams = new URLSearchParams({ q: query, ...(caseSensitive && { case_sensitive: 'true' }), ...(wholeWords && { whole_words: 'true' }) @@ -529,7 +503,6 @@ class GatewayService { const response = await fetch(`${this.baseUrl}/documents/${documentId}/search?${urlParams.toString()}`); if (response.status === 501) { - // Return mock empty results if backend isn't available return []; } @@ -550,9 +523,8 @@ class GatewayService { body: JSON.stringify({ new_text: newText }), }); if (!response.ok) { - // Surface the gateway's detail (e.g. 400 encoding-reject / 403 permission) so the toast is useful. let detail = response.statusText; - try { const j = await response.json(); if (j?.detail) detail = j.detail; } catch { /* ignore */ } + try { const j = await response.json(); if (j?.detail) detail = j.detail; } catch { } throw new Error(detail); } return response.json(); @@ -563,7 +535,7 @@ class GatewayService { { id: 'sample-doc-1', filename: 'Quarterly_Financial_Report.pdf', - sizeBytes: 1024 * 1024 * 3.4, // 3.4MB + sizeBytes: 1024 * 1024 * 3.4, totalPages: 12, pageWidth: 612, pageHeight: 792, @@ -574,7 +546,7 @@ class GatewayService { { id: 'sample-doc-2', filename: 'Engineering_Specification_v4.pdf', - sizeBytes: 1024 * 1024 * 18.2, // 18.2MB + sizeBytes: 1024 * 1024 * 18.2, totalPages: 54, pageWidth: 612, pageHeight: 792, @@ -585,7 +557,7 @@ class GatewayService { { id: 'sample-doc-3', filename: 'Tenant_Lease_Agreement_Final.pdf', - sizeBytes: 1024 * 245, // 245KB + sizeBytes: 1024 * 245, totalPages: 4, pageWidth: 612, pageHeight: 792, @@ -699,7 +671,6 @@ class GatewayService { } } - /** Document outline / bookmarks (flattened with depth level). Safe-fallback to empty. */ async getOutline(documentId: string): Promise { try { const response = await fetch(`${this.baseUrl}/documents/${documentId}/outline`); diff --git a/frontend/src/lib/pdfiumEngine.ts b/frontend/src/lib/pdfiumEngine.ts index cf191a5..94f7762 100644 --- a/frontend/src/lib/pdfiumEngine.ts +++ b/frontend/src/lib/pdfiumEngine.ts @@ -1,7 +1,3 @@ -// Lazy-loaded in-browser PDFium engine (WASM). Renders pages + edit previews PIXEL-IDENTICAL -// to the gateway (same C++ engine compiled to WASM). Used only for the live-edit preview; -// the gateway stays authoritative for commit/save. Loaded on demand (the .wasm is ~6.7MB). - interface PdfiumModule { _malloc(n: number): number; _free(p: number): void; @@ -9,21 +5,18 @@ interface PdfiumModule { ccall(name: string, ret: string | null, argTypes: string[], args: unknown[]): number | string; } -// Per-character caret layout the engine emits for the reflowed paragraph (PDF coords). Lets the -// live preview place a caret EXACTLY on the rendered glyphs (same wrap + metrics as the image). -export interface ReflowLayoutLine { baselineY: number; x0: number; fontSize: number; text: string; adv: number[]; } -export interface ReflowLayout { columnLeft: number; lines: ReflowLayoutLine[]; } +export interface ReflowLayoutLine { baselineY: number; x0: number; fontSize: number; text: string; adv: number[]; pageIndex?: number; } +export interface ReflowLayout { columnLeft: number; anchorPage?: number; lines: ReflowLayoutLine[]; } export interface PreviewResult { blob: Blob | null; layout: ReflowLayout | null; } let modulePromise: Promise | null = null; -const docHandles = new Map(); // documentId -> wasm doc handle +const docHandles = new Map(); -// Load the Emscripten module once (mirrors the existing wasmLoader mechanism). function getModule(): Promise { if (!modulePromise) { modulePromise = (async () => { try { - const V = '20260617d'; + const V = '20260618c'; 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' })); @@ -51,7 +44,6 @@ export async function isReady(): Promise { return (await getModule()) !== null; } -// Load a document into the WASM engine (keyed by documentId). Idempotent per id. export async function wasmLoadDocument(documentId: string, bytes: ArrayBuffer): Promise { const M = await getModule(); if (!M) return false; @@ -74,7 +66,6 @@ export function wasmHasDocument(documentId: string): boolean { function lastRenderBlob(M: PdfiumModule, len: number): Blob | null { if (len <= 0) return null; const ptr = M.ccall('lastRenderPtr', 'number', [], []) as number; - // Copy out of the WASM heap before it can move (ALLOW_MEMORY_GROWTH). const bytes = M.HEAPU8.slice(ptr, ptr + len); return new Blob([bytes], { type: 'image/png' }); } @@ -85,7 +76,6 @@ function lastLayout(M: PdfiumModule): ReflowLayout | null { try { return JSON.parse(json) as ReflowLayout; } catch { return null; } } -// Render the page as-is (PNG blob). export async function wasmRenderPage(documentId: string, pageIndex: number, dpi: number): Promise { const M = await getModule(); if (!M) return null; @@ -95,8 +85,6 @@ export async function wasmRenderPage(documentId: string, pageIndex: number, dpi: return lastRenderBlob(M, len); } -// Render a PREVIEW of an edit (e.g. a reflow_paragraph op) without mutating the loaded doc. -// Returns the PNG blob AND the per-character caret layout the engine computed for the reflow. export async function wasmPreviewRender( documentId: string, pageIndex: number, dpi: number, editsJson: string, ): Promise { @@ -110,9 +98,6 @@ export async function wasmPreviewRender( export interface RegionResult { rgba: Uint8ClampedArray | null; width: number; height: number; layout: ReflowLayout | null; } -// Fast live-preview render: rasterizes ONLY the page band [yTopPt, bottom] (points from page top) -// and returns RAW RGBA (no PNG encode/decode) for canvas putImageData. ~8x faster than the -// full-page PNG previewRender. heightPt<=0 → to the page bottom. export async function wasmPreviewRenderRegion( documentId: string, pageIndex: number, dpi: number, editsJson: string, yTopPt: number, heightPt: number, ): Promise { @@ -127,11 +112,39 @@ export async function wasmPreviewRenderRegion( const ptr = M.ccall('lastRenderPtr', 'number', [], []) as number; const w = M.ccall('lastRenderW', 'number', [], []) as number; const ht = M.ccall('lastRenderH', 'number', [], []) as number; - // Copy out of the WASM heap (ALLOW_MEMORY_GROWTH can move it) into a clamped array for ImageData. const rgba = new Uint8ClampedArray(M.HEAPU8.subarray(ptr, ptr + len)); return { rgba, width: w, height: ht, layout: lastLayout(M) }; } +export interface PreviewRegion { rgba: Uint8ClampedArray; width: number; height: number; pageIndex: number; yTopPt: number; } +export interface PaginatedResult { regions: PreviewRegion[]; layout: ReflowLayout | null; } + +export async function wasmPreviewRenderPaginated( + documentId: string, pageIndex: number, dpi: number, editsJson: string, yTopPt: number, maxFollow = 4, +): Promise { + const M = await getModule(); + if (!M) return { regions: [], layout: null }; + const h = docHandles.get(documentId); + if (h === undefined) return { regions: [], layout: null }; + const count = M.ccall('previewRenderPaginated', 'number', + ['number', 'number', 'number', 'string', 'number', 'number'], + [h, pageIndex, dpi, editsJson, yTopPt, maxFollow]) as number; + const layout = lastLayout(M); + if (count <= 0) return { regions: [], layout }; + const regions: PreviewRegion[] = []; + for (let i = 0; i < count; i++) { + const w = M.ccall('lastRegionW', 'number', ['number'], [i]) as number; + const ht = M.ccall('lastRegionH', 'number', ['number'], [i]) as number; + const pg = M.ccall('lastRegionPage', 'number', ['number'], [i]) as number; + const yt = M.ccall('lastRegionYTop', 'number', ['number'], [i]) as number; + const ptr = M.ccall('lastRegionPtr', 'number', ['number'], [i]) as number; + if (w <= 0 || ht <= 0 || ptr === 0) continue; + const rgba = new Uint8ClampedArray(M.HEAPU8.subarray(ptr, ptr + w * ht * 4)); + regions.push({ rgba, width: w, height: ht, pageIndex: pg, yTopPt: yt }); + } + return { regions, layout }; +} + export async function wasmFreeDocument(documentId: string): Promise { const h = docHandles.get(documentId); if (h === undefined) return; diff --git a/frontend/src/viewer/PDFViewer.tsx b/frontend/src/viewer/PDFViewer.tsx index 3e008cd..b8b8d20 100644 --- a/frontend/src/viewer/PDFViewer.tsx +++ b/frontend/src/viewer/PDFViewer.tsx @@ -6,6 +6,7 @@ import type { Annotation } from './AnnotationLayer'; import { OverlayLayer } from './OverlayLayer'; import { TextEditLayer } from './TextEditLayer'; import type { EditableRun, ReflowParagraphPayload, CommitFrame } from './TextEditLayer'; +import type { OverflowPreviewRegion, OverflowCaret } from './ParagraphEditor'; import { SearchOverlayLayer } from './SearchOverlayLayer'; import type { Rect } from '../lib/coordinateMapping'; import { gatewayService } from '../lib/gatewayService'; @@ -95,25 +96,14 @@ export const PDFViewer = React.forwardRef(({ const [renderedPages, setRenderedPages] = useState([]); const [containerHeight, setContainerHeight] = useState(800); const [pageTexts, setPageTexts] = useState>({}); - // Tracks which document version the cached page images belong to. Each edit creates a - // new documentId; we keep the OLD images visible and re-render in the background (below), - // swapping page-by-page when ready — so the viewer never blanks/flashes on an edit and - // the annotation overlay never unmounts. const renderedDocIdRef = useRef(''); - // Previous documentId so we can free its WASM copy when a new edit mints a new id. Each edit - // creates a new documentId and the live-preview engine loads a FULL doc copy per id; without this - // they accumulate in the WASM heap for the whole session (never freed) → unbounded growth over a - // demo. The preview re-loads on demand, so freeing the superseded version only reclaims memory. const prevDocumentIdRef = useRef(documentId); useEffect(() => { - // Text/model caches ARE document-specific and cheap to refetch — reset them. - // renderedPages is intentionally NOT cleared here (see renderedDocIdRef above). setPageTexts({}); const prev = prevDocumentIdRef.current; if (prev && prev !== documentId) wasmFreeDocument(prev); prevDocumentIdRef.current = documentId; }, [documentId]); - // Free the active document's WASM copy when the viewer unmounts. useEffect(() => () => { wasmFreeDocument(prevDocumentIdRef.current); }, []); const [bridgeFrame, setBridgeFrame] = useState<(CommitFrame & { docId: string }) | null>(null); @@ -127,6 +117,10 @@ export const PDFViewer = React.forwardRef(({ if (b && b.pageIndex === pageIndex && b.docId !== documentIdRef.current) setBridgeFrame(null); }, []); useEffect(() => { setBridgeFrame(null); }, [zoom]); + + const [overflowPreviews, setOverflowPreviews] = useState([]); + const [overflowCaret, setOverflowCaret] = useState(null); + useEffect(() => { setOverflowPreviews([]); setOverflowCaret(null); }, [zoom, documentId]); useEffect(() => { if (!bridgeFrame) return; const t = window.setTimeout(() => setBridgeFrame(null), 6000); @@ -136,7 +130,7 @@ export const PDFViewer = React.forwardRef(({ const basePageWidth = pageWidth || 612; const basePageHeight = pageHeight || 792; - const pageGap = 24; + const pageGap = 24; const pageLayouts = useMemo(() => { const layouts: PageLayout[] = []; @@ -226,12 +220,10 @@ export const PDFViewer = React.forwardRef(({ return { visiblePages: visible, primaryVisiblePage: currentVisiblePageIdx }; }, [pageLayouts, scrollPosition.scrollTop, containerHeight]); - // Notify the parent of the primary on-screen page AFTER render (not during the memo). useEffect(() => { onPageVisible?.(primaryVisiblePage); }, [primaryVisiblePage, onPageVisible]); - // Load the rendered SVG/Image URL for each visible page useEffect(() => { let active = true; const docChanged = renderedDocIdRef.current !== documentId; @@ -247,7 +239,7 @@ export const PDFViewer = React.forwardRef(({ documentId, pageIndex: page.index, zoom, - rotation: 0, // already rotated physically on backend + rotation: 0, }); return { index: page.index, url }; }) @@ -271,7 +263,6 @@ export const PDFViewer = React.forwardRef(({ }; }, [visiblePages, documentId, zoom, renderedPages]); - // Fetch real glyph bounds for visible pages while text tools are active. const textToolActive = activeTool === 'select' || activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly'; useEffect(() => { if (!textToolActive || !documentId) return; @@ -396,19 +387,16 @@ export const PDFViewer = React.forwardRef(({ > {imageUrl ? ( <> - {/* Base PDF Canvas Layer */} - {/* Commit bridge: hold the live-preview frame over the edited band until the new - render lands, so the page never reverts to the original in the gap. */} {bridgeFrame && bridgeFrame.pageIndex === page.index && ( (({ /> )} - {/* Highlight/Comment Annotation Layer */} + {overflowPreviews + .filter((r) => r.pageIndex === page.index) + .map((r, k) => ( + + ))} + + {overflowCaret && overflowCaret.pageIndex === page.index && ( + <> + +
+ + )} + (({ onFieldChange={onFieldChange} /> - {/* Text Selection Dragging Layer */} {(activeTool === 'select' || activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly') && ( (({ /> )} - {/* Redaction Area Selection Layer */} {activeTool === 'redact' && ( (({ /> )} - {/* ink / comment / text-box / stamp / signature overlay tool layer */} (({ onPlaceSignature={onPlaceSignature} /> - {/* Inline editor for existing page text (true content editing) */} {activeTool === 'edit_text' && ( (({ onEditText={onEditText} onReflowParagraph={onReflowParagraph} onCommitPreview={handleCommitPreview} + onOverflowPreview={setOverflowPreviews} + onOverflowCaret={setOverflowCaret} /> )} @@ -496,13 +507,11 @@ export const PDFViewer = React.forwardRef(({ zoom={zoom} onDocumentChanged={onStreamDocumentChanged} onEditSuccess={() => { - // Invalidate the rendered page cache to force a refresh setRenderedPages((prev) => { const next = [...prev]; next[page.index] = ''; return next; }); - // Clear text cache too setPageTexts((prev) => { const next = { ...prev }; delete next[page.index]; @@ -512,7 +521,6 @@ export const PDFViewer = React.forwardRef(({ /> )} - {/* Search highlights overlay */} ; anchorX: n const { ri, ci, ox } = seq[k]; perRun[ri][ci] = k + 1 < seq.length ? seq[k + 1].ox - ox : (line.x + line.w) - ox; } - // Drop runs with a non-positive advance (out-of-order glyphs) so the engine measures them. for (const k of Object.keys(perRun)) { const ri = Number(k); if (perRun[ri].some((a) => a <= 0)) delete perRun[ri]; @@ -53,7 +55,9 @@ interface ParagraphEditorProps { pageWidthPx: number; pageHeightPx: number; onCommit: (payload: ReflowParagraphPayload) => void; - onCommitPreview?: (frame: CommitFrame) => void; // hand the final preview frame up to bridge the commit gap + onCommitPreview?: (frame: CommitFrame) => void; + onOverflowPreview?: (regions: OverflowPreviewRegion[]) => void; + onOverflowCaret?: (caret: OverflowCaret | null) => void; onCancel: () => void; } @@ -86,7 +90,7 @@ function computeLayout(para: any): ParagraphLayout { const prev = seedRuns[seedRuns.length - 1].text; if (prev && !/\s$/.test(prev) && !/^\s/.test(text)) text = ' ' + text; } - const adv = perRun[ri]; // present only for glyph-aligned (unchanged-able) runs + const adv = perRun[ri]; seedRuns.push({ text, fid: r.internal_font_id ?? '', size: r.font_size ?? 12, color: typeof r.color === 'string' ? r.color : '#000000', fontName: r.font_name ?? '', advances: adv }); if (orig) lineFrags.push({ text: orig, fid: r.internal_font_id ?? '', size: r.font_size ?? 12, color: typeof r.color === 'string' ? r.color : '#000000', advances: adv }); } @@ -106,24 +110,12 @@ function computeLayout(para: any): ParagraphLayout { return { columnLeft, columnRight, firstBaselineY, leading, oldLineCount: lines.length, align, objectIndices, seedRuns, origLines }; } -// Resolve the styled span that owns a text node. Editing a contentEditable can split/merge text -// nodes or drop a node bare directly under the editor (no [data-fid] wrapper); the OLD code then -// fell back to `dominantFid` for that text — re-tagging e.g. a bullet's body with the bold LABEL -// font, so the whole run changed font on edit. Here we: (1) climb to the nearest ancestor span that -// carries data-fid (the exact wrapper when typing inside a run — the common case), then (2) for a -// bare node, INHERIT from the nearest styled sibling (preceding first, then following) so typed text -// continues the adjacent run's real font instead of the dominant one. `root` (the editor) carries -// data-fid too, so it's explicitly excluded from the ancestor climb. function resolveStyleEl(node: Text, root: HTMLElement): HTMLElement | null { - // 1. Nearest ancestor span (below root) carrying data-fid — the exact wrapper when typing inside a - // run (the common case). let el: HTMLElement | null = node.parentElement; while (el && el !== root) { if (el.hasAttribute('data-fid')) return el; el = el.parentElement; } - // The FIRST / LAST data-fid element within a sibling's subtree (or the sibling itself). For a - // preceding sibling we want the LAST (rightmost = nearest to us); for a following one, the FIRST. const firstStyled = (n: Node): HTMLElement | null => { if (n.nodeType !== 1) return null; const e = n as HTMLElement; @@ -137,11 +129,6 @@ function resolveStyleEl(node: Text, root: HTMLElement): HTMLElement | null { const all = e.querySelectorAll?.('[data-fid]'); return all && all.length ? (all[all.length - 1] as HTMLElement) : null; }; - // 2. Bare / browser-wrapped node: CLIMB toward root and at each level scan siblings (nearest - // preceding first, then following) for a styled run. Climbing is essential because typing at a run - // boundary often lands the new text in a browser-created wrapper whose only child is that text — - // a sibling-only search there finds nothing and we'd wrongly fall back to dominantFid (the bullet's - // bold label). Climbing reaches the real run spans alongside the wrapper. let cur: Node | null = node; while (cur && cur !== root) { for (let s = cur.previousSibling; s; s = s.previousSibling) { const r = lastStyled(s); if (r) return r; } @@ -163,17 +150,12 @@ function extractFlatRuns(editable: HTMLElement, dominantFid: string, domSize: nu const text = node.textContent ?? ''; if (text) { const frag: ReflowFragment = { text, internalFontId: fid, fontSize: size, color }; - // Carry the source advances ONLY if this text node still exactly matches its seed span - // (unchanged): data-advances aligns 1:1 with the original chars, so a length match means - // the user hasn't edited it. Read them ONLY from the EXACT wrapping span (node.parentElement), - // never an inherited sibling — an inherited advances array of coincidentally-equal length would - // mis-space the text. Edited text drops them and the engine re-measures. const aRaw = node.parentElement === styleEl ? styleEl?.getAttribute('data-advances') : null; if (aRaw) { try { const a = JSON.parse(aRaw) as number[]; if (Array.isArray(a) && a.length === text.length) frag.advances = a; - } catch { /* ignore malformed */ } + } catch { } } out.push(frag); } @@ -182,7 +164,6 @@ function extractFlatRuns(editable: HTMLElement, dominantFid: string, domSize: nu return out; } -// Global character offset of the DOM caret within the editable. function globalCaretOffset(el: HTMLElement): number { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return 0; @@ -194,7 +175,6 @@ function globalCaretOffset(el: HTMLElement): number { return pre.toString().length; } -// Move the DOM caret to a global character offset (so typing inserts at the right place). function setGlobalCaretOffset(el: HTMLElement, target: number): void { const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); let acc = 0; @@ -217,8 +197,6 @@ function setGlobalCaretOffset(el: HTMLElement, target: number): void { sel?.removeAllRanges(); sel?.addRange(range); } -// Global char index where each engine line starts in the full text (continuation lines skip the -// whitespace the engine consumed at the wrap point). function lineStarts(layout: ReflowLayout, fullText: string): number[] { const starts: number[] = []; let pos = 0; @@ -232,21 +210,27 @@ function lineStarts(layout: ReflowLayout, fullText: string): number[] { export const ParagraphEditor: React.FC = ({ documentId, pageIndex, para, pushColumnLeft, leadingOverride, alignOverride, columnLeftOverride, columnRightOverride, - caretClick, heightPts, zoom, pageWidthPx, pageHeightPx, onCommit, onCommitPreview, onCancel, + caretClick, heightPts, zoom, pageWidthPx, pageHeightPx, onCommit, onCommitPreview, onOverflowPreview, onOverflowCaret, onCancel, }) => { const layout = useMemo(() => computeLayout(para), [para]); const leading = leadingOverride ?? layout.leading; const align = alignOverride ?? layout.align; - // Wrap/commit against the true column bounds when provided (bullet items, centered/right headings), - // else the inferred ones. columnLeft override matters for center/right alignment so the engine - // centers/right-aligns within the real page content box, not the text's own extent. const columnLeft = columnLeftOverride ?? layout.columnLeft; const columnRight = columnRightOverride ?? layout.columnRight; + const paraIdRef = useRef(''); + if (!paraIdRef.current) { + let existing = ''; + for (const ln of (para?.lines ?? [])) { + for (const r of (ln.runs ?? [])) if (r?.para_id) { existing = r.para_id; break; } + if (existing) break; + } + paraIdRef.current = existing + || `p-${globalThis.crypto?.randomUUID?.() ?? (Math.random().toString(36).slice(2) + Date.now().toString(36))}`; + } const editRef = useRef(null); const previewCanvasRef = useRef(null); const committedRef = useRef(false); const initialTextRef = useRef(''); - // rAF coalescing: at most one render per frame on the LATEST input, and never two in flight. const rafRef = useRef(null); const renderingRef = useRef(false); const renderDirtyRef = useRef(false); @@ -271,12 +255,8 @@ export const ParagraphEditor: React.FC = ({ const colWidthPx = (columnRight - columnLeft) * zoom; const firstBaselineScreen = (heightPts - layout.firstBaselineY) * zoom; const editorTop = firstBaselineScreen - (leadingPx + fontPx * 0.7) / 2; - const bandTop = Math.max(0, editorTop - leadingPx * 0.5); + const bandTop = Math.max(0, Math.min(editorTop - leadingPx * 0.5, firstBaselineScreen - fontPx * 1.15)); - // Single source of truth for the reflow op `data` payload. BOTH the live preview (buildOpJson → - // WASM) and the final commit (→ gateway) build from this, so the committed render is guaranteed to - // match the last preview frame (no preview-OK/commit-wrong drift). origLines is preview-only (exact - // source layout while unedited); commit never passes it. const buildReflowData = (runs: ReflowFragment[], origLines?: OrigLine[]) => { const linesData = origLines && origLines.length ? { lines: origLines.map((l) => l.frags.map((f) => ({ @@ -294,6 +274,7 @@ export const ParagraphEditor: React.FC = ({ pushColumnLeft: pushColumnLeft ?? columnLeft, firstBaselineY: layout.firstBaselineY, leading, oldLineCount: layout.oldLineCount, align, + paraId: paraIdRef.current, }; }; @@ -315,10 +296,12 @@ export const ParagraphEditor: React.FC = ({ let x = line.x0; for (let k = 0; k < offset; k++) x += line.adv[k] ?? 0; const fpx = line.fontSize * zoom; - return { left: x * zoom, top: (heightPts - line.baselineY) * zoom - fpx * 0.82, height: fpx * 1.04 }; + return { + left: x * zoom, top: (heightPts - line.baselineY) * zoom - fpx * 0.82, height: fpx * 1.04, + pageIndex: line.pageIndex ?? pageIndex, + }; }; - // Map a screen click to a global char offset via the engine layout (line by baseline, char by adv). const globalFromPoint = (clientX: number, clientY: number, lay: ReflowLayout, fullText: string) => { const el = editRef.current; const container = el?.offsetParent as HTMLElement | null; @@ -346,7 +329,21 @@ export const ParagraphEditor: React.FC = ({ const positionCaret = () => { const el = editRef.current, lay = engineLayoutRef.current; if (!el || !lay) return; - setCaretBox(caretBoxFor(globalCaretOffset(el), lay, el.textContent ?? '')); + const box = caretBoxFor(globalCaretOffset(el), lay, el.textContent ?? ''); + if (box && box.pageIndex !== pageIndex) { + setCaretBox(null); + onOverflowCaret?.({ pageIndex: box.pageIndex, left: box.left, top: box.top, height: box.height }); + } else { + setCaretBox(box); + onOverflowCaret?.(null); + } + }; + + const rgbaToDataUrl = (rgba: Uint8ClampedArray, w: number, h: number): string => { + const c = document.createElement('canvas'); c.width = w; c.height = h; + const ctx = c.getContext('2d'); if (!ctx) return ''; + const img = ctx.createImageData(w, h); img.data.set(rgba); ctx.putImageData(img, 0, 0); + return c.toDataURL('image/png'); }; const renderPreview = async () => { @@ -356,9 +353,12 @@ export const ParagraphEditor: React.FC = ({ const origLines = editedRef.current ? undefined : layout.origLines; const opJson = buildOpJson(extractFlatRuns(el, dominantFid, domSize, domColor), origLines); const yTopPt = bandTop / zoom; - const { rgba, width, height, layout: lay } = await wasmPreviewRenderRegion(documentId, pageIndex, dpi, opJson, yTopPt, 0); - if (!rgba || width <= 0 || height <= 0) { return; } + const { regions, layout: lay } = await wasmPreviewRenderPaginated(documentId, pageIndex, dpi, opJson, yTopPt); + if (regions.length === 0) { return; } engineLayoutRef.current = lay; + const r0 = regions[0]; + const { rgba, width, height } = r0; + if (!rgba || width <= 0 || height <= 0) { return; } const cv = previewCanvasRef.current; if (cv) { if (cv.width !== width) cv.width = width; @@ -370,8 +370,10 @@ export const ParagraphEditor: React.FC = ({ ctx.putImageData(img, 0, 0); } } + onOverflowPreview?.(regions.slice(1).map((rg) => ({ + pageIndex: rg.pageIndex, yTopPt: rg.yTopPt, dataUrl: rgbaToDataUrl(rg.rgba, rg.width, rg.height), + }))); if (!hasPreview) setHasPreview(true); - // On the very first render, place the caret where the user clicked (mapped via engine layout). if (!initialCaretApplied.current) { initialCaretApplied.current = true; if (caretClick && lay) setGlobalCaretOffset(el, globalFromPoint(caretClick.x, caretClick.y, lay, el.textContent ?? '')); @@ -387,13 +389,12 @@ export const ParagraphEditor: React.FC = ({ renderingRef.current = true; do { renderDirtyRef.current = false; - try { await renderPreview(); } catch { /* keep editor responsive */ } + try { await renderPreview(); } catch { } } while (renderDirtyRef.current); renderingRef.current = false; }); }; - // Load the document into the WASM engine, then do the initial render. useEffect(() => { let cancelled = false; (async () => { @@ -411,7 +412,8 @@ export const ParagraphEditor: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [documentId]); - // Seed the contentEditable ONCE (one span per run; spaces/empty-fid adopt the dominant font). + useEffect(() => () => { onOverflowPreview?.([]); onOverflowCaret?.(null); }, []); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { const el = editRef.current; if (!el) return; @@ -424,8 +426,6 @@ export const ParagraphEditor: React.FC = ({ span.setAttribute('data-size', String(effSize)); span.setAttribute('data-color', r.color); span.setAttribute('data-fontname', r.fontName); - // Source advances (aligned to this span's exact chars). extractFlatRuns only re-uses them - // while the span text is unchanged (length match), so edits cleanly fall back to measuring. if (r.advances && r.advances.length === r.text.length) span.setAttribute('data-advances', JSON.stringify(r.advances)); span.style.fontSize = `${effSize * zoom}px`; span.style.color = 'transparent'; @@ -451,18 +451,13 @@ export const ParagraphEditor: React.FC = ({ return () => window.clearTimeout(t); }, [hasPreview]); - // IME composition (CJK/accents): the browser fires `input` for every intermediate composition - // keystroke, but the text isn't final until `compositionend`. Re-extracting/re-rendering mid- - // composition rewrites the DOM under the IME and aborts it. So we suppress rendering while - // composing and do a single render when composition ends. const composingRef = useRef(false); - // Re-render every frame on the latest keystroke (rAF-coalesced, no fixed debounce) for real-time feel. const onInput = () => { - editedRef.current = true; // now the engine may re-wrap (the user is actually editing) - if (!edited) setEdited(true); // swap from the untouched original page to the reflow render - if (composingRef.current) return; // defer to compositionend (don't disturb the IME) - positionCaret(); // immediate (approximate, from the prior layout) for responsiveness + editedRef.current = true; + if (!edited) setEdited(true); + if (composingRef.current) return; + positionCaret(); scheduleRender(); }; const onCompositionStart = () => { composingRef.current = true; }; @@ -498,27 +493,23 @@ export const ParagraphEditor: React.FC = ({ dataUrl: cv.toDataURL('image/png'), left: 0, top: bandTop, width: pageWidthPx, height: Math.max(0, pageHeightPx - bandTop), }); - } catch { /* tainted/0-size canvas → skip the bridge (worst case = today's flash) */ } + } catch { } } - // Commit with FLAT runs (no lines) via the SAME builder the live preview used, so the gateway - // wraps identically to the last preview frame (single source of truth — see buildReflowData). + onOverflowPreview?.([]); + onOverflowCaret?.(null); onCommit(buildReflowData(flat) as ReflowParagraphPayload); }; - const cancel = () => { committedRef.current = true; onCancel(); }; + const cancel = () => { committedRef.current = true; onOverflowPreview?.([]); onOverflowCaret?.(null); onCancel(); }; return ( <> - {/* White cover ONLY in the fallback case (WASM failed). */} {fallbackVisible && (
)} - {/* Reflow render — the engine rasterizes ONLY the band [bandTop..bottom] as raw RGBA and we - blit it here via canvas (no PNG round-trip). Mounted always so the ref exists for the first - render; shown ONLY after the user edits (before that the untouched page below shows through). */} = ({ }} /> - {/* Custom caret, positioned from the ENGINE's glyph layout so it lands exactly on the - rendered text (the browser's own layout is never used for positioning). */} {!fallbackVisible && caretBox && (
= ({ /> )} - {/* Transparent input layer: holds the text + receives keys; its OWN layout is ignored. */}
= ({ onClick={onClickEditor} onKeyUp={positionCaret} onKeyDown={(e) => { - // Don't treat Enter/Escape as commit/cancel while an IME composition is active — that - // Enter is confirming the composition, not finishing the edit (isComposing covers it). if (e.nativeEvent.isComposing || composingRef.current) return; if (e.key === 'Enter') { e.preventDefault(); commit(); } if (e.key === 'Escape') { e.preventDefault(); cancel(); } diff --git a/frontend/src/viewer/TextEditLayer.tsx b/frontend/src/viewer/TextEditLayer.tsx index 8258e72..a9560a2 100644 --- a/frontend/src/viewer/TextEditLayer.tsx +++ b/frontend/src/viewer/TextEditLayer.tsx @@ -4,9 +4,6 @@ import { loadPdfFont, releaseDocumentFonts } from '../lib/fontFaceLoader'; import { ParagraphEditor } from './ParagraphEditor'; import type { ReflowAlign } from './ParagraphEditor'; -// A single editable run. Geometry (x/y/w/h, baselineY) is PDF BOTTOM-LEFT (as -// getPageModel returns) and is used only to position the inline editor. The -// replace_text op itself targets the stable objectIndices, not coordinates. export interface EditableRun { text: string; x: number; @@ -19,14 +16,11 @@ export interface EditableRun { internalFontId: string; fontName: string; color: string; - // Position of this run within the raw page model, so a commit can reconstruct its paragraph. paraIndex: number; lineIndex: number; runIndex: number; } -// A whole-paragraph re-layout request (Word-style wrap + push-down), built on commit when the -// edited run's paragraph spans multiple lines. The engine owns the actual line-breaking. export interface ReflowFragment { text: string; internalFontId: string; @@ -37,8 +31,8 @@ export interface ReflowFragment { export interface CommitFrame { pageIndex: number; - dataUrl: string; // the preview canvas as an image - left: number; top: number; width: number; height: number; // screen px within the page container + dataUrl: string; + left: number; top: number; width: number; height: number; } export interface ReflowParagraphPayload { @@ -50,47 +44,28 @@ export interface ReflowParagraphPayload { leading: number; oldLineCount: number; align: ReflowAlign; - pushColumnLeft?: number; // full-width push-down left for bullet items (see engine) - // WYSIWYG: exact visual line breaks from the live editor (one inner array per line). + pushColumnLeft?: number; + paraId?: string; lines?: ReflowFragment[][]; - // Original per-line baseline + left anchor (parallel to `lines`), for exact vertical/left reproduction. lineBaselineY?: number[]; lineX?: number[]; } -// A bullet marker glyph at the start of a list item (•, -, ▪, etc.). export function isBulletMarker(text?: string): boolean { if (!text) return false; const t = text.trim(); return t.length <= 2 && /^[•▪◦‣·●○■□*‒–—\-]$/.test(t); } -// One logical item to reflow inside a non-flowing paragraph (a single bullet + its wrapped lines, -// 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; columnRight: number } | null { const lines = para?.lines ?? []; if (!lines.length) return null; const colLeft = Math.min(...lines.map((l: any) => l.x)); - // 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; @@ -100,7 +75,6 @@ export function buildBulletItem(para: any, runLineIndex: number): { subPara: any 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(start)) start--; let end = runLineIndex + 1; @@ -108,12 +82,6 @@ export function buildBulletItem(para: any, runLineIndex: number): { subPara: any const itemLines = lines.slice(start, end); if (!itemLines.length) return null; - // Leading for a NEWLY-WRAPPED line must be the WRAP spacing — the gap between a bullet line and its - // own hanging continuation — NOT the bullet-to-bullet spacing, which is often larger (list items - // carry extra spacing). The paragraph-wide median is dominated by the bullet gaps and over-spaces a - // wrapped continuation (e.g. 14.2pt bullet gap vs 12.2pt wrap). Prefer: (1) the edited item's own - // internal line spacing if it already wraps, (2) any wrap spacing elsewhere in the block (a line - // hanging at the text indent, i.e. not flush-left), (3) the all-lines median, (4) font*1.2. const hasBl = (i: number) => typeof lines[i].baseline_y === 'number'; const dlt = (i: number) => Math.abs(lines[i].baseline_y - lines[i + 1].baseline_y); const itemDeltas: number[] = []; @@ -123,15 +91,13 @@ export function buildBulletItem(para: any, runLineIndex: number): { subPara: any for (let i = 0; i + 1 < lines.length; i++) { if (!hasBl(i) || !hasBl(i + 1)) continue; allDeltas.push(dlt(i)); - if (!flushLeft(lines[i + 1])) wrapDeltas.push(dlt(i)); // line i+1 is a hanging continuation + if (!flushLeft(lines[i + 1])) wrapDeltas.push(dlt(i)); } const leading = itemDeltas.length ? median(itemDeltas) : wrapDeltas.length ? median(wrapDeltas) : allDeltas.length ? median(allDeltas) : (itemLines[0].runs?.[0]?.font_size ?? 12) * 1.2; - // Strip a leading bullet marker (+ any leading space) from the first line; the text indent is - // where the real text begins (which the wrapped lines already hang to). const firstRuns = itemLines[0].runs ?? []; let subLines = itemLines; if (isBulletMarker(firstRuns[0]?.text)) { @@ -147,9 +113,6 @@ export function buildBulletItem(para: any, runLineIndex: number): { subPara: any return { subPara: { ...para, lines: subLines }, pushColumnLeft: colLeft, leading, columnRight: colRight }; } -// The page's text right margin: the widest line across all paragraphs. A single-line block (e.g. a -// heading) has no inherent column width of its own, so we let it reflow up to this — the same edge -// the body text reaches — instead of its own short right edge. function pageContentRight(model: any): number { let right = -Infinity; for (const p of model?.paragraphs ?? []) { @@ -170,10 +133,6 @@ function pageContentLeft(model: any): number { return isFinite(left) ? left : 0; } -// Infer a single heading line's alignment from its position within the page's content box. Returns -// 'center'/'right' only when clearly so; otherwise 'left' (so the existing left-aligned heading path -// is unchanged). Lets a centered title (e.g. "Software Engineer | 3.2 Years Experience") stay -// centered as it grows, instead of growing rightward from a fixed left. function headingAlign(line: any, model: any): ReflowAlign { const pl = pageContentLeft(model), pr = pageContentRight(model); const w = pr - pl; @@ -186,10 +145,6 @@ function headingAlign(line: any, model: any): ReflowAlign { return 'left'; } -// True only for a genuine FLOWING paragraph — multiple lines that fill the column from a common -// left edge (e.g. the Professional Summary). Bullet lists / structured blocks get grouped into a -// single "paragraph" by the model too, but they must NOT be reflowed (it would merge the bullets -// into one justified blob and mangle markers like • and –). Those fall back to per-line editing. function isFlowingParagraph(para: any): boolean { const lines = para?.lines ?? []; if (lines.length < 2) return false; @@ -198,13 +153,11 @@ function isFlowingParagraph(para: any): boolean { const colRight = Math.max(...lines.map((l: any) => l.x + l.w)); const colW = colRight - colLeft; if (colW <= 0) return false; - // Most non-last lines must reach near the right edge (a filled column, not ragged list items). let reaching = 0; for (let i = 0; i < lines.length - 1; i++) { if (lines[i].x + lines[i].w >= colRight - colW * 0.06) reaching++; } const filled = reaching >= (lines.length - 1) * 0.7; - // And every line must start at (roughly) the same left edge — bullets have hanging indents. const consistentLeft = Math.max(...lefts.map((x: number) => Math.abs(x - colLeft))) < colW * 0.12; return filled && consistentLeft; } @@ -212,17 +165,18 @@ function isFlowingParagraph(para: any): boolean { interface TextEditLayerProps { documentId: string; pageIndex: number; - width: number; // page width in ZOOMED px (= widthPts * zoom) - height: number; // page height in ZOOMED px (= heightPts * zoom) + totalPages: number; + width: number; + height: number; zoom: number; - pageImageUrl?: string; // rendered page image, for the live push-down preview + pageImageUrl?: string; onEditText?: (pageIndex: number, run: EditableRun, newText: string) => void; onReflowParagraph?: (pageIndex: number, payload: ReflowParagraphPayload) => void; - onCommitPreview?: (frame: CommitFrame) => void; // final preview frame at commit (bridges the render gap) + onOverflowPreview?: (regions: import('./ParagraphEditor').OverflowPreviewRegion[]) => void; + onOverflowCaret?: (caret: import('./ParagraphEditor').OverflowCaret | null) => void; + onCommitPreview?: (frame: CommitFrame) => void; } -// Nearest character index to an x-offset (px from the run's left edge), measured in the -// run's font — so a click drops the caret where you clicked, not at the end of the line. let measureCanvas: HTMLCanvasElement | null = null; function caretIndexFromX(text: string, cssFont: string, x: number): number { if (x <= 0) return 0; @@ -233,13 +187,12 @@ function caretIndexFromX(text: string, cssFont: string, x: number): number { let acc = 0; for (let i = 0; i < text.length; i++) { const w = ctx.measureText(text[i]).width; - if (acc + w / 2 >= x) return i; // click past the char's midpoint → caret after it + if (acc + w / 2 >= x) return i; acc += w; } return text.length; } -// Closest base-14 CSS family, used until (or instead of) the real PDF font loads. function fallbackFamily(fontName: string): string { const n = (fontName || '').toLowerCase(); if (n.includes('times') || (n.includes('serif') && !n.includes('sans'))) { @@ -251,8 +204,6 @@ function fallbackFamily(fontName: string): string { return 'Arial, "Helvetica Neue", Helvetica, sans-serif'; } -// Flatten the dynamic page-model JSON into a flat list of editable runs, tagging each with its -// (paragraph, line, run) position in the raw model so a commit can reconstruct the paragraph. function flattenRuns(model: any): EditableRun[] { const runs: EditableRun[] = []; const paragraphs = model?.paragraphs ?? []; @@ -265,7 +216,6 @@ function flattenRuns(model: any): EditableRun[] { for (let ri = 0; ri < lineRuns.length; ri++) { const r = lineRuns[ri]; const objectIndices: number[] = Array.isArray(r.object_indices) ? r.object_indices : []; - // Only runs backed by real page objects are editable (replace_text targets them). if (typeof r.text === 'string' && r.text.trim() && r.w > 0 && r.h > 0 && objectIndices.length > 0) { runs.push({ text: r.text, @@ -285,15 +235,12 @@ function flattenRuns(model: any): EditableRun[] { return runs; } -// Median of an array (used for line leading). function median(xs: number[]): number { if (xs.length === 0) return 0; const s = [...xs].sort((a, b) => a - b); return s[Math.floor(s.length / 2)]; } -// Reconstruct the edited run's paragraph as a reflow request: collect every run in reading -// order (substituting the edited run's new text), infer column bounds / leading / alignment. function buildReflowPayload(model: any, run: EditableRun, newText: string): ReflowParagraphPayload | null { const para = model?.paragraphs?.[run.paraIndex]; if (!para || !Array.isArray(para.lines)) return null; @@ -317,8 +264,6 @@ function buildReflowPayload(model: any, run: EditableRun, newText: string): Refl objectIndices.push(...oi); const isEdited = li === run.lineIndex && ri === run.runIndex; let text = isEdited ? newText : (r.text ?? ''); - // A model line break carries no space character (the gap was positional), so insert - // one at line boundaries — otherwise "high-performance" + "backend" merge on reflow. if (li > 0 && ri === 0 && runs.length > 0) { const prev = runs[runs.length - 1].text; if (prev && !/\s$/.test(prev) && !/^\s/.test(text)) text = ' ' + text; @@ -333,12 +278,10 @@ function buildReflowPayload(model: any, run: EditableRun, newText: string): Refl } if (runs.length === 0 || objectIndices.length === 0) return null; - // Leading = median baseline-to-baseline gap (bottom-left → positive going down). const deltas: number[] = []; for (let i = 0; i < baselines.length - 1; i++) deltas.push(baselines[i] - baselines[i + 1]); const leading = deltas.length ? Math.abs(median(deltas)) : run.fontSize * 1.2; - // Alignment: if most non-last lines reach the right margin, it's justified. const colW = columnRight - columnLeft; let align: 'left' | 'justify' = 'left'; if (para.lines.length >= 2) { @@ -349,41 +292,42 @@ function buildReflowPayload(model: any, run: EditableRun, newText: string): Refl if (reaching >= (para.lines.length - 1) * 0.7) align = 'justify'; } + let paraId = ''; + for (const ln of para.lines ?? []) { for (const r of ln.runs ?? []) if (r?.para_id) { paraId = r.para_id; break; } if (paraId) break; } return { objectIndices, runs, columnLeft, columnRight, firstBaselineY, leading, oldLineCount: para.lines.length, align, + ...(paraId ? { paraId } : {}), }; } export const TextEditLayer: React.FC = ({ documentId, pageIndex, + totalPages, width, height, zoom, onEditText, onReflowParagraph, onCommitPreview, + onOverflowPreview, + onOverflowCaret, }) => { const [runs, setRuns] = useState([]); const [editing, setEditing] = useState(null); const [value, setValue] = useState(''); - // Resolved CSS font-family chain for the editor (fallback first, real font swapped in). const [fontFamily, setFontFamily] = useState('inherit'); const committedRef = useRef(false); const inputRef = useRef(null); const caretIdxRef = useRef(null); const modelRef = useRef(null); - // 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?: ReflowAlign; columnLeft?: number; columnRight?: number; + anchorPageIndex?: 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). const [caretClick, setCaretClick] = useState<{ x: number; y: number } | null>(null); - // Fetch the page model once per document/page. useEffect(() => { let cancelled = false; setEditing(null); @@ -405,10 +349,8 @@ export const TextEditLayer: React.FC = ({ return () => releaseDocumentFonts(documentId); }, [documentId]); - // Page height in points (props are zoomed px) — needed for the bottom-left→top-left flip. const heightPts = height / zoom; - // Display rect (top-left, zoomed px) for a run's glyph bbox. const rectOf = (r: EditableRun) => ({ left: r.x * zoom, top: (heightPts - (r.y + r.h)) * zoom, @@ -416,48 +358,88 @@ export const TextEditLayer: React.FC = ({ height: r.h * zoom, }); + const getParaId = (para: any): string => { + for (const ln of para?.lines ?? []) for (const r of ln.runs ?? []) if (r?.para_id) return r.para_id as string; + return ''; + }; + + const stitchByParaId = async (pid: string): Promise<{ para: any; anchorPageIndex: number } | null> => { + const pieces: { page: number; lines: any[] }[] = []; + for (let pg = 0; pg < totalPages; pg++) { + const model = pg === pageIndex + ? modelRef.current + : await gatewayService.getPageModel(documentId, pg).catch(() => null); + if (!model) continue; + const lines: any[] = []; + for (const p of model.paragraphs ?? []) + for (const ln of p.lines ?? []) + if ((ln.runs ?? []).some((r: any) => r?.para_id === pid)) lines.push(ln); + if (lines.length) pieces.push({ page: pg, lines }); + } + if (pieces.length === 0) return null; + pieces.sort((a, b) => a.page - b.page); + const aLines = pieces[0].lines; + const colLeft = Math.min(...aLines.map((l: any) => l.x)); + const firstBaseline = Math.max(...aLines.map((l: any) => (typeof l.baseline_y === 'number' ? l.baseline_y : -Infinity))); + const ubl = [...new Set(aLines.map((l: any) => Math.round((l.baseline_y ?? 0) * 10) / 10))].sort((a, b) => b - a); + const leading = ubl.length > 1 ? Math.abs(ubl[0] - ubl[1]) : 14; + const combined: any[] = [...aLines]; + let idx = aLines.length; + for (let pi = 1; pi < pieces.length; pi++) { + for (const ln of pieces[pi].lines) { + combined.push({ + ...ln, x: colLeft, baseline_y: firstBaseline - idx * leading, + runs: (ln.runs ?? []).map((r: any) => ({ ...r, object_indices: [] })), + }); + idx++; + } + } + return { para: { lines: combined }, anchorPageIndex: pieces[0].page }; + }; + + const openParaEdit = async (edit: { + para: any; pushColumnLeft?: number; leading?: number; align?: ReflowAlign; columnLeft?: number; columnRight?: number; + }) => { + const pid = getParaId(edit.para); + if (pid) { + try { + const stitched = await stitchByParaId(pid); + if (stitched) { setParaEdit({ ...edit, para: stitched.para, anchorPageIndex: stitched.anchorPageIndex }); return; } + } catch { } + } + setParaEdit({ ...edit, anchorPageIndex: pageIndex }); + }; + const openEditor = (i: number, clickX: number, clientX?: number, clientY?: number) => { const run = runs[i]; - // Multi-line content → live reflow editor. A FLOWING paragraph reflows whole; a bullet/list - // reflows just the clicked ITEM (marker kept in place, hanging indent preserved). const para = modelRef.current?.paragraphs?.[run.paraIndex]; if (Array.isArray(para?.lines) && para.lines.length >= 1 && onReflowParagraph) { const click = clientX != null && clientY != null ? { x: clientX, y: clientY } : null; if (para.lines.length > 1) { if (isFlowingParagraph(para)) { setCaretClick(click); - setParaEdit({ para }); + openParaEdit({ para }); return; } const item = buildBulletItem(para, run.lineIndex); if (item) { setCaretClick(click); - setParaEdit({ para: item.subPara, pushColumnLeft: item.pushColumnLeft, leading: item.leading, align: 'left', columnRight: item.columnRight }); + openParaEdit({ 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 } else { - // SINGLE line: if it's made of more than one text run/object (e.g. the "Technical"+"Skills" - // heading), edit the WHOLE line as a unit via reflow. A per-run replace_text would edit only - // the clicked run and orphan its siblings (they'd overlap the new text). A genuine single-run - // line stays on the precise in-place editor below. const editableRuns = (para.lines[0].runs ?? []).filter((r: any) => (r.text ?? '').trim()).length; if (editableRuns > 1) { - // A single line's own right edge is just where its short text ends; reflowing within that - // would wrap immediately as the user types. Use the page's true text margin so a heading - // can grow across the full width (and only wrap when it genuinely needs to). setCaretClick(click); const al = headingAlign(para.lines[0], modelRef.current); if (al === 'center' || al === 'right') { - // Centered/right headings: align within the full page content box so the text re-centers - // (or stays flush-right) as it grows, instead of growing rightward from a fixed left. - setParaEdit({ + openParaEdit({ para, align: al, columnLeft: pageContentLeft(modelRef.current), columnRight: pageContentRight(modelRef.current), }); } else { - setParaEdit({ para, columnRight: pageContentRight(modelRef.current) }); + openParaEdit({ para, columnRight: pageContentRight(modelRef.current) }); } return; } @@ -465,8 +447,6 @@ export const TextEditLayer: React.FC = ({ } committedRef.current = false; const fb = fallbackFamily(run.fontName); - // Caret lands at the clicked character (measured in the fallback font — close enough - // to pick the index; it stays correct after the real font swaps in). caretIdxRef.current = caretIndexFromX(run.text, `${run.fontSize * zoom}px ${fb}`, clickX); setEditing(i); setValue(run.text); @@ -484,7 +464,6 @@ export const TextEditLayer: React.FC = ({ const next = value; if (next !== run.text) { - // Width guard: warn if new text is > 120% original width const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); let originalWidth = run.w * zoom; @@ -501,7 +480,7 @@ export const TextEditLayer: React.FC = ({ if (newWidth > originalWidth * 1.2) { if (!window.confirm('Warning: The new text is significantly wider (> 120%) than the original. This might cause overlap or layout issues. Continue anyway?')) { - return; // keep editing open + return; } } } @@ -510,9 +489,6 @@ export const TextEditLayer: React.FC = ({ setEditing(null); if (next === run.text) return; - // FLOWING multi-line paragraph → full reflow (re-wrap + push-down). Single lines, headings, - // labels, and bullet/list items → the precise, low-risk replace_text path (no re-wrapping, - // so list structure and markers stay intact). const para = modelRef.current?.paragraphs?.[run.paraIndex]; if (onReflowParagraph && isFlowingParagraph(para)) { const payload = buildReflowPayload(modelRef.current, run, next); @@ -530,11 +506,10 @@ export const TextEditLayer: React.FC = ({ return (
- {/* Live reflow editor (wraps + pushes down as you type) — whole paragraph or one bullet item. */} {paraEdit !== null && ( = ({ pageWidthPx={width} pageHeightPx={height} onCommitPreview={onCommitPreview} - onCommit={(payload) => { setParaEdit(null); onReflowParagraph?.(pageIndex, payload); }} + onOverflowPreview={onOverflowPreview} + onOverflowCaret={onOverflowCaret} + onCommit={(payload) => { const ap = paraEdit.anchorPageIndex ?? pageIndex; setParaEdit(null); onReflowParagraph?.(ap, payload); }} onCancel={() => setParaEdit(null)} /> )} - {/* Per-run hit targets (visible hint on hover). */} {editing === null && paraEdit === null && runs.map((r, i) => { const box = rectOf(r); @@ -566,13 +542,10 @@ export const TextEditLayer: React.FC = ({ ); })} - {/* Seamless in-place editor: a white cover hides the original glyphs, and a - chrome-less single-line input — in the document's real font, exact color, - size, and baseline — sits over them with a caret in the text. */} {run && (() => { const fpx = run.fontSize * zoom; const baselineScreen = (heightPts - run.baselineY) * zoom; - const inputTop = baselineScreen - 0.8 * fpx; // place text baseline on the PDF baseline + const inputTop = baselineScreen - 0.8 * fpx; const box = rectOf(run); return ( <> @@ -587,7 +560,6 @@ export const TextEditLayer: React.FC = ({ value={value} onChange={(e) => setValue(e.target.value)} onFocus={(e) => { - // Drop the caret where the user clicked (set in openEditor), not at the end. const idx = caretIdxRef.current; if (idx != null) { e.currentTarget.setSelectionRange(idx, idx); diff --git a/gateway/app/routers/edits.py b/gateway/app/routers/edits.py index 4a82ffd..7f23e42 100644 --- a/gateway/app/routers/edits.py +++ b/gateway/app/routers/edits.py @@ -228,8 +228,6 @@ class ReflowRun(BaseModel): internalFontId: str fontSize: float color: str = "#000000" - # Original per-character advance (PDF units) of UNCHANGED source text, for pixel-perfect - # reflow spacing. Omitted for edited/new runs (engine re-measures those). advances: list[float] | None = None @@ -243,9 +241,8 @@ class ReflowParagraphData(BaseModel): oldLineCount: int = 1 align: Literal["left", "justify", "center", "right"] = "left" pushColumnLeft: float | None = None + paraId: str | None = None lines: list[list[ReflowRun]] | None = None - # Original per-line baseline + left anchor (parallel to `lines`), so unchanged lines reproduce - # the source's exact vertical spacing and left edge instead of a uniform fallback. lineBaselineY: list[float] | None = None lineX: list[float] | None = None @@ -308,9 +305,6 @@ class EditsRequest(BaseModel): version: Literal["1.0"] operations: list[EditOperation] - -# Which PDF permission each edit operation requires. Unencrypted / owner-unlocked -# docs report every flag True (in the engine), so this never blocks them. _OP_PERMISSION = { "highlight": "canAnnotate", "underline": "canAnnotate", "strikeout": "canAnnotate", "squiggly": "canAnnotate", "comment": "canAnnotate", "freehand": "canAnnotate", @@ -357,10 +351,8 @@ def apply_edits_impl(document_id: str, request: EditsRequest): import os import tempfile - # pyrefly: ignore [missing-import] from PIL import Image - # Remove data URI header if present if "," in img_data_str: img_data_str = img_data_str.split(",", 1)[1] @@ -368,13 +360,11 @@ def apply_edits_impl(document_id: str, request: EditsRequest): img = Image.open(io.BytesIO(raw_bytes)) img_rgba = img.convert("RGBA") - # Convert RGBA to BGRA r, g, b, a = img_rgba.split() img_bgra = Image.merge("RGBA", (b, g, r, a)) bgra_bytes = img_bgra.tobytes() - # Create a temporary binary file to hold raw pixel data fd, temp_path = tempfile.mkstemp(suffix=".bin", prefix="pdf_pixel_") created_temp_files.append(temp_path) try: @@ -388,7 +378,6 @@ def apply_edits_impl(document_id: str, request: EditsRequest): op["data"]["pixelWidth"] = img.width op["data"]["pixelHeight"] = img.height - # Delete base64 strings to keep JSON payload tiny if "imageData" in op["data"]: del op["data"]["imageData"] @@ -396,16 +385,12 @@ def apply_edits_impl(document_id: str, request: EditsRequest): doc_copy = pdfengine.PdfDocument.load_from_memory(doc_info["bytes_data"]) doc_copy.apply_edits(edits_json) - # Redaction and in-place text rewrites mutate existing objects, which do - # not round-trip cleanly through an incremental save — force a full save. full_save_types = {"redaction", "replace_text", "reflow_paragraph"} needs_full = any(op.get("type") in full_save_types for op in req_dict.get("operations", [])) new_bytes = doc_copy.save_full() if needs_full else doc_copy.save_incremental() new_doc = pdfengine.PdfDocument.load_from_memory(new_bytes) - # Carry the original permissions forward — the saved bytes are decrypted, so - # a fresh load would report full access and defeat enforcement. new_info = document_store.add_document( filename=doc_info["filename"], bytes_data=new_bytes, doc_instance=new_doc, permissions=doc_info.get("permissions"), diff --git a/tests/edits/_hyphen_check.py b/tests/edits/_hyphen_check.py new file mode 100644 index 0000000..8edb91f --- /dev/null +++ b/tests/edits/_hyphen_check.py @@ -0,0 +1,74 @@ +"""Does cross-page MIGRATION preserve hyphens/dashes? Grow the page-0 Technologies paragraph so it +pushes the 'Architected ... high-scale e-commerce' paragraph onto page 2 (migration), then check the +migrated text still has its hyphens. + +Run: gateway/.venv/Scripts/python.exe tests/edits/_hyphen_check.py +""" +from __future__ import annotations +import json, sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, r"C:\Users\furqa\pdfeng-build\win-local-pdfium\lib") +sys.path.insert(1, str(ROOT / "gateway")) +import pdfengine +PDF = ROOT / "Mo-Faishal-Qureshi.pdf" + + +def ptext(p): + return "".join(r.text for ln in p.lines for r in ln.runs) + + +def find(doc, page, needle): + for p in doc.get_page(page).extract_document_model().paragraphs: + if needle in ptext(p): + return p + return None + + +def alltext(doc): + out = [] + for i in range(doc.page_count): + for p in doc.get_page(i).extract_document_model().paragraphs: + out.append(ptext(p)) + return " ".join(out) + + +def main(): + doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "") + before = alltext(doc) + print("BEFORE migration:") + print(" 'high' + U+002D + 'scale' :", ("high-scale" in before)) + print(" 'high' + U+2011 + 'scale' :", ("high‑scale" in before)) + print(" 'e' + U+002D + 'commerce' :", ("e-commerce" in before)) + print(" 'e' + U+2011 + 'commerce' :", ("e‑commerce" in before)) + + tech = find(doc, 0, "Technologies") + oi = [i for ln in tech.lines for r in ln.runs for i in r.object_indices] + cl = min(ln.x for ln in tech.lines); cr = max(ln.x + ln.w for ln in tech.lines) + fb = max(ln.baseline_y for ln in tech.lines) + bls = sorted({round(ln.baseline_y, 1) for ln in tech.lines}, reverse=True) + lead = abs(bls[0]-bls[1]) if len(bls) > 1 else 14.0 + big = ptext(tech).strip() + " " + ("filler " * 60) + op = {"version": "1.0", "operations": [{"id": "g", "type": "reflow_paragraph", "pageIndex": 0, "data": { + "objectIndices": oi, "runs": [{"text": big, "internalFontId": tech.lines[0].runs[0].internal_font_id, + "fontSize": 11.0, "color": "#000000"}], "columnLeft": cl, "columnRight": cr, "firstBaselineY": fb, + "leading": lead, "oldLineCount": len(tech.lines), "align": "left", "paraId": "TECHPARA"}}]} + doc.apply_edits(json.dumps(op)) + r = pdfengine.PdfDocument.load_from_memory(doc.save_full(), "") + after = alltext(r) + print(f"\nAFTER migration (pages={r.page_count}):") + print(" 'high' + U+002D + 'scale' :", ("high-scale" in after)) + print(" 'high' + U+2011 + 'scale' :", ("high‑scale" in after)) + print(" 'e' + U+002D + 'commerce' :", ("e-commerce" in after)) + print(" 'e' + U+2011 + 'commerce' :", ("e‑commerce" in after)) + for i in range(r.page_count): + a = find(r, i, "Architected") + if a: + safe = ptext(a)[:120].encode("ascii", "replace").decode("ascii") + print(f"\n Architected (page {i}): {safe!r}") + break + + +if __name__ == "__main__": + main() diff --git a/tests/edits/_overflow_diag.py b/tests/edits/_overflow_diag.py new file mode 100644 index 0000000..b87e7a4 --- /dev/null +++ b/tests/edits/_overflow_diag.py @@ -0,0 +1,62 @@ +"""Diagnose early-overflow: edit the real Technologies paragraph with a long single-run text and +report where each resulting line lands (page + baseline) vs the page-bottom limit. + +Run: gateway/.venv/Scripts/python.exe tests/edits/_overflow_diag.py +""" +from __future__ import annotations +import json, sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, r"C:\Users\furqa\pdfeng-build\win-local-pdfium\lib") +sys.path.insert(1, str(ROOT / "gateway")) +import pdfengine +PDF = ROOT / "Mo-Faishal-Qureshi.pdf" + + +def ptext(p): + return "".join(r.text for ln in p.lines for r in ln.runs) + + +def main(): + doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "") + m = doc.get_page(0).extract_document_model() + tech = next(p for p in m.paragraphs if "Technologies" in ptext(p)) + oi = [i for ln in tech.lines for r in ln.runs for i in r.object_indices] + cl = min(ln.x for ln in tech.lines); cr = max(ln.x + ln.w for ln in tech.lines) + fb = max(ln.baseline_y for ln in tech.lines) + bls = sorted({round(ln.baseline_y, 1) for ln in tech.lines}, reverse=True) + lead = abs(bls[0]-bls[1]) if len(bls) > 1 else 14.0 + page_h = doc.get_page(0).height + tops = [] + for p in m.paragraphs: + for ln in p.lines: + if (ln.x + ln.w) > cl and ln.x < cr: + tops.append(ln.y + ln.h) + max_top = max(tops) if tops else 0 + mirror = page_h - max_top + bottom_limit = max(18.0, min(mirror, page_h * 0.25)) + print(f"page_h={page_h:.0f} tech firstBaselineY={fb:.1f} leading={lead:.1f} cols=[{cl:.0f},{cr:.0f}]") + print(f"max_top(in-col)={max_top:.1f} mirror={mirror:.1f} bottomLimitY(approx)={bottom_limit:.1f}") + room_lines = int((fb - bottom_limit) / lead) + print(f"=> room for ~{room_lines} lines on page 0 before hitting the bottom limit") + + new_text = ptext(tech).strip() + " " + " ".join(f"w{n}" for n in range(40)) + op = {"version": "1.0", "operations": [{"id": "g", "type": "reflow_paragraph", "pageIndex": 0, "data": { + "objectIndices": oi, "runs": [{"text": new_text, "internalFontId": tech.lines[0].runs[0].internal_font_id, + "fontSize": 11.0, "color": "#000000"}], "columnLeft": cl, "columnRight": cr, "firstBaselineY": fb, + "leading": lead, "oldLineCount": len(tech.lines), "align": "left", "paraId": "DIAG"}}]} + doc.apply_edits(json.dumps(op)) + r = pdfengine.PdfDocument.load_from_memory(doc.save_full(), "") + print(f"\nAFTER edit: pages={r.page_count}") + for i in range(r.page_count): + mm = r.get_page(i).extract_document_model() + diag_lines = [ln for p in mm.paragraphs for ln in p.lines + if any(getattr(rr, "para_id", "") == "DIAG" for rr in ln.runs)] + ys = sorted((ln.baseline_y for ln in diag_lines), reverse=True) + print(f" page {i}: {len(diag_lines)} DIAG line(s) baselineY range " + f"{ys[0]:.0f}..{ys[-1]:.0f}" if ys else f" page {i}: 0 DIAG lines") + + +if __name__ == "__main__": + main() diff --git a/tests/edits/_paraid_probe.py b/tests/edits/_paraid_probe.py new file mode 100644 index 0000000..9aead0f --- /dev/null +++ b/tests/edits/_paraid_probe.py @@ -0,0 +1,95 @@ +"""Stage 1 verification: PDFPARA object marks survive (a) save/reload and (b) cross-page migration. + +Run: gateway/.venv/Scripts/python.exe tests/edits/_paraid_probe.py +""" +from __future__ import annotations +import json, sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, r"C:\Users\furqa\pdfeng-build\win-local-pdfium\lib") +sys.path.insert(1, str(ROOT / "gateway")) +import pdfengine + + +def build_pdf() -> bytes: + content = ( + b"BT /F1 14 Tf 72 720 Td (HEADING) Tj ET\n" + b"BT /F1 11 Tf 72 150 Td (The quick brown fox jumps over the lazy) Tj ET\n" + b"BT /F1 11 Tf 72 136 Td (dog near the river bank on a sunny) Tj ET\n" + b"BT /F1 11 Tf 72 122 Td (afternoon in early spring.) Tj ET\n" + ) + objs = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>", + b"<< /Length %d >>\nstream\n" % len(content) + content + b"endstream", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ] + pdf = b"%PDF-1.7\n"; offs = [] + for i, o in enumerate(objs, 1): + offs.append(len(pdf)); pdf += b"%d 0 obj\n" % i + o + b"\nendobj\n" + xref = len(pdf); pdf += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1) + for o in offs: pdf += b"%010d 00000 n \n" % o + pdf += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref) + return pdf + + +def body_para(doc, page=0): + m = doc.get_page(page).extract_document_model() + return next(p for p in m.paragraphs + if "quick brown fox" in " ".join("".join(r.text for r in ln.runs) for ln in p.lines)) + + +def reflow(doc, page, para, text, para_id): + oi = [i for ln in para.lines for r in ln.runs for i in r.object_indices] + cl = min(ln.x for ln in para.lines); cr = max(ln.x + ln.w for ln in para.lines) + fb = max(ln.baseline_y for ln in para.lines) + bls = sorted({round(ln.baseline_y, 1) for ln in para.lines}, reverse=True) + lead = abs(bls[0]-bls[1]) if len(bls) > 1 else 14.0 + op = {"version": "1.0", "operations": [{"id": "p", "type": "reflow_paragraph", "pageIndex": page, "data": { + "objectIndices": oi, "runs": [{"text": text, "internalFontId": para.lines[0].runs[0].internal_font_id, + "fontSize": 11.0, "color": "#000000"}], "columnLeft": cl, "columnRight": cr, "firstBaselineY": fb, + "leading": lead, "oldLineCount": len(para.lines), "align": "left", "paraId": para_id}}]} + doc.apply_edits(json.dumps(op)) + return pdfengine.PdfDocument.load_from_memory(doc.save_full(), "") + + +def all_para_ids(doc): + ids = {} + for i in range(doc.page_count): + m = doc.get_page(i).extract_document_model() + for p in m.paragraphs: + for ln in p.lines: + for r in ln.runs: + if getattr(r, "para_id", ""): + ids.setdefault(r.para_id, []).append(i) + return ids + + +def main() -> int: + ok = True + doc = pdfengine.PdfDocument.load_from_memory(build_pdf(), "") + doc = reflow(doc, 0, body_para(doc), "Short edited text here.", "PARA_TEST_42") + ids = all_para_ids(doc) + if "PARA_TEST_42" in ids: + print(f" ok mark survived save/reload: PARA_TEST_42 on pages {sorted(set(ids['PARA_TEST_42']))}") + else: + print(f" FAIL no paraId after save/reload. ids={ids}"); ok = False + + doc2 = pdfengine.PdfDocument.load_from_memory(build_pdf(), "") + big = "Edited overflow text " + " ".join(f"m{n:03d}" for n in range(80)) + doc2 = reflow(doc2, 0, body_para(doc2), big, "SPAN_ID_7") + ids2 = all_para_ids(doc2) + pages = sorted(set(ids2.get("SPAN_ID_7", []))) + if len(pages) >= 2: + print(f" ok mark survived cross-page migration: SPAN_ID_7 on pages {pages}") + else: + print(f" FAIL paraId not on both pages after overflow. pages={pages} all={ids2}"); ok = False + + print("\nPASS" if ok else "\nFAIL") + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/edits/_reflow_repro.out.txt b/tests/edits/_reflow_repro.out.txt index ebf96d8..35b5189 100644 --- a/tests/edits/_reflow_repro.out.txt +++ b/tests/edits/_reflow_repro.out.txt @@ -1,17 +1,41 @@ -== GREEDY path (re-wrap from scratch; the 'while typing' path) == -overlay [0] anchor='Java Backend Developer' edit=no-op path=greedy dpi=150 - diff pixels = 35821 / 2103750 (1.703%) bbox=(112, 263, 1150, 1435) -overlay [0] anchor='Core Java' edit=no-op path=greedy dpi=150 - diff pixels = 311942 / 2103750 (14.828%) bbox=(109, 291, 1166, 1489) -overlay [1] anchor='Architected and' edit=no-op path=greedy dpi=150 - diff pixels = 315253 / 2103750 (14.985%) bbox=(109, 117, 1166, 1299) +PDF: Mo-Faishal-Qureshi.pdf pages=2 -== LINES path (original breaks emitted verbatim; the 'on open' path) == -overlay [0] anchor='Java Backend Developer' edit=no-op path=lines dpi=150 - diff pixels = 35441 / 2103750 (1.685%) bbox=(112, 263, 1150, 1435) -overlay [0] anchor='Core Java' edit=no-op path=lines dpi=150 - diff pixels = 46851 / 2103750 (2.227%) bbox=(138, 291, 1129, 1435) -overlay [1] anchor='Architected and' edit=no-op path=lines dpi=150 - diff pixels = 60236 / 2103750 (2.863%) bbox=(112, 117, 1156, 1004) +=== PAGE 0 (612x792) paragraphs=10 === + [0.0] lines=1 fonts=['Calibri-Bold_TrueType_32'] + text='MO FAISHAL QURESHI' + [0.1] lines=1 fonts=['Calibri-Bold_TrueType_32'] + text='Software Engineer | 3.2 Years Experience' + [0.2] lines=1 fonts=['Calibri-Bold_TrueType_32'] + text='Professional Summary' + [0.3] lines=5 fonts=['Calibri_TrueType_32'] + text='Java Backend Developer with 3.2 years of experience in designing, developing, and deployin' + [0.4] lines=1 fonts=['Calibri-Bold_TrueType_32'] + text='Technical Skills' + [0.5] lines=11 fonts=['BCDGEE+TimesNewRomanPSMT', 'Calibri-Bold_TrueType_32', 'Calibri_TrueType_32'] + text='• Languages: Core Java, Java 8+, OOPs, Collections, Streams, Lambda • ' + [0.6] lines=1 fonts=['Calibri-Bold_TrueType_32'] + text='Professional Experience' + [0.7] lines=19 fonts=['BCDGEE+TimesNewRomanPSMT', 'BCDJEE+Calibri-Italic', 'BCDKEE+Calibri', 'Calibri-Bold_TrueType_32', 'Calibri-Italic_TrueType_96', 'Calibri_TrueType_32'] + text='Software Engineer | TapQwik Software Pvt. Ltd. Mar 2023 – Present | ' + [0.8] lines=1 fonts=['Calibri-Bold_TrueType_32'] + text='Projects' + [0.9] lines=3 fonts=['BCDLEE+Calibri-Bold', 'Calibri-Bold_TrueType_32', 'Calibri_TrueType_32'] + text='Project: LG – E-Commerce Platform Technologies: Java 8/17, Spring Boot, ' -(noise floor, same pdf twice = 0 px) +=== PAGE 1 (612x792) paragraphs=8 === + [1.0] lines=13 fonts=['BCDGEE+TimesNewRomanPSMT', 'BCDKEE+Calibri', 'Calibri-Bold_TrueType_32', 'Calibri_TrueType_32'] + text='Architected and developed a high-scale e-commerce backend for LG Electro' + [1.1] lines=16 fonts=['BCDGEE+TimesNewRomanPSMT', 'BCDKEE+Calibri', 'BCDLEE+Calibri-Bold', 'Calibri-Bold_TrueType_32', 'Calibri_TrueType_32'] + text='Project: Wego – Hotel Booking Platform Technologies: Java 8/17, Spring B' + [1.2] lines=1 fonts=['Calibri-Bold_TrueType_32'] + text='Education' + [1.3] lines=2 fonts=['Calibri-Bold_TrueType_32', 'Calibri-Italic_TrueType_96', 'Calibri_TrueType_32'] + text='Bachelor of Computer Applications 2023 HKBK Degree College | Bangalore, ' + [1.4] lines=1 fonts=['Calibri-Bold_TrueType_32'] + text='Courses & Certifications' + [1.5] lines=3 fonts=['BCDGEE+TimesNewRomanPSMT', 'BCDKEE+Calibri', 'Calibri_TrueType_32'] + text='• Java Development Certification Course • Apache Kafka for Java Develope' + [1.6] lines=1 fonts=['Calibri-Bold_TrueType_32'] + text='Soft Skills' + [1.7] lines=2 fonts=['BCDKEE+Calibri', 'Calibri_TrueType_32'] + text='Problem-Solving • Team Collaboration • Adaptability • Ownership & Accoun' diff --git a/tests/edits/_xpage_real.py b/tests/edits/_xpage_real.py new file mode 100644 index 0000000..0e09249 --- /dev/null +++ b/tests/edits/_xpage_real.py @@ -0,0 +1,82 @@ +"""Cross-page reflow on the REAL resume (embedded Calibri/Times subset fonts + cascade). +Verifies that migrating EMBEDDED subset-font text objects across pages keeps glyphs intact. + +Run: gateway/.venv/Scripts/python.exe tests/edits/_xpage_real.py +""" +from __future__ import annotations +import io, json, sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "gateway")) +import pdfengine + +PDF = ROOT / "Mo-Faishal-Qureshi.pdf" + + +def ptext(p): + return " ".join("".join(r.text for r in ln.runs) for ln in p.lines) + + +def page_text(doc, i): + m = doc.get_page(i).extract_document_model() + return " ".join(ptext(p) for p in m.paragraphs) + + +def main(): + doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "") + print(f"loaded: pages={doc.page_count}") + m0 = doc.get_page(0).extract_document_model() + body = [p for p in m0.paragraphs if len(p.lines) >= 1] + para = min(body, key=lambda p: min(ln.baseline_y for ln in p.lines)) + print(f"editing page-0 paragraph (lowest): {ptext(para)[:60]!r} lines={len(para.lines)}") + + obj_idxs = [oi for ln in para.lines for r in ln.runs for oi in r.object_indices] + col_left = min(ln.x for ln in para.lines) + col_right = max(ln.x + ln.w for ln in para.lines) + first_baseline = max(ln.baseline_y for ln in para.lines) + bls = sorted({round(ln.baseline_y, 1) for ln in para.lines}, reverse=True) + leading = abs(bls[0] - bls[1]) if len(bls) > 1 else 14.0 + fid = para.lines[0].runs[0].internal_font_id + + marker = " ".join(f"X{n:03d}" for n in range(60)) + new_text = ptext(para).strip() + " " + marker + " ZZEND" + + op = {"version": "1.0", "operations": [{ + "id": "rg", "type": "reflow_paragraph", "pageIndex": 0, "data": { + "objectIndices": obj_idxs, + "runs": [{"text": new_text, "internalFontId": fid, "fontSize": 11.0, "color": "#000000"}], + "columnLeft": col_left, "columnRight": col_right, "firstBaselineY": first_baseline, + "leading": leading, "oldLineCount": len(para.lines), "align": "left"}}]} + doc.apply_edits(json.dumps(op)) + out = doc.save_full() + r = pdfengine.PdfDocument.load_from_memory(out, "") + print(f"after grow: pages={r.page_count}") + + for i in range(r.page_count): + t = page_text(r, i) + has_marker = any(f"X{n:03d}" in t for n in range(60)) + print(f" page {i}: ZZEND={'ZZEND' in t} markers={has_marker} len={len(t)}") + allt = " ".join(page_text(r, i) for i in range(r.page_count)) + print("Architected preserved:", "Architected" in allt) + print("Education preserved:", "Education" in allt) + nospace = allt.replace(" ", "") + miss_join = [f"X{n:03d}" for n in range(60) if f"X{n:03d}" not in allt] + miss_nospace = [f"X{n:03d}" for n in range(60) if f"X{n:03d}" not in nospace] + print(f"markers intact (space-join): {60-len(miss_join)}/60 missing: {miss_join}") + print(f"markers intact (no-space): {60-len(miss_nospace)}/60 missing: {miss_nospace}") + p0 = page_text(r, 0); p1 = page_text(r, 1) + print("\n--- page0 tail ---\n", p0[-220:]) + print("\n--- page1 head ---\n", p1[:220]) + orig = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "") + orig_p1 = page_text(orig, 1) + for needle in ["Architected", "Education", "Soft", "Problem-Solving", "Ownership", "Certifications", "Bachelor"]: + print(f" page1-orig token {needle!r}: present_before={needle in orig_p1} present_after={needle in allt}") + for i in range(r.page_count): + mm = r.get_page(i).extract_document_model() + ys = [ln.baseline_y for p in mm.paragraphs for ln in p.lines] + print(f" page {i}: lowest_baseline_y={min(ys):.1f} (page height 792; <0 means off-page)") + + +if __name__ == "__main__": + main() diff --git a/tests/edits/_xpage_reedit.py b/tests/edits/_xpage_reedit.py new file mode 100644 index 0000000..d21c9c2 --- /dev/null +++ b/tests/edits/_xpage_reedit.py @@ -0,0 +1,76 @@ +"""Reproduce the user's bug: grow a page-0 paragraph so it overflows to page 1, save, THEN edit a +paragraph that now lives on page 1 -> is the result scrambled (leftover glyphs / wrong fonts)? + +Run: gateway/.venv/Scripts/python.exe tests/edits/_xpage_reedit.py +""" +from __future__ import annotations +import json, sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "gateway")) +import pdfengine +PDF = ROOT / "Mo-Faishal-Qureshi.pdf" + + +def ptext(p): + return "".join(r.text for ln in p.lines for r in ln.runs) + + +def find_para(doc, page, needle): + m = doc.get_page(page).extract_document_model() + for p in m.paragraphs: + if needle in ptext(p): + return p + return None + + +def reflow_para(doc, page, para, new_text, align="left"): + obj_idxs = [oi for ln in para.lines for r in ln.runs for oi in r.object_indices] + col_left = min(ln.x for ln in para.lines) + col_right = max(ln.x + ln.w for ln in para.lines) + first_baseline = max(ln.baseline_y for ln in para.lines) + bls = sorted({round(ln.baseline_y, 1) for ln in para.lines}, reverse=True) + leading = abs(bls[0] - bls[1]) if len(bls) > 1 else 14.0 + fid = para.lines[0].runs[0].internal_font_id + op = {"version": "1.0", "operations": [{ + "id": "e", "type": "reflow_paragraph", "pageIndex": page, "data": { + "objectIndices": obj_idxs, + "runs": [{"text": new_text, "internalFontId": fid, "fontSize": 11.0, "color": "#000000"}], + "columnLeft": col_left, "columnRight": col_right, "firstBaselineY": first_baseline, + "leading": leading, "oldLineCount": len(para.lines), "align": align}}]} + doc.apply_edits(json.dumps(op)) + return pdfengine.PdfDocument.load_from_memory(doc.save_full(), "") + + +def main(): + doc = pdfengine.PdfDocument.load_from_memory(PDF.read_bytes(), "") + tech = find_para(doc, 0, "Technologies") + print("step1: growing page-0 para:", ptext(tech)[:50], "...") + big = ptext(tech).strip() + " " + ("g" * 30 + " ") * 25 + doc = reflow_para(doc, 0, tech, big) + print(" pages after grow:", doc.page_count) + + arch = find_para(doc, 1, "Architected") + if not arch: + print(" !! 'Architected' not found on page 1; pages dump:") + for i in range(doc.page_count): + print(f" page {i}:", " | ".join(ptext(p)[:30] for p in doc.get_page(i).extract_document_model().paragraphs)[:200]) + return + print("step2: editing page-1 para:", ptext(arch)[:50], "...") + before = ptext(arch) + doc2 = reflow_para(doc, 1, arch, "Architected and developed a high-scale ecommerce backend NEWTEXT123 for testing.") + + after = ptext(find_para(doc2, 1, "Architected") or find_para(doc2, 0, "Architected")) + print("\n BEFORE:", before[:90]) + print(" AFTER :", after[:120]) + print(" contains NEWTEXT123:", "NEWTEXT123" in after) + clean = "Architected and developed a high-scale ecommerce backend NEWTEXT123 for testing." + print(" clean match:", after.strip() == clean) + for w in ["product", "catalog", "dynamic", "pricing", "transactions"]: + if w in after: + print(f" !! LEFTOVER old word in edited paragraph: {w!r}") + + +if __name__ == "__main__": + main() diff --git a/tests/edits/test_cross_page_reflow.py b/tests/edits/test_cross_page_reflow.py new file mode 100644 index 0000000..822df78 --- /dev/null +++ b/tests/edits/test_cross_page_reflow.py @@ -0,0 +1,274 @@ +"""Tests for CROSS-PAGE reflow — text that overflows the page bottom flows onto the next page +(creating it if needed), Word/Adobe style, while pre-existing footers stay put. + +Run: gateway/.venv/Scripts/python.exe tests/edits/test_cross_page_reflow.py +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +import os +_BUILD_LIB = r"C:\Users\furqa\pdfeng-build\win-local-pdfium\lib" +if os.path.isdir(_BUILD_LIB): + sys.path.insert(0, _BUILD_LIB) +sys.path.insert(1, str(ROOT / "gateway")) +import pdfengine + + +def _build_pdf() -> bytes: + + content = ( + b"BT /F1 14 Tf 72 720 Td (RESUME HEADING) Tj ET\n" + b"BT /F1 11 Tf 72 150 Td (The quick brown fox jumps over the lazy) Tj ET\n" + b"BT /F1 11 Tf 72 136 Td (dog near the river bank on a sunny) Tj ET\n" + b"BT /F1 11 Tf 72 122 Td (afternoon in early spring.) Tj ET\n" + b"BT /F1 9 Tf 72 40 Td (Page 1 of 1 FOOTER) Tj ET\n" + ) + objs = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + b"/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>", + b"<< /Length %d >>\nstream\n" % len(content) + content + b"endstream", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ] + pdf = b"%PDF-1.7\n" + offs = [] + for i, o in enumerate(objs, 1): + offs.append(len(pdf)) + pdf += b"%d 0 obj\n" % i + o + b"\nendobj\n" + xref = len(pdf) + pdf += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1) + for o in offs: + pdf += b"%010d 00000 n \n" % o + pdf += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref) + return pdf + + +def _page_text(model) -> str: + return " ".join("".join(r.text for r in ln.runs) for p in model.paragraphs for ln in p.lines) + +WORDS = [f"w{n:03d}" for n in range(80)] +LONG_TEXT = "The quick brown fox " + " ".join(WORDS) + " end." + + +def _reflow_overflow(): + doc = pdfengine.PdfDocument.load_from_memory(_build_pdf(), "") + m = doc.get_page(0).extract_document_model() + def ptext(p): + return " ".join("".join(r.text for r in ln.runs) for ln in p.lines) + para = next(p for p in m.paragraphs if "quick brown fox" in ptext(p)) + obj_idxs = [oi for ln in para.lines for r in ln.runs for oi in r.object_indices] + col_left = min(ln.x for ln in para.lines) + col_right = max(ln.x + ln.w for ln in para.lines) + first_baseline = max(ln.baseline_y for ln in para.lines) + bls = sorted((ln.baseline_y for ln in para.lines), reverse=True) + leading = abs(bls[0] - bls[1]) if len(bls) > 1 else 14.0 + + op = {"version": "1.0", "operations": [{ + "id": "x1", "type": "reflow_paragraph", "pageIndex": 0, "data": { + "objectIndices": obj_idxs, + "runs": [{"text": LONG_TEXT, "internalFontId": para.lines[0].runs[0].internal_font_id, + "fontSize": 11.0, "color": "#000000"}], + "columnLeft": col_left, "columnRight": col_right, "firstBaselineY": first_baseline, + "leading": leading, "oldLineCount": len(para.lines), "align": "left"}}]} + doc.apply_edits(json.dumps(op)) + out = doc.save_full() + reloaded = pdfengine.PdfDocument.load_from_memory(out, "") + return reloaded + + +def test_overflow_creates_second_page(): + doc = _reflow_overflow() + assert doc.page_count == 2, f"expected a 2nd page to be created, got {doc.page_count} page(s)" + print(f" ok overflow created page 2 (page_count={doc.page_count})") + + +def test_text_split_across_pages_intact(): + doc = _reflow_overflow() + p1 = _page_text(doc.get_page(0).extract_document_model()) + p2 = _page_text(doc.get_page(1).extract_document_model()) + assert "w000" in p1, "first body word should remain on page 1" + assert "w079" in p2 or "end." in p2, f"tail of paragraph should be on page 2 (got: ...{p2[-60:]!r})" + assert any(w in p2 for w in WORDS), "page 2 should contain readable marker words (font migrated intact)" + dup = [w for w in WORDS if w in p1 and w in p2] + assert not dup, f"words duplicated across pages (line moved+kept): {dup[:5]}" + print(f" ok text intact across the split; p1 has w000.., p2 has the tail (no dupes)") + + +def test_footer_stays_on_page_1(): + doc = _reflow_overflow() + p1 = _page_text(doc.get_page(0).extract_document_model()) + p2 = _page_text(doc.get_page(1).extract_document_model()) + assert "FOOTER" in p1, "footer must stay anchored on page 1" + assert "FOOTER" not in p2, "footer must NOT be flowed to page 2" + assert "HEADING" in p1, "heading must stay on page 1" + print(" ok footer + heading stayed on page 1 (anchored, not flowed)") + + +def _build_two_page_pdf() -> bytes: + c1 = ( + b"BT /F1 14 Tf 72 720 Td (RESUME HEADING) Tj ET\n" + b"BT /F1 11 Tf 72 150 Td (The quick brown fox jumps over the lazy) Tj ET\n" + b"BT /F1 11 Tf 72 136 Td (dog near the river bank on a sunny) Tj ET\n" + b"BT /F1 11 Tf 72 122 Td (afternoon in early spring.) Tj ET\n" + ) + c2 = ( + b"BT /F1 14 Tf 72 720 Td (SECOND PAGE TOP) Tj ET\n" + b"BT /F1 11 Tf 72 700 Td (existing second page body content here) Tj ET\n" + ) + objs = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + b"/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>", + b"<< /Length %d >>\nstream\n" % len(c1) + c1 + b"endstream", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + b"/Resources << /Font << /F1 5 0 R >> >> /Contents 7 0 R >>", + b"<< /Length %d >>\nstream\n" % len(c2) + c2 + b"endstream", + ] + pdf = b"%PDF-1.7\n" + offs = [] + for i, o in enumerate(objs, 1): + offs.append(len(pdf)) + pdf += b"%d 0 obj\n" % i + o + b"\nendobj\n" + xref = len(pdf) + pdf += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1) + for o in offs: + pdf += b"%010d 00000 n \n" % o + pdf += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref) + return pdf + + +def _baseline_of(model, needle): + for p in model.paragraphs: + for ln in p.lines: + if needle in "".join(r.text for r in ln.runs): + return ln.baseline_y + return None + + +def test_cascade_onto_existing_page_shifts_it_down(): + doc = pdfengine.PdfDocument.load_from_memory(_build_two_page_pdf(), "") + m = doc.get_page(0).extract_document_model() + def ptext(p): + return " ".join("".join(r.text for r in ln.runs) for ln in p.lines) + para = next(p for p in m.paragraphs if "quick brown fox" in ptext(p)) + obj_idxs = [oi for ln in para.lines for r in ln.runs for oi in r.object_indices] + col_left = min(ln.x for ln in para.lines) + col_right = max(ln.x + ln.w for ln in para.lines) + first_baseline = max(ln.baseline_y for ln in para.lines) + bls = sorted((ln.baseline_y for ln in para.lines), reverse=True) + leading = abs(bls[0] - bls[1]) if len(bls) > 1 else 14.0 + + top_before = _baseline_of(doc.get_page(1).extract_document_model(), "SECOND PAGE TOP") + + op = {"version": "1.0", "operations": [{ + "id": "c1", "type": "reflow_paragraph", "pageIndex": 0, "data": { + "objectIndices": obj_idxs, + "runs": [{"text": LONG_TEXT, "internalFontId": para.lines[0].runs[0].internal_font_id, + "fontSize": 11.0, "color": "#000000"}], + "columnLeft": col_left, "columnRight": col_right, "firstBaselineY": first_baseline, + "leading": leading, "oldLineCount": len(para.lines), "align": "left"}}]} + doc.apply_edits(json.dumps(op)) + out = doc.save_full() + r = pdfengine.PdfDocument.load_from_memory(out, "") + + p2 = _page_text(r.get_page(1).extract_document_model()) + assert "SECOND PAGE TOP" in p2, "pre-existing page-2 content must be preserved, not overwritten" + assert "existing second page body" in p2, "pre-existing page-2 body must be preserved" + top_after = _baseline_of(r.get_page(1).extract_document_model(), "SECOND PAGE TOP") + assert top_before is not None and top_after is not None and top_after < top_before - 5, \ + f"page-2 existing content should shift DOWN to make room (was {top_before}, now {top_after})" + assert any(w in p2 for w in WORDS), "overflow body words should have flowed onto page 2" + print(f" ok cascade: page-2 content shifted down {top_before:.0f}->{top_after:.0f}, preserved + overflow flowed in") + + +def _build_full_page1_plus_page2() -> bytes: + lines1 = [b"BT /F1 14 Tf 72 720 Td (RESUME HEADING) Tj ET\n"] + for i in range(38): + y = 690 - i * 14 + lines1.append(b"BT /F1 11 Tf 72 %d Td (filler line number %d of the tall body paragraph) Tj ET\n" % (y, i)) + c1 = b"".join(lines1) + c2 = ( + b"BT /F1 11 Tf 72 720 Td (PULLME001 second page content alpha) Tj ET\n" + b"BT /F1 11 Tf 72 706 Td (PULLME002 second page content beta) Tj ET\n" + ) + objs = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + b"/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>", + b"<< /Length %d >>\nstream\n" % len(c1) + c1 + b"endstream", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + b"/Resources << /Font << /F1 5 0 R >> >> /Contents 7 0 R >>", + b"<< /Length %d >>\nstream\n" % len(c2) + c2 + b"endstream", + ] + pdf = b"%PDF-1.7\n" + offs = [] + for i, o in enumerate(objs, 1): + offs.append(len(pdf)) + pdf += b"%d 0 obj\n" % i + o + b"\nendobj\n" + xref = len(pdf) + pdf += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1) + for o in offs: + pdf += b"%010d 00000 n \n" % o + pdf += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref) + return pdf + + +def test_shrink_leaves_other_content_in_place(): + doc = pdfengine.PdfDocument.load_from_memory(_build_full_page1_plus_page2(), "") + m = doc.get_page(0).extract_document_model() + def ptext(p): + return " ".join("".join(r.text for r in ln.runs) for ln in p.lines) + para = max((p for p in m.paragraphs if "filler line" in ptext(p)), key=lambda p: len(p.lines)) + obj_idxs = [oi for ln in para.lines for r in ln.runs for oi in r.object_indices] + col_left = min(ln.x for ln in para.lines) + col_right = max(ln.x + ln.w for ln in para.lines) + first_baseline = max(ln.baseline_y for ln in para.lines) + bls = sorted((ln.baseline_y for ln in para.lines), reverse=True) + leading = abs(bls[0] - bls[1]) if len(bls) > 1 else 14.0 + + op = {"version": "1.0", "operations": [{ + "id": "s1", "type": "reflow_paragraph", "pageIndex": 0, "data": { + "objectIndices": obj_idxs, + "runs": [{"text": "Short body now.", "internalFontId": para.lines[0].runs[0].internal_font_id, + "fontSize": 11.0, "color": "#000000"}], + "columnLeft": col_left, "columnRight": col_right, "firstBaselineY": first_baseline, + "leading": leading, "oldLineCount": len(para.lines), "align": "left"}}]} + doc.apply_edits(json.dumps(op)) + out = doc.save_full() + r = pdfengine.PdfDocument.load_from_memory(out, "") + + p1 = _page_text(r.get_page(0).extract_document_model()) + p2 = _page_text(r.get_page(1).extract_document_model()) + assert "Short body now." in p1, "the shrunk paragraph should be on page 1" + assert "PULLME001" in p2 and "PULLME002" in p2, \ + f"unrelated page-2 content must STAY on page 2 (not pulled up). p2={p2[:80]!r}" + assert "PULLME001" not in p1, "page-2 content must NOT be sucked onto page 1" + assert r.page_count == 2, f"page 2 has content -> must NOT be removed, got {r.page_count}" + print(" ok conservative shrink: page-2 section stayed in place, nothing pulled/lost") + + +def main() -> int: + try: + test_overflow_creates_second_page() + test_text_split_across_pages_intact() + test_footer_stays_on_page_1() + test_cascade_onto_existing_page_shifts_it_down() + test_shrink_leaves_other_content_in_place() + except AssertionError as exc: + print(f" FAIL: {exc}") + return 1 + print("\n5/5 passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/edits/test_xpage_reedit.py b/tests/edits/test_xpage_reedit.py new file mode 100644 index 0000000..79eb453 --- /dev/null +++ b/tests/edits/test_xpage_reedit.py @@ -0,0 +1,163 @@ +"""Stage 2: re-editing a paragraph that has flowed across pages must NOT scramble/duplicate. +Uses paraId so the engine reassembles the logical paragraph (deletes ALL its pieces across pages, +re-emits from the anchor, re-flows). Simulates what the frontend will send in Stage 3. + +Run: gateway/.venv/Scripts/python.exe tests/edits/test_xpage_reedit.py +""" +from __future__ import annotations +import json, sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, r"C:\Users\furqa\pdfeng-build\win-local-pdfium\lib") +sys.path.insert(1, str(ROOT / "gateway")) +import pdfengine + + +def build_pdf() -> bytes: + content = ( + b"BT /F1 14 Tf 72 720 Td (HEADING) Tj ET\n" + b"BT /F1 11 Tf 72 150 Td (The quick brown fox jumps over the lazy) Tj ET\n" + b"BT /F1 11 Tf 72 136 Td (dog near the river bank on a sunny) Tj ET\n" + b"BT /F1 11 Tf 72 122 Td (afternoon in early spring.) Tj ET\n" + b"BT /F1 9 Tf 72 40 Td (FOOTER) Tj ET\n" + ) + objs = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>", + b"<< /Length %d >>\nstream\n" % len(content) + content + b"endstream", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ] + pdf = b"%PDF-1.7\n"; offs = [] + for i, o in enumerate(objs, 1): + offs.append(len(pdf)); pdf += b"%d 0 obj\n" % i + o + b"\nendobj\n" + xref = len(pdf); pdf += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1) + for o in offs: pdf += b"%010d 00000 n \n" % o + pdf += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref) + return pdf + + +def runs_with_paraid(doc, page, pid): + """All (line, run) on a page whose run.para_id == pid.""" + m = doc.get_page(page).extract_document_model() + out = [] + for p in m.paragraphs: + for ln in p.lines: + for r in ln.runs: + if getattr(r, "para_id", "") == pid: + out.append((ln, r)) + return out + + +def anchor_geometry(doc, pid): + """Find the start page of paraId and its column/baseline/leading (topmost piece).""" + best_page, lines = None, [] + for pg in range(doc.page_count): + lr = runs_with_paraid(doc, pg, pid) + if lr: + best_page = pg + lines = [ln for ln, _ in lr] + break + if best_page is None: + return None + uniq = {} + for ln in lines: + uniq[round(ln.baseline_y, 1)] = ln + lns = list(uniq.values()) + cl = min(ln.x for ln in lns); cr = max(ln.x + ln.w for ln in lns) + fb = max(ln.baseline_y for ln in lns) + bls = sorted(uniq.keys(), reverse=True) + lead = abs(bls[0] - bls[1]) if len(bls) > 1 else 14.0 + obj_idxs = [oi for ln, r in runs_with_paraid(doc, best_page, pid) for oi in r.object_indices] + fid = lns[0].runs[0].internal_font_id + return dict(page=best_page, cl=cl, cr=cr, fb=fb, lead=lead, obj=obj_idxs, fid=fid) + + +def reflow_initial(doc, text, pid): + m = doc.get_page(0).extract_document_model() + para = next(p for p in m.paragraphs if "quick brown fox" in "".join(r.text for ln in p.lines for r in ln.runs)) + oi = [i for ln in para.lines for r in ln.runs for i in r.object_indices] + cl = min(ln.x for ln in para.lines); cr = max(ln.x + ln.w for ln in para.lines) + fb = max(ln.baseline_y for ln in para.lines) + bls = sorted({round(ln.baseline_y, 1) for ln in para.lines}, reverse=True) + lead = abs(bls[0]-bls[1]) if len(bls) > 1 else 14.0 + op = {"version": "1.0", "operations": [{"id": "i", "type": "reflow_paragraph", "pageIndex": 0, "data": { + "objectIndices": oi, "runs": [{"text": text, "internalFontId": para.lines[0].runs[0].internal_font_id, + "fontSize": 11.0, "color": "#000000"}], "columnLeft": cl, "columnRight": cr, "firstBaselineY": fb, + "leading": lead, "oldLineCount": len(para.lines), "align": "left", "paraId": pid}}]} + doc.apply_edits(json.dumps(op)) + return pdfengine.PdfDocument.load_from_memory(doc.save_full(), "") + + +def reflow_reedit(doc, g, text, pid): + op = {"version": "1.0", "operations": [{"id": "r", "type": "reflow_paragraph", "pageIndex": g["page"], "data": { + "objectIndices": g["obj"], "runs": [{"text": text, "internalFontId": g["fid"], + "fontSize": 11.0, "color": "#000000"}], "columnLeft": g["cl"], "columnRight": g["cr"], + "firstBaselineY": g["fb"], "leading": g["lead"], "oldLineCount": 1, "align": "left", "paraId": pid}}]} + doc.apply_edits(json.dumps(op)) + return pdfengine.PdfDocument.load_from_memory(doc.save_full(), "") + + +def page_text(doc, i): + m = doc.get_page(i).extract_document_model() + return " ".join("".join(r.text for r in ln.runs) for p in m.paragraphs for ln in p.lines) + + +def alltext(doc): + return " ".join(page_text(doc, i) for i in range(doc.page_count)) + + +PID = "PARA_REEDIT" +MARKERS = [f"m{n:03d}" for n in range(80)] +BIG = "Edited body " + " ".join(MARKERS) + " tailEND" + + +def test_reedit_spanned_shrink_no_scramble(): + doc = reflow_initial(pdfengine.PdfDocument.load_from_memory(build_pdf(), ""), BIG, PID) + span_pages = [pg for pg in range(doc.page_count) if runs_with_paraid(doc, pg, PID)] + assert len(span_pages) >= 2, f"setup: paragraph should span >=2 pages, got {span_pages}" + print(f" ok setup: paragraph spans pages {span_pages} (page_count={doc.page_count})") + + g = anchor_geometry(doc, PID) + doc2 = reflow_reedit(doc, g, "Now a short replacement body.", PID) + + at = alltext(doc2) + assert "short replacement body" in at, f"new text missing: ...{at[:120]!r}" + leftover = [m for m in MARKERS if m in at] + assert not leftover, f"OLD overflow markers still present after re-edit (scramble/leftover): {leftover[:8]}" + assert "tailEND" not in at, "old paragraph tail still present after shrink re-edit" + pages_now = [pg for pg in range(doc2.page_count) if runs_with_paraid(doc2, pg, PID)] + assert pages_now == [g["page"]], f"after shrink, paragraph should live on its anchor page only, got {pages_now}" + assert "FOOTER" in at and "HEADING" in at, "footer/heading must survive" + print(f" ok re-edit shrink: clean replace, no leftover markers, paragraph back to page {pages_now}") + + +def test_reedit_spanned_regrow_no_dup(): + doc = reflow_initial(pdfengine.PdfDocument.load_from_memory(build_pdf(), ""), BIG, PID) + g = anchor_geometry(doc, PID) + NEW = [f"z{n:03d}" for n in range(90)] + doc2 = reflow_reedit(doc, g, "Regrown body " + " ".join(NEW) + " zEND", PID) + at = alltext(doc2) + old_left = [m for m in MARKERS if m in at] + assert not old_left, f"old markers duplicated after re-grow: {old_left[:8]}" + found_new = sum(1 for z in NEW if z in at.replace(" ", "")) + assert found_new >= len(NEW) - 3, f"new markers missing after re-grow: {found_new}/{len(NEW)}" + for z in NEW: + assert at.count(z) <= 1, f"marker {z} duplicated across pages" + print(f" ok re-edit re-grow: old continuation replaced (no dup), {found_new}/{len(NEW)} new markers present") + + +def main() -> int: + try: + test_reedit_spanned_shrink_no_scramble() + test_reedit_spanned_regrow_no_dup() + except AssertionError as exc: + print(f" FAIL: {exc}") + return 1 + print("\n2/2 passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/wasm/CMakeLists.txt b/wasm/CMakeLists.txt index a30fc2d..75c7a3c 100644 --- a/wasm/CMakeLists.txt +++ b/wasm/CMakeLists.txt @@ -1,8 +1,3 @@ -# Phase 0 WASM hello-world. -# -# Only built when the Emscripten toolchain is active (see the root -# CMakeLists.txt — it early-returns into this subdir when EMSCRIPTEN is set). -# Rule R5: this target must never become a Phase 1 dependency. if(NOT EMSCRIPTEN) message(FATAL_ERROR @@ -12,26 +7,18 @@ endif() add_executable(hello hello.cpp) -# Emit an ES6 module so Node 20+ and modern browsers can `import` it directly. -# Suffix .mjs is what tells emcc to emit an ES module; the matching .wasm is -# produced alongside it. set_target_properties(hello PROPERTIES OUTPUT_NAME "hello" SUFFIX ".mjs" RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" ) -# Emscripten link flags. Keep this list short — every flag added here is a -# Phase 0 commitment the frontend dev will inherit. target_link_options(hello PRIVATE "-sMODULARIZE=1" "-sEXPORT_ES6=1" "-sENVIRONMENT=node,web" - # Functions callable from JS via ccall/cwrap. Underscore-prefix is the - # C symbol name emscripten exposes. "-sEXPORTED_FUNCTIONS=['_add','_hello_version']" "-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap']" - # Engine blueprint §6.3: real PDFs can exceed the default heap. "-sALLOW_MEMORY_GROWTH=1" ) @@ -40,7 +27,6 @@ target_compile_features(hello PRIVATE cxx_std_23) message(STATUS "WASM hello-world configured") message(STATUS " Output ............... ${CMAKE_BINARY_DIR}/bin/hello.mjs (+ hello.wasm)") -# Phase 1/2 WASM Engine target add_executable(pdfengine_wasm bindings/wasm_engine.cpp ) @@ -62,9 +48,9 @@ target_link_options(pdfengine_wasm PRIVATE "-sEXPORT_ES6=1" "-sENVIRONMENT=node,web" "-sALLOW_MEMORY_GROWTH=1" - "-sWASM_BIGINT" # PDFium uses i64 - "-sSTACK_SIZE=5MB" # PDFium render is stack-heavy - "-sEXPORTED_FUNCTIONS=['_loadDocument','_pageCount','_renderPagePng','_previewRender','_previewRenderRegion','_lastRenderPtr','_lastRenderW','_lastRenderH','_lastLayoutJson','_freeDocument','_engineBuildInfo','_malloc','_free']" + "-sWASM_BIGINT" + "-sSTACK_SIZE=5MB" + "-sEXPORTED_FUNCTIONS=['_loadDocument','_pageCount','_renderPagePng','_previewRender','_previewRenderRegion','_previewRenderPaginated','_lastRegionCount','_lastRegionPtr','_lastRegionW','_lastRegionH','_lastRegionPage','_lastRegionYTop','_lastRenderPtr','_lastRenderW','_lastRenderH','_lastLayoutJson','_freeDocument','_engineBuildInfo','_malloc','_free']" "-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','getValue','setValue','HEAPU8']" ) diff --git a/wasm/bindings/wasm_engine.cpp b/wasm/bindings/wasm_engine.cpp index 5cc7f8a..0521fef 100644 --- a/wasm/bindings/wasm_engine.cpp +++ b/wasm/bindings/wasm_engine.cpp @@ -1,7 +1,3 @@ -// Real engine bindings for WASM: load a PDF, render a page (PNG), and render a PREVIEW of -// an edit (e.g. reflow_paragraph) without mutating the loaded document. Rendering uses the -// SAME C++ engine path as the gateway (PdfDocument::render), so the browser preview is -// pixel-identical to the server-rendered page. #include #include #include @@ -14,17 +10,18 @@ namespace { struct DocEntry { std::shared_ptr doc; - std::vector bytes; // kept so a preview can re-load a clean copy + std::vector bytes; }; std::unordered_map g_docs; int g_nextHandle = 1; -// Holds the most recent render output (PNG) so JS can read it via lastRender* accessors. std::vector g_lastPng; int g_lastW = 0, g_lastH = 0; -// Per-character caret layout (JSON) from the most recent preview reflow, read via lastLayoutJson(). std::string g_lastLayout; +struct PreviewRegion { std::vector rgba; int w = 0, h = 0; int pageIndex = 0; double yTopPt = 0.0; }; +std::vector g_regions; + int renderInto(pdfengine::PdfDocument& doc, int pageIndex, int dpi) { auto page = doc.getPage(pageIndex); if (!page) return -1; @@ -35,14 +32,11 @@ int renderInto(pdfengine::PdfDocument& doc, int pageIndex, int dpi) { g_lastH = img->height; return static_cast(g_lastPng.size()); } -} // namespace +} extern "C" { EMSCRIPTEN_KEEPALIVE int loadDocument(const uint8_t* buffer, int size) { - // Quiet engine logging in the browser: the live preview reloads + re-resolves fonts on EVERY - // render, so info/warn lines (font fallbacks, "caches invalidated", vertical-writing detection) - // would flood the console on every keystroke and slow the page. Keep errors. Set once. static const bool logInit = [] { spdlog::set_level(spdlog::level::err); return true; }(); (void)logInit; std::vector bytes(buffer, buffer + size); @@ -58,15 +52,12 @@ EMSCRIPTEN_KEEPALIVE int pageCount(int handle) { return it == g_docs.end() ? -1 : it->second.doc->pageCount(); } -// Render the loaded page as-is (PNG). Returns byte length (read via lastRenderPtr()). EMSCRIPTEN_KEEPALIVE int renderPagePng(int handle, int pageIndex, int dpi) { auto it = g_docs.find(handle); if (it == g_docs.end()) return -1; return renderInto(*it->second.doc, pageIndex, dpi); } -// Render a PREVIEW of an edit: load a fresh copy from the original bytes, apply the edits -// (e.g. a reflow_paragraph op), render the page — the loaded document is untouched. EMSCRIPTEN_KEEPALIVE int previewRender(int handle, int pageIndex, int dpi, const char* editsJson) { auto it = g_docs.find(handle); if (it == g_docs.end()) return -1; @@ -81,10 +72,6 @@ EMSCRIPTEN_KEEPALIVE int previewRender(int handle, int pageIndex, int dpi, const return renderInto(**fresh, pageIndex, dpi); } -// Render a PREVIEW of an edit, but rasterize ONLY the page band [yTopPt, bottom] and return RAW -// RGBA (no PNG). Same engine path as previewRender; this is the fast path for live typing — skips -// the unchanged area above the edit and the libpng encode. g_lastPng then holds RAW RGBA bytes -// (width*height*4); read via lastRender{Ptr,W,H}() and draw with canvas putImageData. EMSCRIPTEN_KEEPALIVE int previewRenderRegion(int handle, int pageIndex, int dpi, const char* editsJson, double yTopPt, double heightPt) { auto it = g_docs.find(handle); @@ -101,16 +88,60 @@ EMSCRIPTEN_KEEPALIVE int previewRenderRegion(int handle, int pageIndex, int dpi, if (!page) return -1; auto img = (*page)->renderRegionRaw(dpi, yTopPt, heightPt); if (!img) return -1; - g_lastPng = std::move(img->data); // RAW RGBA, not PNG (caller knows via this entry point) + g_lastPng = std::move(img->data); g_lastW = img->width; g_lastH = img->height; return static_cast(g_lastPng.size()); } +EMSCRIPTEN_KEEPALIVE int previewRenderPaginated(int handle, int pageIndex, int dpi, + const char* editsJson, double yTopPt, int maxFollow) { + auto it = g_docs.find(handle); + if (it == g_docs.end()) return -1; + auto fresh = pdfengine::PdfDocument::loadFromMemory(it->second.bytes, ""); + if (!fresh) return -1; + g_lastLayout.clear(); + g_regions.clear(); + bool overflowed = false; + if (editsJson && editsJson[0]) { + auto r = (*fresh)->applyEdits(editsJson); + if (!r) return -1; + g_lastLayout = (*fresh)->lastReflowLayout(); + overflowed = (*fresh)->lastReflowOverflowed(); + } + { + auto page = (*fresh)->getPage(pageIndex); + if (!page) return -1; + auto img = (*page)->renderRegionRaw(dpi, yTopPt, 0.0); + if (!img) return -1; + g_regions.push_back({std::move(img->data), img->width, img->height, pageIndex, yTopPt}); + } + if (overflowed) { + int total = (*fresh)->pageCount(); + int last = pageIndex + (maxFollow > 0 ? maxFollow : 4); + for (int pg = pageIndex + 1; pg < total && pg <= last; ++pg) { + auto page = (*fresh)->getPage(pg); + if (!page) break; + auto img = (*page)->renderRegionRaw(dpi, 0.0, 0.0); + if (!img) break; + g_regions.push_back({std::move(img->data), img->width, img->height, pg, 0.0}); + } + } + return static_cast(g_regions.size()); +} + +EMSCRIPTEN_KEEPALIVE int lastRegionCount() { return static_cast(g_regions.size()); } +EMSCRIPTEN_KEEPALIVE const uint8_t* lastRegionPtr(int i) { + return (i >= 0 && i < static_cast(g_regions.size())) ? g_regions[i].rgba.data() : nullptr; +} +EMSCRIPTEN_KEEPALIVE int lastRegionW(int i) { return (i >= 0 && i < static_cast(g_regions.size())) ? g_regions[i].w : 0; } +EMSCRIPTEN_KEEPALIVE int lastRegionH(int i) { return (i >= 0 && i < static_cast(g_regions.size())) ? g_regions[i].h : 0; } +EMSCRIPTEN_KEEPALIVE int lastRegionPage(int i) { return (i >= 0 && i < static_cast(g_regions.size())) ? g_regions[i].pageIndex : -1; } +EMSCRIPTEN_KEEPALIVE double lastRegionYTop(int i) { return (i >= 0 && i < static_cast(g_regions.size())) ? g_regions[i].yTopPt : 0.0; } + EMSCRIPTEN_KEEPALIVE const uint8_t* lastRenderPtr() { return g_lastPng.data(); } EMSCRIPTEN_KEEPALIVE int lastRenderW() { return g_lastW; } EMSCRIPTEN_KEEPALIVE int lastRenderH() { return g_lastH; } -// Caret layout JSON for the most recent previewRender (empty if it had no reflow). EMSCRIPTEN_KEEPALIVE const char* lastLayoutJson() { return g_lastLayout.c_str(); } EMSCRIPTEN_KEEPALIVE void freeDocument(int handle) { g_docs.erase(handle); } diff --git a/wasm/pdfium-engine.smoke.mjs b/wasm/pdfium-engine.smoke.mjs index 460271d..1930e71 100644 --- a/wasm/pdfium-engine.smoke.mjs +++ b/wasm/pdfium-engine.smoke.mjs @@ -1,7 +1,3 @@ -// Smoke test for the LIVE-PREVIEW engine (wasm-pdfium preset, deployed as pdfium-engine.{mjs,wasm}). -// Unlike pdfengine.test.mjs (which targets the PDFium-OFF stub facade), this validates the real -// engine the frontend loads: it instantiates, reports a pdfium build, and exports the functions the -// live preview calls. Pass PDFENGINE_MJS=. import { existsSync } from "node:fs"; import { pathToFileURL } from "node:url"; @@ -14,7 +10,6 @@ if (!enginePath || !existsSync(enginePath)) { const { default: createModule } = await import(pathToFileURL(enginePath).href); const Module = await createModule(); -// 1. Build info — must report a real pdfium engine (not the stub facade). const buildInfo = Module.ccall("engineBuildInfo", "string", [], []); console.log(`[pdfium-smoke] buildInfo: "${buildInfo}"`); if (!buildInfo || !buildInfo.toLowerCase().includes("pdfium")) { @@ -22,9 +17,10 @@ if (!buildInfo || !buildInfo.toLowerCase().includes("pdfium")) { process.exit(1); } -// 2. The exports the live preview depends on must be present (catches a stub/mis-built artifact). const required = [ "_loadDocument", "_previewRender", "_previewRenderRegion", + "_previewRenderPaginated", "_lastRegionCount", "_lastRegionPtr", + "_lastRegionW", "_lastRegionH", "_lastRegionPage", "_lastRegionYTop", "_lastRenderPtr", "_lastRenderW", "_lastRenderH", "_lastLayoutJson", "_freeDocument", "_malloc", "_free", ];