feat: wasm update and annotations
This commit is contained in:
+44
-42
@@ -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 <platform>-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 <platform>-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}")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
@@ -66,6 +66,8 @@ public:
|
||||
|
||||
[[nodiscard]] virtual std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() const = 0;
|
||||
|
||||
[[nodiscard]] virtual std::expected<std::vector<std::string>, 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;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <fpdf_save.h>
|
||||
#include <fpdf_doc.h>
|
||||
#include <fpdf_edit.h>
|
||||
#include <fpdf_annot.h>
|
||||
#include <png.h>
|
||||
#include "parser/pdfium_loader.hpp"
|
||||
#endif
|
||||
@@ -435,6 +436,39 @@ std::expected<std::vector<GlyphBounds>, EngineError> PdfiumPage::extractTextWith
|
||||
#endif
|
||||
}
|
||||
|
||||
std::expected<std::vector<std::string>, EngineError> PdfiumPage::extractAnnotationsText() const {
|
||||
#ifdef PDFENGINE_WITH_PDFIUM
|
||||
if (!page_) {
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
std::vector<std::string> 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<uint8_t> buf(len);
|
||||
FPDFAnnot_GetStringValue(annot, "Contents", reinterpret_cast<FPDF_WCHAR*>(buf.data()), len);
|
||||
std::string text = utf16le_to_utf8(reinterpret_cast<const char16_t*>(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<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
|
||||
return std::unexpected(EngineError::Unknown);
|
||||
}
|
||||
|
||||
// Create text object
|
||||
FPDF_PAGEOBJECT textObj = FPDFPageObj_NewTextObj(doc_, "Helvetica", static_cast<float>(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<FPDF_WIDESTRING>(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<float>(x);
|
||||
rect.bottom = static_cast<float>(y);
|
||||
rect.right = static_cast<float>(x + 200.0f); // Default width
|
||||
rect.top = static_cast<float>(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<FPDF_WIDESTRING>(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<FPDF_WIDESTRING>(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);
|
||||
|
||||
@@ -40,6 +40,7 @@ public:
|
||||
std::expected<PageImage, EngineError> render(int dpi = 96) const override;
|
||||
std::expected<std::string, EngineError> extractText() const override;
|
||||
std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() const override;
|
||||
std::expected<std::vector<std::string>, 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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Generated
+12
-21
@@ -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"
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#include "mock_renderer.hpp"
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
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<int>(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<int>(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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
#include "pdf_engine_facade.hpp"
|
||||
#include "mock_renderer.hpp"
|
||||
#include <string_view>
|
||||
#include <iostream>
|
||||
|
||||
PdfEngineFacade::PdfEngineFacade()
|
||||
: m_nextHandle(1), m_renderer(std::make_unique<MockRenderer>()) {}
|
||||
|
||||
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<MockDocument>();
|
||||
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<const char*>(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;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <memory>
|
||||
#include <cstdint>
|
||||
#include "renderer_interface.hpp"
|
||||
|
||||
struct MockDocument {
|
||||
int handle;
|
||||
std::vector<uint8_t> 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<int, std::unique_ptr<MockDocument>> m_documents;
|
||||
std::unique_ptr<RendererInterface> m_renderer;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
class RendererInterface {
|
||||
public:
|
||||
virtual ~RendererInterface() = default;
|
||||
virtual bool render(int pageIndex, float scale, uint8_t* outputBuffer, int width, int height) = 0;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
#include <emscripten/emscripten.h>
|
||||
#include <cstdint>
|
||||
#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"
|
||||
@@ -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!");
|
||||
Reference in New Issue
Block a user