Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de4868ff49 | ||
|
|
a1e6b5910c |
+17
-73
@@ -13,6 +13,9 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Static checks: Rule R2 boundary + formatting. Fast, gates the build matrix.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -21,6 +24,9 @@ jobs:
|
|||||||
- name: Rule R2 — PDFium boundary check
|
- name: Rule R2 — PDFium boundary check
|
||||||
run: bash scripts/check_pdfium_boundary.sh
|
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)
|
- name: Install clang-format (pinned)
|
||||||
run: pipx install clang-format==22.1.5
|
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' \) \
|
find engine \( -name '*.cpp' -o -name '*.cc' -o -name '*.h' -o -name '*.hpp' \) \
|
||||||
-print0 | xargs -0 clang-format --dry-run --Werror
|
-print0 | xargs -0 clang-format --dry-run --Werror
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Build + test on all three platforms (Gate G0: compiles clean everywhere).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
build:
|
build:
|
||||||
needs: lint
|
needs: lint
|
||||||
strategy:
|
strategy:
|
||||||
@@ -40,11 +49,9 @@ jobs:
|
|||||||
preset: linux-debug
|
preset: linux-debug
|
||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
preset: macos-debug
|
preset: macos-debug
|
||||||
experimental: true
|
|
||||||
- os: windows-latest
|
- os: windows-latest
|
||||||
preset: windows-debug
|
preset: windows-debug
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
continue-on-error: ${{ matrix.experimental == true }}
|
|
||||||
env:
|
env:
|
||||||
VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}/.vcpkg-cache
|
VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}/.vcpkg-cache
|
||||||
steps:
|
steps:
|
||||||
@@ -57,10 +64,15 @@ jobs:
|
|||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
uses: ilammy/msvc-dev-cmd@v1
|
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
|
- name: Locate vcpkg
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "VCPKG_ROOT=$VCPKG_INSTALLATION_ROOT" >> "$GITHUB_ENV"
|
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
|
git -C "$VCPKG_INSTALLATION_ROOT" fetch --quiet origin || true
|
||||||
|
|
||||||
- name: Create vcpkg binary cache dir
|
- name: Create vcpkg binary cache dir
|
||||||
@@ -74,6 +86,9 @@ jobs:
|
|||||||
key: vcpkg-${{ matrix.os }}-${{ hashFiles('vcpkg.json') }}
|
key: vcpkg-${{ matrix.os }}-${{ hashFiles('vcpkg.json') }}
|
||||||
restore-keys: vcpkg-${{ matrix.os }}-
|
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
|
- name: Pin vcpkg dependency baseline
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -89,74 +104,3 @@ jobs:
|
|||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: ctest --preset ${{ matrix.preset }}
|
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
@@ -1,5 +1,10 @@
|
|||||||
cmake_minimum_required(VERSION 3.25)
|
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)
|
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/vcpkg.json" _pdfengine_vcpkg_json)
|
||||||
string(JSON _pdfengine_baseline ERROR_VARIABLE _pdfengine_baseline_err
|
string(JSON _pdfengine_baseline ERROR_VARIABLE _pdfengine_baseline_err
|
||||||
GET "${_pdfengine_vcpkg_json}" "builtin-baseline")
|
GET "${_pdfengine_vcpkg_json}" "builtin-baseline")
|
||||||
@@ -19,30 +24,32 @@ project(PdfEngine
|
|||||||
HOMEPAGE_URL "https://example.invalid/pdf-engine"
|
HOMEPAGE_URL "https://example.invalid/pdf-engine"
|
||||||
LANGUAGES CXX)
|
LANGUAGES CXX)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Global C++ settings — C++23, no compiler extensions (engine blueprint §1.3).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
set(CMAKE_CXX_STANDARD 23)
|
set(CMAKE_CXX_STANDARD 23)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||||
|
|
||||||
|
# compile_commands.json — consumed by clang-tidy and editors.
|
||||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
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_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
|
||||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib")
|
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib")
|
||||||
set(CMAKE_ARCHIVE_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")
|
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.
|
# Only the top-level project drives testing/install defaults.
|
||||||
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_BINARY_DIR)
|
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_BINARY_DIR)
|
||||||
message(FATAL_ERROR "In-source builds are not allowed. Use a preset: "
|
message(FATAL_ERROR "In-source builds are not allowed. Use a preset: "
|
||||||
"cmake --preset <platform>-debug")
|
"cmake --preset <platform>-debug")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Build options.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
option(PDFENGINE_BUILD_TESTS "Build engine unit/smoke tests" ON)
|
option(PDFENGINE_BUILD_TESTS "Build engine unit/smoke tests" ON)
|
||||||
option(PDFENGINE_ENABLE_SANITIZERS "Build with AddressSanitizer/UBSan" OFF)
|
option(PDFENGINE_ENABLE_SANITIZERS "Build with AddressSanitizer/UBSan" OFF)
|
||||||
option(PDFENGINE_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" OFF)
|
option(PDFENGINE_WARNINGS_AS_ERRORS "Treat compiler warnings as errors" OFF)
|
||||||
@@ -52,6 +59,9 @@ include(CompilerWarnings)
|
|||||||
include(Sanitizers)
|
include(Sanitizers)
|
||||||
include(pdfium) # defines pdfium::pdfium when PDFENGINE_WITH_PDFIUM is ON
|
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(freetype CONFIG REQUIRED)
|
||||||
find_package(harfbuzz CONFIG REQUIRED)
|
find_package(harfbuzz CONFIG REQUIRED)
|
||||||
find_package(spdlog CONFIG REQUIRED)
|
find_package(spdlog CONFIG REQUIRED)
|
||||||
@@ -62,8 +72,13 @@ if(PDFENGINE_BUILD_TESTS)
|
|||||||
include(GoogleTest)
|
include(GoogleTest)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Subprojects.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
add_subdirectory(engine)
|
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 "PdfEngine ${PROJECT_VERSION} configured")
|
||||||
message(STATUS " C++ standard ......... ${CMAKE_CXX_STANDARD}")
|
message(STATUS " C++ standard ......... ${CMAKE_CXX_STANDARD}")
|
||||||
|
|||||||
+1
-1
@@ -99,7 +99,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
"name": "wasm",
|
"name": "wasm",
|
||||||
"displayName": "WASM • Emscripten hello-world (Phase 0, Rule R5)",
|
"displayName": "WASM • Emscripten (Phase 0 stub — validation only)",
|
||||||
"generator": "Ninja",
|
"generator": "Ninja",
|
||||||
"binaryDir": "${sourceDir}/out/build/${presetName}",
|
"binaryDir": "${sourceDir}/out/build/${presetName}",
|
||||||
"toolchainFile": "${sourceDir}/cmake/toolchains/wasm.cmake",
|
"toolchainFile": "${sourceDir}/cmake/toolchains/wasm.cmake",
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
# AddressSanitizer / UndefinedBehaviorSanitizer wiring.
|
# 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>)
|
# 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)
|
function(pdfengine_enable_sanitizers target)
|
||||||
if(NOT PDFENGINE_ENABLE_SANITIZERS)
|
if(NOT PDFENGINE_ENABLE_SANITIZERS)
|
||||||
|
|||||||
+19
-2
@@ -1,5 +1,21 @@
|
|||||||
# PDFium integration.
|
# 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)
|
if(NOT PDFENGINE_WITH_PDFIUM)
|
||||||
message(STATUS "PDFium: disabled (PDFENGINE_WITH_PDFIUM=OFF). "
|
message(STATUS "PDFium: disabled (PDFENGINE_WITH_PDFIUM=OFF). "
|
||||||
@@ -35,7 +51,8 @@ set_target_properties(pdfium::pdfium PROPERTIES
|
|||||||
IMPORTED_LOCATION "${PDFIUM_LIBRARY}"
|
IMPORTED_LOCATION "${PDFIUM_LIBRARY}"
|
||||||
INTERFACE_INCLUDE_DIRECTORIES "${PDFIUM_INCLUDE_DIR}")
|
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)
|
if(UNIX AND NOT APPLE)
|
||||||
set_property(TARGET pdfium::pdfium APPEND PROPERTY
|
set_property(TARGET pdfium::pdfium APPEND PROPERTY
|
||||||
INTERFACE_LINK_LIBRARIES pthread dl)
|
INTERFACE_LINK_LIBRARIES pthread dl)
|
||||||
|
|||||||
@@ -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
|
# The Phase 0 "WASM hello-world" task is validation-only and must NOT block
|
||||||
# toolchain from the active EMSDK. Phase 0 builds only the hello-world target
|
# Phase 1 (Rule R5). This file exists so the integration point is real; the
|
||||||
# in wasm/ (Rule R5 — never blocks shipping). Phase 2 will compile the full
|
# full WASM build (PDFium + Skia + FreeType + HarfBuzz compiled together) is
|
||||||
# engine (PDFium + Skia + FreeType + HarfBuzz) through this same toolchain.
|
# 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})
|
if(NOT DEFINED ENV{EMSDK})
|
||||||
message(FATAL_ERROR
|
message(FATAL_ERROR
|
||||||
|
|||||||
@@ -93,11 +93,6 @@ ctest --preset windows-debug
|
|||||||
The first configure compiles the vcpkg dependencies (freetype, harfbuzz,
|
The first configure compiles the vcpkg dependencies (freetype, harfbuzz,
|
||||||
spdlog, gtest) — slow once, cached after.
|
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
|
### Building with PDFium
|
||||||
|
|
||||||
PDFium is built separately from source (Task 2):
|
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
|
`PATH`. `third_party/pdfium/build_pdfium.ps1` already prepends it
|
||||||
per-run, so the PDFium build still works.
|
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
|
## Dependency pinning
|
||||||
|
|
||||||
The blueprint rule is *"pin all dependency versions on Day 1, never track
|
The blueprint rule is *"pin all dependency versions on Day 1, never track
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# pdfengine — the C++23 PDF SDK core.
|
# 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.
|
# Generate the version header from the project version.
|
||||||
configure_file(
|
configure_file(
|
||||||
@@ -10,8 +13,6 @@ configure_file(
|
|||||||
add_library(pdfengine STATIC
|
add_library(pdfengine STATIC
|
||||||
src/core/engine_info.cpp
|
src/core/engine_info.cpp
|
||||||
src/parser/pdfium_loader.cpp
|
src/parser/pdfium_loader.cpp
|
||||||
src/fonts/font_face.cpp
|
|
||||||
src/fonts/hb_shaper.cpp
|
|
||||||
)
|
)
|
||||||
add_library(pdfengine::pdfengine ALIAS pdfengine)
|
add_library(pdfengine::pdfengine ALIAS pdfengine)
|
||||||
|
|
||||||
@@ -23,6 +24,10 @@ target_include_directories(pdfengine
|
|||||||
"${CMAKE_CURRENT_SOURCE_DIR}/src"
|
"${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
|
target_link_libraries(pdfengine
|
||||||
PUBLIC
|
PUBLIC
|
||||||
spdlog::spdlog
|
spdlog::spdlog
|
||||||
@@ -31,6 +36,8 @@ target_link_libraries(pdfengine
|
|||||||
harfbuzz::harfbuzz
|
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)
|
if(PDFENGINE_WITH_PDFIUM)
|
||||||
target_link_libraries(pdfengine PRIVATE pdfium::pdfium)
|
target_link_libraries(pdfengine PRIVATE pdfium::pdfium)
|
||||||
target_compile_definitions(pdfengine PRIVATE PDFENGINE_WITH_PDFIUM)
|
target_compile_definitions(pdfengine PRIVATE PDFENGINE_WITH_PDFIUM)
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
add_executable(pdfengine_smoke
|
add_executable(pdfengine_smoke
|
||||||
smoke_test.cpp
|
smoke_test.cpp
|
||||||
fonts_test.cpp
|
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(pdfengine_smoke
|
target_link_libraries(pdfengine_smoke
|
||||||
@@ -12,11 +11,6 @@ target_link_libraries(pdfengine_smoke
|
|||||||
GTest::gtest_main
|
GTest::gtest_main
|
||||||
)
|
)
|
||||||
|
|
||||||
target_include_directories(pdfengine_smoke
|
|
||||||
PRIVATE
|
|
||||||
"${CMAKE_CURRENT_SOURCE_DIR}/../src"
|
|
||||||
)
|
|
||||||
|
|
||||||
pdfengine_set_warnings(pdfengine_smoke)
|
pdfengine_set_warnings(pdfengine_smoke)
|
||||||
pdfengine_enable_sanitizers(pdfengine_smoke)
|
pdfengine_enable_sanitizers(pdfengine_smoke)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,107 +1,24 @@
|
|||||||
# Gateway (FastAPI)
|
# Gateway (FastAPI) — placeholder
|
||||||
|
|
||||||
Phase 0 task **"FastAPI service scaffolding"** (Dev 1). Skeleton only — every
|
Phase 0 task **"FastAPI service scaffolding"** (Dev 1) and all later API work
|
||||||
PDF-touching route returns **501** until the pybind11 engine module in
|
lives here. Not implemented yet — this session scaffolds the C++ build pipeline
|
||||||
[`bindings/python/`](../bindings/python/) lands at Gate G0b.
|
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.
|
- Auth, metadata, storage (S3 / MinIO), async job queue.
|
||||||
- Bridges to the C++ engine via **pybind11** (`bindings/python/`).
|
- 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/
|
gateway/
|
||||||
pyproject.toml # pinned FastAPI/uvicorn/pydantic + ruff/pytest
|
pyproject.toml
|
||||||
app/
|
app/
|
||||||
__init__.py # exports __version__
|
main.py # FastAPI app + /health
|
||||||
main.py # FastAPI app factory
|
routers/
|
||||||
config.py # pydantic-settings (PDFENGINE_* env vars)
|
services/
|
||||||
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)
|
|
||||||
tests/
|
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.
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
"""PDF engine gateway — FastAPI service."""
|
|
||||||
|
|
||||||
__version__ = "0.0.0"
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -1,88 +1,22 @@
|
|||||||
# WASM (Emscripten)
|
# WASM build — placeholder
|
||||||
|
|
||||||
Phase 0 task **"WASM hello-world build (Emscripten)"** lives here. The full
|
Phase 0 task **"WASM hello-world build (Emscripten)"** and the Phase 2 task
|
||||||
engine-in-WASM build is Phase 2 work — see *Phase 2* below.
|
**"WASM rendering path"** live here. Not implemented yet.
|
||||||
|
|
||||||
> **Rule R5: WASM never blocks shipping.** Server-side rendering is always the
|
**Rule R5: WASM never blocks shipping.** Server-side rendering is always the
|
||||||
> fallback. This Phase 0 deliverable is *validation only* — proves that the
|
fallback. The Phase 0 WASM task is *validation only* — prove that Emscripten can
|
||||||
> Emscripten toolchain end-to-end compiles a C++ function callable from JS.
|
compile a C++ function callable from JS. Do not gate Phase 1 on it.
|
||||||
> Do **not** gate Phase 1 on it.
|
|
||||||
|
|
||||||
## What's here today
|
Planned pipeline (engine blueprint §6):
|
||||||
|
|
||||||
```
|
|
||||||
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
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cmake --preset wasm
|
emcmake cmake --preset wasm
|
||||||
cmake --build --preset wasm
|
emmake cmake --build --preset wasm
|
||||||
|
# outputs: engine.wasm + engine.js
|
||||||
node wasm/hello.test.mjs
|
|
||||||
# → [wasm-smoke] OK — add(2,3)=5, hello_version()=1, cwrap add(40,2)=42
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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 `cmake/toolchains/wasm.cmake` hook and a `wasm` CMake preset are stubbed in
|
||||||
|
this session so the integration point exists.
|
||||||
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.
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
@@ -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");
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user