fix: built base emsdk and other tool fixes

This commit is contained in:
Furqan-14
2026-05-16 11:01:20 +05:30
parent 011c526b11
commit 26dccd3536
26 changed files with 743 additions and 109 deletions
+71 -17
View File
@@ -13,9 +13,6 @@ concurrency:
cancel-in-progress: true
jobs:
# ---------------------------------------------------------------------------
# Static checks: Rule R2 boundary + formatting. Fast, gates the build matrix.
# ---------------------------------------------------------------------------
lint:
runs-on: ubuntu-latest
steps:
@@ -24,9 +21,6 @@ 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
@@ -36,9 +30,6 @@ 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:
@@ -66,15 +57,10 @@ 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
@@ -88,9 +74,6 @@ 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: |
@@ -106,3 +89,74 @@ 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
+6 -21
View File
@@ -1,10 +1,5 @@
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")
@@ -24,32 +19,30 @@ 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)
@@ -59,9 +52,6 @@ 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)
@@ -72,13 +62,8 @@ 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 (Phase 0 stub — validation only)",
"displayName": "WASM • Emscripten hello-world (Phase 0, Rule R5)",
"generator": "Ninja",
"binaryDir": "${sourceDir}/out/build/${presetName}",
"toolchainFile": "${sourceDir}/cmake/toolchains/wasm.cmake",
+1 -7
View File
@@ -1,12 +1,6 @@
# AddressSanitizer / UndefinedBehaviorSanitizer wiring.
# Enabled per build via -DPDFENGINE_ENABLE_SANITIZERS=ON (see the *-asan presets).
# Enabled per build via -DPDFENGINE_ENABLE_SANITIZERS=ON
# 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)
+2 -19
View File
@@ -1,21 +1,5 @@
# PDFium integration.
#
# 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.
# Creates imported target pdfium::pdfium from third_party/pdfium/install/.
if(NOT PDFENGINE_WITH_PDFIUM)
message(STATUS "PDFium: disabled (PDFENGINE_WITH_PDFIUM=OFF). "
@@ -51,8 +35,7 @@ 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)
+5 -8
View File
@@ -1,12 +1,9 @@
# WebAssembly (Emscripten) toolchain hook — STUB for Phase 0.
# WebAssembly (Emscripten) toolchain hook.
#
# 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.
# 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.
if(NOT DEFINED ENV{EMSDK})
message(FATAL_ERROR
-9
View File
@@ -1,8 +1,5 @@
# 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(
@@ -24,10 +21,6 @@ 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
@@ -36,8 +29,6 @@ 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)
+95 -12
View File
@@ -1,24 +1,107 @@
# Gateway (FastAPI) — placeholder
# Gateway (FastAPI)
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.
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.
Planned responsibilities (see `docs/phase0.md` and the engine blueprint §8):
What the gateway eventually does (see `docs/phase0.md` and engine blueprint §8):
- Auth, metadata, storage (S3 / MinIO), async job queue.
- Bridges to the C++ engine via **pybind11** (`bindings/python/`).
- Placeholder routes + a `/health` endpoint returning `200`**no PDF logic**
until the engine wrappers exist.
- Hosts the API the React + TypeScript viewer in [`frontend/`](../frontend/) calls.
When implemented:
## Current state
```
gateway/
pyproject.toml
pyproject.toml # pinned FastAPI/uvicorn/pydantic + ruff/pytest
app/
main.py # FastAPI app + /health
routers/
services/
__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)
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
@@ -0,0 +1,3 @@
"""PDF engine gateway — FastAPI service."""
__version__ = "0.0.0"
+33
View File
@@ -0,0 +1,33 @@
"""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
@@ -0,0 +1,24 @@
"""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
@@ -0,0 +1,32 @@
"""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
@@ -0,0 +1,16 @@
"""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
@@ -0,0 +1,35 @@
"""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
@@ -0,0 +1,21 @@
"""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
@@ -0,0 +1,45 @@
"""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
@@ -0,0 +1,59 @@
[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
@@ -0,0 +1,14 @@
"""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
@@ -0,0 +1,25 @@
"""`/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
@@ -0,0 +1,29 @@
"""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()
+41
View File
@@ -0,0 +1,41 @@
# 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)")
+81 -15
View File
@@ -1,22 +1,88 @@
# WASM build — placeholder
# WASM (Emscripten)
Phase 0 task **"WASM hello-world build (Emscripten)"** and the Phase 2 task
**"WASM rendering path"** live here. Not implemented yet.
Phase 0 task **"WASM hello-world build (Emscripten)"** lives here. The full
engine-in-WASM build is Phase 2 work — see *Phase 2* below.
**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.
> **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.
Planned pipeline (engine blueprint §6):
## What's here today
```sh
emcmake cmake --preset wasm
emmake cmake --build --preset wasm
# outputs: engine.wasm + engine.js
```
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
```
Key build flags to carry forward: `ALLOW_MEMORY_GROWTH=1`, Web Worker +
OffscreenCanvas for off-main-thread rendering.
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.
The `cmake/toolchains/wasm.cmake` hook and a `wasm` CMake preset are stubbed in
this session so the integration point exists.
## 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
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
```
CI does the same three commands on Ubuntu after installing the pinned emsdk.
### 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.
+10
View File
@@ -0,0 +1,10 @@
# 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
@@ -0,0 +1,35 @@
// 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
@@ -0,0 +1,46 @@
// 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
@@ -0,0 +1,13 @@
{
"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"
}
}