Merge pull request 'feat: wasm update and annotations' (#19) from furqan into dev

Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/19
This commit is contained in:
furqan
2026-05-26 10:22:28 +00:00
20 changed files with 604 additions and 87 deletions
+44 -42
View File
@@ -32,47 +32,49 @@ set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib")
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
if(EMSCRIPTEN) 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) 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() 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}")
+3
View File
@@ -96,6 +96,9 @@ PYBIND11_MODULE(pdfengine, m) {
.def("extract_text", [](const pdfengine::PdfPage& self) { .def("extract_text", [](const pdfengine::PdfPage& self) {
return get_or_throw(self.extractText()); 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) { .def("extract_text_with_bounds", [](const pdfengine::PdfPage& self) {
auto res = get_or_throw(self.extractTextWithBounds()); auto res = get_or_throw(self.extractTextWithBounds());
py::list py_list; py::list py_list;
+27
View File
@@ -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<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 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; [[nodiscard]] virtual Point2D deviceToPage(const DevicePoint& devicePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept = 0;
}; };
+68 -13
View File
@@ -6,6 +6,7 @@
#include <fpdf_save.h> #include <fpdf_save.h>
#include <fpdf_doc.h> #include <fpdf_doc.h>
#include <fpdf_edit.h> #include <fpdf_edit.h>
#include <fpdf_annot.h>
#include <png.h> #include <png.h>
#include "parser/pdfium_loader.hpp" #include "parser/pdfium_loader.hpp"
#endif #endif
@@ -435,6 +436,39 @@ std::expected<std::vector<GlyphBounds>, EngineError> PdfiumPage::extractTextWith
#endif #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 { DevicePoint PdfiumPage::pageToDevice(const Point2D& pagePoint, int deviceWidth, int deviceHeight, int rotate) const noexcept {
#ifdef PDFENGINE_WITH_PDFIUM #ifdef PDFENGINE_WITH_PDFIUM
if (!page_) return {0, 0}; if (!page_) return {0, 0};
@@ -590,29 +624,50 @@ std::expected<void, EngineError> PdfiumDocument::applyEdits(const std::string& e
return std::unexpected(EngineError::Unknown); return std::unexpected(EngineError::Unknown);
} }
// Create text object // Create FreeText Annotation
FPDF_PAGEOBJECT textObj = FPDFPageObj_NewTextObj(doc_, "Helvetica", static_cast<float>(fontSize)); FPDF_ANNOTATION annot = FPDFPage_CreateAnnot(page, FPDF_ANNOT_FREETEXT);
if (!textObj) { if (!annot) {
spdlog::error("Failed to create PDF text object"); spdlog::error("Failed to create FreeText annotation");
FPDF_ClosePage(page); FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown); return std::unexpected(EngineError::Unknown);
} }
// Convert text to UTF-16LE and set it // Set bounding box (Rect)
auto utf16 = utf8_to_utf16le(text); FS_RECTF rect;
if (!FPDFText_SetText(textObj, reinterpret_cast<FPDF_WIDESTRING>(utf16.data()))) { rect.left = static_cast<float>(x);
spdlog::error("Failed to set text content on page object"); rect.bottom = static_cast<float>(y);
FPDFPageObj_Destroy(textObj); 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); FPDF_ClosePage(page);
return std::unexpected(EngineError::Unknown); return std::unexpected(EngineError::Unknown);
} }
// Translate the text object to (x, y) // Set the Default Appearance (/DA) for Helvetica, black text
FPDFPageObj_Transform(textObj, 1.0, 0.0, 0.0, 1.0, x, y); 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 // Set the text contents
FPDFPage_InsertObject(page, textObj); 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)) { if (!FPDFPage_GenerateContent(page)) {
spdlog::error("Failed to generate page content after editing"); spdlog::error("Failed to generate page content after editing");
FPDF_ClosePage(page); FPDF_ClosePage(page);
+1
View File
@@ -40,6 +40,7 @@ public:
std::expected<PageImage, EngineError> render(int dpi = 96) const override; std::expected<PageImage, EngineError> render(int dpi = 96) const override;
std::expected<std::string, EngineError> extractText() const override; std::expected<std::string, EngineError> extractText() const override;
std::expected<std::vector<GlyphBounds>, EngineError> extractTextWithBounds() 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; 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; Point2D deviceToPage(const DevicePoint& devicePoint, int deviceWidth, int deviceHeight, int rotate = 0) const noexcept override;
+10 -4
View File
@@ -419,10 +419,16 @@ TEST(DocumentEditTest, ApplyEditsAndIncrementalSave) {
ASSERT_TRUE(newPageRes.has_value()); ASSERT_TRUE(newPageRes.has_value());
auto newPage = *newPageRes; auto newPage = *newPageRes;
auto textRes = newPage->extractText(); auto annotsRes = newPage->extractAnnotationsText();
ASSERT_TRUE(textRes.has_value()); ASSERT_TRUE(annotsRes.has_value());
std::string text = *textRes; bool found = false;
EXPECT_NE(text.find("UniqueEditedTextAnnotation123"), std::string::npos); for (const auto& annotText : *annotsRes) {
if (annotText.find("UniqueEditedTextAnnotation123") != std::string::npos) {
found = true;
break;
}
}
EXPECT_TRUE(found);
} }
} }
+12 -21
View File
@@ -59,6 +59,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -268,27 +269,6 @@
"node": ">=6.9.0" "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": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -1107,6 +1087,7 @@
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@@ -1117,6 +1098,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -1176,6 +1158,7 @@
"integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/scope-manager": "8.59.3",
"@typescript-eslint/types": "8.59.3", "@typescript-eslint/types": "8.59.3",
@@ -1406,6 +1389,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1496,6 +1480,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.12", "baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782", "caniuse-lite": "^1.0.30001782",
@@ -1643,6 +1628,7 @@
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
@@ -2538,6 +2524,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -2598,6 +2585,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -2769,6 +2757,7 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -2854,6 +2843,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
@@ -2978,6 +2968,7 @@
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
File diff suppressed because one or more lines are too long
Binary file not shown.
+65 -7
View File
@@ -24,15 +24,73 @@ class WasmLoader {
} }
try { try {
return new Promise((resolve) => { // Dynamic import from the public folder / static route
setTimeout(() => { // @ts-ignore
this.isLoaded = true; const createModule = (await import(/* @vite-ignore */ '/pdfengine.mjs')).default;
this.instance = this.createMockEngineInstance(); const Module = await createModule({
resolve(this.instance); locateFile: (path: string) => {
}, 1200); // Simulate network/compilation delay 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) { } 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.isLoaded = true;
this.instance = this.createMockEngineInstance(); this.instance = this.createMockEngineInstance();
return this.instance; return this.instance;
+3
View File
@@ -44,6 +44,9 @@ def extract_page_text(document_id: str, page_index: int):
doc = doc_info["doc_instance"] doc = doc_info["doc_instance"]
page = doc.get_page(page_index) page = doc.get_page(page_index)
text = page.extract_text() text = page.extract_text()
annots = page.extract_annotations_text()
if annots:
text += "\n" + "\n".join(annots)
glyphs = page.extract_text_with_bounds() glyphs = page.extract_text_with_bounds()
return {"text": text, "glyphs": glyphs} return {"text": text, "glyphs": glyphs}
except IndexError: except IndexError:
+32
View File
@@ -39,3 +39,35 @@ target_compile_features(hello PRIVATE cxx_std_23)
message(STATUS "WASM hello-world configured") message(STATUS "WASM hello-world configured")
message(STATUS " Output ............... ${CMAKE_BINARY_DIR}/bin/hello.mjs (+ hello.wasm)") 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)")
+98
View File
@@ -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;
}
+10
View File
@@ -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;
};
+68
View File
@@ -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;
}
+30
View File
@@ -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;
};
+8
View File
@@ -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;
};
+30
View File
@@ -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"
+91
View File
@@ -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!");