fix
This commit is contained in:
Binary file not shown.
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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_) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }
|
||||
]);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import UTC, datetime,timezone
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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']"
|
||||
)
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user