This commit is contained in:
saqib mir
2026-06-09 10:52:54 +05:30
35 changed files with 1390 additions and 190 deletions
Binary file not shown.
+4
View File
@@ -240,5 +240,9 @@ PYBIND11_MODULE(pdfengine, m) {
.def("save_incremental", [](const pdfengine::PdfDocument& self) {
std::vector<uint8_t> res = get_or_throw(self.saveIncremental());
return py::bytes(reinterpret_cast<const char*>(res.data()), res.size());
})
.def("save_full", [](const pdfengine::PdfDocument& self) {
std::vector<uint8_t> res = get_or_throw(self.saveFull());
return py::bytes(reinterpret_cast<const char*>(res.data()), res.size());
});
}
@@ -191,6 +191,9 @@ public:
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
saveIncremental() const = 0;
[[nodiscard]] virtual std::expected<std::vector<uint8_t>, EngineError>
saveFull() const = 0;
};
}
@@ -1,4 +1,5 @@
#include "fonts/pdf_fonts/font_subset.hpp"
#include <vector>
#include <algorithm>
#include <cctype>
#include <ft2build.h>
@@ -3,6 +3,8 @@
#include <string>
#include <unordered_map>
#include <memory>
#include <vector>
#include <cstdint>
namespace pdfengine::fonts::pdf_fonts {
@@ -101,7 +101,18 @@ uint32_t TrueTypeFont::decodeToUnicode(uint32_t charCode) const {
if (subset_info_) {
if (subset_info_->hasGlyphMapping(charCode)) {
return subset_info_->mapSubsetToOriginal(charCode);
uint32_t originalGid = subset_info_->mapSubsetToOriginal(charCode);
FT_Face face = font_face_.getFace();
if (face) {
FT_UInt gindex;
FT_ULong charcode = FT_Get_First_Char(face, &gindex);
while (gindex != 0) {
if (gindex == originalGid) {
return static_cast<uint32_t>(charcode);
}
charcode = FT_Get_Next_Char(face, charcode, &gindex);
}
}
}
}
@@ -105,7 +105,18 @@ uint32_t Type1Font::decodeToUnicode(uint32_t charCode) const {
if (subset_info_) {
if (subset_info_->hasGlyphMapping(charCode)) {
return subset_info_->mapSubsetToOriginal(charCode);
uint32_t originalGid = subset_info_->mapSubsetToOriginal(charCode);
FT_Face face = font_face_.getFace();
if (face) {
FT_UInt gindex;
FT_ULong charcode = FT_Get_First_Char(face, &gindex);
while (gindex != 0) {
if (gindex == originalGid) {
return static_cast<uint32_t>(charcode);
}
charcode = FT_Get_Next_Char(face, charcode, &gindex);
}
}
}
}
+76 -1
View File
@@ -1564,7 +1564,67 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
FPDF_ClosePage(page);
} else if (type == "redaction") {
spdlog::info("Parsed redaction edit operation (stub)");
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("redaction operation missing 'data' object");
return std::unexpected(EngineError::InvalidFormat);
}
auto data = op["data"];
double x = data.value("x", 0.0);
double y = data.value("y", 0.0);
double width = data.value("width", 0.0);
double height = data.value("height", 0.0);
std::string fillColor = data.value("fillColor", "#ffffff");
FPDF_PAGE page = FPDF_LoadPage(doc_, pageIndex);
if (!page) {
spdlog::error("Failed to load page index {} for redaction", pageIndex);
return std::unexpected(EngineError::Unknown);
}
// 1. Permanently remove underlying page objects overlapping with selection
int count = FPDFPage_CountObjects(page);
for (int i = count - 1; i >= 0; --i) {
FPDF_PAGEOBJECT obj = FPDFPage_GetObject(page, i);
if (obj) {
float left = 0, bottom = 0, right = 0, top = 0;
if (FPDFPageObj_GetBounds(obj, &left, &bottom, &right, &top)) {
// Check overlap
if (!(left > x + width || right < x || bottom > y + height || top < y)) {
FPDFPage_RemoveObject(page, obj);
FPDFPageObj_Destroy(obj);
}
}
}
}
// 2. Place white cover rectangle over region
FPDF_PAGEOBJECT rectObj = FPDFPageObj_CreateNewRect(
static_cast<float>(x),
static_cast<float>(y),
static_cast<float>(width),
static_cast<float>(height)
);
if (!rectObj) {
spdlog::error("Failed to create redaction cover rectangle object");
FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown);
}
unsigned int r = 255, g = 255, b = 255;
parseHexColor(fillColor, r, g, b);
FPDFPageObj_SetFillColor(rectObj, r, g, b, 255);
FPDFPath_SetDrawMode(rectObj, FPDF_FILLMODE_WINDING, 0);
FPDFPage_InsertObject(page, rectObj);
// 3. Regenerate page content stream
if (!FPDFPage_GenerateContent(page)) {
spdlog::error("Failed to generate page content after redaction");
FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown);
}
FPDF_ClosePage(page);
} else if (type == "image_overlay") {
if (!op.contains("data") || !op["data"].is_object()) {
spdlog::error("image_overlay operation missing 'data' object");
@@ -1889,6 +1949,21 @@ std::expected<std::vector<uint8_t>, EngineError> PdfiumDocument::saveIncremental
#endif
}
std::expected<std::vector<uint8_t>, EngineError> PdfiumDocument::saveFull() const {
#ifdef PDFENGINE_WITH_PDFIUM
if (!doc_) {
return std::unexpected(EngineError::Unknown);
}
VectorWriter writer;
if (!FPDF_SaveWithVersion(doc_, &writer, 0, 14)) {
return std::unexpected(EngineError::WriteFailed);
}
return writer.buffer;
#else
return std::unexpected(EngineError::Unknown);
#endif
}
std::expected<std::vector<FontInfo>, EngineError> PdfiumPage::getFonts() const {
#ifdef PDFENGINE_WITH_PDFIUM
if (!page_) {
+1
View File
@@ -87,6 +87,7 @@ public:
std::expected<void, EngineError> applyEdits(const std::string& editsJson) override;
std::expected<std::vector<uint8_t>, EngineError> saveIncremental() const override;
std::expected<std::vector<uint8_t>, EngineError> saveFull() const override;
private:
NativeDocHandle doc_ = nullptr;
+63
View File
@@ -435,6 +435,69 @@ TEST(DocumentEditTest, ApplyEditsAndIncrementalSave) {
EXPECT_NE(textRes->find("UniqueEditedTextAnnotation123"), std::string::npos);
}
TEST(DocumentEditTest, ApplyRedactionAndFullSave) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world.pdf");
if (!std::filesystem::exists(path)) {
GTEST_SKIP() << "hello_world.pdf not found in corpus.";
}
auto docRes = PdfDocument::loadFromFile(path.string());
ASSERT_TRUE(docRes.has_value());
auto doc = *docRes;
// Verify text exists initially
{
auto pageRes = doc->getPage(0);
ASSERT_TRUE(pageRes.has_value());
auto textRes = (*pageRes)->extractText();
ASSERT_TRUE(textRes.has_value());
EXPECT_NE(textRes->find("Hello"), std::string::npos);
}
// Redact the entire page bounds to remove all objects
std::string editsJson = R"({
"version": "1.0",
"operations": [
{
"id": "op_redact_test_1",
"type": "redaction",
"pageIndex": 0,
"data": {
"x": 0.0,
"y": 0.0,
"width": 612.0,
"height": 792.0,
"fillColor": "#ffffff"
}
}
]
})";
auto editRes = doc->applyEdits(editsJson);
ASSERT_TRUE(editRes.has_value());
auto saveRes = doc->saveFull();
ASSERT_TRUE(saveRes.has_value());
const auto& savedBytes = *saveRes;
ASSERT_FALSE(savedBytes.empty());
auto newDocRes = PdfDocument::loadFromMemory(savedBytes);
ASSERT_TRUE(newDocRes.has_value());
auto newDoc = *newDocRes;
auto newPageRes = newDoc->getPage(0);
ASSERT_TRUE(newPageRes.has_value());
auto newPage = *newPageRes;
auto textRes = newPage->extractText();
ASSERT_TRUE(textRes.has_value());
// The text should be completely gone
EXPECT_EQ(textRes->find("Hello"), std::string::npos);
EXPECT_EQ(textRes->find("world"), std::string::npos);
}
TEST(DocumentEditTest, ApplyImageOverlayAndIncrementalSave) {
SKIP_IF_NO_PDFIUM();
auto path = getCorpusPath("basic", "hello_world.pdf");
File diff suppressed because one or more lines are too long
Binary file not shown.
+79
View File
@@ -7,6 +7,7 @@ import type { Annotation } from './viewer/AnnotationLayer';
import { gatewayService } from './lib/gatewayService';
import type { DocumentInfo, SearchResult, EditOperation } from './lib/gatewayService';
import { wasmLoader } from './lib/wasmLoader';
import { WasmInspector } from './components/WasmInspector';
import './App.css';
function App() {
@@ -26,6 +27,7 @@ function App() {
const [backendHealthy, setBackendHealthy] = useState<boolean | null>(null);
const [sidebarTab, setSidebarTab] = useState<'documents' | 'annotations' | 'outline'>('documents');
const [isLoading, setIsLoading] = useState<boolean>(true);
const [wasmInspectorOpen, setWasmInspectorOpen] = useState<boolean>(false);
// Search State
const [searchQuery, setSearchQuery] = useState('');
@@ -414,6 +416,71 @@ function App() {
}
};
const handleRedactArea = async (pageIndex: number, bounds: { x: number; y: number; width: number; height: number }) => {
if (!selectedDocId || !activeDoc) return;
const pageInfo = activeDoc.pages?.[pageIndex];
const pageHeight = pageInfo ? pageInfo.height : 792;
// Convert coordinates: flip Y axis for PDF space (origin bottom-left)
const x = bounds.x / zoom;
const y = pageHeight - (bounds.y + bounds.height) / zoom;
const width = bounds.width / zoom;
const height = bounds.height / zoom;
if (!confirm("Are you sure you want to permanently redact this area? All text, images, and vectors underneath will be permanently deleted from the file structure. This cannot be undone.")) {
return;
}
try {
setIsLoading(true);
const op = {
id: `redact_${Math.random().toString(36).substring(2, 11)}`,
type: 'redaction' as const,
pageIndex: pageIndex,
data: {
x,
y,
width,
height,
fillColor: '#ffffff',
}
};
const result = await gatewayService.applyEdits(selectedDocId, [op]);
if (result.success) {
// Re-fetch document list so sidebar updates
const docs = await gatewayService.listDocuments();
setDocuments(docs);
// Select the new document ID
setSelectedDocId(result.newDocumentId);
// Reset tool back to select
setActiveTool('select');
}
} catch (err) {
console.error('Failed to apply redaction:', err);
alert('Failed to apply redaction. Make sure the gateway is connected.');
} finally {
setIsLoading(false);
}
};
const handleExport = async () => {
if (!selectedDocId || !activeDoc) return;
try {
setIsLoading(true);
await gatewayService.exportDocument(selectedDocId, activeDoc.filename);
} catch (err) {
console.error('Failed to export document:', err);
alert('Failed to export document. Make sure the gateway is connected.');
} finally {
setIsLoading(false);
}
};
return (
<div className="w-screen h-screen flex flex-col overflow-hidden bg-slate-950 font-sans text-slate-100 antialiased">
{/* Top Navigation / Toolbar */}
@@ -435,6 +502,9 @@ function App() {
searchCurrentMatch={searchCurrentMatch}
onSearchNext={handleSearchNext}
onSearchPrev={handleSearchPrev}
onExport={handleExport}
onWasmInspectToggle={() => setWasmInspectorOpen(!wasmInspectorOpen)}
wasmInspectorOpen={wasmInspectorOpen}
onSaveEdits={handleSaveEdits}
hasAnnotations={annotations.length > 0}
/>
@@ -482,6 +552,7 @@ function App() {
searchCurrentMatch={searchCurrentMatch}
onAnnotationAdded={handleAnnotationAdded}
onPageVisible={setCurrentPage}
onRedactArea={handleRedactArea}
/>
) : (
<div className="flex-1 h-full flex flex-col items-center justify-center bg-slate-900 gap-3 text-slate-400">
@@ -491,6 +562,14 @@ function App() {
<p className="text-sm font-bold">No active document. Please upload a PDF file.</p>
</div>
)}
{wasmInspectorOpen && activeDoc && (
<WasmInspector
documentId={activeDoc.id}
currentPage={currentPage}
onClose={() => setWasmInspectorOpen(false)}
/>
)}
</div>
</div>
);
+38
View File
@@ -21,6 +21,9 @@ interface ToolbarProps {
searchCurrentMatch: number;
onSearchNext: () => void;
onSearchPrev: () => void;
onExport?: () => void;
onWasmInspectToggle?: () => void;
wasmInspectorOpen?: boolean;
onSaveEdits?: () => void;
hasAnnotations?: boolean;
}
@@ -43,6 +46,9 @@ export const Toolbar: React.FC<ToolbarProps> = ({
searchCurrentMatch,
onSearchNext,
onSearchPrev,
onExport,
onWasmInspectToggle,
wasmInspectorOpen = false,
onSaveEdits,
hasAnnotations,
}) => {
@@ -205,6 +211,14 @@ export const Toolbar: React.FC<ToolbarProps> = ({
>
Comment
</button>
<button
onClick={() => onActiveToolChange('redact')}
className={`tool-btn redact ${activeTool === 'redact' ? 'active' : ''}`}
title="Permanently Redact Area"
>
Redact
</button>
</div>
{/* SEARCH BAR */}
@@ -216,6 +230,30 @@ export const Toolbar: React.FC<ToolbarProps> = ({
onPrev={onSearchPrev}
/>
{onWasmInspectToggle && (
<button
onClick={onWasmInspectToggle}
className={`wasm-badge cursor-pointer hover:bg-indigo-500/25 hover:border-indigo-400/40 transition-all ${wasmInspectorOpen ? 'border-indigo-400 bg-indigo-500/25 text-indigo-200' : ''}`}
title="Inspect client-side WebAssembly execution"
>
<span className="wasm-dot" />
WASM Inspect
</button>
)}
{onExport && (
<button
onClick={onExport}
className="export-btn"
title="Export clean PDF (Stripping incremental history)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Export PDF
</button>
)}
{onSaveEdits && (
<button
onClick={onSaveEdits}
+252
View File
@@ -0,0 +1,252 @@
import React, { useEffect, useRef, useState } from 'react';
import { wasmLoader } from '../lib/wasmLoader';
import { gatewayService } from '../lib/gatewayService';
interface WasmInspectorProps {
documentId: string;
currentPage: number;
onClose: () => void;
}
export const WasmInspector: React.FC<WasmInspectorProps> = ({
documentId,
currentPage,
onClose,
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [status, setStatus] = useState<string>('Initializing...');
const [logs, setLogs] = useState<string[]>([]);
const [engineInfo, setEngineInfo] = useState<string>('');
const [textJson, setTextJson] = useState<string>('');
const [isCompiling, setIsCompiling] = useState<boolean>(true);
const [stats, setStats] = useState<{
loadTimeMs: number;
renderTimeMs: number;
textTimeMs: number;
docHandle: number;
fileSize: number;
}>({ loadTimeMs: 0, renderTimeMs: 0, textTimeMs: 0, docHandle: 0, fileSize: 0 });
const addLog = (msg: string) => {
setLogs((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
};
useEffect(() => {
let active = true;
let docHandle = 0;
let engine: any = null;
const runWasmPipeline = async () => {
try {
setIsCompiling(true);
setStatus('Loading WASM module...');
addLog('Initializing Emscripten compiler context in browser...');
engine = await wasmLoader.loadEngine();
if (!active) return;
const info = engine.engineBuildInfo();
setEngineInfo(info);
addLog(`C++ Core Loaded: ${info}`);
setStatus('Downloading PDF bytes from Gateway...');
addLog(`Fetching document ID: ${documentId} ...`);
const startTimeFetch = performance.now();
const buffer = await gatewayService.fetchDocumentBytes(documentId);
const fetchTime = performance.now() - startTimeFetch;
if (!active) return;
addLog(`Downloaded ${buffer.byteLength} bytes in ${fetchTime.toFixed(1)}ms`);
setStatus('Allocating heap and loading document...');
addLog('Calling loadDocument() on WASM engine...');
const startTimeLoad = performance.now();
docHandle = engine.loadDocument(buffer);
const loadTime = performance.now() - startTimeLoad;
if (!active) return;
if (docHandle <= 0) {
throw new Error('loadDocument failed to return a valid handle');
}
addLog(`Document loaded successfully. Allocated handle ID: ${docHandle} in ${loadTime.toFixed(1)}ms`);
// Update stats
setStats((prev) => ({
...prev,
docHandle,
fileSize: buffer.byteLength,
loadTimeMs: loadTime,
}));
// Render Page
setStatus('Rendering page client-side...');
addLog(`Calling renderPage(handle: ${docHandle}, pageIndex: ${currentPage}, scale: 1.0) ...`);
const startTimeRender = performance.now();
const scale = 1.0;
const imageData = engine.renderPage(docHandle, currentPage, scale);
const renderTime = performance.now() - startTimeRender;
if (!active) return;
addLog(`Page rendered to raw pixel RGBA buffer in ${renderTime.toFixed(1)}ms`);
// Draw onto Canvas
if (canvasRef.current) {
const canvas = canvasRef.current;
canvas.width = imageData.width;
canvas.height = imageData.height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.putImageData(imageData, 0, 0);
addLog(`Rendered pixel bytes displayed on HTML5 canvas (${imageData.width}x${imageData.height})`);
}
}
// Get Text JSON
setStatus('Extracting page text...');
addLog(`Calling getTextJson(handle: ${docHandle}, pageIndex: ${currentPage}) ...`);
const startTimeText = performance.now();
const textData = engine.getTextJson(docHandle, currentPage);
const textTime = performance.now() - startTimeText;
if (!active) return;
addLog(`Text query complete in ${textTime.toFixed(1)}ms`);
// Pretty print JSON
try {
const parsed = JSON.parse(textData);
setTextJson(JSON.stringify(parsed, null, 2));
} catch {
setTextJson(textData);
}
setStats((prev) => ({
...prev,
renderTimeMs: renderTime,
textTimeMs: textTime,
}));
setStatus('Complete');
setIsCompiling(false);
} catch (err: any) {
addLog(`ERROR: ${err.message}`);
setStatus('Pipeline Failed');
setIsCompiling(false);
}
};
runWasmPipeline();
return () => {
active = false;
if (docHandle > 0 && engine) {
try {
engine.freeDocument(docHandle);
console.log(`[WasmInspector] Freed doc handle ${docHandle}`);
} catch (e) {
console.error(e);
}
}
};
}, [documentId, currentPage]);
return (
<div className="flex flex-col w-[400px] border-l border-slate-800 bg-slate-900/90 backdrop-blur-md h-full text-slate-200 overflow-hidden shadow-2xl z-40 animate-in slide-in-from-right duration-300">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-800 bg-slate-950/40">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-indigo-500 animate-pulse shadow-[0_0_8px_#6366f1]" />
<h3 className="font-bold text-sm tracking-wider uppercase text-indigo-400">WASM Compiler Inspect</h3>
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-white p-1.5 rounded-lg hover:bg-slate-800 transition-colors"
title="Close Inspector"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Status Card */}
<div className="p-3.5 rounded-xl border border-slate-800 bg-slate-950/20">
<div className="text-xs text-slate-400 font-medium">Pipeline Status</div>
<div className="text-sm font-bold text-slate-100 flex items-center gap-2 mt-1">
{isCompiling && <span className="w-3.5 h-3.5 border-2 border-indigo-400 border-t-transparent rounded-full animate-spin" />}
{status}
</div>
</div>
{/* Metrics Grid */}
<div className="grid grid-cols-2 gap-2.5">
<div className="p-3 rounded-lg border border-slate-800 bg-slate-950/10">
<div className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider">Doc Handle ID</div>
<div className="text-sm font-bold mt-0.5 text-indigo-300">{stats.docHandle || 'N/A'}</div>
</div>
<div className="p-3 rounded-lg border border-slate-800 bg-slate-950/10">
<div className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider">File Size</div>
<div className="text-sm font-bold mt-0.5 text-slate-200">
{stats.fileSize ? `${(stats.fileSize / 1024).toFixed(1)} KB` : '0 KB'}
</div>
</div>
<div className="p-3 rounded-lg border border-slate-800 bg-slate-950/10">
<div className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider">WASM Load</div>
<div className="text-sm font-bold mt-0.5 text-emerald-400">
{stats.loadTimeMs ? `${stats.loadTimeMs.toFixed(1)}ms` : '0ms'}
</div>
</div>
<div className="p-3 rounded-lg border border-slate-800 bg-slate-950/10">
<div className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider">Render Time</div>
<div className="text-sm font-bold mt-0.5 text-emerald-400">
{stats.renderTimeMs ? `${stats.renderTimeMs.toFixed(1)}ms` : '0ms'}
</div>
</div>
</div>
{/* Client-side Canvas Preview */}
<div className="space-y-1.5">
<div className="text-[11px] font-bold tracking-wider uppercase text-slate-400">Client Canvas Output</div>
<div className="border border-slate-800 rounded-xl bg-slate-950 flex items-center justify-center p-4 overflow-hidden relative min-h-[160px]">
<canvas ref={canvasRef} className="max-w-full max-h-[220px] rounded shadow-lg object-contain bg-slate-900 border border-slate-800/40" />
{!stats.renderTimeMs && (
<div className="absolute text-slate-500 text-xs font-bold uppercase tracking-widest">Awaiting Render...</div>
)}
</div>
</div>
{/* Text Bounds JSON Output */}
<div className="flex-1 flex flex-col space-y-1.5 min-h-[220px]">
<div className="text-[11px] font-bold tracking-wider uppercase text-slate-400">extractTextWithBounds() JSON</div>
<div className="flex-1 min-h-[150px] max-h-[300px] border border-slate-800 rounded-xl bg-slate-950 p-3 overflow-y-auto font-mono text-xs text-indigo-300">
{textJson ? (
<pre className="whitespace-pre-wrap">{textJson}</pre>
) : (
<div className="text-slate-650 italic text-center py-8">No text extracted yet</div>
)}
</div>
</div>
{/* Console Logs */}
<div className="space-y-1.5">
<div className="text-[11px] font-bold tracking-wider uppercase text-slate-400">Execution Console Logs</div>
<div className="border border-slate-800 rounded-xl bg-slate-950 p-3 max-h-[200px] overflow-y-auto font-mono text-[10px] space-y-1 text-slate-400">
{logs.map((log, i) => (
<div key={i} className={log.includes('ERROR') ? 'text-rose-400' : log.includes('Success') || log.includes('successfully') ? 'text-emerald-400' : ''}>
{log}
</div>
))}
</div>
</div>
</div>
{/* Footer / Engine Tag */}
<div className="p-3 border-t border-slate-800 bg-slate-950/60 text-[10px] text-center text-slate-500 font-bold uppercase tracking-widest">
{engineInfo || 'WASM Engine Offline'}
</div>
</div>
);
};
+26
View File
@@ -337,6 +337,12 @@ body {
border: 1px solid rgba(79, 70, 229, 0.4);
}
.tool-btn.redact.active {
background: rgba(244, 63, 94, 0.2);
color: #fda4af;
border: 1px solid rgba(244, 63, 94, 0.4);
}
.upload-btn {
display: flex;
align-items: center;
@@ -358,6 +364,26 @@ body {
transform: translateY(-1px);
}
.export-btn {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.05);
color: white;
font-weight: 600;
font-size: 12px;
padding: 8px 16px;
border-radius: 10px;
cursor: pointer;
border: 1px solid var(--border-main);
transition: transform 0.2s, background-color 0.2s;
}
.export-btn:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateY(-1px);
}
.hidden-file-input {
display: none;
}
+20 -7
View File
@@ -1,10 +1,3 @@
/**
* Gateway API Service Client
*
* Handles all network requests to the FastAPI gateway backend.
* Provides endpoints for document CRUD, rendering, metadata retrieval, and edits.
*/
export interface PageInfo {
index: number;
width: number;
@@ -425,6 +418,26 @@ class GatewayService {
`;
return `data:image/svg+xml;utf8,${encodeURIComponent(svg.trim())}`;
}
async exportDocument(documentId: string, filename: string): Promise<void> {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/export`);
if (!response.ok) throw new Error(`Export failed: ${response.statusText}`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
async fetchDocumentBytes(documentId: string): Promise<ArrayBuffer> {
const response = await fetch(`${this.baseUrl}/documents/${documentId}/export`);
if (!response.ok) throw new Error(`Failed to fetch document bytes: ${response.statusText}`);
return response.arrayBuffer();
}
}
export const gatewayService = new GatewayService();
+15
View File
@@ -12,6 +12,7 @@ export interface WasmEngineInstance {
freeDocument: (handle: number) => void;
engineBuildInfo: () => string;
engineHasSkia: () => boolean;
getTextJson: (handle: number, page: number) => string;
}
class WasmLoader {
@@ -85,6 +86,9 @@ class WasmLoader {
},
engineHasSkia: () => {
return Module.ccall('engineHasSkia', 'number', [], []) !== 0;
},
getTextJson: (handle: number, page: number) => {
return Module.ccall('getPageTextJson', 'string', ['number', 'number'], [handle, page]);
}
};
@@ -131,6 +135,17 @@ class WasmLoader {
},
engineHasSkia: () => {
return true;
},
getTextJson: (handle: number, page: number) => {
void handle;
return JSON.stringify([
{ text: "P", x: 60.0, y: 80.0, w: 6.0, h: 8.0, fontSize: 12.0 },
{ text: "a", x: 66.0, y: 80.0, w: 6.0, h: 8.0, fontSize: 12.0 },
{ text: "g", x: 72.0, y: 80.0, w: 6.0, h: 8.0, fontSize: 12.0 },
{ text: "e", x: 78.0, y: 80.0, w: 6.0, h: 8.0, fontSize: 12.0 },
{ text: " ", x: 84.0, y: 80.0, w: 6.0, h: 8.0, fontSize: 12.0 },
{ text: String(page + 1), x: 90.0, y: 80.0, w: 6.0, h: 8.0, fontSize: 12.0 }
]);
}
};
}
+6 -2
View File
@@ -34,7 +34,9 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
const handlePointerDown = (e: React.PointerEvent) => {
if (activeTool !== 'draw') return;
setIsDrawing(true);
(e.target as Element).setPointerCapture?.(e.pointerId);
if (e.target instanceof Element) {
(e.target as Element).setPointerCapture(e.pointerId);
}
setCurrentPath([getCoordinates(e)]);
};
@@ -46,7 +48,9 @@ export const OverlayLayer: React.FC<OverlayLayerProps> = ({
const handlePointerUp = (e: React.PointerEvent) => {
if (!isDrawing || activeTool !== 'draw') return;
setIsDrawing(false);
(e.target as Element).releasePointerCapture?.(e.pointerId);
if (e.target instanceof Element) {
e.target.releasePointerCapture(e.pointerId);
}
if (currentPath.length > 1) {
// Calculate bounding box
+35 -14
View File
@@ -8,6 +8,7 @@ import { SearchOverlayLayer } from './SearchOverlayLayer';
import type { Rect } from '../lib/coordinateMapping';
import { gatewayService } from '../lib/gatewayService';
import type { SearchResult, PageInfo } from '../lib/gatewayService';
import { RedactionLayer } from './RedactionLayer';
interface PDFViewerProps {
documentId: string;
@@ -24,6 +25,7 @@ interface PDFViewerProps {
searchCurrentMatch?: number;
onAnnotationAdded?: (anno: Annotation) => void;
onPageVisible?: (pageIndex: number) => void;
onRedactArea?: (pageIndex: number, bounds: Rect) => void;
}
interface PageLayout {
@@ -54,15 +56,20 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
searchCurrentMatch,
onAnnotationAdded,
onPageVisible,
onRedactArea,
}, ref) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const [scrollPosition, setScrollPosition] = useState({ scrollLeft: 0, scrollTop: 0 });
const [renderedPages, setRenderedPages] = useState<string[]>([]);
const [verifiedPages, setVerifiedPages] = useState<Record<number, boolean>>({});
const [containerHeight, setContainerHeight] = useState(800);
const verifiedPagesRef = useRef<Set<number>>(new Set());
// Reset cached page renders when switching documents
useEffect(() => {
setRenderedPages([]);
setVerifiedPages({});
verifiedPagesRef.current.clear();
}, [documentId]);
// Use provided dimensions or fallback to Letter size
@@ -203,15 +210,13 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
};
}, [visiblePages, documentId, zoom, renderedPages]);
const verifiedPages = useRef<Set<number>>(new Set());
useEffect(() => {
const verifyPageModel = async () => {
if (visiblePages.length > 0 && documentId) {
if (visiblePages.length > 0 && documentId && !verifiedPages[visiblePages[0].index]) {
const pageIndex = visiblePages[0].index;
if (verifiedPages.current.has(pageIndex)) return;
if (verifiedPagesRef.current.has(pageIndex)) return;
verifiedPages.current.add(pageIndex);
verifiedPagesRef.current.add(pageIndex);
try {
const model = await gatewayService.getPageModel(documentId, pageIndex);
console.log(`--- Verification for Page ${pageIndex} ---`);
@@ -224,13 +229,17 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
});
});
});
setVerifiedPages((prev) => ({
...prev,
[pageIndex]: true,
}));
} catch (e) {
verifiedPages.current.delete(pageIndex);
verifiedPagesRef.current.delete(pageIndex);
}
}
};
verifyPageModel();
}, [visiblePages, documentId]);
}, [visiblePages, documentId, verifiedPages]);
const handleTextSelection = (text: string, bbox: Rect, pageIndex: number) => {
if (activeTool === 'highlight') {
@@ -332,13 +341,25 @@ export const PDFViewer = React.forwardRef<PDFViewerRef, PDFViewerProps>(({
/>
{/* Text Selection Dragging Layer */}
<SelectionLayer
pageIndex={page.index}
width={page.width}
height={page.height}
zoom={zoom}
onTextSelected={(text, bbox) => handleTextSelection(text, bbox, page.index)}
/>
{activeTool !== 'redact' && (
<SelectionLayer
pageIndex={page.index}
width={page.width}
height={page.height}
zoom={zoom}
onTextSelected={(text, bbox) => handleTextSelection(text, bbox, page.index)}
/>
)}
{/* Redaction Area Selection Layer */}
{activeTool === 'redact' && (
<RedactionLayer
pageIndex={page.index}
width={page.width}
height={page.height}
onRedactionSelected={(bounds) => onRedactArea?.(page.index, bounds)}
/>
)}
{/* signature/ink overlay tool layer */}
<OverlayLayer
+91
View File
@@ -0,0 +1,91 @@
import React, { useState, useRef } from 'react';
import type { Point, Rect } from '../lib/coordinateMapping';
interface RedactionLayerProps {
pageIndex: number;
width: number;
height: number;
onRedactionSelected: (bounds: Rect) => void;
}
export const RedactionLayer: React.FC<RedactionLayerProps> = ({
width,
height,
onRedactionSelected,
}) => {
const [dragStart, setDragStart] = useState<Point | null>(null);
const [redactBox, setRedactBox] = useState<Rect | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setDragStart({ x, y });
setRedactBox({ x, y, width: 0, height: 0 });
};
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!dragStart || !redactBox || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const newBox: Rect = {
x: Math.min(dragStart.x, x),
y: Math.min(dragStart.y, y),
width: Math.abs(dragStart.x - x),
height: Math.abs(dragStart.y - y),
};
setRedactBox(newBox);
};
const handleMouseUp = () => {
if (redactBox && redactBox.width > 5 && redactBox.height > 5) {
onRedactionSelected(redactBox);
}
setDragStart(null);
setRedactBox(null);
};
return (
<div
ref={containerRef}
className="redaction-layer"
style={{
position: 'absolute',
top: 0,
left: 0,
width: `${width}px`,
height: `${height}px`,
cursor: 'crosshair',
zIndex: 25,
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
{redactBox && (
<div
className="redaction-box-drag"
style={{
position: 'absolute',
left: `${redactBox.x}px`,
top: `${redactBox.y}px`,
width: `${redactBox.width}px`,
height: `${redactBox.height}px`,
border: '2px dashed var(--color-error, #f43f5e)',
backgroundColor: 'rgba(244, 63, 94, 0.15)',
pointerEvents: 'none',
boxShadow: '0 0 12px rgba(244, 63, 94, 0.25)',
borderRadius: '4px',
}}
/>
)}
</div>
);
};
+106 -64
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, File, HTTPException, UploadFile, status
from fastapi import APIRouter, File, HTTPException, Response, UploadFile, status
from pydantic import BaseModel
from app.services import engine
@@ -24,6 +24,7 @@ class DocumentInfoResponse(BaseModel):
status: str
pages: list[PageInfoResponse] = []
def make_document_response(d: dict) -> DocumentInfoResponse:
pages_list = []
if "doc_instance" in d:
@@ -176,6 +177,7 @@ class FontInfoResponse(BaseModel):
descent: float
capHeight: float
@router.get("/{document_id}/fonts", response_model=list[FontInfoResponse])
def get_document_fonts(
document_id: str, start_page: int = 0, end_page: int = -1
@@ -224,32 +226,35 @@ def get_document_fonts(
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
class SearchRect(BaseModel):
x: float
y: float
w: float
h: float
class SearchMatch(BaseModel):
pageIndex: int
rects: list[SearchRect]
text: str
@router.get("/{document_id}/search", response_model=list[SearchMatch])
def search_document(document_id: str, q: str) -> list[SearchMatch]:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available."
detail="Engine bridge (bindings/python) not yet available.",
)
if not q:
return []
doc_info = document_store.get_document(document_id)
if not doc_info:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
try:
doc = doc_info["doc_instance"]
matches = []
@@ -277,17 +282,17 @@ def search_document(document_id: str, q: str) -> list[SearchMatch]:
idx = lower_text.find(lower_query, idx)
if idx == -1:
break
start_glyph_idx = char_to_glyph[idx]
end_glyph_idx = char_to_glyph[idx + query_len - 1]
rects = []
current_rect = None
for g_idx in range(start_glyph_idx, end_glyph_idx + 1):
g = glyphs[g_idx]
dom_y = page.height - (g["y"] + g["h"])
if current_rect is None:
current_rect = {"x": g["x"], "y": dom_y, "w": g["w"], "h": g["h"]}
else:
@@ -299,16 +304,16 @@ def search_document(document_id: str, q: str) -> list[SearchMatch]:
else:
rects.append(SearchRect(**current_rect))
current_rect = {"x": g["x"], "y": dom_y, "w": g["w"], "h": g["h"]}
if current_rect:
rects.append(SearchRect(**current_rect))
matches.append(SearchMatch(
pageIndex=page_idx,
rects=rects,
text=text_str[idx:idx + query_len]
))
matches.append(
SearchMatch(
pageIndex=page_idx, rects=rects, text=text_str[idx : idx + query_len]
)
)
idx += 1
return matches
@@ -316,6 +321,7 @@ def search_document(document_id: str, q: str) -> list[SearchMatch]:
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
class GlyphModel(BaseModel):
text: str
unicode: int
@@ -330,6 +336,7 @@ class GlyphModel(BaseModel):
bbox_h: float
angle: float
class TextRunModel(BaseModel):
text: str
font_name: str
@@ -344,6 +351,7 @@ class TextRunModel(BaseModel):
w: float
h: float
class TextLineModel(BaseModel):
runs: list[TextRunModel]
baseline_y: float
@@ -352,6 +360,7 @@ class TextLineModel(BaseModel):
w: float
h: float
class ParagraphModel(BaseModel):
lines: list[TextLineModel]
x: float
@@ -359,12 +368,14 @@ class ParagraphModel(BaseModel):
w: float
h: float
class PageModelResponse(BaseModel):
paragraphs: list[ParagraphModel]
width: float
height: float
page_index: int
@router.get("/{document_id}/pages/{page_index}/model", response_model=PageModelResponse)
def get_page_model(document_id: str, page_index: int) -> PageModelResponse:
if not engine.is_available():
@@ -381,64 +392,64 @@ def get_page_model(document_id: str, page_index: int) -> PageModelResponse:
doc = doc_info["doc_instance"]
page = doc.get_page(page_index)
model = page.extract_document_model()
paragraphs = []
for p in model.paragraphs:
lines = []
for l in p.lines:
for line in p.lines:
runs = []
for r in l.runs:
for r in line.runs:
glyphs = []
for g in r.glyphs:
glyphs.append(GlyphModel(
text=g.text,
unicode=g.unicode,
font_name=g.font_name,
flags=g.flags,
font_size=g.font_size,
origin_x=g.origin_x,
origin_y=g.origin_y,
bbox_x=g.bbox_x,
bbox_y=g.bbox_y,
bbox_w=g.bbox_w,
bbox_h=g.bbox_h,
angle=g.angle
))
runs.append(TextRunModel(
text=r.text,
font_name=r.font_name,
flags=r.flags,
font_size=r.font_size,
internal_font_id=r.internal_font_id,
is_embedded=r.is_embedded,
type=r.type,
glyphs=glyphs,
x=r.x,
y=r.y,
w=r.w,
h=r.h
))
lines.append(TextLineModel(
runs=runs,
baseline_y=l.baseline_y,
x=l.x,
y=l.y,
w=l.w,
h=l.h
))
paragraphs.append(ParagraphModel(
lines=lines,
x=p.x,
y=p.y,
w=p.w,
h=p.h
))
glyphs.append(
GlyphModel(
text=g.text,
unicode=g.unicode,
font_name=g.font_name,
flags=g.flags,
font_size=g.font_size,
origin_x=g.origin_x,
origin_y=g.origin_y,
bbox_x=g.bbox_x,
bbox_y=g.bbox_y,
bbox_w=g.bbox_w,
bbox_h=g.bbox_h,
angle=g.angle,
)
)
runs.append(
TextRunModel(
text=r.text,
font_name=r.font_name,
flags=r.flags,
font_size=r.font_size,
internal_font_id=r.internal_font_id,
is_embedded=r.is_embedded,
type=r.type,
glyphs=glyphs,
x=r.x,
y=r.y,
w=r.w,
h=r.h,
)
)
lines.append(
TextLineModel(
runs=runs,
baseline_y=line.baseline_y,
x=line.x,
y=line.y,
w=line.w,
h=line.h,
)
)
paragraphs.append(ParagraphModel(lines=lines, x=p.x, y=p.y, w=p.w, h=p.h))
return PageModelResponse(
paragraphs=paragraphs,
width=model.width,
height=model.height,
page_index=model.page_index
page_index=model.page_index,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@@ -498,3 +509,34 @@ def get_document_annotations(document_id: str) -> list[AnnotationResponse]:
return all_annots
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.get("/{document_id}/export")
def export_document(document_id: str):
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available.",
)
d = document_store.get_document(document_id)
if not d:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
try:
doc = d["doc_instance"]
bytes_data = doc.save_full()
filename = d["filename"]
if not filename.endswith(".pdf"):
filename += ".pdf"
return Response(
content=bytes_data,
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Length": str(len(bytes_data)),
},
)
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
+2 -1
View File
@@ -251,7 +251,8 @@ def apply_edits_impl(document_id: str, request: EditsRequest):
edits_json = json.dumps(req_dict)
doc.apply_edits(edits_json)
new_bytes = doc.save_incremental()
has_redaction = any(op.get("type") == "redaction" for op in req_dict.get("operations", []))
new_bytes = doc.save_full() if has_redaction else doc.save_incremental()
new_doc = pdfengine.PdfDocument.load_from_memory(new_bytes)
+16 -9
View File
@@ -1,6 +1,6 @@
from typing import List, Annotated
from fastapi import APIRouter, HTTPException, status, Response, Path, Query
from fastapi import APIRouter, HTTPException, Response, status
from typing import Annotated
from fastapi import APIRouter, HTTPException, Path, Query, Response, status
from app.routers.documents import FontInfoResponse
from app.services import engine
@@ -11,7 +11,9 @@ compat_router = APIRouter(tags=["render"])
@router.get("/{page_index}/render")
def render_page(document_id: str, page_index: Annotated[int, Path(ge=0)], dpi: int = 96) -> Response:
def render_page(
document_id: str, page_index: Annotated[int, Path(ge=0)], dpi: int = 96
) -> Response:
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
@@ -205,17 +207,23 @@ def get_page_fonts(document_id: str, page_index: int) -> list[FontInfoResponse]:
@router.get("/{page_index}/fonts/glyph-width")
def get_page_glyph_width(document_id: str, page_index: Annotated[int, Path(ge=0)], font_name: str, charcode: int, font_size: Annotated[float, Query(gt=0)] = 12.0):
def get_page_glyph_width(
document_id: str,
page_index: Annotated[int, Path(ge=0)],
font_name: str,
charcode: int,
font_size: Annotated[float, Query(gt=0)] = 12.0,
):
if not engine.is_available():
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Engine bridge (bindings/python) not yet available."
detail="Engine bridge (bindings/python) not yet available.",
)
doc_info = document_store.get_document(document_id)
if not doc_info:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
try:
doc = doc_info["doc_instance"]
page = doc.get_page(page_index)
@@ -225,4 +233,3 @@ def get_page_glyph_width(document_id: str, page_index: Annotated[int, Path(ge=0)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Page index out of range")
except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+1 -1
View File
@@ -1,6 +1,6 @@
import threading
import uuid
from datetime import UTC, datetime,timezone
from datetime import UTC, datetime
from typing import Any
+49 -31
View File
@@ -6,9 +6,10 @@ Validates: upload, document fonts, page fonts, text extraction, metadata accurac
"""
import json
import sys
import os
import sys
import time
import httpx
BASE_URL = "http://localhost:8000"
@@ -22,11 +23,25 @@ TARGET_PDFS = [
]
REQUIRED_FONT_FIELDS = [
"fontName", "type", "isEmbedded", "isSubset", "isVertical",
"encoding", "cmapName", "cidSystemInfo", "subsetTag",
"sourceType", "substitutedFrom", "substitutedTo",
"normalizedFamily", "internalFontId", "flags",
"ascent", "descent", "capHeight", "hasToUnicode",
"fontName",
"type",
"isEmbedded",
"isSubset",
"isVertical",
"encoding",
"cmapName",
"cidSystemInfo",
"subsetTag",
"sourceType",
"substitutedFrom",
"substitutedTo",
"normalizedFamily",
"internalFontId",
"flags",
"ascent",
"descent",
"capHeight",
"hasToUnicode",
]
@@ -45,7 +60,9 @@ def validate_font_fields(font: dict, pdf_name: str, font_index: int) -> list:
issues = []
for field in REQUIRED_FONT_FIELDS:
if field not in font:
issues.append(f" [MISSING] Font #{font_index} ({font.get('fontName', '?')}): field '{field}' is missing")
issues.append(
f" [MISSING] Font #{font_index} ({font.get('fontName', '?')}): field '{field}' is missing"
)
return issues
@@ -190,7 +207,7 @@ def validate_pdf(client: httpx.Client, pdf_filename: str) -> dict:
result["issues"].append(f"Page fonts not in document fonts: {extra_in_page}")
print(f" [ISSUE] Page has fonts not in document-level: {extra_in_page}")
else:
print(f" [OK] Page fonts are a subset of document fonts")
print(" [OK] Page fonts are a subset of document fonts")
except Exception as e:
result["errors"].append(f"Page fonts exception: {e}")
@@ -210,7 +227,7 @@ def validate_pdf(client: httpx.Client, pdf_filename: str) -> dict:
glyphs = text_data.get("glyphs", [])
result["glyph_count"] = len(glyphs)
print(f" Extracted text: {repr(text_content[:300])}")
print(f" Extracted text: {text_content[:300]!r}")
print(f" Glyph count: {len(glyphs)}")
if not text_content.strip():
@@ -220,7 +237,7 @@ def validate_pdf(client: httpx.Client, pdf_filename: str) -> dict:
result["issues"].append("Text extraction: no glyphs returned")
else:
# Show first 5 glyphs as samples
print(f"\n First 5 glyphs (sample):")
print("\n First 5 glyphs (sample):")
for i, g in enumerate(glyphs[:5]):
print(f" Glyph #{i}: {json.dumps(g, indent=6)}")
@@ -250,34 +267,36 @@ def validate_pdf(client: httpx.Client, pdf_filename: str) -> dict:
print(f" Vertical text PDF - fonts with isVertical=true: {len(vertical_fonts)}")
if len(vertical_fonts) == 0:
result["issues"].append("vertical_text.pdf: No fonts have isVertical=true")
print(f" [ISSUE] No vertical fonts detected!")
print(" [ISSUE] No vertical fonts detected!")
else:
for vf in vertical_fonts:
print(f" - {vf['fontName']} (isVertical=true)")
print(f" [OK] Vertical fonts detected correctly")
print(" [OK] Vertical fonts detected correctly")
if "subset" in pdf_filename.lower() and result["doc_fonts"]:
subset_fonts = [f for f in result["doc_fonts"] if f.get("isSubset")]
print(f" Subset font PDF - fonts with isSubset=true: {len(subset_fonts)}")
if len(subset_fonts) == 0:
result["issues"].append("subset_font.pdf: No fonts have isSubset=true")
print(f" [ISSUE] No subset fonts detected!")
print(" [ISSUE] No subset fonts detected!")
else:
for sf in subset_fonts:
print(f" - {sf['fontName']} (isSubset=true, subsetTag='{sf.get('subsetTag', '')}')")
print(f" [OK] Subset fonts detected correctly")
print(
f" - {sf['fontName']} (isSubset=true, subsetTag='{sf.get('subsetTag', '')}')"
)
print(" [OK] Subset fonts detected correctly")
if "utf" in pdf_filename.lower() and result["text_result"]:
text = result["text_result"].get("text", "")
print(f" UTF-8 PDF - extracted text: {repr(text[:300])}")
print(f" UTF-8 PDF - extracted text: {text[:300]!r}")
# Check for non-ASCII characters
non_ascii = [c for c in text if ord(c) > 127]
if non_ascii:
print(f" Non-ASCII characters found: {len(non_ascii)} chars")
print(f" Sample non-ASCII: {repr(''.join(non_ascii[:30]))}")
print(f" [OK] UTF-8 text extracts with non-ASCII content")
print(f" Sample non-ASCII: {''.join(non_ascii[:30])!r}")
print(" [OK] UTF-8 text extracts with non-ASCII content")
else:
print(f" [INFO] No non-ASCII characters detected - content may be ASCII-only")
print(" [INFO] No non-ASCII characters detected - content may be ASCII-only")
return result
@@ -327,10 +346,10 @@ def main():
print(f" Glyph Count: {r['glyph_count']}")
print(f" Font Sizes: {sorted(r['font_sizes']) if r['font_sizes'] else 'N/A'}")
print(f" Issues: {len(r['issues'])}")
for issue in r['issues']:
for issue in r["issues"]:
print(f" >> {issue}")
print(f" Errors: {len(r['errors'])}")
for err in r['errors']:
for err in r["errors"]:
print(f" XX {err}")
# Summary Answers
@@ -340,18 +359,17 @@ def main():
total_errors = sum(len(r["errors"]) for r in results)
all_fonts_extracted = all(r["doc_font_count"] > 0 for r in results if not r["errors"])
print(f" 1. Are fonts being extracted correctly?")
print(f" {'YES' if all_fonts_extracted else 'NO'} - {sum(r['doc_font_count'] for r in results)} total fonts across {len(results)} PDFs")
page_doc_consistent = all(
not any("not in document" in i for i in r["issues"])
for r in results
print(" 1. Are fonts being extracted correctly?")
print(
f" {'YES' if all_fonts_extracted else 'NO'} - {sum(r['doc_font_count'] for r in results)} total fonts across {len(results)} PDFs"
)
print(f"\n 2. Are page fonts and document fonts consistent?")
page_doc_consistent = all(not any("not in document" in i for i in r["issues"]) for r in results)
print("\n 2. Are page fonts and document fonts consistent?")
print(f" {'YES' if page_doc_consistent else 'NO'}")
font_sizes_ok = all(r["glyph_count"] > 0 for r in results if not r["errors"])
print(f"\n 3. Are font sizes being extracted correctly?")
print("\n 3. Are font sizes being extracted correctly?")
print(f" {'YES' if font_sizes_ok else 'NO'}")
# Check vertical/subset
@@ -363,11 +381,11 @@ def main():
if "subset" in r["filename"] and any("isSubset" in i for i in r["issues"]):
subset_ok = False
print(f"\n 4. Are vertical/subset fonts detected correctly?")
print("\n 4. Are vertical/subset fonts detected correctly?")
print(f" Vertical: {'YES' if vertical_ok else 'NO'}")
print(f" Subset: {'YES' if subset_ok else 'NO'}")
print(f"\n 5. Are there any metadata inaccuracies?")
print("\n 5. Are there any metadata inaccuracies?")
if total_issues == 0 and total_errors == 0:
print(f" NO - All {len(results)} PDFs passed validation cleanly")
else:
+32 -45
View File
@@ -1,12 +1,6 @@
<<<<<<< HEAD
import re
import pytest
from fastapi.testclient import TestClient
import sys
=======
import contextlib
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
import os
import re
import sys
from fastapi.testclient import TestClient
@@ -21,12 +15,9 @@ with contextlib.suppress(ImportError):
client = TestClient(app)
<<<<<<< HEAD
# Regex: six uppercase ASCII letters followed by '+'
SUBSET_PREFIX_RE = re.compile(r'^[A-Z]{6}\+')
SUBSET_PREFIX_RE = re.compile(r"^[A-Z]{6}\+")
=======
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
def get_doc_id(filename: str) -> str:
"""Upload a PDF from corpus/fonts and return its document ID."""
@@ -37,13 +28,11 @@ def get_doc_id(filename: str) -> str:
return resp.json()["id"]
<<<<<<< HEAD
# =========================================================================
# 1. Vertical font regression
# =========================================================================
=======
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
def test_is_vertical_regression():
"""Identity-V fonts must be flagged isVertical; horizontal fonts must not."""
doc_id = get_doc_id("vertical_text.pdf")
@@ -55,10 +44,7 @@ def test_is_vertical_regression():
assert font["isVertical"] is True
assert font["encoding"] == "Identity-V"
<<<<<<< HEAD
=======
# 2. Verify horizontal fonts are not falsely detected as vertical
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
doc_id_h = get_doc_id("utf-8.pdf")
resp_h = client.get(f"/documents/{doc_id_h}/fonts")
fonts_h = resp_h.json()
@@ -67,11 +53,11 @@ def test_is_vertical_regression():
assert f["isVertical"] is False
<<<<<<< HEAD
# =========================================================================
# 2. Internal Font ID: no duplicate subset prefix (core regression)
# =========================================================================
def test_internal_font_id_no_duplicate_prefix():
"""
Regression: subset fonts must NOT produce "ABCDEF_ABCDEF+Arial".
@@ -90,9 +76,9 @@ def test_internal_font_id_no_duplicate_prefix():
if font.get("isSubset") and tag:
# Must NOT start with "TAG_TAG"
assert not fid.startswith(tag + "_" + tag), (
f"Duplicate subset prefix detected: internalFontId='{fid}'"
)
assert not fid.startswith(
tag + "_" + tag
), f"Duplicate subset prefix detected: internalFontId='{fid}'"
# Must equal fontName directly (e.g. "ABCDEF+Arial")
assert fid == font["fontName"], (
f"Expected internalFontId==fontName for subset font, "
@@ -105,10 +91,6 @@ def test_internal_font_id_subset_format():
For any subset font the internalFontId must match the pattern
ABCDEF+BaseName — exactly the fontName reported by PDFium.
"""
=======
def test_internal_font_id_regression():
# Verify subset fonts do not duplicate subset prefixes
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
doc_id = get_doc_id("text_font.pdf")
resp = client.get(f"/documents/{doc_id}/fonts")
assert resp.status_code == 200
@@ -118,17 +100,33 @@ def test_internal_font_id_regression():
for font in fonts:
if font.get("isSubset"):
assert font["subsetTag"] in font["fontName"]
<<<<<<< HEAD
# internalFontId == fontName (e.g. "ABCDEF+Arial")
assert font["internalFontId"] == font["fontName"]
# The ID must contain exactly one '+' from the subset tag
assert font["internalFontId"].count("+") == 1
def test_internal_font_id_regression():
# Verify subset fonts do not duplicate subset prefixes
doc_id = get_doc_id("text_font.pdf")
resp = client.get(f"/documents/{doc_id}/fonts")
assert resp.status_code == 200
fonts = resp.json()
assert len(fonts) > 0
for font in fonts:
if font.get("isSubset"):
assert font["subsetTag"] in font["fontName"]
assert not font["internalFontId"].startswith(
font["subsetTag"] + "_" + font["subsetTag"]
)
# =========================================================================
# 3. Non-subset font ID format
# =========================================================================
def test_internal_font_id_non_subset_format():
"""
Non-subset fonts must have internalFontId = fontName_type_flags.
@@ -155,6 +153,7 @@ def test_internal_font_id_non_subset_format():
# 4. ID stability across document-level and page-level APIs
# =========================================================================
def test_font_id_stable_across_apis():
"""
The internalFontId for the same font must be identical whether queried
@@ -186,12 +185,7 @@ def test_font_id_stable_across_apis():
# =========================================================================
# 5. CID collection regression
# =========================================================================
=======
assert not font["internalFontId"].startswith(
font["subsetTag"] + "_" + font["subsetTag"]
)
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
def test_cid_collection_regression():
"""Adobe CID collections must use the 'Adobe-' prefix."""
@@ -204,11 +198,10 @@ def test_cid_collection_regression():
assert "Adobe-" in font["cidSystemInfo"]
<<<<<<< HEAD
def test_cns1_regression():
"""Verify Adobe-CNS1 (Traditional Chinese) CID fonts and text extraction."""
doc_id = get_doc_id("cns1_test.pdf")
# 1. Verify font extraction
resp = client.get(f"/documents/{doc_id}/fonts")
assert resp.status_code == 200
@@ -216,19 +209,19 @@ def test_cns1_regression():
assert len(fonts) > 0
cns1_fonts = [f for f in fonts if f.get("cidSystemInfo") == "Adobe-CNS1"]
assert len(cns1_fonts) > 0, "No Adobe-CNS1 fonts detected"
# 2. Verify text extraction
resp = client.get(f"/documents/{doc_id}/pages/0/text")
assert resp.status_code == 200
text = resp.json()["text"]
assert "\u4e00\u4e2d\u4ed7" in text, "Failed to extract Traditional Chinese text"
# =========================================================================
# 6. UTF-8 corpus regression
# =========================================================================
=======
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
def test_utf8_corpus_regression():
doc_id = get_doc_id("utf-8.pdf")
resp = client.get(f"/documents/{doc_id}/pages/0/text")
@@ -238,26 +231,24 @@ def test_utf8_corpus_regression():
assert len(data["text"]) > 0
<<<<<<< HEAD
# =========================================================================
# 7. Font size regression
# =========================================================================
=======
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
def test_font_size_regression():
doc_id = get_doc_id("utf-8.pdf")
resp = client.get(f"/documents/{doc_id}/pages/0/text")
data = resp.json()
sizes = set(g["fontSize"] for g in data["glyphs"])
assert len(sizes) >= 2 # utf-8.pdf should have multiple font sizes
<<<<<<< HEAD
# =========================================================================
# 8. ID uniqueness
# =========================================================================
def test_font_ids_unique_within_document():
"""Each font within a document must have a distinct internalFontId."""
for pdf in ("subset_font.pdf", "utf-8.pdf", "vertical_text.pdf"):
@@ -270,8 +261,4 @@ def test_font_ids_unique_within_document():
fonts = resp.json()
ids = [f["internalFontId"] for f in fonts]
assert len(ids) == len(set(ids)), (
f"Duplicate internalFontId values in {pdf}: {ids}"
)
=======
>>>>>>> 2638dfe969874b954502b26656df48ad971e306d
assert len(ids) == len(set(ids)), f"Duplicate internalFontId values in {pdf}: {ids}"
+9 -10
View File
@@ -1,6 +1,8 @@
import pytest
import os
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
@@ -12,10 +14,7 @@ def get_doc_id(filename: str) -> str:
if not os.path.exists(filepath):
filepath = os.path.abspath(f"../corpus/fonts/{filename}")
with open(filepath, "rb") as f:
resp = client.post(
"/documents",
files={"file": (filename, f, "application/pdf")}
)
resp = client.post("/documents", files={"file": (filename, f, "application/pdf")})
assert resp.status_code == 201, f"Failed to load {filename}: {resp.json()}"
return resp.json()["id"]
@@ -36,7 +35,7 @@ def test_glyph_width_proportionality():
# 2. Get width of wide character 'W' (charcode 87)
w_resp = client.get(
f"/documents/{doc_id}/pages/0/fonts/glyph-width",
params={"font_name": font_name, "charcode": 87, "font_size": 12.0}
params={"font_name": font_name, "charcode": 87, "font_size": 12.0},
)
assert w_resp.status_code == 200
w_width = w_resp.json()["width"]
@@ -45,7 +44,7 @@ def test_glyph_width_proportionality():
# 3. Get width of narrow character 'i' (charcode 105)
i_resp = client.get(
f"/documents/{doc_id}/pages/0/fonts/glyph-width",
params={"font_name": font_name, "charcode": 105, "font_size": 12.0}
params={"font_name": font_name, "charcode": 105, "font_size": 12.0},
)
assert i_resp.status_code == 200
i_width = i_resp.json()["width"]
@@ -65,7 +64,7 @@ def test_glyph_width_font_size_scaling():
# Width at 12pt
resp_12 = client.get(
f"/documents/{doc_id}/pages/0/fonts/glyph-width",
params={"font_name": font_name, "charcode": 65, "font_size": 12.0}
params={"font_name": font_name, "charcode": 65, "font_size": 12.0},
)
assert resp_12.status_code == 200
width_12 = resp_12.json()["width"]
@@ -73,7 +72,7 @@ def test_glyph_width_font_size_scaling():
# Width at 24pt
resp_24 = client.get(
f"/documents/{doc_id}/pages/0/fonts/glyph-width",
params={"font_name": font_name, "charcode": 65, "font_size": 24.0}
params={"font_name": font_name, "charcode": 65, "font_size": 24.0},
)
assert resp_24.status_code == 200
width_24 = resp_24.json()["width"]
@@ -87,7 +86,7 @@ def test_glyph_width_invalid_font():
doc_id = get_doc_id("utf-8.pdf")
resp = client.get(
f"/documents/{doc_id}/pages/0/fonts/glyph-width",
params={"font_name": "NonExistentFontName123", "charcode": 65, "font_size": 12.0}
params={"font_name": "NonExistentFontName123", "charcode": 65, "font_size": 12.0},
)
assert resp.status_code == 400
assert "detail" in resp.json()
+63 -1
View File
@@ -137,7 +137,7 @@ def test_extract_page_text(client: TestClient):
first_glyph = glyphs[0]
for key in ["text", "x", "y", "w", "h", "fontSize"]:
assert key in first_glyph
for g in glyphs:
assert g["fontSize"] != 1.0, f"Fake fontSize 1.0 detected for glyph: {g}"
assert g["text"] not in ["\r", "\n"], f"Control character detected in glyph bounds: {g}"
@@ -275,6 +275,46 @@ def test_apply_page_rotation_and_incremental_save(client: TestClient):
assert render_resp.headers["content-type"] == "image/png"
def test_apply_redaction_and_full_save(client: TestClient):
with open(HELLO_WORLD_PDF, "rb") as f:
upload_resp = client.post(
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
)
doc_id = upload_resp.json()["id"]
# Apply redaction edit operation
edits_payload = {
"version": "1.0",
"operations": [
{
"id": "op_route_redact_test_123",
"type": "redaction",
"pageIndex": 0,
"data": {
"x": 0.0,
"y": 0.0,
"width": 612.0,
"height": 792.0,
"fillColor": "#ffffff",
},
}
],
}
edits_resp = client.post(f"/documents/{doc_id}/edits", json=edits_payload)
assert edits_resp.status_code == 200
payload = edits_resp.json()
assert payload["success"] is True
new_doc_id = payload["newDocumentId"]
assert new_doc_id != doc_id
# Verify text is deleted from the page
text_resp = client.get(f"/documents/{new_doc_id}/pages/0/text")
assert text_resp.status_code == 200
assert "hello" not in text_resp.json()["text"].lower()
assert "world" not in text_resp.json()["text"].lower()
def test_apply_page_deletion_and_incremental_save(client: TestClient):
# We need a 2-page PDF to test deletion
two_pages_pdf = CORPUS_DIR / "basic" / "hello_world_2_pages.pdf"
@@ -513,3 +553,25 @@ def test_font_size_and_diagnostics_advanced(client: TestClient):
)
assert has_vertical, "Expected to find a vertical font in vertical_text.pdf"
def test_export_document(client: TestClient):
# 1. Upload sample document
with open(HELLO_WORLD_PDF, "rb") as f:
upload_resp = client.post(
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
)
assert upload_resp.status_code == 201
doc_id = upload_resp.json()["id"]
# 2. Call export endpoint
export_resp = client.get(f"/documents/{doc_id}/export")
assert export_resp.status_code == 200
assert export_resp.headers["content-type"] == "application/pdf"
assert "attachment" in export_resp.headers["content-disposition"]
assert HELLO_WORLD_PDF.name in export_resp.headers["content-disposition"]
# 3. Assert content is PDF
content = export_resp.content
assert len(content) > 0
assert content.startswith(b"%PDF")
+229
View File
@@ -0,0 +1,229 @@
import openpyxl
import copy
import shutil
import os
def update_timeline():
excel_path = "PDF Editor Timeline.xlsx"
backup_path = "PDF Editor Timeline.xlsx.bak"
if os.path.exists(excel_path):
shutil.copyfile(excel_path, backup_path)
print(f"Created backup at {backup_path}")
else:
print(f"Error: {excel_path} not found.")
return
wb = openpyxl.load_workbook(excel_path)
tasks_data = {
"FreeType + HarfBuzz wrappers + LRU glyph cache": [
("FreeType Integration", "1 Day", "22-05-26", "22-05-26", "FreeType library initialized; FT_Face successfully loaded from font buffer", "", "Base integration for the entire typography engine.", "Wk 2", "Wk 2", "Critical"),
("HarfBuzz Integration", "1 Day", "22-05-26", "22-05-26", "HarfBuzz library linked; hb_font and hb_buffer created from FT_Face", "", "Required for shaping Unicode and layout engine.", "Wk 2", "Wk 2", "Critical"),
("FontFace Architecture", "1-2 Days", "22-05-26", "22-05-26", "RAII wrapper for FT_Face and hb_font_t; clean memory management", "", "Encapsulates font face lifecycle in C++.", "Wk 2", "Wk 2", "High"),
("FontLoader System", "1 Day", "22-05-26", "22-05-26", "Loads raw font binary data (TTF/OTF) from memory buffer into FontFace", "", "Supports loading from system or custom paths.", "Wk 2", "Wk 2", "High"),
("FontResolver System", "1-2 Days", "22-05-26", "22-05-26", "Resolves font requests (family, weight, italic) to best matching local font", "", "Determines which font to load dynamically.", "Wk 2", "Wk 2", "High"),
("Font Metrics Engine", "1 Day", "22-05-26", "23-05-26", "Calculates ascender, descender, line gap, and scale factors", "", "Ensures vertical text alignment is correct.", "Wk 2", "Wk 3", "High"),
("Advance Width Calculation", "1 Day", "22-05-26", "23-05-26", "Retrieves horizontal advances for individual glyphs via FreeType / HarfBuzz", "", "Needed to correctly advance cursor during text rendering.", "Wk 2", "Wk 3", "High"),
("Glyph Lookup System", "1 Day", "22-05-26", "23-05-26", "Maps unicode character points to glyph indices accurately", "", "Core of character representation.", "Wk 2", "Wk 3", "High"),
("Missing Glyph Detection", "1 Day", "23-05-26", "23-05-26", "Identifies when a font lacks a glyph for a codepoint; triggers fallback", "", "Prevents rendering blank spaces / boxes (tofu).", "Wk 3", "Wk 3", "Med"),
("LRU Glyph Cache", "1-2 Days", "22-05-26", "23-05-26", "Caches rendered glyph bitmaps; cache hits >90% on text corpus", "", "Crucial for text rendering performance.", "Wk 2", "Wk 3", "Critical"),
("Runtime Font Cache", "1 Day", "22-05-26", "23-05-26", "Stores loaded FontFace instances globally to prevent reloading same font resource", "", "Speeds up multi-page renders.", "Wk 2", "Wk 3", "High"),
("Thread-Safe Font Engine", "1 Day", "22-05-26", "23-05-26", "All font engine components are thread-safe; concurrent access protected by mutexes", "", "Supports background parsing and parallel rasterization.", "Wk 2", "Wk 3", "High"),
("Memory Management & RAII", "1 Day", "22-05-26", "22-05-26", "Zero memory leaks under ASan when loading and unloading fonts", "", "Strict enforcement of ownership semantics.", "Wk 2", "Wk 2", "Critical"),
("HarfBuzz Ligature & Kerning Support", "1 Day", "22-05-26", "23-05-26", "Applies advanced open type features (liga, kern) correctly during text shaping", "", "Ensures professional typesetting look.", "Wk 2", "Wk 3", "High"),
("Unicode Text Shaping Pipeline", "2 Days", "22-05-26", "23-05-26", "Shapes incoming UTF-8 string into shaped glyph run with correct advance and offsets", "", "Translates abstract string to concrete spatial coordinates.", "Wk 2", "Wk 3", "Critical")
],
"PDF font resource loader (Type1/TrueType/CID)": [
("PDF Font Resource Loader", "2 Days", "23-05-26", "25-05-26", "Loads font resource streams directly from PDF dictionaries", "", "Essential for rendering documents with embedded fonts.", "Wk 3", "Wk 3", "Critical"),
("TrueType Font Support", "1 Day", "23-05-26", "24-05-26", "Parses and renders standard TrueType (.ttf) fonts", "", "Handles glyph outline rendering for TrueType format.", "Wk 3", "Wk 3", "High"),
("OpenType Font Support", "1 Day", "24-05-26", "24-05-26", "Parses and renders standard OpenType (.otf) fonts", "", "Handles CFF outlines and advanced OpenType tables.", "Wk 3", "Wk 3", "High"),
("Type1 Font Support", "1 Day", "24-05-26", "25-05-26", "Parses and renders legacy Type 1 postscript fonts", "", "Needed for legacy PDF documents.", "Wk 3", "Wk 3", "High"),
("CIDFont Support", "2 Days", "25-05-26", "26-05-26", "Correctly maps character IDs to glyph indices for CIDFonts", "", "Crucial for large character sets (CJK).", "Wk 3", "Wk 4", "Critical"),
("Embedded Font Loading", "1 Day", "23-05-26", "24-05-26", "Extracts and loads embedded font file streams (FontFile, FontFile2, FontFile3)", "", "Prevents font missing errors.", "Wk 3", "Wk 3", "Critical"),
("Subset Font Loading", "1-2 Days", "24-05-26", "26-05-26", "Successfully loads fonts containing only a subset of glyphs", "", "Common optimization in PDFs; requires robust glyph-index mapping.", "Wk 3", "Wk 4", "High"),
("Font Metadata Extraction", "1 Day", "23-05-26", "24-05-26", "Parses font names, flags, subtypes, and panose data", "", "Useful for debugging and font substitution matching.", "Wk 3", "Wk 3", "Med"),
("CJK (Chinese/Japanese/Korean) Support", "2 Days", "24-05-26", "26-05-26", "Successfully renders double-byte character sets and vertical writing parameters", "", "Crucial for international document rendering.", "Wk 3", "Wk 4", "High"),
("PDFium ↔ Font Engine Integration", "2 Days", "24-05-26", "26-05-26", "Synchronizes PDFium's font extraction with our FreeType/HarfBuzz pipeline", "", "Binds the layout interpreter to our rendering path.", "Wk 3", "Wk 4", "Critical"),
("Lazy Font Loading", "1 Day", "24-05-26", "26-05-26", "Loads font resource streams only when a glyph from that font needs to be rendered", "", "Optimizes document open times and memory consumption.", "Wk 3", "Wk 4", "Med")
],
"Font substitution system (Liberation fonts)": [
("Font Fallback System", "1-2 Days", "25-05-26", "27-05-26", "Dynamically selects a fallback font when primary font lacks glyph", "", "Ensures all characters render.", "Wk 4", "Wk 4", "High"),
("Font Substitution System (Liberation Fonts)", "2 Days", "25-05-26", "01-06-26", "Substitutes missing system fonts (e.g. Arial) with metric-compatible Liberation fonts", "", "Keeps layout intact.", "Wk 4", "Wk 4", "High")
]
}
def copy_cell_style(src_cell, dest_cell):
if src_cell.has_style:
dest_cell.font = copy.copy(src_cell.font)
dest_cell.fill = copy.copy(src_cell.fill)
dest_cell.border = copy.copy(src_cell.border)
dest_cell.alignment = copy.copy(src_cell.alignment)
dest_cell.number_format = copy.copy(src_cell.number_format)
dest_cell.protection = copy.copy(src_cell.protection)
# 1. Update 📋 Master Timeline
ws = wb['📋 Master Timeline']
headers = [ws.cell(row=6, column=col).value for col in range(1, ws.max_column + 1)]
has_dates = "Start Date" in headers
if not has_dates:
print("Inserting Start Date and End Date columns in Master Timeline...")
# Insert 2 columns at Column K (index 11)
ws.insert_cols(11, 2)
ws.cell(row=6, column=11).value = "Start Date"
ws.cell(row=6, column=12).value = "End Date"
headers = [ws.cell(row=6, column=col).value for col in range(1, ws.max_column + 1)]
copy_cell_style(ws.cell(row=6, column=10), ws.cell(row=6, column=11))
copy_cell_style(ws.cell(row=6, column=10), ws.cell(row=6, column=12))
parent_rows = {}
for r in range(7, ws.max_row + 1):
task_name = ws.cell(row=r, column=3).value
if task_name in tasks_data:
parent_rows[task_name] = r
sorted_parents = sorted(parent_rows.items(), key=lambda x: x[1], reverse=True)
total_inserted = 0
for parent_name, row_idx in sorted_parents:
subtasks = tasks_data[parent_name]
num_sub = len(subtasks)
print(f"Found parent '{parent_name}' at row {row_idx}. Inserting {num_sub} subtasks below it.")
ws.insert_rows(row_idx + 1, num_sub)
for i, sub in enumerate(subtasks):
curr_row = row_idx + 1 + i
task_t, dur, sd, ed, sc, dep, notes, wk_s, wk_e, pri = sub
ws.cell(row=curr_row, column=1).value = "P1"
ws.cell(row=curr_row, column=2).value = "Phase 1"
ws.cell(row=curr_row, column=3).value = f" - {task_t}" # indented slightly for readability
ws.cell(row=curr_row, column=4).value = "Dev 3"
ws.cell(row=curr_row, column=5).value = wk_s
ws.cell(row=curr_row, column=6).value = wk_e
ws.cell(row=curr_row, column=7).value = dur
ws.cell(row=curr_row, column=8).value = "Done"
ws.cell(row=curr_row, column=9).value = pri
ws.cell(row=curr_row, column=10).value = 1.0 # 100% done
ws.cell(row=curr_row, column=11).value = sd
ws.cell(row=curr_row, column=12).value = ed
ws.cell(row=curr_row, column=13).value = sc
ws.cell(row=curr_row, column=14).value = dep if dep else None
ws.cell(row=curr_row, column=15).value = notes
for col in range(1, 16):
copy_cell_style(ws.cell(row=row_idx, column=col), ws.cell(row=curr_row, column=col))
total_inserted += num_sub
summary_row_idx = None
for r in range(7, ws.max_row + 1):
val = ws.cell(row=r, column=1).value
if val and isinstance(val, str) and "=COUNTIF" in val:
summary_row_idx = r
break
if summary_row_idx:
print(f"Summary row found at {summary_row_idx}. Updating formulas to include new rows.")
old_end_row = 69
new_end_row = 69 + total_inserted
# Update formulas
# A: Dev counts formula
# D: Priority counts formula
# H: Status counts formula
# K: average completion formula
# A
f_a = ws.cell(row=summary_row_idx, column=1).value
ws.cell(row=summary_row_idx, column=1).value = f_a.replace(f"D{old_end_row}", f"D{new_end_row}")
# D
f_d = ws.cell(row=summary_row_idx, column=4).value
ws.cell(row=summary_row_idx, column=4).value = f_d.replace(f"I{old_end_row}", f"I{new_end_row}")
# H
f_h = ws.cell(row=summary_row_idx, column=8).value
ws.cell(row=summary_row_idx, column=8).value = f_h.replace(f"H{old_end_row}", f"H{new_end_row}")
# K -> shifted to M (13)
f_k = ws.cell(row=summary_row_idx, column=13).value
if f_k and "J7:J69" in f_k:
ws.cell(row=summary_row_idx, column=13).value = f_k.replace(f"J{old_end_row}", f"J{new_end_row}")
print("Master Timeline formulas updated successfully.")
if '🔍 Phase Detail' in wb.sheetnames:
ws_det = wb['🔍 Phase Detail']
parent_rows_det = {}
for r in range(7, ws_det.max_row + 1):
task_name = ws_det.cell(row=r, column=3).value
if task_name in tasks_data:
parent_rows_det[task_name] = r
sorted_parents_det = sorted(parent_rows_det.items(), key=lambda x: x[1], reverse=True)
for parent_name, row_idx in sorted_parents_det:
subtasks = tasks_data[parent_name]
num_sub = len(subtasks)
ws_det.insert_rows(row_idx + 1, num_sub)
for i, sub in enumerate(subtasks):
curr_row = row_idx + 1 + i
task_t, dur, sd, ed, sc, dep, notes, wk_s, wk_e, pri = sub
ws_det.cell(row=curr_row, column=1).value = "P1"
ws_det.cell(row=curr_row, column=2).value = "Phase 1"
ws_det.cell(row=curr_row, column=3).value = f" - {task_t}"
ws_det.cell(row=curr_row, column=4).value = "Dev 3"
ws_det.cell(row=curr_row, column=5).value = wk_s
ws_det.cell(row=curr_row, column=6).value = wk_e
ws_det.cell(row=curr_row, column=7).value = dur
ws_det.cell(row=curr_row, column=8).value = "Done"
ws_det.cell(row=curr_row, column=9).value = pri
ws_det.cell(row=curr_row, column=10).value = 1.0
ws_det.cell(row=curr_row, column=11).value = sc
for col in range(1, 12):
copy_cell_style(ws_det.cell(row=row_idx, column=col), ws_det.cell(row=curr_row, column=col))
print("Phase Detail sheet updated successfully.")
if '📅 Gantt' in wb.sheetnames:
ws_gantt = wb['📅 Gantt']
parent_rows_g = {}
for r in range(5, ws_gantt.max_row + 1):
task_name = ws_gantt.cell(row=r, column=2).value
if task_name in tasks_data:
parent_rows_g[task_name] = r
sorted_parents_g = sorted(parent_rows_g.items(), key=lambda x: x[1], reverse=True)
for parent_name, row_idx in sorted_parents_g:
subtasks = tasks_data[parent_name]
num_sub = len(subtasks)
ws_gantt.insert_rows(row_idx + 1, num_sub)
for i, sub in enumerate(subtasks):
curr_row = row_idx + 1 + i
task_t, dur, sd, ed, sc, dep, notes, wk_s, wk_e, pri = sub
ws_gantt.cell(row=curr_row, column=1).value = "P1"
ws_gantt.cell(row=curr_row, column=2).value = f" - {task_t}"
ws_gantt.cell(row=curr_row, column=3).value = "Dev 3"
ws_gantt.cell(row=curr_row, column=4).value = dur
wk_s_num = int(wk_s.split()[1]) # 'Wk 2' -> 2
wk_e_num = int(wk_e.split()[1]) # 'Wk 3' -> 3
for wk in range(1, 21):
col_idx = 4 + wk
if wk_s_num <= wk <= wk_e_num:
ws_gantt.cell(row=curr_row, column=col_idx).value = ""
else:
ws_gantt.cell(row=curr_row, column=col_idx).value = None
for col in range(1, 25):
copy_cell_style(ws_gantt.cell(row=row_idx, column=col), ws_gantt.cell(row=curr_row, column=col))
print("Gantt sheet updated successfully.")
wb.save(excel_path)
print(f"Successfully saved updated timeline to {excel_path}!")
if __name__ == "__main__":
update_timeline()
+1 -1
View File
@@ -64,7 +64,7 @@ target_link_options(pdfengine_wasm PRIVATE
"-sENVIRONMENT=node,web"
"-sFILESYSTEM=0"
"-sALLOW_MEMORY_GROWTH=1"
"-sEXPORTED_FUNCTIONS=['_loadDocument','_renderPage','_freeDocument','_engineBuildInfo','_engineHasSkia','_malloc','_free']"
"-sEXPORTED_FUNCTIONS=['_loadDocument','_renderPage','_freeDocument','_engineBuildInfo','_engineHasSkia','_malloc','_free','_getPageTextJson']"
"-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','getValue','setValue','HEAPU8']"
)
+121
View File
@@ -86,6 +86,58 @@ std::expected<std::vector<pdfengine::GlyphBounds>, pdfengine::EngineError> WasmM
return glyphs;
}
std::expected<pdfengine::PageModel, pdfengine::EngineError> WasmMockPage::extractDocumentModel() const {
pdfengine::PageModel model;
model.width = width();
model.height = height();
model.pageIndex = m_pageIndex;
pdfengine::Paragraph p;
p.x = 60.0;
p.y = 80.0;
p.w = 100.0;
p.h = 20.0;
pdfengine::TextLine line;
line.x = 60.0;
line.y = 80.0;
line.w = 100.0;
line.h = 20.0;
line.baselineY = 80.0;
pdfengine::TextRun run;
run.text = "Page " + std::to_string(m_pageIndex + 1);
run.fontName = "Helvetica";
run.fontSize = 12.0;
run.x = 60.0;
run.y = 80.0;
run.w = 100.0;
run.h = 20.0;
double startX = 60.0;
double startY = 80.0;
for (size_t i = 0; i < run.text.length(); ++i) {
pdfengine::Glyph g;
g.text = std::string(1, run.text[i]);
g.unicode = run.text[i];
g.fontName = "Helvetica";
g.fontSize = 12.0;
g.originX = startX + i * 6.0;
g.originY = startY;
g.bboxX = g.originX;
g.bboxY = g.originY;
g.bboxW = 6.0;
g.bboxH = 8.0;
run.glyphs.push_back(g);
}
line.runs.push_back(run);
p.lines.push_back(line);
model.paragraphs.push_back(p);
return model;
}
std::expected<std::vector<std::string>, pdfengine::EngineError> WasmMockPage::extractAnnotationsText() const {
return std::vector<std::string>();
}
@@ -210,6 +262,20 @@ std::expected<std::vector<uint8_t>, pdfengine::EngineError> WasmMockDocument::sa
return m_data;
}
std::expected<std::vector<uint8_t>, pdfengine::EngineError> WasmMockDocument::getFontData(const std::string& internalFontId) const {
(void)internalFontId;
return std::unexpected(pdfengine::EngineError::Unknown);
}
std::expected<std::shared_ptr<pdfengine::fonts::pdf_fonts::Font>, std::string> WasmMockDocument::getResolvedFont(const pdfengine::FontInfo& fontInfo) {
(void)fontInfo;
return std::unexpected("Not implemented in WASM mock");
}
std::expected<std::vector<uint8_t>, pdfengine::EngineError> WasmMockDocument::saveFull() const {
return m_data;
}
// --- PdfEngineFacade Implementation ---
PdfEngineFacade::PdfEngineFacade() : m_nextHandle(1) {}
@@ -328,6 +394,45 @@ std::string fontInfosToJsonLocal(const std::vector<pdfengine::FontInfo>& fonts)
json += "]";
return json;
}
std::string escapeJsonString(const std::string& input) {
std::string output;
for (char c : input) {
if (c == '"') output += "\\\"";
else if (c == '\\') output += "\\\\";
else if (c == '\b') output += "\\b";
else if (c == '\f') output += "\\f";
else if (c == '\n') output += "\\n";
else if (c == '\r') output += "\\r";
else if (c == '\t') output += "\\t";
else if (static_cast<unsigned char>(c) < 32) {
char buf[16];
snprintf(buf, sizeof(buf), "\\u%04x", c);
output += buf;
} else {
output += c;
}
}
return output;
}
std::string glyphBoundsToJsonLocal(const std::vector<pdfengine::GlyphBounds>& glyphs) {
std::string json = "[";
for (size_t i = 0; i < glyphs.size(); ++i) {
const auto& g = glyphs[i];
if (i > 0) json += ",";
json += "{";
json += "\"text\":\"" + escapeJsonString(g.text) + "\",";
json += "\"x\":" + std::to_string(g.x) + ",";
json += "\"y\":" + std::to_string(g.y) + ",";
json += "\"w\":" + std::to_string(g.w) + ",";
json += "\"h\":" + std::to_string(g.h) + ",";
json += "\"fontSize\":" + std::to_string(g.fontSize);
json += "}";
}
json += "]";
return json;
}
} // namespace
std::string PdfEngineFacade::getDocumentFonts(int docHandle, int startPage, int endPage) {
@@ -357,3 +462,19 @@ std::string PdfEngineFacade::getPageFonts(int docHandle, int pageIndex) {
}
return fontInfosToJsonLocal(*result);
}
std::string PdfEngineFacade::getPageTextJson(int docHandle, int pageIndex) {
auto it = m_documents.find(docHandle);
if (it == m_documents.end()) {
return "[]";
}
auto pageRes = it->second->getPage(pageIndex);
if (!pageRes.has_value()) {
return "[]";
}
auto result = (*pageRes)->extractTextWithBounds();
if (!result.has_value()) {
return "[]";
}
return glyphBoundsToJsonLocal(*result);
}
+5
View File
@@ -19,6 +19,7 @@ public:
[[nodiscard]] std::expected<pdfengine::PageImage, pdfengine::EngineError> render(int dpi = 96) const override;
[[nodiscard]] std::expected<std::string, pdfengine::EngineError> extractText() const override;
[[nodiscard]] std::expected<std::vector<pdfengine::GlyphBounds>, pdfengine::EngineError> extractTextWithBounds() const override;
[[nodiscard]] std::expected<pdfengine::PageModel, pdfengine::EngineError> extractDocumentModel() const override;
[[nodiscard]] std::expected<std::vector<pdfengine::FontInfo>, pdfengine::EngineError> getFonts() const override;
[[nodiscard]] std::expected<std::vector<std::string>, pdfengine::EngineError> extractAnnotationsText() const override;
[[nodiscard]] std::expected<double, pdfengine::EngineError> getGlyphWidth(const std::string& fontName, uint32_t charcode, double fontSize) const override;
@@ -45,6 +46,9 @@ public:
std::expected<void, pdfengine::EngineError> applyEdits(const std::string& editsJson) override;
[[nodiscard]] std::expected<std::vector<uint8_t>, pdfengine::EngineError> saveIncremental() const override;
[[nodiscard]] std::expected<std::vector<uint8_t>, pdfengine::EngineError> getFontData(const std::string& internalFontId) const override;
[[nodiscard]] std::expected<std::shared_ptr<pdfengine::fonts::pdf_fonts::Font>, std::string> getResolvedFont(const pdfengine::FontInfo& fontInfo) override;
[[nodiscard]] std::expected<std::vector<uint8_t>, pdfengine::EngineError> saveFull() const override;
private:
int m_pageCount;
@@ -63,6 +67,7 @@ public:
std::string getDocumentFonts(int docHandle, int startPage, int endPage);
std::string getPageFonts(int docHandle, int pageIndex);
std::string getPageTextJson(int docHandle, int pageIndex);
static const char* buildInfo();
static bool hasSkia();
+6
View File
@@ -40,4 +40,10 @@ EMSCRIPTEN_KEEPALIVE const char* getPageFonts(int docHandle, int pageIndex) {
return s_buf.c_str();
}
EMSCRIPTEN_KEEPALIVE const char* getPageTextJson(int docHandle, int pageIndex) {
static std::string s_buf;
s_buf = g_facade.getPageTextJson(docHandle, pageIndex);
return s_buf.c_str();
}
}
+13
View File
@@ -123,6 +123,19 @@ assert.ok(pageFonts[0].descent < 0, "font descent should be negative");
const invalidFontsJson = getDocumentFonts(9999, 0, -1);
assert.equal(invalidFontsJson, "[]", "invalid docHandle should return empty array");
// 6d. Per-page text query (getPageTextJson)
const getPageTextJson = Module.cwrap("getPageTextJson", "string", ["number", "number"]);
const pageTextJson = getPageTextJson(docHandle2, 0);
console.log(`[pdfengine-smoke] getPageTextJson(page 0): ${pageTextJson}`);
const pageText = JSON.parse(pageTextJson);
assert.ok(Array.isArray(pageText), "getPageTextJson should return a JSON array");
assert.ok(pageText.length > 0, "getPageTextJson should return at least one entry");
assert.equal(pageText[0].text, "P", "First char text should be P");
assert.ok(pageText[0].fontSize > 0, "font size should be positive");
assert.ok(pageText[0].x > 0, "x should be positive");
assert.ok(pageText[0].y > 0, "y should be positive");
freeDocument(docHandle2);
Module._free(dataPtr2);