2 Commits
Author SHA1 Message Date
Furqan-14 de4868ff49 Revert "Merge pull request 'saqib' (#5) from saqib into main"
CI / lint (push) Waiting to run
CI / build (macos-latest, macos-debug) (push) Blocked by required conditions
CI / build (ubuntu-latest, linux-debug) (push) Blocked by required conditions
CI / build (windows-latest, windows-debug) (push) Blocked by required conditions
This reverts commit a1e6b5910c, reversing
changes made to 43eba01201.
2026-05-21 11:52:27 +05:30
furqan a1e6b5910c Merge pull request 'saqib' (#5) from saqib into main
Reviewed-on: https://gitea.maskantech.in/gitea_admin/pdf/pulls/5
2026-05-21 06:18:23 +00:00
35 changed files with 108 additions and 1503 deletions
+17 -73
View File
@@ -13,6 +13,9 @@ concurrency:
cancel-in-progress: true
jobs:
# ---------------------------------------------------------------------------
# Static checks: Rule R2 boundary + formatting. Fast, gates the build matrix.
# ---------------------------------------------------------------------------
lint:
runs-on: ubuntu-latest
steps:
@@ -21,6 +24,9 @@ jobs:
- name: Rule R2 — PDFium boundary check
run: bash scripts/check_pdfium_boundary.sh
# Pinned so CI matches the version the tree was formatted with — clang-format
# output drifts between releases, and an unpinned runner version would cause
# spurious lint failures.
- name: Install clang-format (pinned)
run: pipx install clang-format==22.1.5
@@ -30,6 +36,9 @@ jobs:
find engine \( -name '*.cpp' -o -name '*.cc' -o -name '*.h' -o -name '*.hpp' \) \
-print0 | xargs -0 clang-format --dry-run --Werror
# ---------------------------------------------------------------------------
# Build + test on all three platforms (Gate G0: compiles clean everywhere).
# ---------------------------------------------------------------------------
build:
needs: lint
strategy:
@@ -40,11 +49,9 @@ jobs:
preset: linux-debug
- os: macos-latest
preset: macos-debug
experimental: true
- os: windows-latest
preset: windows-debug
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.experimental == true }}
env:
VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}/.vcpkg-cache
steps:
@@ -57,10 +64,15 @@ jobs:
if: runner.os == 'Windows'
uses: ilammy/msvc-dev-cmd@v1
# GitHub-hosted runners ship vcpkg preinstalled. VCPKG_INSTALLATION_ROOT is
# a runner OS env var, so it must be promoted to GITHUB_ENV here — it is
# NOT visible via the ${{ env.* }} context in a job-level env: block.
- name: Locate vcpkg
shell: bash
run: |
echo "VCPKG_ROOT=$VCPKG_INSTALLATION_ROOT" >> "$GITHUB_ENV"
# The runner's preinstalled vcpkg may predate our pinned builtin-baseline;
# fetch so manifest resolution can find that commit's versions tree.
git -C "$VCPKG_INSTALLATION_ROOT" fetch --quiet origin || true
- name: Create vcpkg binary cache dir
@@ -74,6 +86,9 @@ jobs:
key: vcpkg-${{ matrix.os }}-${{ hashFiles('vcpkg.json') }}
restore-keys: vcpkg-${{ matrix.os }}-
# vcpkg.json ships without a builtin-baseline; the local bootstrap script
# normally pins it. In CI we pin it on the fly (idempotent) so the
# CMakeLists dependency-pinning guard is satisfied.
- name: Pin vcpkg dependency baseline
shell: bash
run: |
@@ -89,74 +104,3 @@ jobs:
- name: Test
run: ctest --preset ${{ matrix.preset }}
gateway:
needs: lint
runs-on: ubuntu-latest
defaults:
run:
working-directory: gateway
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
cache-dependency-path: gateway/pyproject.toml
- name: Install gateway (editable, with dev extras)
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
- name: Ruff — lint
run: python -m ruff check .
- name: Ruff — format check
run: python -m ruff format --check .
- name: Pytest
run: python -m pytest
wasm:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Read pinned emsdk version
id: emsdk-version
run: |
version=$(grep '^EMSDK_VERSION=' wasm/emsdk.pinned | cut -d= -f2)
if [ -z "$version" ]; then
echo "::error::EMSDK_VERSION not found in wasm/emsdk.pinned"
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Install Ninja
uses: seanmiddleditch/gha-setup-ninja@v5
- name: Set up Emscripten ${{ steps.emsdk-version.outputs.version }}
uses: mymindstorm/setup-emsdk@v14
with:
version: ${{ steps.emsdk-version.outputs.version }}
actions-cache-folder: emsdk-cache-${{ steps.emsdk-version.outputs.version }}
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Configure (WASM)
run: cmake --preset wasm
- name: Build
run: cmake --build --preset wasm
- name: Smoke test
run: node wasm/hello.test.mjs
+21 -6
View File
@@ -1,5 +1,10 @@
cmake_minimum_required(VERSION 3.25)
# ---------------------------------------------------------------------------
# Dependency-pinning guard (Rule: pin all dependency versions on Day 1).
# We refuse to configure until vcpkg.json carries a 'builtin-baseline', which
# scripts/bootstrap adds via `vcpkg x-update-baseline --add-initial-baseline`.
# ---------------------------------------------------------------------------
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/vcpkg.json" _pdfengine_vcpkg_json)
string(JSON _pdfengine_baseline ERROR_VARIABLE _pdfengine_baseline_err
GET "${_pdfengine_vcpkg_json}" "builtin-baseline")
@@ -19,30 +24,32 @@ project(PdfEngine
HOMEPAGE_URL "https://example.invalid/pdf-engine"
LANGUAGES CXX)
# ---------------------------------------------------------------------------
# Global C++ settings — C++23, no compiler extensions (engine blueprint §1.3).
# ---------------------------------------------------------------------------
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# compile_commands.json — consumed by clang-tidy and editors.
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# Single output dirs so test binaries find imported shared libs at runtime.
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib")
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)")
add_subdirectory(wasm)
return()
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()
# ---------------------------------------------------------------------------
# Build options.
# ---------------------------------------------------------------------------
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)
@@ -52,6 +59,9 @@ include(CompilerWarnings)
include(Sanitizers)
include(pdfium) # defines pdfium::pdfium when PDFENGINE_WITH_PDFIUM is ON
# ---------------------------------------------------------------------------
# Third-party dependencies (resolved by vcpkg via the manifest).
# ---------------------------------------------------------------------------
find_package(freetype CONFIG REQUIRED)
find_package(harfbuzz CONFIG REQUIRED)
find_package(spdlog CONFIG REQUIRED)
@@ -62,8 +72,13 @@ if(PDFENGINE_BUILD_TESTS)
include(GoogleTest)
endif()
# ---------------------------------------------------------------------------
# Subprojects.
# ---------------------------------------------------------------------------
add_subdirectory(engine)
# bindings/, gateway/, frontend/, wasm/ are placeholders in Phase 0 and are not
# wired into the build yet.
message(STATUS "PdfEngine ${PROJECT_VERSION} configured")
message(STATUS " C++ standard ......... ${CMAKE_CXX_STANDARD}")
+1 -1
View File
@@ -99,7 +99,7 @@
{
"name": "wasm",
"displayName": "WASM • Emscripten hello-world (Phase 0, Rule R5)",
"displayName": "WASM • Emscripten (Phase 0 stub — validation only)",
"generator": "Ninja",
"binaryDir": "${sourceDir}/out/build/${presetName}",
"toolchainFile": "${sourceDir}/cmake/toolchains/wasm.cmake",
+7 -1
View File
@@ -1,6 +1,12 @@
# AddressSanitizer / UndefinedBehaviorSanitizer wiring.
# Enabled per build via -DPDFENGINE_ENABLE_SANITIZERS=ON
# Enabled per build via -DPDFENGINE_ENABLE_SANITIZERS=ON (see the *-asan presets).
# Usage: pdfengine_enable_sanitizers(<target>)
#
# Coverage by platform:
# MSVC -> ASan only (/fsanitize=address). UBSan/TSan unsupported.
# GCC/Clang -> ASan + UBSan.
# TSan is intentionally not wired here yet; it conflicts with ASan and is only
# relevant once the engine is multithreaded (Phase 3+).
function(pdfengine_enable_sanitizers target)
if(NOT PDFENGINE_ENABLE_SANITIZERS)
+19 -2
View File
@@ -1,5 +1,21 @@
# PDFium integration.
# Creates imported target pdfium::pdfium from third_party/pdfium/install/.
#
# PDFium is NOT a vcpkg package — it is built from source via depot_tools/GN/Ninja
# (see third_party/pdfium/). That build installs into:
#
# third_party/pdfium/install/
# include/ public PDFium headers (fpdfview.h, fpdf_*.h, ...)
# lib/ the static library (pdfium.lib / libpdfium.a)
#
# This module turns that install tree into an imported target: pdfium::pdfium
#
# Behaviour:
# PDFENGINE_WITH_PDFIUM = OFF (default)
# No target is created. The engine compiles with PDFium code paths
# #ifdef-ed out. This keeps Phase 0 unblocked before PDFium is built.
# PDFENGINE_WITH_PDFIUM = ON
# The install tree MUST exist; otherwise this is a hard error directing
# the developer to the build script.
if(NOT PDFENGINE_WITH_PDFIUM)
message(STATUS "PDFium: disabled (PDFENGINE_WITH_PDFIUM=OFF). "
@@ -35,7 +51,8 @@ set_target_properties(pdfium::pdfium PROPERTIES
IMPORTED_LOCATION "${PDFIUM_LIBRARY}"
INTERFACE_INCLUDE_DIRECTORIES "${PDFIUM_INCLUDE_DIR}")
# PDFium is a C++ static lib; consumers on Linux also need the C++ runtime and
# pthreads. These are no-ops where irrelevant.
if(UNIX AND NOT APPLE)
set_property(TARGET pdfium::pdfium APPEND PROPERTY
INTERFACE_LINK_LIBRARIES pthread dl)
+8 -5
View File
@@ -1,9 +1,12 @@
# WebAssembly (Emscripten) toolchain hook.
# WebAssembly (Emscripten) toolchain hook — STUB for Phase 0.
#
# Used by the `wasm` configure preset to chain-load the real Emscripten
# toolchain from the active EMSDK. Phase 0 builds only the hello-world target
# in wasm/ (Rule R5 — never blocks shipping). Phase 2 will compile the full
# engine (PDFium + Skia + FreeType + HarfBuzz) through this same toolchain.
# The Phase 0 "WASM hello-world" task is validation-only and must NOT block
# Phase 1 (Rule R5). This file exists so the integration point is real; the
# full WASM build (PDFium + Skia + FreeType + HarfBuzz compiled together) is
# Phase 2 work.
#
# It chain-loads the real Emscripten toolchain from the active EMSDK, then lets
# vcpkg layer on top. Used by the `wasm` configure preset.
if(NOT DEFINED ENV{EMSDK})
message(FATAL_ERROR
-75
View File
@@ -93,11 +93,6 @@ ctest --preset windows-debug
The first configure compiles the vcpkg dependencies (freetype, harfbuzz,
spdlog, gtest) — slow once, cached after.
> **Verifying everything at once:** use `scripts/test_phase0.{ps1,sh}` to run
> the R2 boundary check, engine, gateway, and WASM smoke test as a single
> command and get one pass/fail summary. See
> ["Verifying your setup"](#verifying-your-setup--scriptstest_phase0ps1sh) below.
### Building with PDFium
PDFium is built separately from source (Task 2):
@@ -195,76 +190,6 @@ broken `ninja` for the engine build. Two fixes:
`PATH`. `third_party/pdfium/build_pdfium.ps1` already prepends it
per-run, so the PDFium build still works.
## Verifying your setup — `scripts/test_phase0.{ps1,sh}`
After bootstrap, this aggregator runs every Phase 0 check in sequence and
prints one pass/fail summary. It is the local mirror of
[.github/workflows/ci.yml](../.github/workflows/ci.yml) — new teammates should
use it as the "did I set everything up right?" one-liner.
Pieces it runs, in order:
1. **Rule R2 boundary**`scripts/check_pdfium_boundary.*`
2. **Engine**`cmake --preset … --build --preset … ctest --preset …`
3. **Gateway**`pip install -e .[dev]` + ruff (lint + format check) + pytest
4. **WASM hello-world**`cmake --preset wasm` + `node wasm/hello.test.mjs`
A piece **skips** (not fails) when its toolchain is absent — no Emscripten on
`PATH` skips WASM, no `python` skips the gateway, etc. Each piece is
independent: one failure does not abort later pieces. Exit code is non-zero
only if a piece genuinely **failed**, so you can pipe it into CI.
### Windows
`vcvars64.bat` must be active in the shell (or use *Developer PowerShell for
VS*) and `VCPKG_ROOT` must be set:
```powershell
./scripts/test_phase0.ps1 -Preset win-local # debug, no PDFium - fastest
./scripts/test_phase0.ps1 -Preset win-local-pdfium # release + PDFium - full
```
Both presets live in your local `CMakeUserPresets.json` (template in the
"Windows + PDFium" section above), with `binaryDir` pointed outside OneDrive on
a space-free path.
Other useful flags: `-BinaryDir <path>` (override the preset's binaryDir),
`-SkipEngine` / `-SkipGateway` / `-SkipWasm` (skip a piece explicitly).
### Linux / macOS
```sh
./scripts/test_phase0.sh # auto-picks linux-debug or macos-debug
./scripts/test_phase0.sh linux-asan # different preset
SKIP_WASM=1 ./scripts/test_phase0.sh # if Emscripten not installed
PHASE0_BINARY_DIR=/tmp/build ./scripts/test_phase0.sh # override binaryDir
```
Compatible with bash 3.2 (macOS default), so no `brew install bash` needed.
### Gotcha: stale build dir after a triplet switch
If a build dir was previously configured against the dynamic
(`x64-windows`) vcpkg triplet and you re-run against the static
(`x64-windows-static`) one — which is what the `windows-base` preset now pins
for the static-CRT story — vcpkg correctly purges the old libs, but ninja's
build graph still references them. You will see:
```
ninja: error: 'vcpkg_installed/x64-windows/debug/lib/harfbuzz.lib' ... missing
```
Fix by regenerating from scratch:
```powershell
cmake --preset win-local --fresh # or whichever preset
./scripts/test_phase0.ps1 -Preset win-local
```
`--fresh` is the cleanest option (CMake ≥ 3.24); it preserves the build dir
but invalidates the cache so all targets are re-resolved. Alternatively, delete
the build dir and reconfigure.
## Dependency pinning
The blueprint rule is *"pin all dependency versions on Day 1, never track
+9 -2
View File
@@ -1,5 +1,8 @@
# pdfengine — the C++23 PDF SDK core.
#
# Phase 0 scope: a minimal-but-real static library that compiles, links against
# its vcpkg dependencies, and exposes version/build introspection. Feature
# modules (parser, render, text, core) are filled in from Phase 1 onward.
# Generate the version header from the project version.
configure_file(
@@ -10,8 +13,6 @@ configure_file(
add_library(pdfengine STATIC
src/core/engine_info.cpp
src/parser/pdfium_loader.cpp
src/fonts/font_face.cpp
src/fonts/hb_shaper.cpp
)
add_library(pdfengine::pdfengine ALIAS pdfengine)
@@ -23,6 +24,10 @@ target_include_directories(pdfengine
"${CMAKE_CURRENT_SOURCE_DIR}/src"
)
# Dependencies resolved by vcpkg.
# spdlog - logging, used now (engine blueprint §13.1).
# freetype/harfbuzz - linked now to prove the vcpkg toolchain end to end;
# actually exercised by Dev 3 from Phase 1 onward.
target_link_libraries(pdfengine
PUBLIC
spdlog::spdlog
@@ -31,6 +36,8 @@ target_link_libraries(pdfengine
harfbuzz::harfbuzz
)
# PDFium is optional in Phase 0 (built from source separately). When enabled,
# only this target gets the macro + link — and only src/parser/ uses it (R2).
if(PDFENGINE_WITH_PDFIUM)
target_link_libraries(pdfengine PRIVATE pdfium::pdfium)
target_compile_definitions(pdfengine PRIVATE PDFENGINE_WITH_PDFIUM)
-75
View File
@@ -1,75 +0,0 @@
#include "font_face.hpp"
#include <iostream>
namespace pdfengine::fonts {
FontFace::FontFace()
: ft_library_(nullptr),
face_(nullptr) {
if (FT_Init_FreeType(&ft_library_)) {
std::cerr << "Failed to initialize FreeType\n";
}
}
FontFace::~FontFace() {
if (face_) {
FT_Done_Face(face_);
}
if (ft_library_) {
FT_Done_FreeType(ft_library_);
}
}
FontFace::FontFace(FontFace&& other) noexcept
: ft_library_(other.ft_library_),
face_(other.face_) {
other.ft_library_ = nullptr;
other.face_ = nullptr;
}
FontFace& FontFace::operator=(FontFace&& other) noexcept {
if (this != &other) {
if (face_) {
FT_Done_Face(face_);
}
if (ft_library_) {
FT_Done_FreeType(ft_library_);
}
ft_library_ = other.ft_library_;
face_ = other.face_;
other.ft_library_ = nullptr;
other.face_ = nullptr;
}
return *this;
}
bool FontFace::loadFromFile(const std::string& path) {
if (FT_New_Face(
ft_library_,
path.c_str(),
0,
&face_)) {
std::cerr << "Failed to load font: "
<< path << '\n';
return false;
}
// Set a default pixel size of 16px so that font coordinates and shaping
// advances are non-zero by default.
FT_Set_Pixel_Sizes(face_, 0, 16);
return true;
}
FT_Face FontFace::getFace() const {
return face_;
}
} // namespace pdfengine::fonts
-30
View File
@@ -1,30 +0,0 @@
#pragma once
#include <string>
#include <ft2build.h>
#include FT_FREETYPE_H
namespace pdfengine::fonts {
class FontFace {
public:
FontFace();
~FontFace();
// FontFace is move-only to avoid double freeing FreeType resources.
FontFace(const FontFace&) = delete;
FontFace& operator=(const FontFace&) = delete;
FontFace(FontFace&& other) noexcept;
FontFace& operator=(FontFace&& other) noexcept;
bool loadFromFile(const std::string& path);
FT_Face getFace() const;
private:
FT_Library ft_library_;
FT_Face face_;
};
} // namespace pdfengine::fonts
-74
View File
@@ -1,74 +0,0 @@
#include "hb_shaper.hpp"
#include <hb.h>
#include <hb-ft.h>
namespace pdfengine::fonts {
HbShaper::HbShaper() = default;
HbShaper::~HbShaper() = default;
std::vector<ShapedGlyph> HbShaper::shapeText(const FontFace& fontFace, const std::string& text) {
std::vector<ShapedGlyph> result;
FT_Face ftFace = fontFace.getFace();
if (!ftFace) {
return result;
}
// Create a HarfBuzz font wrapper around the FreeType face.
// hb_ft_font_create_referenced increments the reference count of the FT_Face,
// making it safe even if the FontFace object changes or moves.
hb_font_t* hbFont = hb_ft_font_create_referenced(ftFace);
if (!hbFont) {
return result;
}
// Set the scale of HarfBuzz font to match the FreeType face size.
// If not set, HarfBuzz will default to using the font's design units (upem).
hb_ft_font_changed(hbFont);
// Create a text buffer.
hb_buffer_t* hbBuffer = hb_buffer_create();
if (!hbBuffer) {
hb_font_destroy(hbFont);
return result;
}
// Add text to buffer as UTF-8.
hb_buffer_add_utf8(hbBuffer, text.c_str(), static_cast<int>(text.length()), 0, -1);
// Let HarfBuzz guess direction, script, and language properties.
hb_buffer_guess_segment_properties(hbBuffer);
// Shape the text inside the buffer using the font.
hb_shape(hbFont, hbBuffer, nullptr, 0);
// Retrieve the results.
unsigned int glyphCount = 0;
hb_glyph_info_t* glyphInfos = hb_buffer_get_glyph_infos(hbBuffer, &glyphCount);
hb_glyph_position_t* glyphPositions = hb_buffer_get_glyph_positions(hbBuffer, &glyphCount);
if (glyphInfos && glyphPositions && glyphCount > 0) {
result.reserve(glyphCount);
for (unsigned int i = 0; i < glyphCount; ++i) {
ShapedGlyph sg;
sg.glyphIndex = glyphInfos[i].codepoint;
// HarfBuzz coordinates are fractional 26.6 pixels (1/64 of a pixel).
// Convert to standard double-precision float values.
sg.xAdvance = static_cast<double>(glyphPositions[i].x_advance) / 64.0;
sg.yAdvance = static_cast<double>(glyphPositions[i].y_advance) / 64.0;
sg.xOffset = static_cast<double>(glyphPositions[i].x_offset) / 64.0;
sg.yOffset = static_cast<double>(glyphPositions[i].y_offset) / 64.0;
result.push_back(sg);
}
}
// Clean up HarfBuzz resources.
hb_buffer_destroy(hbBuffer);
hb_font_destroy(hbFont);
return result;
}
} // namespace pdfengine::fonts
-32
View File
@@ -1,32 +0,0 @@
#pragma once
#include "font_face.hpp"
#include <string>
#include <vector>
namespace pdfengine::fonts {
struct ShapedGlyph {
unsigned int glyphIndex;
double xAdvance;
double yAdvance;
double xOffset;
double yOffset;
};
class HbShaper {
public:
HbShaper();
~HbShaper();
HbShaper(const HbShaper&) = delete;
HbShaper& operator=(const HbShaper&) = delete;
HbShaper(HbShaper&&) noexcept = default;
HbShaper& operator=(HbShaper&&) noexcept = default;
// Shapes the input UTF-8 text using the given FontFace.
// Returns a vector of shaped glyphs.
std::vector<ShapedGlyph> shapeText(const FontFace& fontFace, const std::string& text);
};
} // namespace pdfengine::fonts
-6
View File
@@ -2,7 +2,6 @@
add_executable(pdfengine_smoke
smoke_test.cpp
fonts_test.cpp
)
target_link_libraries(pdfengine_smoke
@@ -12,11 +11,6 @@ target_link_libraries(pdfengine_smoke
GTest::gtest_main
)
target_include_directories(pdfengine_smoke
PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/../src"
)
pdfengine_set_warnings(pdfengine_smoke)
pdfengine_enable_sanitizers(pdfengine_smoke)
-131
View File
@@ -1,131 +0,0 @@
#include "fonts/font_face.hpp"
#include "fonts/hb_shaper.hpp"
#include <gtest/gtest.h>
#include <filesystem>
#include <iostream>
#include <string>
#include <vector>
namespace {
std::string getSystemFontPath() {
#if defined(_WIN32)
// Common Windows fonts
std::vector<std::string> paths = {
"C:\\Windows\\Fonts\\arial.ttf",
"C:\\Windows\\Fonts\\consola.ttf",
"C:\\Windows\\Fonts\\tahoma.ttf"
};
#elif defined(__APPLE__)
// Common macOS fonts
std::vector<std::string> paths = {
"/Library/Fonts/Arial.ttf",
"/System/Library/Fonts/Geneva.ttf",
"/System/Library/Fonts/Helvetica.ttc",
"/System/Library/Fonts/Supplemental/Arial.ttf"
};
#else
// Common Linux fonts
std::vector<std::string> paths = {
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf"
};
#endif
for (const auto& path : paths) {
if (std::filesystem::exists(path)) {
return path;
}
}
return "";
}
} // namespace
namespace pdfengine::fonts {
TEST(FontTest, FontFaceInitialization) {
FontFace face;
EXPECT_EQ(face.getFace(), nullptr);
}
TEST(FontTest, FontFaceLoadNonExistentFile) {
FontFace face;
EXPECT_FALSE(face.loadFromFile("this_file_does_not_exist_12345.ttf"));
EXPECT_EQ(face.getFace(), nullptr);
}
TEST(FontTest, FontFaceMoveSemantics) {
std::string fontPath = getSystemFontPath();
if (fontPath.empty()) {
GTEST_SKIP() << "No system font found to run move semantics test.";
}
FontFace face1;
ASSERT_TRUE(face1.loadFromFile(fontPath));
FT_Face rawFace = face1.getFace();
ASSERT_NE(rawFace, nullptr);
// Move construction
FontFace face2(std::move(face1));
EXPECT_EQ(face1.getFace(), nullptr);
EXPECT_EQ(face2.getFace(), rawFace);
// Move assignment
FontFace face3;
face3 = std::move(face2);
EXPECT_EQ(face2.getFace(), nullptr);
EXPECT_EQ(face3.getFace(), rawFace);
}
TEST(FontTest, HbShaperEmptyInput) {
std::string fontPath = getSystemFontPath();
if (fontPath.empty()) {
GTEST_SKIP() << "No system font found to run empty input shaper test.";
}
FontFace face;
ASSERT_TRUE(face.loadFromFile(fontPath));
HbShaper shaper;
auto glyphs = shaper.shapeText(face, "");
EXPECT_TRUE(glyphs.empty());
}
TEST(FontTest, HbShaperNullFace) {
FontFace face; // Null face
HbShaper shaper;
auto glyphs = shaper.shapeText(face, "Hello");
EXPECT_TRUE(glyphs.empty());
}
TEST(FontTest, HbShaperShapeTextSuccess) {
std::string fontPath = getSystemFontPath();
if (fontPath.empty()) {
std::cout << "[ WARNING ] Skipping shape success test: no system font found." << std::endl;
GTEST_SKIP() << "No system font found to run text shaping test.";
}
FontFace face;
ASSERT_TRUE(face.loadFromFile(fontPath));
ASSERT_NE(face.getFace(), nullptr);
HbShaper shaper;
std::string testText = "Hello World!";
auto glyphs = shaper.shapeText(face, testText);
// Validate that some glyphs were shaped.
// Note that the number of glyphs doesn't strictly have to match testText.length() (e.g. ligatures),
// but for simple English it's usually 1:1.
EXPECT_FALSE(glyphs.empty());
for (const auto& g : glyphs) {
// Glyph index should be non-zero for valid glyphs (0 is usually .notdef)
// Note: some fonts might not map all characters, but Arial/DejaVu/Consolas should map ASCII.
EXPECT_GT(g.xAdvance, 0.0);
}
}
} // namespace pdfengine::fonts
+12 -95
View File
@@ -1,107 +1,24 @@
# Gateway (FastAPI)
# Gateway (FastAPI) — placeholder
Phase 0 task **"FastAPI service scaffolding"** (Dev 1). Skeleton only — every
PDF-touching route returns **501** until the pybind11 engine module in
[`bindings/python/`](../bindings/python/) lands at Gate G0b.
Phase 0 task **"FastAPI service scaffolding"** (Dev 1) and all later API work
lives here. Not implemented yet — this session scaffolds the C++ build pipeline
only.
What the gateway eventually does (see `docs/phase0.md` and engine blueprint §8):
Planned responsibilities (see `docs/phase0.md` and the engine blueprint §8):
- Auth, metadata, storage (S3 / MinIO), async job queue.
- Bridges to the C++ engine via **pybind11** (`bindings/python/`).
- Hosts the API the React + TypeScript viewer in [`frontend/`](../frontend/) calls.
- Placeholder routes + a `/health` endpoint returning `200`**no PDF logic**
until the engine wrappers exist.
## Current state
When implemented:
```
gateway/
pyproject.toml # pinned FastAPI/uvicorn/pydantic + ruff/pytest
pyproject.toml
app/
__init__.py # exports __version__
main.py # FastAPI app factory
config.py # pydantic-settings (PDFENGINE_* env vars)
routers/ # PEP 420 namespace pkg — no __init__.py needed
health.py # GET /health → 200
documents.py # CRUD → 501 (placeholder)
render.py # render/text → 501 (placeholder)
edits.py # apply edits → 501 (placeholder)
services/ # PEP 420 namespace pkg
engine.py # lazy pybind11 import wrapper (engine absent in Phase 0)
main.py # FastAPI app + /health
routers/
services/
tests/
conftest.py
test_health.py # /health works without the engine
test_placeholders.py # every PDF route 501s until G0b lands
```
The gateway depends on **no C++** today — the `engine` service module probes
for `import pdfengine` lazily and reports `engine_available: false` via
`/health` while the pybind11 module does not exist.
## Local development
Requires Python 3.11+ (3.12 works). Build dirs are git-ignored.
```powershell
cd gateway
python -m venv .venv
.venv\Scripts\Activate.ps1
pip install -e ".[dev]"
```
```sh
# Linux/macOS
cd gateway
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```
Run the service:
```sh
uvicorn app.main:app --reload
# → http://127.0.0.1:8000/health
# → http://127.0.0.1:8000/docs (OpenAPI UI)
```
Lint, format, and test (the exact commands CI runs):
```sh
ruff check .
ruff format --check .
pytest
```
## Configuration
Environment variables are read by `app/config.py` with the prefix
`PDFENGINE_`:
| Var | Default | Meaning |
|------------------------------|---------|--------------------------------------------------|
| `PDFENGINE_ENVIRONMENT` | `dev` | `dev` / `staging` / `prod` — echoed in `/health` |
| `PDFENGINE_ENGINE_AVAILABLE` | `false` | Forces the engine-availability flag for testing |
A `.env` file in `gateway/` is auto-loaded if present (it is git-ignored
via the repo-wide `.venv/` and Python rules — add `.env` to your local
ignores if you keep secrets in it).
## CI
The `gateway` job in [`.github/workflows/ci.yml`](../.github/workflows/ci.yml)
runs on **ubuntu-latest only** — the gateway is pure Python with no
OS-specific surface, so a cross-platform matrix would burn runner minutes
catching nothing. The engine `build` matrix still covers Linux + macOS +
Windows; once the pybind11 wheel is built per-platform there, the gateway
job will install it and run integration tests against the real engine.
## What is **not** here yet (and why)
- **Real document/render/edit routes** — blocked on Gate G0b
(`PdfDocument` / `PdfPage` interface contracts) and the pybind11 module.
Stubbing them earlier would just lock us into a bad API.
- **Auth, storage, job queue** — Phase 1 / 2 work. Leaving these out of
Phase 0 keeps the surface area small enough to keep CI green while the
engine is still being scaffolded.
- **Containerization (Dockerfile, compose)** — added when there is
something non-trivial to package. A FastAPI app with a `/health` route
does not need an image yet.
-3
View File
@@ -1,3 +0,0 @@
"""PDF engine gateway — FastAPI service."""
__version__ = "0.0.0"
-33
View File
@@ -1,33 +0,0 @@
"""Runtime configuration loaded from environment variables.
Anything that varies between dev / staging / prod (storage URLs, queue
endpoints, auth secrets) lands here. Phase 0 only exposes the bare minimum
needed for `/health` and the app factory.
"""
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="PDFENGINE_",
env_file=".env",
extra="ignore",
)
environment: str = Field(default="dev", description="dev | staging | prod")
engine_available: bool = Field(
default=False,
description=(
"True once the pybind11 module in bindings/python/ is importable. "
"Phase 0 default is False — routes that need the engine return 501."
),
)
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
-24
View File
@@ -1,24 +0,0 @@
"""FastAPI app factory."""
from fastapi import FastAPI
from app import __version__
from app.routers import documents, edits, health, render
def create_app() -> FastAPI:
app = FastAPI(
title="PDF Engine Gateway",
version=__version__,
description="Auth, metadata, storage, and job-queue gateway over the C++ PDF engine.",
)
app.include_router(health.router)
app.include_router(documents.router)
app.include_router(render.router)
app.include_router(edits.router)
return app
app = create_app()
-32
View File
@@ -1,32 +0,0 @@
"""Document CRUD — upload, list, fetch metadata, delete.
Phase 0 placeholder: every route returns 501 because there is no engine
to parse PDFs and no storage backend wired up. Real implementations land
after Gate G0b (engine interface contracts) and the pybind11 module exist.
"""
from fastapi import APIRouter, HTTPException, status
router = APIRouter(prefix="/documents", tags=["documents"])
_NOT_IMPLEMENTED = "Engine bridge (bindings/python) not yet available — Phase 0 placeholder."
@router.post("", status_code=status.HTTP_501_NOT_IMPLEMENTED)
def upload_document() -> None:
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=_NOT_IMPLEMENTED)
@router.get("", status_code=status.HTTP_501_NOT_IMPLEMENTED)
def list_documents() -> None:
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=_NOT_IMPLEMENTED)
@router.get("/{document_id}", status_code=status.HTTP_501_NOT_IMPLEMENTED)
def get_document(document_id: str) -> None:
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=_NOT_IMPLEMENTED)
@router.delete("/{document_id}", status_code=status.HTTP_501_NOT_IMPLEMENTED)
def delete_document(document_id: str) -> None:
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=_NOT_IMPLEMENTED)
-16
View File
@@ -1,16 +0,0 @@
"""Edit operations — proxies to `engine.apply_edits()` (pybind11).
Phase 0 placeholder. Real implementation produces an incremental save
(append-only xref, Rule R4) and returns a new document revision id.
"""
from fastapi import APIRouter, HTTPException, status
router = APIRouter(prefix="/documents/{document_id}/edits", tags=["edits"])
_NOT_IMPLEMENTED = "Engine bridge (bindings/python) not yet available — Phase 0 placeholder."
@router.post("", status_code=status.HTTP_501_NOT_IMPLEMENTED)
def apply_edits(document_id: str) -> None:
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=_NOT_IMPLEMENTED)
-35
View File
@@ -1,35 +0,0 @@
"""Liveness / readiness probes.
`/health` must work with **zero** engine dependencies — it is the signal load
balancers and orchestrators use to decide whether the process is up. It
deliberately does not import or touch the pybind11 bridge.
"""
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from app import __version__
from app.config import Settings, get_settings
router = APIRouter(tags=["health"])
SettingsDep = Annotated[Settings, Depends(get_settings)]
class HealthResponse(BaseModel):
status: str
version: str
environment: str
engine_available: bool
@router.get("/health", response_model=HealthResponse)
def health(settings: SettingsDep) -> HealthResponse:
return HealthResponse(
status="ok",
version=__version__,
environment=settings.environment,
engine_available=settings.engine_available,
)
-21
View File
@@ -1,21 +0,0 @@
"""Page rendering — proxies to `engine.render_page()` (pybind11).
Phase 0 placeholder. Real route returns a PNG/JPEG of the requested page
at the requested DPI once the engine bridge exists.
"""
from fastapi import APIRouter, HTTPException, status
router = APIRouter(prefix="/documents/{document_id}/pages", tags=["render"])
_NOT_IMPLEMENTED = "Engine bridge (bindings/python) not yet available — Phase 0 placeholder."
@router.get("/{page_index}/render", status_code=status.HTTP_501_NOT_IMPLEMENTED)
def render_page(document_id: str, page_index: int, dpi: int = 96) -> None:
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=_NOT_IMPLEMENTED)
@router.get("/{page_index}/text", status_code=status.HTTP_501_NOT_IMPLEMENTED)
def extract_page_text(document_id: str, page_index: int) -> None:
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=_NOT_IMPLEMENTED)
-45
View File
@@ -1,45 +0,0 @@
"""Thin wrapper around the pybind11 engine module (`bindings/python/`).
The wrapper exists so routers depend on this module, not on the pybind11
import directly. That keeps routers testable when the engine is absent
(Phase 0) and gives us one place to translate engine error types into
HTTP responses later.
The pybind11 module is not built yet; importing it is expected to fail
in Phase 0. `is_available()` is the public probe.
"""
from __future__ import annotations
from typing import Any
_engine: Any | None = None
_import_error: Exception | None = None
def _try_import() -> None:
global _engine, _import_error
if _engine is not None or _import_error is not None:
return
try:
import pdfengine as _mod # type: ignore[import-not-found]
_engine = _mod
except ImportError as exc:
_import_error = exc
def is_available() -> bool:
_try_import()
return _engine is not None
def require() -> Any:
"""Return the engine module or raise — callers should prefer ``is_available``
and return 501 themselves so the error surface is consistent."""
_try_import()
if _engine is None:
raise RuntimeError(
"pdfengine pybind11 module is not built — see bindings/python/."
) from _import_error
return _engine
-59
View File
@@ -1,59 +0,0 @@
[project]
name = "pdfengine-gateway"
version = "0.0.0"
description = "FastAPI gateway over the PDF engine (auth, metadata, storage, jobs)."
requires-python = ">=3.11"
license = { text = "Proprietary" }
readme = "README.md"
# Pinned exact versions — see docs/phase0.md "Dependency pinning".
# Bumps are deliberate, not opportunistic.
dependencies = [
"fastapi==0.115.6",
"uvicorn[standard]==0.34.0",
"pydantic==2.10.4",
"pydantic-settings==2.7.1",
]
[project.optional-dependencies]
dev = [
"pytest==8.3.4",
"httpx==0.28.1",
"ruff==0.8.6",
]
[build-system]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
# --- Ruff -------------------------------------------------------------------
# Single tool for lint + format. CI runs `ruff check` and `ruff format --check`.
[tool.ruff]
line-length = 100
target-version = "py311"
extend-exclude = [".venv", "build", "dist"]
[tool.ruff.lint]
select = [
"E", "F", "W", # pycodestyle + pyflakes
"I", # isort
"B", # bugbear
"UP", # pyupgrade
"SIM", # simplify
"RUF", # ruff-specific
]
ignore = [
"E501", # line length handled by formatter
]
[tool.ruff.format]
quote-style = "double"
# --- Pytest ------------------------------------------------------------------
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
addopts = "-ra --strict-markers --strict-config"
-14
View File
@@ -1,14 +0,0 @@
"""Shared pytest fixtures for gateway tests."""
from collections.abc import Iterator
import pytest
from fastapi.testclient import TestClient
from app.main import create_app
@pytest.fixture()
def client() -> Iterator[TestClient]:
with TestClient(create_app()) as test_client:
yield test_client
-25
View File
@@ -1,25 +0,0 @@
"""`/health` is the load-balancer probe — it must keep working even when the
engine bridge is absent (Phase 0 default)."""
from fastapi.testclient import TestClient
def test_health_returns_ok(client: TestClient) -> None:
response = client.get("/health")
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "ok"
assert payload["engine_available"] is False
assert "version" in payload
assert "environment" in payload
def test_health_does_not_require_engine(client: TestClient) -> None:
"""Regression guard: importing the app and hitting /health must not
transitively import the pybind11 module (which doesn't exist yet)."""
import sys
response = client.get("/health")
assert response.status_code == 200
assert "pdfengine" not in sys.modules
-29
View File
@@ -1,29 +0,0 @@
"""Every PDF-touching route returns 501 until the engine bridge exists.
When Gate G0b lands and `bindings/python/` is wired up, these tests will
fail loudly — that is the cue to replace them with real route tests.
"""
import pytest
from fastapi.testclient import TestClient
@pytest.mark.parametrize(
("method", "path"),
[
("POST", "/documents"),
("GET", "/documents"),
("GET", "/documents/abc"),
("DELETE", "/documents/abc"),
("GET", "/documents/abc/pages/0/render"),
("GET", "/documents/abc/pages/0/text"),
("POST", "/documents/abc/edits"),
],
)
def test_placeholder_routes_return_501(client: TestClient, method: str, path: str) -> None:
response = client.request(method, path)
assert response.status_code == 501, (
f"{method} {path} returned {response.status_code}; "
"Phase 0 placeholder routes must 501 until the engine bridge exists."
)
assert "engine bridge" in response.json()["detail"].lower()
-165
View File
@@ -1,165 +0,0 @@
#requires -Version 5.1
# Phase 0 aggregator - runs every check in .github/workflows/ci.yml locally and
# reports a single pass/fail summary. Use this as the "did I set everything up
# right?" one-liner for new teammates.
#
# Pieces run, in order:
# 1. Rule R2 boundary (scripts/check_pdfium_boundary.ps1)
# 2. Engine (cmake configure + build + ctest)
# 3. Gateway (pip install -e [dev] + ruff + pytest, in gateway/)
# 4. WASM hello-world (cmake configure + build + node hello.test.mjs)
#
# Skips (not failures) are reported when a toolchain isn't present - e.g. no
# Emscripten on PATH skips WASM. Each piece runs independently; a failure in
# one does not abort later pieces.
#
# Examples:
# pwsh scripts/test_phase0.ps1
# pwsh scripts/test_phase0.ps1 -Preset win-local-pdfium
# pwsh scripts/test_phase0.ps1 -BinaryDir C:/Users/me/pdfeng-build/windows-debug
# pwsh scripts/test_phase0.ps1 -SkipWasm
#
# OneDrive note: on this repo's spaced/OneDrive-synced path the default in-tree
# binaryDir breaks the engine build (see docs/phase0.md "OneDrive warning").
# Pass -BinaryDir to redirect, or use a CMakeUserPresets preset that already
# does so (e.g. win-local-pdfium).
param(
[string]$Preset = '',
[string]$BinaryDir = '',
[switch]$SkipEngine,
[switch]$SkipGateway,
[switch]$SkipWasm
)
$ErrorActionPreference = 'Continue'
$RepoRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
Set-Location $RepoRoot
if (-not $Preset) {
if ($env:OS -eq 'Windows_NT') { $Preset = 'windows-debug' }
elseif ($IsMacOS) { $Preset = 'macos-debug' }
else { $Preset = 'linux-debug' }
}
$script:results = [ordered]@{}
function Invoke-Step {
param([string]$Name, [scriptblock]$Body)
Write-Host ''
Write-Host ">> [$Name]"
$global:LASTEXITCODE = 0
try {
& $Body
$rc = $LASTEXITCODE
if ($rc -and $rc -ne 0) {
$script:results[$Name] = 'FAIL'
Write-Host " [$Name] FAILED (exit $rc)"
} else {
$script:results[$Name] = 'PASS'
Write-Host " [$Name] OK"
}
} catch {
$script:results[$Name] = 'FAIL'
Write-Host " [$Name] FAILED: $_"
}
}
function Skip-Step {
param([string]$Name, [string]$Reason)
Write-Host ''
Write-Host ">> [$Name] SKIPPED: $Reason"
$script:results[$Name] = 'SKIP'
}
Write-Host "Phase 0 aggregator - preset='$Preset'$(if ($BinaryDir) { " binaryDir='$BinaryDir'" })"
# --- 1. Rule R2 boundary -----------------------------------------------------
Invoke-Step 'R2 boundary' {
& (Join-Path $RepoRoot 'scripts/check_pdfium_boundary.ps1')
}
# --- 2. Engine ---------------------------------------------------------------
if ($SkipEngine) {
Skip-Step 'engine' 'requested via -SkipEngine'
} elseif (-not $env:VCPKG_ROOT) {
Skip-Step 'engine' 'VCPKG_ROOT not set - run scripts/bootstrap.ps1 first (and re-open the shell, or set $env:VCPKG_ROOT)'
} else {
Invoke-Step 'engine configure' {
if ($BinaryDir) { cmake --preset $Preset -B $BinaryDir } else { cmake --preset $Preset }
}
if ($script:results['engine configure'] -eq 'PASS') {
Invoke-Step 'engine build' {
if ($BinaryDir) { cmake --build $BinaryDir } else { cmake --build --preset $Preset }
}
if ($script:results['engine build'] -eq 'PASS') {
Invoke-Step 'engine test' {
if ($BinaryDir) { ctest --test-dir $BinaryDir --output-on-failure } else { ctest --preset $Preset }
}
} else {
Skip-Step 'engine test' 'build failed'
}
} else {
Skip-Step 'engine build' 'configure failed'
Skip-Step 'engine test' 'configure failed'
}
}
# --- 3. Gateway --------------------------------------------------------------
if ($SkipGateway) {
Skip-Step 'gateway' 'requested via -SkipGateway'
} elseif (-not (Get-Command python -ErrorAction SilentlyContinue)) {
Skip-Step 'gateway' 'python not found on PATH'
} else {
Push-Location (Join-Path $RepoRoot 'gateway')
try {
Invoke-Step 'gateway install' { python -m pip install --quiet -e ".[dev]" }
if ($script:results['gateway install'] -eq 'PASS') {
Invoke-Step 'gateway lint' { python -m ruff check . }
Invoke-Step 'gateway format' { python -m ruff format --check . }
Invoke-Step 'gateway pytest' { python -m pytest }
}
} finally { Pop-Location }
}
# --- 4. WASM hello-world -----------------------------------------------------
if ($SkipWasm) {
Skip-Step 'wasm' 'requested via -SkipWasm'
} elseif (-not (Get-Command emcc -ErrorAction SilentlyContinue)) {
Skip-Step 'wasm' 'emcc not on PATH - activate emsdk (emsdk_env.ps1) first'
} elseif (-not (Get-Command node -ErrorAction SilentlyContinue)) {
Skip-Step 'wasm' 'node not on PATH'
} else {
Invoke-Step 'wasm configure' { cmake --preset wasm }
if ($script:results['wasm configure'] -eq 'PASS') {
Invoke-Step 'wasm build' { cmake --build --preset wasm }
if ($script:results['wasm build'] -eq 'PASS') {
Invoke-Step 'wasm test' { node wasm/hello.test.mjs }
} else {
Skip-Step 'wasm test' 'build failed'
}
} else {
Skip-Step 'wasm build' 'configure failed'
Skip-Step 'wasm test' 'configure failed'
}
}
# --- Summary ----------------------------------------------------------------
Write-Host ''
Write-Host '================ Phase 0 summary ================'
$pad = 0
foreach ($k in $script:results.Keys) { if ($k.Length -gt $pad) { $pad = $k.Length } }
$fmt = ' {0,-' + $pad + '} {1}'
$anyFail = $false
foreach ($name in $script:results.Keys) {
$status = $script:results[$name]
Write-Host ($fmt -f $name, $status)
if ($status -eq 'FAIL') { $anyFail = $true }
}
Write-Host '================================================='
if ($anyFail) {
Write-Host 'Phase 0: FAIL'
exit 1
}
Write-Host 'Phase 0: PASS'
exit 0
-169
View File
@@ -1,169 +0,0 @@
#!/usr/bin/env bash
# Phase 0 aggregator — runs every check in .github/workflows/ci.yml locally and
# reports a single pass/fail summary. Use this as the "did I set everything up
# right?" one-liner for new teammates.
#
# Pieces run, in order:
# 1. Rule R2 boundary (scripts/check_pdfium_boundary.sh)
# 2. Engine (cmake configure + build + ctest)
# 3. Gateway (pip install -e [dev] + ruff + pytest, in gateway/)
# 4. WASM hello-world (cmake configure + build + node hello.test.mjs)
#
# Skips (not failures) are reported when a toolchain isn't present — e.g. no
# Emscripten on PATH skips WASM. Each piece runs independently; a failure in
# one does not abort later pieces.
#
# Usage:
# ./scripts/test_phase0.sh # auto-pick preset by OS
# ./scripts/test_phase0.sh linux-asan # override preset
# PHASE0_BINARY_DIR=/tmp/build ./scripts/test_phase0.sh
# SKIP_WASM=1 ./scripts/test_phase0.sh
#
# Compatible with bash 3.2 (macOS default) — uses parallel indexed arrays
# instead of associative arrays.
set -uo pipefail # NOT -e: we want to collect failures, not abort on first
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${REPO_ROOT}"
PRESET="${1:-}"
if [[ -z "${PRESET}" ]]; then
case "$(uname -s)" in
Darwin*) PRESET=macos-debug ;;
*) PRESET=linux-debug ;;
esac
fi
BINARY_DIR="${PHASE0_BINARY_DIR:-}"
STEPS=()
STATUSES=()
LAST_STATUS=PASS
record() {
STEPS+=("$1")
STATUSES+=("$2")
LAST_STATUS="$2"
}
run_step() {
local name="$1"; shift
echo
echo ">> [${name}]"
if "$@"; then
record "${name}" PASS
echo " [${name}] OK"
else
local rc=$?
record "${name}" FAIL
echo " [${name}] FAILED (exit ${rc})"
fi
}
skip_step() {
local name="$1"
local reason="$2"
echo
echo ">> [${name}] SKIPPED: ${reason}"
record "${name}" SKIP
}
echo "Phase 0 aggregator — preset='${PRESET}'${BINARY_DIR:+ binaryDir='${BINARY_DIR}'}"
# --- 1. Rule R2 boundary -----------------------------------------------------
run_step 'R2 boundary' bash "${REPO_ROOT}/scripts/check_pdfium_boundary.sh"
# --- 2. Engine ---------------------------------------------------------------
engine_configure() {
if [[ -n "${BINARY_DIR}" ]]; then cmake --preset "${PRESET}" -B "${BINARY_DIR}";
else cmake --preset "${PRESET}"; fi
}
engine_build() {
if [[ -n "${BINARY_DIR}" ]]; then cmake --build "${BINARY_DIR}";
else cmake --build --preset "${PRESET}"; fi
}
engine_test() {
if [[ -n "${BINARY_DIR}" ]]; then ctest --test-dir "${BINARY_DIR}" --output-on-failure;
else ctest --preset "${PRESET}"; fi
}
if [[ "${SKIP_ENGINE:-0}" == "1" ]]; then
skip_step 'engine' 'requested via SKIP_ENGINE=1'
elif [[ -z "${VCPKG_ROOT:-}" ]]; then
skip_step 'engine' 'VCPKG_ROOT not set — run scripts/bootstrap.sh first (and re-source your shell rc)'
else
run_step 'engine configure' engine_configure
if [[ "${LAST_STATUS}" == PASS ]]; then
run_step 'engine build' engine_build
if [[ "${LAST_STATUS}" == PASS ]]; then
run_step 'engine test' engine_test
else
skip_step 'engine test' 'build failed'
fi
else
skip_step 'engine build' 'configure failed'
skip_step 'engine test' 'configure failed'
fi
fi
# --- 3. Gateway --------------------------------------------------------------
PY=""
if command -v python3 >/dev/null 2>&1; then PY="$(command -v python3)";
elif command -v python >/dev/null 2>&1; then PY="$(command -v python)"; fi
if [[ "${SKIP_GATEWAY:-0}" == "1" ]]; then
skip_step 'gateway' 'requested via SKIP_GATEWAY=1'
elif [[ -z "${PY}" ]]; then
skip_step 'gateway' 'python/python3 not on PATH'
else
pushd gateway >/dev/null
run_step 'gateway install' "${PY}" -m pip install --quiet -e ".[dev]"
if [[ "${LAST_STATUS}" == PASS ]]; then
run_step 'gateway lint' "${PY}" -m ruff check .
run_step 'gateway format' "${PY}" -m ruff format --check .
run_step 'gateway pytest' "${PY}" -m pytest
fi
popd >/dev/null
fi
# --- 4. WASM hello-world -----------------------------------------------------
if [[ "${SKIP_WASM:-0}" == "1" ]]; then
skip_step 'wasm' 'requested via SKIP_WASM=1'
elif ! command -v emcc >/dev/null 2>&1; then
skip_step 'wasm' 'emcc not on PATH — source emsdk_env.sh first'
elif ! command -v node >/dev/null 2>&1; then
skip_step 'wasm' 'node not on PATH'
else
run_step 'wasm configure' cmake --preset wasm
if [[ "${LAST_STATUS}" == PASS ]]; then
run_step 'wasm build' cmake --build --preset wasm
if [[ "${LAST_STATUS}" == PASS ]]; then
run_step 'wasm test' node wasm/hello.test.mjs
else
skip_step 'wasm test' 'build failed'
fi
else
skip_step 'wasm build' 'configure failed'
skip_step 'wasm test' 'configure failed'
fi
fi
# --- Summary ----------------------------------------------------------------
echo
echo '================ Phase 0 summary ================'
pad=0
for s in "${STEPS[@]}"; do
if (( ${#s} > pad )); then pad=${#s}; fi
done
any_fail=0
for i in "${!STEPS[@]}"; do
printf " %-${pad}s %s\n" "${STEPS[$i]}" "${STATUSES[$i]}"
if [[ "${STATUSES[$i]}" == FAIL ]]; then any_fail=1; fi
done
echo '================================================='
if [[ "${any_fail}" -ne 0 ]]; then
echo 'Phase 0: FAIL'
exit 1
fi
echo 'Phase 0: PASS'
exit 0
-41
View File
@@ -1,41 +0,0 @@
# Phase 0 WASM hello-world.
#
# Only built when the Emscripten toolchain is active (see the root
# CMakeLists.txt — it early-returns into this subdir when EMSCRIPTEN is set).
# Rule R5: this target must never become a Phase 1 dependency.
if(NOT EMSCRIPTEN)
message(FATAL_ERROR
"wasm/CMakeLists.txt requires the Emscripten toolchain. "
"Use: cmake --preset wasm (after activating emsdk).")
endif()
add_executable(hello hello.cpp)
# Emit an ES6 module so Node 20+ and modern browsers can `import` it directly.
# Suffix .mjs is what tells emcc to emit an ES module; the matching .wasm is
# produced alongside it.
set_target_properties(hello PROPERTIES
OUTPUT_NAME "hello"
SUFFIX ".mjs"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
)
# Emscripten link flags. Keep this list short — every flag added here is a
# Phase 0 commitment the frontend dev will inherit.
target_link_options(hello PRIVATE
"-sMODULARIZE=1"
"-sEXPORT_ES6=1"
"-sENVIRONMENT=node,web"
# Functions callable from JS via ccall/cwrap. Underscore-prefix is the
# C symbol name emscripten exposes.
"-sEXPORTED_FUNCTIONS=['_add','_hello_version']"
"-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap']"
# Engine blueprint §6.3: real PDFs can exceed the default heap.
"-sALLOW_MEMORY_GROWTH=1"
)
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)")
+14 -80
View File
@@ -1,88 +1,22 @@
# WASM (Emscripten)
# WASM build — placeholder
Phase 0 task **"WASM hello-world build (Emscripten)"** lives here. The full
engine-in-WASM build is Phase 2 work — see *Phase 2* below.
Phase 0 task **"WASM hello-world build (Emscripten)"** and the Phase 2 task
**"WASM rendering path"** live here. Not implemented yet.
> **Rule R5: WASM never blocks shipping.** Server-side rendering is always the
> fallback. This Phase 0 deliverable is *validation only* — proves that the
> Emscripten toolchain end-to-end compiles a C++ function callable from JS.
> Do **not** gate Phase 1 on it.
**Rule R5: WASM never blocks shipping.** Server-side rendering is always the
fallback. The Phase 0 WASM task is *validation only* — prove that Emscripten can
compile a C++ function callable from JS. Do not gate Phase 1 on it.
## What's here today
```
wasm/
emsdk.pinned # pinned Emscripten SDK version
CMakeLists.txt # builds the hello-world Emscripten target
hello.cpp # exports add() and hello_version() via EMSCRIPTEN_KEEPALIVE
hello.test.mjs # Node 20+ smoke test — loads the module, asserts return values
package.json # `npm test` shortcut for hello.test.mjs
```
Build output goes to `out/build/wasm/bin/hello.{mjs,wasm}`. The `.mjs` is an
ES6 module that imports cleanly into Node 20+ **and** modern browsers.
## Prerequisites
1. **Emscripten SDK** at the version pinned in [`emsdk.pinned`](emsdk.pinned).
```sh
git clone https://github.com/emscripten-core/emsdk
cd emsdk
./emsdk install 3.1.74 # match emsdk.pinned
./emsdk activate 3.1.74
source ./emsdk_env.sh # Linux/macOS
.\emsdk_env.bat # Windows cmd.exe
.\emsdk_env.ps1 # Windows PowerShell
```
After `emsdk_env` runs in your shell, `$EMSDK` is set — that's what
[`cmake/toolchains/wasm.cmake`](../cmake/toolchains/wasm.cmake) keys off.
On Windows, install emsdk on a space-free path outside OneDrive (same rule
as the PDFium build — see [`docs/phase0.md`](../docs/phase0.md#onedrive-warning)).
2. **Node 20+** for the smoke test (CI uses Node 20).
3. **CMake 3.25+ and Ninja** — already required for the rest of the repo.
## Build + run
Planned pipeline (engine blueprint §6):
```sh
cmake --preset wasm
cmake --build --preset wasm
node wasm/hello.test.mjs
# → [wasm-smoke] OK — add(2,3)=5, hello_version()=1, cwrap add(40,2)=42
emcmake cmake --preset wasm
emmake cmake --build --preset wasm
# outputs: engine.wasm + engine.js
```
CI does the same three commands on Ubuntu after installing the pinned emsdk.
Key build flags to carry forward: `ALLOW_MEMORY_GROWTH=1`, Web Worker +
OffscreenCanvas for off-main-thread rendering.
### Local build dir override (Windows / OneDrive)
The default `binaryDir` lives under the repo. On Windows the
[OneDrive + spaced-path constraint](../docs/phase0.md#onedrive-warning)
applies — drop a `CMakeUserPresets.json` next to this README's parent that
overrides `binaryDir` to e.g. `C:/Users/<you>/pdfeng-build/wasm`, and set
`HELLO_MJS=C:/Users/<you>/pdfeng-build/wasm/bin/hello.mjs` when running the
smoke test.
## What this proves (and what it does NOT)
| Validated by Phase 0 hello-world | Still TODO in Phase 2 |
|------------------------------------------|---------------------------------------------|
| emsdk install + version pin | PDFium cross-compiled with Emscripten |
| `cmake --preset wasm` configures cleanly | Skia / FreeType / HarfBuzz cross-compiled |
| C → JS export via `ccall`/`cwrap` | Engine cross-compiled |
| ES6-module output usable from Node + web | Web Worker + OffscreenCanvas integration |
| CI pipeline for the WASM artifact | Page-render parity with the server path |
## Phase 2 — real engine-in-WASM
When that work begins, **do not** delete the hello-world target — keep it as a
toolchain canary so a broken emsdk install fails fast and obviously. The real
target will live alongside it (e.g. `pdfengine_wasm`) and consume the engine
library via the standard CMake import path once the engine has Emscripten
support in its vcpkg/build story.
Key flags to carry forward (engine blueprint §6):
`ALLOW_MEMORY_GROWTH=1`, Web Worker + `OffscreenCanvas` for off-main-thread
rendering, ES6 module output.
The `cmake/toolchains/wasm.cmake` hook and a `wasm` CMake preset are stubbed in
this session so the integration point exists.
-10
View File
@@ -1,10 +0,0 @@
# Emscripten SDK pinned version — Rule: never track rolling HEAD.
#
# Bumps are deliberate (quarterly cadence, like PDFium). CI installs this exact
# version; local developers should match it. Look up versions at:
# https://github.com/emscripten-core/emsdk/blob/main/emscripten-releases-tags.json
#
# To re-pin: pick a tagged release and update EMSDK_VERSION. The setup-emsdk
# GitHub Action reads this file via its `version:` input in CI.
EMSDK_VERSION=3.1.74
-35
View File
@@ -1,35 +0,0 @@
// Phase 0 WASM hello-world.
//
// Scope (Rule R5: WASM never blocks shipping):
// Prove the Emscripten toolchain compiles a C++ function callable from JS.
// Nothing more. The real engine-in-WASM build happens in Phase 2 and depends
// on PDFium/Skia/FreeType/HarfBuzz cross-compiled with Emscripten plus the
// frozen interface contracts from Gate G0b — all of which are out of scope
// for Phase 0.
//
// The exported C symbols here are loaded from JS via cwrap()/ccall(); see
// hello.test.mjs.
#include <cstdint>
#include <cstring>
#if defined(__EMSCRIPTEN__)
#include <emscripten/emscripten.h>
#define WASM_EXPORT EMSCRIPTEN_KEEPALIVE
#else
#define WASM_EXPORT
#endif
extern "C" {
// Smallest possible "does it run?" probe.
WASM_EXPORT int add(int a, int b) {
return a + b;
}
// A version probe so the JS side can sanity-check the module it loaded.
WASM_EXPORT int hello_version() {
return 1;
}
} // extern "C"
-46
View File
@@ -1,46 +0,0 @@
// Phase 0 WASM smoke test.
//
// Loads the Emscripten-built hello module and asserts the C export is callable
// from JS. This is the test CI runs after `cmake --build --preset wasm`.
//
// Run from the repo root:
// node wasm/hello.test.mjs
//
// Or set HELLO_MJS to point elsewhere if your build directory differs.
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 helloPath =
process.env.HELLO_MJS ?? resolve(repoRoot, "out/build/wasm/bin/hello.mjs");
if (!existsSync(helloPath)) {
console.error(
`[wasm-smoke] hello.mjs not found at: ${helloPath}\n` +
`Did you run \`cmake --build --preset wasm\` first?\n` +
`Override with HELLO_MJS=/path/to/hello.mjs if your build dir differs.`,
);
process.exit(1);
}
const { default: createModule } = await import(pathToFileURL(helloPath).href);
const Module = await createModule();
// add(): the core "does it run?" check.
const addResult = Module.ccall("add", "number", ["number", "number"], [2, 3]);
assert.equal(addResult, 5, `add(2, 3) returned ${addResult}, expected 5`);
// hello_version(): guards against loading a stale module from a previous build.
const version = Module.ccall("hello_version", "number", [], []);
assert.equal(version, 1, `hello_version() returned ${version}, expected 1`);
// cwrap-style wrapping should also work — frontend will use this pattern.
const addWrapped = Module.cwrap("add", "number", ["number", "number"]);
assert.equal(addWrapped(40, 2), 42);
console.log("[wasm-smoke] OK — add(2,3)=5, hello_version()=1, cwrap add(40,2)=42");
-13
View File
@@ -1,13 +0,0 @@
{
"name": "@pdfengine/wasm-smoke",
"version": "0.0.0",
"private": true,
"description": "Phase 0 WASM hello-world smoke test (Rule R5 — never blocks shipping).",
"type": "module",
"scripts": {
"test": "node hello.test.mjs"
},
"engines": {
"node": ">=20"
}
}