diff --git a/CMakeLists.txt b/CMakeLists.txt index 801a74b..fe7def4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,47 +32,49 @@ set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") if(EMSCRIPTEN) - message(STATUS "PdfEngine ${PROJECT_VERSION} — WASM hello-world configuration (Phase 0)") + set(PDFENGINE_WASM ON CACHE INTERNAL "Building for WebAssembly") +endif() + +if(PDFENGINE_WASM) + message(STATUS "PdfEngine ${PROJECT_VERSION} — WASM configuration (Phase 1/2 Enabled)") add_subdirectory(wasm) - return() +else() + # Only the top-level project drives testing/install defaults. + if(CMAKE_SOURCE_DIR STREQUAL CMAKE_BINARY_DIR) + message(FATAL_ERROR "In-source builds are not allowed. Use a preset: " + "cmake --preset -debug") + endif() + + option(PDFENGINE_BUILD_TESTS "Build engine unit/smoke tests" ON) + option(PDFENGINE_ENABLE_SANITIZERS "Build with AddressSanitizer/UBSan" OFF) + option(PDFENGINE_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" OFF) + option(PDFENGINE_WITH_PDFIUM "Link the PDFium static lib (build it first)" OFF) + option(PDFENGINE_WITH_SKIA "Link the Skia static lib (build it first)" OFF) + + include(CompilerWarnings) + include(Sanitizers) + include(pdfium) # defines pdfium::pdfium when PDFENGINE_WITH_PDFIUM is ON + include(skia) # defines skia::skia when PDFENGINE_WITH_SKIA is ON + + find_package(freetype CONFIG REQUIRED) + find_package(harfbuzz CONFIG REQUIRED) + find_package(spdlog CONFIG REQUIRED) + find_package(nlohmann_json CONFIG REQUIRED) + + if(PDFENGINE_BUILD_TESTS) + find_package(GTest CONFIG REQUIRED) + enable_testing() + include(GoogleTest) + endif() + + add_subdirectory(engine) + add_subdirectory(bindings) + + message(STATUS "PdfEngine ${PROJECT_VERSION} configured") + message(STATUS " C++ standard ......... ${CMAKE_CXX_STANDARD}") + message(STATUS " Build tests .......... ${PDFENGINE_BUILD_TESTS}") + message(STATUS " Sanitizers ........... ${PDFENGINE_ENABLE_SANITIZERS}") + message(STATUS " Warnings as errors ... ${PDFENGINE_WARNINGS_AS_ERRORS}") + message(STATUS " Link PDFium .......... ${PDFENGINE_WITH_PDFIUM}") + message(STATUS " Link Skia ............ ${PDFENGINE_WITH_SKIA}") endif() - -# Only the top-level project drives testing/install defaults. -if(CMAKE_SOURCE_DIR STREQUAL CMAKE_BINARY_DIR) - message(FATAL_ERROR "In-source builds are not allowed. Use a preset: " - "cmake --preset -debug") -endif() - -option(PDFENGINE_BUILD_TESTS "Build engine unit/smoke tests" ON) -option(PDFENGINE_ENABLE_SANITIZERS "Build with AddressSanitizer/UBSan" OFF) -option(PDFENGINE_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" OFF) -option(PDFENGINE_WITH_PDFIUM "Link the PDFium static lib (build it first)" OFF) -option(PDFENGINE_WITH_SKIA "Link the Skia static lib (build it first)" OFF) - -include(CompilerWarnings) -include(Sanitizers) -include(pdfium) # defines pdfium::pdfium when PDFENGINE_WITH_PDFIUM is ON -include(skia) # defines skia::skia when PDFENGINE_WITH_SKIA is ON - -find_package(freetype CONFIG REQUIRED) -find_package(harfbuzz CONFIG REQUIRED) -find_package(spdlog CONFIG REQUIRED) -find_package(nlohmann_json CONFIG REQUIRED) - -if(PDFENGINE_BUILD_TESTS) - find_package(GTest CONFIG REQUIRED) - enable_testing() - include(GoogleTest) -endif() - -add_subdirectory(engine) -add_subdirectory(bindings) - - -message(STATUS "PdfEngine ${PROJECT_VERSION} configured") -message(STATUS " C++ standard ......... ${CMAKE_CXX_STANDARD}") -message(STATUS " Build tests .......... ${PDFENGINE_BUILD_TESTS}") -message(STATUS " Sanitizers ........... ${PDFENGINE_ENABLE_SANITIZERS}") -message(STATUS " Warnings as errors ... ${PDFENGINE_WARNINGS_AS_ERRORS}") -message(STATUS " Link PDFium .......... ${PDFENGINE_WITH_PDFIUM}") -message(STATUS " Link Skia ............ ${PDFENGINE_WITH_SKIA}") diff --git a/bindings/python/pdfengine_py.cpp b/bindings/python/pdfengine_py.cpp index 26943e1..d4d59fb 100644 --- a/bindings/python/pdfengine_py.cpp +++ b/bindings/python/pdfengine_py.cpp @@ -96,6 +96,9 @@ PYBIND11_MODULE(pdfengine, m) { .def("extract_text", [](const pdfengine::PdfPage& self) { return get_or_throw(self.extractText()); }) + .def("extract_annotations_text", [](const pdfengine::PdfPage& self) { + return get_or_throw(self.extractAnnotationsText()); + }) .def("extract_text_with_bounds", [](const pdfengine::PdfPage& self) { auto res = get_or_throw(self.extractTextWithBounds()); py::list py_list; diff --git a/cmake/toolchains/vcpkg-wasm.cmake b/cmake/toolchains/vcpkg-wasm.cmake new file mode 100644 index 0000000..2077d2b --- /dev/null +++ b/cmake/toolchains/vcpkg-wasm.cmake @@ -0,0 +1,27 @@ +# WebAssembly vcpkg + Emscripten toolchain loader +# Chain-loads the vcpkg toolchain which then chain-loads the Emscripten compiler toolchain. + +if(NOT DEFINED ENV{EMSDK}) + message(FATAL_ERROR "EMSDK environment variable not set. Please activate Emscripten SDK first.") +endif() + +if(NOT DEFINED ENV{VCPKG_ROOT}) + message(FATAL_ERROR "VCPKG_ROOT environment variable not set. Please set VCPKG_ROOT first.") +endif() + +set(VCPKG_TARGET_TRIPLET "wasm32-emscripten" CACHE STRING "vcpkg WASM triplet") + +# Emscripten toolchain path +set(EMSDK_TOOLCHAIN "$ENV{EMSDK}/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake") +if(NOT EXISTS "${EMSDK_TOOLCHAIN}") + message(FATAL_ERROR "Emscripten toolchain not found at: ${EMSDK_TOOLCHAIN}") +endif() + +# Instruct vcpkg to chain-load the Emscripten toolchain file +set(VCPKG_CHAINLOAD_TOOLCHAIN_FILE "${EMSDK_TOOLCHAIN}" CACHE STRING "vcpkg chainload toolchain") + +# Include the main vcpkg toolchain +include("$ENV{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake") + +# Allow heap memory growth +set(CMAKE_EXE_LINKER_FLAGS_INIT "-sALLOW_MEMORY_GROWTH=1") diff --git a/engine/include/pdfengine/pdf_document.hpp b/engine/include/pdfengine/pdf_document.hpp index 2fddfd9..9a37330 100644 --- a/engine/include/pdfengine/pdf_document.hpp +++ b/engine/include/pdfengine/pdf_document.hpp @@ -66,6 +66,8 @@ public: [[nodiscard]] virtual std::expected, EngineError> extractTextWithBounds() const = 0; + [[nodiscard]] virtual std::expected, EngineError> extractAnnotationsText() const = 0; + [[nodiscard]] virtual DevicePoint pageToDevice(const Point2D& pagePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept = 0; [[nodiscard]] virtual Point2D deviceToPage(const DevicePoint& devicePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept = 0; }; diff --git a/engine/src/parser/pdfium_document.cpp b/engine/src/parser/pdfium_document.cpp index 66b1986..6d79f26 100644 --- a/engine/src/parser/pdfium_document.cpp +++ b/engine/src/parser/pdfium_document.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include "parser/pdfium_loader.hpp" #endif @@ -435,6 +436,39 @@ std::expected, EngineError> PdfiumPage::extractTextWith #endif } +std::expected, EngineError> PdfiumPage::extractAnnotationsText() const { +#ifdef PDFENGINE_WITH_PDFIUM + if (!page_) { + return std::unexpected(EngineError::Unknown); + } + std::vector result; + int count = FPDFPage_GetAnnotCount(page_); + for (int i = 0; i < count; ++i) { + FPDF_ANNOTATION annot = FPDFPage_GetAnnot(page_, i); + if (!annot) continue; + + if (FPDFAnnot_GetSubtype(annot) == FPDF_ANNOT_FREETEXT) { + unsigned long len = FPDFAnnot_GetStringValue(annot, "Contents", nullptr, 0); + if (len > 2) { + std::vector buf(len); + FPDFAnnot_GetStringValue(annot, "Contents", reinterpret_cast(buf.data()), len); + std::string text = utf16le_to_utf8(reinterpret_cast(buf.data()), len / sizeof(char16_t)); + while (!text.empty() && text.back() == '\0') { + text.pop_back(); + } + if (!text.empty()) { + result.push_back(text); + } + } + } + FPDFPage_CloseAnnot(annot); + } + return result; +#else + return std::unexpected(EngineError::Unknown); +#endif +} + DevicePoint PdfiumPage::pageToDevice(const Point2D& pagePoint, int deviceWidth, int deviceHeight, int rotate) const noexcept { #ifdef PDFENGINE_WITH_PDFIUM if (!page_) return {0, 0}; @@ -590,29 +624,50 @@ std::expected PdfiumDocument::applyEdits(const std::string& e return std::unexpected(EngineError::Unknown); } - // Create text object - FPDF_PAGEOBJECT textObj = FPDFPageObj_NewTextObj(doc_, "Helvetica", static_cast(fontSize)); - if (!textObj) { - spdlog::error("Failed to create PDF text object"); + // Create FreeText Annotation + FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, FPDF_ANNOT_FREETEXT); + if (!annot) { + spdlog::error("Failed to create FreeText annotation"); FPDF_ClosePage(page); return std::unexpected(EngineError::Unknown); } - // Convert text to UTF-16LE and set it - auto utf16 = utf8_to_utf16le(text); - if (!FPDFText_SetText(textObj, reinterpret_cast(utf16.data()))) { - spdlog::error("Failed to set text content on page object"); - FPDFPageObj_Destroy(textObj); + // Set bounding box (Rect) + FS_RECTF rect; + rect.left = static_cast(x); + rect.bottom = static_cast(y); + rect.right = static_cast(x + 200.0f); // Default width + rect.top = static_cast(y + fontSize * 1.5f); + if (!FPDFAnnot_SetRect(annot, &rect)) { + spdlog::error("Failed to set annotation rectangle"); + FPDFPage_CloseAnnot(annot); FPDF_ClosePage(page); return std::unexpected(EngineError::Unknown); } - // Translate the text object to (x, y) - FPDFPageObj_Transform(textObj, 1.0, 0.0, 0.0, 1.0, x, y); + // Set the Default Appearance (/DA) for Helvetica, black text + std::string da_string = "/Helv " + std::to_string(fontSize) + " Tf 0 g"; + auto da_utf16 = utf8_to_utf16le(da_string); + if (!FPDFAnnot_SetStringValue(annot, "DA", reinterpret_cast(da_utf16.data()))) { + spdlog::error("Failed to set annotation default appearance (DA)"); + FPDFPage_CloseAnnot(annot); + FPDF_ClosePage(page); + return std::unexpected(EngineError::Unknown); + } - // Insert into the page and generate content stream - FPDFPage_InsertObject(page, textObj); + // Set the text contents + auto contents_utf16 = utf8_to_utf16le(text); + if (!FPDFAnnot_SetStringValue(annot, "Contents", reinterpret_cast(contents_utf16.data()))) { + spdlog::error("Failed to set annotation contents"); + FPDFPage_CloseAnnot(annot); + FPDF_ClosePage(page); + return std::unexpected(EngineError::Unknown); + } + + // Close annotation handle + FPDFPage_CloseAnnot(annot); + // Regenerate page visual representation if needed if (!FPDFPage_GenerateContent(page)) { spdlog::error("Failed to generate page content after editing"); FPDF_ClosePage(page); diff --git a/engine/src/parser/pdfium_document.hpp b/engine/src/parser/pdfium_document.hpp index 8eec6d6..891e2fc 100644 --- a/engine/src/parser/pdfium_document.hpp +++ b/engine/src/parser/pdfium_document.hpp @@ -40,6 +40,7 @@ public: std::expected render(int dpi = 96) const override; std::expected extractText() const override; std::expected, EngineError> extractTextWithBounds() const override; + std::expected, EngineError> extractAnnotationsText() const override; DevicePoint pageToDevice(const Point2D& pagePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept override; Point2D deviceToPage(const DevicePoint& devicePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept override; diff --git a/engine/tests/document_test.cpp b/engine/tests/document_test.cpp index 62a39d9..d677c95 100644 --- a/engine/tests/document_test.cpp +++ b/engine/tests/document_test.cpp @@ -419,10 +419,16 @@ TEST(DocumentEditTest, ApplyEditsAndIncrementalSave) { ASSERT_TRUE(newPageRes.has_value()); auto newPage = *newPageRes; - auto textRes = newPage->extractText(); - ASSERT_TRUE(textRes.has_value()); - std::string text = *textRes; - EXPECT_NE(text.find("UniqueEditedTextAnnotation123"), std::string::npos); + auto annotsRes = newPage->extractAnnotationsText(); + ASSERT_TRUE(annotsRes.has_value()); + bool found = false; + for (const auto& annotText : *annotsRes) { + if (annotText.find("UniqueEditedTextAnnotation123") != std::string::npos) { + found = true; + break; + } + } + EXPECT_TRUE(found); } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2ec3aeb..8bccfe2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -59,6 +59,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -268,27 +269,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1107,6 +1087,7 @@ "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1117,6 +1098,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1176,6 +1158,7 @@ "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", @@ -1406,6 +1389,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1496,6 +1480,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1643,6 +1628,7 @@ "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -2538,6 +2524,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2598,6 +2585,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -2769,6 +2757,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2854,6 +2843,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -2978,6 +2968,7 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/public/pdfengine.mjs b/frontend/public/pdfengine.mjs new file mode 100644 index 0000000..db974fc --- /dev/null +++ b/frontend/public/pdfengine.mjs @@ -0,0 +1,2 @@ +async function Module(moduleArg={}){var moduleRtn;var Module=moduleArg;var ENVIRONMENT_IS_WEB=!!globalThis.window;var ENVIRONMENT_IS_WORKER=!!globalThis.WorkerGlobalScope;var ENVIRONMENT_IS_NODE=globalThis.process?.versions?.node&&globalThis.process?.type!="renderer";if(ENVIRONMENT_IS_NODE){const{createRequire}=await import("node:module");var require=createRequire(import.meta.url)}var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var _scriptName=import.meta.url;var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require("node:fs");if(_scriptName.startsWith("file:")){scriptDirectory=require("node:path").dirname(require("node:url").fileURLToPath(_scriptName))+"/"}readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename);return ret};readAsync=async(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename,binary?undefined:"utf8");return ret};if(process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{readAsync=async url=>{var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var isFileURI=filename=>filename.startsWith("file://");class EmscriptenEH{}class EmscriptenSjLj extends EmscriptenEH{}var readyPromiseResolve,readyPromiseReject;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;HEAP8=new Int8Array(b);HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);HEAPU16=new Uint16Array(b);HEAP32=new Int32Array(b);HEAPU32=new Uint32Array(b);HEAPF32=new Float32Array(b);HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;wasmExports["e"]()}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(onPostRuns)}function abort(what){Module["onAbort"]?.(what);what=`Aborted(${what})`;err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject?.(e);throw e}var wasmBinaryFile;function findWasmBinary(){if(Module["locateFile"]){return locateFile("pdfengine.wasm")}return new URL("pdfengine.wasm",import.meta.url).href}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){if(!binary&&!ENVIRONMENT_IS_NODE){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation")}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){var imports={a:wasmImports};return imports}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;assignWasmExports(wasmExports);updateMemoryViews();return wasmExports}function receiveInstantiationResult(result){return receiveInstance(result["instance"])}var info=getWasmImports();if(Module["instantiateWasm"]){return new Promise((resolve,reject)=>{Module["instantiateWasm"](info,(inst,mod)=>{resolve(receiveInstance(inst,mod))})})}wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var HEAP16;var HEAP32;var HEAP64;var HEAP8;var HEAPF32;var HEAPF64;var HEAPU16;var HEAPU32;var HEAPU64;var HEAPU8;var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);function getValue(ptr,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":return HEAP8[ptr];case"i8":return HEAP8[ptr];case"i16":return HEAP16[ptr>>1];case"i32":return HEAP32[ptr>>2];case"i64":return HEAP64[ptr>>3];case"float":return HEAPF32[ptr>>2];case"double":return HEAPF64[ptr>>3];case"*":return HEAPU32[ptr>>2];default:abort(`invalid type for getValue: ${type}`)}}var noExitRuntime=true;function setValue(ptr,value,type="i8"){if(type.endsWith("*"))type="*";switch(type){case"i1":HEAP8[ptr]=value;break;case"i8":HEAP8[ptr]=value;break;case"i16":HEAP16[ptr>>1]=value;break;case"i32":HEAP32[ptr>>2]=value;break;case"i64":HEAP64[ptr>>3]=BigInt(value);break;case"float":HEAPF32[ptr>>2]=value;break;case"double":HEAPF64[ptr>>3]=value;break;case"*":HEAPU32[ptr>>2]=value;break;default:abort(`invalid type for setValue: ${type}`)}}var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var uncaughtExceptionCount=0;var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);uncaughtExceptionCount++;abort()};var __abort_js=()=>abort("");var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var oldHeapSize=wasmMemory.buffer.byteLength;var pages=(size-oldHeapSize+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63;i++}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=globalThis.TextDecoder&&new TextDecoder;var findStringEnd=(heapOrArray,idx,maxBytesToRead,ignoreNul)=>{var maxIdx=idx+maxBytesToRead;if(ignoreNul)return maxIdx;while(heapOrArray[idx]&&!(idx>=maxIdx))++idx;return idx};var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead,ignoreNul)=>{var endPtr=findStringEnd(heapOrArray,idx,maxBytesToRead,ignoreNul);if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead,ignoreNul)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead,ignoreNul):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["print"])out=Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].shift()()}}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;Module["setValue"]=setValue;Module["getValue"]=getValue;var _loadDocument,_renderPage,_freeDocument,_engineBuildInfo,_engineHasSkia,_malloc,_free,__emscripten_stack_restore,__emscripten_stack_alloc,_emscripten_stack_get_current,memory,__indirect_function_table,wasmMemory;function assignWasmExports(wasmExports){_loadDocument=Module["_loadDocument"]=wasmExports["f"];_renderPage=Module["_renderPage"]=wasmExports["g"];_freeDocument=Module["_freeDocument"]=wasmExports["h"];_engineBuildInfo=Module["_engineBuildInfo"]=wasmExports["i"];_engineHasSkia=Module["_engineHasSkia"]=wasmExports["j"];_malloc=Module["_malloc"]=wasmExports["k"];_free=Module["_free"]=wasmExports["l"];__emscripten_stack_restore=wasmExports["m"];__emscripten_stack_alloc=wasmExports["n"];_emscripten_stack_get_current=wasmExports["o"];memory=wasmMemory=wasmExports["d"];__indirect_function_table=wasmExports["__indirect_function_table"]}var wasmImports={a:___cxa_throw,c:__abort_js,b:_emscripten_resize_heap};function run(){preRun();function doRun(){Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve?.(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}var wasmExports;wasmExports=await (createWasm());run();if(runtimeInitialized){moduleRtn=Module}else{moduleRtn=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject})} +;return moduleRtn}export default Module; diff --git a/frontend/public/pdfengine.wasm b/frontend/public/pdfengine.wasm new file mode 100644 index 0000000..fd9d400 Binary files /dev/null and b/frontend/public/pdfengine.wasm differ diff --git a/frontend/src/lib/wasmLoader.ts b/frontend/src/lib/wasmLoader.ts index 70ba404..0af2143 100644 --- a/frontend/src/lib/wasmLoader.ts +++ b/frontend/src/lib/wasmLoader.ts @@ -24,15 +24,73 @@ class WasmLoader { } try { - return new Promise((resolve) => { - setTimeout(() => { - this.isLoaded = true; - this.instance = this.createMockEngineInstance(); - resolve(this.instance); - }, 1200); // Simulate network/compilation delay + // Dynamic import from the public folder / static route + // @ts-ignore + const createModule = (await import(/* @vite-ignore */ '/pdfengine.mjs')).default; + const Module = await createModule({ + locateFile: (path: string) => { + if (path.endsWith('.wasm')) { + return '/pdfengine.wasm'; + } + return path; + } }); + + console.log('[WASM Loader] Real C++ WebAssembly Engine loaded successfully.'); + + this.instance = { + version: '1.0.0-wasm-g0b-cpp', + loadDocument: (buffer: ArrayBuffer) => { + const size = buffer.byteLength; + const ptr = Module._malloc(size); + const heap = new Uint8Array(Module.HEAPU8.buffer, ptr, size); + heap.set(new Uint8Array(buffer)); + + const handle = Module.ccall('loadDocument', 'number', ['number', 'number'], [ptr, size]); + Module._free(ptr); + return handle; + }, + renderPage: (handle: number, page: number, scale: number) => { + const width = Math.floor(800 * scale); + const height = Math.floor(1100 * scale); + const bufferSize = width * height * 4; + + const ptr = Module._malloc(bufferSize); + + const success = Module.ccall( + 'renderPage', + 'number', + ['number', 'number', 'number', 'number', 'number', 'number'], + [handle, page, scale, ptr, width, height] + ); + + if (!success) { + Module._free(ptr); + throw new Error(`C++ renderPage failed for page ${page}`); + } + + // Copy the pixel buffer so we can free the WASM heap allocation + const wasmPixels = new Uint8Array(Module.HEAPU8.buffer, ptr, bufferSize); + const clientPixels = new Uint8ClampedArray(wasmPixels); + Module._free(ptr); + + return new ImageData(clientPixels, width, height); + }, + freeDocument: (handle: number) => { + Module.ccall('freeDocument', 'void', ['number'], [handle]); + }, + engineBuildInfo: () => { + return Module.ccall('engineBuildInfo', 'string', [], []); + }, + engineHasSkia: () => { + return Module.ccall('engineHasSkia', 'number', [], []) !== 0; + } + }; + + this.isLoaded = true; + return this.instance; } catch (err) { - console.warn('WASM Loading failed, using JavaScript Canvas Mock rendering.', err); + console.warn('Real WASM loading failed, falling back to simulated JavaScript Canvas Mock rendering.', err); this.isLoaded = true; this.instance = this.createMockEngineInstance(); return this.instance; diff --git a/gateway/app/routers/render.py b/gateway/app/routers/render.py index 6eb6332..0ff4d2c 100644 --- a/gateway/app/routers/render.py +++ b/gateway/app/routers/render.py @@ -44,6 +44,9 @@ def extract_page_text(document_id: str, page_index: int): doc = doc_info["doc_instance"] page = doc.get_page(page_index) text = page.extract_text() + annots = page.extract_annotations_text() + if annots: + text += "\n" + "\n".join(annots) glyphs = page.extract_text_with_bounds() return {"text": text, "glyphs": glyphs} except IndexError: diff --git a/wasm/CMakeLists.txt b/wasm/CMakeLists.txt index 0fd8f92..7d23632 100644 --- a/wasm/CMakeLists.txt +++ b/wasm/CMakeLists.txt @@ -39,3 +39,35 @@ target_compile_features(hello PRIVATE cxx_std_23) message(STATUS "WASM hello-world configured") message(STATUS " Output ............... ${CMAKE_BINARY_DIR}/bin/hello.mjs (+ hello.wasm)") + +# Phase 1/2 WASM Engine target +add_executable(pdfengine_wasm + bindings/wasm_engine.cpp + bindings/pdf_engine_facade.cpp + bindings/mock_renderer.cpp +) + +set_target_properties(pdfengine_wasm PROPERTIES + OUTPUT_NAME "pdfengine" + SUFFIX ".mjs" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" +) + +target_include_directories(pdfengine_wasm PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/bindings" +) + +target_link_options(pdfengine_wasm PRIVATE + "-sMODULARIZE=1" + "-sEXPORT_ES6=1" + "-sENVIRONMENT=node,web" + "-sFILESYSTEM=0" + "-sALLOW_MEMORY_GROWTH=1" + "-sEXPORTED_FUNCTIONS=['_loadDocument','_renderPage','_freeDocument','_engineBuildInfo','_engineHasSkia','_malloc','_free']" + "-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','getValue','setValue','HEAPU8']" +) + +target_compile_features(pdfengine_wasm PRIVATE cxx_std_23) + +message(STATUS "WASM pdfengine configured") +message(STATUS " Output ............... ${CMAKE_BINARY_DIR}/bin/pdfengine.mjs (+ pdfengine.wasm)") diff --git a/wasm/bindings/mock_renderer.cpp b/wasm/bindings/mock_renderer.cpp new file mode 100644 index 0000000..30bbebb --- /dev/null +++ b/wasm/bindings/mock_renderer.cpp @@ -0,0 +1,98 @@ +#include "mock_renderer.hpp" +#include +#include + +bool MockRenderer::render(int pageIndex, float scale, uint8_t* outputBuffer, int width, int height) { + if (!outputBuffer || width <= 0 || height <= 0) { + return false; + } + + // Fill with a nice off-white background: RGBA (250, 250, 245, 255) + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + int idx = (y * width + x) * 4; + outputBuffer[idx + 0] = 250; // R + outputBuffer[idx + 1] = 250; // G + outputBuffer[idx + 2] = 245; // B + outputBuffer[idx + 3] = 255; // A + } + } + + // Draw a nice grid pattern: grid lines every 50 pixels (scaled) + int gridSpacing = static_cast(50.0f * scale); + if (gridSpacing < 10) gridSpacing = 10; + + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + bool isGridLine = (x % gridSpacing == 0) || (y % gridSpacing == 0); + if (isGridLine) { + int idx = (y * width + x) * 4; + // Light gray-blue for grid + outputBuffer[idx + 0] = 220; // R + outputBuffer[idx + 1] = 225; // G + outputBuffer[idx + 2] = 230; // B + outputBuffer[idx + 3] = 255; // A + } + } + } + + // Draw a dark margin/border (e.g. 5 pixels) + int borderWidth = 4; + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + if (x < borderWidth || x >= width - borderWidth || y < borderWidth || y >= height - borderWidth) { + int idx = (y * width + x) * 4; + outputBuffer[idx + 0] = 120; // R + outputBuffer[idx + 1] = 120; // G + outputBuffer[idx + 2] = 120; // B + outputBuffer[idx + 3] = 255; // A + } + } + } + + // Draw a simple shape or pattern centered on the page based on the pageIndex + int centerX = width / 2; + int centerY = height / 2; + int size = static_cast(120.0f * scale); + if (size > width) size = width; + if (size > height) size = height; + + if (pageIndex == 0) { + // Draw a filled colored square in the center + int halfSize = size / 2; + int startX = centerX - halfSize; + int endX = centerX + halfSize; + int startY = centerY - halfSize; + int endY = centerY + halfSize; + + for (int y = std::max(0, startY); y < std::min(height, endY); ++y) { + for (int x = std::max(0, startX); x < std::min(width, endX); ++x) { + int idx = (y * width + x) * 4; + // Coral pink square + outputBuffer[idx + 0] = 240; // R + outputBuffer[idx + 1] = 128; // G + outputBuffer[idx + 2] = 128; // B + outputBuffer[idx + 3] = 255; // A + } + } + } else { + // Draw a diamond shape + int halfSize = size / 2; + for (int y = std::max(0, centerY - halfSize); y < std::min(height, centerY + halfSize); ++y) { + int dy = std::abs(y - centerY); + int dxLimit = halfSize - dy; + int startX = centerX - dxLimit; + int endX = centerX + dxLimit; + for (int x = std::max(0, startX); x < std::min(width, endX); ++x) { + int idx = (y * width + x) * 4; + // Steel blue diamond + outputBuffer[idx + 0] = 70; // R + outputBuffer[idx + 1] = 130; // G + outputBuffer[idx + 2] = 180; // B + outputBuffer[idx + 3] = 255; // A + } + } + } + + return true; +} diff --git a/wasm/bindings/mock_renderer.hpp b/wasm/bindings/mock_renderer.hpp new file mode 100644 index 0000000..0cc8103 --- /dev/null +++ b/wasm/bindings/mock_renderer.hpp @@ -0,0 +1,10 @@ +#pragma once +#include "renderer_interface.hpp" + +class MockRenderer : public RendererInterface { +public: + MockRenderer() = default; + ~MockRenderer() override = default; + + bool render(int pageIndex, float scale, uint8_t* outputBuffer, int width, int height) override; +}; diff --git a/wasm/bindings/pdf_engine_facade.cpp b/wasm/bindings/pdf_engine_facade.cpp new file mode 100644 index 0000000..b54dba5 --- /dev/null +++ b/wasm/bindings/pdf_engine_facade.cpp @@ -0,0 +1,68 @@ +#include "pdf_engine_facade.hpp" +#include "mock_renderer.hpp" +#include +#include + +PdfEngineFacade::PdfEngineFacade() + : m_nextHandle(1), m_renderer(std::make_unique()) {} + +PdfEngineFacade::~PdfEngineFacade() = default; + +int PdfEngineFacade::loadDocument(const uint8_t* buffer, int size) { + if (!buffer || size <= 0) { + return 0; // Invalid handle + } + + auto doc = std::make_unique(); + doc->handle = m_nextHandle++; + doc->data.assign(buffer, buffer + size); + + // Realistic page count detector + int pages = 0; + if (size > 4 && buffer[0] == '%' && buffer[1] == 'P' && buffer[2] == 'D' && buffer[3] == 'F') { + std::string_view sv(reinterpret_cast(buffer), size); + size_t pos = 0; + while ((pos = sv.find("/Type /Page", pos)) != std::string_view::npos) { + // Avoid matching /Type /Pages + if (pos + 11 < sv.size() && sv[pos + 11] != 's') { + pages++; + } + pos += 11; + } + } + + if (pages == 0) { + pages = 3; // Default fallback + } + doc->pageCount = pages; + + int handle = doc->handle; + m_documents[handle] = std::move(doc); + return handle; +} + +bool PdfEngineFacade::renderPage(int docHandle, int pageIndex, float scale, uint8_t* outputBuffer, int width, int height) { + auto it = m_documents.find(docHandle); + if (it == m_documents.end()) { + return false; + } + + const auto& doc = it->second; + if (pageIndex < 0 || pageIndex >= doc->pageCount) { + return false; + } + + return m_renderer->render(pageIndex, scale, outputBuffer, width, height); +} + +void PdfEngineFacade::freeDocument(int docHandle) { + m_documents.erase(docHandle); +} + +const char* PdfEngineFacade::buildInfo() { + return "PdfEngine WASM Facade (Phase 1/2 Enabled)"; +} + +bool PdfEngineFacade::hasSkia() { + return false; +} diff --git a/wasm/bindings/pdf_engine_facade.hpp b/wasm/bindings/pdf_engine_facade.hpp new file mode 100644 index 0000000..850273b --- /dev/null +++ b/wasm/bindings/pdf_engine_facade.hpp @@ -0,0 +1,30 @@ +#pragma once +#include +#include +#include +#include +#include "renderer_interface.hpp" + +struct MockDocument { + int handle; + std::vector data; + int pageCount; +}; + +class PdfEngineFacade { +public: + PdfEngineFacade(); + ~PdfEngineFacade(); + + int loadDocument(const uint8_t* buffer, int size); + bool renderPage(int docHandle, int pageIndex, float scale, uint8_t* outputBuffer, int width, int height); + void freeDocument(int docHandle); + + static const char* buildInfo(); + static bool hasSkia(); + +private: + int m_nextHandle; + std::unordered_map> m_documents; + std::unique_ptr m_renderer; +}; diff --git a/wasm/bindings/renderer_interface.hpp b/wasm/bindings/renderer_interface.hpp new file mode 100644 index 0000000..c418583 --- /dev/null +++ b/wasm/bindings/renderer_interface.hpp @@ -0,0 +1,8 @@ +#pragma once +#include + +class RendererInterface { +public: + virtual ~RendererInterface() = default; + virtual bool render(int pageIndex, float scale, uint8_t* outputBuffer, int width, int height) = 0; +}; diff --git a/wasm/bindings/wasm_engine.cpp b/wasm/bindings/wasm_engine.cpp new file mode 100644 index 0000000..4a13164 --- /dev/null +++ b/wasm/bindings/wasm_engine.cpp @@ -0,0 +1,30 @@ +#include +#include +#include "pdf_engine_facade.hpp" + +// Global facade instance +static PdfEngineFacade g_facade; + +extern "C" { + +EMSCRIPTEN_KEEPALIVE int loadDocument(const uint8_t* buffer, int size) { + return g_facade.loadDocument(buffer, size); +} + +EMSCRIPTEN_KEEPALIVE int renderPage(int docHandle, int pageIndex, float scale, uint8_t* outputBuffer, int width, int height) { + return g_facade.renderPage(docHandle, pageIndex, scale, outputBuffer, width, height) ? 1 : 0; +} + +EMSCRIPTEN_KEEPALIVE void freeDocument(int docHandle) { + g_facade.freeDocument(docHandle); +} + +EMSCRIPTEN_KEEPALIVE const char* engineBuildInfo() { + return PdfEngineFacade::buildInfo(); +} + +EMSCRIPTEN_KEEPALIVE int engineHasSkia() { + return PdfEngineFacade::hasSkia() ? 1 : 0; +} + +} // extern "C" diff --git a/wasm/pdfengine.test.mjs b/wasm/pdfengine.test.mjs new file mode 100644 index 0000000..47777bc --- /dev/null +++ b/wasm/pdfengine.test.mjs @@ -0,0 +1,91 @@ +import { strict as assert } from "node:assert"; +import { existsSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { dirname, resolve } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, ".."); + +const enginePath = + process.env.PDFENGINE_MJS ?? resolve(repoRoot, "out/build/wasm/bin/pdfengine.mjs"); + +if (!existsSync(enginePath)) { + console.error( + `[pdfengine-smoke] pdfengine.mjs not found at: ${enginePath}\n` + + `Did you run \`cmake --build --preset wasm\` first?`, + ); + process.exit(1); +} + +const { default: createModule } = await import(pathToFileURL(enginePath).href); +const Module = await createModule(); + +// 1. Test build info +const buildInfo = Module.ccall("engineBuildInfo", "string", [], []); +console.log(`[pdfengine-smoke] buildInfo: "${buildInfo}"`); +assert.equal(buildInfo, "PdfEngine WASM Facade (Phase 1/2 Enabled)"); + +// 2. Test hasSkia +const hasSkia = Module.ccall("engineHasSkia", "number", [], []); +console.log(`[pdfengine-smoke] hasSkia: ${hasSkia}`); +assert.equal(hasSkia, 0); + +// 3. Test document loading and lifecycle +const mockPdfData = new TextEncoder().encode("%PDF-1.4\n1 0 obj\n<< /Type /Page >>\nendobj\n2 0 obj\n<< /Type /Page >>\nendobj\n"); +const dataSize = mockPdfData.length; + +// Allocate memory in WASM heap +const dataPtr = Module._malloc(dataSize); +Module.HEAPU8.set(mockPdfData, dataPtr); + +// Load document +const loadDocument = Module.cwrap("loadDocument", "number", ["number", "number"]); +const docHandle = loadDocument(dataPtr, dataSize); +console.log(`[pdfengine-smoke] docHandle: ${docHandle}`); +assert.ok(docHandle > 0, "loadDocument should return a valid handle (> 0)"); + +// 4. Test page rendering +const renderPage = Module.cwrap("renderPage", "number", ["number", "number", "number", "number", "number", "number"]); +const width = 100; +const height = 100; +const bufferSize = width * height * 4; // RGBA + +const outputBufferPtr = Module._malloc(bufferSize); + +// Render page 0 +const renderResult = renderPage(docHandle, 0, 1.0, outputBufferPtr, width, height); +console.log(`[pdfengine-smoke] renderResult: ${renderResult}`); +assert.equal(renderResult, 1, "renderPage should return 1 for success"); + +// Validate some rendered pixel bytes (from our mock_renderer, page 0 is covered by the coral pink square: 240, 128, 128) +const pixels = new Uint8Array(Module.HEAPU8.buffer, outputBufferPtr, bufferSize); +assert.equal(pixels[0], 240); // R +assert.equal(pixels[1], 128); // G +assert.equal(pixels[2], 128); // B +assert.equal(pixels[3], 255); // A + +// Try rendering page 1 (diamond shape, should also succeed) +const renderResultPage1 = renderPage(docHandle, 1, 1.0, outputBufferPtr, width, height); +assert.equal(renderResultPage1, 1, "renderPage should return 1 for page 1"); + +// On page 1, pixel (10,10) is outside the diamond and border, so it should be off-white background (250, 250, 245) +const pixelsPage1 = new Uint8Array(Module.HEAPU8.buffer, outputBufferPtr, bufferSize); +const idx10_10 = (10 * width + 10) * 4; +assert.equal(pixelsPage1[idx10_10 + 0], 250); // R +assert.equal(pixelsPage1[idx10_10 + 1], 250); // G +assert.equal(pixelsPage1[idx10_10 + 2], 245); // B +assert.equal(pixelsPage1[idx10_10 + 3], 255); // A + +// Try rendering invalid page index (should fail since mock document has 3 pages by default, pageIndex 3 is OOB) +const renderResultOob = renderPage(docHandle, 3, 1.0, outputBufferPtr, width, height); +assert.equal(renderResultOob, 0, "renderPage should return 0 for out-of-bounds page index"); + +// 5. Test free document +const freeDocument = Module.cwrap("freeDocument", "void", ["number"]); +freeDocument(docHandle); + +// Free heap allocations +Module._free(dataPtr); +Module._free(outputBufferPtr); + +console.log("[pdfengine-smoke] ALL TESTS PASSED SUCCESSFULLY!");