feat: implemented multi page reflow
This commit is contained in:
@@ -54,10 +54,6 @@ void get_or_throw(std::expected<void, pdfengine::EngineError>&& res) {
|
||||
#include <serializer/content_serializer.hpp>
|
||||
#include <serializer/ast_serializer.hpp>
|
||||
|
||||
// A TJ array number more negative than this is treated as an inter-word space when flattening a
|
||||
// TJ run to display/edit text (numbers are thousandths-of-em, subtracted from the X position; a
|
||||
// word space is a large positive gap = large-negative adjustment). extract and replace MUST share
|
||||
// this constant so the flattened text they produce stays positionally aligned (see P1a redistribution).
|
||||
static constexpr double kTjSpaceKern = -500.0;
|
||||
|
||||
class StreamEditor {
|
||||
@@ -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<std::pair<pdfengine::AstNode*, std::string>> 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_<pdfengine::TextLine>(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<const char*>(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();
|
||||
|
||||
@@ -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<GlyphBounds> rects; // per-line union rects (text field left empty)
|
||||
std::vector<GlyphBounds> 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<Glyph> glyphs;
|
||||
std::vector<int> 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<std::vector<GlyphBounds>, EngineError> orderedGlyphs() const;
|
||||
|
||||
// Nearest glyph/caret to a point.
|
||||
[[nodiscard]] std::expected<HitResult, EngineError> hitGlyph(double x, double y) const;
|
||||
|
||||
// Reading-order selection between two points (e.g. drag anchor → focus).
|
||||
[[nodiscard]] std::expected<TextSelection, EngineError>
|
||||
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;
|
||||
@@ -206,7 +197,6 @@ public:
|
||||
int pageIndex = 0;
|
||||
std::vector<std::vector<Point2D>> 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<std::vector<OutlineItem>, EngineError> extractOutline() const = 0;
|
||||
|
||||
@@ -259,10 +248,10 @@ public:
|
||||
|
||||
virtual std::expected<void, EngineError> 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<std::vector<uint8_t>, EngineError>
|
||||
saveIncremental() const = 0;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<PdfiumDocument> ownerDoc_;
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
mutable std::unordered_map<std::string, FPDF_FONT> fontHandleCache_;
|
||||
@@ -102,13 +99,12 @@ public:
|
||||
std::expected<std::vector<uint8_t>, EngineError> saveIncremental() const override;
|
||||
std::expected<std::vector<uint8_t>, 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<uint8_t> 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<std::string, std::vector<uint8_t>> fontDataCache_;
|
||||
mutable int fontDataScannedPages_ = 0;
|
||||
|
||||
// Resolve an embedded font program from the SPECIFIC page objects of the paragraph being
|
||||
// edited (its objectIndices), matching each object's /BaseFont to internalFontId. Used by
|
||||
// reflow so a re-subset re-uses the exact font its source text referenced — no document-wide
|
||||
// name collision when several same-named subsets accumulate across sequential edits.
|
||||
// Returns nullopt if no object in the set uses that font.
|
||||
std::optional<std::vector<uint8_t>>
|
||||
getFontDataFromObjects(int pageIndex, const std::vector<int>& objectIndices,
|
||||
const std::string& internalFontId) const;
|
||||
|
||||
// Weak so the document never co-owns its pages: ownership runs page → document
|
||||
// only. A cached entry is reused while a page is still referenced elsewhere,
|
||||
// and lazily rebuilt once it expires.
|
||||
mutable std::unordered_map<int, std::weak_ptr<PdfPage>> pageCache_;
|
||||
mutable std::mutex pageCacheMutex_;
|
||||
|
||||
// Font Engine Bridge
|
||||
std::unique_ptr<pdfengine::fonts::loader::FontResolver> fontResolver_;
|
||||
std::unordered_map<std::string, std::shared_ptr<fonts::pdf_fonts::Font>> resolvedFontsCache_;
|
||||
std::mutex resolvedFontsMutex_;
|
||||
|
||||
// Loaded PDFium Font Cache for Font Reuse
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
mutable std::unordered_map<std::string, FPDF_FONT> loadedFontsCache_;
|
||||
mutable std::unordered_map<std::string, std::vector<uint8_t>> loadedFontDataBuffers_;
|
||||
mutable std::unordered_map<std::string, std::shared_ptr<fonts::FontFace>> 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<fonts::pdf_fonts::Font> 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<fonts::FontFace> measureFace;
|
||||
};
|
||||
// srcObjects = the page objects of the paragraph being reflowed (for per-object font
|
||||
// resolution); pass {} when no source objects are known (falls back to the name scan).
|
||||
// reuseFont = the ORIGINAL embedded FPDF_FONT of the source text (from a still-open page);
|
||||
// when non-null the embedded path reuses it directly instead of re-subsetting + loading a new
|
||||
// same-named font (which PDFium aliases, scrambling multi-edit output). null => subset+load.
|
||||
EmissionFont loadEmissionFont(int pageIndex, const std::string& internalFontId,
|
||||
double fontSize, const std::vector<uint32_t>& codepoints,
|
||||
const std::vector<int>& srcObjects = {},
|
||||
@@ -170,7 +144,6 @@ private:
|
||||
#endif
|
||||
};
|
||||
|
||||
// Exposed for testing
|
||||
std::vector<unsigned short> 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);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -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<ArrayBuffer | null> {
|
||||
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<ArrayBuffer | null> {
|
||||
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();
|
||||
@@ -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<OutlineItem[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/documents/${documentId}/outline`);
|
||||
|
||||
@@ -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<PdfiumModule | null> | null = null;
|
||||
const docHandles = new Map<string, number>(); // documentId -> wasm doc handle
|
||||
const docHandles = new Map<string, number>();
|
||||
|
||||
// Load the Emscripten module once (mirrors the existing wasmLoader mechanism).
|
||||
function getModule(): Promise<PdfiumModule | null> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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<Blob | null> {
|
||||
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<PreviewResult> {
|
||||
@@ -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<RegionResult> {
|
||||
@@ -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<PaginatedResult> {
|
||||
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<void> {
|
||||
const h = docHandles.get(documentId);
|
||||
if (h === undefined) return;
|
||||
|
||||
@@ -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<PDFViewerRef, PDFViewerProps>(({
|
||||
const [renderedPages, setRenderedPages] = useState<string[]>([]);
|
||||
const [containerHeight, setContainerHeight] = useState(800);
|
||||
const [pageTexts, setPageTexts] = useState<Record<number, Glyph[]>>({});
|
||||
// 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<string>('');
|
||||
// Previous documentId so we can free its WASM copy when a new edit mints a new id. Each edit
|
||||
// creates a new documentId and the live-preview engine loads a FULL doc copy per id; without this
|
||||
// they accumulate in the WASM heap for the whole session (never freed) → unbounded growth over a
|
||||
// demo. The preview re-loads on demand, so freeing the superseded version only reclaims memory.
|
||||
const prevDocumentIdRef = useRef<string>(documentId);
|
||||
useEffect(() => {
|
||||
// Text/model caches ARE document-specific and cheap to refetch — reset them.
|
||||
// renderedPages is intentionally NOT cleared here (see renderedDocIdRef above).
|
||||
setPageTexts({});
|
||||
const prev = prevDocumentIdRef.current;
|
||||
if (prev && prev !== documentId) wasmFreeDocument(prev);
|
||||
prevDocumentIdRef.current = documentId;
|
||||
}, [documentId]);
|
||||
// Free the active document's WASM copy when the viewer unmounts.
|
||||
useEffect(() => () => { wasmFreeDocument(prevDocumentIdRef.current); }, []);
|
||||
|
||||
const [bridgeFrame, setBridgeFrame] = useState<(CommitFrame & { docId: string }) | null>(null);
|
||||
@@ -127,6 +117,10 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
if (b && b.pageIndex === pageIndex && b.docId !== documentIdRef.current) setBridgeFrame(null);
|
||||
}, []);
|
||||
useEffect(() => { setBridgeFrame(null); }, [zoom]);
|
||||
|
||||
const [overflowPreviews, setOverflowPreviews] = useState<OverflowPreviewRegion[]>([]);
|
||||
const [overflowCaret, setOverflowCaret] = useState<OverflowCaret | null>(null);
|
||||
useEffect(() => { setOverflowPreviews([]); setOverflowCaret(null); }, [zoom, documentId]);
|
||||
useEffect(() => {
|
||||
if (!bridgeFrame) return;
|
||||
const t = window.setTimeout(() => setBridgeFrame(null), 6000);
|
||||
@@ -226,12 +220,10 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
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<PDFViewerRef, PDFViewerProps>(({
|
||||
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<PDFViewerRef, PDFViewerProps>(({
|
||||
};
|
||||
}, [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<PDFViewerRef, PDFViewerProps>(({
|
||||
>
|
||||
{imageUrl ? (
|
||||
<>
|
||||
{/* Base PDF Canvas Layer */}
|
||||
<CanvasLayer
|
||||
pageIndex={page.index}
|
||||
imageUrl={imageUrl}
|
||||
zoom={zoom}
|
||||
rotation={0} // already rotated physically on backend
|
||||
rotation={0}
|
||||
width={page.width}
|
||||
height={page.height}
|
||||
onRenderComplete={handlePagePainted}
|
||||
/>
|
||||
|
||||
{/* 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 && (
|
||||
<img
|
||||
src={bridgeFrame.dataUrl}
|
||||
@@ -418,7 +406,31 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Highlight/Comment Annotation Layer */}
|
||||
{overflowPreviews
|
||||
.filter((r) => r.pageIndex === page.index)
|
||||
.map((r, k) => (
|
||||
<img
|
||||
key={`ovf-${k}`}
|
||||
src={r.dataUrl}
|
||||
alt=""
|
||||
className="absolute z-[37] pointer-events-none select-none"
|
||||
style={{ left: 0, top: r.yTopPt * zoom, width: page.width, height: page.height }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{overflowCaret && overflowCaret.pageIndex === page.index && (
|
||||
<>
|
||||
<style>{`@keyframes pe-caret-blink2{0%,49%{opacity:1}50%,100%{opacity:0}}`}</style>
|
||||
<div
|
||||
className="absolute z-[39] pointer-events-none"
|
||||
style={{
|
||||
left: overflowCaret.left, top: overflowCaret.top, height: overflowCaret.height,
|
||||
width: 1.6, background: '#2563eb', animation: 'pe-caret-blink2 1s step-end infinite',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AnnotationLayer
|
||||
pageIndex={page.index}
|
||||
width={page.width}
|
||||
@@ -430,7 +442,6 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
onFieldChange={onFieldChange}
|
||||
/>
|
||||
|
||||
{/* Text Selection Dragging Layer */}
|
||||
{(activeTool === 'select' || activeTool === 'highlight' || activeTool === 'underline' || activeTool === 'strikeout' || activeTool === 'squiggly') && (
|
||||
<SelectionLayer
|
||||
pageIndex={page.index}
|
||||
@@ -443,7 +454,6 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Redaction Area Selection Layer */}
|
||||
{activeTool === 'redact' && (
|
||||
<RedactionLayer
|
||||
pageIndex={page.index}
|
||||
@@ -453,7 +463,6 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ink / comment / text-box / stamp / signature overlay tool layer */}
|
||||
<OverlayLayer
|
||||
pageIndex={page.index}
|
||||
width={page.width}
|
||||
@@ -472,11 +481,11 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
onPlaceSignature={onPlaceSignature}
|
||||
/>
|
||||
|
||||
{/* Inline editor for existing page text (true content editing) */}
|
||||
{activeTool === 'edit_text' && (
|
||||
<TextEditLayer
|
||||
documentId={documentId}
|
||||
pageIndex={page.index}
|
||||
totalPages={totalPages}
|
||||
width={page.width}
|
||||
height={page.height}
|
||||
zoom={zoom}
|
||||
@@ -484,6 +493,8 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
onEditText={onEditText}
|
||||
onReflowParagraph={onReflowParagraph}
|
||||
onCommitPreview={handleCommitPreview}
|
||||
onOverflowPreview={setOverflowPreviews}
|
||||
onOverflowCaret={setOverflowCaret}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -496,13 +507,11 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
|
||||
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<PDFViewerRef, PDFViewerProps>(({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search highlights overlay */}
|
||||
<SearchOverlayLayer
|
||||
pageIndex={page.index}
|
||||
width={page.width}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { gatewayService } from '../lib/gatewayService';
|
||||
import { wasmLoadDocument, wasmHasDocument, wasmPreviewRenderRegion } from '../lib/pdfiumEngine';
|
||||
import { wasmLoadDocument, wasmHasDocument, wasmPreviewRenderPaginated } from '../lib/pdfiumEngine';
|
||||
import type { ReflowLayout } from '../lib/pdfiumEngine';
|
||||
|
||||
export interface OverflowPreviewRegion { pageIndex: number; yTopPt: number; dataUrl: string; }
|
||||
export interface OverflowCaret { pageIndex: number; left: number; top: number; height: number; }
|
||||
import type { ReflowParagraphPayload, CommitFrame } from './TextEditLayer';
|
||||
import type { ReflowFragment } from '../lib/gatewayService';
|
||||
|
||||
@@ -31,7 +34,6 @@ function lineAdvances(line: any): { perRun: Record<number, number[]>; 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<ParagraphEditorProps> = ({
|
||||
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<string>('');
|
||||
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<HTMLDivElement>(null);
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(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<number | null>(null);
|
||||
const renderingRef = useRef(false);
|
||||
const renderDirtyRef = useRef(false);
|
||||
@@ -271,12 +255,8 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
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<ParagraphEditorProps> = ({
|
||||
pushColumnLeft: pushColumnLeft ?? columnLeft,
|
||||
firstBaselineY: layout.firstBaselineY, leading,
|
||||
oldLineCount: layout.oldLineCount, align,
|
||||
paraId: paraIdRef.current,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -315,10 +296,12 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
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<ParagraphEditorProps> = ({
|
||||
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<ParagraphEditorProps> = ({
|
||||
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<ParagraphEditorProps> = ({
|
||||
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<ParagraphEditorProps> = ({
|
||||
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<ParagraphEditorProps> = ({
|
||||
// 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<ParagraphEditorProps> = ({
|
||||
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<ParagraphEditorProps> = ({
|
||||
return () => window.clearTimeout(t);
|
||||
}, [hasPreview]);
|
||||
|
||||
// IME composition (CJK/accents): the browser fires `input` for every intermediate composition
|
||||
// keystroke, but the text isn't final until `compositionend`. Re-extracting/re-rendering mid-
|
||||
// composition rewrites the DOM under the IME and aborts it. So we suppress rendering while
|
||||
// composing and do a single render when composition ends.
|
||||
const composingRef = useRef(false);
|
||||
|
||||
// Re-render every frame on the latest keystroke (rAF-coalesced, no fixed debounce) for real-time feel.
|
||||
const onInput = () => {
|
||||
editedRef.current = true; // now the engine may re-wrap (the user is actually editing)
|
||||
if (!edited) setEdited(true); // swap from the untouched original page to the reflow render
|
||||
if (composingRef.current) return; // defer to compositionend (don't disturb the IME)
|
||||
positionCaret(); // immediate (approximate, from the prior layout) for responsiveness
|
||||
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<ParagraphEditorProps> = ({
|
||||
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 (
|
||||
<>
|
||||
<style>{`@keyframes pe-caret-blink{0%,49%{opacity:1}50%,100%{opacity:0}}`}</style>
|
||||
{/* White cover ONLY in the fallback case (WASM failed). */}
|
||||
{fallbackVisible && (
|
||||
<div
|
||||
className="absolute z-[36] bg-white"
|
||||
style={{ left: colLeftPx - 2, top: editorTop - 2, width: colWidthPx + 4, height: layout.oldLineCount * leadingPx + 8 }}
|
||||
/>
|
||||
)}
|
||||
{/* 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). */}
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
className="absolute z-[37]"
|
||||
@@ -528,8 +519,6 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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 && (
|
||||
<div
|
||||
className="absolute z-[39]"
|
||||
@@ -537,7 +526,6 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Transparent input layer: holds the text + receives keys; its OWN layout is ignored. */}
|
||||
<div
|
||||
ref={editRef}
|
||||
contentEditable
|
||||
@@ -553,8 +541,6 @@ export const ParagraphEditor: React.FC<ParagraphEditorProps> = ({
|
||||
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(); }
|
||||
|
||||
@@ -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<TextEditLayerProps> = ({
|
||||
documentId,
|
||||
pageIndex,
|
||||
totalPages,
|
||||
width,
|
||||
height,
|
||||
zoom,
|
||||
onEditText,
|
||||
onReflowParagraph,
|
||||
onCommitPreview,
|
||||
onOverflowPreview,
|
||||
onOverflowCaret,
|
||||
}) => {
|
||||
const [runs, setRuns] = useState<EditableRun[]>([]);
|
||||
const [editing, setEditing] = useState<number | null>(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<HTMLInputElement>(null);
|
||||
const caretIdxRef = useRef<number | null>(null);
|
||||
const modelRef = useRef<any>(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<TextEditLayerProps> = ({
|
||||
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<TextEditLayerProps> = ({
|
||||
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<TextEditLayerProps> = ({
|
||||
}
|
||||
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<TextEditLayerProps> = ({
|
||||
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<TextEditLayerProps> = ({
|
||||
|
||||
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<TextEditLayerProps> = ({
|
||||
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<TextEditLayerProps> = ({
|
||||
|
||||
return (
|
||||
<div className="absolute top-0 left-0 z-[35]" style={{ width: `${width}px`, height: `${height}px` }}>
|
||||
{/* Live reflow editor (wraps + pushes down as you type) — whole paragraph or one bullet item. */}
|
||||
{paraEdit !== null && (
|
||||
<ParagraphEditor
|
||||
documentId={documentId}
|
||||
pageIndex={pageIndex}
|
||||
pageIndex={paraEdit.anchorPageIndex ?? pageIndex}
|
||||
para={paraEdit.para}
|
||||
pushColumnLeft={paraEdit.pushColumnLeft}
|
||||
leadingOverride={paraEdit.leading}
|
||||
@@ -547,12 +522,13 @@ export const TextEditLayer: React.FC<TextEditLayerProps> = ({
|
||||
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<TextEditLayerProps> = ({
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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<TextEditLayerProps> = ({
|
||||
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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
+3
-17
@@ -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']"
|
||||
)
|
||||
|
||||
|
||||
@@ -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 <emscripten/emscripten.h>
|
||||
#include <pdfengine/pdf_document.hpp>
|
||||
#include <spdlog/spdlog.h>
|
||||
@@ -14,17 +10,18 @@
|
||||
namespace {
|
||||
struct DocEntry {
|
||||
std::shared_ptr<pdfengine::PdfDocument> doc;
|
||||
std::vector<uint8_t> bytes; // kept so a preview can re-load a clean copy
|
||||
std::vector<uint8_t> bytes;
|
||||
};
|
||||
std::unordered_map<int, DocEntry> g_docs;
|
||||
int g_nextHandle = 1;
|
||||
|
||||
// Holds the most recent render output (PNG) so JS can read it via lastRender* accessors.
|
||||
std::vector<uint8_t> 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<uint8_t> rgba; int w = 0, h = 0; int pageIndex = 0; double yTopPt = 0.0; };
|
||||
std::vector<PreviewRegion> 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<int>(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<uint8_t> 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<int>(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<int>(g_regions.size());
|
||||
}
|
||||
|
||||
EMSCRIPTEN_KEEPALIVE int lastRegionCount() { return static_cast<int>(g_regions.size()); }
|
||||
EMSCRIPTEN_KEEPALIVE const uint8_t* lastRegionPtr(int i) {
|
||||
return (i >= 0 && i < static_cast<int>(g_regions.size())) ? g_regions[i].rgba.data() : nullptr;
|
||||
}
|
||||
EMSCRIPTEN_KEEPALIVE int lastRegionW(int i) { return (i >= 0 && i < static_cast<int>(g_regions.size())) ? g_regions[i].w : 0; }
|
||||
EMSCRIPTEN_KEEPALIVE int lastRegionH(int i) { return (i >= 0 && i < static_cast<int>(g_regions.size())) ? g_regions[i].h : 0; }
|
||||
EMSCRIPTEN_KEEPALIVE int lastRegionPage(int i) { return (i >= 0 && i < static_cast<int>(g_regions.size())) ? g_regions[i].pageIndex : -1; }
|
||||
EMSCRIPTEN_KEEPALIVE double lastRegionYTop(int i) { return (i >= 0 && i < static_cast<int>(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); }
|
||||
|
||||
@@ -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=<path to the built 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",
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user