feat: implemented multi page reflow

This commit is contained in:
Furqan-14
2026-06-18 16:48:29 +05:30
parent ff0962b111
commit a124a43877
23 changed files with 1559 additions and 890 deletions
+3 -18
View File
@@ -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();
+34 -45
View File
@@ -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
+3 -30
View File
@@ -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.
+11 -40
View File
@@ -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`);
+33 -20
View File
@@ -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;
+35 -27
View File
@@ -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}
+60 -74
View File
@@ -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(); }
+82 -110
View File
@@ -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);
+1 -16
View File
@@ -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"),
+74
View File
@@ -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' :", ("highscale" in before))
print(" 'e' + U+002D + 'commerce' :", ("e-commerce" in before))
print(" 'e' + U+2011 + 'commerce' :", ("ecommerce" 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' :", ("highscale" in after))
print(" 'e' + U+002D + 'commerce' :", ("e-commerce" in after))
print(" 'e' + U+2011 + 'commerce' :", ("ecommerce" 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()
+62
View File
@@ -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()
+95
View File
@@ -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())
+39 -15
View File
@@ -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'
+82
View File
@@ -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()
+76
View File
@@ -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()
+274
View File
@@ -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())
+163
View File
@@ -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
View File
@@ -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']"
)
+51 -20
View File
@@ -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); }
+2 -6
View File
@@ -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",
];