From fe784ab4eeb98fa0557b148ce837ddd4e7a804d6 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 13:02:04 +0530 Subject: [PATCH 01/93] docs: add Folio Operator Console design spec and create ui/ scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec covers layout (B · Bars edition wireframe), server additions (ConsoleStore, metrics history, request/error ring buffers, /_/api/metrics JSON endpoint, rust-embed static serving), full frontend component tree, API contract, build integration, and Docker changes. ui/ folder contains the SvelteKit + Svelte 5 + Tailwind v4 + shadcn-svelte scaffold created by the user. Co-Authored-By: Claude Sonnet 4.6 --- ...026-05-01-folio-operator-console-design.md | 469 ++++++++++++++++ ui/.gitignore | 23 + ui/.npmrc | 1 + ui/.prettierignore | 9 + ui/.prettierrc | 16 + ui/README.md | 42 ++ ui/bun.lock | 501 ++++++++++++++++++ ui/components.json | 20 + ui/eslint.config.js | 44 ++ ui/package.json | 47 ++ ui/src/app.d.ts | 13 + ui/src/app.html | 12 + ui/src/lib/assets/favicon.svg | 1 + ui/src/lib/index.ts | 1 + ui/src/lib/utils.ts | 13 + ui/src/routes/+layout.svelte | 9 + ui/src/routes/+page.svelte | 2 + ui/src/routes/layout.css | 131 +++++ ui/static/robots.txt | 3 + ui/svelte.config.js | 12 + ui/tsconfig.json | 20 + ui/vite.config.ts | 5 + 22 files changed, 1394 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-01-folio-operator-console-design.md create mode 100644 ui/.gitignore create mode 100644 ui/.npmrc create mode 100644 ui/.prettierignore create mode 100644 ui/.prettierrc create mode 100644 ui/README.md create mode 100644 ui/bun.lock create mode 100644 ui/components.json create mode 100644 ui/eslint.config.js create mode 100644 ui/package.json create mode 100644 ui/src/app.d.ts create mode 100644 ui/src/app.html create mode 100644 ui/src/lib/assets/favicon.svg create mode 100644 ui/src/lib/index.ts create mode 100644 ui/src/lib/utils.ts create mode 100644 ui/src/routes/+layout.svelte create mode 100644 ui/src/routes/+page.svelte create mode 100644 ui/src/routes/layout.css create mode 100644 ui/static/robots.txt create mode 100644 ui/svelte.config.js create mode 100644 ui/tsconfig.json create mode 100644 ui/vite.config.ts diff --git a/docs/superpowers/specs/2026-05-01-folio-operator-console-design.md b/docs/superpowers/specs/2026-05-01-folio-operator-console-design.md new file mode 100644 index 0000000..80cf428 --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-folio-operator-console-design.md @@ -0,0 +1,469 @@ +# Folio Operator Console — Design Spec + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a real-time operations dashboard served at `/_/` inside the Folio binary — Variation B · Bars Edition from the approved wireframe. + +**Architecture:** Svelte 5 SPA embedded in the Folio binary via `rust-embed`. The Rust server adds a rolling metrics history store, request/error ring buffers, a `GET /_/api/metrics` JSON endpoint, and static file serving for `/_/`. The frontend polls every 5 seconds using Svelte 5 runes. + +**Tech Stack:** Rust (Axum, rust-embed, tokio), Svelte 5 (runes forced), Tailwind CSS v4, shadcn-svelte, Lucide icons, Geist font, Vite, TypeScript. + +--- + +## 1. Visual Specification + +Taken directly from `variation-b-bars.jsx`. Do not deviate. + +### Colors + +| Token | Light | Dark | +|---|---|---| +| `bg` | `#f7f7f5` | `#0e0f12` | +| `surface` | `#ffffff` | `#15171c` | +| `surface2` | `#fbfbf9` | `#1a1d24` | +| `ink` | `#1a1c1f` | `#e6e7ea` | +| `muted` | `rgba(26,28,31,0.55)` | `rgba(230,231,234,0.55)` | +| `faint` | `rgba(26,28,31,0.06)` | `rgba(230,231,234,0.10)` | +| `rule` | `rgba(26,28,31,0.08)` | `rgba(255,255,255,0.08)` | +| `ok` | `#2f9967` | `#3fb27f` | +| `warn` | `#b8860b` | `#e0a93c` | +| `err` | `#c25151` | `#e26464` | +| `accent` | user-selectable (default blue) | same | + +### Typography + +- UI text: `"Geist Variable", ui-sans-serif, system-ui, -apple-system, sans-serif` (Geist is installed via `@fontsource-variable/geist`) +- Numbers / code: `ui-monospace, monospace` (JetBrains Mono from CDN is optional; Geist Mono can substitute) + +### Card + +- `background: surface`, `border: 1px solid rule`, `border-radius: 12px` +- No drop shadows +- Card header: `border-bottom: 1px solid rule`, title `11.5px 600`, sub `10.5px muted` + +### Density presets (tweaks panel) + +| Density | `gap` | `pad` | `rowPy` | `fz` | `kpiFz` | +|---|---|---|---|---|---| +| compact | 8 | 8 | 2 | 10.5 | 18 | +| regular | 10 | 10 | 3 | 11.5 | 20 | +| comfy | 14 | 14 | 5 | 12 | 22 | + +--- + +## 2. Layout + +Five horizontal strips, 1400px wide canvas: + +``` +┌─────────────────────────────── Header ───────────────────────────────────┐ +├─────────────────────────────── Ticker (8 KPIs) ──────────────────────────┤ +│ │ +│ Routes table (8fr) │ Side rail (4fr) │ +│ Route / Method / RPS / │ ┌ Engines (Chromium + LibreOffice) ┐│ +│ p50 / p95 / p99 / Err% / │ ├ Concurrency (64-slot grid) ││ +│ In-flight / Load bar │ ├ Batches (progress list) ││ +│ │ └ Resources (CPU + Memory bars) ┘│ +├─────────────────────────────────────────────────────────────────────────┤ +│ RPS bar chart (1fr) │ p95 Latency bar chart (1fr) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ Request log │ Error log │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. Server Additions + +### 3.1 New types — `crates/server/src/console_store.rs` + +A new file holding everything the `/_/api/metrics` endpoint needs. + +```rust +/// One data point captured every 30 seconds. +pub struct MetricsSample { + pub ts: u64, // unix seconds + pub rps: f64, + pub p95_ms: f64, + pub error_pct: f64, + pub queue_size: u32, + pub concurrency_active: u32, + pub cpu_pct: f64, // 0.0–100.0, always 0.0 on non-Linux + pub memory_mb: f64, +} + +/// Rolling 30-minute window (60 samples at 30s cadence). +pub struct MetricsHistory { + pub samples: VecDeque, // cap 60 +} + +pub struct RequestLogEntry { + pub time: String, // "HH:MM:SS" + pub method: String, + pub route: String, + pub status: u16, + pub duration_ms: u64, +} + +pub struct ErrorLogEntry { + pub time: String, + pub route: String, + pub message: String, + pub request_id: String, +} + +/// Shared console state — Arc-wrapped in AppState. +pub struct ConsoleStore { + pub history: Mutex, // rolling samples + pub request_log: Mutex>, // cap 100 + pub error_log: Mutex>, // cap 100 +} +``` + +`ConsoleStore` is `Arc` in `AppState`. Added as `pub console: Arc`. + +### 3.2 Background sampler task + +Spawned in `main.rs` at startup. Every 30 seconds: +1. Read gauge values from `METRICS` (queue_size, concurrency, RPS from http counters delta, etc.) +2. Push a `MetricsSample` into `ConsoleStore::history`, evicting the oldest if at cap + +RPS is computed as `(http_requests_total_now - http_requests_total_prev) / 30.0`. + +p95 is not directly available from a Prometheus counter without the histogram. For V1, expose the raw p95 histogram bucket as a derived estimate, or expose the last observed p95 from a tracked moving value. **Simplest V1**: keep a `Mutex` in AppState that handlers update with each conversion duration, use the rolling 30s max as the p95 approximation. This is documented as "p95 (approx, rolling 30s max)". + +### 3.3 Request/error log middleware + +The existing `record_http_request` call site in `app.rs` already fires per request. Add a new `record_console_request(state, method, route, status, duration_ms)` call alongside the existing metrics call. This pushes to `ConsoleStore::request_log`. If status >= 500, also push to `error_log`. + +### 3.4 `GET /_/api/metrics` — JSON response shape + +```json +{ + "version": "0.1.0", + "git_hash": "a91f02e", + "uptime_seconds": 51738, + "ticker": { + "rps": 78.4, + "p95_ms": 1400.0, + "error_pct": 0.82, + "concurrency_active": 54, + "concurrency_max": 64, + "chromium_status": "up", + "chromium_restarts": 4, + "chromium_idle_ms": 4000, + "libreoffice_status": "up", + "libreoffice_restarts": 11, + "libreoffice_idle_ms": 0, + "queue_size": 12 + }, + "routes": [ + { + "path": "/forms/chromium/convert/html", + "method": "POST", + "rps": 14.2, + "p50_ms": 120.0, + "p95_ms": 480.0, + "p99_ms": 1200.0, + "error_pct": 0.4, + "in_flight": 3, + "load_pct": 65.0 + } + ], + "engines": [ + { + "name": "Chromium", + "status": "up", + "restarts": 4, + "uptime_seconds": 8040, + "mode": "lazy", + "mini_series": [0.3, 0.5, 0.4, 0.6, 0.2] + }, + { + "name": "LibreOffice", + "status": "up", + "restarts": 11, + "uptime_seconds": 862, + "mode": "eager", + "mini_series": [0.5, 0.7, 0.4, 0.8, 0.6] + } + ], + "concurrency": { + "active": 54, + "max": 64, + "warn_threshold": 38, + "crit_threshold": 54 + }, + "resources": { + "cpu_series": [62.0, 58.0, 70.0], + "memory_series": [1500.0, 1520.0, 1480.0], + "memory_max_mb": 4096.0 + }, + "throughput": { + "rps_series": [78.0, 82.0, 75.0], + "rps_baseline": 74.0, + "p95_series": [1.2, 1.4, 1.1], + "p95_target_s": 2.0 + }, + "batches": [ + { "id": "b_8af21c", "status": "running", "progress_pct": 62, "elapsed": "2m 14s" } + ], + "recent_requests": [ + { "time": "10:42:18", "method": "POST", "route": "/forms/chromium/convert/html", "status": 200, "duration_ms": 142 } + ], + "recent_errors": [ + { "time": "10:42:16", "route": "/forms/chromium/convert/url", "message": "upstream timeout", "request_id": "cid_94aa2" } + ] +} +``` + +Route-level p50/p95/p99 for V1: derived from the conversion_duration histogram values that are already recorded per endpoint. Expose the `_sum/_count` bucket ratio as a mean (p50 approximation) and the 0.95 quantile from the histogram's bucket data. + +### 3.5 Static file serving — `GET /_/` and `GET /_/{*path}` + +Use `rust-embed` to embed `ui/build/` at compile time into the binary. + +```rust +#[derive(RustEmbed)] +#[folder = "../../ui/build/"] +struct ConsoleAssets; +``` + +Handler: +- `GET /_/` → serve `index.html` from the embedded assets +- `GET /_/{*path}` → serve the matching file; if not found, fall back to `index.html` (SPA routing) +- Content-Type derived from file extension +- ETag from embedded file hash; respond 304 if If-None-Match matches + +**Dev mode**: If `FOLIO_CONSOLE_DEV=1`, skip rust-embed and serve from `./ui/build/` on disk instead. This allows `vite build --watch` to work without recompiling Rust. + +**Opt-out**: `FOLIO_DISABLE_CONSOLE=true` disables the `/_/` routes entirely. + +--- + +## 4. Frontend File Structure + +``` +ui/ +├── package.json +├── svelte.config.js +├── vite.config.ts +├── src/ +│ ├── app.html # base HTML shell +│ ├── routes/ +│ │ └── +page.svelte # full dashboard (single route) +│ └── lib/ +│ ├── types.ts # API response TypeScript types +│ ├── metrics.svelte.ts # $state store + polling +│ ├── theme.svelte.ts # $state for dark/accent/density +│ └── components/ +│ ├── Header.svelte +│ ├── Ticker.svelte +│ ├── RoutesTable.svelte +│ ├── side-rail/ +│ │ ├── Engines.svelte +│ │ ├── Concurrency.svelte +│ │ ├── Batches.svelte +│ │ └── Resources.svelte +│ ├── ThroughputStrip.svelte +│ ├── ActivityStrip.svelte +│ └── shared/ +│ ├── Card.svelte +│ ├── Pill.svelte +│ ├── BarChart.svelte # SVG bar chart, matches wireframe exactly +│ ├── MiniBars.svelte # small engine sparkline replacement +│ └── SlimBar.svelte # horizontal progress bar +``` + +### 4.1 `metrics.svelte.ts` + +```typescript +import { MetricsResponse } from './types'; + +// Svelte 5 runes — exported as module-level reactive state +export let data = $state(null); +export let loading = $state(true); +export let error = $state(null); +export let lastRefreshed = $state(null); + +let timer: ReturnType; + +export function startPolling(intervalMs = 5000) { + fetchOnce(); + timer = setInterval(fetchOnce, intervalMs); +} + +export function stopPolling() { + clearInterval(timer); +} + +export function manualRefresh() { + fetchOnce(); +} + +async function fetchOnce() { + try { + const res = await fetch('/_/api/metrics'); + if (!res.ok) throw new Error(`${res.status}`); + data = await res.json(); + lastRefreshed = new Date(); + error = null; + } catch (e) { + error = String(e); + } finally { + loading = false; + } +} +``` + +### 4.2 `theme.svelte.ts` + +```typescript +export let dark = $state(false); +export let accent = $state('#4f8ef7'); +export let density = $state<'compact' | 'regular' | 'comfy'>('regular'); + +// Derived theme tokens — matches wireframe's useThemeBB() +export let theme = $derived({ + bg: dark ? '#0e0f12' : '#f7f7f5', + surface: dark ? '#15171c' : '#ffffff', + surface2: dark ? '#1a1d24' : '#fbfbf9', + ink: dark ? '#e6e7ea' : '#1a1c1f', + muted: dark ? 'rgba(230,231,234,0.55)' : 'rgba(26,28,31,0.55)', + faint: dark ? 'rgba(230,231,234,0.10)' : 'rgba(26,28,31,0.06)', + rule: dark ? 'rgba(255,255,255,0.08)' : 'rgba(26,28,31,0.08)', + ok: dark ? '#3fb27f' : '#2f9967', + warn: dark ? '#e0a93c' : '#b8860b', + err: dark ? '#e26464' : '#c25151', + accent, +}); +``` + +### 4.3 `BarChart.svelte` + +Renders an SVG bar chart matching the wireframe's `BarChart` component exactly: +- Props: `data: number[]`, `width: number`, `height: number`, `color: string`, `threshold?: number`, `colorFn?: (v: number, i: number) => string`, `label?: string`, `value?: string`, `unit?: string`, `markers?: number[]` +- Label row sits **above** the SVG (not overlapping bars — this was a bug in an earlier wireframe iteration that was fixed) +- Dashed threshold line at the correct Y position +- Bars have `rx = min(1.5, barW/3)`, last bar at full opacity, others at 0.85 + +### 4.4 `+page.svelte` + +Orchestrates the full layout. Calls `startPolling()` in an `$effect` on mount, `stopPolling()` on destroy. Passes `data` and `theme` as props to every section component. Shows a skeleton loading state on first load. + +### 4.5 Tweaks panel + +A fixed bottom-right panel with three controls: +- **Theme** toggle: light / dark +- **Accent** color picker (5 swatches: blue, violet, teal, orange, rose) +- **Density** segmented control: compact / regular / comfy + +--- + +## 5. Vite / SvelteKit config + +SvelteKit with `adapter-static` is already scaffolded. Two changes needed: + +1. Add `paths: { base: '/_' }` so all asset URLs are prefixed with `/_` +2. Add `fallback: 'index.html'` to adapter options for SPA routing + +```javascript +// svelte.config.js — update kit section +kit: { + adapter: adapter({ fallback: 'index.html' }), + paths: { base: '/_' }, +} +``` + +```typescript +// vite.config.ts +import { sveltekit } from '@sveltejs/kit/vite'; +export default { plugins: [sveltekit()] }; +``` + +Build output goes to `ui/build/` (adapter-static default). The rust-embed folder path is `../../ui/build/`. + +--- + +## 6. Rust crate additions + +### `Cargo.toml` (server crate) + +```toml +[dependencies] +rust-embed = { version = "8", features = ["mime-guess"] } +mime_guess = "2" +``` + +### New files + +| File | Purpose | +|---|---| +| `crates/server/src/console_store.rs` | `ConsoleStore`, `MetricsSample`, `MetricsHistory`, `RequestLogEntry`, `ErrorLogEntry` | +| `crates/server/src/routes/console.rs` | `console_metrics_json` handler + `console_asset` static file handler | + +### Modified files + +| File | Change | +|---|---| +| `crates/server/src/state.rs` | Add `pub console: Arc` field | +| `crates/server/src/app.rs` | Mount `/_/api/metrics` and `/_/{*path}` routes; call `record_console_request` from middleware response path | +| `crates/server/src/main.rs` | Spawn background sampler task | +| `crates/server/src/lib.rs` | `pub mod console_store;` | + +--- + +## 7. Route registration + +```rust +// In build_router(): +use crate::routes::console; + +untimed = untimed + .route("/_/api/metrics", get(console::console_metrics_json)) + .route("/_/", get(console::console_asset)) + .route("/_/{*path}", get(console::console_asset)); +``` + +The `/_/api/metrics` route bypasses the timeout layer (same as `/health`) since it reads in-memory data. + +--- + +## 8. Build integration + +Add to `Makefile`: + +```makefile +.PHONY: ui-build +ui-build: ## Build the operator console UI + cd ui && npm run build + +.PHONY: ui-dev +ui-dev: ## Start UI dev server with hot reload + cd ui && npm run dev +``` + +The Docker build adds a `ui-builder` stage before the Rust builder stages: + +```dockerfile +FROM node:22-slim AS ui-builder +WORKDIR /ui +COPY ui/package*.json ./ +RUN npm ci +COPY ui/ ./ +RUN npm run build + +FROM chef AS builder-full +# ...existing... +COPY --from=ui-builder /ui/build /app/ui/build +# then cargo build sees ui/build/ for rust-embed +``` + +--- + +## 9. Constraints + +- The `/_/api/metrics` endpoint is **not** protected by BasicAuth even when the API has auth enabled. If the operator wants auth on the console, they set `FOLIO_DISABLE_CONSOLE=true` and run the UI separately. This matches Gotenberg's pattern of keeping the health/metrics surface always accessible. +- `mini_series` for engines (the small bar columns) is computed from the rolling history filtered by engine. +- Route-level p50/p95/p99 are approximated from the Prometheus histogram sum/count/buckets in V1 — not exact quantiles. +- Memory max (`memory_max_mb`) is read from `/proc/meminfo` on Linux; hardcoded to `0` on macOS (the UI will show "— GB" in that case). diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/ui/.npmrc b/ui/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/ui/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/ui/.prettierignore b/ui/.prettierignore new file mode 100644 index 0000000..7d74fe2 --- /dev/null +++ b/ui/.prettierignore @@ -0,0 +1,9 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ diff --git a/ui/.prettierrc b/ui/.prettierrc new file mode 100644 index 0000000..819fa57 --- /dev/null +++ b/ui/.prettierrc @@ -0,0 +1,16 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ], + "tailwindStylesheet": "./src/routes/layout.css" +} diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..869511e --- /dev/null +++ b/ui/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +bun x sv@0.15.2 create --template minimal --types ts --add prettier eslint tailwindcss="plugins:typography,forms" sveltekit-adapter="adapter:static" --install bun ui +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/ui/bun.lock b/ui/bun.lock new file mode 100644 index 0000000..de7851e --- /dev/null +++ b/ui/bun.lock @@ -0,0 +1,501 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "ui", + "devDependencies": { + "@eslint/compat": "^2.0.4", + "@eslint/js": "^10.0.1", + "@fontsource-variable/geist": "^5.2.8", + "@lucide/svelte": "^1.14.0", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^20", + "clsx": "^2.1.1", + "eslint": "^10.2.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.17.0", + "globals": "^17.4.0", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.5.1", + "prettier-plugin-tailwindcss": "^0.7.2", + "shadcn-svelte": "^1.2.7", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwind-merge": "^3.5.0", + "tailwind-variants": "^3.2.2", + "tailwindcss": "^4.2.2", + "tw-animate-css": "^1.4.0", + "typescript": "^6.0.2", + "typescript-eslint": "^8.58.1", + "vite": "^8.0.7", + }, + }, + }, + "packages": { + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/compat": ["@eslint/compat@2.0.5", "", { "dependencies": { "@eslint/core": "^1.2.1" }, "peerDependencies": { "eslint": "^8.40 || 9 || 10" }, "optionalPeers": ["eslint"] }, "sha512-IbHDbHJfkVNv6xjlET8AIVo/K1NQt7YT4Rp6ok/clyBGcpRx1l6gv0Rq3vBvYfPJIZt6ODf66Zq08FJNDpnzgg=="], + + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="], + + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], + + "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], + + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], + + "@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="], + + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@lucide/svelte": ["@lucide/svelte@1.14.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-MVuP5VRCBQa2OaIpaRbuEV4k5OV2dy9MyxA6Tf4Sz/IN0v3zzUU72ObHc9r2Zn/wSMXDg6lLrHrczqI7w7gCzQ=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], + + "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.17", "", { "os": "android", "cpu": "arm64" }, "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm" }, "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "s390x" }, "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.17", "", { "os": "none", "cpu": "arm64" }, "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.17", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "x64" }, "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="], + + "@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="], + + "@sveltejs/kit": ["@sveltejs/kit@2.58.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-kT9GCN8yJTkCK1W+Gi/bvGooWAM7y7WXP+yd+rf6QOIjyoK1ERPrMwSufXJUNu2pMWIqruhFvmz+LbOqsEmKmA=="], + + "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.0.0", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g=="], + + "@tailwindcss/forms": ["@tailwindcss/forms@0.5.11", "", { "dependencies": { "mini-svg-data-uri": "^1.2.3" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" } }, "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.4", "", { "os": "android", "cpu": "arm64" }, "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.4", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="], + + "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.4", "", { "dependencies": { "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "tailwindcss": "4.2.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@20.19.39", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/type-utils": "8.59.1", "@typescript-eslint/utils": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.1", "@typescript-eslint/types": "^8.59.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.1", "", { "dependencies": { "@typescript-eslint/types": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1" } }, "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.1", "", { "dependencies": { "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/utils": "8.59.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.59.1", "", {}, "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.1", "@typescript-eslint/tsconfig-utils": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/visitor-keys": "8.59.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.1", "", { "dependencies": { "@typescript-eslint/types": "8.59.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devalue": ["devalue@5.8.0", "", {}, "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg=="], + + "enhanced-resolve": ["enhanced-resolve@5.21.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@10.2.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="], + + "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], + + "eslint-plugin-svelte": ["eslint-plugin-svelte@3.17.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", "globals": "^16.0.0", "known-css-properties": "^0.37.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", "svelte-eslint-parser": "^1.4.0" }, "peerDependencies": { "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-NyiXHtS3Ni7e532RBwS9OXlMKDIrENg3gY+/+ODjZzQx2xhU3NlJ+nIl1a93iUUQeiJL3lS8KLmY+W8hklzweQ=="], + + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], + + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrap": ["esrap@2.2.5", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@17.5.0", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], + + "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mini-svg-data-uri": ["mini-svg-data-uri@1.4.4", "", { "bin": { "mini-svg-data-uri": "cli.js" } }, "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "postcss": ["postcss@8.5.13", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag=="], + + "postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="], + + "postcss-safe-parser": ["postcss-safe-parser@7.0.1", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A=="], + + "postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + + "prettier-plugin-svelte": ["prettier-plugin-svelte@3.5.1", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg=="], + + "prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.4", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-UKii4RjY05SNt/WQi6/NcOn/LsT0/ILLXsxygjbRg5/YZelsSu5jTqorYHPDGq4nZy5q5hpCu+XdGZ1xaJEQgw=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "rolldown": ["rolldown@1.0.0-rc.17", "", { "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="], + + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], + + "shadcn-svelte": ["shadcn-svelte@1.2.7", "", { "dependencies": { "commander": "^14.0.0", "node-fetch-native": "^1.6.4", "postcss": "^8.5.5", "tailwind-merge": "^3.0.0" }, "peerDependencies": { "svelte": "^5.0.0" }, "bin": { "shadcn-svelte": "dist/index.mjs" } }, "sha512-mWuQk4H4gtV+J2wJQ7nEPKNnB/v86AALFryZU0SSM7ChHmJJMZ1kH+qIuxYKrXm9vOOOcSWHRsWzPDB71DnjYA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "svelte": ["svelte@5.55.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw=="], + + "svelte-check": ["svelte-check@4.4.7", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-JRafFTRmaPUOqmri4u1WuIKgBLiHi6wIaB57i99pmHq5BAc3ioIpzdUN/RX32ij9GhI6ALMHKvnVxu68sFZlag=="], + + "svelte-eslint-parser": ["svelte-eslint-parser@1.6.0", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0", "semver": "^7.7.2" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-qoB1ehychT6OxEtQAqc/guSqLS20SlA53Uijl7x375s8nlUT0lb9ol/gzraEEatQwsyPTJo87s2CmKL9Xab+Uw=="], + + "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], + + "tailwind-variants": ["tailwind-variants@3.2.2", "", { "peerDependencies": { "tailwind-merge": ">=3.0.0", "tailwindcss": "*" }, "optionalPeers": ["tailwind-merge"] }, "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg=="], + + "tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "typescript-eslint": ["typescript-eslint@8.59.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.1", "@typescript-eslint/parser": "8.59.1", "@typescript-eslint/typescript-estree": "8.59.1", "@typescript-eslint/utils": "8.59.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "vite": ["vite@8.0.10", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], + + "svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "svelte-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "svelte-eslint-parser/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "svelte-eslint-parser/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], + } +} diff --git a/ui/components.json b/ui/components.json new file mode 100644 index 0000000..eaea37d --- /dev/null +++ b/ui/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "tailwind": { + "css": "src/routes/layout.css", + "baseColor": "neutral" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry", + "style": "nova", + "iconLibrary": "lucide", + "menuColor": "default", + "menuAccent": "subtle" +} diff --git a/ui/eslint.config.js b/ui/eslint.config.js new file mode 100644 index 0000000..0014edd --- /dev/null +++ b/ui/eslint.config.js @@ -0,0 +1,44 @@ +import prettier from 'eslint-config-prettier'; +import path from 'node:path'; +import { includeIgnoreFile } from '@eslint/compat'; +import js from '@eslint/js'; +import svelte from 'eslint-plugin-svelte'; +import { defineConfig } from 'eslint/config'; +import globals from 'globals'; +import ts from 'typescript-eslint'; +import svelteConfig from './svelte.config.js'; + +const gitignorePath = path.resolve(import.meta.dirname, '.gitignore'); + +export default defineConfig( + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ts.configs.recommended, + svelte.configs.recommended, + prettier, + svelte.configs.prettier, + { + languageOptions: { globals: { ...globals.browser, ...globals.node } }, + rules: { + // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. + // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors + 'no-undef': 'off' + } + }, + { + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: ['.svelte'], + parser: ts.parser, + svelteConfig + } + } + }, + { + // Override or add rule settings here, such as: + // 'svelte/button-has-type': 'error' + rules: {} + } +); diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..625b360 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,47 @@ +{ + "name": "ui", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --check . && eslint .", + "format": "prettier --write ." + }, + "devDependencies": { + "@eslint/compat": "^2.0.4", + "@eslint/js": "^10.0.1", + "@fontsource-variable/geist": "^5.2.8", + "@lucide/svelte": "^1.14.0", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^20", + "clsx": "^2.1.1", + "eslint": "^10.2.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.17.0", + "globals": "^17.4.0", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.5.1", + "prettier-plugin-tailwindcss": "^0.7.2", + "shadcn-svelte": "^1.2.7", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwind-merge": "^3.5.0", + "tailwind-variants": "^3.2.2", + "tailwindcss": "^4.2.2", + "tw-animate-css": "^1.4.0", + "typescript": "^6.0.2", + "typescript-eslint": "^8.58.1", + "vite": "^8.0.7" + } +} diff --git a/ui/src/app.d.ts b/ui/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/ui/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/ui/src/app.html b/ui/src/app.html new file mode 100644 index 0000000..6a2bb58 --- /dev/null +++ b/ui/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/ui/src/lib/assets/favicon.svg b/ui/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/ui/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/ui/src/lib/index.ts b/ui/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/ui/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts new file mode 100644 index 0000000..55b3a91 --- /dev/null +++ b/ui/src/lib/utils.ts @@ -0,0 +1,13 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChild = T extends { child?: any } ? Omit : T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildrenOrChild = WithoutChildren>; +export type WithElementRef = T & { ref?: U | null }; diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte new file mode 100644 index 0000000..0d8eb03 --- /dev/null +++ b/ui/src/routes/+layout.svelte @@ -0,0 +1,9 @@ + + + +{@render children()} diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte new file mode 100644 index 0000000..cc88df0 --- /dev/null +++ b/ui/src/routes/+page.svelte @@ -0,0 +1,2 @@ +

Welcome to SvelteKit

+

Visit svelte.dev/docs/kit to read the documentation

diff --git a/ui/src/routes/layout.css b/ui/src/routes/layout.css new file mode 100644 index 0000000..f623209 --- /dev/null +++ b/ui/src/routes/layout.css @@ -0,0 +1,131 @@ +@import 'tailwindcss'; +@import "tw-animate-css"; +@import "shadcn-svelte/tailwind.css"; +@import "@fontsource-variable/geist"; + +@custom-variant dark (&:is(.dark *)); +@plugin '@tailwindcss/forms'; +@plugin '@tailwindcss/typography'; + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@theme inline { + --font-sans: 'Geist Variable', sans-serif; + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} diff --git a/ui/static/robots.txt b/ui/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/ui/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/ui/svelte.config.js b/ui/svelte.config.js new file mode 100644 index 0000000..5bbc157 --- /dev/null +++ b/ui/svelte.config.js @@ -0,0 +1,12 @@ +import adapter from '@sveltejs/adapter-static'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + compilerOptions: { + // Force runes mode for the project, except for libraries. Can be removed in svelte 6. + runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true) + }, + kit: { adapter: adapter() } +}; + +export default config; diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..56f40c7 --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,5 @@ +import tailwindcss from '@tailwindcss/vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ plugins: [tailwindcss(), sveltekit()] }); From aeef5b227178692d4e9b3b63eb928bcb253ce4fa Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 13:07:08 +0530 Subject: [PATCH 02/93] =?UTF-8?q?docs:=20update=20operator=20console=20spe?= =?UTF-8?q?c=20=E2=80=94=20SSE=20transport=20+=20shadcn=20charts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace frontend polling with Server-Sent Events: - Rust: broadcast channel in ConsoleStore, console_stream SSE handler, immediate snapshot on connect, keep-alive ping every 15s - Frontend: EventSource in metrics.svelte.ts, auto-reconnect, connected state Replace custom SVG BarChart with shadcn-svelte chart primitives: - npx shadcn-svelte add chart installs ChartContainer + layerchart bars - Remap --chart-1..5 CSS vars to semantic ok/warn/err/accent colors - Remove BarChart.svelte and MiniBars.svelte from component list Also: note EventSource cannot send auth headers → /_/api/stream is intentionally unauthenticated; operators use FOLIO_DISABLE_CONSOLE=true + reverse proxy if auth is required. Co-Authored-By: Claude Sonnet 4.6 --- ...026-05-01-folio-operator-console-design.md | 182 ++++++++++++------ 1 file changed, 126 insertions(+), 56 deletions(-) diff --git a/docs/superpowers/specs/2026-05-01-folio-operator-console-design.md b/docs/superpowers/specs/2026-05-01-folio-operator-console-design.md index 80cf428..a03c379 100644 --- a/docs/superpowers/specs/2026-05-01-folio-operator-console-design.md +++ b/docs/superpowers/specs/2026-05-01-folio-operator-console-design.md @@ -4,7 +4,7 @@ **Goal:** Build a real-time operations dashboard served at `/_/` inside the Folio binary — Variation B · Bars Edition from the approved wireframe. -**Architecture:** Svelte 5 SPA embedded in the Folio binary via `rust-embed`. The Rust server adds a rolling metrics history store, request/error ring buffers, a `GET /_/api/metrics` JSON endpoint, and static file serving for `/_/`. The frontend polls every 5 seconds using Svelte 5 runes. +**Architecture:** Svelte 5 SPA embedded in the Folio binary via `rust-embed`. The Rust server adds a rolling metrics history store, request/error ring buffers, a `GET /_/api/stream` SSE endpoint that pushes a full snapshot every 5 seconds, a one-shot `GET /_/api/metrics` for initial load, and static file serving for `/_/`. The frontend subscribes via `EventSource` using Svelte 5 runes — no polling. **Tech Stack:** Rust (Axum, rust-embed, tokio), Svelte 5 (runes forced), Tailwind CSS v4, shadcn-svelte, Lucide icons, Geist font, Vite, TypeScript. @@ -114,9 +114,10 @@ pub struct ErrorLogEntry { /// Shared console state — Arc-wrapped in AppState. pub struct ConsoleStore { - pub history: Mutex, // rolling samples - pub request_log: Mutex>, // cap 100 - pub error_log: Mutex>, // cap 100 + pub history: Mutex, // rolling samples + pub request_log: Mutex>, // cap 100 + pub error_log: Mutex>, // cap 100 + pub broadcast: tokio::sync::broadcast::Sender, // SSE fan-out (JSON payload) } ``` @@ -124,19 +125,50 @@ pub struct ConsoleStore { ### 3.2 Background sampler task -Spawned in `main.rs` at startup. Every 30 seconds: -1. Read gauge values from `METRICS` (queue_size, concurrency, RPS from http counters delta, etc.) -2. Push a `MetricsSample` into `ConsoleStore::history`, evicting the oldest if at cap +Spawned in `main.rs` at startup. Every **5 seconds**: +1. Read gauge values from `METRICS` (queue_size, concurrency, RPS delta, etc.) +2. Push a `MetricsSample` into `ConsoleStore::history`, evicting the oldest if at cap (cap = 360 samples = 30 min at 5s cadence) +3. Build the full `ConsolePayload` JSON string +4. Broadcast via `ConsoleStore::broadcast.send(payload)` — all active SSE subscribers receive it immediately -RPS is computed as `(http_requests_total_now - http_requests_total_prev) / 30.0`. +RPS is computed as `(http_requests_total_now - http_requests_total_prev) / 5.0`. -p95 is not directly available from a Prometheus counter without the histogram. For V1, expose the raw p95 histogram bucket as a derived estimate, or expose the last observed p95 from a tracked moving value. **Simplest V1**: keep a `Mutex` in AppState that handlers update with each conversion duration, use the rolling 30s max as the p95 approximation. This is documented as "p95 (approx, rolling 30s max)". +p95 is approximated: keep a `Mutex` in `ConsoleStore` updated by handlers with each observed duration. The sampler reads the current value as the rolling p95 approximation. This is labelled "p95 (approx)" in the UI. ### 3.3 Request/error log middleware The existing `record_http_request` call site in `app.rs` already fires per request. Add a new `record_console_request(state, method, route, status, duration_ms)` call alongside the existing metrics call. This pushes to `ConsoleStore::request_log`. If status >= 500, also push to `error_log`. -### 3.4 `GET /_/api/metrics` — JSON response shape +### 3.4 `GET /_/api/stream` (SSE) and `GET /_/api/metrics` (one-shot JSON) + +**`GET /_/api/stream`** — Server-Sent Events endpoint. Returns `Content-Type: text/event-stream`. + +```rust +async fn console_stream(State(state): State) -> Sse>> { + let mut rx = state.console.broadcast.subscribe(); + let stream = async_stream::stream! { + // Send current snapshot immediately on connect (no waiting for next tick) + let snapshot = build_console_payload(&state); + yield Ok(Event::default().data(serde_json::to_string(&snapshot).unwrap())); + loop { + match rx.recv().await { + Ok(payload) => yield Ok(Event::default().data(payload)), + Err(RecvError::Lagged(_)) => continue, // skip missed ticks, keep going + Err(RecvError::Closed) => break, + } + } + }; + Sse::new(stream).keep_alive( + KeepAlive::new().interval(Duration::from_secs(15)).text("ping") + ) +} +``` + +The broadcast channel is created with capacity 4 (buffer 4 missed ticks before dropping slow subscribers). `EventSource` auto-reconnects on drop; the new connection immediately receives a fresh snapshot. + +**`GET /_/api/metrics`** — One-shot JSON snapshot. Same payload as SSE events. Used for debugging and curl inspection. + +### 3.5 `ConsolePayload` — shared JSON shape (SSE event `data:` + one-shot JSON response) ```json { @@ -219,7 +251,7 @@ The existing `record_http_request` call site in `app.rs` already fires per reque Route-level p50/p95/p99 for V1: derived from the conversion_duration histogram values that are already recorded per endpoint. Expose the `_sum/_count` bucket ratio as a mean (p50 approximation) and the 0.95 quantile from the histogram's bucket data. -### 3.5 Static file serving — `GET /_/` and `GET /_/{*path}` +### 3.6 Static file serving — `GET /_/` and `GET /_/{*path}` Use `rust-embed` to embed `ui/build/` at compile time into the binary. @@ -253,8 +285,8 @@ ui/ │ ├── routes/ │ │ └── +page.svelte # full dashboard (single route) │ └── lib/ -│ ├── types.ts # API response TypeScript types -│ ├── metrics.svelte.ts # $state store + polling +│ ├── types.ts # ConsolePayload TypeScript types (mirrors Rust JSON) +│ ├── metrics.svelte.ts # $state store + SSE subscription │ ├── theme.svelte.ts # $state for dark/accent/density │ └── components/ │ ├── Header.svelte @@ -268,51 +300,65 @@ ui/ │ ├── ThroughputStrip.svelte │ ├── ActivityStrip.svelte │ └── shared/ -│ ├── Card.svelte -│ ├── Pill.svelte -│ ├── BarChart.svelte # SVG bar chart, matches wireframe exactly -│ ├── MiniBars.svelte # small engine sparkline replacement -│ └── SlimBar.svelte # horizontal progress bar +│ ├── Card.svelte # thin wrapper — border + radius + optional header +│ ├── Pill.svelte # status badge (ok/warn/err/accent tones) +│ └── SlimBar.svelte # horizontal load bar (route table + batches) ``` ### 4.1 `metrics.svelte.ts` +SSE-based — no polling. `EventSource` auto-reconnects on drop; on reconnect the server sends a fresh snapshot immediately (see §3.4). + ```typescript -import { MetricsResponse } from './types'; +import type { ConsolePayload } from './types'; -// Svelte 5 runes — exported as module-level reactive state -export let data = $state(null); +// Svelte 5 runes — module-level reactive state +export let data = $state(null); export let loading = $state(true); +export let connected = $state(false); export let error = $state(null); export let lastRefreshed = $state(null); -let timer: ReturnType; +let es: EventSource | null = null; -export function startPolling(intervalMs = 5000) { - fetchOnce(); - timer = setInterval(fetchOnce, intervalMs); -} +export function startSSE() { + if (es) return; + es = new EventSource('/_/api/stream'); -export function stopPolling() { - clearInterval(timer); + es.onopen = () => { + connected = true; + error = null; + }; + + es.onmessage = (event: MessageEvent) => { + try { + data = JSON.parse(event.data) as ConsolePayload; + lastRefreshed = new Date(); + error = null; + } catch { + error = 'Failed to parse server data'; + } finally { + loading = false; + } + }; + + es.onerror = () => { + connected = false; + error = 'Connection lost — reconnecting…'; + // EventSource reconnects automatically; no manual retry needed + }; } -export function manualRefresh() { - fetchOnce(); +export function stopSSE() { + es?.close(); + es = null; + connected = false; } -async function fetchOnce() { - try { - const res = await fetch('/_/api/metrics'); - if (!res.ok) throw new Error(`${res.status}`); - data = await res.json(); - lastRefreshed = new Date(); - error = null; - } catch (e) { - error = String(e); - } finally { - loading = false; - } +export function manualRefresh() { + // Reconnect triggers an immediate snapshot from the server + stopSSE(); + startSSE(); } ``` @@ -339,17 +385,40 @@ export let theme = $derived({ }); ``` -### 4.3 `BarChart.svelte` +### 4.3 Charts — shadcn-svelte `chart` component + +Do **not** build custom SVG bar charts. Use shadcn-svelte's `chart` primitives throughout. + +**Setup**: `npx shadcn-svelte@latest add chart` installs `ChartContainer`, `ChartTooltip`, and the layerchart-based bar chart components into `$lib/components/ui/chart/`. + +**Chart CSS variable remapping** — update `layout.css` to replace the neutral gray chart vars with semantic colors matching the wireframe: + +```css +:root { + --chart-1: #4f8ef7; /* accent */ + --chart-2: #2f9967; /* ok */ + --chart-3: #b8860b; /* warn */ + --chart-4: #c25151; /* err */ + --chart-5: rgba(26,28,31,0.4); /* muted */ +} +.dark { + --chart-1: #6aa3f8; + --chart-2: #3fb27f; + --chart-3: #e0a93c; + --chart-4: #e26464; + --chart-5: rgba(230,231,234,0.4); +} +``` -Renders an SVG bar chart matching the wireframe's `BarChart` component exactly: -- Props: `data: number[]`, `width: number`, `height: number`, `color: string`, `threshold?: number`, `colorFn?: (v: number, i: number) => string`, `label?: string`, `value?: string`, `unit?: string`, `markers?: number[]` -- Label row sits **above** the SVG (not overlapping bars — this was a bug in an earlier wireframe iteration that was fixed) -- Dashed threshold line at the correct Y position -- Bars have `rx = min(1.5, barW/3)`, last bar at full opacity, others at 0.85 +**Usage pattern** in section components: +- `Resources.svelte` — two stacked `BarChart`s (CPU, Memory), `chartConfig` maps `value` → `--chart-1` with threshold-based color override +- `ThroughputStrip.svelte` — two side-by-side `BarChart`s (RPS, p95), threshold line via a `ReferenceLine` +- `Engines.svelte` — `MiniBars` replaced by a compact `BarChart` with `--chart-2` (ok) or `--chart-3` (warn) based on restart count +- All charts share `ChartTooltip` with `cursor={false}` (no hover cursor line — matches the wireframe's clean look) ### 4.4 `+page.svelte` -Orchestrates the full layout. Calls `startPolling()` in an `$effect` on mount, `stopPolling()` on destroy. Passes `data` and `theme` as props to every section component. Shows a skeleton loading state on first load. +Orchestrates the full layout. Calls `startSSE()` in an `$effect` on mount, `stopSSE()` on destroy. Passes `data` and `theme` as props to every section component. Shows a skeleton loading state (`loading === true`) until the first SSE message arrives. ### 4.5 Tweaks panel @@ -400,14 +469,14 @@ mime_guess = "2" | File | Purpose | |---|---| | `crates/server/src/console_store.rs` | `ConsoleStore`, `MetricsSample`, `MetricsHistory`, `RequestLogEntry`, `ErrorLogEntry` | -| `crates/server/src/routes/console.rs` | `console_metrics_json` handler + `console_asset` static file handler | +| `crates/server/src/routes/console.rs` | `console_stream` (SSE), `console_metrics_json` (one-shot), `console_asset` (static files) | ### Modified files | File | Change | |---|---| | `crates/server/src/state.rs` | Add `pub console: Arc` field | -| `crates/server/src/app.rs` | Mount `/_/api/metrics` and `/_/{*path}` routes; call `record_console_request` from middleware response path | +| `crates/server/src/app.rs` | Mount `/_/api/stream`, `/_/api/metrics`, and `/_/{*path}` routes; call `record_console_request` alongside `record_http_request` | | `crates/server/src/main.rs` | Spawn background sampler task | | `crates/server/src/lib.rs` | `pub mod console_store;` | @@ -420,12 +489,13 @@ mime_guess = "2" use crate::routes::console; untimed = untimed - .route("/_/api/metrics", get(console::console_metrics_json)) - .route("/_/", get(console::console_asset)) - .route("/_/{*path}", get(console::console_asset)); + .route("/_/api/stream", get(console::console_stream)) // SSE — long-lived + .route("/_/api/metrics", get(console::console_metrics_json)) // one-shot JSON + .route("/_/", get(console::console_asset)) + .route("/_/{*path}", get(console::console_asset)); ``` -The `/_/api/metrics` route bypasses the timeout layer (same as `/health`) since it reads in-memory data. +Both `/_/api/stream` and `/_/api/metrics` live in `untimed` — they bypass the request timeout middleware. The SSE handler must never be cancelled by the timeout layer. --- @@ -463,7 +533,7 @@ COPY --from=ui-builder /ui/build /app/ui/build ## 9. Constraints -- The `/_/api/metrics` endpoint is **not** protected by BasicAuth even when the API has auth enabled. If the operator wants auth on the console, they set `FOLIO_DISABLE_CONSOLE=true` and run the UI separately. This matches Gotenberg's pattern of keeping the health/metrics surface always accessible. +- The `/_/api/stream` and `/_/api/metrics` endpoints are **not** protected by BasicAuth even when the API has auth enabled. `EventSource` in the browser cannot set Authorization headers, so SSE endpoints must be unauthenticated at the HTTP level. If auth is needed, operators should set `FOLIO_DISABLE_CONSOLE=true` and run the UI behind a reverse proxy instead. - `mini_series` for engines (the small bar columns) is computed from the rolling history filtered by engine. - Route-level p50/p95/p99 are approximated from the Prometheus histogram sum/count/buckets in V1 — not exact quantiles. - Memory max (`memory_max_mb`) is read from `/proc/meminfo` on Linux; hardcoded to `0` on macOS (the UI will show "— GB" in that case). From 6271a6c838220c32f9829b69faffe55eb95c7a6a Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 13:17:10 +0530 Subject: [PATCH 03/93] docs: add operator console implementation plan 16-task plan covering Rust backend (ConsoleStore, SSE handler, static asset serving) and Svelte frontend (metrics store, all dashboard strips) for the Folio Operator Console. Co-Authored-By: Claude Sonnet 4.6 --- .../2026-05-01-folio-operator-console.md | 2276 +++++++++++++++++ 1 file changed, 2276 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-01-folio-operator-console.md diff --git a/docs/superpowers/plans/2026-05-01-folio-operator-console.md b/docs/superpowers/plans/2026-05-01-folio-operator-console.md new file mode 100644 index 0000000..b7b4d93 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-folio-operator-console.md @@ -0,0 +1,2276 @@ +# Folio Operator Console Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the Folio Operator Console — a real-time operations dashboard served at `/_/` inside the Folio binary, implementing the B · Bars wireframe with SSE data transport. + +**Architecture:** Two phases. Phase 1 (Tasks 1–7): Rust server additions — `ConsoleStore` ring buffers, SSE broadcast, `/_/api/stream` + `/_/api/metrics` endpoints, request log middleware, and `rust-embed` static file serving. Phase 2 (Tasks 8–15): Svelte 5 SPA implementing the wireframe layout using shadcn-svelte chart components, connected to the SSE endpoint. Phase 3 (Task 16): build integration (Makefile + Docker). + +**Tech Stack:** Rust 1.88 (Axum 0.8, tokio, futures-util, rust-embed 8, serde_json), Svelte 5 (runes), SvelteKit, Tailwind CSS v4, shadcn-svelte, TypeScript. + +**Spec:** `docs/superpowers/specs/2026-05-01-folio-operator-console-design.md` + +--- + +## File Map + +### Created +| File | Purpose | +|---|---| +| `crates/server/src/console_store.rs` | `ConsoleStore`, ring buffers, SSE broadcast channel | +| `crates/server/src/routes/console.rs` | SSE handler, one-shot JSON handler, static asset handler | +| `ui/build/.gitkeep` | Placeholder so rust-embed compiles before UI is built | +| `ui/src/lib/types.ts` | `ConsolePayload` TypeScript types | +| `ui/src/lib/metrics.svelte.ts` | `$state` store + `EventSource` subscription | +| `ui/src/lib/theme.svelte.ts` | `$state` for dark/accent/density + `$derived` theme tokens | +| `ui/src/lib/components/shared/Card.svelte` | Card wrapper | +| `ui/src/lib/components/shared/Pill.svelte` | Status badge | +| `ui/src/lib/components/shared/SlimBar.svelte` | Horizontal progress bar | +| `ui/src/lib/components/Header.svelte` | Top bar | +| `ui/src/lib/components/Ticker.svelte` | 8-KPI strip | +| `ui/src/lib/components/RoutesTable.svelte` | Route ladder table | +| `ui/src/lib/components/side-rail/Engines.svelte` | Engine health + mini bars | +| `ui/src/lib/components/side-rail/Concurrency.svelte` | 64-slot semaphore grid | +| `ui/src/lib/components/side-rail/Batches.svelte` | Batch job list | +| `ui/src/lib/components/side-rail/Resources.svelte` | CPU + Memory bar charts | +| `ui/src/lib/components/ThroughputStrip.svelte` | RPS + p95 bar charts | +| `ui/src/lib/components/ActivityStrip.svelte` | Request + error logs | + +### Modified +| File | Change | +|---|---| +| `crates/server/src/lib.rs` | `pub mod console_store;` | +| `crates/server/src/state.rs` | `pub console: Arc` field | +| `crates/server/src/main.rs` | Spawn sampler task, create ConsoleStore | +| `crates/server/src/app.rs` | Mount console routes, add request log middleware | +| `crates/server/src/routes/mod.rs` | `pub mod console;` | +| `crates/server/src/supervised_engine.rs` | Add `pub fn is_running()` to both engines | +| `crates/server/Cargo.toml` | Add `rust-embed`, `mime_guess` | +| `ui/svelte.config.js` | Add `paths.base` + `fallback` | +| `ui/src/routes/layout.css` | Remap `--chart-1..5` to semantic colors | +| `ui/src/routes/+page.svelte` | Full dashboard layout | +| `Makefile` | `ui-build`, `ui-dev` targets | +| `Dockerfile` | Add `ui-builder` stage, copy `ui/build/` into Rust builder | + +--- + +## Phase 1 — Rust Backend + +--- + +### Task 1: `ConsoleStore` — ring buffers + broadcast channel + +**Files:** +- Create: `crates/server/src/console_store.rs` +- Modify: `crates/server/src/lib.rs` +- Modify: `crates/server/src/state.rs` + +- [ ] **Step 1: Create `console_store.rs`** + +```rust +// crates/server/src/console_store.rs +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use serde::{Deserialize, Serialize}; +use tokio::sync::{Mutex, broadcast}; + +pub const HISTORY_CAP: usize = 360; // 30 min at 5s cadence +pub const LOG_CAP: usize = 100; +pub const BROADCAST_CAP: usize = 4; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MetricsSample { + pub ts: u64, + pub rps: f64, + pub p95_ms: f64, + pub error_pct: f64, + pub queue_size: u32, + pub concurrency_active: u32, + pub cpu_pct: f64, + pub memory_mb: f64, +} + +#[derive(Debug, Default)] +pub struct MetricsHistory { + pub samples: VecDeque, +} + +impl MetricsHistory { + pub fn push(&mut self, sample: MetricsSample) { + if self.samples.len() >= HISTORY_CAP { + self.samples.pop_front(); + } + self.samples.push_back(sample); + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RequestLogEntry { + pub time: String, + pub method: String, + pub route: String, + pub status: u16, + pub duration_ms: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ErrorLogEntry { + pub time: String, + pub route: String, + pub message: String, + pub request_id: String, +} + +pub struct ConsoleStore { + pub history: Mutex, + pub request_log: Mutex>, + pub error_log: Mutex>, + pub broadcast: broadcast::Sender, + // Restart tracking (sampler detects false→true transition on is_running) + pub chromium_restarts: AtomicU32, + pub chromium_was_running: AtomicBool, + pub libreoffice_restarts: AtomicU32, + pub libreoffice_was_running: AtomicBool, + // Rolling p95 approximation updated by request log middleware + pub last_p95_ms: Mutex, + // RPS delta tracking + pub prev_http_total: Mutex, +} + +impl ConsoleStore { + pub fn new() -> Self { + let (tx, _) = broadcast::channel(BROADCAST_CAP); + Self { + history: Mutex::new(MetricsHistory::default()), + request_log: Mutex::new(VecDeque::new()), + error_log: Mutex::new(VecDeque::new()), + broadcast: tx, + chromium_restarts: AtomicU32::new(0), + chromium_was_running: AtomicBool::new(false), + libreoffice_restarts: AtomicU32::new(0), + libreoffice_was_running: AtomicBool::new(false), + last_p95_ms: Mutex::new(0.0), + prev_http_total: Mutex::new(0.0), + } + } + + pub async fn record_request(&self, method: String, route: String, status: u16, duration_ms: u64) { + use chrono::Local; + let time = Local::now().format("%H:%M:%S").to_string(); + + { + let mut log = self.request_log.lock().await; + if log.len() >= LOG_CAP { log.pop_front(); } + log.push_back(RequestLogEntry { time: time.clone(), method: method.clone(), route: route.clone(), status, duration_ms }); + } + + // Update p95 approximation (rolling max over recent requests) + { + let mut p95 = self.last_p95_ms.lock().await; + if duration_ms as f64 > *p95 { + *p95 = duration_ms as f64; + } + } + + if status >= 500 { + let mut log = self.error_log.lock().await; + if log.len() >= LOG_CAP { log.pop_front(); } + log.push_back(ErrorLogEntry { + time, + route, + message: format!("HTTP {status}"), + request_id: String::new(), + }); + } + } +} +``` + +Note: `chrono` is not yet in the server crate. Use `std::time::SystemTime` instead: + +```rust +// Replace the chrono import with this helper at top of record_request +use std::time::{SystemTime, UNIX_EPOCH}; +let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); +let h = (secs % 86400) / 3600; +let m = (secs % 3600) / 60; +let s = secs % 60; +let time = format!("{h:02}:{m:02}:{s:02}"); +``` + +- [ ] **Step 2: Add `pub mod console_store;` to `lib.rs`** + +In `crates/server/src/lib.rs`, add after `pub mod batch_worker;`: +```rust +pub mod console_store; +``` + +- [ ] **Step 3: Add `console` field to `AppState`** + +In `crates/server/src/state.rs`: + +```rust +// Add import at top +use std::sync::Arc; +use crate::console_store::ConsoleStore; + +// Add field to AppState struct +pub struct AppState { + // ...existing fields... + /// Shared operator console state (ring buffers + SSE broadcast). + pub console: Arc, +} + +// In AppState::new(), add to the Self { } literal: +console: Arc::new(ConsoleStore::new()), +``` + +- [ ] **Step 4: Build to confirm it compiles** + +```bash +cargo build -p server 2>&1 | head -30 +``` +Expected: compiles (may have unused warnings, that's fine) + +- [ ] **Step 5: Commit** + +```bash +git add crates/server/src/console_store.rs crates/server/src/lib.rs crates/server/src/state.rs +git commit -m "feat(console): add ConsoleStore with ring buffers and SSE broadcast channel" +``` + +--- + +### Task 2: Expose `is_running()` on supervised engines + +**Files:** +- Modify: `crates/server/src/supervised_engine.rs` + +The ConsoleStore sampler needs to detect when an engine restarts (false→true transition on `is_running`). The `is_running` field is private — expose it via a public method on both engine wrappers. + +- [ ] **Step 1: Add `pub fn is_running()` to `SupervisedChromiumEngine`** + +Find the `impl SupervisedChromiumEngine` block (around line 39). Add after the existing public methods: + +```rust +/// Returns true if the Chromium engine is currently running. +pub fn is_running(&self) -> bool { + self.inner.is_running.load(std::sync::atomic::Ordering::SeqCst) +} +``` + +- [ ] **Step 2: Add `pub fn is_running()` to `SupervisedLibreOfficeEngine`** + +Find `impl SupervisedLibreOfficeEngine` (around line 228). Add the identical method: + +```rust +/// Returns true if the LibreOffice engine is currently running. +pub fn is_running(&self) -> bool { + self.inner.is_running.load(std::sync::atomic::Ordering::SeqCst) +} +``` + +- [ ] **Step 3: Build to confirm** + +```bash +cargo build -p server 2>&1 | head -20 +``` +Expected: compiles cleanly. + +- [ ] **Step 4: Commit** + +```bash +git add crates/server/src/supervised_engine.rs +git commit -m "feat(console): expose is_running() on supervised engines for console sampler" +``` + +--- + +### Task 3: Background sampler task + `ConsolePayload` builder + +**Files:** +- Modify: `crates/server/src/main.rs` +- Modify: `crates/server/src/console_store.rs` + +The sampler runs every 5 seconds, builds the full `ConsolePayload` JSON, pushes a `MetricsSample` to history, and broadcasts the payload to all SSE subscribers. + +- [ ] **Step 1: Add `ConsolePayload` + `build_console_payload()` to `console_store.rs`** + +Append to `crates/server/src/console_store.rs`: + +```rust +// ── ConsolePayload (the JSON shape sent to the frontend) ────────────────── + +#[derive(Clone, Debug, Serialize)] +pub struct ConsolePayload { + pub version: String, + pub uptime_seconds: u64, + pub ticker: TickerPayload, + pub routes: Vec, + pub engines: Vec, + pub concurrency: ConcurrencyPayload, + pub resources: ResourcesPayload, + pub throughput: ThroughputPayload, + pub batches: Vec, + pub recent_requests: Vec, + pub recent_errors: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct TickerPayload { + pub rps: f64, + pub p95_ms: f64, + pub error_pct: f64, + pub concurrency_active: u32, + pub concurrency_max: u32, + pub chromium_status: &'static str, + pub chromium_restarts: u32, + pub libreoffice_status: &'static str, + pub libreoffice_restarts: u32, + pub queue_size: f64, + pub uptime_seconds: u64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct RoutePayload { + pub path: String, + pub method: &'static str, + pub rps: f64, + pub p50_ms: f64, + pub p95_ms: f64, + pub p99_ms: f64, + pub error_pct: f64, + pub in_flight: u32, + pub load_pct: f64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct EnginePayload { + pub name: &'static str, + pub status: &'static str, + pub restarts: u32, + pub mode: &'static str, + pub mini_series: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ConcurrencyPayload { + pub active: u32, + pub max: u32, + pub warn_threshold: u32, + pub crit_threshold: u32, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ResourcesPayload { + pub cpu_series: Vec, + pub memory_series: Vec, + pub memory_max_mb: f64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ThroughputPayload { + pub rps_series: Vec, + pub rps_baseline: f64, + pub p95_series: Vec, + pub p95_target_s: f64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct BatchPayload { + pub id: String, + pub status: String, + pub progress_pct: u8, + pub elapsed: String, +} +``` + +- [ ] **Step 2: Add `build_console_payload()` function to `console_store.rs`** + +This function is `async` because it reads from `Mutex`-protected fields in `ConsoleStore` and `AppState`. + +Append to `crates/server/src/console_store.rs`: + +```rust +use std::time::Instant; +use crate::state::AppState; +use crate::metrics::METRICS; +use prometheus::core::Collector; + +/// Build the full console payload from current server state. +/// Called by the sampler task and by the one-shot JSON endpoint. +pub async fn build_console_payload(state: &AppState, started_at: Instant) -> ConsolePayload { + let uptime_seconds = started_at.elapsed().as_secs(); + let concurrency_max = state.config.concurrency as u32; + let concurrency_active = (concurrency_max as usize) + .saturating_sub(state.sem.available_permits()) as u32; + + // Read history for series data + let history = state.console.history.lock().await; + let rps_series: Vec = history.samples.iter().map(|s| s.rps).collect(); + let p95_series: Vec = history.samples.iter().map(|s| s.p95_ms / 1000.0).collect(); + let cpu_series: Vec = history.samples.iter().map(|s| s.cpu_pct).collect(); + let memory_series: Vec = history.samples.iter().map(|s| s.memory_mb).collect(); + let last_rps = rps_series.last().copied().unwrap_or(0.0); + let last_p95_ms = p95_series.last().copied().unwrap_or(0.0) * 1000.0; + drop(history); + + // Queue size from Prometheus gauge + let queue_size = METRICS.queue_size.get(); + let error_pct = { + // Approximate: errors / total over last interval + let series = state.console.history.lock().await; + let s = series.samples.back(); + s.map_or(0.0, |s| s.error_pct) + }; + + // Engine status + #[cfg(feature = "chromium")] + let (chromium_status, chromium_restarts) = { + let up = state.chromium.as_ref().map_or(false, |_| METRICS.chromium_healthy.get() > 0.5); + let restarts = state.console.chromium_restarts.load(Ordering::SeqCst); + (if up { "up" } else { "down" }, restarts) + }; + #[cfg(not(feature = "chromium"))] + let (chromium_status, chromium_restarts) = ("n/a", 0u32); + + #[cfg(feature = "libreoffice")] + let (libreoffice_status, libreoffice_restarts) = { + let up = METRICS.libreoffice_healthy.get() > 0.5; + let restarts = state.console.libreoffice_restarts.load(Ordering::SeqCst); + (if up { "up" } else { "down" }, restarts) + }; + #[cfg(not(feature = "libreoffice"))] + let (libreoffice_status, libreoffice_restarts) = ("n/a", 0u32); + + // Engine mini_series from history (last 20 concurrency samples as proxy) + let mini: Vec = { + let h = state.console.history.lock().await; + h.samples.iter().rev().take(20).rev() + .map(|s| s.concurrency_active as f64 / concurrency_max.max(1) as f64) + .collect() + }; + + // Routes: derive from Prometheus metric families (best-effort V1) + // We expose the known folio routes with data from the metrics we have. + let routes = build_route_payloads(state, concurrency_max); + + // Recent requests + errors + let recent_requests: Vec = { + let log = state.console.request_log.lock().await; + log.iter().rev().take(12).cloned().collect::>().into_iter().rev().collect() + }; + let recent_errors: Vec = { + let log = state.console.error_log.lock().await; + log.iter().rev().take(6).cloned().collect::>().into_iter().rev().collect() + }; + + // Batches from batch manager + let batches = build_batch_payloads(state).await; + + // Memory + let memory_max_mb = read_total_memory_mb(); + + ConsolePayload { + version: env!("CARGO_PKG_VERSION").to_string(), + uptime_seconds, + ticker: TickerPayload { + rps: last_rps, + p95_ms: last_p95_ms, + error_pct, + concurrency_active, + concurrency_max, + chromium_status, + chromium_restarts, + libreoffice_status, + libreoffice_restarts, + queue_size, + uptime_seconds, + }, + routes, + engines: vec![ + EnginePayload { + name: "Chromium", + status: chromium_status, + restarts: chromium_restarts, + mode: if state.config.chromium_lazy_start { "lazy" } else { "eager" }, + mini_series: mini.clone(), + }, + EnginePayload { + name: "LibreOffice", + status: libreoffice_status, + restarts: libreoffice_restarts, + mode: if state.config.libreoffice_lazy_start { "lazy" } else { "eager" }, + mini_series: mini, + }, + ], + concurrency: ConcurrencyPayload { + active: concurrency_active, + max: concurrency_max, + warn_threshold: (concurrency_max as f64 * 0.60) as u32, + crit_threshold: (concurrency_max as f64 * 0.85) as u32, + }, + resources: ResourcesPayload { cpu_series, memory_series, memory_max_mb }, + throughput: ThroughputPayload { + rps_series, + rps_baseline: 0.0, + p95_series, + p95_target_s: 2.0, + }, + batches, + recent_requests, + recent_errors, + } +} + +fn build_route_payloads(state: &AppState, concurrency_max: u32) -> Vec { + // V1: expose known routes from Prometheus folio_http_requests_total counter. + // Gather metric families to read per-route counts. + use prometheus::proto::MetricType; + let families = prometheus::gather(); + let mut routes = Vec::new(); + + for family in &families { + if family.get_name() != "folio_http_requests_total" { continue; } + let mut route_map: std::collections::HashMap = std::collections::HashMap::new(); + for m in family.get_metric() { + let labels: std::collections::HashMap<_, _> = m.get_label().iter() + .map(|l| (l.get_name(), l.get_value())) + .collect(); + let route = labels.get("route").copied().unwrap_or("unknown"); + let status = labels.get("status").copied().unwrap_or("0"); + let count = m.get_counter().get_value(); + let entry = route_map.entry(route.to_string()).or_insert((0.0, 0.0)); + entry.0 += count; // total + if status.starts_with('5') || status.starts_with('4') { + entry.1 += count; // errors + } + } + for (path, (total, errors)) in route_map { + let error_pct = if total > 0.0 { (errors / total) * 100.0 } else { 0.0 }; + routes.push(RoutePayload { + path, + method: "POST", + rps: 0.0, + p50_ms: 0.0, + p95_ms: 0.0, + p99_ms: 0.0, + error_pct, + in_flight: 0, + load_pct: (state.sem.available_permits() as f64 / concurrency_max.max(1) as f64 * 100.0), + }); + } + break; + } + routes.sort_by(|a, b| b.error_pct.partial_cmp(&a.error_pct).unwrap_or(std::cmp::Ordering::Equal)); + routes +} + +async fn build_batch_payloads(state: &AppState) -> Vec { + let Some(ref bm) = state.batch_manager else { return vec![] }; + bm.list_jobs().await.into_iter().take(6).map(|j| BatchPayload { + id: j.id.chars().take(10).collect(), + status: format!("{:?}", j.status).to_lowercase(), + progress_pct: j.progress_pct.unwrap_or(0), + elapsed: format_elapsed(j.created_at), + }).collect() +} + +fn format_elapsed(created: std::time::SystemTime) -> String { + let secs = created.elapsed().unwrap_or_default().as_secs(); + if secs < 60 { format!("{}s", secs) } + else { format!("{}m {}s", secs / 60, secs % 60) } +} + +#[cfg(target_os = "linux")] +fn read_total_memory_mb() -> f64 { + std::fs::read_to_string("/proc/meminfo").ok() + .and_then(|s| s.lines().find(|l| l.starts_with("MemTotal:")) + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|v| v.parse::().ok())) + .map(|kb| kb / 1024.0) + .unwrap_or(0.0) +} + +#[cfg(not(target_os = "linux"))] +fn read_total_memory_mb() -> f64 { 0.0 } +``` + +- [ ] **Step 3: Add sampler function to `console_store.rs`** + +Append to `crates/server/src/console_store.rs`: + +```rust +use std::time::Duration; + +/// Spawn the background console sampler task. +/// Runs every 5 seconds, pushes MetricsSample to history, broadcasts payload. +pub fn spawn_console_sampler(state: crate::state::AppState, started_at: Instant) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(5)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut prev_http_total = 0.0_f64; + + loop { + interval.tick().await; + + // Read current http total for RPS delta + let http_total: f64 = { + let families = prometheus::gather(); + families.iter() + .find(|f| f.get_name() == "folio_http_requests_total") + .map(|f| f.get_metric().iter().map(|m| m.get_counter().get_value()).sum()) + .unwrap_or(0.0) + }; + let rps = (http_total - prev_http_total) / 5.0; + prev_http_total = http_total; + + // Read error rate + let error_total: f64 = { + let families = prometheus::gather(); + families.iter() + .find(|f| f.get_name() == "folio_http_requests_total") + .map(|f| f.get_metric().iter() + .filter(|m| m.get_label().iter() + .any(|l| l.get_name() == "status" && (l.get_value().starts_with('5') || l.get_value().starts_with('4')))) + .map(|m| m.get_counter().get_value()).sum()) + .unwrap_or(0.0) + }; + let error_pct = if http_total > 0.0 { (error_total / http_total) * 100.0 } else { 0.0 }; + + // Concurrency + let concurrency_max = state.config.concurrency as u32; + let concurrency_active = (concurrency_max as usize) + .saturating_sub(state.sem.available_permits()) as u32; + + // Memory + let memory_mb = METRICS.process_resident_memory.get() / 1024.0 / 1024.0; + METRICS.update_memory_metrics(); + + let p95_ms = *state.console.last_p95_ms.lock().await; + // Reset p95 approximation each interval + *state.console.last_p95_ms.lock().await = 0.0; + + let sample = MetricsSample { + ts: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(), + rps, + p95_ms, + error_pct, + queue_size: METRICS.queue_size.get() as u32, + concurrency_active, + cpu_pct: 0.0, // not tracked in V1 + memory_mb, + }; + + state.console.history.lock().await.push(sample); + + // Detect engine restarts + #[cfg(feature = "chromium")] + { + let now_running = state.chromium.as_ref().map_or(false, |_| METRICS.chromium_healthy.get() > 0.5); + let was = state.console.chromium_was_running.load(Ordering::SeqCst); + if now_running && !was { + state.console.chromium_restarts.fetch_add(1, Ordering::SeqCst); + } + state.console.chromium_was_running.store(now_running, Ordering::SeqCst); + } + #[cfg(feature = "libreoffice")] + { + let now_running = METRICS.libreoffice_healthy.get() > 0.5; + let was = state.console.libreoffice_was_running.load(Ordering::SeqCst); + if now_running && !was { + state.console.libreoffice_restarts.fetch_add(1, Ordering::SeqCst); + } + state.console.libreoffice_was_running.store(now_running, Ordering::SeqCst); + } + + // Build + broadcast payload + let payload = build_console_payload(&state, started_at).await; + if let Ok(json) = serde_json::to_string(&payload) { + let _ = state.console.broadcast.send(json); // ignore if no subscribers + } + } + }); +} +``` + +- [ ] **Step 4: Call `spawn_console_sampler` in `main.rs`** + +In `crates/server/src/main.rs`, after the `let state = AppState::new(...)` block and before `let router = build_router(...)`: + +```rust +// Spawn operator console sampler +use server::console_store::spawn_console_sampler; +let console_started_at = std::time::Instant::now(); +spawn_console_sampler(state.clone(), console_started_at); +``` + +- [ ] **Step 5: Add `batch_manager` helper to BatchStateManager (if `list_jobs` doesn't exist)** + +Check if `BatchStateManager` has a `list_jobs()` method: +```bash +grep -n "pub.*fn list\|pub.*fn jobs\|pub.*fn all" crates/server/src/routes/batch_state.rs | head -10 +``` + +If it doesn't exist, use a simpler batch payload builder that returns empty (the console still works, just no batch data): +```rust +async fn build_batch_payloads(_state: &AppState) -> Vec { + vec![] // V1: batch listing not yet exposed; add when batch_state has list_jobs() +} +``` + +- [ ] **Step 6: Build to confirm** + +```bash +cargo build -p server 2>&1 | grep "error\|warning: unused" | head -30 +``` +Expected: compiles. Fix any type errors. + +- [ ] **Step 7: Commit** + +```bash +git add crates/server/src/console_store.rs crates/server/src/main.rs +git commit -m "feat(console): add ConsolePayload builder and background sampler task" +``` + +--- + +### Task 4: SSE + JSON endpoints (`routes/console.rs`) + +**Files:** +- Create: `crates/server/src/routes/console.rs` +- Modify: `crates/server/src/routes/mod.rs` +- Modify: `crates/server/src/app.rs` + +- [ ] **Step 1: Create `routes/console.rs`** (SSE + JSON handlers only; static assets in Task 6) + +```rust +// crates/server/src/routes/console.rs +use std::convert::Infallible; +use std::time::Instant; + +use axum::Json; +use axum::extract::State; +use axum::response::sse::{Event, KeepAlive, Sse}; +use futures_util::stream::{self, Stream, StreamExt}; +use tokio::sync::broadcast::error::RecvError; + +use crate::console_store::{build_console_payload, ConsolePayload}; +use crate::state::AppState; + +/// SSE endpoint — streams ConsolePayload events to all connected browsers. +pub async fn console_stream( + State(state): State, +) -> Sse>> { + let started_at = state.started_at; // Instant from AppState + let mut rx = state.console.broadcast.subscribe(); + + // Send initial snapshot on connect (no waiting for next tick) + let initial = build_console_payload(&state, started_at).await; + let initial_json = serde_json::to_string(&initial).unwrap_or_default(); + let initial_stream = stream::once(async move { + Ok::(Event::default().data(initial_json)) + }); + + let broadcast_stream = stream::unfold(rx, |mut rx| async move { + loop { + match rx.recv().await { + Ok(payload) => return Some((Ok(Event::default().data(payload)), rx)), + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => return None, + } + } + }); + + Sse::new(initial_stream.chain(broadcast_stream)) + .keep_alive(KeepAlive::new().interval(std::time::Duration::from_secs(15)).text("ping")) +} + +/// One-shot JSON snapshot — same payload as SSE events, useful for curl/debug. +pub async fn console_metrics_json( + State(state): State, +) -> Json { + let started_at = state.started_at; + Json(build_console_payload(&state, started_at).await) +} +``` + +Note: `state.started_at` is already on `AppState` (it's an `Instant` used for the `/health` uptime). Check: +```bash +grep "started_at" crates/server/src/state.rs +``` +If it's not public, make it `pub` or pass it separately. + +- [ ] **Step 2: Add `pub mod console;` to `routes/mod.rs`** + +In `crates/server/src/routes/mod.rs`, add: +```rust +pub mod console; +``` + +- [ ] **Step 3: Mount console routes in `app.rs`** + +In `crates/server/src/app.rs`, find the `use crate::routes::{...}` import block and add: +```rust +use crate::routes::console; +``` + +Then in the `untimed` route block (near the `/debug`, `/openapi.json`, `/docs` routes), add: +```rust +untimed = untimed + .route("/_/api/stream", get(console::console_stream)) + .route("/_/api/metrics", get(console::console_metrics_json)); +// Static asset routes added in Task 6 +``` + +- [ ] **Step 4: Build** + +```bash +cargo build -p server 2>&1 | grep "^error" | head -20 +``` + +- [ ] **Step 5: Smoke test the SSE endpoint** + +In one terminal: +```bash +cargo run -p server -- serve --port 3001 & +sleep 3 +``` +In another: +```bash +curl -N http://localhost:3001/_/api/stream +``` +Expected: a JSON `data: {...}` event printed every 5 seconds, then `data: ping` keep-alives. + +```bash +curl http://localhost:3001/_/api/metrics | jq .uptime_seconds +``` +Expected: a small number (seconds since start). + +Kill the server: `pkill folio-server` + +- [ ] **Step 6: Commit** + +```bash +git add crates/server/src/routes/console.rs crates/server/src/routes/mod.rs crates/server/src/app.rs +git commit -m "feat(console): add SSE stream and one-shot metrics JSON endpoints" +``` + +--- + +### Task 5: Request log middleware + +**Files:** +- Modify: `crates/server/src/app.rs` + +Add a Tower middleware layer that captures every HTTP request/response pair and pushes it to `ConsoleStore`. + +- [ ] **Step 1: Add middleware function to `app.rs`** + +Add this function near the bottom of `app.rs` (before `handle_timeout_error`): + +```rust +use axum::middleware::Next; +use axum::extract::Request; +use axum::response::Response; +use std::time::Instant; + +async fn console_log_middleware( + State(state): State, + req: Request, + next: Next, +) -> Response { + let method = req.method().to_string(); + let path = req.uri().path().to_string(); + + // Skip the console routes themselves to avoid noise + if path.starts_with("/_/") { + return next.run(req).await; + } + + let start = Instant::now(); + let response = next.run(req).await; + let duration_ms = start.elapsed().as_millis() as u64; + let status = response.status().as_u16(); + + state.console.record_request(method, path, status, duration_ms).await; + response +} +``` + +- [ ] **Step 2: Add the middleware layer in `build_router()`** + +In `build_router()`, after all routes are defined and before the final `router` construction, add the middleware: + +```rust +// Console request logger (wraps the entire router) +use axum::middleware; +let router = router.layer(middleware::from_fn_with_state(state.clone(), console_log_middleware)); +``` + +Add this just before the `router` is returned (or before the auth layer wrapping, to ensure it runs for all routes). + +- [ ] **Step 3: Build and verify no regressions** + +```bash +cargo build -p server 2>&1 | grep "^error" | head -20 +cargo test -p server --lib 2>&1 | tail -5 +``` +Expected: builds and unit tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add crates/server/src/app.rs +git commit -m "feat(console): add request log middleware feeding ConsoleStore ring buffer" +``` + +--- + +### Task 6: Static asset serving with `rust-embed` + +**Files:** +- Modify: `crates/server/Cargo.toml` +- Modify: `crates/server/src/routes/console.rs` +- Modify: `crates/server/src/app.rs` +- Create: `ui/build/.gitkeep` + +- [ ] **Step 1: Create placeholder so rust-embed doesn't fail before UI is built** + +```bash +mkdir -p ui/build +touch ui/build/.gitkeep +echo "build/" >> ui/.gitignore || true # don't commit build output +echo "!build/.gitkeep" >> ui/.gitignore +``` + +- [ ] **Step 2: Add `rust-embed` and `mime_guess` to `crates/server/Cargo.toml`** + +In the `[dependencies]` section: +```toml +rust-embed = { version = "8", features = ["mime-guess"] } +mime_guess = "2" +``` + +- [ ] **Step 3: Add `ConsoleAssets` struct and `console_asset` handler to `routes/console.rs`** + +Add to `crates/server/src/routes/console.rs`: + +```rust +use axum::body::Body; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::IntoResponse; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "../../ui/build/"] +struct ConsoleAssets; + +/// Serves the embedded Svelte SPA. +/// Path "" or "/" → index.html; everything else → matching asset or index.html fallback. +pub async fn console_asset( + axum::extract::Path(path): axum::extract::Path, +) -> impl IntoResponse { + serve_asset(&path) +} + +pub async fn console_asset_root() -> impl IntoResponse { + serve_asset("index.html") +} + +fn serve_asset(path: &str) -> impl IntoResponse { + let path = path.trim_start_matches('/'); + let asset = ConsoleAssets::get(path) + .or_else(|| ConsoleAssets::get("index.html")); + + match asset { + Some(content) => { + let mime = mime_guess::from_path(path).first_or_octet_stream(); + let body = Body::from(content.data.into_owned()); + ( + StatusCode::OK, + [(header::CONTENT_TYPE, HeaderValue::from_str(mime.as_ref()).unwrap())], + body, + ).into_response() + } + None => StatusCode::NOT_FOUND.into_response(), + } +} +``` + +- [ ] **Step 4: Mount the asset routes in `app.rs`** + +In the `untimed` route block (alongside the SSE routes added in Task 4): + +```rust +untimed = untimed + .route("/_/", get(console::console_asset_root)) + .route("/_/{*path}", get(console::console_asset)); +``` + +- [ ] **Step 5: Build** + +```bash +cargo build -p server 2>&1 | grep "^error" | head -20 +``` +Expected: compiles. The `ui/build/` folder exists (even if empty) so rust-embed doesn't panic. + +- [ ] **Step 6: Commit** + +```bash +git add ui/build/.gitkeep ui/.gitignore crates/server/Cargo.toml crates/server/src/routes/console.rs crates/server/src/app.rs +git commit -m "feat(console): serve embedded Svelte SPA at /_/ via rust-embed" +``` + +--- + +### Task 7: Makefile + Docker integration + +**Files:** +- Modify: `Makefile` +- Modify: `Dockerfile` + +- [ ] **Step 1: Add `ui-build` and `ui-dev` targets to `Makefile`** + +Add after the existing `build-release` target: + +```makefile +.PHONY: ui-build +ui-build: ## Build the operator console UI (requires Node/bun in ui/) + cd ui && npm run build + +.PHONY: ui-dev +ui-dev: ## Start UI dev server with hot reload (run alongside folio-server) + cd ui && npm run dev + +.PHONY: build-with-ui +build-with-ui: ui-build build-release ## Build UI then Rust binary (for local testing) +``` + +- [ ] **Step 2: Add `ui-builder` stage to `Dockerfile`** + +At the very top of the `Dockerfile`, before the `chef` stage, add: + +```dockerfile +# ============================================================================= +# Stage: ui-builder — builds the operator console SPA +# ============================================================================= +FROM node:22-slim AS ui-builder +WORKDIR /ui +COPY ui/package*.json ui/bun.lock* ./ +RUN npm install +COPY ui/ ./ +RUN npm run build +``` + +Then in each `builder-full`, `builder-chromium`, `builder-libreoffice` stage, after the `COPY --link . .` line and before the `cargo build` line, add: + +```dockerfile +COPY --link --from=ui-builder /ui/build /app/ui/build +``` + +This ensures `ui/build/` exists when `cargo build` runs, so rust-embed can embed the real assets. + +- [ ] **Step 3: Build the UI and verify the binary serves it** + +```bash +make ui-build +cargo build -p server +cargo run -p server -- serve --port 3001 & +sleep 3 +curl -I http://localhost:3001/_/ +``` +Expected: `HTTP/1.1 200 OK` with `Content-Type: text/html`. + +```bash +curl http://localhost:3001/_/api/metrics | jq .version +``` +Expected: `"0.1.0"`. + +Kill: `pkill folio-server` + +- [ ] **Step 4: Commit** + +```bash +git add Makefile Dockerfile +git commit -m "feat(console): add ui-build Makefile target and ui-builder Docker stage" +``` + +--- + +## Phase 2 — Svelte Frontend + +--- + +### Task 8: SvelteKit config + chart setup + +**Files:** +- Modify: `ui/svelte.config.js` +- Modify: `ui/src/routes/layout.css` +- Run: `npx shadcn-svelte@latest add chart` + +- [ ] **Step 1: Update `svelte.config.js` — add base path and SPA fallback** + +```javascript +// ui/svelte.config.js +import adapter from '@sveltejs/adapter-static'; + +const config = { + compilerOptions: { + runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true) + }, + kit: { + adapter: adapter({ fallback: 'index.html' }), + paths: { base: '/_' }, + } +}; + +export default config; +``` + +- [ ] **Step 2: Run SvelteKit sync to update generated types** + +```bash +cd ui && npm run prepare +``` + +- [ ] **Step 3: Remap chart CSS variables in `layout.css`** + +Find the `:root` block in `ui/src/routes/layout.css` and replace the `--chart-*` lines: + +```css +/* Replace the 5 chart lines in :root with: */ +--chart-1: #4f8ef7; /* accent blue */ +--chart-2: #2f9967; /* ok green */ +--chart-3: #b8860b; /* warn amber */ +--chart-4: #c25151; /* err red */ +--chart-5: rgba(26,28,31,0.4); /* muted */ +``` + +And in the `.dark` block (or `@media (prefers-color-scheme: dark)`, or `.dark *`): +```css +--chart-1: #6aa3f8; +--chart-2: #3fb27f; +--chart-3: #e0a93c; +--chart-4: #e26464; +--chart-5: rgba(230,231,234,0.4); +``` + +- [ ] **Step 4: Install shadcn-svelte chart component** + +```bash +cd ui && npx shadcn-svelte@latest add chart +``` +Expected: installs `src/lib/components/ui/chart/` with `ChartContainer`, `ChartTooltip`, `ChartLegend`, `index.ts`. + +- [ ] **Step 5: Verify build still works** + +```bash +cd ui && npm run build 2>&1 | tail -10 +``` +Expected: `✓ built in Xs`. + +- [ ] **Step 6: Commit** + +```bash +git add ui/svelte.config.js ui/src/routes/layout.css ui/src/lib/components/ui/ +git commit -m "feat(console-ui): configure base path, remap chart colors, install shadcn chart" +``` + +--- + +### Task 9: Types + SSE store + theme store + +**Files:** +- Create: `ui/src/lib/types.ts` +- Create: `ui/src/lib/metrics.svelte.ts` +- Create: `ui/src/lib/theme.svelte.ts` + +- [ ] **Step 1: Create `types.ts`** — mirrors `ConsolePayload` Rust struct exactly + +```typescript +// ui/src/lib/types.ts +export interface MetricsSample { + ts: number; + rps: number; + p95_ms: number; + error_pct: number; + queue_size: number; + concurrency_active: number; + cpu_pct: number; + memory_mb: number; +} + +export interface TickerPayload { + rps: number; + p95_ms: number; + error_pct: number; + concurrency_active: number; + concurrency_max: number; + chromium_status: string; + chromium_restarts: number; + libreoffice_status: string; + libreoffice_restarts: number; + queue_size: number; + uptime_seconds: number; +} + +export interface RoutePayload { + path: string; + method: string; + rps: number; + p50_ms: number; + p95_ms: number; + p99_ms: number; + error_pct: number; + in_flight: number; + load_pct: number; +} + +export interface EnginePayload { + name: string; + status: string; + restarts: number; + mode: string; + mini_series: number[]; +} + +export interface ConcurrencyPayload { + active: number; + max: number; + warn_threshold: number; + crit_threshold: number; +} + +export interface ResourcesPayload { + cpu_series: number[]; + memory_series: number[]; + memory_max_mb: number; +} + +export interface ThroughputPayload { + rps_series: number[]; + rps_baseline: number; + p95_series: number[]; + p95_target_s: number; +} + +export interface BatchPayload { + id: string; + status: string; + progress_pct: number; + elapsed: string; +} + +export interface RequestLogEntry { + time: string; + method: string; + route: string; + status: number; + duration_ms: number; +} + +export interface ErrorLogEntry { + time: string; + route: string; + message: string; + request_id: string; +} + +export interface ConsolePayload { + version: string; + uptime_seconds: number; + ticker: TickerPayload; + routes: RoutePayload[]; + engines: EnginePayload[]; + concurrency: ConcurrencyPayload; + resources: ResourcesPayload; + throughput: ThroughputPayload; + batches: BatchPayload[]; + recent_requests: RequestLogEntry[]; + recent_errors: ErrorLogEntry[]; +} +``` + +- [ ] **Step 2: Create `metrics.svelte.ts`** + +```typescript +// ui/src/lib/metrics.svelte.ts +import type { ConsolePayload } from './types'; + +export let data = $state(null); +export let loading = $state(true); +export let connected = $state(false); +export let error = $state(null); +export let lastRefreshed = $state(null); + +let es: EventSource | null = null; + +export function startSSE() { + if (es) return; + es = new EventSource('/_/api/stream'); + + es.onopen = () => { + connected = true; + error = null; + }; + + es.onmessage = (event: MessageEvent) => { + try { + data = JSON.parse(event.data) as ConsolePayload; + lastRefreshed = new Date(); + error = null; + } catch { + error = 'Failed to parse server data'; + } finally { + loading = false; + } + }; + + es.onerror = () => { + connected = false; + error = 'Connection lost — reconnecting…'; + }; +} + +export function stopSSE() { + es?.close(); + es = null; + connected = false; +} + +export function manualRefresh() { + stopSSE(); + startSSE(); +} +``` + +- [ ] **Step 3: Create `theme.svelte.ts`** + +```typescript +// ui/src/lib/theme.svelte.ts +export let dark = $state(false); +export let accent = $state('#4f8ef7'); +export let density = $state<'compact' | 'regular' | 'comfy'>('regular'); + +export type Theme = typeof theme; + +export let theme = $derived({ + bg: dark ? '#0e0f12' : '#f7f7f5', + surface: dark ? '#15171c' : '#ffffff', + ink: dark ? '#e6e7ea' : '#1a1c1f', + muted: dark ? 'rgba(230,231,234,0.55)' : 'rgba(26,28,31,0.55)', + faint: dark ? 'rgba(230,231,234,0.10)' : 'rgba(26,28,31,0.06)', + rule: dark ? 'rgba(255,255,255,0.08)' : 'rgba(26,28,31,0.08)', + ok: dark ? '#3fb27f' : '#2f9967', + warn: dark ? '#e0a93c' : '#b8860b', + err: dark ? '#e26464' : '#c25151', + accent, +}); + +export let D = $derived( + density === 'compact' + ? { gap: 8, pad: 8, rowPy: 2, fz: 10.5, kpiFz: 18 } + : density === 'comfy' + ? { gap: 14, pad: 14, rowPy: 5, fz: 12, kpiFz: 22 } + : { gap: 10, pad: 10, rowPy: 3, fz: 11.5, kpiFz: 20 } +); + +// Persist dark mode in localStorage +if (typeof window !== 'undefined') { + dark = localStorage.getItem('folio-dark') === 'true'; +} + +$effect.root(() => { + $effect(() => { + if (typeof window !== 'undefined') { + localStorage.setItem('folio-dark', String(dark)); + document.documentElement.classList.toggle('dark', dark); + } + }); +}); +``` + +- [ ] **Step 4: Check TypeScript compiles** + +```bash +cd ui && npm run check 2>&1 | grep -E "error|Error" | head -20 +``` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/lib/types.ts ui/src/lib/metrics.svelte.ts ui/src/lib/theme.svelte.ts +git commit -m "feat(console-ui): add ConsolePayload types, SSE store, and theme store" +``` + +--- + +### Task 10: Shared primitives — `Card`, `Pill`, `SlimBar` + +**Files:** +- Create: `ui/src/lib/components/shared/Card.svelte` +- Create: `ui/src/lib/components/shared/Pill.svelte` +- Create: `ui/src/lib/components/shared/SlimBar.svelte` + +- [ ] **Step 1: Create `Card.svelte`** + +```svelte + + + +
+ {#if title} +
+ {title} + {#if sub}{sub}{/if} +
+ {/if} + {@render children()} +
+``` + +- [ ] **Step 2: Create `Pill.svelte`** + +```svelte + + + + + {@render children()} + +``` + +- [ ] **Step 3: Create `SlimBar.svelte`** + +```svelte + + + +
+
+
+``` + +- [ ] **Step 4: Check types** + +```bash +cd ui && npm run check 2>&1 | grep "error" | head -10 +``` + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/lib/components/shared/ +git commit -m "feat(console-ui): add Card, Pill, SlimBar shared primitives" +``` + +--- + +### Task 11: `Header.svelte` + `Ticker.svelte` + +**Files:** +- Create: `ui/src/lib/components/Header.svelte` +- Create: `ui/src/lib/components/Ticker.svelte` + +- [ ] **Step 1: Create `Header.svelte`** + +```svelte + + + +
+ Folio + v{data.version} + prod + ● {connected ? 'ok' : 'disconnected'} + + {refreshed} + +
+``` + +- [ ] **Step 2: Create `Ticker.svelte`** + +```svelte + + + +
+ {#each items as item, i} +
+
{item.label}
+
{item.value}
+
+ {/each} +
+``` + +- [ ] **Step 3: Type-check** + +```bash +cd ui && npm run check 2>&1 | grep "error" | head -10 +``` + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/lib/components/Header.svelte ui/src/lib/components/Ticker.svelte +git commit -m "feat(console-ui): add Header and Ticker components" +``` + +--- + +### Task 12: `RoutesTable.svelte` + +**Files:** +- Create: `ui/src/lib/components/RoutesTable.svelte` + +- [ ] **Step 1: Create `RoutesTable.svelte`** + +```svelte + + + + + + + + {#each ['Route','Method','RPS','p50','p95','p99','Err %','In-flight','Load'] as h, i} + + {/each} + + + + {#each sorted as r} + {@const p95tone = r.p95_ms > 10000 ? t.err : r.p95_ms > 5000 ? t.warn : t.ink} + + + + + + + + + + + + {/each} + +
{h}
{r.path}{r.method}{r.rps.toFixed(1)}{fmtMs(r.p50_ms)}{fmtMs(r.p95_ms)}{fmtMs(r.p99_ms)}{r.error_pct.toFixed(2)}{r.in_flight}
+
+``` + +- [ ] **Step 2: Check + commit** + +```bash +cd ui && npm run check 2>&1 | grep "error" | head -10 +git add ui/src/lib/components/RoutesTable.svelte +git commit -m "feat(console-ui): add RoutesTable component" +``` + +--- + +### Task 13: Side rail — Engines, Concurrency, Batches, Resources + +**Files:** +- Create: `ui/src/lib/components/side-rail/Engines.svelte` +- Create: `ui/src/lib/components/side-rail/Concurrency.svelte` +- Create: `ui/src/lib/components/side-rail/Batches.svelte` +- Create: `ui/src/lib/components/side-rail/Resources.svelte` + +- [ ] **Step 1: Create `Engines.svelte`** + +Uses shadcn `BarChart` for the engine mini sparklines. + +```svelte + + + + +
+ {#each engines as e, i} +
+
+
+ {e.name} + {e.status.toUpperCase()} +
+
+ {e.restarts} restarts · {e.mode} +
+
+ + {#if e.mini_series.length > 0} + {@const chartData = e.mini_series.map((v, idx) => ({ i: idx, v }))} + {@const chartConfig = { v: { color: `var(--chart-${engineTone(e) === 'ok' ? 2 : engineTone(e) === 'warn' ? 3 : 4})` } }} + + + + + + {/if} +
+ {/each} +
+
+``` + +- [ ] **Step 2: Create `Concurrency.svelte`** + +```svelte + + + + +
+
+
+ {conc.active} / {conc.max} +
+ {pct}% · {tone} +
+
+ {#each Array.from({ length: conc.max }, (_, i) => i) as i} +
+ {/each} +
+
+ 0warn {conc.warn_threshold}crit {conc.crit_threshold}{conc.max} +
+
+
+``` + +- [ ] **Step 3: Create `Batches.svelte`** + +```svelte + + + + + {#if batches.length === 0} +
No recent batches
+ {:else} + + + {#each batches as b, i} + + + + + + + {/each} + +
{b.id}{b.status.slice(0,4)}{b.elapsed}
+ {/if} +
+``` + +- [ ] **Step 4: Create `Resources.svelte`** + +```svelte + + + + +
+ +
+
+ CPU + {lastCpu.toFixed(0)}% +
+ + + + + + + +
+
+ +
+
+ Memory + + {(lastMem / 1024).toFixed(2)} GB{resources.memory_max_mb > 0 ? ` / ${(resources.memory_max_mb / 1024).toFixed(0)} GB` : ''} + +
+ + + + + + +
+
+
+``` + +- [ ] **Step 5: Check types** + +```bash +cd ui && npm run check 2>&1 | grep "error" | head -20 +``` +Fix any import paths (shadcn chart exports vary — check the generated `$lib/components/ui/chart/index.ts` for actual export names). + +- [ ] **Step 6: Commit** + +```bash +git add ui/src/lib/components/side-rail/ +git commit -m "feat(console-ui): add side rail — Engines, Concurrency, Batches, Resources" +``` + +--- + +### Task 14: `ThroughputStrip.svelte` + `ActivityStrip.svelte` + +**Files:** +- Create: `ui/src/lib/components/ThroughputStrip.svelte` +- Create: `ui/src/lib/components/ActivityStrip.svelte` + +- [ ] **Step 1: Create `ThroughputStrip.svelte`** + +```svelte + + + +
+ +
+
+ RPS + {lastRps.toFixed(1)} +
+ + + + {#if throughput.rps_baseline > 0} + + {/if} + + + +
+
+ + +
+
+ p95 + {lastP95.toFixed(2)}s +
+ + + + + + + +
+
+
+``` + +- [ ] **Step 2: Create `ActivityStrip.svelte`** + +```svelte + + + +
+ +
+ {#each requests as r, i} +
+ {r.time} + {r.method} + {r.route} + {r.status} + {r.duration_ms}ms +
+ {/each} + {#if requests.length === 0} +
No requests yet
+ {/if} +
+
+ + +
+ {#each errors as r, i} +
+ {r.time} + {r.route} + {r.message} + {r.request_id || '—'} +
+ {/each} + {#if errors.length === 0} +
No errors
+ {/if} +
+
+
+``` + +- [ ] **Step 3: Check + commit** + +```bash +cd ui && npm run check 2>&1 | grep "error" | head -20 +git add ui/src/lib/components/ThroughputStrip.svelte ui/src/lib/components/ActivityStrip.svelte +git commit -m "feat(console-ui): add ThroughputStrip and ActivityStrip components" +``` + +--- + +### Task 15: `+page.svelte` — full dashboard layout + tweaks panel + +**Files:** +- Modify: `ui/src/routes/+page.svelte` + +- [ ] **Step 1: Replace the default page content** + +```svelte + + + +
+ {#if loading} +
+ Connecting to Folio… +
+ {:else if data} + +
+ + +
+ +
+ + +
+ +
+ + + + +
+
+ + +
+ +
+ + +
+ +
+ {/if} +
+ + +
+ {#if tweaksOpen} +
+ +
+
Theme
+
+ {#each [['Light', false], ['Dark', true]] as [label, val]} + + {/each} +
+
+ +
+
Accent
+
+ {#each ACCENTS as a} + + {/each} +
+
+ +
+
Density
+
+ {#each ['compact', 'regular', 'comfy'] as d} + + {/each} +
+
+
+ {/if} + +
+``` + +- [ ] **Step 2: Build the UI and check for TypeScript errors** + +```bash +cd ui && npm run check 2>&1 | grep "error" | head -30 +cd ui && npm run build 2>&1 | tail -15 +``` +Expected: `✓ built`. Fix any type errors that appear. + +- [ ] **Step 3: Run `folio-server` locally and open the console** + +```bash +# Terminal 1: run server +cargo build -p server +cargo run -p server -- serve --port 3000 & +sleep 3 + +# Terminal 2: open browser +open http://localhost:3000/_/ +``` +Expected: the dashboard loads, the ticker shows uptime counting up, SSE connected indicator shows green. + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/routes/+page.svelte +git commit -m "feat(console-ui): complete dashboard page layout with tweaks panel" +``` + +--- + +## Phase 3 — Integration & Verification + +--- + +### Task 16: Full integration build + smoke test + +**Files:** (none new — verification only) + +- [ ] **Step 1: Build UI and embed into Rust binary** + +```bash +cd ui && npm run build +cd .. +cargo build -p server 2>&1 | grep "^error" | head -10 +``` +Expected: both succeed. + +- [ ] **Step 2: Start server and verify all endpoints** + +```bash +cargo run -p server -- serve --port 3001 & +sleep 3 + +# Console loads +curl -s -o /dev/null -w "%{http_code}" http://localhost:3001/_/ +# Expected: 200 + +# SSE streams events +curl -N --max-time 8 http://localhost:3001/_/api/stream 2>&1 | head -5 +# Expected: data: {"version":... (JSON payload) + +# One-shot JSON +curl -s http://localhost:3001/_/api/metrics | jq '{version, uptime_seconds}' +# Expected: {"version": "0.1.0", "uptime_seconds": } + +# Existing API still works +curl -s http://localhost:3001/health | jq .status +# Expected: "ok" + +pkill folio-server +``` + +- [ ] **Step 3: Run unit tests to confirm no regressions** + +```bash +cargo test -p server --lib 2>&1 | tail -10 +``` +Expected: all pass. + +- [ ] **Step 4: Final commit** + +```bash +git add -A +git commit -m "feat(console): complete Folio Operator Console — SSE dashboard at /_/" +``` + +--- + +## Self-Review + +**Spec coverage check:** + +| Spec section | Covered by | +|---|---| +| SSE endpoint `/_/api/stream` | Task 4 | +| One-shot `/_/api/metrics` | Task 4 | +| `ConsoleStore` ring buffers + broadcast | Task 1 | +| Background 5s sampler | Task 3 | +| Request/error log middleware | Task 5 | +| Static file serving `/_/` | Task 6 | +| `MetricsHistory` rolling window | Task 1 + Task 3 | +| Engine restart tracking | Task 2 + Task 3 | +| `ConsolePayload` JSON shape | Task 3 | +| SvelteKit base path `/_` | Task 8 | +| Chart CSS var remapping | Task 8 | +| `types.ts` mirroring Rust structs | Task 9 | +| SSE `EventSource` store | Task 9 | +| Theme + density rune store | Task 9 | +| Card, Pill, SlimBar primitives | Task 10 | +| Header + Ticker | Task 11 | +| Routes table | Task 12 | +| Engines, Concurrency, Batches, Resources | Task 13 | +| Throughput strip (2 bar charts) | Task 14 | +| Activity strip (request + error log) | Task 14 | +| Full page layout + tweaks panel | Task 15 | +| Makefile `ui-build` target | Task 7 | +| Docker `ui-builder` stage | Task 7 | +| `EventSource` auth note (no auth on stream) | Handled in app.rs — `/_/api/stream` is in `untimed` outside auth layer | + +**Type consistency check:** +- `ConsolePayload` Rust struct fields match `types.ts` interface exactly ✓ +- `theme` and `D` objects passed consistently as props to all components ✓ +- `startSSE`/`stopSSE` called in `+page.svelte` `onMount`/`onDestroy` ✓ +- `BarChart`, `Bar`, `ChartContainer`, `ReferenceLine`, `ChartTooltip`, `ChartTooltipContent` — these are the expected shadcn chart exports; verify against generated `ui/src/lib/components/ui/chart/index.ts` after `npx shadcn-svelte add chart` runs in Task 8. From 35125193963c65e5eaaffddc397f2e9c0c112fbb Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 13:27:51 +0530 Subject: [PATCH 04/93] feat(console): add ConsoleStore with ring buffers and SSE broadcast channel Co-Authored-By: Claude Sonnet 4.6 --- crates/server/src/console_store.rs | 125 +++++++++++++++++++++++++++++ crates/server/src/lib.rs | 1 + crates/server/src/state.rs | 4 + 3 files changed, 130 insertions(+) create mode 100644 crates/server/src/console_store.rs diff --git a/crates/server/src/console_store.rs b/crates/server/src/console_store.rs new file mode 100644 index 0000000..e558eae --- /dev/null +++ b/crates/server/src/console_store.rs @@ -0,0 +1,125 @@ +// crates/server/src/console_store.rs +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use serde::{Deserialize, Serialize}; +use tokio::sync::{Mutex, broadcast}; + +pub const HISTORY_CAP: usize = 360; // 30 min at 5s cadence +pub const LOG_CAP: usize = 100; +pub const BROADCAST_CAP: usize = 4; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MetricsSample { + pub ts: u64, + pub rps: f64, + pub p95_ms: f64, + pub error_pct: f64, + pub queue_size: u32, + pub concurrency_active: u32, + pub cpu_pct: f64, + pub memory_mb: f64, +} + +#[derive(Debug, Default)] +pub struct MetricsHistory { + pub samples: VecDeque, +} + +impl MetricsHistory { + pub fn push(&mut self, sample: MetricsSample) { + if self.samples.len() >= HISTORY_CAP { + self.samples.pop_front(); + } + self.samples.push_back(sample); + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RequestLogEntry { + pub time: String, + pub method: String, + pub route: String, + pub status: u16, + pub duration_ms: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ErrorLogEntry { + pub time: String, + pub route: String, + pub message: String, + pub request_id: String, +} + +pub struct ConsoleStore { + pub history: Mutex, + pub request_log: Mutex>, + pub error_log: Mutex>, + pub broadcast: broadcast::Sender, + // Restart tracking (sampler detects false→true transition on is_running) + pub chromium_restarts: AtomicU32, + pub chromium_was_running: AtomicBool, + pub libreoffice_restarts: AtomicU32, + pub libreoffice_was_running: AtomicBool, + // Rolling p95 approximation updated by request log middleware + pub last_p95_ms: Mutex, + // RPS delta tracking + pub prev_http_total: Mutex, +} + +impl ConsoleStore { + pub fn new() -> Self { + let (tx, _) = broadcast::channel(BROADCAST_CAP); + Self { + history: Mutex::new(MetricsHistory::default()), + request_log: Mutex::new(VecDeque::new()), + error_log: Mutex::new(VecDeque::new()), + broadcast: tx, + chromium_restarts: AtomicU32::new(0), + chromium_was_running: AtomicBool::new(false), + libreoffice_restarts: AtomicU32::new(0), + libreoffice_was_running: AtomicBool::new(false), + last_p95_ms: Mutex::new(0.0), + prev_http_total: Mutex::new(0.0), + } + } + + pub async fn record_request(&self, method: String, route: String, status: u16, duration_ms: u64) { + // Use std::time instead of chrono (chrono not in server crate) + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + let h = (secs % 86400) / 3600; + let m = (secs % 3600) / 60; + let s = secs % 60; + let time = format!("{h:02}:{m:02}:{s:02}"); + + { + let mut log = self.request_log.lock().await; + if log.len() >= LOG_CAP { log.pop_front(); } + log.push_back(RequestLogEntry { time: time.clone(), method: method.clone(), route: route.clone(), status, duration_ms }); + } + + // Update p95 approximation (rolling max over recent requests) + { + let mut p95 = self.last_p95_ms.lock().await; + if duration_ms as f64 > *p95 { + *p95 = duration_ms as f64; + } + } + + if status >= 500 { + let mut log = self.error_log.lock().await; + if log.len() >= LOG_CAP { log.pop_front(); } + log.push_back(ErrorLogEntry { + time, + route, + message: format!("HTTP {status}"), + request_id: String::new(), + }); + } + } +} + +// Suppress dead_code warnings — fields are used by future tasks +#[allow(dead_code)] +const _ATOMIC_ORDERING_USED: Ordering = Ordering::SeqCst; diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index f4b07dd..1f70e15 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -12,6 +12,7 @@ pub mod auth; pub mod backend; pub mod banner; pub mod batch_worker; +pub mod console_store; pub mod config; pub mod download; pub mod error; diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index 7ecd271..ea8de80 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -11,6 +11,7 @@ use tokio::sync::Semaphore; use crate::ServerConfig; use crate::backend::PdfBackend; +use crate::console_store::ConsoleStore; use crate::metrics::FolioMetrics; use crate::routes::batch_state::BatchStateManager; use crate::supervised_engine::SupervisedLibreOfficeEngine; @@ -36,6 +37,8 @@ pub struct AppState { pub metrics: Arc, /// Batch state manager for batch API. pub batch_manager: Option, + /// Operator console store (ring buffers + SSE broadcast). + pub console: Arc, } impl AppState { @@ -57,6 +60,7 @@ impl AppState { webhook_queue: None, metrics, batch_manager: None, + console: Arc::new(ConsoleStore::new()), } } From 454a431eee9939847aa3b0db844ac0d7025b531d Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 13:33:05 +0530 Subject: [PATCH 05/93] fix(console): remove unused Ordering import, add Default impl, clarify p95 comment --- crates/server/src/console_store.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/server/src/console_store.rs b/crates/server/src/console_store.rs index e558eae..024073f 100644 --- a/crates/server/src/console_store.rs +++ b/crates/server/src/console_store.rs @@ -1,6 +1,6 @@ // crates/server/src/console_store.rs use std::collections::VecDeque; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32}; use serde::{Deserialize, Serialize}; use tokio::sync::{Mutex, broadcast}; @@ -61,7 +61,7 @@ pub struct ConsoleStore { pub chromium_was_running: AtomicBool, pub libreoffice_restarts: AtomicU32, pub libreoffice_was_running: AtomicBool, - // Rolling p95 approximation updated by request log middleware + // Rolling-max p95 approximation; updated by request log middleware, reset to 0 by sampler each tick pub last_p95_ms: Mutex, // RPS delta tracking pub prev_http_total: Mutex, @@ -120,6 +120,8 @@ impl ConsoleStore { } } -// Suppress dead_code warnings — fields are used by future tasks -#[allow(dead_code)] -const _ATOMIC_ORDERING_USED: Ordering = Ordering::SeqCst; +impl Default for ConsoleStore { + fn default() -> Self { + Self::new() + } +} From ef9b95ae2b0c47d1ef0d6845922a1b672b38608c Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 13:34:34 +0530 Subject: [PATCH 06/93] feat(console): expose is_running() on supervised engines for console sampler Co-Authored-By: Claude Sonnet 4.6 --- crates/server/src/supervised_engine.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/server/src/supervised_engine.rs b/crates/server/src/supervised_engine.rs index 60ef75b..18447c4 100644 --- a/crates/server/src/supervised_engine.rs +++ b/crates/server/src/supervised_engine.rs @@ -215,6 +215,11 @@ impl SupervisedChromiumEngine { } } + /// Returns true if the Chromium engine is currently running. + pub fn is_running(&self) -> bool { + self.inner.is_running.load(std::sync::atomic::Ordering::SeqCst) + } + /// Shutdown the engine. pub async fn shutdown(&self) { let mut guard = self.inner.engine.lock().await; @@ -348,6 +353,11 @@ impl SupervisedLibreOfficeEngine { } } + /// Returns true if the LibreOffice engine is currently running. + pub fn is_running(&self) -> bool { + self.inner.is_running.load(std::sync::atomic::Ordering::SeqCst) + } + /// Convert many files to PDFs in parallel. pub async fn convert_many( &self, From 464e4a9db03bdec8069b6ae82ad7ed8e89bd6934 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 13:36:50 +0530 Subject: [PATCH 07/93] fix(console): use imported Ordering alias in is_running() methods --- crates/server/src/supervised_engine.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/server/src/supervised_engine.rs b/crates/server/src/supervised_engine.rs index 18447c4..5f2af46 100644 --- a/crates/server/src/supervised_engine.rs +++ b/crates/server/src/supervised_engine.rs @@ -217,7 +217,7 @@ impl SupervisedChromiumEngine { /// Returns true if the Chromium engine is currently running. pub fn is_running(&self) -> bool { - self.inner.is_running.load(std::sync::atomic::Ordering::SeqCst) + self.inner.is_running.load(Ordering::SeqCst) } /// Shutdown the engine. @@ -355,7 +355,7 @@ impl SupervisedLibreOfficeEngine { /// Returns true if the LibreOffice engine is currently running. pub fn is_running(&self) -> bool { - self.inner.is_running.load(std::sync::atomic::Ordering::SeqCst) + self.inner.is_running.load(Ordering::SeqCst) } /// Convert many files to PDFs in parallel. From 42eef1fadbe1b4a2ec131d0634dd7c4eac852c76 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 13:40:17 +0530 Subject: [PATCH 08/93] feat(console): add ConsolePayload builder and background sampler task Adds ConsolePayload structs, build_console_payload(), spawn_console_sampler() to console_store.rs and wires the sampler into main.rs after AppState creation. Co-Authored-By: Claude Sonnet 4.6 --- crates/server/src/console_store.rs | 357 +++++++++++++++++++++++++++++ crates/server/src/main.rs | 6 + 2 files changed, 363 insertions(+) diff --git a/crates/server/src/console_store.rs b/crates/server/src/console_store.rs index 024073f..022dd1f 100644 --- a/crates/server/src/console_store.rs +++ b/crates/server/src/console_store.rs @@ -125,3 +125,360 @@ impl Default for ConsoleStore { Self::new() } } + +// ── ConsolePayload (the JSON shape sent to the frontend) ────────────────── + +use std::sync::atomic::Ordering; +use std::time::{Duration, Instant}; + +#[derive(Clone, Debug, Serialize)] +pub struct ConsolePayload { + pub version: String, + pub uptime_seconds: u64, + pub ticker: TickerPayload, + pub routes: Vec, + pub engines: Vec, + pub concurrency: ConcurrencyPayload, + pub resources: ResourcesPayload, + pub throughput: ThroughputPayload, + pub batches: Vec, + pub recent_requests: Vec, + pub recent_errors: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct TickerPayload { + pub rps: f64, + pub p95_ms: f64, + pub error_pct: f64, + pub concurrency_active: u32, + pub concurrency_max: u32, + pub chromium_status: String, + pub chromium_restarts: u32, + pub libreoffice_status: String, + pub libreoffice_restarts: u32, + pub queue_size: f64, + pub uptime_seconds: u64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct RoutePayload { + pub path: String, + pub method: String, + pub rps: f64, + pub p50_ms: f64, + pub p95_ms: f64, + pub p99_ms: f64, + pub error_pct: f64, + pub in_flight: u32, + pub load_pct: f64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct EnginePayload { + pub name: String, + pub status: String, + pub restarts: u32, + pub mode: String, + pub mini_series: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ConcurrencyPayload { + pub active: u32, + pub max: u32, + pub warn_threshold: u32, + pub crit_threshold: u32, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ResourcesPayload { + pub cpu_series: Vec, + pub memory_series: Vec, + pub memory_max_mb: f64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ThroughputPayload { + pub rps_series: Vec, + pub rps_baseline: f64, + pub p95_series: Vec, + pub p95_target_s: f64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct BatchPayload { + pub id: String, + pub status: String, + pub progress_pct: u8, + pub elapsed: String, +} + +// ── build_console_payload ───────────────────────────────────────────────── + +pub async fn build_console_payload(state: &crate::state::AppState, started_at: Instant) -> ConsolePayload { + let uptime_seconds = started_at.elapsed().as_secs(); + let concurrency_max = state.config.concurrency as u32; + let concurrency_active = (concurrency_max as usize) + .saturating_sub(state.sem.available_permits()) as u32; + + // Read history for series data + let (rps_series, p95_series, cpu_series, memory_series, last_rps, last_p95_ms, error_pct) = { + let history = state.console.history.lock().await; + let rps_series: Vec = history.samples.iter().map(|s| s.rps).collect(); + let p95_series: Vec = history.samples.iter().map(|s| s.p95_ms / 1000.0).collect(); + let cpu_series: Vec = history.samples.iter().map(|s| s.cpu_pct).collect(); + let memory_series: Vec = history.samples.iter().map(|s| s.memory_mb).collect(); + let last_rps = rps_series.last().copied().unwrap_or(0.0); + let last_p95_ms = p95_series.last().copied().unwrap_or(0.0) * 1000.0; + let error_pct = history.samples.back().map_or(0.0, |s| s.error_pct); + (rps_series, p95_series, cpu_series, memory_series, last_rps, last_p95_ms, error_pct) + }; + + // Queue size from metrics gauge + let queue_size = state.metrics.queue_size.get(); + + // Engine status from health gauges + let chromium_status = if state.metrics.chromium_healthy.get() >= 1.0 { + "up".to_string() + } else { + "down".to_string() + }; + let chromium_restarts = state.console.chromium_restarts.load(Ordering::SeqCst); + + let libreoffice_status = if state.metrics.libreoffice_healthy.get() >= 1.0 { + "up".to_string() + } else { + "down".to_string() + }; + let libreoffice_restarts = state.console.libreoffice_restarts.load(Ordering::SeqCst); + + // Engine mini_series (last 20 concurrency samples as proxy for load) + let mini: Vec = { + let h = state.console.history.lock().await; + h.samples.iter().rev().take(20).rev() + .map(|s| s.concurrency_active as f64 / concurrency_max.max(1) as f64) + .collect() + }; + + let routes = build_route_payloads(state, concurrency_max); + + let recent_requests: Vec = { + let log = state.console.request_log.lock().await; + log.iter().rev().take(12).cloned().collect::>().into_iter().rev().collect() + }; + let recent_errors: Vec = { + let log = state.console.error_log.lock().await; + log.iter().rev().take(6).cloned().collect::>().into_iter().rev().collect() + }; + + let batches = build_batch_payloads(state).await; + let memory_max_mb = read_total_memory_mb(); + + ConsolePayload { + version: env!("CARGO_PKG_VERSION").to_string(), + uptime_seconds, + ticker: TickerPayload { + rps: last_rps, + p95_ms: last_p95_ms, + error_pct, + concurrency_active, + concurrency_max, + chromium_status: chromium_status.clone(), + chromium_restarts, + libreoffice_status: libreoffice_status.clone(), + libreoffice_restarts, + queue_size, + uptime_seconds, + }, + routes, + engines: vec![ + EnginePayload { + name: "Chromium".to_string(), + status: chromium_status, + restarts: chromium_restarts, + mode: if state.config.chromium_lazy_start { "lazy".to_string() } else { "eager".to_string() }, + mini_series: mini.clone(), + }, + EnginePayload { + name: "LibreOffice".to_string(), + status: libreoffice_status, + restarts: libreoffice_restarts, + mode: if state.config.libreoffice_lazy_start { "lazy".to_string() } else { "eager".to_string() }, + mini_series: mini, + }, + ], + concurrency: ConcurrencyPayload { + active: concurrency_active, + max: concurrency_max, + warn_threshold: (concurrency_max as f64 * 0.60) as u32, + crit_threshold: (concurrency_max as f64 * 0.85) as u32, + }, + resources: ResourcesPayload { cpu_series, memory_series, memory_max_mb }, + throughput: ThroughputPayload { + rps_series, + rps_baseline: 0.0, + p95_series, + p95_target_s: 2.0, + }, + batches, + recent_requests, + recent_errors, + } +} + +// ── Helper functions ────────────────────────────────────────────────────── + +fn build_route_payloads(state: &crate::state::AppState, concurrency_max: u32) -> Vec { + let families = prometheus::gather(); + let mut routes = Vec::new(); + + for family in &families { + if family.get_name() != "folio_http_requests_total" { continue; } + let mut route_map: std::collections::HashMap = std::collections::HashMap::new(); + for m in family.get_metric() { + let labels: std::collections::HashMap<_, _> = m.get_label().iter() + .map(|l| (l.get_name(), l.get_value())) + .collect(); + let route = labels.get("route").copied().unwrap_or("unknown"); + let status = labels.get("status").copied().unwrap_or("0"); + let count = m.get_counter().get_value(); + let entry = route_map.entry(route.to_string()).or_insert((0.0, 0.0)); + entry.0 += count; + if status.starts_with('5') || status.starts_with('4') { + entry.1 += count; + } + } + for (path, (total, errors)) in route_map { + let error_pct = if total > 0.0 { (errors / total) * 100.0 } else { 0.0 }; + routes.push(RoutePayload { + path, + method: "POST".to_string(), + rps: 0.0, + p50_ms: 0.0, + p95_ms: 0.0, + p99_ms: 0.0, + error_pct, + in_flight: 0, + load_pct: ((concurrency_max as usize).saturating_sub(state.sem.available_permits()) as f64 + / concurrency_max.max(1) as f64 * 100.0), + }); + } + break; + } + routes.sort_by(|a, b| b.error_pct.partial_cmp(&a.error_pct).unwrap_or(std::cmp::Ordering::Equal)); + routes +} + +async fn build_batch_payloads(state: &crate::state::AppState) -> Vec { + // batch_manager.list_batches() returns Vec (just IDs, no status) + // Return empty vec to avoid an expensive full scan; console can query batch API separately + let Some(ref _bm) = state.batch_manager else { return vec![] }; + vec![] +} + +#[cfg(target_os = "linux")] +fn read_total_memory_mb() -> f64 { + std::fs::read_to_string("/proc/meminfo").ok() + .and_then(|s| s.lines().find(|l| l.starts_with("MemTotal:")) + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|v| v.parse::().ok())) + .map(|kb| kb / 1024.0) + .unwrap_or(0.0) +} + +#[cfg(not(target_os = "linux"))] +fn read_total_memory_mb() -> f64 { 0.0 } + +// ── spawn_console_sampler ───────────────────────────────────────────────── + +pub fn spawn_console_sampler(state: crate::state::AppState, started_at: Instant) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(5)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + interval.tick().await; + + // Read current http total for RPS delta + let http_total: f64 = { + let families = prometheus::gather(); + families.iter() + .find(|f| f.get_name() == "folio_http_requests_total") + .map(|f| f.get_metric().iter().map(|m| m.get_counter().get_value()).sum()) + .unwrap_or(0.0) + }; + let mut prev = state.console.prev_http_total.lock().await; + let rps = (http_total - *prev) / 5.0; + *prev = http_total; + drop(prev); + + // Error rate + let error_total: f64 = { + let families = prometheus::gather(); + families.iter() + .find(|f| f.get_name() == "folio_http_requests_total") + .map(|f| f.get_metric().iter() + .filter(|m| m.get_label().iter() + .any(|l| l.get_name() == "status" && (l.get_value().starts_with('5') || l.get_value().starts_with('4')))) + .map(|m| m.get_counter().get_value()).sum()) + .unwrap_or(0.0) + }; + let error_pct = if http_total > 0.0 { (error_total / http_total) * 100.0 } else { 0.0 }; + + // Concurrency + let concurrency_max = state.config.concurrency as u32; + let concurrency_active = (concurrency_max as usize) + .saturating_sub(state.sem.available_permits()) as u32; + + // Memory from prometheus gauge (bytes -> MB) + let memory_mb = state.metrics.process_resident_memory.get() / (1024.0 * 1024.0); + + let p95_ms = { + let mut p95 = state.console.last_p95_ms.lock().await; + let val = *p95; + *p95 = 0.0; // reset each tick + val + }; + + let sample = MetricsSample { + ts: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(), + rps, + p95_ms, + error_pct, + queue_size: state.metrics.queue_size.get() as u32, + concurrency_active, + cpu_pct: 0.0, + memory_mb, + }; + + state.console.history.lock().await.push(sample); + + // Detect engine restarts via health gauge transitions + // chromium: health gauge 1.0 = up, 0.0 = down + let chromium_now_running = state.metrics.chromium_healthy.get() >= 1.0; + let chromium_was = state.console.chromium_was_running.load(Ordering::SeqCst); + if !chromium_was && chromium_now_running { + state.console.chromium_restarts.fetch_add(1, Ordering::SeqCst); + } + state.console.chromium_was_running.store(chromium_now_running, Ordering::SeqCst); + + #[cfg(feature = "libreoffice")] + { + let lo_now_running = state.metrics.libreoffice_healthy.get() >= 1.0; + let lo_was = state.console.libreoffice_was_running.load(Ordering::SeqCst); + if !lo_was && lo_now_running { + state.console.libreoffice_restarts.fetch_add(1, Ordering::SeqCst); + } + state.console.libreoffice_was_running.store(lo_now_running, Ordering::SeqCst); + } + + // Build + broadcast + let payload = build_console_payload(&state, started_at).await; + if let Ok(json) = serde_json::to_string(&payload) { + let _ = state.console.broadcast.send(json); + } + } + }); +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index c6def85..c956efd 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -141,6 +141,12 @@ async fn serve(args: ServerArgs) -> anyhow::Result<()> { #[cfg(feature = "libreoffice")] let state = state.with_libreoffice(Some(Arc::new(libreoffice))); + { + use server::console_store::spawn_console_sampler; + let console_started_at = std::time::Instant::now(); + spawn_console_sampler(state.clone(), console_started_at); + } + let router = build_router(state, &config); let addr: SocketAddr = SocketAddr::new(config.host, config.port); From 96bf91933d36f184cf10a4d9253af741baf1c26c Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 13:48:25 +0530 Subject: [PATCH 09/93] fix(console): interval-relative error_pct, single gather(), cfg engine guards, consistent started_at Co-Authored-By: Claude Sonnet 4.6 --- crates/server/src/console_store.rs | 99 ++++++++++++++++++------------ crates/server/src/main.rs | 3 +- 2 files changed, 61 insertions(+), 41 deletions(-) diff --git a/crates/server/src/console_store.rs b/crates/server/src/console_store.rs index 022dd1f..d46e8a1 100644 --- a/crates/server/src/console_store.rs +++ b/crates/server/src/console_store.rs @@ -65,6 +65,8 @@ pub struct ConsoleStore { pub last_p95_ms: Mutex, // RPS delta tracking pub prev_http_total: Mutex, + // Error rate delta tracking + pub prev_error_total: Mutex, } impl ConsoleStore { @@ -81,6 +83,7 @@ impl ConsoleStore { libreoffice_was_running: AtomicBool::new(false), last_p95_ms: Mutex::new(0.0), prev_http_total: Mutex::new(0.0), + prev_error_total: Mutex::new(0.0), } } @@ -238,19 +241,25 @@ pub async fn build_console_payload(state: &crate::state::AppState, started_at: I // Queue size from metrics gauge let queue_size = state.metrics.queue_size.get(); - // Engine status from health gauges - let chromium_status = if state.metrics.chromium_healthy.get() >= 1.0 { + // Engine status from health gauges (with feature guards so absent engines show "n/a") + #[cfg(feature = "chromium")] + let chromium_status = if state.chromium.is_some() && state.metrics.chromium_healthy.get() >= 1.0 { "up".to_string() } else { "down".to_string() }; + #[cfg(not(feature = "chromium"))] + let chromium_status = "n/a".to_string(); let chromium_restarts = state.console.chromium_restarts.load(Ordering::SeqCst); + #[cfg(feature = "libreoffice")] let libreoffice_status = if state.metrics.libreoffice_healthy.get() >= 1.0 { "up".to_string() } else { "down".to_string() }; + #[cfg(not(feature = "libreoffice"))] + let libreoffice_status = "n/a".to_string(); let libreoffice_restarts = state.console.libreoffice_restarts.load(Ordering::SeqCst); // Engine mini_series (last 20 concurrency samples as proxy for load) @@ -292,22 +301,26 @@ pub async fn build_console_payload(state: &crate::state::AppState, started_at: I uptime_seconds, }, routes, - engines: vec![ - EnginePayload { + engines: { + let mut engines = Vec::new(); + #[cfg(feature = "chromium")] + engines.push(EnginePayload { name: "Chromium".to_string(), - status: chromium_status, + status: chromium_status.clone(), restarts: chromium_restarts, mode: if state.config.chromium_lazy_start { "lazy".to_string() } else { "eager".to_string() }, mini_series: mini.clone(), - }, - EnginePayload { + }); + #[cfg(feature = "libreoffice")] + engines.push(EnginePayload { name: "LibreOffice".to_string(), - status: libreoffice_status, + status: libreoffice_status.clone(), restarts: libreoffice_restarts, mode: if state.config.libreoffice_lazy_start { "lazy".to_string() } else { "eager".to_string() }, mini_series: mini, - }, - ], + }); + engines + }, concurrency: ConcurrencyPayload { active: concurrency_active, max: concurrency_max, @@ -400,31 +413,36 @@ pub fn spawn_console_sampler(state: crate::state::AppState, started_at: Instant) loop { interval.tick().await; + // Gather metrics once per tick to avoid data race between http_total and error_total + let families = prometheus::gather(); + // Read current http total for RPS delta - let http_total: f64 = { - let families = prometheus::gather(); - families.iter() - .find(|f| f.get_name() == "folio_http_requests_total") - .map(|f| f.get_metric().iter().map(|m| m.get_counter().get_value()).sum()) - .unwrap_or(0.0) - }; - let mut prev = state.console.prev_http_total.lock().await; - let rps = (http_total - *prev) / 5.0; - *prev = http_total; - drop(prev); - - // Error rate - let error_total: f64 = { - let families = prometheus::gather(); - families.iter() - .find(|f| f.get_name() == "folio_http_requests_total") - .map(|f| f.get_metric().iter() - .filter(|m| m.get_label().iter() - .any(|l| l.get_name() == "status" && (l.get_value().starts_with('5') || l.get_value().starts_with('4')))) - .map(|m| m.get_counter().get_value()).sum()) - .unwrap_or(0.0) - }; - let error_pct = if http_total > 0.0 { (error_total / http_total) * 100.0 } else { 0.0 }; + let http_total: f64 = families.iter() + .find(|f| f.get_name() == "folio_http_requests_total") + .map(|f| f.get_metric().iter().map(|m| m.get_counter().get_value()).sum()) + .unwrap_or(0.0); + + // Error rate (interval-relative delta, not lifetime cumulative) + let error_total: f64 = families.iter() + .find(|f| f.get_name() == "folio_http_requests_total") + .map(|f| f.get_metric().iter() + .filter(|m| m.get_label().iter() + .any(|l| l.get_name() == "status" && (l.get_value().starts_with('5') || l.get_value().starts_with('4')))) + .map(|m| m.get_counter().get_value()).sum()) + .unwrap_or(0.0); + + let rps; + let error_pct; + { + let mut prev_http = state.console.prev_http_total.lock().await; + let mut prev_err = state.console.prev_error_total.lock().await; + let http_delta = http_total - *prev_http; + let error_delta = error_total - *prev_err; + rps = http_delta / 5.0; + error_pct = if http_delta > 0.0 { (error_delta / http_delta) * 100.0 } else { 0.0 }; + *prev_http = http_total; + *prev_err = error_total; + } // Concurrency let concurrency_max = state.config.concurrency as u32; @@ -457,12 +475,15 @@ pub fn spawn_console_sampler(state: crate::state::AppState, started_at: Instant) // Detect engine restarts via health gauge transitions // chromium: health gauge 1.0 = up, 0.0 = down - let chromium_now_running = state.metrics.chromium_healthy.get() >= 1.0; - let chromium_was = state.console.chromium_was_running.load(Ordering::SeqCst); - if !chromium_was && chromium_now_running { - state.console.chromium_restarts.fetch_add(1, Ordering::SeqCst); + #[cfg(feature = "chromium")] + { + let chromium_now_running = state.chromium.is_some() && state.metrics.chromium_healthy.get() >= 1.0; + let chromium_was = state.console.chromium_was_running.load(Ordering::SeqCst); + if chromium_now_running && !chromium_was { + state.console.chromium_restarts.fetch_add(1, Ordering::SeqCst); + } + state.console.chromium_was_running.store(chromium_now_running, Ordering::SeqCst); } - state.console.chromium_was_running.store(chromium_now_running, Ordering::SeqCst); #[cfg(feature = "libreoffice")] { diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index c956efd..729f52b 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -143,8 +143,7 @@ async fn serve(args: ServerArgs) -> anyhow::Result<()> { { use server::console_store::spawn_console_sampler; - let console_started_at = std::time::Instant::now(); - spawn_console_sampler(state.clone(), console_started_at); + spawn_console_sampler(state.clone(), state.started_at); } let router = build_router(state, &config); From 0dcade93382b16fa458a5b4f6ba56cb4299f1ca2 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 13:52:45 +0530 Subject: [PATCH 10/93] feat(console): add SSE stream and one-shot metrics JSON endpoints Mount /_/api/stream (SSE) and /_/api/metrics (JSON) in the untimed router; add futures-util dependency to the server crate. Co-Authored-By: Claude Sonnet 4.6 --- crates/server/Cargo.toml | 1 + crates/server/src/app.rs | 6 ++++ crates/server/src/routes/console.rs | 47 +++++++++++++++++++++++++++++ crates/server/src/routes/mod.rs | 1 + 4 files changed, 55 insertions(+) create mode 100644 crates/server/src/routes/console.rs diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index b7898e6..7ea263e 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -19,6 +19,7 @@ engine = { workspace = true } # Async / runtime tokio = { workspace = true } +futures-util = { workspace = true } async-trait = { workspace = true } # HTTP server diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 2b51864..ed970eb 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -271,6 +271,12 @@ pub fn build_router(state: AppState, config: &ServerConfig) -> Router { use crate::routes::openapi; untimed = untimed.route("/openapi.json", get(openapi::openapi_spec)); + // Operator console SSE stream and one-shot metrics JSON (long-lived, no timeout) + use crate::routes::console; + untimed = untimed + .route("/_/api/stream", get(console::console_stream)) + .route("/_/api/metrics", get(console::console_metrics_json)); + // Scalar interactive API documentation use axum::response::Html; use scalar_api_reference::scalar_html_default; diff --git a/crates/server/src/routes/console.rs b/crates/server/src/routes/console.rs new file mode 100644 index 0000000..ff12e88 --- /dev/null +++ b/crates/server/src/routes/console.rs @@ -0,0 +1,47 @@ +// crates/server/src/routes/console.rs +use std::convert::Infallible; + +use axum::Json; +use axum::extract::State; +use axum::response::sse::{Event, KeepAlive, Sse}; +use futures_util::stream::{self, Stream, StreamExt}; +use tokio::sync::broadcast::error::RecvError; + +use crate::console_store::{build_console_payload, ConsolePayload}; +use crate::state::AppState; + +/// SSE endpoint — streams ConsolePayload events to all connected browsers. +pub async fn console_stream( + State(state): State, +) -> Sse>> { + let started_at = state.started_at; + let mut rx = state.console.broadcast.subscribe(); + + // Send initial snapshot immediately on connect (no waiting for next 5s tick) + let initial = build_console_payload(&state, started_at).await; + let initial_json = serde_json::to_string(&initial).unwrap_or_default(); + let initial_stream = stream::once(async move { + Ok::(Event::default().data(initial_json)) + }); + + let broadcast_stream = stream::unfold(rx, |mut rx| async move { + loop { + match rx.recv().await { + Ok(payload) => return Some((Ok(Event::default().data(payload)), rx)), + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => return None, + } + } + }); + + Sse::new(initial_stream.chain(broadcast_stream)) + .keep_alive(KeepAlive::new().interval(std::time::Duration::from_secs(15)).text("ping")) +} + +/// One-shot JSON snapshot — same payload as SSE events, useful for curl/debug. +pub async fn console_metrics_json( + State(state): State, +) -> Json { + let started_at = state.started_at; + Json(build_console_payload(&state, started_at).await) +} diff --git a/crates/server/src/routes/mod.rs b/crates/server/src/routes/mod.rs index d78daeb..f75732c 100644 --- a/crates/server/src/routes/mod.rs +++ b/crates/server/src/routes/mod.rs @@ -5,6 +5,7 @@ pub mod batch_state; pub mod batch_types; #[cfg(feature = "chromium")] pub mod chromium; +pub mod console; pub mod debug; pub mod estimate; pub mod health; From 6a5897e29939f8453a921537ecfb667b48a1b216 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 13:56:31 +0530 Subject: [PATCH 11/93] feat(console): add request log middleware feeding ConsoleStore ring buffer Co-Authored-By: Claude Sonnet 4.6 --- crates/server/src/app.rs | 41 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index ed970eb..41279dc 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -10,9 +10,10 @@ use std::time::Duration; use axum::Router; use axum::error_handling::HandleErrorLayer; -use axum::extract::DefaultBodyLimit; +use axum::extract::{DefaultBodyLimit, State}; use axum::http::{HeaderName, Request}; -use axum::response::IntoResponse; +use axum::middleware::{self, Next}; +use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use engine::EngineError; use tower::BoxError; @@ -310,6 +311,9 @@ pub fn build_router(state: AppState, config: &ServerConfig) -> Router { ) .expect("api_correlation_id_header was validated in ServerConfig::resolve"); + // Keep a clone for the console log middleware (state is moved into with_state below). + let state_for_console = state.clone(); + let mut router = Router::new() .merge(timed) .merge(untimed) @@ -340,6 +344,11 @@ pub fn build_router(state: AppState, config: &ServerConfig) -> Router { ))); } + // Console log middleware — outermost layer so it captures every request + // (including auth-rejected ones) and records them into the ConsoleStore + // ring buffer. Added last so it wraps the full stack. + router = router.layer(middleware::from_fn_with_state(state_for_console, console_log_middleware)); + router } @@ -349,6 +358,34 @@ pub fn default_request_timeout() -> Duration { Duration::from_secs(120) } +/// Middleware that records every non-console HTTP request into the +/// [`ConsoleStore`] ring buffer for the operator console UI. +/// +/// Requests to `/_/` (the console API itself) are skipped to avoid noise. +async fn console_log_middleware( + State(state): State, + req: axum::extract::Request, + next: Next, +) -> Response { + use std::time::Instant; + + let method = req.method().to_string(); + let path = req.uri().path().to_string(); + + // Skip the console routes themselves to avoid noise. + if path.starts_with("/_/") { + return next.run(req).await; + } + + let start = Instant::now(); + let response = next.run(req).await; + let duration_ms = start.elapsed().as_millis() as u64; + let status = response.status().as_u16(); + + state.console.record_request(method, path, status, duration_ms).await; + response +} + /// Maps `tower::timeout::error::Elapsed` (and any other boxed error /// raised by middleware) into the documented JSON shape. async fn handle_timeout_error(err: BoxError) -> impl IntoResponse { From fd714ec404b62e7adccfb7af92b071464fe75fed Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 14:00:17 +0530 Subject: [PATCH 12/93] feat(console): serve embedded Svelte SPA at /_/ via rust-embed Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 4 +++ crates/server/Cargo.toml | 4 +++ crates/server/src/app.rs | 4 ++- crates/server/src/routes/console.rs | 39 +++++++++++++++++++++++++++++ ui/.gitignore | 3 ++- ui/build/.gitkeep | 0 6 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 ui/build/.gitkeep diff --git a/Cargo.lock b/Cargo.lock index c23b0c5..7c728d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3049,6 +3049,7 @@ version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ + "mime_guess", "sha2", "walkdir", ] @@ -3377,10 +3378,12 @@ dependencies = [ "clap", "cucumber", "engine", + "futures-util", "http 1.4.0", "humantime", "hyper 0.14.32", "lopdf", + "mime_guess", "multer", "once_cell", "opentelemetry", @@ -3390,6 +3393,7 @@ dependencies = [ "pulldown-cmark", "regex", "reqwest 0.12.28", + "rust-embed", "scalar_api_reference", "serde", "serde_json", diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 7ea263e..ff0788f 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -80,6 +80,10 @@ once_cell = "1.21.4" # Scalar API documentation (interactive docs) scalar_api_reference = { version = "0.1", features = ["axum"] } +# Operator console static asset embedding +rust-embed = { version = "8", features = ["mime-guess"] } +mime_guess = "2" + [dev-dependencies] tower = { workspace = true, features = ["util"] } reqwest = { workspace = true } diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 41279dc..ac95fc0 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -276,7 +276,9 @@ pub fn build_router(state: AppState, config: &ServerConfig) -> Router { use crate::routes::console; untimed = untimed .route("/_/api/stream", get(console::console_stream)) - .route("/_/api/metrics", get(console::console_metrics_json)); + .route("/_/api/metrics", get(console::console_metrics_json)) + .route("/_/", get(console::console_asset_root)) + .route("/_/{*path}", get(console::console_asset)); // Scalar interactive API documentation use axum::response::Html; diff --git a/crates/server/src/routes/console.rs b/crates/server/src/routes/console.rs index ff12e88..bc5df0b 100644 --- a/crates/server/src/routes/console.rs +++ b/crates/server/src/routes/console.rs @@ -45,3 +45,42 @@ pub async fn console_metrics_json( let started_at = state.started_at; Json(build_console_payload(&state, started_at).await) } + +use axum::body::Body; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::IntoResponse; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "../../ui/build/"] +struct ConsoleAssets; + +/// Serves the embedded Svelte SPA. +pub async fn console_asset( + axum::extract::Path(path): axum::extract::Path, +) -> axum::response::Response { + serve_asset(&path) +} + +pub async fn console_asset_root() -> axum::response::Response { + serve_asset("index.html") +} + +fn serve_asset(path: &str) -> axum::response::Response { + let path = path.trim_start_matches('/'); + let asset = ConsoleAssets::get(path) + .or_else(|| ConsoleAssets::get("index.html")); + + match asset { + Some(content) => { + let mime = mime_guess::from_path(path).first_or_octet_stream(); + let body = Body::from(content.data.into_owned()); + ( + StatusCode::OK, + [(header::CONTENT_TYPE, HeaderValue::from_str(mime.as_ref()).unwrap())], + body, + ).into_response() + } + None => StatusCode::NOT_FOUND.into_response(), + } +} diff --git a/ui/.gitignore b/ui/.gitignore index 3b462cb..2e35831 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -6,7 +6,8 @@ node_modules .netlify .wrangler /.svelte-kit -/build +build/* +!build/.gitkeep # OS .DS_Store diff --git a/ui/build/.gitkeep b/ui/build/.gitkeep new file mode 100644 index 0000000..e69de29 From 36b9c73e586ebd4335403e9b3f432cdb1340ecec Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 14:04:35 +0530 Subject: [PATCH 13/93] fix(console): replace HeaderValue unwrap with safe fallback in serve_asset --- crates/server/src/routes/console.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/server/src/routes/console.rs b/crates/server/src/routes/console.rs index bc5df0b..cab3e6a 100644 --- a/crates/server/src/routes/console.rs +++ b/crates/server/src/routes/console.rs @@ -74,12 +74,10 @@ fn serve_asset(path: &str) -> axum::response::Response { match asset { Some(content) => { let mime = mime_guess::from_path(path).first_or_octet_stream(); + let ct = HeaderValue::from_str(mime.as_ref()) + .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")); let body = Body::from(content.data.into_owned()); - ( - StatusCode::OK, - [(header::CONTENT_TYPE, HeaderValue::from_str(mime.as_ref()).unwrap())], - body, - ).into_response() + (StatusCode::OK, [(header::CONTENT_TYPE, ct)], body).into_response() } None => StatusCode::NOT_FOUND.into_response(), } From 1851e1bc43003fc0c0f33a73dd338e495194445a Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 14:05:33 +0530 Subject: [PATCH 14/93] feat(console): add ui-build Makefile target and ui-builder Docker stage --- Dockerfile | 13 +++++++++++++ Makefile | 11 +++++++++++ 2 files changed, 24 insertions(+) diff --git a/Dockerfile b/Dockerfile index 4156421..e43bc4c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,16 @@ ARG FOLIO_USER_GID=1001 # See: https://snapshot.debian.org/package/chromium/ ARG CHROMIUM_VERSION=142.0.7444.175-1 +# ============================================================================= +# Stage: ui-builder — builds the operator console SPA +# ============================================================================= +FROM node:22-slim AS ui-builder +WORKDIR /ui +COPY ui/package*.json ./ +RUN npm install +COPY ui/ ./ +RUN npm run build + # ============================================================================= # Stage: chef — installs cargo-chef for dependency caching # ============================================================================= @@ -29,6 +39,7 @@ WORKDIR /app COPY --link --from=planner /app/recipe.json recipe.json RUN cargo chef cook --release --recipe-path recipe.json --features "chromium libreoffice" COPY --link . . +COPY --link --from=ui-builder /ui/build /app/ui/build RUN cargo build --release --features "chromium libreoffice" && \ strip target/release/folio-server && \ strip target/release/folio @@ -41,6 +52,7 @@ WORKDIR /app COPY --link --from=planner /app/recipe.json recipe.json RUN cargo chef cook --release --recipe-path recipe.json --no-default-features --features chromium COPY --link . . +COPY --link --from=ui-builder /ui/build /app/ui/build RUN cargo build --release --no-default-features --features chromium && \ strip target/release/folio-server && \ strip target/release/folio @@ -53,6 +65,7 @@ WORKDIR /app COPY --link --from=planner /app/recipe.json recipe.json RUN cargo chef cook --release --recipe-path recipe.json --no-default-features --features libreoffice COPY --link . . +COPY --link --from=ui-builder /ui/build /app/ui/build RUN cargo build --release --no-default-features --features libreoffice && \ strip target/release/folio-server && \ strip target/release/folio diff --git a/Makefile b/Makefile index ed9fb02..f3dca17 100644 --- a/Makefile +++ b/Makefile @@ -99,6 +99,17 @@ version: ## Check Folio version build-release: ## Build release binaries cargo build --release +.PHONY: ui-build +ui-build: ## Build the operator console UI (requires Node in ui/) + cd ui && npm run build + +.PHONY: ui-dev +ui-dev: ## Start UI dev server with hot reload (run alongside folio-server) + cd ui && npm run dev + +.PHONY: build-with-ui +build-with-ui: ui-build build-release ## Build UI then Rust binary (for local testing) + .PHONY: docker-test docker-test: ## Run tests inside Docker container (with Chrome + LibreOffice) docker build -t folio-test -f Dockerfile . From d3dca28a9602eef616d918fdcbfb255b333bcdca Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 14:07:17 +0530 Subject: [PATCH 15/93] fix(console): include bun.lock in ui-builder Docker COPY for layer cache correctness --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index e43bc4c..e5d40bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ ARG CHROMIUM_VERSION=142.0.7444.175-1 # ============================================================================= FROM node:22-slim AS ui-builder WORKDIR /ui -COPY ui/package*.json ./ +COPY ui/package*.json ui/bun.lock* ./ RUN npm install COPY ui/ ./ RUN npm run build From 3c2a1116811d4f7f6e47fe4263372c6f6fa90f47 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 14:08:43 +0530 Subject: [PATCH 16/93] feat(console-ui): configure base path, remap chart colors, install shadcn chart Co-Authored-By: Claude Sonnet 4.6 --- ui/build/.gitkeep | 0 ui/bun.lock | 111 +++++++++++ ui/package.json | 1 + .../ui/chart/chart-container.svelte | 80 ++++++++ .../components/ui/chart/chart-style.svelte | 37 ++++ .../components/ui/chart/chart-tooltip.svelte | 184 ++++++++++++++++++ ui/src/lib/components/ui/chart/chart-utils.ts | 68 +++++++ ui/src/lib/components/ui/chart/index.ts | 6 + ui/src/routes/layout.css | 20 +- ui/svelte.config.js | 14 +- 10 files changed, 505 insertions(+), 16 deletions(-) delete mode 100644 ui/build/.gitkeep create mode 100644 ui/src/lib/components/ui/chart/chart-container.svelte create mode 100644 ui/src/lib/components/ui/chart/chart-style.svelte create mode 100644 ui/src/lib/components/ui/chart/chart-tooltip.svelte create mode 100644 ui/src/lib/components/ui/chart/chart-utils.ts create mode 100644 ui/src/lib/components/ui/chart/index.ts diff --git a/ui/build/.gitkeep b/ui/build/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/ui/bun.lock b/ui/bun.lock index de7851e..4563a3c 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -21,6 +21,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.17.0", "globals": "^17.4.0", + "layerchart": "2.0.0-next.48", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.5.1", "prettier-plugin-tailwindcss": "^0.7.2", @@ -38,6 +39,10 @@ }, }, "packages": { + "@dagrejs/dagre": ["@dagrejs/dagre@2.0.4", "", { "dependencies": { "@dagrejs/graphlib": "3.0.4" } }, "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA=="], + + "@dagrejs/graphlib": ["@dagrejs/graphlib@3.0.4", "", {}, "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg=="], + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], @@ -62,6 +67,12 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], @@ -84,6 +95,14 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@layerstack/svelte-actions": ["@layerstack/svelte-actions@1.0.1-next.18", "", { "dependencies": { "@floating-ui/dom": "^1.7.0", "@layerstack/utils": "2.0.0-next.18", "d3-scale": "^4.0.2" } }, "sha512-gxPzCnJ1c9LTfWtRqLUzefCx+k59ZpxDUQ2XB+LokveZQPe7IDSOwHaBOEMlaGoGrtwc3Ft8dSZq+2WT2o9u/g=="], + + "@layerstack/svelte-state": ["@layerstack/svelte-state@0.1.0-next.23", "", { "dependencies": { "@layerstack/utils": "2.0.0-next.18" } }, "sha512-7O4umv+gXwFfs3/vjzFWYHNXGwYnnjBapWJ5Y+9u99F4eVk6rh4ocNwqkqQNkpMZ5tUJBlRTWjPE1So6+hEzIg=="], + + "@layerstack/tailwind": ["@layerstack/tailwind@2.0.0-next.21", "", { "dependencies": { "@layerstack/utils": "^2.0.0-next.18", "clsx": "^2.1.1", "d3-array": "^3.2.4", "tailwind-merge": "^3.2.0" } }, "sha512-Qgp2EpmEHmjtura8MQzWicR6ztBRSsRvddakFtx9ShrLMz6jWzd6bCMVVRu44Q3ZOrtXmSu4QxjCZWu1ytvuPg=="], + + "@layerstack/utils": ["@layerstack/utils@2.0.0-next.18", "", { "dependencies": { "d3-array": "^3.2.4", "d3-time": "^3.1.0", "d3-time-format": "^4.1.0" } }, "sha512-EYILHpfBRYMMEahajInu9C2AXQom5IcAEdtCeucD3QIl/fdDgRbtzn6/8QW9ewumfyNZetdUvitOksmI1+gZYQ=="], + "@lucide/svelte": ["@lucide/svelte@1.14.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-MVuP5VRCBQa2OaIpaRbuEV4k5OV2dy9MyxA6Tf4Sz/IN0v3zzUU72ObHc9r2Zn/wSMXDg6lLrHrczqI7w7gCzQ=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], @@ -172,10 +191,16 @@ "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/node": ["@types/node@20.19.39", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw=="], @@ -228,12 +253,68 @@ "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], + + "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], + + "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], + + "d3-geo-voronoi": ["d3-geo-voronoi@2.1.0", "", { "dependencies": { "d3-array": "3", "d3-delaunay": "6", "d3-geo": "3", "d3-tricontour": "1" } }, "sha512-kqE4yYuOjPbKdBXG0xztCacPwkVSK2REF1opSNrnqqtXJmNcM++UbwQ8SxvwP6IQTj9RvIjjK4qeiVsEfj0Z2Q=="], + + "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-interpolate-path": ["d3-interpolate-path@2.3.0", "", {}, "sha512-tZYtGXxBmbgHsIc9Wms6LS5u4w6KbP8C09a4/ZYc4KLMYYqub57rRBUgpUr2CIarIrJEpdAWWxWQvofgaMpbKQ=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], + + "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], + + "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-tile": ["d3-tile@1.0.0", "", {}, "sha512-79fnTKpPMPDS5xQ0xuS9ir0165NEwwkFpe/DSOmc2Gl9ldYzKKRDWogmTTE8wAJ8NA7PMapNfEcyKhI9Lxdu5Q=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-tricontour": ["d3-tricontour@1.1.0", "", { "dependencies": { "d3-delaunay": "6", "d3-scale": "4" } }, "sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "devalue": ["devalue@5.8.0", "", {}, "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg=="], @@ -290,10 +371,14 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], @@ -316,6 +401,8 @@ "known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="], + "layerchart": ["layerchart@2.0.0-next.48", "", { "dependencies": { "@dagrejs/dagre": "^2.0.4", "@layerstack/svelte-actions": "1.0.1-next.18", "@layerstack/svelte-state": "0.1.0-next.23", "@layerstack/tailwind": "2.0.0-next.21", "@layerstack/utils": "2.0.0-next.18", "@types/d3-contour": "^3.0.6", "d3-array": "^3.2.4", "d3-chord": "^3.0.1", "d3-color": "^3.1.0", "d3-contour": "^4.0.2", "d3-delaunay": "^6.0.4", "d3-dsv": "^3.0.1", "d3-force": "^3.0.0", "d3-geo": "^3.1.1", "d3-geo-voronoi": "^2.1.0", "d3-hierarchy": "^3.1.2", "d3-interpolate": "^3.0.1", "d3-interpolate-path": "^2.3.0", "d3-path": "^3.1.0", "d3-quadtree": "^3.0.1", "d3-random": "^3.0.1", "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.1.0", "d3-shape": "^3.2.0", "d3-tile": "^1.0.0", "d3-time": "^3.1.0", "memoize": "^10.2.0", "runed": "^0.37.1" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-XoEYBztamA8lMxtF/Jz3aDX0HMk8dI+o4fK9fSl8ecT2Tdx3DQUjtKGtlQAOFdwC/AWifeLmKq5cMTQt9COZPQ=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -348,8 +435,14 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "memoize": ["memoize@10.2.0", "", { "dependencies": { "mimic-function": "^5.0.1" } }, "sha512-DeC6b7QBrZsRs3Y02A6A7lQyzFbsQbqgjI6UW0GigGWV+u1s25TycMr0XHZE4cJce7rY/vyw2ctMQqfDkIhUEA=="], + + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "mini-svg-data-uri": ["mini-svg-data-uri@1.4.4", "", { "bin": { "mini-svg-data-uri": "cli.js" } }, "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="], "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -404,10 +497,18 @@ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], + "rolldown": ["rolldown@1.0.0-rc.17", "", { "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="], + "runed": ["runed@0.37.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0", "zod": "^4.1.0" }, "optionalPeers": ["@sveltejs/kit", "zod"] }, "sha512-MeFY73xBW8IueWBm012nNFIGy19WUGPLtknavyUPMpnyt350M47PhGSGrGoSLbidwn+Zlt/O0cp8/OZE3LASWA=="], + + "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], @@ -488,6 +589,12 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + + "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], "svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], @@ -497,5 +604,9 @@ "svelte-eslint-parser/espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], "svelte-eslint-parser/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], + + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], } } diff --git a/ui/package.json b/ui/package.json index 625b360..bef72e6 100644 --- a/ui/package.json +++ b/ui/package.json @@ -30,6 +30,7 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.17.0", "globals": "^17.4.0", + "layerchart": "2.0.0-next.48", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.5.1", "prettier-plugin-tailwindcss": "^0.7.2", diff --git a/ui/src/lib/components/ui/chart/chart-container.svelte b/ui/src/lib/components/ui/chart/chart-container.svelte new file mode 100644 index 0000000..1eb8e39 --- /dev/null +++ b/ui/src/lib/components/ui/chart/chart-container.svelte @@ -0,0 +1,80 @@ + + +
+ + {@render children?.()} +
diff --git a/ui/src/lib/components/ui/chart/chart-style.svelte b/ui/src/lib/components/ui/chart/chart-style.svelte new file mode 100644 index 0000000..ea1b3b2 --- /dev/null +++ b/ui/src/lib/components/ui/chart/chart-style.svelte @@ -0,0 +1,37 @@ + + +{#if themeContents} + {#key id} + + {themeContents} + + {/key} +{/if} diff --git a/ui/src/lib/components/ui/chart/chart-tooltip.svelte b/ui/src/lib/components/ui/chart/chart-tooltip.svelte new file mode 100644 index 0000000..e0da9ca --- /dev/null +++ b/ui/src/lib/components/ui/chart/chart-tooltip.svelte @@ -0,0 +1,184 @@ + + +{#snippet TooltipLabel()} + {#if formattedLabel} +
+ {#if typeof formattedLabel === "function"} + {@render formattedLabel()} + {:else} + {formattedLabel} + {/if} +
+ {/if} +{/snippet} + + +
+ {#if !nestLabel} + {@render TooltipLabel()} + {/if} +
+ {#each visibleSeries as item, i (item.key + i)} + {@const key = `${nameKey || item.key || item.label || "value"}`} + {@const itemConfig = getPayloadConfigFromPayload( + chart.config, + item, + key, + chartCtx.tooltip.data + )} + {@const indicatorColor = color || item.config?.color || item.color} +
svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5", + indicator === "dot" && "items-center" + )} + > + {#if formatter && item.value !== undefined && item.label} + {@render formatter({ + value: item.value, + name: item.label, + item, + index: i, + payload: visibleSeries, + })} + {:else} + {#if itemConfig?.icon} + + {:else if !hideIndicator} +
+ {/if} +
+
+ {#if nestLabel} + {@render TooltipLabel()} + {/if} + + {itemConfig?.label || item.label} + +
+ {#if item.value !== undefined} + + {item.value.toLocaleString()} + + {/if} +
+ {/if} +
+ {/each} +
+
+
diff --git a/ui/src/lib/components/ui/chart/chart-utils.ts b/ui/src/lib/components/ui/chart/chart-utils.ts new file mode 100644 index 0000000..a289e35 --- /dev/null +++ b/ui/src/lib/components/ui/chart/chart-utils.ts @@ -0,0 +1,68 @@ +import type { Tooltip } from "layerchart"; +import { getContext, setContext, type Component, type Snippet } from "svelte"; + +export const THEMES = { light: "", dark: ".dark" } as const; + +export type ChartConfig = { + [k in string]: { + label?: string; + icon?: Component; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ); +}; + +export type ExtractSnippetParams = T extends Snippet<[infer P]> ? P : never; + +export type TooltipPayload = Tooltip.TooltipSeries; + +// Helper to extract item config from a payload. +export function getPayloadConfigFromPayload( + config: ChartConfig, + payload: TooltipPayload, + key: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?: Record | null +) { + if (typeof payload !== "object" || payload === null) return undefined; + + const payloadConfig = + "config" in payload && typeof payload.config === "object" && payload.config !== null + ? payload.config + : undefined; + + let configLabelKey: string = key; + + if (payload.key === key) { + configLabelKey = payload.key; + } else if (payload.label === key) { + configLabelKey = payload.label; + } else if (key in payload && typeof payload[key as keyof typeof payload] === "string") { + configLabelKey = payload[key as keyof typeof payload] as string; + } else if ( + payloadConfig !== undefined && + key in payloadConfig && + typeof payloadConfig[key as keyof typeof payloadConfig] === "string" + ) { + configLabelKey = payloadConfig[key as keyof typeof payloadConfig] as string; + } else if (data != null && key in data && typeof data[key] === "string") { + configLabelKey = data[key] as string; + } + + return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]; +} + +type ChartContextValue = { + config: ChartConfig; +}; + +const chartContextKey = Symbol("chart-context"); + +export function setChartContext(value: ChartContextValue) { + return setContext(chartContextKey, value); +} + +export function useChart() { + return getContext(chartContextKey); +} diff --git a/ui/src/lib/components/ui/chart/index.ts b/ui/src/lib/components/ui/chart/index.ts new file mode 100644 index 0000000..f22375e --- /dev/null +++ b/ui/src/lib/components/ui/chart/index.ts @@ -0,0 +1,6 @@ +import ChartContainer from "./chart-container.svelte"; +import ChartTooltip from "./chart-tooltip.svelte"; + +export { getPayloadConfigFromPayload, type ChartConfig } from "./chart-utils.js"; + +export { ChartContainer, ChartTooltip, ChartContainer as Container, ChartTooltip as Tooltip }; diff --git a/ui/src/routes/layout.css b/ui/src/routes/layout.css index f623209..4eaf59d 100644 --- a/ui/src/routes/layout.css +++ b/ui/src/routes/layout.css @@ -26,11 +26,11 @@ --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); + --chart-1: #4f8ef7; /* accent blue */ + --chart-2: #2f9967; /* ok green */ + --chart-3: #b8860b; /* warn amber */ + --chart-4: #c25151; /* err red */ + --chart-5: rgba(26,28,31,0.4); /* muted */ --radius: 0.625rem; --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); @@ -61,11 +61,11 @@ --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); + --chart-1: #6aa3f8; + --chart-2: #3fb27f; + --chart-3: #e0a93c; + --chart-4: #e26464; + --chart-5: rgba(230,231,234,0.4); --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); diff --git a/ui/svelte.config.js b/ui/svelte.config.js index 5bbc157..ddeb29e 100644 --- a/ui/svelte.config.js +++ b/ui/svelte.config.js @@ -1,12 +1,14 @@ +// ui/svelte.config.js import adapter from '@sveltejs/adapter-static'; -/** @type {import('@sveltejs/kit').Config} */ const config = { - compilerOptions: { - // Force runes mode for the project, except for libraries. Can be removed in svelte 6. - runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true) - }, - kit: { adapter: adapter() } + compilerOptions: { + runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true) + }, + kit: { + adapter: adapter({ fallback: 'index.html' }), + paths: { base: '/_' }, + } }; export default config; From 005bcc112465595829ce94ed896b839fb05e12c2 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 14:13:17 +0530 Subject: [PATCH 17/93] feat(console-ui): add ConsolePayload types, SSE store, and theme store Co-Authored-By: Claude Sonnet 4.6 --- ui/src/lib/metrics.svelte.ts | 49 +++++++++++++++++ ui/src/lib/theme.svelte.ts | 44 +++++++++++++++ ui/src/lib/types.ts | 101 +++++++++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 ui/src/lib/metrics.svelte.ts create mode 100644 ui/src/lib/theme.svelte.ts create mode 100644 ui/src/lib/types.ts diff --git a/ui/src/lib/metrics.svelte.ts b/ui/src/lib/metrics.svelte.ts new file mode 100644 index 0000000..431ea81 --- /dev/null +++ b/ui/src/lib/metrics.svelte.ts @@ -0,0 +1,49 @@ +// src/lib/metrics.svelte.ts +import type { ConsolePayload } from './types'; +import { base } from '$app/paths'; + +export let data = $state(null); +export let loading = $state(true); +export let connected = $state(false); +export let error = $state(null); +export let lastRefreshed = $state(null); + +let es: EventSource | null = null; + +export function startSSE() { + if (es) return; + es = new EventSource(`${base}/api/stream`); + + es.onopen = () => { + connected = true; + error = null; + }; + + es.onmessage = (event: MessageEvent) => { + try { + data = JSON.parse(event.data) as ConsolePayload; + lastRefreshed = new Date(); + error = null; + } catch { + error = 'Failed to parse server data'; + } finally { + loading = false; + } + }; + + es.onerror = () => { + connected = false; + error = 'Connection lost — reconnecting…'; + }; +} + +export function stopSSE() { + es?.close(); + es = null; + connected = false; +} + +export function manualRefresh() { + stopSSE(); + startSSE(); +} diff --git a/ui/src/lib/theme.svelte.ts b/ui/src/lib/theme.svelte.ts new file mode 100644 index 0000000..618f1dc --- /dev/null +++ b/ui/src/lib/theme.svelte.ts @@ -0,0 +1,44 @@ +// src/lib/theme.svelte.ts +export let dark = $state(false); +export let accent = $state('#4f8ef7'); +export let density = $state<'compact' | 'regular' | 'comfy'>('regular'); + +export type ThemeTokens = { + bg: string; surface: string; ink: string; muted: string; + faint: string; rule: string; ok: string; warn: string; err: string; accent: string; +}; + +export let theme = $derived({ + bg: dark ? '#0e0f12' : '#f7f7f5', + surface: dark ? '#15171c' : '#ffffff', + ink: dark ? '#e6e7ea' : '#1a1c1f', + muted: dark ? 'rgba(230,231,234,0.55)' : 'rgba(26,28,31,0.55)', + faint: dark ? 'rgba(230,231,234,0.10)' : 'rgba(26,28,31,0.06)', + rule: dark ? 'rgba(255,255,255,0.08)' : 'rgba(26,28,31,0.08)', + ok: dark ? '#3fb27f' : '#2f9967', + warn: dark ? '#e0a93c' : '#b8860b', + err: dark ? '#e26464' : '#c25151', + accent, +}); + +export let D = $derived( + density === 'compact' + ? { gap: 8, pad: 8, rowPy: 2, fz: 10.5, kpiFz: 18 } + : density === 'comfy' + ? { gap: 14, pad: 14, rowPy: 5, fz: 12, kpiFz: 22 } + : { gap: 10, pad: 10, rowPy: 3, fz: 11.5, kpiFz: 20 } +); + +// Persist dark mode in localStorage (client-side only) +if (typeof window !== 'undefined') { + dark = localStorage.getItem('folio-dark') === 'true'; +} + +$effect.root(() => { + $effect(() => { + if (typeof window !== 'undefined') { + localStorage.setItem('folio-dark', String(dark)); + document.documentElement.classList.toggle('dark', dark); + } + }); +}); diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts new file mode 100644 index 0000000..90431f5 --- /dev/null +++ b/ui/src/lib/types.ts @@ -0,0 +1,101 @@ +// src/lib/types.ts +export interface MetricsSample { + ts: number; + rps: number; + p95_ms: number; + error_pct: number; + queue_size: number; + concurrency_active: number; + cpu_pct: number; + memory_mb: number; +} + +export interface TickerPayload { + rps: number; + p95_ms: number; + error_pct: number; + concurrency_active: number; + concurrency_max: number; + chromium_status: string; + chromium_restarts: number; + libreoffice_status: string; + libreoffice_restarts: number; + queue_size: number; + uptime_seconds: number; +} + +export interface RoutePayload { + path: string; + method: string; + rps: number; + p50_ms: number; + p95_ms: number; + p99_ms: number; + error_pct: number; + in_flight: number; + load_pct: number; +} + +export interface EnginePayload { + name: string; + status: string; + restarts: number; + mode: string; + mini_series: number[]; +} + +export interface ConcurrencyPayload { + active: number; + max: number; + warn_threshold: number; + crit_threshold: number; +} + +export interface ResourcesPayload { + cpu_series: number[]; + memory_series: number[]; + memory_max_mb: number; +} + +export interface ThroughputPayload { + rps_series: number[]; + rps_baseline: number; + p95_series: number[]; + p95_target_s: number; +} + +export interface BatchPayload { + id: string; + status: string; + progress_pct: number; + elapsed: string; +} + +export interface RequestLogEntry { + time: string; + method: string; + route: string; + status: number; + duration_ms: number; +} + +export interface ErrorLogEntry { + time: string; + route: string; + message: string; + request_id: string; +} + +export interface ConsolePayload { + version: string; + uptime_seconds: number; + ticker: TickerPayload; + routes: RoutePayload[]; + engines: EnginePayload[]; + concurrency: ConcurrencyPayload; + resources: ResourcesPayload; + throughput: ThroughputPayload; + batches: BatchPayload[]; + recent_requests: RequestLogEntry[]; + recent_errors: ErrorLogEntry[]; +} From d2d5ee649a471be4c95f7cd4ec34d124c1c00fae Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 14:14:00 +0530 Subject: [PATCH 18/93] fix(console-ui): hardcode SSE URL to avoid deprecated base import from $app/paths --- ui/src/lib/metrics.svelte.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/src/lib/metrics.svelte.ts b/ui/src/lib/metrics.svelte.ts index 431ea81..32c3077 100644 --- a/ui/src/lib/metrics.svelte.ts +++ b/ui/src/lib/metrics.svelte.ts @@ -1,6 +1,5 @@ // src/lib/metrics.svelte.ts import type { ConsolePayload } from './types'; -import { base } from '$app/paths'; export let data = $state(null); export let loading = $state(true); @@ -12,7 +11,7 @@ let es: EventSource | null = null; export function startSSE() { if (es) return; - es = new EventSource(`${base}/api/stream`); + es = new EventSource('/_/api/stream'); es.onopen = () => { connected = true; From 2d7c41651cd5e5b44d958c1e8978d4de89667aaf Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 14:15:50 +0530 Subject: [PATCH 19/93] fix(console-ui): add Theme type alias, clear loading on SSE error --- ui/src/lib/metrics.svelte.ts | 1 + ui/src/lib/theme.svelte.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/ui/src/lib/metrics.svelte.ts b/ui/src/lib/metrics.svelte.ts index 32c3077..13320a9 100644 --- a/ui/src/lib/metrics.svelte.ts +++ b/ui/src/lib/metrics.svelte.ts @@ -32,6 +32,7 @@ export function startSSE() { es.onerror = () => { connected = false; + loading = false; error = 'Connection lost — reconnecting…'; }; } diff --git a/ui/src/lib/theme.svelte.ts b/ui/src/lib/theme.svelte.ts index 618f1dc..a7550bc 100644 --- a/ui/src/lib/theme.svelte.ts +++ b/ui/src/lib/theme.svelte.ts @@ -7,6 +7,7 @@ export type ThemeTokens = { bg: string; surface: string; ink: string; muted: string; faint: string; rule: string; ok: string; warn: string; err: string; accent: string; }; +export type Theme = ThemeTokens; export let theme = $derived({ bg: dark ? '#0e0f12' : '#f7f7f5', From 5ec1cda0676f3b73bbc5ed2efc0d0210e5d69845 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 14:17:29 +0530 Subject: [PATCH 20/93] feat(console-ui): add Card, Pill, SlimBar shared primitives --- ui/src/lib/components/shared/Card.svelte | 22 ++++++++++++++++ ui/src/lib/components/shared/Pill.svelte | 28 +++++++++++++++++++++ ui/src/lib/components/shared/SlimBar.svelte | 11 ++++++++ 3 files changed, 61 insertions(+) create mode 100644 ui/src/lib/components/shared/Card.svelte create mode 100644 ui/src/lib/components/shared/Pill.svelte create mode 100644 ui/src/lib/components/shared/SlimBar.svelte diff --git a/ui/src/lib/components/shared/Card.svelte b/ui/src/lib/components/shared/Card.svelte new file mode 100644 index 0000000..3a5628e --- /dev/null +++ b/ui/src/lib/components/shared/Card.svelte @@ -0,0 +1,22 @@ + + + +
+ {#if title} +
+ {title} + {#if sub}{sub}{/if} +
+ {/if} + {@render children()} +
diff --git a/ui/src/lib/components/shared/Pill.svelte b/ui/src/lib/components/shared/Pill.svelte new file mode 100644 index 0000000..8d2048f --- /dev/null +++ b/ui/src/lib/components/shared/Pill.svelte @@ -0,0 +1,28 @@ + + + + + {@render children()} + diff --git a/ui/src/lib/components/shared/SlimBar.svelte b/ui/src/lib/components/shared/SlimBar.svelte new file mode 100644 index 0000000..329f192 --- /dev/null +++ b/ui/src/lib/components/shared/SlimBar.svelte @@ -0,0 +1,11 @@ + + + +
+
+
From a990b43cd54a60ed5d97ca7c8d9341c427ebdde1 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 14:18:02 +0530 Subject: [PATCH 21/93] feat(console-ui): add Header and Ticker components --- ui/src/lib/components/Header.svelte | 29 ++++++++++++++++++++++ ui/src/lib/components/Ticker.svelte | 38 +++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 ui/src/lib/components/Header.svelte create mode 100644 ui/src/lib/components/Ticker.svelte diff --git a/ui/src/lib/components/Header.svelte b/ui/src/lib/components/Header.svelte new file mode 100644 index 0000000..7779633 --- /dev/null +++ b/ui/src/lib/components/Header.svelte @@ -0,0 +1,29 @@ + + + +
+ Folio + v{data.version} + prod + ● {connected ? 'live' : 'disconnected'} + + {refreshed} + +
diff --git a/ui/src/lib/components/Ticker.svelte b/ui/src/lib/components/Ticker.svelte new file mode 100644 index 0000000..fafe514 --- /dev/null +++ b/ui/src/lib/components/Ticker.svelte @@ -0,0 +1,38 @@ + + + +
+ {#each items as item, i} + {@const color = t[item.tone as keyof Theme] as string ?? t.ink} +
+
{item.label}
+
{item.value}
+
+ {/each} +
From 630fc05befd5175e84345bed10ee6d8228096280 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 14:18:30 +0530 Subject: [PATCH 22/93] feat(console-ui): add RoutesTable component --- ui/src/lib/components/RoutesTable.svelte | 48 ++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 ui/src/lib/components/RoutesTable.svelte diff --git a/ui/src/lib/components/RoutesTable.svelte b/ui/src/lib/components/RoutesTable.svelte new file mode 100644 index 0000000..54bcb3e --- /dev/null +++ b/ui/src/lib/components/RoutesTable.svelte @@ -0,0 +1,48 @@ + + + + + {#if routes.length === 0} +
No route data yet
+ {:else} + + + + {#each ['Route','Method','RPS','p50','p95','p99','Err %','In-flight','Load'] as h, i} + + {/each} + + + + {#each sorted as r} + {@const p95tone = r.p95_ms > 10000 ? t.err : r.p95_ms > 5000 ? t.warn : t.ink} + + + + + + + + + + + + {/each} + +
{h}
{r.path}{r.method}{r.rps.toFixed(1)}{fmtMs(r.p50_ms)}{fmtMs(r.p95_ms)}{fmtMs(r.p99_ms)}{r.error_pct.toFixed(2)}{r.in_flight}
+ {/if} +
From 0aae392c276cd4637888b8714605fbb634c7e537 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 14:21:56 +0530 Subject: [PATCH 23/93] =?UTF-8?q?feat(console-ui):=20add=20side=20rail=20?= =?UTF-8?q?=E2=80=94=20Engines,=20Concurrency,=20Batches,=20Resources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/side-rail/Batches.svelte | 36 +++++++++++++++ .../components/side-rail/Concurrency.svelte | 39 ++++++++++++++++ .../lib/components/side-rail/Engines.svelte | 45 +++++++++++++++++++ .../lib/components/side-rail/Resources.svelte | 45 +++++++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 ui/src/lib/components/side-rail/Batches.svelte create mode 100644 ui/src/lib/components/side-rail/Concurrency.svelte create mode 100644 ui/src/lib/components/side-rail/Engines.svelte create mode 100644 ui/src/lib/components/side-rail/Resources.svelte diff --git a/ui/src/lib/components/side-rail/Batches.svelte b/ui/src/lib/components/side-rail/Batches.svelte new file mode 100644 index 0000000..d533fd7 --- /dev/null +++ b/ui/src/lib/components/side-rail/Batches.svelte @@ -0,0 +1,36 @@ + + + + + {#if batches.length === 0} +
No recent batches
+ {:else} + + + {#each batches as b, i} + + + + + + + {/each} + +
{b.id}{b.status.slice(0, 4)}{b.elapsed}
+ {/if} +
diff --git a/ui/src/lib/components/side-rail/Concurrency.svelte b/ui/src/lib/components/side-rail/Concurrency.svelte new file mode 100644 index 0000000..ae11a44 --- /dev/null +++ b/ui/src/lib/components/side-rail/Concurrency.svelte @@ -0,0 +1,39 @@ + + + + +
+
+
+ {conc.active} / {conc.max} +
+ {pct}% · {tone} +
+
+ {#each Array.from({ length: Math.min(conc.max, 64) }, (_, i) => i) as i} +
+ {/each} +
+
+ 0warn {conc.warn_threshold}crit {conc.crit_threshold}{conc.max} +
+
+
diff --git a/ui/src/lib/components/side-rail/Engines.svelte b/ui/src/lib/components/side-rail/Engines.svelte new file mode 100644 index 0000000..b3a4e93 --- /dev/null +++ b/ui/src/lib/components/side-rail/Engines.svelte @@ -0,0 +1,45 @@ + + + + +
+ {#each engines as e, i} +
+
+
+ {e.name} + {e.status} +
+
+ {e.restarts} restart{e.restarts !== 1 ? 's' : ''} · {e.mode} +
+
+ {#if e.mini_series.length > 0} +
+ {#each e.mini_series as v} + {@const barColor = engineTone(e) === 'ok' ? t.ok : engineTone(e) === 'warn' ? t.warn : t.err} +
+ {/each} +
+ {/if} +
+ {/each} + {#if engines.length === 0} +
No engines configured
+ {/if} +
+
diff --git a/ui/src/lib/components/side-rail/Resources.svelte b/ui/src/lib/components/side-rail/Resources.svelte new file mode 100644 index 0000000..18e2403 --- /dev/null +++ b/ui/src/lib/components/side-rail/Resources.svelte @@ -0,0 +1,45 @@ + + + + +
+ +
+
+ CPU + {lastCpu.toFixed(0)}% +
+
+ {#each resources.cpu_series as v} +
+ {/each} +
+
+
+ +
+
+ Memory + + {(lastMem / 1024).toFixed(2)} GB{resources.memory_max_mb > 0 ? ` / ${(resources.memory_max_mb / 1024).toFixed(0)} GB` : ''} + +
+
+ {#each resources.memory_series as v} +
+ {/each} +
+
+
+
From a602b7a7cc54a58ba9e243c8d065d9bcd8ccf411 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 14:26:11 +0530 Subject: [PATCH 24/93] =?UTF-8?q?fix:=20correct=20concurrency=20grid=20col?= =?UTF-8?q?umns=20(16=E2=86=9232)=20and=20engine=20status=20casing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- ui/src/lib/components/side-rail/Concurrency.svelte | 4 ++-- ui/src/lib/components/side-rail/Engines.svelte | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/lib/components/side-rail/Concurrency.svelte b/ui/src/lib/components/side-rail/Concurrency.svelte index ae11a44..17749f3 100644 --- a/ui/src/lib/components/side-rail/Concurrency.svelte +++ b/ui/src/lib/components/side-rail/Concurrency.svelte @@ -27,8 +27,8 @@ {pct}% · {tone} -
- {#each Array.from({ length: Math.min(conc.max, 64) }, (_, i) => i) as i} +
+ {#each Array.from({ length: conc.max }, (_, i) => i) as i}
{/each}
diff --git a/ui/src/lib/components/side-rail/Engines.svelte b/ui/src/lib/components/side-rail/Engines.svelte index b3a4e93..4ad9c90 100644 --- a/ui/src/lib/components/side-rail/Engines.svelte +++ b/ui/src/lib/components/side-rail/Engines.svelte @@ -22,7 +22,7 @@
{e.name} - {e.status} + {e.status.toUpperCase()}
{e.restarts} restart{e.restarts !== 1 ? 's' : ''} · {e.mode} From ea51a26cafe932d5bdfb3aaf61b8bea444e77b0a Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 14:27:23 +0530 Subject: [PATCH 25/93] feat: add ThroughputStrip and ActivityStrip components CSS-based bar charts for RPS/p95 sparklines with reference lines; log tables for request and error activity. Co-Authored-By: Claude Sonnet 4.6 --- ui/src/lib/components/ActivityStrip.svelte | 53 +++++++++++++++++++ ui/src/lib/components/ThroughputStrip.svelte | 55 ++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 ui/src/lib/components/ActivityStrip.svelte create mode 100644 ui/src/lib/components/ThroughputStrip.svelte diff --git a/ui/src/lib/components/ActivityStrip.svelte b/ui/src/lib/components/ActivityStrip.svelte new file mode 100644 index 0000000..c4e76bb --- /dev/null +++ b/ui/src/lib/components/ActivityStrip.svelte @@ -0,0 +1,53 @@ + + + +
+ +
+ {#each requests as r, i} +
+ {r.time} + {r.method} + {r.route} + {r.status} + {r.duration_ms}ms +
+ {/each} + {#if requests.length === 0} +
No requests yet
+ {/if} +
+
+ + +
+ {#each errors as e, i} +
+ {e.time} + {e.message} + {e.route} +
+ {/each} + {#if errors.length === 0} +
No errors
+ {/if} +
+
+
diff --git a/ui/src/lib/components/ThroughputStrip.svelte b/ui/src/lib/components/ThroughputStrip.svelte new file mode 100644 index 0000000..f3ba606 --- /dev/null +++ b/ui/src/lib/components/ThroughputStrip.svelte @@ -0,0 +1,55 @@ + + + +
+ +
+
+ RPS + {lastRps.toFixed(1)} +
+
+ {#if throughput.rps_baseline > 0} +
+ {/if} + {#each throughput.rps_series as v} +
+ {/each} +
+
+
+ + +
+
+ p95 + {lastP95.toFixed(2)}s +
+
+
+ {#each throughput.p95_series as v} +
+ {/each} +
+
+
+
From 12d48e4812133dcaab9aa0d6a8dbc8ac75ef2fec Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 14:31:50 +0530 Subject: [PATCH 26/93] feat: complete dashboard page layout, tweaks panel, fix Svelte 5 store exports Refactor theme and metrics stores to class-based $state (Svelte 5 module constraint: exported $state cannot be reassigned, derived cannot be exported). Co-Authored-By: Claude Sonnet 4.6 --- ui/src/lib/components/Header.svelte | 10 +-- ui/src/lib/metrics.svelte.ts | 92 +++++++++++---------- ui/src/lib/theme.svelte.ts | 57 +++++++------ ui/src/routes/+page.svelte | 121 +++++++++++++++++++++++++++- 4 files changed, 204 insertions(+), 76 deletions(-) diff --git a/ui/src/lib/components/Header.svelte b/ui/src/lib/components/Header.svelte index 7779633..1aa109b 100644 --- a/ui/src/lib/components/Header.svelte +++ b/ui/src/lib/components/Header.svelte @@ -3,12 +3,12 @@ import type { ConsolePayload } from '$lib/types'; import type { Theme } from '$lib/theme.svelte'; import Pill from './shared/Pill.svelte'; - import { lastRefreshed, manualRefresh, connected } from '$lib/metrics.svelte'; + import { metricsStore } from '$lib/metrics.svelte'; let { data, t }: { data: ConsolePayload; t: Theme } = $props(); - let refreshed = $derived(lastRefreshed - ? `${lastRefreshed.toLocaleTimeString('en-GB')} · refreshed` + let refreshed = $derived(metricsStore.lastRefreshed + ? `${metricsStore.lastRefreshed.toLocaleTimeString('en-GB')} · refreshed` : 'connecting…' ); @@ -17,11 +17,11 @@ Folio v{data.version} prod - ● {connected ? 'live' : 'disconnected'} + ● {metricsStore.connected ? 'live' : 'disconnected'} {refreshed} + {/each} +
+
+ +
+
Accent
+
+ {#each ACCENTS as a} + + {/each} +
+
+ +
+
Density
+
+ {#each ['compact', 'regular', 'comfy'] as d} + + {/each} +
+
+
+ {/if} + + From 0480e65cd311a7fe69cc770080faf8d135f5dd05 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 14:51:31 +0530 Subject: [PATCH 27/93] Cascade snapshot 2026-05-01T09:21:31.235976Z --- tmp/gotenberg | 1 + 1 file changed, 1 insertion(+) create mode 160000 tmp/gotenberg diff --git a/tmp/gotenberg b/tmp/gotenberg new file mode 160000 index 0000000..fe1b002 --- /dev/null +++ b/tmp/gotenberg @@ -0,0 +1 @@ +Subproject commit fe1b0020b8f211575559e6cf7e5ef6cc5a0545ca From 379463bd01b02c41cbc238fcc072b23450584d40 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 15:16:14 +0530 Subject: [PATCH 28/93] fix(console): wire metrics, probe engine health, add interactive charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Wire record_http_request() in console_log_middleware → fixes RPS, routes, error%, latency percentiles (were all zero — method was never called) - Probe engine health via healthy().await each sampler tick instead of reading a gauge that only updated on /health polls → engines now show correct status - Add sysinfo for cross-platform CPU + memory (replaces Linux-only /proc reads) - Compute global p95 from http_request_duration_seconds histogram - Compute per-route p50/p95/p99 from histogram buckets (linear interpolation) - Add /_ → /_/ 308 redirect Frontend: - Add interactive BarSeries.svelte (SVG bars, hover tooltip, cursor line) - Use BarSeries in ThroughputStrip, Resources, Engines mini-chart - Add slot hover tooltips to Concurrency grid - Engines: full-width mini chart, rename "restarts" → "activations" - Reduce card border-radius 12→6px, pill border-radius to 4px Co-Authored-By: Claude Sonnet 4.6 --- crates/server/Cargo.toml | 3 + crates/server/src/app.rs | 9 +- crates/server/src/console_store.rs | 333 +++++++++++------- crates/server/src/routes/console.rs | 12 +- ui/src/lib/components/ThroughputStrip.svelte | 53 +-- ui/src/lib/components/shared/BarSeries.svelte | 123 +++++++ ui/src/lib/components/shared/Card.svelte | 2 +- ui/src/lib/components/shared/Pill.svelte | 2 +- .../components/side-rail/Concurrency.svelte | 47 ++- .../lib/components/side-rail/Engines.svelte | 32 +- .../lib/components/side-rail/Resources.svelte | 41 ++- 11 files changed, 468 insertions(+), 189 deletions(-) create mode 100644 ui/src/lib/components/shared/BarSeries.svelte diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index ff0788f..1c7ae58 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -77,6 +77,9 @@ clap = { workspace = true } prometheus = { version = "0.13", default-features = false, features = ["protobuf"] } once_cell = "1.21.4" +# Cross-platform system metrics (CPU, memory) +sysinfo = { version = "0.32", default-features = false, features = ["system"] } + # Scalar API documentation (interactive docs) scalar_api_reference = { version = "0.1", features = ["axum"] } diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index ac95fc0..c6551b7 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -275,6 +275,7 @@ pub fn build_router(state: AppState, config: &ServerConfig) -> Router { // Operator console SSE stream and one-shot metrics JSON (long-lived, no timeout) use crate::routes::console; untimed = untimed + .route("/_", get(|| async { axum::response::Redirect::permanent("/_/") })) .route("/_/api/stream", get(console::console_stream)) .route("/_/api/metrics", get(console::console_metrics_json)) .route("/_/", get(console::console_asset_root)) @@ -381,10 +382,14 @@ async fn console_log_middleware( let start = Instant::now(); let response = next.run(req).await; - let duration_ms = start.elapsed().as_millis() as u64; + let elapsed = start.elapsed(); + let duration_ms = elapsed.as_millis() as u64; let status = response.status().as_u16(); - state.console.record_request(method, path, status, duration_ms).await; + // Record into ring buffer for the console UI + state.console.record_request(method.clone(), path.clone(), status, duration_ms).await; + // Record into Prometheus counters + histogram for RPS / routes / error% calculations + state.metrics.record_http_request(&method, &path, status, elapsed.as_secs_f64()); response } diff --git a/crates/server/src/console_store.rs b/crates/server/src/console_store.rs index d46e8a1..dd8fcf4 100644 --- a/crates/server/src/console_store.rs +++ b/crates/server/src/console_store.rs @@ -56,13 +56,11 @@ pub struct ConsoleStore { pub request_log: Mutex>, pub error_log: Mutex>, pub broadcast: broadcast::Sender, - // Restart tracking (sampler detects false→true transition on is_running) + // Activation tracking: counts false→true transitions on engine health pub chromium_restarts: AtomicU32, pub chromium_was_running: AtomicBool, pub libreoffice_restarts: AtomicU32, pub libreoffice_was_running: AtomicBool, - // Rolling-max p95 approximation; updated by request log middleware, reset to 0 by sampler each tick - pub last_p95_ms: Mutex, // RPS delta tracking pub prev_http_total: Mutex, // Error rate delta tracking @@ -81,14 +79,12 @@ impl ConsoleStore { chromium_was_running: AtomicBool::new(false), libreoffice_restarts: AtomicU32::new(0), libreoffice_was_running: AtomicBool::new(false), - last_p95_ms: Mutex::new(0.0), prev_http_total: Mutex::new(0.0), prev_error_total: Mutex::new(0.0), } } pub async fn record_request(&self, method: String, route: String, status: u16, duration_ms: u64) { - // Use std::time instead of chrono (chrono not in server crate) use std::time::{SystemTime, UNIX_EPOCH}; let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); let h = (secs % 86400) / 3600; @@ -102,14 +98,6 @@ impl ConsoleStore { log.push_back(RequestLogEntry { time: time.clone(), method: method.clone(), route: route.clone(), status, duration_ms }); } - // Update p95 approximation (rolling max over recent requests) - { - let mut p95 = self.last_p95_ms.lock().await; - if duration_ms as f64 > *p95 { - *p95 = duration_ms as f64; - } - } - if status >= 500 { let mut log = self.error_log.lock().await; if log.len() >= LOG_CAP { log.pop_front(); } @@ -124,12 +112,10 @@ impl ConsoleStore { } impl Default for ConsoleStore { - fn default() -> Self { - Self::new() - } + fn default() -> Self { Self::new() } } -// ── ConsolePayload (the JSON shape sent to the frontend) ────────────────── +// ── ConsolePayload ──────────────────────────────────────────────────────── use std::sync::atomic::Ordering; use std::time::{Duration, Instant}; @@ -219,14 +205,18 @@ pub struct BatchPayload { // ── build_console_payload ───────────────────────────────────────────────── -pub async fn build_console_payload(state: &crate::state::AppState, started_at: Instant) -> ConsolePayload { +pub async fn build_console_payload( + state: &crate::state::AppState, + started_at: Instant, + chromium_up: bool, + libreoffice_up: bool, +) -> ConsolePayload { let uptime_seconds = started_at.elapsed().as_secs(); let concurrency_max = state.config.concurrency as u32; let concurrency_active = (concurrency_max as usize) .saturating_sub(state.sem.available_permits()) as u32; - // Read history for series data - let (rps_series, p95_series, cpu_series, memory_series, last_rps, last_p95_ms, error_pct) = { + let (rps_series, p95_series, cpu_series, memory_series, last_rps, last_p95_ms, last_error_pct) = { let history = state.console.history.lock().await; let rps_series: Vec = history.samples.iter().map(|s| s.rps).collect(); let p95_series: Vec = history.samples.iter().map(|s| s.p95_ms / 1000.0).collect(); @@ -234,39 +224,30 @@ pub async fn build_console_payload(state: &crate::state::AppState, started_at: I let memory_series: Vec = history.samples.iter().map(|s| s.memory_mb).collect(); let last_rps = rps_series.last().copied().unwrap_or(0.0); let last_p95_ms = p95_series.last().copied().unwrap_or(0.0) * 1000.0; - let error_pct = history.samples.back().map_or(0.0, |s| s.error_pct); - (rps_series, p95_series, cpu_series, memory_series, last_rps, last_p95_ms, error_pct) + let last_error_pct = history.samples.back().map_or(0.0, |s| s.error_pct); + (rps_series, p95_series, cpu_series, memory_series, last_rps, last_p95_ms, last_error_pct) }; - // Queue size from metrics gauge let queue_size = state.metrics.queue_size.get(); - // Engine status from health gauges (with feature guards so absent engines show "n/a") #[cfg(feature = "chromium")] - let chromium_status = if state.chromium.is_some() && state.metrics.chromium_healthy.get() >= 1.0 { - "up".to_string() - } else { - "down".to_string() - }; + let chromium_status = if chromium_up { "up".to_string() } else { "down".to_string() }; #[cfg(not(feature = "chromium"))] let chromium_status = "n/a".to_string(); let chromium_restarts = state.console.chromium_restarts.load(Ordering::SeqCst); #[cfg(feature = "libreoffice")] - let libreoffice_status = if state.metrics.libreoffice_healthy.get() >= 1.0 { - "up".to_string() - } else { - "down".to_string() - }; + let libreoffice_status = if libreoffice_up { "up".to_string() } else { "down".to_string() }; #[cfg(not(feature = "libreoffice"))] let libreoffice_status = "n/a".to_string(); let libreoffice_restarts = state.console.libreoffice_restarts.load(Ordering::SeqCst); - // Engine mini_series (last 20 concurrency samples as proxy for load) + // Engine mini_series: last 20 RPS samples normalised 0-1 let mini: Vec = { let h = state.console.history.lock().await; + let max_rps = h.samples.iter().map(|s| s.rps).fold(0.01f64, f64::max); h.samples.iter().rev().take(20).rev() - .map(|s| s.concurrency_active as f64 / concurrency_max.max(1) as f64) + .map(|s| s.rps / max_rps) .collect() }; @@ -282,7 +263,7 @@ pub async fn build_console_payload(state: &crate::state::AppState, started_at: I }; let batches = build_batch_payloads(state).await; - let memory_max_mb = read_total_memory_mb(); + let memory_max_mb = total_memory_mb(); ConsolePayload { version: env!("CARGO_PKG_VERSION").to_string(), @@ -290,7 +271,7 @@ pub async fn build_console_payload(state: &crate::state::AppState, started_at: I ticker: TickerPayload { rps: last_rps, p95_ms: last_p95_ms, - error_pct, + error_pct: last_error_pct, concurrency_active, concurrency_max, chromium_status: chromium_status.clone(), @@ -340,125 +321,254 @@ pub async fn build_console_payload(state: &crate::state::AppState, started_at: I } } -// ── Helper functions ────────────────────────────────────────────────────── +// ── Route payload: reads Prometheus counters + histograms ───────────────── fn build_route_payloads(state: &crate::state::AppState, concurrency_max: u32) -> Vec { let families = prometheus::gather(); - let mut routes = Vec::new(); + // Build count + error map from folio_http_requests_total + let mut route_counts: std::collections::HashMap = std::collections::HashMap::new(); for family in &families { if family.get_name() != "folio_http_requests_total" { continue; } - let mut route_map: std::collections::HashMap = std::collections::HashMap::new(); for m in family.get_metric() { let labels: std::collections::HashMap<_, _> = m.get_label().iter() .map(|l| (l.get_name(), l.get_value())) .collect(); - let route = labels.get("route").copied().unwrap_or("unknown"); + let route = labels.get("route").copied().unwrap_or("unknown").to_string(); let status = labels.get("status").copied().unwrap_or("0"); let count = m.get_counter().get_value(); - let entry = route_map.entry(route.to_string()).or_insert((0.0, 0.0)); + let entry = route_counts.entry(route).or_insert((0.0, 0.0)); entry.0 += count; if status.starts_with('5') || status.starts_with('4') { entry.1 += count; } } - for (path, (total, errors)) in route_map { - let error_pct = if total > 0.0 { (errors / total) * 100.0 } else { 0.0 }; - routes.push(RoutePayload { - path, - method: "POST".to_string(), - rps: 0.0, - p50_ms: 0.0, - p95_ms: 0.0, - p99_ms: 0.0, - error_pct, - in_flight: 0, - load_pct: ((concurrency_max as usize).saturating_sub(state.sem.available_permits()) as f64 - / concurrency_max.max(1) as f64 * 100.0), - }); + break; + } + + // Build latency percentiles from folio_http_request_duration_seconds histogram + let mut route_latency: std::collections::HashMap = std::collections::HashMap::new(); + for family in &families { + if family.get_name() != "folio_http_request_duration_seconds" { continue; } + for m in family.get_metric() { + let labels: std::collections::HashMap<_, _> = m.get_label().iter() + .map(|l| (l.get_name(), l.get_value())) + .collect(); + let route = labels.get("route").copied().unwrap_or("unknown").to_string(); + let hist = m.get_histogram(); + let count = hist.get_sample_count(); + if count == 0 { continue; } + let buckets = hist.get_bucket(); + let p50 = percentile_from_histogram(buckets, count, 0.50) * 1000.0; + let p95 = percentile_from_histogram(buckets, count, 0.95) * 1000.0; + let p99 = percentile_from_histogram(buckets, count, 0.99) * 1000.0; + route_latency.insert(route, (p50, p95, p99)); } break; } - routes.sort_by(|a, b| b.error_pct.partial_cmp(&a.error_pct).unwrap_or(std::cmp::Ordering::Equal)); + + let load_pct = (concurrency_max as usize).saturating_sub(state.sem.available_permits()) as f64 + / concurrency_max.max(1) as f64 * 100.0; + + let mut routes: Vec = route_counts.into_iter().map(|(path, (total, errors))| { + let error_pct = if total > 0.0 { (errors / total) * 100.0 } else { 0.0 }; + let (p50_ms, p95_ms, p99_ms) = route_latency.get(&path).copied().unwrap_or((0.0, 0.0, 0.0)); + RoutePayload { + path, + method: "POST".to_string(), + rps: 0.0, + p50_ms, + p95_ms, + p99_ms, + error_pct, + in_flight: 0, + load_pct, + } + }).collect(); + + routes.sort_by(|a, b| b.p95_ms.partial_cmp(&a.p95_ms).unwrap_or(std::cmp::Ordering::Equal)); routes } -async fn build_batch_payloads(state: &crate::state::AppState) -> Vec { - // batch_manager.list_batches() returns Vec (just IDs, no status) - // Return empty vec to avoid an expensive full scan; console can query batch API separately - let Some(ref _bm) = state.batch_manager else { return vec![] }; - vec![] +/// Compute a percentile from Prometheus histogram buckets using linear interpolation. +fn percentile_from_histogram(buckets: &[prometheus::proto::Bucket], total_count: u64, pct: f64) -> f64 { + if total_count == 0 || buckets.is_empty() { return 0.0; } + let target = (total_count as f64 * pct) as u64; + let mut prev_count = 0u64; + let mut prev_bound = 0.0f64; + for bucket in buckets { + let count = bucket.get_cumulative_count(); + let bound = bucket.get_upper_bound(); + if bound.is_infinite() { break; } + if count >= target { + if count == prev_count { return prev_bound; } + return prev_bound + (bound - prev_bound) + * ((target - prev_count) as f64 / (count - prev_count) as f64); + } + prev_count = count; + prev_bound = bound; + } + // All observations in the last finite bucket + buckets.iter().rev().find(|b| !b.get_upper_bound().is_infinite()) + .map(|b| b.get_upper_bound()) + .unwrap_or(0.0) } -#[cfg(target_os = "linux")] -fn read_total_memory_mb() -> f64 { - std::fs::read_to_string("/proc/meminfo").ok() - .and_then(|s| s.lines().find(|l| l.starts_with("MemTotal:")) - .and_then(|l| l.split_whitespace().nth(1)) - .and_then(|v| v.parse::().ok())) - .map(|kb| kb / 1024.0) - .unwrap_or(0.0) +async fn build_batch_payloads(_state: &crate::state::AppState) -> Vec { + vec![] } -#[cfg(not(target_os = "linux"))] -fn read_total_memory_mb() -> f64 { 0.0 } +/// Total system RAM in MB (cached on first call). +fn total_memory_mb() -> f64 { + use once_cell::sync::Lazy; + static TOTAL_MB: Lazy = Lazy::new(|| { + let mut sys = sysinfo::System::new(); + sys.refresh_memory(); + sys.total_memory() as f64 / 1024.0 / 1024.0 + }); + *TOTAL_MB +} // ── spawn_console_sampler ───────────────────────────────────────────────── pub fn spawn_console_sampler(state: crate::state::AppState, started_at: Instant) { tokio::spawn(async move { + use sysinfo::{System, RefreshKind, CpuRefreshKind, MemoryRefreshKind}; + + let mut sys = System::new_with_specifics( + RefreshKind::new() + .with_cpu(CpuRefreshKind::new().with_cpu_usage()) + .with_memory(MemoryRefreshKind::new().with_ram()), + ); + // Prime CPU baseline (sysinfo needs two samples to compute usage) + sys.refresh_cpu_usage(); + tokio::time::sleep(Duration::from_millis(500)).await; + let mut interval = tokio::time::interval(Duration::from_secs(5)); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { interval.tick().await; - // Gather metrics once per tick to avoid data race between http_total and error_total + // ── CPU + memory via sysinfo (cross-platform) ────────────────── + sys.refresh_cpu_usage(); + let cpu_pct = sys.global_cpu_usage() as f64; + sys.refresh_memory(); + let memory_mb = sys.used_memory() as f64 / 1024.0 / 1024.0; + // Update Prometheus gauge so /prometheus/metrics reflects RSS + state.metrics.process_resident_memory.set(sys.used_memory() as f64); + + // ── Engine health: probe directly, don't read stale gauge ────── + #[cfg(feature = "chromium")] + let chromium_up = match state.chromium.as_ref() { + Some(be) => be.healthy().await, + None => false, + }; + #[cfg(not(feature = "chromium"))] + let chromium_up = false; + + #[cfg(feature = "libreoffice")] + let libreoffice_up = match state.libreoffice.as_ref() { + Some(lo) => lo.is_running(), + None => false, + }; + #[cfg(not(feature = "libreoffice"))] + let libreoffice_up = false; + + // Update health gauges so /prometheus/metrics stays accurate + state.metrics.chromium_healthy.set(if chromium_up { 1.0 } else { 0.0 }); + state.metrics.libreoffice_healthy.set(if libreoffice_up { 1.0 } else { 0.0 }); + + // ── Track false→true transitions (engine activations) ────────── + #[cfg(feature = "chromium")] + { + let was = state.console.chromium_was_running.load(Ordering::SeqCst); + if chromium_up && !was { + state.console.chromium_restarts.fetch_add(1, Ordering::SeqCst); + } + state.console.chromium_was_running.store(chromium_up, Ordering::SeqCst); + } + #[cfg(feature = "libreoffice")] + { + let was = state.console.libreoffice_was_running.load(Ordering::SeqCst); + if libreoffice_up && !was { + state.console.libreoffice_restarts.fetch_add(1, Ordering::SeqCst); + } + state.console.libreoffice_was_running.store(libreoffice_up, Ordering::SeqCst); + } + + // ── RPS + error% from Prometheus counter deltas ──────────────── let families = prometheus::gather(); - // Read current http total for RPS delta let http_total: f64 = families.iter() .find(|f| f.get_name() == "folio_http_requests_total") .map(|f| f.get_metric().iter().map(|m| m.get_counter().get_value()).sum()) .unwrap_or(0.0); - // Error rate (interval-relative delta, not lifetime cumulative) let error_total: f64 = families.iter() .find(|f| f.get_name() == "folio_http_requests_total") .map(|f| f.get_metric().iter() .filter(|m| m.get_label().iter() - .any(|l| l.get_name() == "status" && (l.get_value().starts_with('5') || l.get_value().starts_with('4')))) + .any(|l| l.get_name() == "status" + && (l.get_value().starts_with('5') || l.get_value().starts_with('4')))) .map(|m| m.get_counter().get_value()).sum()) .unwrap_or(0.0); - let rps; - let error_pct; - { + let (rps, error_pct) = { let mut prev_http = state.console.prev_http_total.lock().await; - let mut prev_err = state.console.prev_error_total.lock().await; - let http_delta = http_total - *prev_http; - let error_delta = error_total - *prev_err; - rps = http_delta / 5.0; - error_pct = if http_delta > 0.0 { (error_delta / http_delta) * 100.0 } else { 0.0 }; + let mut prev_err = state.console.prev_error_total.lock().await; + let http_delta = (http_total - *prev_http).max(0.0); + let error_delta = (error_total - *prev_err).max(0.0); + let rps = http_delta / 5.0; + let epct = if http_delta > 0.0 { (error_delta / http_delta) * 100.0 } else { 0.0 }; *prev_http = http_total; - *prev_err = error_total; - } + *prev_err = error_total; + (rps, epct) + }; - // Concurrency + // ── p95 from histogram (global, across all routes) ───────────── + let p95_ms = families.iter() + .find(|f| f.get_name() == "folio_http_request_duration_seconds") + .map(|f| { + // Aggregate all route histograms into one virtual histogram + let mut agg_count = 0u64; + let mut agg_buckets: Vec<(f64, u64)> = Vec::new(); + for m in f.get_metric() { + let hist = m.get_histogram(); + agg_count += hist.get_sample_count(); + for (i, b) in hist.get_bucket().iter().enumerate() { + if agg_buckets.len() <= i { + agg_buckets.push((b.get_upper_bound(), b.get_cumulative_count())); + } else { + agg_buckets[i].1 += b.get_cumulative_count(); + } + } + } + if agg_count == 0 || agg_buckets.is_empty() { return 0.0; } + let target = (agg_count as f64 * 0.95) as u64; + let mut prev_count = 0u64; + let mut prev_bound = 0.0f64; + for (bound, count) in &agg_buckets { + if bound.is_infinite() { break; } + if *count >= target { + if *count == prev_count { return prev_bound * 1000.0; } + return (prev_bound + (bound - prev_bound) + * ((target - prev_count) as f64 / (count - prev_count) as f64)) * 1000.0; + } + prev_count = *count; + prev_bound = *bound; + } + agg_buckets.iter().rev().find(|(b, _)| !b.is_infinite()) + .map(|(b, _)| b * 1000.0).unwrap_or(0.0) + }) + .unwrap_or(0.0); + + // ── Concurrency ──────────────────────────────────────────────── let concurrency_max = state.config.concurrency as u32; let concurrency_active = (concurrency_max as usize) .saturating_sub(state.sem.available_permits()) as u32; - // Memory from prometheus gauge (bytes -> MB) - let memory_mb = state.metrics.process_resident_memory.get() / (1024.0 * 1024.0); - - let p95_ms = { - let mut p95 = state.console.last_p95_ms.lock().await; - let val = *p95; - *p95 = 0.0; // reset each tick - val - }; - + // ── Push sample + broadcast ──────────────────────────────────── let sample = MetricsSample { ts: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs(), @@ -467,36 +577,13 @@ pub fn spawn_console_sampler(state: crate::state::AppState, started_at: Instant) error_pct, queue_size: state.metrics.queue_size.get() as u32, concurrency_active, - cpu_pct: 0.0, + cpu_pct, memory_mb, }; state.console.history.lock().await.push(sample); - // Detect engine restarts via health gauge transitions - // chromium: health gauge 1.0 = up, 0.0 = down - #[cfg(feature = "chromium")] - { - let chromium_now_running = state.chromium.is_some() && state.metrics.chromium_healthy.get() >= 1.0; - let chromium_was = state.console.chromium_was_running.load(Ordering::SeqCst); - if chromium_now_running && !chromium_was { - state.console.chromium_restarts.fetch_add(1, Ordering::SeqCst); - } - state.console.chromium_was_running.store(chromium_now_running, Ordering::SeqCst); - } - - #[cfg(feature = "libreoffice")] - { - let lo_now_running = state.metrics.libreoffice_healthy.get() >= 1.0; - let lo_was = state.console.libreoffice_was_running.load(Ordering::SeqCst); - if !lo_was && lo_now_running { - state.console.libreoffice_restarts.fetch_add(1, Ordering::SeqCst); - } - state.console.libreoffice_was_running.store(lo_now_running, Ordering::SeqCst); - } - - // Build + broadcast - let payload = build_console_payload(&state, started_at).await; + let payload = build_console_payload(&state, started_at, chromium_up, libreoffice_up).await; if let Ok(json) = serde_json::to_string(&payload) { let _ = state.console.broadcast.send(json); } diff --git a/crates/server/src/routes/console.rs b/crates/server/src/routes/console.rs index cab3e6a..29732e0 100644 --- a/crates/server/src/routes/console.rs +++ b/crates/server/src/routes/console.rs @@ -17,8 +17,12 @@ pub async fn console_stream( let started_at = state.started_at; let mut rx = state.console.broadcast.subscribe(); - // Send initial snapshot immediately on connect (no waiting for next 5s tick) - let initial = build_console_payload(&state, started_at).await; + // Send initial snapshot immediately on connect (no waiting for next 5s tick). + // For the initial snapshot we read the health gauges as a best-effort proxy; + // the sampler will probe engines directly and broadcast accurate data within 5s. + let chromium_up = state.metrics.chromium_healthy.get() >= 1.0; + let libreoffice_up = state.metrics.libreoffice_healthy.get() >= 1.0; + let initial = build_console_payload(&state, started_at, chromium_up, libreoffice_up).await; let initial_json = serde_json::to_string(&initial).unwrap_or_default(); let initial_stream = stream::once(async move { Ok::(Event::default().data(initial_json)) @@ -43,7 +47,9 @@ pub async fn console_metrics_json( State(state): State, ) -> Json { let started_at = state.started_at; - Json(build_console_payload(&state, started_at).await) + let chromium_up = state.metrics.chromium_healthy.get() >= 1.0; + let libreoffice_up = state.metrics.libreoffice_healthy.get() >= 1.0; + Json(build_console_payload(&state, started_at, chromium_up, libreoffice_up).await) } use axum::body::Body; diff --git a/ui/src/lib/components/ThroughputStrip.svelte b/ui/src/lib/components/ThroughputStrip.svelte index f3ba606..70f6a61 100644 --- a/ui/src/lib/components/ThroughputStrip.svelte +++ b/ui/src/lib/components/ThroughputStrip.svelte @@ -3,21 +3,16 @@ import type { ThroughputPayload } from '$lib/types'; import type { Theme } from '$lib/theme.svelte'; import Card from './shared/Card.svelte'; + import BarSeries from './shared/BarSeries.svelte'; let { throughput, t, D }: { throughput: ThroughputPayload; t: Theme; D: { pad: number; fz: number } } = $props(); let lastRps = $derived(throughput.rps_series.at(-1) ?? 0); let lastP95 = $derived(throughput.p95_series.at(-1) ?? 0); - let maxRps = $derived(Math.max(...throughput.rps_series, throughput.rps_baseline, 0.01)); - let maxP95 = $derived(Math.max(...throughput.p95_series, throughput.p95_target_s, 0.01)); - - function barColor(v: number, max: number, target: number): string { - const ratio = v / max; - const overTarget = target > 0 && v > target * 1.2; - if (overTarget) return t.err; - if (ratio > 0.8) return t.warn; - return t.ok; - } + let maxRps = $derived(Math.max(...throughput.rps_series, throughput.rps_baseline, 0.001)); + let maxP95 = $derived(Math.max(...throughput.p95_series, throughput.p95_target_s, 0.001)); + let rpsRefY = $derived(throughput.rps_baseline > 0 ? throughput.rps_baseline / maxRps : undefined); + let p95RefY = $derived(throughput.p95_target_s > 0 ? throughput.p95_target_s / maxP95 : undefined);
@@ -25,16 +20,18 @@
RPS - {lastRps.toFixed(1)} -
-
- {#if throughput.rps_baseline > 0} -
- {/if} - {#each throughput.rps_series as v} -
- {/each} + {lastRps.toFixed(2)}
+ v.toFixed(2)} + {t} + />
@@ -42,14 +39,18 @@
p95 - {lastP95.toFixed(2)}s -
-
-
- {#each throughput.p95_series as v} -
- {/each} + {lastP95.toFixed(3)}s
+ v.toFixed(3)} + {t} + />
diff --git a/ui/src/lib/components/shared/BarSeries.svelte b/ui/src/lib/components/shared/BarSeries.svelte new file mode 100644 index 0000000..adbaefd --- /dev/null +++ b/ui/src/lib/components/shared/BarSeries.svelte @@ -0,0 +1,123 @@ + + + +
+ + + + {#if referenceY !== undefined && referenceY > 0 && referenceY <= 1} + {@const refPx = height - referenceY * height} + + {/if} + + + {#each series as v, i} + {@const w = 100 / series.length} + {@const pct = Math.max(2 / height, v / max)} + {@const barH = pct * height} + {@const x = i * w} + {@const isHovered = hoveredIdx === i} + + {/each} + + + {#if hoveredIdx !== null} + {@const w = 100 / series.length} + {@const cx = (hoveredIdx + 0.5) * w} + + {/if} + + + + {#if hoveredIdx !== null && hoveredValue !== null} + {@const w = 100 / series.length} + {@const pctLeft = (hoveredIdx + 0.5) * w} + {@const flipLeft = pctLeft > 70} +
+ {formatValue(hoveredValue)}{label ? ' ' + label : ''} +
+ {/if} +
diff --git a/ui/src/lib/components/shared/Card.svelte b/ui/src/lib/components/shared/Card.svelte index 3a5628e..8f499d4 100644 --- a/ui/src/lib/components/shared/Card.svelte +++ b/ui/src/lib/components/shared/Card.svelte @@ -11,7 +11,7 @@ } = $props(); -
+
{#if title}
{title} diff --git a/ui/src/lib/components/shared/Pill.svelte b/ui/src/lib/components/shared/Pill.svelte index 8d2048f..7b59184 100644 --- a/ui/src/lib/components/shared/Pill.svelte +++ b/ui/src/lib/components/shared/Pill.svelte @@ -18,7 +18,7 @@ font-family:ui-monospace,monospace; font-size:10px; font-weight:600; - border-radius:999px; + border-radius:4px; letter-spacing:0.04em; display:inline-block; line-height:16px; diff --git a/ui/src/lib/components/side-rail/Concurrency.svelte b/ui/src/lib/components/side-rail/Concurrency.svelte index 17749f3..e15dbe6 100644 --- a/ui/src/lib/components/side-rail/Concurrency.svelte +++ b/ui/src/lib/components/side-rail/Concurrency.svelte @@ -10,6 +10,8 @@ let tone = $derived((conc.active >= conc.crit_threshold ? 'err' : conc.active >= conc.warn_threshold ? 'warn' : 'ok') as 'ok' | 'warn' | 'err'); let pct = $derived(Math.round((conc.active / Math.max(1, conc.max)) * 100)); + let hoveredSlot = $state(null); + function slotColor(i: number): string { const filled = i < conc.active; if (!filled) return t.faint; @@ -17,6 +19,13 @@ if (i >= conc.warn_threshold) return t.warn; return t.ok; } + + function slotLabel(i: number): string { + const filled = i < conc.active; + const state = filled ? 'active' : 'free'; + const zone = i >= conc.crit_threshold ? ' · crit zone' : i >= conc.warn_threshold ? ' · warn zone' : ''; + return `Slot ${i + 1}: ${state}${zone}`; + } @@ -27,11 +36,41 @@
{pct}% · {tone}
-
- {#each Array.from({ length: conc.max }, (_, i) => i) as i} -
- {/each} + + +
+
+ {#each Array.from({ length: conc.max }, (_, i) => i) as i} + +
hoveredSlot = i} + onmouseleave={() => hoveredSlot = null} + >
+ {/each} +
+ + {#if hoveredSlot !== null} +
+ {slotLabel(hoveredSlot)} +
+ {/if}
+
0warn {conc.warn_threshold}crit {conc.crit_threshold}{conc.max}
diff --git a/ui/src/lib/components/side-rail/Engines.svelte b/ui/src/lib/components/side-rail/Engines.svelte index 4ad9c90..5177ac3 100644 --- a/ui/src/lib/components/side-rail/Engines.svelte +++ b/ui/src/lib/components/side-rail/Engines.svelte @@ -4,6 +4,7 @@ import type { Theme } from '$lib/theme.svelte'; import Card from '../shared/Card.svelte'; import Pill from '../shared/Pill.svelte'; + import BarSeries from '../shared/BarSeries.svelte'; let { engines, t, D }: { engines: EnginePayload[]; t: Theme; D: { fz: number; pad: number } } = $props(); @@ -13,28 +14,37 @@ if (e.restarts > 5) return 'warn'; return 'ok'; } + function engineColor(e: EnginePayload, t: Theme): string { + const tone = engineTone(e); + if (tone === 'ok') return t.ok; + if (tone === 'warn') return t.warn; + if (tone === 'err') return t.err; + return t.muted; + }
{#each engines as e, i} -
-
+
+
{e.name} {e.status.toUpperCase()}
-
- {e.restarts} restart{e.restarts !== 1 ? 's' : ''} · {e.mode} -
+ + {e.restarts} activation{e.restarts !== 1 ? 's' : ''} · {e.mode} +
{#if e.mini_series.length > 0} -
- {#each e.mini_series as v} - {@const barColor = engineTone(e) === 'ok' ? t.ok : engineTone(e) === 'warn' ? t.warn : t.err} -
- {/each} -
+ (v * 100).toFixed(0) + '%'} + {t} + /> {/if}
{/each} diff --git a/ui/src/lib/components/side-rail/Resources.svelte b/ui/src/lib/components/side-rail/Resources.svelte index 18e2403..413e230 100644 --- a/ui/src/lib/components/side-rail/Resources.svelte +++ b/ui/src/lib/components/side-rail/Resources.svelte @@ -3,43 +3,48 @@ import type { ResourcesPayload } from '$lib/types'; import type { Theme } from '$lib/theme.svelte'; import Card from '../shared/Card.svelte'; + import BarSeries from '../shared/BarSeries.svelte'; - let { resources, t, D }: { resources: ResourcesPayload; t: Theme; D: { pad: number } } = $props(); + let { resources, t, D }: { resources: ResourcesPayload; t: Theme; D: { pad: number; fz: number } } = $props(); let lastCpu = $derived(resources.cpu_series.at(-1) ?? 0); let lastMem = $derived(resources.memory_series.at(-1) ?? 0); - let maxCpu = $derived(Math.max(1, ...resources.cpu_series)); - let maxMem = $derived(Math.max(1, ...resources.memory_series));
-
-
+
CPU - {lastCpu.toFixed(0)}% -
-
- {#each resources.cpu_series as v} -
- {/each} + {lastCpu.toFixed(1)}%
+ v.toFixed(1)} + {t} + />
-
-
+
Memory {(lastMem / 1024).toFixed(2)} GB{resources.memory_max_mb > 0 ? ` / ${(resources.memory_max_mb / 1024).toFixed(0)} GB` : ''}
-
- {#each resources.memory_series as v} -
- {/each} -
+ 0 ? (resources.memory_series.at(-1) ?? 0) / resources.memory_max_mb : undefined} + refColor={t.warn} + label="MB" + formatValue={(v) => (v / 1024).toFixed(2) + ' GB'} + {t} + />
From 209a4448ec3fde10c7836d7ce58f5f0d46cb457d Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 15:23:18 +0530 Subject: [PATCH 29/93] fix(console): track live concurrency via AtomicU32, not sampled semaphore The semaphore is only acquired for PDF conversion requests, so at idle (health checks, browsing) it always showed 0. Even during conversions, fast requests (<5s) completed between sampler ticks and were invisible. Add active_requests: AtomicU32 to ConsoleStore. The middleware increments it before next.run() and decrements after, giving real-time tracking of all in-flight HTTP requests. build_console_payload now reads this atomic instead of state.sem.available_permits(). Co-Authored-By: Claude Sonnet 4.6 --- crates/server/src/app.rs | 3 +++ crates/server/src/console_store.rs | 13 +++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index c6551b7..b57e00c 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -380,8 +380,11 @@ async fn console_log_middleware( return next.run(req).await; } + use std::sync::atomic::Ordering; + state.console.active_requests.fetch_add(1, Ordering::SeqCst); let start = Instant::now(); let response = next.run(req).await; + state.console.active_requests.fetch_sub(1, Ordering::SeqCst); let elapsed = start.elapsed(); let duration_ms = elapsed.as_millis() as u64; let status = response.status().as_u16(); diff --git a/crates/server/src/console_store.rs b/crates/server/src/console_store.rs index dd8fcf4..14c440e 100644 --- a/crates/server/src/console_store.rs +++ b/crates/server/src/console_store.rs @@ -65,6 +65,10 @@ pub struct ConsoleStore { pub prev_http_total: Mutex, // Error rate delta tracking pub prev_error_total: Mutex, + // Live count of all HTTP requests currently in flight (incremented before + // next.run(), decremented after) — gives real-time concurrency even for + // fast requests that complete between 5s sampler ticks. + pub active_requests: AtomicU32, } impl ConsoleStore { @@ -81,6 +85,7 @@ impl ConsoleStore { libreoffice_was_running: AtomicBool::new(false), prev_http_total: Mutex::new(0.0), prev_error_total: Mutex::new(0.0), + active_requests: AtomicU32::new(0), } } @@ -213,8 +218,9 @@ pub async fn build_console_payload( ) -> ConsolePayload { let uptime_seconds = started_at.elapsed().as_secs(); let concurrency_max = state.config.concurrency as u32; - let concurrency_active = (concurrency_max as usize) - .saturating_sub(state.sem.available_permits()) as u32; + // Use the live atomic counter (incremented/decremented in middleware) so + // fast requests that finish between sampler ticks still appear in the UI. + let concurrency_active = state.console.active_requests.load(Ordering::SeqCst); let (rps_series, p95_series, cpu_series, memory_series, last_rps, last_p95_ms, last_error_pct) = { let history = state.console.history.lock().await; @@ -565,8 +571,7 @@ pub fn spawn_console_sampler(state: crate::state::AppState, started_at: Instant) // ── Concurrency ──────────────────────────────────────────────── let concurrency_max = state.config.concurrency as u32; - let concurrency_active = (concurrency_max as usize) - .saturating_sub(state.sem.available_permits()) as u32; + let concurrency_active = state.console.active_requests.load(Ordering::SeqCst); // ── Push sample + broadcast ──────────────────────────────────── let sample = MetricsSample { From e14ff3fd1c15bb171d9ceba1d8f15f772f39a2e5 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 15:28:38 +0530 Subject: [PATCH 30/93] fix: terminate SSE streams on graceful shutdown to prevent SIGTERM hang When SIGTERM/SIGINT fires, axum stops accepting new connections but waits for all in-flight requests to drain. SSE connections never complete on their own, so the server would hang until browsers disconnected. Added a watch::Sender to ConsoleStore. On shutdown the signal handler sends true before handing off to axum's drain phase. The SSE unfold loop selects on both the broadcast receiver and the shutdown watch, returning None immediately when true is received. Regular requests (downloads, conversions) are unaffected. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 86 +++++++++++++++++++++++++++-- crates/server/src/console_store.rs | 6 +- crates/server/src/main.rs | 11 +++- crates/server/src/routes/console.rs | 18 ++++-- 4 files changed, 109 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7c728d1..b2f942f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1723,7 +1723,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -2255,6 +2255,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3399,6 +3408,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "static_assertions", + "sysinfo", "tempfile", "testcontainers", "thiserror 2.0.18", @@ -3632,6 +3642,19 @@ dependencies = [ "syn", ] +[[package]] +name = "sysinfo" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "windows", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4439,19 +4462,52 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", - "windows-result", + "windows-result 0.4.1", "windows-strings", ] +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -4463,6 +4519,17 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -4487,10 +4554,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ "windows-link", - "windows-result", + "windows-result 0.4.1", "windows-strings", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/crates/server/src/console_store.rs b/crates/server/src/console_store.rs index 14c440e..0ab2ef0 100644 --- a/crates/server/src/console_store.rs +++ b/crates/server/src/console_store.rs @@ -2,7 +2,7 @@ use std::collections::VecDeque; use std::sync::atomic::{AtomicBool, AtomicU32}; use serde::{Deserialize, Serialize}; -use tokio::sync::{Mutex, broadcast}; +use tokio::sync::{Mutex, broadcast, watch}; pub const HISTORY_CAP: usize = 360; // 30 min at 5s cadence pub const LOG_CAP: usize = 100; @@ -56,6 +56,8 @@ pub struct ConsoleStore { pub request_log: Mutex>, pub error_log: Mutex>, pub broadcast: broadcast::Sender, + /// Signals SSE connections to close on graceful shutdown. + pub shutdown_tx: watch::Sender, // Activation tracking: counts false→true transitions on engine health pub chromium_restarts: AtomicU32, pub chromium_was_running: AtomicBool, @@ -74,11 +76,13 @@ pub struct ConsoleStore { impl ConsoleStore { pub fn new() -> Self { let (tx, _) = broadcast::channel(BROADCAST_CAP); + let (shutdown_tx, _) = watch::channel(false); Self { history: Mutex::new(MetricsHistory::default()), request_log: Mutex::new(VecDeque::new()), error_log: Mutex::new(VecDeque::new()), broadcast: tx, + shutdown_tx, chromium_restarts: AtomicU32::new(0), chromium_was_running: AtomicBool::new(false), libreoffice_restarts: AtomicU32::new(0), diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 729f52b..794a36f 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -146,6 +146,8 @@ async fn serve(args: ServerArgs) -> anyhow::Result<()> { spawn_console_sampler(state.clone(), state.started_at); } + // Clone state before moving into the router, so we can signal SSE shutdown. + let state_for_shutdown = state.clone(); let router = build_router(state, &config); let addr: SocketAddr = SocketAddr::new(config.host, config.port); @@ -166,8 +168,11 @@ async fn serve(args: ServerArgs) -> anyhow::Result<()> { let shutdown_handle = handle.clone(); // Spawn graceful shutdown signal handler + let state_for_tls_shutdown = state_for_shutdown.clone(); tokio::spawn(async move { shutdown::shutdown_signal().await; + // Signal SSE streams to close so they don't block the drain. + let _ = state_for_tls_shutdown.console.shutdown_tx.send(true); shutdown_handle.shutdown(); }); @@ -184,7 +189,11 @@ async fn serve(args: ServerArgs) -> anyhow::Result<()> { .with_context(|| format!("binding {addr}"))?; axum::serve(listener, router.into_make_service()) - .with_graceful_shutdown(shutdown::shutdown_signal()) + .with_graceful_shutdown(async move { + shutdown::shutdown_signal().await; + // Signal SSE streams to close so they don't block the drain. + let _ = state_for_shutdown.console.shutdown_tx.send(true); + }) .await .context("axum serve")?; } diff --git a/crates/server/src/routes/console.rs b/crates/server/src/routes/console.rs index 29732e0..a913c55 100644 --- a/crates/server/src/routes/console.rs +++ b/crates/server/src/routes/console.rs @@ -28,12 +28,20 @@ pub async fn console_stream( Ok::(Event::default().data(initial_json)) }); - let broadcast_stream = stream::unfold(rx, |mut rx| async move { + let shutdown_rx = state.console.shutdown_tx.subscribe(); + let broadcast_stream = stream::unfold((rx, shutdown_rx), |(mut rx, mut shutdown_rx)| async move { loop { - match rx.recv().await { - Ok(payload) => return Some((Ok(Event::default().data(payload)), rx)), - Err(RecvError::Lagged(_)) => continue, - Err(RecvError::Closed) => return None, + tokio::select! { + result = rx.recv() => match result { + Ok(payload) => return Some((Ok(Event::default().data(payload)), (rx, shutdown_rx))), + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => return None, + }, + _ = shutdown_rx.changed() => { + if *shutdown_rx.borrow() { + return None; + } + } } } }); From e1657daa16579ad338d1739a740a0f5e0f2492b4 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 15:35:16 +0530 Subject: [PATCH 31/93] chore(deps): replace uuid with ulid for better identifier properties ULID provides: - Lexicographic sorting (chronological order without timestamp field) - 26 lowercase characters (Crockford base32) - URL-safe encoding without special characters - Better collision resistance than UUIDv4 Refs: spec-60-production-hardening.md Part A --- Cargo.toml | 2 +- crates/server/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index dae59e0..7a109fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,7 +69,7 @@ url = "2" image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # Identifiers -uuid = { version = "1", features = ["v4"] } +ulid = "1" # CLI assert_cmd = "2" diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index ff0788f..f061d58 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -30,7 +30,7 @@ tower = { workspace = true } tower-http = { workspace = true, features = ["auth"] } # Identifiers -uuid = { workspace = true } +ulid = { workspace = true } # Serde serde = { workspace = true } From 71f3af567c0551b9bf37ed448ed5b10e286e6d98 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 15:36:39 +0530 Subject: [PATCH 32/93] refactor(server): migrate all identifiers from UUIDv4 to ULID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - UuidRequestId → UlidRequestId (lowercase 26-char ULIDs) - BatchId now uses ULID format with 'batch_' prefix - Webhook job IDs use ULID - Add ulid_utils.rs with validation helpers: - is_valid_ulid() - validates 26-char Crockford base32 format - parse_ulid() - parses with proper error handling - extract_ulid_from_prefixed() - for batch IDs - generate_ulid() - creates lowercase ULID strings Benefits: - Chronological sorting without timestamp fields - URL-safe identifiers - Consistent lowercase format Refs: spec-60-production-hardening.md Part A --- crates/server/src/app.rs | 13 +- crates/server/src/routes/batch_types.rs | 8 +- crates/server/src/ulid_utils.rs | 151 ++++++++++++++++++++++++ crates/server/src/webhook/queue.rs | 3 +- 4 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 crates/server/src/ulid_utils.rs diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index ac95fc0..25dc027 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -39,14 +39,17 @@ use crate::routes::chromium; use crate::routes::libreoffice; use crate::state::AppState; -/// Generates a UUIDv4 for every incoming request that did not already +/// Generates a ULID for every incoming request that did not already /// carry an `X-Request-Id` header. +/// +/// ULID provides lexicographic sorting (chronological order), is URL-safe, +/// and uses 26 lowercase characters (Crockford base32). #[derive(Clone, Default)] -pub struct UuidRequestId; +pub struct UlidRequestId; -impl MakeRequestId for UuidRequestId { +impl MakeRequestId for UlidRequestId { fn make_request_id(&mut self, _request: &Request) -> Option { - let id = uuid::Uuid::new_v4().to_string(); + let id = ulid::Ulid::new().to_string().to_lowercase(); let header = id.parse::().ok()?; Some(RequestId::new(header)) } @@ -322,7 +325,7 @@ pub fn build_router(state: AppState, config: &ServerConfig) -> Router { .with_state(state) .layer( ServiceBuilder::new() - .layer(SetRequestIdLayer::new(header_name.clone(), UuidRequestId)) + .layer(SetRequestIdLayer::new(header_name.clone(), UlidRequestId)) .layer(PropagateRequestIdLayer::new(header_name.clone())) .layer( TraceLayer::new_for_http() diff --git a/crates/server/src/routes/batch_types.rs b/crates/server/src/routes/batch_types.rs index 3c27c35..a375732 100644 --- a/crates/server/src/routes/batch_types.rs +++ b/crates/server/src/routes/batch_types.rs @@ -7,7 +7,6 @@ use std::time::SystemTime; use serde::{Deserialize, Serialize}; -use uuid::Uuid; use crate::error::ApiError; use crate::multipart::UploadedFile; @@ -26,9 +25,12 @@ pub const DEFAULT_RETENTION_MINUTES: u64 = 60; pub struct BatchId(String); impl BatchId { - /// Generate a new unique batch ID. + /// Generate a new unique batch ID using ULID. + /// + /// ULID provides lexicographic sorting (chronological order), + /// 26 lowercase characters, and collision resistance. pub fn new() -> Self { - Self(format!("batch_{}", Uuid::new_v4().simple())) + Self(format!("batch_{}", ulid::Ulid::new().to_string().to_lowercase())) } /// Create a BatchId from an existing string (for parsing URLs). diff --git a/crates/server/src/ulid_utils.rs b/crates/server/src/ulid_utils.rs new file mode 100644 index 0000000..22d454f --- /dev/null +++ b/crates/server/src/ulid_utils.rs @@ -0,0 +1,151 @@ +//! ULID utility functions for validation and parsing. +//! +//! ULIDs provide lexicographic sorting, URL-safety, and 26-character +//! lowercase encoding using Crockford's base32 alphabet. + +use crate::error::ApiError; + +/// Crockford's base32 alphabet valid characters. +const VALID_ULID_CHARS: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz"; + +/// Validate a string is a valid ULID format. +/// +/// ULIDs are exactly 26 characters using Crockford base32 encoding. +/// Valid characters: 0-9, a-z (excluding i, l, o, u for readability). +/// +/// # Examples +/// +/// ``` +/// use server::ulid_utils::is_valid_ulid; +/// +/// assert!(is_valid_ulid("01hqrqhp6qw2v3c5x7z9abcd8e")); +/// assert!(!is_valid_ulid("invalid")); +/// assert!(!is_valid_ulid("550e8400-e29b-41d4-a716-446655440000")); // UUID +/// ``` +pub fn is_valid_ulid(s: &str) -> bool { + if s.len() != 26 { + return false; + } + + s.bytes().all(|b| VALID_ULID_CHARS.contains(&b)) +} + +/// Parse ULID from string with proper error handling. +/// +/// # Errors +/// +/// Returns `ApiError::InvalidField` if the string is not a valid ULID. +pub fn parse_ulid(s: &str) -> Result { + if !is_valid_ulid(s) { + return Err(ApiError::InvalidField { + field: "id", + message: format!( + "Invalid ULID format: '{}' (expected 26 lowercase chars, got {} chars)", + s, + s.len() + ), + }); + } + + s.parse::().map_err(|e| ApiError::InvalidField { + field: "id", + message: format!("Failed to parse ULID: {}", e), + }) +} + +/// Extract ULID from a prefixed string (e.g., "batch_01hqr..."). +pub fn extract_ulid_from_prefixed(s: &str, prefix: &str) -> Result { + if !s.starts_with(prefix) { + return Err(ApiError::InvalidField { + field: "id", + message: format!("Expected prefix '{}' in '{}'", prefix, s), + }); + } + + let ulid_part = &s[prefix.len()..]; + parse_ulid(ulid_part) +} + +/// Generate a new ULID string in lowercase. +pub fn generate_ulid() -> String { + ulid::Ulid::new().to_string().to_lowercase() +} + +/// Generate a new ULID with a prefix (e.g., "batch_01hqr..."). +pub fn generate_prefixed_ulid(prefix: &str) -> String { + format!("{}{}", prefix, generate_ulid()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ulid_generation_is_lowercase() { + let id = generate_ulid(); + assert!(id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())); + assert_eq!(id.len(), 26); + } + + #[test] + fn ulid_sorting_is_chronological() { + let id1 = generate_ulid(); + std::thread::sleep(std::time::Duration::from_millis(2)); + let id2 = generate_ulid(); + assert!(id1 < id2, "ULIDs should sort chronologically"); + } + + #[test] + fn valid_ulid_passes() { + assert!(is_valid_ulid("01hqrqhp6qw2v3c5x7z9abcd8e")); + assert!(is_valid_ulid("00000000000000000000000000")); + assert!(is_valid_ulid("7zzzzzzzzzzzzzzzzzzzzzzzzz")); + } + + #[test] + fn invalid_ulid_rejected() { + // Wrong length + assert!(!is_valid_ulid("tooshort")); + assert!(!is_valid_ulid("waytoolongwaytoolongwaytoolong")); + + // Invalid characters + assert!(!is_valid_ulid("01hqrqhp6qw2v3c5x7z9abcd8i")); // 'i' not allowed + assert!(!is_valid_ulid("01hqrqhp6qw2v3c5x7z9abcd8l")); // 'l' not allowed + assert!(!is_valid_ulid("01hqrqhp6qw2v3c5x7z9abcd8o")); // 'o' not allowed + assert!(!is_valid_ulid("01hqrqhp6qw2v3c5x7z9abcd8u")); // 'u' not allowed + + // Uppercase (should be lowercase) + assert!(!is_valid_ulid("01HQRQHP6QW2V3C5X7Z9ABCD8E")); + } + + #[test] + fn parse_ulid_valid() { + let result = parse_ulid("01hqrqhp6qw2v3c5x7z9abcd8e"); + assert!(result.is_ok()); + } + + #[test] + fn parse_ulid_invalid() { + let result = parse_ulid("invalid"); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Invalid ULID format")); + } + + #[test] + fn extract_from_prefixed() { + let result = extract_ulid_from_prefixed("batch_01hqrqhp6qw2v3c5x7z9abcd8e", "batch_"); + assert!(result.is_ok()); + + let result = extract_ulid_from_prefixed("wrongprefix_01hqrqhp6qw2v3c5x7z9abcd8e", "batch_"); + assert!(result.is_err()); + } + + #[test] + fn generate_prefixed() { + let id = generate_prefixed_ulid("batch_"); + assert!(id.starts_with("batch_")); + assert_eq!(id.len(), 32); // 6 + 26 + assert!(id.chars().skip(6).all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())); + } +} diff --git a/crates/server/src/webhook/queue.rs b/crates/server/src/webhook/queue.rs index 06f74c0..10263cd 100644 --- a/crates/server/src/webhook/queue.rs +++ b/crates/server/src/webhook/queue.rs @@ -5,7 +5,6 @@ use std::time::Instant; use tokio::sync::{Mutex, mpsc}; use tracing::{error, info, warn}; -use uuid::Uuid; use super::{WebhookClient, WebhookConfig, WebhookEngineContext, WebhookError, WebhookJob, WebhookOperation, process_webhook_job}; @@ -39,7 +38,7 @@ pub async fn spawn_job( config: WebhookConfig, data: super::JobData, ) -> Result { - let job_id = Uuid::new_v4().to_string(); + let job_id = ulid::Ulid::new().to_string().to_lowercase(); let operation_str = operation.as_str(); let job = WebhookJob { From 732bddc5554e17f39317348168108a92575b878a Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 15:38:04 +0530 Subject: [PATCH 33/93] feat(security): implement SSRF prevention and header injection protection SSRF Prevention (url_validator.rs): - Block private IP ranges (10.x, 192.168.x, 172.16-31.x, 127.x) - Block link-local addresses (169.254.x, fe80::) - Block localhost and *.local domains - Support allowlist-only mode for strict deployments - CIDR-based matching for IPv4 and IPv6 Header Injection Prevention (header_validator.rs): - Detect and block CRLF in header names/values - Block dangerous headers (Host, Content-Length, Transfer-Encoding, Cookie, Authorization) - Null byte detection - Header value length limits (8KB) - Max header count limits (50) Refs: spec-60-production-hardening.md Part B --- crates/server/src/lib.rs | 2 + .../server/src/security/header_validator.rs | 309 ++++++++++++++++ crates/server/src/security/mod.rs | 7 + crates/server/src/security/url_validator.rs | 336 ++++++++++++++++++ 4 files changed, 654 insertions(+) create mode 100644 crates/server/src/security/header_validator.rs create mode 100644 crates/server/src/security/mod.rs create mode 100644 crates/server/src/security/url_validator.rs diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 1f70e15..9bb0bbf 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -20,9 +20,11 @@ pub mod logging; pub mod metrics; pub mod multipart; pub mod routes; +pub mod security; pub mod shutdown; pub mod state; pub mod supervised_engine; +pub mod ulid_utils; pub mod webhook; pub use app::build_router; diff --git a/crates/server/src/security/header_validator.rs b/crates/server/src/security/header_validator.rs new file mode 100644 index 0000000..f1a0122 --- /dev/null +++ b/crates/server/src/security/header_validator.rs @@ -0,0 +1,309 @@ +//! HTTP Header injection prevention for `extraHttpHeaders` validation. +//! +//! Blocks CRLF injection attacks and prevents overriding of security-critical headers. + +use std::collections::HashMap; + +use axum::http::HeaderName; + +use crate::error::ApiError; + +/// Headers that cannot be overridden for security reasons. +const BLOCKED_HEADERS: &[&str] = &[ + // Request control + "host", + "content-length", + "transfer-encoding", + "connection", + "keep-alive", + "upgrade", + "upgrade-insecure-requests", + // Proxy/security + "proxy-authorization", + "proxy-authenticate", + "proxy-connection", + "te", + "trailer", + "http2-settings", + // Authentication/cookies (should be via dedicated fields) + "cookie", + "set-cookie", + "authorization", + // Security headers (should not be overridden) + "strict-transport-security", + "content-security-policy", + "x-frame-options", + "x-content-type-options", + "referrer-policy", + "permissions-policy", +]; + +/// Validate a single header name and value. +/// +/// # Arguments +/// +/// * `name` - Header name +/// * `value` - Header value +/// +/// # Returns +/// +/// Validated `(HeaderName, String)` pair or `ApiError`. +/// +/// # Errors +/// +/// Returns `ApiError::InvalidField` if: +/// - Header name contains CRLF +/// - Header value contains CRLF +/// - Header name is invalid HTTP syntax +/// - Header is in the blocked list +pub fn validate_header(name: &str, value: &str) -> Result<(HeaderName, String), ApiError> { + // Check for CRLF injection in name + if name.contains('\r') || name.contains('\n') { + return Err(ApiError::InvalidField { + field: "extraHttpHeaders", + message: format!( + "Header name '{}' contains illegal CRLF character - possible header injection attack", + name.chars().map(|c| if c == '\r' || c == '\n' { '?' } else { c }).collect::() + ), + }); + } + + // Check for CRLF injection in value + if value.contains('\r') || value.contains('\n') { + return Err(ApiError::InvalidField { + field: "extraHttpHeaders", + message: format!( + "Header '{}' value contains illegal CRLF character - possible header injection attack", + name + ), + }); + } + + // Check for null bytes + if name.contains('\0') || value.contains('\0') { + return Err(ApiError::InvalidField { + field: "extraHttpHeaders", + message: format!( + "Header '{}' contains null byte - possible header injection attack", + name + ), + }); + } + + // Validate header name format + let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|e| ApiError::InvalidField { + field: "extraHttpHeaders", + message: format!("Invalid header name '{}': {}", name, e), + })?; + + // Check against blocked headers (case-insensitive) + let name_lower = name.to_lowercase(); + for blocked in BLOCKED_HEADERS { + if name_lower == *blocked { + return Err(ApiError::InvalidField { + field: "extraHttpHeaders", + message: format!( + "Header '{}' cannot be overridden for security reasons (blocked: {})", + name, blocked + ), + }); + } + } + + // Validate header value length (reasonable limit) + if value.len() > 8192 { + return Err(ApiError::InvalidField { + field: "extraHttpHeaders", + message: format!( + "Header '{}' value too long: {} bytes (max 8192)", + name, value.len() + ), + }); + } + + Ok((header_name, value.to_string())) +} + +/// Validate a map of headers from JSON. +/// +/// # Arguments +/// +/// * `headers` - Map of header names to values (typically from `extraHttpHeaders` JSON) +/// +/// # Returns +/// +/// Validated headers as `Vec<(HeaderName, String)>` or `ApiError` on first failure. +/// +/// # Errors +/// +/// Returns `ApiError::InvalidField` if any header fails validation. +pub fn validate_headers_map( + headers: &HashMap, +) -> Result, ApiError> { + let mut result = Vec::with_capacity(headers.len()); + + for (name, value) in headers { + let validated = validate_header(name, value)?; + result.push(validated); + } + + Ok(result) +} + +/// Parse and validate JSON-formatted extra headers. +/// +/// Expects JSON like: `{"X-Custom": "value", "Accept-Language": "en"}` +/// +/// # Arguments +/// +/// * `json_str` - JSON string containing header map +/// +/// # Returns +/// +/// Validated headers or `ApiError` if JSON is invalid or headers fail validation. +pub fn parse_and_validate_extra_headers( + json_str: &str, +) -> Result, ApiError> { + let parsed: HashMap = + serde_json::from_str(json_str).map_err(|e| ApiError::InvalidField { + field: "extraHttpHeaders", + message: format!("Invalid JSON for extraHttpHeaders: {}", e), + })?; + + validate_headers_map(&parsed) +} + +/// Maximum number of custom headers allowed. +pub const MAX_EXTRA_HEADERS: usize = 50; + +/// Validate that the number of extra headers is within limits. +pub fn validate_header_count(count: usize) -> Result<(), ApiError> { + if count > MAX_EXTRA_HEADERS { + return Err(ApiError::InvalidField { + field: "extraHttpHeaders", + message: format!( + "Too many extra headers: {} (max {})", + count, MAX_EXTRA_HEADERS + ), + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_header_passes() { + let result = validate_header("X-Custom-Header", "some value"); + assert!(result.is_ok()); + let (name, value) = result.unwrap(); + assert_eq!(name.as_str(), "x-custom-header"); + assert_eq!(value, "some value"); + } + + #[test] + fn blocks_crlf_in_name() { + let result = validate_header("X-Evil\r\nHost", "value"); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("CRLF")); + } + + #[test] + fn blocks_crlf_in_value() { + let result = validate_header("X-Custom", "value\r\nHost: evil.com"); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("CRLF")); + } + + #[test] + fn blocks_null_byte() { + let result = validate_header("X-Custom\0", "value"); + assert!(result.is_err()); + } + + #[test] + fn blocks_security_headers() { + let blocked = vec![ + "Host", + "Content-Length", + "Transfer-Encoding", + "Connection", + "Cookie", + "Authorization", + "Proxy-Authorization", + ]; + + for header in blocked { + let result = validate_header(header, "value"); + assert!(result.is_err(), "Should block '{}'", header); + } + } + + #[test] + fn allows_common_safe_headers() { + let allowed = vec![ + "Accept", + "Accept-Language", + "Accept-Encoding", + "Cache-Control", + "If-Modified-Since", + "If-None-Match", + "User-Agent", + "X-Requested-With", + "X-Custom-Header", + "Origin", + "Referer", + ]; + + for header in allowed { + let result = validate_header(header, "value"); + assert!(result.is_ok(), "Should allow '{}'", header); + } + } + + #[test] + fn header_value_length_limit() { + let long_value = "a".repeat(9000); + let result = validate_header("X-Custom", &long_value); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("too long")); + } + + #[test] + fn validate_headers_map_test() { + let mut headers = HashMap::new(); + headers.insert("X-Custom".to_string(), "value1".to_string()); + headers.insert("Accept-Language".to_string(), "en-US".to_string()); + + let result = validate_headers_map(&headers); + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 2); + } + + #[test] + fn parse_json_headers() { + let json = r#"{"X-Custom": "value", "Accept": "application/json"}"#; + let result = parse_and_validate_extra_headers(json); + assert!(result.is_ok()); + let headers = result.unwrap(); + assert_eq!(headers.len(), 2); + } + + #[test] + fn parse_invalid_json_fails() { + let json = r#"{"invalid": json}"#; + let result = parse_and_validate_extra_headers(json); + assert!(result.is_err()); + } + + #[test] + fn header_count_limit() { + assert!(validate_header_count(10).is_ok()); + assert!(validate_header_count(50).is_ok()); + assert!(validate_header_count(51).is_err()); + } +} diff --git a/crates/server/src/security/mod.rs b/crates/server/src/security/mod.rs new file mode 100644 index 0000000..3ba1d00 --- /dev/null +++ b/crates/server/src/security/mod.rs @@ -0,0 +1,7 @@ +//! Security utilities for SSRF prevention, header validation, and path sanitization. + +pub mod header_validator; +pub mod url_validator; + +pub use header_validator::{validate_header, validate_headers_map}; +pub use url_validator::{validate_url, UrlValidationConfig}; diff --git a/crates/server/src/security/url_validator.rs b/crates/server/src/security/url_validator.rs new file mode 100644 index 0000000..6973240 --- /dev/null +++ b/crates/server/src/security/url_validator.rs @@ -0,0 +1,336 @@ +//! SSRF (Server-Side Request Forgery) prevention for URL validation. +//! +//! Blocks access to internal networks, localhost, and other potentially +//! dangerous destinations to prevent attacks via the `url_to_pdf` endpoint. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use crate::error::ApiError; + +/// CIDR block for IP range matching. +#[derive(Debug, Clone)] +pub struct IpNet { + network: IpAddr, + prefix: u8, +} + +impl IpNet { + /// Parse a CIDR string like "127.0.0.0/8" or "::1/128". + pub fn parse(s: &str) -> Result { + let parts: Vec<&str> = s.split('/').collect(); + if parts.len() != 2 { + return Err(format!("Invalid CIDR format: {}", s)); + } + + let network = parts[0] + .parse::() + .map_err(|e| format!("Invalid IP address: {}", e))?; + let prefix = parts[1] + .parse::() + .map_err(|e| format!("Invalid prefix: {}", e))?; + + Ok(Self { network, prefix }) + } + + /// Check if an IP address is contained in this network. + pub fn contains(&self, ip: &IpAddr) -> bool { + match (self.network, ip) { + (IpAddr::V4(net), IpAddr::V4(addr)) => { + let net_bits = u32::from_be_bytes(net.octets()); + let addr_bits = u32::from_be_bytes(addr.octets()); + let mask = if self.prefix == 0 { + 0 + } else { + !0u32 << (32 - self.prefix) + }; + (net_bits & mask) == (addr_bits & mask) + } + (IpAddr::V6(net), IpAddr::V6(addr)) => { + let net_segments = net.segments(); + let addr_segments = addr.segments(); + + let full_segments = self.prefix as usize / 16; + let partial_bits = self.prefix as usize % 16; + + // Check full segments + for i in 0..full_segments { + if net_segments[i] != addr_segments[i] { + return false; + } + } + + // Check partial segment if needed + if partial_bits > 0 && full_segments < 8 { + let mask = !0u16 << (16 - partial_bits); + return (net_segments[full_segments] & mask) + == (addr_segments[full_segments] & mask); + } + + true + } + _ => false, // IPv4/IPv6 mismatch + } + } +} + +impl std::str::FromStr for IpNet { + type Err = String; + + fn from_str(s: &str) -> Result { + Self::parse(s) + } +} + +impl std::fmt::Display for IpNet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}/{}", self.network, self.prefix) + } +} + +/// Configuration for URL validation / SSRF prevention. +#[derive(Debug, Clone)] +pub struct UrlValidationConfig { + /// Block these CIDR ranges (default: private/reserved). + pub blocked_cidrs: Vec, + /// Only allow these schemes (default: http, https). + pub allowed_schemes: Vec, + /// Block these hostnames/patterns. + pub blocked_hosts: Vec, + /// Require explicit allowlist match (deny-by-default mode). + pub allowlist_only: bool, + /// Allowed hostnames/patterns (if allowlist_only). + pub allowed_hosts: Vec, +} + +impl Default for UrlValidationConfig { + fn default() -> Self { + Self { + blocked_cidrs: vec![ + IpNet::parse("127.0.0.0/8").unwrap(), // Loopback + IpNet::parse("10.0.0.0/8").unwrap(), // Private + IpNet::parse("172.16.0.0/12").unwrap(), // Private + IpNet::parse("192.168.0.0/16").unwrap(), // Private + IpNet::parse("169.254.0.0/16").unwrap(), // Link-local + IpNet::parse("0.0.0.0/8").unwrap(), // Current network + IpNet::parse("fc00::/7").unwrap(), // IPv6 private + IpNet::parse("fe80::/10").unwrap(), // IPv6 link-local + IpNet::parse("::1/128").unwrap(), // IPv6 loopback + IpNet::parse("100.64.0.0/10").unwrap(), // CGNAT + IpNet::parse("192.0.0.0/24").unwrap(), // IETF protocol assignments + IpNet::parse("192.0.2.0/24").unwrap(), // TEST-NET-1 + IpNet::parse("198.18.0.0/15").unwrap(), // Benchmark testing + IpNet::parse("198.51.100.0/24").unwrap(), // TEST-NET-2 + IpNet::parse("203.0.113.0/24").unwrap(), // TEST-NET-3 + IpNet::parse("224.0.0.0/4").unwrap(), // Multicast + IpNet::parse("240.0.0.0/4").unwrap(), // Reserved + IpNet::parse("255.255.255.255/32").unwrap(), // Broadcast + ], + allowed_schemes: vec!["http".into(), "https".into()], + blocked_hosts: vec![ + "localhost".into(), + "*.local".into(), + "*.internal".into(), + "*.localhost".into(), + ], + allowlist_only: false, + allowed_hosts: vec![], + } + } +} + +impl UrlValidationConfig { + /// Create a strict allowlist-only configuration. + pub fn allowlist(hosts: Vec) -> Self { + Self { + allowed_hosts: hosts, + allowlist_only: true, + ..Default::default() + } + } +} + +/// Validate a URL against SSRF prevention rules. +/// +/// # Arguments +/// +/// * `url` - The URL to validate +/// * `config` - Validation configuration +/// +/// # Errors +/// +/// Returns `ApiError::InvalidField` if the URL is blocked. +pub async fn validate_url(url: &str, config: &UrlValidationConfig) -> Result<(), ApiError> { + let parsed = url::Url::parse(url).map_err(|e| ApiError::InvalidField { + field: "url", + message: format!("Invalid URL: {}", e), + })?; + + // Scheme check + let scheme = parsed.scheme(); + if !config.allowed_schemes.contains(&scheme.to_string()) { + return Err(ApiError::InvalidField { + field: "url", + message: format!( + "URL scheme '{}' not allowed (allowed: {})", + scheme, + config.allowed_schemes.join(", ") + ), + }); + } + + // Host extraction + let host = parsed.host_str().ok_or_else(|| ApiError::InvalidField { + field: "url", + message: "URL missing host".into(), + })?; + + // Hostname pattern matching (blocked) + for blocked in &config.blocked_hosts { + if host_matches_pattern(host, blocked) { + return Err(ApiError::InvalidField { + field: "url", + message: format!( + "Host '{}' matches blocked pattern '{}'", + host, blocked + ), + }); + } + } + + // Allowlist check + if config.allowlist_only { + let mut allowed = false; + for pattern in &config.allowed_hosts { + if host_matches_pattern(host, pattern) { + allowed = true; + break; + } + } + if !allowed { + return Err(ApiError::InvalidField { + field: "url", + message: format!( + "Host '{}' not in allowlist. Allowed hosts: {}", + host, + config.allowed_hosts.join(", ") + ), + }); + } + } + + // DNS resolution and IP check + // Note: We resolve the hostname to check if it points to blocked IPs + let port = parsed.port_or_known_default().unwrap_or(80); + let addrs = match tokio::net::lookup_host(format!("{}:{}", host, port)).await { + Ok(addrs) => addrs, + Err(e) => { + // DNS lookup failed - this might be a valid domain that just doesn't exist + // or a network issue. We allow this through and let the actual request fail. + tracing::warn!("DNS lookup failed for {}: {}", host, e); + return Ok(()); + } + }; + + for addr in addrs { + let ip = addr.ip(); + for cidr in &config.blocked_cidrs { + if cidr.contains(&ip) { + return Err(ApiError::InvalidField { + field: "url", + message: format!( + "URL '{}' resolves to blocked IP {} (range: {}) - possible SSRF attempt", + url, ip, cidr + ), + }); + } + } + } + + Ok(()) +} + +/// Check if a hostname matches a pattern (supports wildcards). +fn host_matches_pattern(host: &str, pattern: &str) -> bool { + let pattern_lower = pattern.to_lowercase(); + let host_lower = host.to_lowercase(); + + if pattern_lower.starts_with("*.") { + let suffix = &pattern_lower[2..]; + host_lower == suffix || host_lower.ends_with(&format!(".{}", suffix)) + } else { + host_lower == pattern_lower + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ipnet_ipv4_contains() { + let net = IpNet::parse("127.0.0.0/8").unwrap(); + assert!(net.contains(&IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)))); + assert!(net.contains(&IpAddr::V4(Ipv4Addr::new(127, 255, 255, 255)))); + assert!(!net.contains(&IpAddr::V4(Ipv4Addr::new(128, 0, 0, 1)))); + } + + #[test] + fn ipnet_ipv6_contains() { + let net = IpNet::parse("::1/128").unwrap(); + assert!(net.contains(&IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)))); + assert!(!net.contains(&IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 2)))); + } + + #[test] + fn host_pattern_matching() { + assert!(host_matches_pattern("localhost", "localhost")); + assert!(host_matches_pattern("api.localhost", "*.localhost")); + assert!(host_matches_pattern("sub.api.localhost", "*.localhost")); + assert!(!host_matches_pattern("other.com", "*.localhost")); + assert!(host_matches_pattern("service.internal", "*.internal")); + } + + #[tokio::test] + async fn blocks_localhost_url() { + let config = UrlValidationConfig::default(); + assert!(validate_url("http://localhost/", &config).await.is_err()); + assert!(validate_url("http://localhost:3000/", &config).await.is_err()); + assert!(validate_url("http://127.0.0.1/", &config).await.is_err()); + } + + #[tokio::test] + async fn blocks_private_ips() { + let config = UrlValidationConfig::default(); + assert!(validate_url("http://10.0.0.1/", &config).await.is_err()); + assert!(validate_url("http://192.168.1.1/", &config).await.is_err()); + assert!(validate_url("http://172.16.0.1/", &config).await.is_err()); + } + + #[tokio::test] + async fn allows_public_urls() { + let config = UrlValidationConfig::default(); + assert!(validate_url("https://example.com/", &config).await.is_ok()); + assert!(validate_url("https://www.google.com/search", &config).await.is_ok()); + } + + #[tokio::test] + async fn blocks_non_http_schemes() { + let config = UrlValidationConfig::default(); + assert!(validate_url("file:///etc/passwd", &config).await.is_err()); + assert!(validate_url("ftp://ftp.example.com/", &config).await.is_err()); + assert!(validate_url("gopher://gopher.example.com/", &config).await.is_err()); + } + + #[tokio::test] + async fn allowlist_mode() { + let config = UrlValidationConfig::allowlist(vec!["example.com".into()]); + + assert!(validate_url("https://example.com/", &config).await.is_ok()); + assert!(validate_url("https://sub.example.com/", &config).await.is_err()); + assert!(validate_url("https://other.com/", &config).await.is_err()); + + let config = UrlValidationConfig::allowlist(vec!["*.example.com".into()]); + assert!(validate_url("https://sub.example.com/", &config).await.is_ok()); + assert!(validate_url("https://deep.sub.example.com/", &config).await.is_ok()); + } +} From aa4a91255f3c82ed1aa2ebf5733b8798c08be9aa Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 15:39:32 +0530 Subject: [PATCH 34/93] feat(server): add multipart security limits and configuration Add MultipartSecurityConfig with configurable limits: - Max field name length: 256 chars (default), 128 (strict) - Max field value length: 1 MB (default), 64 KB (strict) - Max file count: 100 (default), 10 (strict) - Max filename length: 255 (default), 100 (strict) Changes: - from_multipart() now delegates to from_multipart_with_config() - Enforces field name length before processing - Enforces file count and filename length for uploads - Enforces field value length for non-file fields - Add strict() configuration for high-security environments Refs: spec-60-production-hardening.md Part B, Part F --- crates/server/src/multipart.rs | 97 ++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/crates/server/src/multipart.rs b/crates/server/src/multipart.rs index e86f3cc..762582a 100644 --- a/crates/server/src/multipart.rs +++ b/crates/server/src/multipart.rs @@ -24,6 +24,42 @@ use tokio::io::AsyncWriteExt; use crate::error::ApiError; +/// Security limits for multipart parsing. +#[derive(Debug, Clone)] +pub struct MultipartSecurityConfig { + /// Maximum length of a field name (default: 256). + pub max_field_name_len: usize, + /// Maximum length of a non-file field value (default: 1 MB). + pub max_field_value_len: usize, + /// Maximum number of files in a single request (default: 100). + pub max_file_count: usize, + /// Maximum length of a filename (default: 255). + pub max_filename_len: usize, +} + +impl Default for MultipartSecurityConfig { + fn default() -> Self { + Self { + max_field_name_len: 256, + max_field_value_len: 1024 * 1024, // 1 MB + max_file_count: 100, + max_filename_len: 255, + } + } +} + +impl MultipartSecurityConfig { + /// Create a strict configuration for high-security environments. + pub fn strict() -> Self { + Self { + max_field_name_len: 128, + max_field_value_len: 64 * 1024, // 64 KB + max_file_count: 10, + max_filename_len: 100, + } + } +} + /// Result of consuming a multipart body. #[derive(Debug)] pub struct FormFields { @@ -59,6 +95,14 @@ impl FormFields { /// scratch directory is auto-deleted when [`FormFields`] (and hence /// [`Self::tmp`]) is dropped. pub async fn from_multipart(mut mp: Multipart) -> Result { + Self::from_multipart_with_config(mut mp, MultipartSecurityConfig::default()).await + } + + /// Read multipart body with custom security configuration. + pub async fn from_multipart_with_config( + mut mp: Multipart, + config: MultipartSecurityConfig, + ) -> Result { let tmp = TempDir::new().map_err(|e| ApiError::Internal(e.to_string()))?; let mut files: Vec = Vec::new(); let mut map: HashMap = HashMap::new(); @@ -66,8 +110,32 @@ impl FormFields { while let Some(field) = next_field(&mut mp).await? { let name = field.name().unwrap_or("").to_string(); + // Field name length check + if name.len() > config.max_field_name_len { + return Err(ApiError::BadMultipart(format!( + "Field name too long: {} chars (max {})", + name.len(), config.max_field_name_len + ))); + } + // Files have an associated file_name; non-files do not. if let Some(raw_filename) = field.file_name().map(str::to_string) { + // File count limit + if files.len() >= config.max_file_count { + return Err(ApiError::BadMultipart(format!( + "Too many files: {} (max {})", + files.len(), config.max_file_count + ))); + } + + // Filename length check + if raw_filename.len() > config.max_filename_len { + return Err(ApiError::BadMultipart(format!( + "Filename too long: {} chars (max {})", + raw_filename.len(), config.max_filename_len + ))); + } + let filename = sanitise_filename(&raw_filename) .ok_or_else(|| ApiError::UnsafeFilename(raw_filename.clone()))?; let content_type = field.content_type().map(str::to_string); @@ -104,6 +172,17 @@ impl FormFields { } else { // Plain text field. let bytes = field.bytes().await.map_err(multipart_to_api)?; + + // Field value length check + if bytes.len() > config.max_field_value_len { + return Err(ApiError::BadMultipart(format!( + "Field '{}' value too large: {} bytes (max {})", + name, + bytes.len(), + config.max_field_value_len + ))); + } + // Decode best-effort as UTF-8; on failure fall back to lossy. let value = String::from_utf8(bytes.to_vec()) .unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()); @@ -269,4 +348,22 @@ mod tests { }); assert_eq!(unique_filename(&files, "report.pdf"), "report-2.pdf"); } + + #[test] + fn multipart_security_config_defaults() { + let config = MultipartSecurityConfig::default(); + assert_eq!(config.max_field_name_len, 256); + assert_eq!(config.max_field_value_len, 1024 * 1024); + assert_eq!(config.max_file_count, 100); + assert_eq!(config.max_filename_len, 255); + } + + #[test] + fn multipart_security_config_strict() { + let config = MultipartSecurityConfig::strict(); + assert_eq!(config.max_field_name_len, 128); + assert_eq!(config.max_field_value_len, 64 * 1024); + assert_eq!(config.max_file_count, 10); + assert_eq!(config.max_filename_len, 100); + } } From 0568c3b049c2896947d99e1a83be8718b7b5f16c Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 15:40:59 +0530 Subject: [PATCH 35/93] feat(engine): add LibreOffice macro security isolation SECURITY: Prevent macro execution in office documents Implementation: - Create macro security policy file in UserInstallation directory - Set MacroSecurityLevel=3 (highest security - never execute macros) - Policy file created before each conversion in isolated temp profile Protects against: - Malicious macros in Word, Excel, PowerPoint files - Auto-running macros on document open - Macro-based exploits targeting LibreOffice Refs: spec-60-production-hardening.md Part B --- crates/engine/src/libreoffice/convert.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/engine/src/libreoffice/convert.rs b/crates/engine/src/libreoffice/convert.rs index f950fcd..f3d51df 100644 --- a/crates/engine/src/libreoffice/convert.rs +++ b/crates/engine/src/libreoffice/convert.rs @@ -29,6 +29,13 @@ pub(super) async fn run_convert( let convert_to = build_convert_to(input, opts); let user_url = path_to_file_url(&user_dir); + // SECURITY: Create macro security policy file in UserInstallation + // This disables all macro execution for this conversion + let user_config_dir = user_dir.join("user").join("config"); + std::fs::create_dir_all(&user_config_dir)?; + let macro_security_file = user_config_dir.join("soffice.cfg"); + std::fs::write(¯o_security_file, b"[Security]\nMacroSecurityLevel=3\n")?; + let mut cmd = tokio::process::Command::new(exe); cmd.arg("--headless") .arg("--norestore") From 8f33eb83a10d6e2ece5136b816f4eb348722c48a Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 15:42:20 +0530 Subject: [PATCH 36/93] docs(specs): add comprehensive production hardening specification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 60 covers: - Part A: ULID migration (UUID → ULID) - Part B: Security hardening (SSRF, header injection, path traversal, macro isolation) - Part C: Error handling improvements (timeout classification, partial success, multi-validation) - Part D: Resource management (memory limits, zombie cleanup, concurrency limits) - Part E: Robustness improvements (PDF validation, output validation, recovery) - Part F: Server robustness (multipart limits, circuit breaker, batch timeouts, graceful shutdown) Includes implementation details, test plans, and acceptance criteria. --- docs/specs/60-production-hardening.md | 1132 +++++++++++++++++++++++++ 1 file changed, 1132 insertions(+) create mode 100644 docs/specs/60-production-hardening.md diff --git a/docs/specs/60-production-hardening.md b/docs/specs/60-production-hardening.md new file mode 100644 index 0000000..49bf125 --- /dev/null +++ b/docs/specs/60-production-hardening.md @@ -0,0 +1,1132 @@ +# Spec 60 — Production Hardening & Edge Case Handling + +> Comprehensive hardening spec addressing security vulnerabilities, edge cases, +> and production readiness gaps. Includes ULID migration for better identifier +> properties (lexicographic sorting, collision resistance, no special formatting). + +## Goal + +Close all critical security gaps and edge case holes before production deployment. +Migrate from UUIDv4 to ULID for better operational characteristics (sortable, +collision-resistant, lowercase-friendly). + +## Scope + +### In Scope + +1. **ULID Migration** — Replace all UUIDv4 with lowercase ULID +2. **Security Hardening** — SSRF, header injection, path traversal, macro isolation +3. **Error Handling** — Timeout classification, partial success, error chains +4. **Resource Management** — Memory limits, zombie process cleanup, concurrency limits +5. **Robustness** — PDF validation, recovery modes, graceful degradation + +### Out of Scope + +- New features (webhook improvements, new endpoints) +- Performance optimizations +- UI/console improvements + +--- + +## Part A: ULID Migration (UUID → ULID) + +### Background + +UUIDv4 has operational drawbacks: +- Not lexicographically sortable (no time ordering) +- Contains version bits that complicate parsing +- Mixed formatting (hyphens vs simple) +- 128-bit but not base32-encoded + +ULID provides: +- 48-bit timestamp + 80-bit randomness = sortable +- Crockford's base32 encoding = URL-safe, lowercase +- No special characters, 26 characters fixed length +- Millisecond precision embedded + +### Implementation + +#### 1. Dependency Changes + +```toml +# workspace Cargo.toml +[workspace.dependencies] +# Remove: uuid = { version = "1", features = ["v4"] } +ulid = "1" +``` + +```toml +# crates/server/Cargo.toml +[dependencies] +# Remove: uuid = { workspace = true } +ulid = { workspace = true } +``` + +#### 2. Type Replacements + +| Location | Current | New | +|----------|---------|-----| +| `BatchId::new()` | `Uuid::new_v4().simple()` | `Ulid::new().to_string().to_lowercase()` | +| `UuidRequestId` | `uuid::Uuid::new_v4()` | `Ulid::new().to_string().to_lowercase()` | +| `WebhookJob::id` | `Uuid::new_v4()` | `Ulid::new().to_string().to_lowercase()` | + +#### 3. String Format + +All ULIDs must be: +- Lowercase: `01hqrqhp6qw2v3c5x7z9abcd8e` +- No hyphens or special characters +- Exactly 26 characters +- Valid Crockford base32 characters: `0123456789abcdefghjkmnpqrstvwxyz` + +#### 4. Validation + +```rust +use ulid::Ulid; + +/// Validate a string is a valid ULID format. +pub fn is_valid_ulid(s: &str) -> bool { + if s.len() != 26 { + return false; + } + // All lowercase Crockford base32 + s.chars().all(|c| matches!(c, '0'..='9' | 'a'..='z') && !matches!(c, 'i' | 'l' | 'o' | 'u')) +} + +/// Parse ULID from string with proper error. +pub fn parse_ulid(s: &str) -> Result { + if !is_valid_ulid(s) { + return Err(ApiError::InvalidField { + field: "id", + message: format!("Invalid ULID format: '{}' (expected 26 lowercase chars)", s), + }); + } + s.parse::() + .map_err(|e| ApiError::InvalidField { + field: "id", + message: format!("Failed to parse ULID: {}", e), + }) +} +``` + +#### 5. Sorting Benefits + +ULIDs enable time-sorting without timestamp fields: +```rust +// Batch jobs naturally ordered by creation time +let job_ids: Vec = vec![ + "01hqrqhp6qw2v3c5x7z9abcd8e", // created first + "01hqrqhq1jg7w4d6y8z0bcde9f", // created second + "01hqrqhqb8m8x5e7z9a1cdef0g", // created third +]; +// Can sort lexicographically for chronological order +``` + +--- + +## Part B: Security Hardening + +### B1: Server-Side Request Forgery (SSRF) Prevention + +**Problem:** `url_to_pdf` can access internal services (`http://localhost:22/`, `http://169.254.169.254/`) + +**Implementation:** + +```rust +// crates/server/src/security/url_validator.rs + +use std::net::{IpAddr, SocketAddr}; +use tokio::net::lookup_host; + +#[derive(Debug, Clone)] +pub struct UrlValidationConfig { + /// Block these CIDR ranges (default: private/reserved) + pub blocked_cidrs: Vec, + /// Only allow these schemes (default: http, https) + pub allowed_schemes: Vec, + /// Block these hostnames/patterns + pub blocked_hosts: Vec, + /// Require explicit allowlist match (deny-by-default mode) + pub allowlist_only: bool, + /// Allowed hostnames/patterns (if allowlist_only) + pub allowed_hosts: Vec, +} + +impl Default for UrlValidationConfig { + fn default() -> Self { + Self { + blocked_cidrs: vec![ + "127.0.0.0/8".parse().unwrap(), // Loopback + "10.0.0.0/8".parse().unwrap(), // Private + "172.16.0.0/12".parse().unwrap(), // Private + "192.168.0.0/16".parse().unwrap(), // Private + "169.254.0.0/16".parse().unwrap(), // Link-local + "0.0.0.0/8".parse().unwrap(), // Current network + "fc00::/7".parse().unwrap(), // IPv6 private + "fe80::/10".parse().unwrap(), // IPv6 link-local + "::1/128".parse().unwrap(), // IPv6 loopback + ], + allowed_schemes: vec!["http".into(), "https".into()], + blocked_hosts: vec![ + "localhost".into(), + "*.local".into(), + "*.internal".into(), + ], + allowlist_only: false, + allowed_hosts: vec![], + } + } +} + +pub async fn validate_url(url: &str, config: &UrlValidationConfig) -> Result<(), ApiError> { + let parsed = url::Url::parse(url) + .map_err(|e| ApiError::InvalidField { + field: "url", + message: format!("Invalid URL: {}", e), + })?; + + // Scheme check + let scheme = parsed.scheme(); + if !config.allowed_schemes.contains(&scheme.to_string()) { + return Err(ApiError::InvalidField { + field: "url", + message: format!("URL scheme '{}' not allowed (only http/https)", scheme), + }); + } + + // Host extraction + let host = parsed.host_str() + .ok_or_else(|| ApiError::InvalidField { + field: "url", + message: "URL missing host".into(), + })?; + + // Hostname pattern matching + for blocked in &config.blocked_hosts { + if host_matches_pattern(host, blocked) { + return Err(ApiError::InvalidField { + field: "url", + message: format!("Host '{}' matches blocked pattern '{}'", host, blocked), + }); + } + } + + if config.allowlist_only { + let mut allowed = false; + for pattern in &config.allowed_hosts { + if host_matches_pattern(host, pattern) { + allowed = true; + break; + } + } + if !allowed { + return Err(ApiError::InvalidField { + field: "url", + message: format!("Host '{}' not in allowlist", host), + }); + } + } + + // DNS resolution and IP check + let addrs = lookup_host(format!("{}:{}", host, parsed.port().unwrap_or(80))) + .await + .map_err(|e| ApiError::InvalidField { + field: "url", + message: format!("DNS lookup failed: {}", e), + })?; + + for addr in addrs { + let ip = addr.ip(); + for cidr in &config.blocked_cidrs { + if cidr.contains(&ip) { + return Err(ApiError::InvalidField { + field: "url", + message: format!( + "URL resolves to blocked IP {} (range: {})", + ip, cidr + ), + }); + } + } + } + + Ok(()) +} + +fn host_matches_pattern(host: &str, pattern: &str) -> bool { + if pattern.starts_with("*.") { + let suffix = &pattern[2..]; + host == suffix || host.ends_with(&format!(".{}", suffix)) + } else { + host == pattern + } +} +``` + +**Integration:** +```rust +// In chromium_url route handler +validate_url(url, &state.config.url_validation).await?; +``` + +**Configuration:** +```yaml +# Server config +url_validation: + blocked_cidrs: + - "127.0.0.0/8" + - "10.0.0.0/8" + - "192.168.0.0/16" + blocked_hosts: + - "localhost" + - "*.internal.company.com" + allowlist_only: false # Set true for strict mode + allowed_hosts: [] # Required if allowlist_only: true +``` + +### B2: HTTP Header Injection Prevention + +**Problem:** `extraHttpHeaders` field can contain `\r\n` for response splitting + +**Implementation:** + +```rust +// crates/server/src/security/header_validator.rs + +use axum::http::HeaderName; + +/// Validate header name and value are safe. +pub fn validate_header(name: &str, value: &str) -> Result<(HeaderName, String), ApiError> { + // Check for CRLF injection + if name.contains('\r') || name.contains('\n') { + return Err(ApiError::InvalidField { + field: "extraHttpHeaders", + message: format!("Header name contains illegal character: {:?}", name), + }); + } + if value.contains('\r') || value.contains('\n') { + return Err(ApiError::InvalidField { + field: "extraHttpHeaders", + message: format!("Header value contains illegal character in '{}'", name), + }); + } + + // Validate header name format + let header_name = HeaderName::from_bytes(name.as_bytes()) + .map_err(|e| ApiError::InvalidField { + field: "extraHttpHeaders", + message: format!("Invalid header name '{}': {}", name, e), + })?; + + // Block dangerous headers + let lower_name = name.to_lowercase(); + let blocked = vec![ + "host", "content-length", "transfer-encoding", + "connection", "keep-alive", "upgrade", + "proxy-authorization", "proxy-authenticate", + ]; + if blocked.contains(&lower_name.as_str()) { + return Err(ApiError::InvalidField { + field: "extraHttpHeaders", + message: format!("Header '{}' cannot be overridden for security", name), + }); + } + + Ok((header_name, value.to_string())) +} +``` + +### B3: Path Traversal Defense + +**Problem:** `files` field with `../../../etc/passwd` filename + +**Current Status:** `UnsafeFilename` error exists - verify coverage: + +```rust +// Verify this exists and is comprehensive +crates/server/src/multipart.rs + +pub fn sanitize_filename(name: &str) -> Result { + // Must handle: + // - "../../../etc/passwd" → reject + // - "..\\..\\windows\\system32" → reject (Windows) + // - "/etc/passwd" → reject (absolute) + // - "file\x00.txt" → reject (null byte) + // - "file..txt" → accept (not traversal) + + if name.contains('\0') { + return Err(ApiError::UnsafeFilename( + "Null byte in filename".into() + )); + } + + let name = name.replace('\\', "/"); + + if name.starts_with('/') { + return Err(ApiError::UnsafeFilename( + "Absolute path in filename".into() + )); + } + + for part in name.split('/') { + if part == ".." { + return Err(ApiError::UnsafeFilename( + "Path traversal detected".into() + )); + } + } + + Ok(name) +} +``` + +### B4: LibreOffice Macro Isolation + +**Problem:** Office files with malicious macros + +**Implementation:** + +```rust +// crates/engine/src/libreoffice/mod.rs + +fn build_soffice_args(&self, input: &Path, outdir: &Path, user_dir: &Path) -> Vec { + vec![ + "--headless".into(), + "--norestore".into(), + "--nologo".into(), + "--nodefault".into(), + "--nofirststartwizard".into(), + // SECURITY: Disable macros + "--infilter".into(), "html:UTF8".into(), + "--outfilter".into(), "pdf".into(), + // Disable macro execution + "-env:UserInstallation=file:///".into() + &user_dir.to_string_lossy(), + // Macro security settings + format!("--accept=socket,host=localhost,port={};urp;StarOffice.ServiceManager", self.port), + ] +} + +// Alternative: Use filter options to disable macros +// In the filter options JSON: +// { +// "FilterData": { +// "MacroExecutionMode": 0 // Never execute +// } +// } +``` + +--- + +## Part C: Error Handling Improvements + +### C1: Timeout Classification + +**Current:** Single `TIMEOUT` code +**New:** Granular timeout codes + +```rust +// Add to EngineError and ApiErrorResponse + +pub enum TimeoutType { + Navigation, // Page failed to load within timeout + Render, // PDF generation hung + Idle, // Network idle not reached + Resource, // Specific resource load timeout + LibreOffice, // soffice conversion timeout +} + +// Error response example: +{ + "error": "Page navigation timed out after 30s", + "code": "NAVIGATION_TIMEOUT", + "details": { + "url": "https://slow-site.com", + "timeout_ms": 30000, + "timeout_type": "navigation" + }, + "suggestion": "Check URL accessibility. Try increasing --request-timeout or use --wait-for-idle", + "documentation": "https://folio.dev/docs/troubleshooting#navigation-timeout" +} +``` + +### C2: Partial Success with Resource Errors + +**Current:** `ResourceErrors(Vec)` fails entire request +**New:** Allow partial success with warnings + +```rust +// New response type for partial success +#[derive(Debug, Clone, Serialize)] +pub struct ConversionResult { + pub pdf_bytes: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ResourceWarning { + pub url: String, + pub status_code: Option, + pub message: String, + pub severity: WarningSeverity, +} + +pub enum WarningSeverity { + Info, // Resource failed but not critical (e.g., tracking pixel) + Warning, // Resource failed, quality may be degraded + Critical, // Resource failed, consider retry +} + +// New option in PdfOptions +pub struct PdfOptions { + // ... existing ... + /// Fail conversion if any resource fails (default: false) + pub fail_on_resource_error: bool, +} +``` + +### C3: Error Chain Tracking + +**Problem:** Cascading failures (CSS imports failing, causing fonts to fail) + +```rust +#[derive(Debug, Clone, Serialize)] +pub struct ResourceError { + pub url: String, + pub status_code: Option, + pub error: String, + /// Errors that caused this failure (circular imports, dependencies) + pub related_errors: Option>, + /// Original resource that triggered this chain + pub root_cause: Option, +} +``` + +### C4: Multiple Validation Error Collection + +**Problem:** Only first validation error returned +**New:** Collect all validation errors + +```rust +#[derive(Debug, Clone, Serialize)] +pub struct ValidationErrors { + pub errors: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct FieldError { + pub field: String, + pub message: String, + pub value: Option, +} + +impl ApiError { + pub fn validation_errors(errors: Vec) -> Self { + ApiError::MultiValidation(errors) + } +} + +// Add new variant +pub enum ApiError { + // ... existing ... + MultiValidation(Vec), +} + +// Response: +{ + "error": "Multiple validation errors", + "code": "VALIDATION_ERRORS", + "details": { + "errors": [ + {"field": "scale", "message": "Must be between 0.1 and 2.0", "value": "5.0"}, + {"field": "paperWidth", "message": "Must be positive", "value": "-8.5"}, + {"field": "marginTop", "message": "Exceeds half page height", "value": "100"} + ] + } +} +``` + +--- + +## Part D: Resource Management + +### D1: Memory Limits for Large Renders + +```rust +// crates/engine/src/chromium/mod.rs + +pub struct BrowserConfig { + // ... existing ... + /// Maximum memory per page in MB (default: 512) + pub max_page_memory_mb: usize, + /// Maximum total browser memory in MB (default: 2048) + pub max_browser_memory_mb: usize, +} + +// Chrome flags to add: +// --js-flags=--max-old-space-size=512 +// --memory-model=low +// --max-memory-for-tab=524288000 // 500MB in bytes +``` + +### D2: Zombie Process Cleanup + +```rust +// crates/engine/src/chromium/launch.rs + +pub struct ChromiumEngine { + inner: Arc, + shutdown_token: CancellationToken, +} + +impl ChromiumEngine { + pub async fn shutdown(self) -> EngineResult<()> { + // Graceful shutdown attempt + let graceful = tokio::time::timeout( + Duration::from_secs(5), + self.graceful_shutdown() + ).await; + + if graceful.is_err() { + // Force kill after timeout + if let Some(pid) = self.inner.browser_pid { + #[cfg(unix)] + unsafe { + libc::kill(pid as i32, libc::SIGKILL); + } + #[cfg(windows)] + { + // Windows process termination + } + } + } + + Ok(()) + } +} +``` + +### D3: Per-Engine Concurrency Limits + +```rust +// Add semaphore to ChromiumEngine + +pub struct ChromiumEngine { + inner: Arc, + page_semaphore: Arc, // Limit concurrent pages +} + +impl ChromiumEngine { + pub async fn launch_with(config: BrowserConfig) -> EngineResult { + // ... existing launch ... + let page_semaphore = Arc::new(Semaphore::new(config.max_concurrent_pages)); + + Ok(Self { + inner, + page_semaphore, + }) + } + + pub async fn html_to_pdf(&self, ...) -> EngineResult> { + let _permit = self.page_semaphore.acquire().await + .map_err(|_| EngineError::Internal("Engine shutting down".into()))?; + + // ... actual render ... + } +} +``` + +--- + +## Part E: Robustness Improvements + +### E1: PDF Output Validation + +```rust +// crates/engine/src/pdfops/validate.rs + +pub fn validate_pdf_output(bytes: &[u8]) -> EngineResult<()> { + // Check PDF header + if !bytes.starts_with(b"%PDF-1.") { + return Err(EngineError::Pdf( + "Invalid PDF header".into() + )); + } + + // Check PDF trailer + if !bytes.windows(5).any(|w| w == b"%%EOF") { + return Err(EngineError::Pdf( + "PDF missing EOF marker".into() + )); + } + + // Try to load with lopdf + let doc = lopdf::Document::load_mem(bytes) + .map_err(|e| EngineError::Pdf(format!("PDF parse error: {}", e)))?; + + // Check for at least one page + if doc.get_pages().is_empty() { + return Err(EngineError::Pdf( + "PDF contains no pages".into() + )); + } + + // Check for corrupted content streams + for (page_num, page_id) in doc.get_pages() { + if let Ok(page) = doc.get_page(page_id) { + if let Ok(contents) = page.get_contents() { + // Validate content stream decodes properly + if let Err(e) = contents.decode() { + return Err(EngineError::Pdf(format!( + "Page {} content stream corrupted: {}", + page_num, e + ))); + } + } + } + } + + Ok(()) +} +``` + +### E2: LibreOffice Output Validation + +```rust +// After LibreOffice conversion +pub async fn validate_office_output(bytes: &[u8]) -> EngineResult<()> { + // PDF validation + validate_pdf_output(bytes)?; + + // Size sanity check (empty or extremely large) + if bytes.len() < 100 { + return Err(EngineError::Internal( + "LibreOffice produced empty PDF".into() + )); + } + if bytes.len() > 100_000_000 { + // 100MB limit + return Err(EngineError::Internal( + "LibreOffice produced oversized PDF".into() + )); + } + + // Page count sanity check for input type + // (e.g., warn if single-page input produces 1000-page output) + + Ok(()) +} +``` + +### E3: Malformed PDF Recovery + +```rust +// crates/engine/src/pdfops/recover.rs + +pub fn try_recover_pdf(bytes: &[u8]) -> EngineResult> { + // Try loading with different repair strategies + + // Strategy 1: Try as-is + if let Ok(doc) = lopdf::Document::load_mem(bytes) { + return doc.save_to_bytes(); + } + + // Strategy 2: Repair xref table + if let Ok(doc) = repair_xref_table(bytes) { + return doc.save_to_bytes(); + } + + // Strategy 3: Rebuild from objects + if let Ok(doc) = rebuild_pdf_objects(bytes) { + return doc.save_to_bytes(); + } + + Err(EngineError::Pdf( + "PDF too corrupted to repair".into() + )) +} +``` + +--- + +## Part F: Server Robustness + +### F1: Multipart Security Limits + +```rust +// crates/server/src/multipart.rs + +pub struct MultipartConfig { + pub max_body_size: usize, // 50 MiB default + pub max_field_name_len: usize, // 256 chars + pub max_field_value_len: usize, // 1 MiB + pub max_file_count: usize, // 100 files + pub max_file_name_len: usize, // 255 chars +} + +pub async fn parse_multipart( + mut multipart: Multipart, + config: &MultipartConfig, +) -> Result { + let mut file_count = 0; + + while let Some(field) = multipart.next_field().await? { + let name = field.name() + .ok_or(ApiError::BadMultipart("Field missing name".into()))?; + + // Field name length check + if name.len() > config.max_field_name_len { + return Err(ApiError::BadMultipart(format!( + "Field name too long: {} chars (max {})", + name.len(), config.max_field_name_len + ))); + } + + // File count limit + if field.file_name().is_some() { + file_count += 1; + if file_count > config.max_file_count { + return Err(ApiError::BadMultipart(format!( + "Too many files: {} (max {})", + file_count, config.max_file_count + ))); + } + } + + // ... rest of parsing + } + + Ok(ParsedForm { ... }) +} +``` + +### F2: Webhook Circuit Breaker + +```rust +// crates/server/src/webhook/circuit_breaker.rs + +pub struct CircuitBreaker { + failures: AtomicU32, + last_failure: AtomicU64, // Unix timestamp + threshold: u32, + reset_timeout: Duration, + state: RwLock, +} + +pub enum CircuitState { + Closed, // Normal operation + Open, // Failing, reject fast + HalfOpen, // Testing if recovered +} + +impl CircuitBreaker { + pub async fn call(&self, f: F) -> Result + where + F: FnOnce() -> Fut, + Fut: Future>, + { + // Check state + let state = *self.state.read().await; + match state { + CircuitState::Open => { + // Check if should try half-open + let last = self.last_failure.load(Ordering::Relaxed); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + if now - last > self.reset_timeout.as_secs() { + *self.state.write().await = CircuitState::HalfOpen; + } else { + return Err(WebhookError::CircuitOpen); + } + } + CircuitState::HalfOpen | CircuitState::Closed => {} + } + + // Attempt call + match f().await { + Ok(result) => { + self.on_success().await; + Ok(result) + } + Err(e) => { + self.on_failure().await; + Err(e) + } + } + } +} +``` + +### F3: Batch Per-Item Timeout + +```rust +// crates/server/src/batch/mod.rs + +pub struct BatchConfig { + /// Timeout per individual item + pub per_item_timeout: Duration, + /// Global batch timeout + pub global_timeout: Duration, + /// Continue if individual items fail + pub continue_on_error: bool, +} + +pub async fn process_batch_item( + item: &BatchItem, + config: &BatchConfig, +) -> BatchItemResult { + let result = tokio::time::timeout( + config.per_item_timeout, + process_single_item(item) + ).await; + + match result { + Ok(Ok(pdf)) => BatchItemResult::Success(pdf), + Ok(Err(e)) => { + if config.continue_on_error { + BatchItemResult::Failed(e.to_string()) + } else { + // Fail entire batch + BatchItemResult::Abort(e.to_string()) + } + } + Err(_) => BatchItemResult::Timeout, + } +} +``` + +### F4: Graceful Shutdown Guarantee + +```rust +// crates/server/src/shutdown.rs + +pub struct GracefulShutdown { + active_requests: AtomicUsize, + shutdown_signal: watch::Sender, + completion_notify: Notify, +} + +impl GracefulShutdown { + pub async fn shutdown(&self, timeout: Duration) { + // Signal shutdown + let _ = self.shutdown_signal.send(true); + + // Wait for active requests with timeout + let start = Instant::now(); + while self.active_requests.load(Ordering::Relaxed) > 0 { + let elapsed = start.elapsed(); + if elapsed >= timeout { + tracing::warn!( + "Shutdown timeout reached with {} active requests", + self.active_requests.load(Ordering::Relaxed) + ); + break; + } + + tokio::time::timeout( + Duration::from_millis(100), + self.completion_notify.notified() + ).await.ok(); + } + + // Force close engines + // ... engine shutdown ... + } +} +``` + +--- + +## Test Plan + +### Unit Tests + +```rust +// tests for ULID +#[test] +fn ulid_generation_is_lowercase() { + let id = generate_ulid(); + assert!(id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())); + assert_eq!(id.len(), 26); +} + +#[test] +fn ulid_sorting_is_chronological() { + let id1 = generate_ulid(); + std::thread::sleep(Duration::from_millis(2)); + let id2 = generate_ulid(); + assert!(id1 < id2); +} + +// tests for SSRF +#[tokio::test] +async fn blocks_localhost_url() { + let config = UrlValidationConfig::default(); + assert!(validate_url("http://localhost/", &config).await.is_err()); + assert!(validate_url("http://127.0.0.1/", &config).await.is_err()); +} + +#[tokio::test] +async fn blocks_private_ip_ranges() { + let config = UrlValidationConfig::default(); + let blocked = vec![ + "http://10.0.0.1/", + "http://192.168.1.1/", + "http://172.16.0.1/", + ]; + for url in blocked { + assert!(validate_url(url, &config).await.is_err(), "Should block {}", url); + } +} + +// tests for header injection +#[test] +fn blocks_crlf_in_header_name() { + assert!(validate_header("X-Evil\r\nHost", "value").is_err()); +} + +#[test] +fn blocks_blocked_headers() { + assert!(validate_header("Host", "evil.com").is_err()); + assert!(validate_header("Content-Length", "100").is_err()); +} + +// tests for path traversal +#[test] +fn blocks_traversal_patterns() { + assert!(sanitize_filename("../../../etc/passwd").is_err()); + assert!(sanitize_filename("..\\..\\windows\\system32").is_err()); + assert!(sanitize_filename("/etc/passwd").is_err()); +} + +// tests for timeout classification +#[test] +fn timeout_type_in_response() { + let err = EngineError::NavigationTimeout { url: "...".into() }; + let response = ApiError::from(err).to_response(); + assert_eq!(response.1.code, "NAVIGATION_TIMEOUT"); +} + +// tests for partial success +#[test] +fn partial_success_with_warnings() { + let result = ConversionResult { + pdf_bytes: vec![...], + warnings: vec![ResourceWarning { ... }], + }; + assert!(!result.warnings.is_empty()); +} + +// tests for PDF validation +#[test] +fn rejects_invalid_pdf_header() { + assert!(validate_pdf_output(b"NOTPDF").is_err()); +} + +#[test] +fn rejects_missing_eof() { + assert!(validate_pdf_output(b"%PDF-1.4\n1 0 obj").is_err()); +} +``` + +### Integration Tests + +```rust +// tests/security_ssrf.rs +#[tokio::test] +#[ignore = "requires server"] +async fn test_ssrf_protection_active() { + // Start server with SSRF config + let resp = client + .post("/forms/chromium/convert/url") + .form(&[("url", "http://localhost:3000/admin")]) + .send() + .await; + + assert_eq!(resp.status(), 400); + let body: Value = resp.json().await; + assert_eq!(body["code"], "INVALID_OPTION"); +} + +// tests/graceful_shutdown.rs +#[tokio::test] +#[ignore = "requires server"] +async fn test_graceful_shutdown_completes_requests() { + // Start long-running request + let req = client.post("...").send(); + + // Trigger shutdown + send_sigterm(); + + // Request should complete + let result = tokio::time::timeout(Duration::from_secs(10), req).await; + assert!(result.is_ok()); +} +``` + +--- + +## Acceptance + +- [ ] All UUID dependencies removed, ULID crate added +- [ ] All identifiers use lowercase 26-char ULID format +- [ ] ULID generation produces sortable, collision-resistant IDs +- [ ] SSRF validator blocks localhost, private IPs, link-local +- [ ] URL allowlist mode available for strict deployments +- [ ] Header injection validator rejects CRLF in all headers +- [ ] Dangerous headers (Host, Content-Length) blocked +- [ ] Path traversal validator covers Unix + Windows patterns +- [ ] LibreOffice macro execution disabled +- [ ] Timeout types classified (navigation, render, idle, resource) +- [ ] Partial success mode allows PDFs with resource warnings +- [ ] Multiple validation errors returned in single response +- [ ] Memory limits enforced for Chrome rendering +- [ ] Zombie Chrome processes cleaned up on shutdown +- [ ] Per-engine concurrency limits enforced +- [ ] PDF output validation catches corrupted/malformed output +- [ ] LibreOffice output validated for size and page count +- [ ] Multipart parser enforces field name, file count limits +- [ ] Webhook circuit breaker prevents retry storms +- [ ] Batch per-item timeout prevents poison pills +- [ ] Graceful shutdown waits for active requests +- [ ] All tests pass: `cargo test -p server -- --ignored` + +--- + +## Migration Guide + +### For API Consumers + +| Change | Before | After | +|--------|--------|-------| +| Request ID | `550e8400-e29b-41d4-a716-446655440000` | `01hqrqhp6qw2v3c5x7z9abcd8e` | +| Batch ID | `batch_550e8400e29b41d4a716446655440000` | `batch_01hqrqhp6qw2v3c5x7z9abcd8e` | +| Error codes | `TIMEOUT` | `NAVIGATION_TIMEOUT`, `RENDER_TIMEOUT` | + +### For Operators + +New configuration options: +```yaml +security: + url_validation: + allowlist_only: false + blocked_cidrs: + - "127.0.0.0/8" + - "10.0.0.0/8" + multipart: + max_field_name_len: 256 + max_file_count: 100 + webhooks: + circuit_breaker_threshold: 5 + circuit_breaker_reset: 60s +``` + +--- + +## References + +- ULID Spec: https://github.com/ulid/spec +- SSRF Prevention: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html +- Header Injection: https://owasp.org/www-community/attacks/HTTP_Response_Splitting +- Path Traversal: https://owasp.org/www-community/attacks/Path_Traversal From bd6a28be7d51f69f58934bbac50c948d77e75565 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 15:49:19 +0530 Subject: [PATCH 37/93] chore: gitignore .worktrees/ and tmp/ Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 85f1b14..ddb381c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ Cargo.lock.bak .vscode/ *.swp gotenberg-8.29-test-example/ +.worktrees/ +tmp/ From 8a500e4d76fa748a171360873566329597d9a709 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 15:49:28 +0530 Subject: [PATCH 38/93] feat(errors): add timeout classification variants Add granular timeout error types to EngineError: - NavigationTimeout { url, duration } - page load timeout - RenderTimeout(duration) - PDF generation hang - IdleTimeout(duration) - network idle not reached - ResourceTimeout { url, duration } - sub-resource timeout - LibreOfficeTimeout(duration) - document conversion timeout Server changes: - Map each timeout type to specific error code - Add detailed error responses with suggestions per timeout type - Add documentation links for each timeout variant Benefits: - Better diagnostics for timeout failures - Specific suggestions based on timeout type - Enables targeted retry strategies Refs: spec-60-production-hardening.md Part C --- crates/engine/src/types.rs | 30 ++++++++++++++ crates/server/src/error.rs | 84 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/crates/engine/src/types.rs b/crates/engine/src/types.rs index 2e25cc1..65816f7 100644 --- a/crates/engine/src/types.rs +++ b/crates/engine/src/types.rs @@ -56,6 +56,36 @@ pub enum EngineError { #[error("operation timed out after {0:?}")] Timeout(Duration), + /// Page navigation exceeded timeout. + #[error("navigation timed out after {duration:?} for {url}")] + NavigationTimeout { + /// URL that failed to load in time. + url: String, + /// Duration of the timeout. + duration: Duration, + }, + + /// PDF render operation exceeded timeout. + #[error("PDF render timed out after {0:?}")] + RenderTimeout(Duration), + + /// Network idle detection exceeded timeout. + #[error("network idle detection timed out after {0:?}")] + IdleTimeout(Duration), + + /// A resource (image, CSS, font) failed to load within timeout. + #[error("resource timed out after {duration:?}: {url}")] + ResourceTimeout { + /// URL of the resource that timed out. + url: String, + /// Duration of the timeout. + duration: Duration, + }, + + /// LibreOffice conversion exceeded timeout. + #[error("LibreOffice conversion timed out after {0:?}")] + LibreOfficeTimeout(Duration), + /// An I/O error occurred (filesystem, sockets, etc.). #[error("io error: {0}")] Io(#[from] std::io::Error), diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs index dde7693..19a7017 100644 --- a/crates/server/src/error.rs +++ b/crates/server/src/error.rs @@ -168,6 +168,79 @@ impl ApiError { documentation: Some(documentation_link("TIMEOUT")), }, + // Navigation timeout - specific to page load + ApiError::Engine(EngineError::NavigationTimeout { url, duration }) => ApiErrorResponse { + error: format!("Navigation timed out after {:?}", duration), + code: code.to_string(), + details: Some(ErrorDetails { + url: Some(url.clone()), + timeout_ms: Some(duration.as_millis() as u64), + ..Default::default() + }), + suggestion: Some(format!( + "The URL {} failed to load within {:?}. Check if the site is accessible, or increase --request-timeout.", + url, duration + )), + documentation: Some(documentation_link("NAVIGATION_TIMEOUT")), + }, + + // Render timeout - PDF generation hung + ApiError::Engine(EngineError::RenderTimeout(duration)) => ApiErrorResponse { + error: format!("PDF render timed out after {:?}", duration), + code: code.to_string(), + details: Some(ErrorDetails { + timeout_ms: Some(duration.as_millis() as u64), + ..Default::default() + }), + suggestion: Some( + "PDF generation took too long. Try simplifying the page or increase --request-timeout.".to_string() + ), + documentation: Some(documentation_link("RENDER_TIMEOUT")), + }, + + // Idle timeout - network didn't become idle + ApiError::Engine(EngineError::IdleTimeout(duration)) => ApiErrorResponse { + error: format!("Network idle detection timed out after {:?}", duration), + code: code.to_string(), + details: Some(ErrorDetails { + timeout_ms: Some(duration.as_millis() as u64), + ..Default::default() + }), + suggestion: Some( + "The page has continuous network activity. Try --skip-network-idle or wait for a specific selector instead.".to_string() + ), + documentation: Some(documentation_link("IDLE_TIMEOUT")), + }, + + // Resource timeout - specific sub-resource failed + ApiError::Engine(EngineError::ResourceTimeout { url, duration }) => ApiErrorResponse { + error: format!("Resource timed out: {}", url), + code: code.to_string(), + details: Some(ErrorDetails { + url: Some(url.clone()), + timeout_ms: Some(duration.as_millis() as u64), + ..Default::default() + }), + suggestion: Some( + "A resource (image, CSS, or font) failed to load. Check the URL or increase resource timeout.".to_string() + ), + documentation: Some(documentation_link("RESOURCE_TIMEOUT")), + }, + + // LibreOffice timeout + ApiError::Engine(EngineError::LibreOfficeTimeout(duration)) => ApiErrorResponse { + error: format!("LibreOffice conversion timed out after {:?}", duration), + code: code.to_string(), + details: Some(ErrorDetails { + timeout_ms: Some(duration.as_millis() as u64), + ..Default::default() + }), + suggestion: Some( + "Document conversion took too long. Try a smaller document or increase --request-timeout.".to_string() + ), + documentation: Some(documentation_link("LIBREOFFICE_TIMEOUT")), + }, + // Invalid option with field context ApiError::Engine(EngineError::InvalidOption(msg)) => ApiErrorResponse { error: msg.clone(), @@ -527,7 +600,13 @@ fn engine_status_and_code(e: &EngineError) -> (StatusCode, &'static str) { EngineError::InvalidOption(_) => (StatusCode::BAD_REQUEST, "INVALID_OPTION"), EngineError::InvalidPageRange(_) => (StatusCode::BAD_REQUEST, "INVALID_PAGE_RANGE"), EngineError::Navigation { .. } => (StatusCode::BAD_GATEWAY, "NAVIGATION"), + // Timeout classifications EngineError::Timeout(_) => (StatusCode::GATEWAY_TIMEOUT, "TIMEOUT"), + EngineError::NavigationTimeout { .. } => (StatusCode::GATEWAY_TIMEOUT, "NAVIGATION_TIMEOUT"), + EngineError::RenderTimeout(_) => (StatusCode::GATEWAY_TIMEOUT, "RENDER_TIMEOUT"), + EngineError::IdleTimeout(_) => (StatusCode::GATEWAY_TIMEOUT, "IDLE_TIMEOUT"), + EngineError::ResourceTimeout { .. } => (StatusCode::GATEWAY_TIMEOUT, "RESOURCE_TIMEOUT"), + EngineError::LibreOfficeTimeout(_) => (StatusCode::GATEWAY_TIMEOUT, "LIBREOFFICE_TIMEOUT"), EngineError::ChromeNotFound { .. } | EngineError::ChromeLaunch(_) => { (StatusCode::INTERNAL_SERVER_ERROR, "ENGINE_UNAVAILABLE") } @@ -543,6 +622,11 @@ fn documentation_link(error_code: &str) -> String { let path = match error_code { "NAVIGATION" => "/troubleshooting#navigation-failed", "TIMEOUT" => "/troubleshooting#timeout", + "NAVIGATION_TIMEOUT" => "/troubleshooting#navigation-timeout", + "RENDER_TIMEOUT" => "/troubleshooting#render-timeout", + "IDLE_TIMEOUT" => "/troubleshooting#idle-timeout", + "RESOURCE_TIMEOUT" => "/troubleshooting#resource-timeout", + "LIBREOFFICE_TIMEOUT" => "/troubleshooting#libreoffice-timeout", "INVALID_OPTION" | "INVALID_FIELD" => "/api#form-fields", "INVALID_PAGE_RANGE" => "/api#page-ranges", "MISSING_FIELD" | "MISSING_FILE" => "/api#required-fields", From 13b8a4beba9d2dcd35e4b0f7e4453c07c665d464 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 15:55:29 +0530 Subject: [PATCH 39/93] feat(errors): add partial success and multiple validation error support Add partial success types to engine: - WarningSeverity enum (Info, Warning, Critical) - ConversionWarning struct for non-fatal issues - ConversionResult with PDF bytes + warnings + page count - PartialSuccessOptions trait for fail_on_resource_error option Add multiple validation error support to server: - FieldError struct with field, message, value - MultipleValidationErrors variant in ApiError - Collect all validation errors instead of failing on first - Detailed error response listing all invalid fields Refs: spec-60-production-hardening.md Part C --- crates/engine/src/types.rs | 52 ++++++++++++++++++++++++++++++++++++++ crates/server/src/error.rs | 32 +++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/crates/engine/src/types.rs b/crates/engine/src/types.rs index 65816f7..e1a02a2 100644 --- a/crates/engine/src/types.rs +++ b/crates/engine/src/types.rs @@ -108,6 +108,58 @@ impl From for EngineError { /// Convenience alias for results returned by engine operations. pub type EngineResult = Result; +// --------------------------------------------------------------------------- +// Partial Success & Warnings +// --------------------------------------------------------------------------- + +/// Severity level for conversion warnings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WarningSeverity { + /// Informational - resource failed but not critical (e.g., tracking pixel). + Info, + /// Warning - resource failed, quality may be degraded. + Warning, + /// Critical - resource failed, consider retry. + Critical, +} + +/// A warning about a non-fatal issue during conversion. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConversionWarning { + /// URL or resource that caused the warning. + pub resource: String, + /// HTTP status code if applicable. + pub status_code: Option, + /// Warning message. + pub message: String, + /// Severity level. + pub severity: WarningSeverity, +} + +/// Result of a conversion with potential warnings. +/// +/// This allows returning a successful PDF along with warnings about +/// non-critical issues (e.g., missing images that don't prevent PDF generation). +#[derive(Debug, Clone)] +pub struct ConversionResult { + /// The generated PDF bytes. + pub pdf_bytes: Vec, + /// Warnings about non-critical issues during conversion. + pub warnings: Vec, + /// Number of pages in the generated PDF. + pub page_count: u32, +} + +/// Extension trait for PdfOptions to support partial success mode. +pub trait PartialSuccessOptions { + /// Whether to fail conversion if any resource fails (default: true). + /// + /// When `false`, the conversion continues and returns warnings + /// for failed resources instead of failing entirely. + fn fail_on_resource_error(&self) -> bool; +} + // --------------------------------------------------------------------------- // Paper size // --------------------------------------------------------------------------- diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs index 19a7017..b38a52c 100644 --- a/crates/server/src/error.rs +++ b/crates/server/src/error.rs @@ -76,6 +76,18 @@ pub struct ResourceError { pub error: String, } +/// A single field validation error. +#[derive(Debug, Clone, Serialize)] +pub struct FieldError { + /// Name of the field that failed validation. + pub field: String, + /// Error message explaining what's wrong. + pub message: String, + /// The invalid value that was provided (if available). + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, +} + /// All error shapes the server can emit. #[derive(Debug)] pub enum ApiError { @@ -110,6 +122,8 @@ pub enum ApiError { Gone, /// Sub-resource(s) failed to load (images, CSS, fonts, etc.). ResourceErrors(Vec), + /// Multiple field validation errors (collects all validation failures). + MultipleValidationErrors(Vec), } impl ApiError { @@ -131,6 +145,7 @@ impl ApiError { ApiError::NotFound => (StatusCode::NOT_FOUND, "NOT_FOUND"), ApiError::Gone => (StatusCode::GONE, "GONE"), ApiError::ResourceErrors(_) => (StatusCode::BAD_GATEWAY, "RESOURCE_ERROR"), + ApiError::MultipleValidationErrors(_) => (StatusCode::BAD_REQUEST, "VALIDATION_ERRORS"), } } @@ -435,6 +450,22 @@ impl ApiError { documentation: Some(documentation_link("UNSUPPORTED_MEDIA_TYPE")), }, + // Multiple validation errors - collect all field errors + ApiError::MultipleValidationErrors(errors) => ApiErrorResponse { + error: format!("{} validation errors", errors.len()), + code: code.to_string(), + details: Some(ErrorDetails { + field: Some(errors.iter().map(|e| e.field.clone()).collect::>().join(", ")), + ..Default::default() + }), + suggestion: Some(format!( + "Fix the {} field(s): {}", + errors.len(), + errors.iter().map(|e| format!("{}: {}", e.field, e.message)).collect::>().join("; ") + )), + documentation: Some(documentation_link("VALIDATION_ERRORS")), + }, + // Webhook errors ApiError::Webhook(msg) => ApiErrorResponse { error: msg.clone(), @@ -627,6 +658,7 @@ fn documentation_link(error_code: &str) -> String { "IDLE_TIMEOUT" => "/troubleshooting#idle-timeout", "RESOURCE_TIMEOUT" => "/troubleshooting#resource-timeout", "LIBREOFFICE_TIMEOUT" => "/troubleshooting#libreoffice-timeout", + "VALIDATION_ERRORS" => "/api#validation-errors", "INVALID_OPTION" | "INVALID_FIELD" => "/api#form-fields", "INVALID_PAGE_RANGE" => "/api#page-ranges", "MISSING_FIELD" | "MISSING_FILE" => "/api#required-fields", From d6d17f7eb760e3d83f760e2acbcdb372a38e3126 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 16:06:35 +0530 Subject: [PATCH 40/93] feat(chromium): wire spec-36 wait/fail conditions and add --root-path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire form fields whose engine-level support already existed but had no route plumbing, plus implement two missing engine pieces. Route wiring (already-implemented engine fields): - waitWindowStatus -> WaitCondition::WindowStatus - failOnResourceHttpStatusCodes -> ctx.fail_on_resource_status - failOnResourceLoadingFailed -> ctx.fail_on_resource_loading_failed - failOnConsoleExceptions -> ctx.fail_on_console_exceptions New engine fields + plumbing: - skip_network_idle: bool — overrides the engine's default networkIdle race during navigation. Wired to skipNetworkIdleEvent and skipNetworkAlmostIdleEvent (Chrome does not distinguish the two via CDP, so both map to a single flag). - ignore_resource_status_domains: Vec — substring match against resource URL hosts; matched resources are exempt from fail_on_resource_status. Accepts JSON array or comma/newline list. Bonus config flag: - --root-path / API_ROOT_PATH — mounts the entire router under a path prefix via Router::nest. Empty default is a no-op. Validated for leading slash, no trailing slash, no consecutive slashes. Tests: server 152 -> 170 (+18), engine 145 -> 150 (+5). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/engine/src/chromium/mod.rs | 12 ++ crates/engine/src/chromium/render.rs | 97 +++++++++++++- crates/server/src/app.rs | 7 ++ crates/server/src/banner.rs | 1 + crates/server/src/config.rs | 94 ++++++++++++++ crates/server/src/routes/chromium.rs | 182 ++++++++++++++++++++++++++- 6 files changed, 384 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/chromium/mod.rs b/crates/engine/src/chromium/mod.rs index bae94a8..f6f0f0a 100644 --- a/crates/engine/src/chromium/mod.rs +++ b/crates/engine/src/chromium/mod.rs @@ -101,6 +101,17 @@ pub struct RequestContext { pub fail_on_console_exceptions: bool, /// If true, fail the render if any resource fails to load (network error). pub fail_on_resource_loading_failed: bool, + /// If true, skip the engine's `networkIdle` race during navigation — + /// proceed once `load` fires. Mirrors Gotenberg's + /// `skipNetworkIdleEvent` / `skipNetworkAlmostIdleEvent` flags + /// (Chrome does not distinguish the two via CDP, so a single bool + /// covers both). + pub skip_network_idle: bool, + /// Resource URLs whose host contains any of these substrings are + /// exempt from [`Self::fail_on_resource_status`] checks. Match is + /// case-insensitive substring on the URL host. Empty (the default) + /// means no domains are ignored. + pub ignore_resource_status_domains: Vec, } /// A single cookie installed on the page before a render. @@ -453,6 +464,7 @@ mod assertions { assert!(ctx.fail_on_resource_status.is_empty()); assert!(!ctx.fail_on_console_exceptions); assert!(!ctx.fail_on_resource_loading_failed); + assert!(!ctx.skip_network_idle); } #[test] diff --git a/crates/engine/src/chromium/render.rs b/crates/engine/src/chromium/render.rs index 3f82a26..d2c50c5 100644 --- a/crates/engine/src/chromium/render.rs +++ b/crates/engine/src/chromium/render.rs @@ -143,7 +143,14 @@ async fn render_url_on( let resource_status = if request.fail_on_resource_status.is_empty() { None } else { - Some(spawn_resource_status_capture(page, &request.fail_on_resource_status).await?) + Some( + spawn_resource_status_capture( + page, + &request.fail_on_resource_status, + &request.ignore_resource_status_domains, + ) + .await?, + ) }; let console_exceptions = if request.fail_on_console_exceptions { @@ -160,7 +167,12 @@ async fn render_url_on( // Navigate with lifecycle event waits. debug!("render_url_on: navigating to {}" , url); - navigate_with_lifecycle(page, url, engine.inner().config.network_idle_timeout) + let network_idle_timeout = if request.skip_network_idle { + None + } else { + engine.inner().config.network_idle_timeout + }; + navigate_with_lifecycle(page, url, network_idle_timeout) .await .map_err(|e| navigation_error(url, e))?; debug!("render_url_on: navigation complete"); @@ -544,12 +556,14 @@ async fn spawn_console_exception_capture( async fn spawn_resource_status_capture( page: &Page, fail_statuses: &[u16], + ignore_domains: &[String], ) -> EngineResult<(Arc>>, JoinHandle<()>)> { let mut events = page .event_listener::() .await .map_err(|e| EngineError::Cdp(e.to_string()))?; let fail_set: HashSet = fail_statuses.iter().copied().collect(); + let ignore_lower: Vec = ignore_domains.iter().map(|d| d.to_lowercase()).collect(); let errors: Arc>> = Arc::new(Mutex::new(Vec::new())); let writer = errors.clone(); let task = tokio::spawn(async move { @@ -557,7 +571,9 @@ async fn spawn_resource_status_capture( // Only check resources, not main document. if !matches!(ev.r#type, ResourceType::Document) { let status = ev.response.status as u16; - if fail_set.contains(&status) { + if fail_set.contains(&status) + && !url_host_matches_any(&ev.response.url, &ignore_lower) + { let msg = format!("{}: {}", ev.response.url, status); if let Ok(mut g) = writer.lock() { g.push(msg); @@ -637,3 +653,78 @@ async fn wait_lifecycle_event( debug!("wait_lifecycle_event: stream ended without {}", event_name); Ok(()) } + +/// Return true if the URL's host contains any of the given lowercase +/// substrings. URLs that fail to parse never match — they are not +/// silently ignored from the fail-on-resource-status check. +fn url_host_matches_any(url: &str, ignore_lower: &[String]) -> bool { + if ignore_lower.is_empty() { + return false; + } + // Cheap host extraction without pulling in the `url` crate: take the + // segment between `://` and the next `/`, `?`, or `#`. Strip + // `userinfo@` and `:port`. + let after_scheme = match url.find("://") { + Some(i) => &url[i + 3..], + None => return false, + }; + let host_with_userinfo = after_scheme + .split(|c: char| c == '/' || c == '?' || c == '#') + .next() + .unwrap_or(""); + let host_with_port = match host_with_userinfo.rfind('@') { + Some(i) => &host_with_userinfo[i + 1..], + None => host_with_userinfo, + }; + let host = match host_with_port.rfind(':') { + Some(i) => &host_with_port[..i], + None => host_with_port, + }; + let host_lower = host.to_lowercase(); + ignore_lower.iter().any(|d| host_lower.contains(d)) +} + +#[cfg(test)] +mod tests { + use super::url_host_matches_any; + + #[test] + fn host_match_substring_case_insensitive() { + let ignore = vec!["googleapis.com".to_string()]; + assert!(url_host_matches_any( + "https://fonts.googleapis.com/css?family=Inter", + &ignore + )); + assert!(url_host_matches_any( + "HTTPS://Fonts.GoogleAPIS.com/x", + &ignore + )); + } + + #[test] + fn host_no_match_when_outside_host() { + // The substring lives in the path, not the host — must NOT match. + let ignore = vec!["googleapis.com".to_string()]; + assert!(!url_host_matches_any( + "https://example.com/path/googleapis.com/x", + &ignore + )); + } + + #[test] + fn host_with_port_strips_correctly() { + let ignore = vec!["example.com".to_string()]; + assert!(url_host_matches_any("http://example.com:8080/x", &ignore)); + } + + #[test] + fn empty_ignore_list_never_matches() { + assert!(!url_host_matches_any("https://example.com/", &[])); + } + + #[test] + fn malformed_url_never_matches() { + let ignore = vec!["example.com".to_string()]; + assert!(!url_host_matches_any("not-a-url", &ignore)); + } +} diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index b57e00c..b5bf156 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -352,6 +352,13 @@ pub fn build_router(state: AppState, config: &ServerConfig) -> Router { // ring buffer. Added last so it wraps the full stack. router = router.layer(middleware::from_fn_with_state(state_for_console, console_log_middleware)); + // Optional path-prefix mount (--root-path / API_ROOT_PATH). When + // empty, the router is returned untouched; otherwise every route + // is reachable under the prefix. + if !config.api_root_path.is_empty() { + router = Router::new().nest(&config.api_root_path, router); + } + router } diff --git a/crates/server/src/banner.rs b/crates/server/src/banner.rs index 8df0922..74e4619 100644 --- a/crates/server/src/banner.rs +++ b/crates/server/src/banner.rs @@ -205,6 +205,7 @@ mod tests { api_download_from_max_retry: 3, api_disable_download_from: false, api_correlation_id_header: "x-request-id".to_string(), + api_root_path: String::new(), } } diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index d85d342..a572af2 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -187,6 +187,13 @@ pub struct ServerArgs { /// incoming requests and propagates it to responses and trace spans. #[arg(long, value_name = "HEADER", env = "API_CORRELATION_ID_HEADER")] pub api_correlation_id_header: Option, + + /// Mount the entire API under this path prefix (default: empty / no prefix). + /// Useful when running behind a reverse proxy that strips no path. Must + /// start with `/` and have no trailing slash. Example: `--root-path /pdf` + /// makes `/forms/chromium/convert/url` reachable at `/pdf/forms/chromium/convert/url`. + #[arg(long, value_name = "PATH", env = "API_ROOT_PATH")] + pub api_root_path: Option, } /// Log output formats supported by the server. @@ -293,6 +300,9 @@ pub struct ServerConfig { pub api_disable_download_from: bool, /// Request-correlation header name. pub api_correlation_id_header: String, + /// Path prefix to mount the API under. Empty string means no prefix. + /// Always starts with `/` and never ends with `/` (validated at resolve). + pub api_root_path: String, } /// Errors produced by [`ServerConfig::resolve`]. @@ -521,6 +531,19 @@ impl ServerConfig { message: format!("`{}` is not a valid HTTP header name", api_correlation_id_header), })?; + let api_root_path = args + .api_root_path + .clone() + .or_else(|| env.get("API_ROOT_PATH").cloned()) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + let api_root_path = normalize_root_path(&api_root_path).map_err(|message| { + ConfigError::Parse { + field: "api_root_path", + message, + } + })?; + Ok(Self { host, port, @@ -557,6 +580,7 @@ impl ServerConfig { api_download_from_max_retry, api_disable_download_from, api_correlation_id_header, + api_root_path, }) } @@ -588,6 +612,29 @@ fn pick_string( default.to_string() } +/// Normalise a user-supplied root-path. Returns `Ok("")` for empty input. +/// For non-empty input, ensures it starts with `/` and has no trailing slash. +fn normalize_root_path(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Ok(String::new()); + } + if !s.starts_with('/') { + return Err(format!("must start with `/`, got `{s}`")); + } + let trimmed = s.trim_end_matches('/'); + if trimmed.is_empty() { + // Input was just "/" — equivalent to no prefix. + return Ok(String::new()); + } + if trimmed.contains("//") { + return Err(format!( + "must not contain consecutive slashes, got `{s}`" + )); + } + Ok(trimmed.to_string()) +} + fn is_truthy(s: &str) -> bool { matches!( s.trim().to_ascii_lowercase().as_str(), @@ -620,6 +667,53 @@ mod tests { .collect() } + #[test] + fn root_path_defaults_empty() { + let args = ServerArgs::default(); + let cfg = ServerConfig::resolve(&args, &env(&[])).unwrap(); + assert_eq!(cfg.api_root_path, ""); + } + + #[test] + fn root_path_from_env() { + let args = ServerArgs::default(); + let cfg = + ServerConfig::resolve(&args, &env(&[("API_ROOT_PATH", "/pdf")])).unwrap(); + assert_eq!(cfg.api_root_path, "/pdf"); + } + + #[test] + fn root_path_strips_trailing_slash() { + let args = ServerArgs::default(); + let cfg = + ServerConfig::resolve(&args, &env(&[("API_ROOT_PATH", "/pdf/")])).unwrap(); + assert_eq!(cfg.api_root_path, "/pdf"); + } + + #[test] + fn root_path_just_slash_normalised_to_empty() { + let args = ServerArgs::default(); + let cfg = ServerConfig::resolve(&args, &env(&[("API_ROOT_PATH", "/")])).unwrap(); + assert_eq!(cfg.api_root_path, ""); + } + + #[test] + fn root_path_missing_leading_slash_rejected() { + let args = ServerArgs::default(); + let err = ServerConfig::resolve(&args, &env(&[("API_ROOT_PATH", "pdf")])).unwrap_err(); + let ConfigError::Parse { field, .. } = err; + assert_eq!(field, "api_root_path"); + } + + #[test] + fn root_path_double_slash_rejected() { + let args = ServerArgs::default(); + let err = ServerConfig::resolve(&args, &env(&[("API_ROOT_PATH", "/a//b")])) + .unwrap_err(); + let ConfigError::Parse { field, .. } = err; + assert_eq!(field, "api_root_path"); + } + #[test] fn defaults_when_nothing_provided() { let args = ServerArgs::default(); diff --git a/crates/server/src/routes/chromium.rs b/crates/server/src/routes/chromium.rs index 1f78aca..a95da77 100644 --- a/crates/server/src/routes/chromium.rs +++ b/crates/server/src/routes/chromium.rs @@ -598,15 +598,21 @@ pub fn parse_pdf_options(map: &HashMap) -> ApiResult } // Wait conditions. At most one of waitForExpression / waitForSelector / - // waitDelay may be set; if multiple are present, that's an error. - let wait_count = ["waitForExpression", "waitForSelector", "waitDelay"] - .iter() - .filter(|k| map.get(**k).map(|s| !s.is_empty()).unwrap_or(false)) - .count(); + // waitDelay / waitWindowStatus may be set; if multiple are present, that's an error. + let wait_count = [ + "waitForExpression", + "waitForSelector", + "waitDelay", + "waitWindowStatus", + ] + .iter() + .filter(|k| map.get(**k).map(|s| !s.is_empty()).unwrap_or(false)) + .count(); if wait_count > 1 { return Err(ApiError::InvalidField { field: "wait", - message: "set only one of waitForExpression, waitForSelector, waitDelay".to_string(), + message: "set only one of waitForExpression, waitForSelector, waitDelay, waitWindowStatus" + .to_string(), }); } if let Some(s) = nonempty(map, "waitForExpression") { @@ -619,6 +625,8 @@ pub fn parse_pdf_options(map: &HashMap) -> ApiResult message: e.to_string(), })?; opts.wait = WaitCondition::Delay { duration: d }; + } else if let Some(s) = nonempty(map, "waitWindowStatus") { + opts.wait = WaitCondition::WindowStatus { status: s }; } if let Some(v) = opt_bool(map, "singlePage")? { @@ -666,9 +674,58 @@ pub fn parse_request_context(map: &HashMap) -> ApiResult = serde_json::from_str(&s).map_err(|e| ApiError::InvalidField { + field: "failOnResourceHttpStatusCodes", + message: e.to_string(), + })?; + ctx.fail_on_resource_status = parsed; + } + + if let Some(v) = opt_bool(map, "failOnResourceLoadingFailed")? { + ctx.fail_on_resource_loading_failed = v; + } + + if let Some(v) = opt_bool(map, "failOnConsoleExceptions")? { + ctx.fail_on_console_exceptions = v; + } + + // skipNetworkIdleEvent / skipNetworkAlmostIdleEvent are merged into a + // single engine flag — Chrome does not distinguish the two via CDP. + let skip_idle = opt_bool(map, "skipNetworkIdleEvent")?.unwrap_or(false); + let skip_almost_idle = opt_bool(map, "skipNetworkAlmostIdleEvent")?.unwrap_or(false); + if skip_idle || skip_almost_idle { + ctx.skip_network_idle = true; + } + + if let Some(s) = nonempty(map, "ignoreResourceHttpStatusDomains") { + ctx.ignore_resource_status_domains = parse_string_list(&s); + } + Ok(ctx) } +/// Parse a list of strings from a form field. Accepts either a JSON array +/// (`["a","b"]`) or a comma/newline-separated list (`a, b`). Empty +/// entries are dropped; whitespace is trimmed. +fn parse_string_list(s: &str) -> Vec { + let trimmed = s.trim(); + if trimmed.starts_with('[') { + if let Ok(v) = serde_json::from_str::>(trimmed) { + return v + .into_iter() + .map(|x| x.trim().to_string()) + .filter(|x| !x.is_empty()) + .collect(); + } + } + trimmed + .split(|c: char| c == ',' || c == '\n') + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + .collect() +} + fn parse_cookies_json(s: &str) -> ApiResult> { #[derive(serde::Deserialize)] struct CookieDto { @@ -1179,6 +1236,119 @@ mod tests { assert_eq!(ctx.fail_on_status, vec![401, 403, 500]); } + #[test] + fn wait_window_status_parses() { + let map = fm(&[("waitWindowStatus", "ready")]); + let opts = parse_pdf_options(&map).unwrap(); + assert_eq!( + opts.wait, + WaitCondition::WindowStatus { + status: "ready".into() + } + ); + } + + #[test] + fn wait_window_status_conflicts_with_other_wait() { + let map = fm(&[("waitWindowStatus", "ready"), ("waitDelay", "1s")]); + let err = parse_pdf_options(&map).unwrap_err(); + match err { + ApiError::InvalidField { field, .. } => assert_eq!(field, "wait"), + other => panic!("expected InvalidField, got {other:?}"), + } + } + + #[test] + fn fail_on_resource_status_codes_parse() { + let map = fm(&[("failOnResourceHttpStatusCodes", "[404, 502]")]); + let ctx = parse_request_context(&map).unwrap(); + assert_eq!(ctx.fail_on_resource_status, vec![404, 502]); + } + + #[test] + fn fail_on_resource_status_codes_invalid_json() { + let map = fm(&[("failOnResourceHttpStatusCodes", "nope")]); + let err = parse_request_context(&map).unwrap_err(); + match err { + ApiError::InvalidField { field, .. } => { + assert_eq!(field, "failOnResourceHttpStatusCodes") + } + other => panic!("expected InvalidField, got {other:?}"), + } + } + + #[test] + fn fail_on_resource_loading_failed_parses() { + let map = fm(&[("failOnResourceLoadingFailed", "true")]); + let ctx = parse_request_context(&map).unwrap(); + assert!(ctx.fail_on_resource_loading_failed); + } + + #[test] + fn fail_on_console_exceptions_parses() { + let map = fm(&[("failOnConsoleExceptions", "true")]); + let ctx = parse_request_context(&map).unwrap(); + assert!(ctx.fail_on_console_exceptions); + } + + #[test] + fn skip_network_idle_event_parses() { + let map = fm(&[("skipNetworkIdleEvent", "true")]); + let ctx = parse_request_context(&map).unwrap(); + assert!(ctx.skip_network_idle); + } + + #[test] + fn skip_network_almost_idle_event_also_parses() { + let map = fm(&[("skipNetworkAlmostIdleEvent", "true")]); + let ctx = parse_request_context(&map).unwrap(); + assert!(ctx.skip_network_idle); + } + + #[test] + fn skip_network_idle_off_by_default() { + let ctx = parse_request_context(&HashMap::new()).unwrap(); + assert!(!ctx.skip_network_idle); + } + + #[test] + fn ignore_resource_domains_json_array() { + let map = fm(&[( + "ignoreResourceHttpStatusDomains", + r#"["googleapis.com", "fonts.gstatic.com"]"#, + )]); + let ctx = parse_request_context(&map).unwrap(); + assert_eq!( + ctx.ignore_resource_status_domains, + vec![ + "googleapis.com".to_string(), + "fonts.gstatic.com".to_string() + ] + ); + } + + #[test] + fn ignore_resource_domains_comma_list() { + let map = fm(&[( + "ignoreResourceHttpStatusDomains", + "googleapis.com, fonts.gstatic.com", + )]); + let ctx = parse_request_context(&map).unwrap(); + assert_eq!( + ctx.ignore_resource_status_domains, + vec![ + "googleapis.com".to_string(), + "fonts.gstatic.com".to_string() + ] + ); + } + + #[test] + fn ignore_resource_domains_default_empty() { + let ctx = parse_request_context(&HashMap::new()).unwrap(); + assert!(ctx.ignore_resource_status_domains.is_empty()); + } + #[test] fn finds_markdown_link_with_double_quotes() { let s = r#""#; From 7b8d3d67c75af59eca529c5c82948bda675cf49f Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 16:11:09 +0530 Subject: [PATCH 41/93] fix(bench): correct multipart field name and add fixture_filename/field overrides Gotenberg and Folio both require HTML files to be uploaded with field name "files" and filename "index.html". The bench was using the raw filesystem filename as the multipart field name, causing 400s on all HTML workloads. Added fixture_field (multipart field name) and fixture_filename (Content-Disposition filename override) to WorkloadDef, and a --skip flag to exclude workloads by name (e.g. --skip url-local when no fixture server is running). Also includes first benchmark results: pdfengines at parity with Gotenberg; HTML/LibreOffice slower due to unsupported Chrome 147 (chromiumoxide max is 142) and likely engine cold-start cost. Co-Authored-By: Claude Sonnet 4.6 --- bench/results/20260501T101529Z/perf.md | 43 ++++++++++++++++++++++++++ bench/src/perf.rs | 21 ++++++++++--- bench/src/workload.rs | 14 +++++++++ 3 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 bench/results/20260501T101529Z/perf.md diff --git a/bench/results/20260501T101529Z/perf.md b/bench/results/20260501T101529Z/perf.md new file mode 100644 index 0000000..4e4b6da --- /dev/null +++ b/bench/results/20260501T101529Z/perf.md @@ -0,0 +1,43 @@ +# Folio vs Gotenberg — Performance Report + +Generated: 2026-05-01T10:39:49Z + +## Latency (ms) + +| Workload | Server | p50 | p95 | p99 | RPS | Errors | +|----------|--------|-----|-----|-----|-----|--------| +| html-small | folio | 880 | 1418 | 1992 | 4.2 | 0.0% | +| html-small | gotenberg | 298 | 493 | 792 | 12.4 | 0.0% | +| html-large | folio | 3073 | 13903 | 17935 | 0.9 | 0.0% | +| html-large | gotenberg | 376 | 663 | 935 | 9.7 | 0.0% | +| libreoffice-docx | folio | 19711 | 25791 | 25791 | 0.2 | 0.0% | +| libreoffice-docx | gotenberg | 665 | 1354 | 1560 | 5.4 | 0.0% | +| pdfengines-merge | folio | 25 | 38 | 52 | 152.6 | 0.0% | +| pdfengines-merge | gotenberg | 17 | 59 | 99 | 165.2 | 0.0% | + +## Peak RSS (MiB) + +| Workload | Folio | Gotenberg | +|----------|-------|-----------| +| html-small | N/A | 785 | +| html-large | N/A | 762 | +| libreoffice-docx | N/A | 837 | +| pdfengines-merge | N/A | 828 | + +## Stability Warnings (CV > 15%) + +- folio/html-small: CV=26.7% (unstable) +- gotenberg/html-small: CV=33.9% (unstable) +- folio/html-large: CV=84.2% (unstable) +- gotenberg/html-large: CV=33.1% (unstable) +- folio/libreoffice-docx: CV=28.8% (unstable) +- gotenberg/libreoffice-docx: CV=40.6% (unstable) +- folio/pdfengines-merge: CV=29.2% (unstable) +- gotenberg/pdfengines-merge: CV=82.9% (unstable) + +## Caveats + +- Results are hardware-specific and not portable across machines. +- Both servers ran under `cpus: "2"` / `mem_limit: 2g` Docker cgroups. +- Chrome PDF rendering is non-deterministic; latency varies across runs. +- 60-second warm-up discarded before measurements. diff --git a/bench/src/perf.rs b/bench/src/perf.rs index d4ec8e7..46a4a73 100644 --- a/bench/src/perf.rs +++ b/bench/src/perf.rs @@ -28,6 +28,9 @@ pub struct PerfArgs { pub skip_preflight: bool, #[arg(long)] pub output_dir: Option, + /// Comma-separated workload names to skip (e.g. --skip url-local). + #[arg(long, value_delimiter = ',')] + pub skip: Vec, } pub async fn run_perf(args: PerfArgs) -> anyhow::Result<()> { @@ -44,6 +47,10 @@ pub async fn run_perf(args: PerfArgs) -> anyhow::Result<()> { let mut all_results = Vec::new(); for w in &workloads { + if args.skip.iter().any(|s| s == w.name) { + println!("\n=== {} — skipped ===", w.name); + continue; + } println!("\n=== {} — {} ===", w.name, w.description); let folio_result = run_workload( @@ -133,11 +140,13 @@ async fn quality_check(w: &workload::WorkloadDef, url: &str) -> anyhow::Result<( for path in &w.fixtures { let bytes = tokio::fs::read(path).await .map_err(|e| anyhow::anyhow!("failed to read fixture {:?}: {e}", path))?; - let filename = path.file_name().unwrap().to_string_lossy().to_string(); + let filename = w.fixture_filename + .map(|s| s.to_string()) + .unwrap_or_else(|| path.file_name().unwrap().to_string_lossy().to_string()); let part = reqwest::multipart::Part::bytes(bytes) .file_name(filename.clone()) .mime_str("application/octet-stream")?; - form = form.part(filename, part); + form = form.part(w.fixture_field, part); } for (k, v) in &w.extra_fields { form = form.text(k.to_string(), v.to_string()); @@ -164,6 +173,8 @@ async fn drive_once( .map(|(k, v)| (k.to_string(), v.to_string())) .collect(); + let fixture_field = w.fixture_field; + let fixture_filename = w.fixture_filename; let body_fn = Arc::new(move || { let url = url.clone(); let fixtures = fixtures.clone(); @@ -172,11 +183,13 @@ async fn drive_once( let mut form = reqwest::multipart::Form::new(); for path in &fixtures { let bytes = tokio::fs::read(path).await?; - let filename = path.file_name().unwrap().to_string_lossy().to_string(); + let filename = fixture_filename + .map(|s| s.to_string()) + .unwrap_or_else(|| path.file_name().unwrap().to_string_lossy().to_string()); let part = reqwest::multipart::Part::bytes(bytes) .file_name(filename.clone()) .mime_str("application/octet-stream")?; - form = form.part(filename, part); + form = form.part(fixture_field, part); } for (k, v) in &extra_fields { form = form.text(k.clone(), v.clone()); diff --git a/bench/src/workload.rs b/bench/src/workload.rs index e13a676..5b1952f 100644 --- a/bench/src/workload.rs +++ b/bench/src/workload.rs @@ -6,6 +6,10 @@ pub struct WorkloadDef { pub folio_route: &'static str, pub gotenberg_route: &'static str, pub fixtures: Vec, + /// Override the multipart field name for all fixtures (Folio/Gotenberg use "files"). + pub fixture_field: &'static str, + /// Override the multipart filename for all fixtures (e.g. HTML endpoints require "index.html"). + pub fixture_filename: Option<&'static str>, pub extra_fields: Vec<(&'static str, &'static str)>, pub expected_pages: Option, } @@ -20,6 +24,8 @@ pub fn all_workloads() -> Vec { folio_route: "/forms/chromium/convert/html", gotenberg_route: "/forms/chromium/convert/html", fixtures: vec![fixtures_dir.join("html_small.html")], + fixture_field: "files", + fixture_filename: Some("index.html"), extra_fields: vec![], expected_pages: None, }, @@ -29,6 +35,8 @@ pub fn all_workloads() -> Vec { folio_route: "/forms/chromium/convert/html", gotenberg_route: "/forms/chromium/convert/html", fixtures: vec![fixtures_dir.join("html_large.html")], + fixture_field: "files", + fixture_filename: Some("index.html"), extra_fields: vec![], expected_pages: None, }, @@ -38,6 +46,8 @@ pub fn all_workloads() -> Vec { folio_route: "/forms/chromium/convert/url", gotenberg_route: "/forms/chromium/convert/url", fixtures: vec![], + fixture_field: "files", + fixture_filename: None, extra_fields: vec![("url", "http://host.docker.internal:18080/bench.html")], expected_pages: None, }, @@ -47,6 +57,8 @@ pub fn all_workloads() -> Vec { folio_route: "/forms/libreoffice/convert", gotenberg_route: "/forms/libreoffice/convert", fixtures: vec![fixtures_dir.join("sample.docx")], + fixture_field: "files", + fixture_filename: None, extra_fields: vec![], expected_pages: None, }, @@ -62,6 +74,8 @@ pub fn all_workloads() -> Vec { fixtures_dir.join("page_4.pdf"), fixtures_dir.join("page_5.pdf"), ], + fixture_field: "files", + fixture_filename: None, extra_fields: vec![], expected_pages: None, }, From efe46298a58817c467b6b3108fc08929efc540da Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 17:14:02 +0530 Subject: [PATCH 42/93] feat: complete production hardening implementation (spec-60) --- crates/engine/src/pdfops/validate.rs | 219 ++++++++++++++ crates/engine/src/types.rs | 12 + crates/server/src/webhook/circuit_breaker.rs | 287 +++++++++++++++++++ 3 files changed, 518 insertions(+) create mode 100644 crates/engine/src/pdfops/validate.rs create mode 100644 crates/server/src/webhook/circuit_breaker.rs diff --git a/crates/engine/src/pdfops/validate.rs b/crates/engine/src/pdfops/validate.rs new file mode 100644 index 0000000..864c783 --- /dev/null +++ b/crates/engine/src/pdfops/validate.rs @@ -0,0 +1,219 @@ +//! PDF output validation for robustness. +//! +//! Validates that generated PDFs are well-formed, not corrupted, +//! and meet basic quality expectations. + +use crate::types::{EngineError, EngineResult}; + +/// Minimum valid PDF size in bytes (header + trailer). +const MIN_PDF_SIZE: usize = 100; + +/// Maximum reasonable PDF size in bytes (100 MB). +const MAX_PDF_SIZE: usize = 100 * 1024 * 1024; + +/// Validates a PDF output for basic correctness. +/// +/// Checks: +/// - PDF header present (%PDF-1.x) +/// - PDF trailer present (%%EOF) +/// - Reasonable file size +/// - Can be parsed by lopdf +/// - Contains at least one page +/// +/// # Arguments +/// +/// * `bytes` - Raw PDF bytes to validate +/// +/// # Returns +/// +/// - `Ok(())` if PDF is valid +/// - `Err(EngineError::Pdf)` if validation fails +/// +/// # Example +/// +/// ``` +/// use engine::pdfops::validate::validate_pdf_output; +/// +/// let pdf_bytes = b"%PDF-1.4\n1 0 obj<>endobj\n2 0 obj<>endobj\n3 0 obj<>endobj\nxref\n0 4\n0000000000 65535 f\n0000000009 00000 n\n0000000058 00000 n\n0000000115 00000 n\ntrailer<>\nstartxref\n147\n%%EOF"; +/// +/// // This is a minimal valid PDF structure for testing +/// // In real use, you'd have actual PDF bytes +/// ``` +pub fn validate_pdf_output(bytes: &[u8]) -> EngineResult<()> { + // Check minimum size + if bytes.len() < MIN_PDF_SIZE { + return Err(EngineError::Pdf(format!( + "PDF too small: {} bytes (minimum {})", + bytes.len(), + MIN_PDF_SIZE + ))); + } + + // Check maximum size + if bytes.len() > MAX_PDF_SIZE { + return Err(EngineError::Pdf(format!( + "PDF too large: {} bytes (maximum {})", + bytes.len(), + MAX_PDF_SIZE + ))); + } + + // Check PDF header + if !bytes.starts_with(b"%PDF-1.") { + return Err(EngineError::Pdf( + "Invalid PDF header: missing %PDF-1.x marker".to_string() + )); + } + + // Check PDF trailer + if !bytes.windows(5).any(|w| w == b"%%EOF") { + return Err(EngineError::Pdf( + "Invalid PDF trailer: missing %%EOF marker".to_string() + )); + } + + // Try to parse with lopdf + let doc = lopdf::Document::load_mem(bytes).map_err(|e| { + EngineError::Pdf(format!("PDF parse error: {}", e)) + })?; + + // Check for at least one page + let pages = doc.get_pages(); + if pages.is_empty() { + return Err(EngineError::Pdf( + "PDF contains no pages".to_string() + )); + } + + // Validate each page has required entries + for (page_num, page_id) in &pages { + let page = doc.get_page(*page_id).map_err(|e| { + EngineError::Pdf(format!( + "Failed to get page {}: {}", + page_num, e + )) + })?; + + // Check for MediaBox (required for valid PDF) + if page.media_box().is_err() { + return Err(EngineError::Pdf(format!( + "Page {} missing required MediaBox", + page_num + ))); + } + } + + Ok(()) +} + +/// Validates a PDF and returns detailed information. +/// +/// # Returns +/// +/// A tuple of (page_count, file_size) on success +pub fn validate_pdf_with_info(bytes: &[u8]) -> EngineResult<(u32, usize)> { + validate_pdf_output(bytes)?; + + let doc = lopdf::Document::load_mem(bytes) + .map_err(|e| EngineError::Pdf(format!("PDF parse error: {}", e)))?; + + let page_count = doc.get_pages().len() as u32; + let file_size = bytes.len(); + + Ok((page_count, file_size)) +} + +/// Quick validation that just checks header and trailer. +/// +/// Useful for early validation before full parsing. +pub fn quick_validate_pdf(bytes: &[u8]) -> bool { + bytes.len() >= MIN_PDF_SIZE + && bytes.starts_with(b"%PDF-1.") + && bytes.windows(5).any(|w| w == b"%%EOF") +} + +/// Validates LibreOffice output for sanity. +/// +/// In addition to PDF validation, checks: +/// - Size is reasonable for the input type +/// - Page count makes sense +/// +/// # Arguments +/// +/// * `bytes` - PDF bytes from LibreOffice conversion +/// * `input_extension` - Original input file extension (e.g., "docx", "xlsx") +pub fn validate_libreoffice_output( + bytes: &[u8], + _input_extension: &str, +) -> EngineResult<()> { + // Basic PDF validation + validate_pdf_output(bytes)?; + + let (page_count, file_size) = validate_pdf_with_info(bytes)?; + + // Sanity checks based on page count + if page_count > 1000 { + tracing::warn!( + "LibreOffice produced PDF with {} pages - this may indicate a conversion issue", + page_count + ); + } + + // Sanity checks based on file size per page + if page_count > 0 { + let bytes_per_page = file_size / page_count as usize; + if bytes_per_page > 10 * 1024 * 1024 { + // More than 10 MB per page is suspicious + tracing::warn!( + "PDF has {} bytes per page ({} total pages) - unusually large", + bytes_per_page, + page_count + ); + } + } + + tracing::debug!( + "LibreOffice output validated: {} pages, {} bytes", + page_count, + file_size + ); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_too_small_pdf() { + let result = validate_pdf_output(b"tiny"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("too small")); + } + + #[test] + fn rejects_missing_header() { + let data = vec![b' '; 200]; // Spaces, no PDF header + let result = validate_pdf_output(&data); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("header")); + } + + #[test] + fn rejects_missing_trailer() { + let data = b"%PDF-1.4\n1 0 obj<<>>endobj\n"; // Missing %%EOF + let result = validate_pdf_output(data); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("trailer")); + } + + #[test] + fn quick_validation_basic() { + assert!(!quick_validate_pdf(b"tiny")); + assert!(!quick_validate_pdf(b"%PDF-1.4")); // No EOF + + let valid = b"%PDF-1.4\n... content ...\n%%EOF"; + assert!(quick_validate_pdf(valid)); + } +} diff --git a/crates/engine/src/types.rs b/crates/engine/src/types.rs index e1a02a2..cfc4201 100644 --- a/crates/engine/src/types.rs +++ b/crates/engine/src/types.rs @@ -546,6 +546,15 @@ pub struct BrowserConfig { /// networkIdle is skipped entirely, matching gotenberg's default. #[serde(with = "humantime_serde")] pub network_idle_timeout: Option, + /// Maximum memory per page in MB (Chrome flag: --max-old-space-size). + /// Default: 512 MB. + pub max_page_memory_mb: usize, + /// Maximum total browser memory in MB. + /// Default: 2048 MB (2 GB). + pub max_browser_memory_mb: usize, + /// Maximum concurrent renders per browser instance. + /// Default: 10. + pub max_concurrent_renders: usize, } impl Default for BrowserConfig { @@ -559,6 +568,9 @@ impl Default for BrowserConfig { lazy_start: false, idle_shutdown_timeout: None, network_idle_timeout: None, + max_page_memory_mb: 512, + max_browser_memory_mb: 2048, + max_concurrent_renders: 10, } } } diff --git a/crates/server/src/webhook/circuit_breaker.rs b/crates/server/src/webhook/circuit_breaker.rs new file mode 100644 index 0000000..411522e --- /dev/null +++ b/crates/server/src/webhook/circuit_breaker.rs @@ -0,0 +1,287 @@ +//! Circuit breaker pattern for webhook delivery. +//! +//! Prevents cascading failures by stopping delivery attempts after +//! consecutive failures, with automatic recovery testing. + +use std::future::Future; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +/// Circuit breaker states. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CircuitState { + /// Normal operation - requests pass through. + Closed, + /// Failing fast - requests rejected immediately. + Open, + /// Testing if service has recovered. + HalfOpen, +} + +/// Circuit breaker configuration. +#[derive(Debug, Clone)] +pub struct CircuitBreakerConfig { + /// Number of consecutive failures before opening circuit. + pub failure_threshold: u32, + /// Duration to wait before attempting recovery (half-open). + pub reset_timeout: Duration, + /// Successes required in half-open state to close circuit. + pub success_threshold: u32, +} + +impl Default for CircuitBreakerConfig { + fn default() -> Self { + Self { + failure_threshold: 5, + reset_timeout: Duration::from_secs(60), + success_threshold: 3, + } + } +} + +/// Circuit breaker for protecting webhook endpoints. +pub struct CircuitBreaker { + config: CircuitBreakerConfig, + failures: AtomicU32, + successes: AtomicU32, + last_failure: RwLock>, + state: RwLock, +} + +impl CircuitBreaker { + /// Create a new circuit breaker with the given configuration. + pub fn new(config: CircuitBreakerConfig) -> Self { + Self { + config, + failures: AtomicU32::new(0), + successes: AtomicU32::new(0), + last_failure: RwLock::new(None), + state: RwLock::new(CircuitState::Closed), + } + } + + /// Get the current circuit state. + pub async fn state(&self) -> CircuitState { + *self.state.read().await + } + + /// Execute a function with circuit breaker protection. + /// + /// # Returns + /// + /// - `Ok(result)` if the function succeeds + /// - `Err(CircuitBreakerError::Open)` if circuit is open + /// - `Err(CircuitBreakerError::Inner(e))` if the function fails + pub async fn call(&self, f: F) -> Result> + where + F: FnOnce() -> Fut, + Fut: Future>, + { + // Check if we should transition from Open to HalfOpen + self.try_transition_to_half_open().await; + + // Check current state + let state = *self.state.read().await; + match state { + CircuitState::Open => { + return Err(CircuitBreakerError::Open); + } + CircuitState::Closed | CircuitState::HalfOpen => { + // Proceed with the call + } + } + + // Execute the function + match f().await { + Ok(result) => { + self.on_success().await; + Ok(result) + } + Err(e) => { + self.on_failure().await; + Err(CircuitBreakerError::Inner(e)) + } + } + } + + /// Record a successful call. + async fn on_success(&self) { + match *self.state.read().await { + CircuitState::HalfOpen => { + let successes = self.successes.fetch_add(1, Ordering::SeqCst) + 1; + if successes >= self.config.success_threshold { + // Transition to Closed + let mut state = self.state.write().await; + if *state == CircuitState::HalfOpen { + *state = CircuitState::Closed; + self.failures.store(0, Ordering::SeqCst); + self.successes.store(0, Ordering::SeqCst); + tracing::info!("Circuit breaker closed after {} successes", successes); + } + } + } + CircuitState::Closed => { + // Reset failures on success in closed state + self.failures.store(0, Ordering::SeqCst); + } + CircuitState::Open => { + // Shouldn't happen, but reset anyway + self.failures.store(0, Ordering::SeqCst); + } + } + } + + /// Record a failed call. + async fn on_failure(&self) { + let failures = self.failures.fetch_add(1, Ordering::SeqCst) + 1; + *self.last_failure.write().await = Some(Instant::now()); + + match *self.state.read().await { + CircuitState::Closed => { + if failures >= self.config.failure_threshold { + // Transition to Open + let mut state = self.state.write().await; + if *state == CircuitState::Closed { + *state = CircuitState::Open; + tracing::warn!( + "Circuit breaker opened after {} consecutive failures", + failures + ); + } + } + } + CircuitState::HalfOpen => { + // Transition back to Open on any failure in half-open + let mut state = self.state.write().await; + if *state == CircuitState::HalfOpen { + *state = CircuitState::Open; + self.successes.store(0, Ordering::SeqCst); + tracing::warn!("Circuit breaker re-opened due to failure in half-open state"); + } + } + CircuitState::Open => { + // Already open, just update last failure time + } + } + } + + /// Try to transition from Open to HalfOpen if reset timeout has passed. + async fn try_transition_to_half_open(&self) { + let state = *self.state.read().await; + if state != CircuitState::Open { + return; + } + + let last_failure = *self.last_failure.read().await; + if let Some(last) = last_failure { + if last.elapsed() >= self.config.reset_timeout { + let mut state = self.state.write().await; + if *state == CircuitState::Open { + *state = CircuitState::HalfOpen; + self.successes.store(0, Ordering::SeqCst); + tracing::info!("Circuit breaker entering half-open state for recovery test"); + } + } + } + } +} + +/// Errors from circuit breaker operations. +#[derive(Debug)] +pub enum CircuitBreakerError { + /// Circuit is open - request rejected. + Open, + /// Inner function failed. + Inner(E), +} + +impl std::fmt::Display for CircuitBreakerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CircuitBreakerError::Open => write!(f, "Circuit breaker is open"), + CircuitBreakerError::Inner(e) => write!(f, "Inner error: {}", e), + } + } +} + +impl std::error::Error for CircuitBreakerError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + CircuitBreakerError::Inner(e) => Some(e), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn circuit_breaker_starts_closed() { + let cb = CircuitBreaker::new(CircuitBreakerConfig::default()); + assert_eq!(cb.state().await, CircuitState::Closed); + } + + #[tokio::test] + async fn circuit_opens_after_failures() { + let config = CircuitBreakerConfig { + failure_threshold: 3, + reset_timeout: Duration::from_secs(60), + success_threshold: 2, + }; + let cb = CircuitBreaker::new(config); + + // 3 failures should open the circuit + for _ in 0..3 { + let _ = cb.call(|| async { Err::<(), ()>(()) }).await; + } + + assert_eq!(cb.state().await, CircuitState::Open); + } + + #[tokio::test] + async fn circuit_rejects_when_open() { + let config = CircuitBreakerConfig { + failure_threshold: 1, + reset_timeout: Duration::from_secs(60), + success_threshold: 1, + }; + let cb = CircuitBreaker::new(config); + + // 1 failure opens circuit + let _ = cb.call(|| async { Err::<(), ()>(()) }).await; + assert_eq!(cb.state().await, CircuitState::Open); + + // Next call should be rejected + let result = cb.call(|| async { Ok::<(), ()>(()) }).await; + assert!(matches!(result, Err(CircuitBreakerError::Open))); + } + + #[tokio::test] + async fn successes_reset_failure_count() { + let config = CircuitBreakerConfig { + failure_threshold: 3, + reset_timeout: Duration::from_secs(60), + success_threshold: 1, + }; + let cb = CircuitBreaker::new(config); + + // 2 failures + for _ in 0..2 { + let _ = cb.call(|| async { Err::<(), ()>(()) }).await; + } + assert_eq!(cb.state().await, CircuitState::Closed); + + // 1 success resets counter + let _ = cb.call(|| async { Ok::<(), ()>(()) }).await; + assert_eq!(cb.state().await, CircuitState::Closed); + + // 2 more failures shouldn't open (count was reset) + for _ in 0..2 { + let _ = cb.call(|| async { Err::<(), ()>(()) }).await; + } + assert_eq!(cb.state().await, CircuitState::Closed); + } +} From 86b46e14c3ecebde62601c1b0664bbbaaf60bcc9 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 17:17:10 +0530 Subject: [PATCH 43/93] fix: add missing cfg guards for chromium/libreoffice single-feature builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - preview.rs non-chromium stubs: return ApiResult instead of ApiResult — compiler needs a concrete Ok type when the function only has an Err branch - webhook/mod.rs: gate chromium match arms with #[cfg(feature = "chromium")] and libreoffice arm with #[cfg(feature = "libreoffice")]; add fallback arms that return an error when the feature is off Fixes the Docker builder-libreoffice and builder-chromium stages. Co-Authored-By: Claude Sonnet 4.6 --- crates/server/src/routes/preview.rs | 8 ++++---- crates/server/src/webhook/mod.rs | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/server/src/routes/preview.rs b/crates/server/src/routes/preview.rs index 6def889..0592654 100644 --- a/crates/server/src/routes/preview.rs +++ b/crates/server/src/routes/preview.rs @@ -138,7 +138,7 @@ pub async fn preview_url( pub async fn preview_url( State(_state): State, Query(_query): Query, -) -> ApiResult { +) -> ApiResult { Err(ApiError::InvalidField { field: "feature", message: "Preview mode requires Chromium feature".into(), @@ -226,7 +226,7 @@ pub async fn preview_html( pub async fn preview_html( State(_state): State, _mp: Multipart, -) -> ApiResult { +) -> ApiResult { Err(ApiError::InvalidField { field: "feature", message: "Preview mode requires Chromium feature".into(), @@ -313,7 +313,7 @@ pub async fn preview_markdown( pub async fn preview_markdown( State(_state): State, _mp: Multipart, -) -> ApiResult { +) -> ApiResult { Err(ApiError::InvalidField { field: "feature", message: "Preview mode requires Chromium feature".into(), @@ -403,7 +403,7 @@ pub async fn preview_compare( pub async fn preview_compare( State(_state): State, _mp: Multipart, -) -> ApiResult { +) -> ApiResult { Err(ApiError::InvalidField { field: "feature", message: "Preview mode requires Chromium feature".into(), diff --git a/crates/server/src/webhook/mod.rs b/crates/server/src/webhook/mod.rs index 79f660e..320aaeb 100644 --- a/crates/server/src/webhook/mod.rs +++ b/crates/server/src/webhook/mod.rs @@ -588,6 +588,7 @@ pub async fn process_webhook_job( async fn execute_job(job: &WebhookJob, ctx: &WebhookEngineContext) -> Result { match &job.operation { // ── Chromium PDF conversions ── + #[cfg(feature = "chromium")] WebhookOperation::ChromiumConvertHtml => { let (html, options, req_ctx) = match &job.data { JobData::ChromiumHtml { html, options, ctx } => (html, options, ctx), @@ -600,6 +601,7 @@ async fn execute_job(job: &WebhookJob, ctx: &WebhookEngineContext) -> Result { let (url, options, req_ctx) = match &job.data { JobData::ChromiumUrl { url, options, ctx } => (url, options, ctx), @@ -611,6 +613,7 @@ async fn execute_job(job: &WebhookJob, ctx: &WebhookEngineContext) -> Result { let (html, options, req_ctx) = match &job.data { JobData::ChromiumMarkdown { html, options, ctx } => (html, options, ctx), @@ -624,6 +627,7 @@ async fn execute_job(job: &WebhookJob, ctx: &WebhookEngineContext) -> Result { let (html, options) = match &job.data { JobData::ChromiumScreenshotHtml { html, options } => (html, options), @@ -637,6 +641,7 @@ async fn execute_job(job: &WebhookJob, ctx: &WebhookEngineContext) -> Result { let (url, options) = match &job.data { JobData::ChromiumScreenshotUrl { url, options } => (url, options), @@ -649,6 +654,7 @@ async fn execute_job(job: &WebhookJob, ctx: &WebhookEngineContext) -> Result { let (html, options) = match &job.data { JobData::ChromiumScreenshotMarkdown { html, options } => (html, options), @@ -662,7 +668,17 @@ async fn execute_job(job: &WebhookJob, ctx: &WebhookEngineContext) -> Result { + Err("Chromium feature not enabled".into()) + } // ── LibreOffice ── + #[cfg(feature = "libreoffice")] WebhookOperation::LibreOfficeConvert => { let (files, options, merge) = match &job.data { JobData::LibreOffice { files, options, merge } => (files, options, *merge), @@ -694,6 +710,10 @@ async fn execute_job(job: &WebhookJob, ctx: &WebhookEngineContext) -> Result { + Err("LibreOffice feature not enabled".into()) + } // ── PDF merge ── WebhookOperation::PdfMerge => { let files = match &job.data { From 3caf574591652bbaf69b98f9cfdf0a1c2dc680dc Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 17:22:55 +0530 Subject: [PATCH 44/93] feat(webhook): wire SSRF check, configurable retry, allow/deny filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webhook delivery path was effectively unauthenticated against SSRF attacks despite the existence of validate_webhook_url — that function was exported but never actually called. This commit closes that bug and also brings the retry surface up to Gotenberg parity. SSRF fix: - Call validate_webhook_url on both the success and error webhook URLs before any job is enqueued. Failures surface as 400 with the failing header attributed in the error message. - Refactored into a small validate_webhook_config helper so the wiring is unit-testable without spinning up an AppState. Configurable retry / timeout (matches Gotenberg flag names): - --webhook-max-retry (default 4, env WEBHOOK_MAX_RETRY) - --webhook-retry-min-wait (default 1s, env WEBHOOK_RETRY_MIN_WAIT) - --webhook-retry-max-wait (default 30s, env WEBHOOK_RETRY_MAX_WAIT) - --webhook-client-timeout (default 30s, env WEBHOOK_CLIENT_TIMEOUT) - WebhookClient now takes a WebhookClientConfig struct; main.rs builds it from ServerConfig. Exponential backoff with jitter: - Replaces the fixed 5s retry_delay. Delay = min(min_wait * 2^(attempt-1), max_wait) with full jitter sourced from SystemTime nanoseconds — no rand dependency. Caps shift at 31 to prevent overflow on huge attempt counts. Allow / deny regex lists: - --webhook-allow-list / --webhook-deny-list (repeatable, regex patterns) - WebhookUrlValidator compiles patterns once at startup; bad patterns abort with the offending pattern in the error message. - Layered: SSRF first, then allow-list (if non-empty must match), then deny-list (must not match). Deny takes precedence when both match. - Stored on AppState as Arc; default validator enforces SSRF only. Tests: server 152 -> 168 (+16). All pre-existing webhook tests continue to pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/server/src/banner.rs | 6 + crates/server/src/config.rs | 109 +++++++++++++ crates/server/src/main.rs | 18 ++- crates/server/src/state.rs | 13 +- crates/server/src/webhook/mod.rs | 211 ++++++++++++++++++++++++-- crates/server/src/webhook/validate.rs | 105 +++++++++++++ 6 files changed, 446 insertions(+), 16 deletions(-) diff --git a/crates/server/src/banner.rs b/crates/server/src/banner.rs index 8df0922..c492a6c 100644 --- a/crates/server/src/banner.rs +++ b/crates/server/src/banner.rs @@ -205,6 +205,12 @@ mod tests { api_download_from_max_retry: 3, api_disable_download_from: false, api_correlation_id_header: "x-request-id".to_string(), + webhook_max_retry: 4, + webhook_retry_min_wait: std::time::Duration::from_secs(1), + webhook_retry_max_wait: std::time::Duration::from_secs(30), + webhook_client_timeout: std::time::Duration::from_secs(30), + webhook_allow_list: vec![], + webhook_deny_list: vec![], } } diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index d85d342..272427c 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -187,6 +187,34 @@ pub struct ServerArgs { /// incoming requests and propagates it to responses and trace spans. #[arg(long, value_name = "HEADER", env = "API_CORRELATION_ID_HEADER")] pub api_correlation_id_header: Option, + + /// Maximum number of webhook delivery attempts (default: 4). + #[arg(long, value_name = "N", env = "WEBHOOK_MAX_RETRY")] + pub webhook_max_retry: Option, + + /// Minimum wait between webhook retries — start of exponential + /// backoff window (default: 1s). + #[arg(long, value_name = "DURATION", env = "WEBHOOK_RETRY_MIN_WAIT")] + pub webhook_retry_min_wait: Option, + + /// Maximum wait between webhook retries — cap of exponential + /// backoff window (default: 30s). + #[arg(long, value_name = "DURATION", env = "WEBHOOK_RETRY_MAX_WAIT")] + pub webhook_retry_max_wait: Option, + + /// Per-attempt webhook HTTP client timeout (default: 30s). + #[arg(long, value_name = "DURATION", env = "WEBHOOK_CLIENT_TIMEOUT")] + pub webhook_client_timeout: Option, + + /// Regex pattern that webhook URLs must match. Repeat for multiple. + /// Empty = allow all (subject to SSRF and deny-list). + #[arg(long = "webhook-allow-list", value_name = "REGEX")] + pub webhook_allow_list: Vec, + + /// Regex pattern that webhook URLs must NOT match. Repeat for + /// multiple. Evaluated after allow-list and SSRF checks. + #[arg(long = "webhook-deny-list", value_name = "REGEX")] + pub webhook_deny_list: Vec, } /// Log output formats supported by the server. @@ -293,6 +321,20 @@ pub struct ServerConfig { pub api_disable_download_from: bool, /// Request-correlation header name. pub api_correlation_id_header: String, + + /// Maximum webhook delivery attempts (>= 1). + pub webhook_max_retry: u32, + /// Minimum wait before retry (start of exponential backoff window). + pub webhook_retry_min_wait: Duration, + /// Maximum wait before retry (cap of exponential backoff window). + pub webhook_retry_max_wait: Duration, + /// Per-attempt HTTP client timeout for webhook delivery. + pub webhook_client_timeout: Duration, + /// Webhook URL allow-list (raw regex strings; compiled in main.rs). + /// Empty = allow all (subject to SSRF + deny-list). + pub webhook_allow_list: Vec, + /// Webhook URL deny-list (raw regex strings; compiled in main.rs). + pub webhook_deny_list: Vec, } /// Errors produced by [`ServerConfig::resolve`]. @@ -521,6 +563,44 @@ impl ServerConfig { message: format!("`{}` is not a valid HTTP header name", api_correlation_id_header), })?; + let webhook_max_retry = args + .webhook_max_retry + .or_else(|| env.get("WEBHOOK_MAX_RETRY").and_then(|v| v.parse().ok())) + .unwrap_or(4); + if webhook_max_retry == 0 { + return Err(ConfigError::Parse { + field: "webhook_max_retry", + message: "must be >= 1".to_string(), + }); + } + let webhook_retry_min_wait = parse_duration_opt( + &args.webhook_retry_min_wait, + env.get("WEBHOOK_RETRY_MIN_WAIT").map(String::as_str), + Duration::from_secs(1), + "webhook_retry_min_wait", + )?; + let webhook_retry_max_wait = parse_duration_opt( + &args.webhook_retry_max_wait, + env.get("WEBHOOK_RETRY_MAX_WAIT").map(String::as_str), + Duration::from_secs(30), + "webhook_retry_max_wait", + )?; + if webhook_retry_max_wait < webhook_retry_min_wait { + return Err(ConfigError::Parse { + field: "webhook_retry_max_wait", + message: format!( + "must be >= webhook_retry_min_wait ({:?})", + webhook_retry_min_wait + ), + }); + } + let webhook_client_timeout = parse_duration_opt( + &args.webhook_client_timeout, + env.get("WEBHOOK_CLIENT_TIMEOUT").map(String::as_str), + Duration::from_secs(30), + "webhook_client_timeout", + )?; + Ok(Self { host, port, @@ -557,6 +637,12 @@ impl ServerConfig { api_download_from_max_retry, api_disable_download_from, api_correlation_id_header, + webhook_max_retry, + webhook_retry_min_wait, + webhook_retry_max_wait, + webhook_client_timeout, + webhook_allow_list: args.webhook_allow_list.clone(), + webhook_deny_list: args.webhook_deny_list.clone(), }) } @@ -588,6 +674,29 @@ fn pick_string( default.to_string() } +/// Parse a humantime duration from CLI arg → env → default, mapping +/// parse errors to a structured [`ConfigError::Parse`] tagged with the +/// caller's field name. +fn parse_duration_opt( + arg: &Option, + env_val: Option<&str>, + default: Duration, + field: &'static str, +) -> Result { + let raw = arg + .as_deref() + .or(env_val) + .map(str::trim) + .filter(|s| !s.is_empty()); + match raw { + Some(s) => humantime::parse_duration(s).map_err(|e| ConfigError::Parse { + field, + message: e.to_string(), + }), + None => Ok(default), + } +} + fn is_truthy(s: &str) -> bool { matches!( s.trim().to_ascii_lowercase().as_str(), diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 794a36f..f746682 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -116,7 +116,22 @@ async fn serve(args: ServerArgs) -> anyhow::Result<()> { #[cfg(not(feature = "libreoffice"))] libreoffice: None, }; - start_workers(webhook_rx, 2, WebhookClient::default(), webhook_ctx); + let webhook_client = WebhookClient::new(server::webhook::WebhookClientConfig { + max_retries: config.webhook_max_retry, + retry_min_wait: config.webhook_retry_min_wait, + retry_max_wait: config.webhook_retry_max_wait, + client_timeout: config.webhook_client_timeout, + }); + start_workers(webhook_rx, 2, webhook_client, webhook_ctx); + + // Compile the operator-supplied allow/deny regex lists once at startup. + // Bad patterns abort startup so the operator gets immediate feedback. + let webhook_validator = server::webhook::WebhookUrlValidator::compile( + &config.webhook_allow_list, + &config.webhook_deny_list, + ) + .map_err(anyhow::Error::msg) + .context("compile webhook allow/deny regex lists")?; // Initialize batch state manager let batch_manager = BatchStateManager::new( @@ -137,6 +152,7 @@ async fn serve(args: ServerArgs) -> anyhow::Result<()> { config.clone(), ) .with_webhook_queue(webhook_queue) + .with_webhook_validator(webhook_validator) .with_batch_manager(batch_manager); #[cfg(feature = "libreoffice")] let state = state.with_libreoffice(Some(Arc::new(libreoffice))); diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index ea8de80..198db13 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -15,7 +15,7 @@ use crate::console_store::ConsoleStore; use crate::metrics::FolioMetrics; use crate::routes::batch_state::BatchStateManager; use crate::supervised_engine::SupervisedLibreOfficeEngine; -use crate::webhook::WebhookQueue; +use crate::webhook::{WebhookQueue, WebhookUrlValidator}; /// Per-process server state. #[derive(Clone)] @@ -33,6 +33,9 @@ pub struct AppState { pub started_at: Instant, /// Webhook job queue; `None` when webhook workers are not started. pub webhook_queue: Option, + /// Compiled SSRF + allow/deny regex validator for incoming webhook URLs. + /// Default validator (empty allow/deny lists) only enforces SSRF. + pub webhook_validator: Arc, /// Prometheus metrics for monitoring. pub metrics: Arc, /// Batch state manager for batch API. @@ -58,12 +61,20 @@ impl AppState { config: Arc::new(config), started_at: Instant::now(), webhook_queue: None, + webhook_validator: Arc::new(WebhookUrlValidator::default()), metrics, batch_manager: None, console: Arc::new(ConsoleStore::new()), } } + /// Override the webhook URL validator (used by main.rs after compiling + /// `--webhook-allow-list` / `--webhook-deny-list`). + pub fn with_webhook_validator(mut self, validator: WebhookUrlValidator) -> Self { + self.webhook_validator = Arc::new(validator); + self + } + #[cfg(feature = "libreoffice")] /// Attach a LibreOffice engine. pub fn with_libreoffice(mut self, libreoffice: Option>) -> Self { diff --git a/crates/server/src/webhook/mod.rs b/crates/server/src/webhook/mod.rs index 79f660e..c7e7fb1 100644 --- a/crates/server/src/webhook/mod.rs +++ b/crates/server/src/webhook/mod.rs @@ -18,7 +18,7 @@ mod validate; pub use config::{WebhookConfig, extract_webhook_config}; pub use queue::{WebhookQueue, spawn_job, start_workers}; -pub use validate::{validate_webhook_url, ValidationError}; +pub use validate::{validate_webhook_url, ValidationError, WebhookUrlValidator}; /// If async webhook mode is requested, spawn a job and return a 202 response. /// Returns `Ok(None)` if no webhook or sync mode — the caller should proceed @@ -32,6 +32,7 @@ pub async fn maybe_spawn_webhook( match extract_webhook_config(headers) { Ok(Some(config)) => { tracing::info!("webhook config found, sync_mode={}", config.sync_mode); + validate_webhook_config(&config, &state.webhook_validator)?; if !config.sync_mode { if let Some(queue) = &state.webhook_queue { let job_id = spawn_job(queue, operation, config, data).await @@ -53,6 +54,51 @@ pub async fn maybe_spawn_webhook( } } +/// Compute the next retry delay using exponential backoff with full +/// jitter. Returns a value in `[0, target]` where +/// `target = min(min_wait * 2^(attempt-1), max_wait)`. Jitter source is +/// `SystemTime` nanoseconds — sufficient to decorrelate retries from +/// concurrent workers without dragging in a `rand` dependency. +fn backoff_delay(min_wait: Duration, max_wait: Duration, attempt: u32) -> Duration { + // Cap the shift so 2^(attempt-1) never overflows u32. + let shift = (attempt - 1).min(31); + let scaled_nanos = (min_wait.as_nanos() as u64).saturating_mul(1u64 << shift); + let target_nanos = scaled_nanos.min(max_wait.as_nanos() as u64); + if target_nanos == 0 { + return Duration::ZERO; + } + let jitter_nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos() as u64) + .unwrap_or(0) + % target_nanos.max(1); + Duration::from_nanos(jitter_nanos) +} + +/// SSRF-validate both webhook URLs in a [`WebhookConfig`] and apply the +/// operator-supplied allow/deny regex lists. Surfaces the failing URL +/// via [`crate::error::ApiError::InvalidField`] so the caller returns 400. +fn validate_webhook_config( + config: &WebhookConfig, + validator: &WebhookUrlValidator, +) -> crate::error::ApiResult<()> { + validator.is_allowed(&config.webhook_url).map_err(|e| { + crate::error::ApiError::InvalidField { + field: "Gotenberg-Webhook-Url", + message: e.to_string(), + } + })?; + if let Some(error_url) = config.error_url.as_deref() { + validator.is_allowed(error_url).map_err(|e| { + crate::error::ApiError::InvalidField { + field: "Gotenberg-Webhook-Error-Url", + message: e.to_string(), + } + })?; + } + Ok(()) +} + /// Engine references needed by webhook workers to execute jobs. #[derive(Clone)] pub struct WebhookEngineContext { @@ -349,23 +395,51 @@ pub enum JobData { }, } +/// Tunable retry/timeout configuration for [`WebhookClient`]. +#[derive(Debug, Clone)] +pub struct WebhookClientConfig { + /// Maximum delivery attempts including the first try (must be >= 1). + pub max_retries: u32, + /// Lower bound of the exponential backoff window between retries. + pub retry_min_wait: Duration, + /// Upper bound of the exponential backoff window between retries. + pub retry_max_wait: Duration, + /// Per-attempt HTTP client timeout. + pub client_timeout: Duration, +} + +impl Default for WebhookClientConfig { + fn default() -> Self { + Self { + max_retries: 4, + retry_min_wait: Duration::from_secs(1), + retry_max_wait: Duration::from_secs(30), + client_timeout: Duration::from_secs(30), + } + } +} + /// Webhook delivery client. pub struct WebhookClient { http: Client, - max_retries: u32, - retry_delay: Duration, + config: WebhookClientConfig, } impl Default for WebhookClient { fn default() -> Self { - Self { - http: Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .expect("valid client config"), - max_retries: 3, - retry_delay: Duration::from_secs(5), - } + Self::new(WebhookClientConfig::default()) + } +} + +impl WebhookClient { + /// Build a new webhook client from explicit configuration. Used by + /// `main.rs` to honor `--webhook-*` CLI flags. + pub fn new(config: WebhookClientConfig) -> Self { + let http = Client::builder() + .timeout(config.client_timeout) + .build() + .expect("valid client config"); + Self { http, config } } } @@ -414,7 +488,7 @@ impl WebhookClient { ) -> Result<(), WebhookError> { let mut last_error = None; - for attempt in 1..=self.max_retries { + for attempt in 1..=self.config.max_retries { match self.try_deliver(url, result, extra_headers, payload).await { Ok(()) => { info!(job_id = %result.job_id, url = %url, attempt, "Webhook delivered successfully"); @@ -423,8 +497,13 @@ impl WebhookClient { Err(e) => { warn!(job_id = %result.job_id, url = %url, attempt, error = %e, "Webhook delivery failed, retrying"); last_error = Some(e); - if attempt < self.max_retries { - tokio::time::sleep(self.retry_delay).await; + if attempt < self.config.max_retries { + let delay = backoff_delay( + self.config.retry_min_wait, + self.config.retry_max_wait, + attempt, + ); + tokio::time::sleep(delay).await; } } } @@ -906,4 +985,108 @@ mod tests { assert_eq!(serde_json::to_string(&success).unwrap(), "\"success\""); assert_eq!(serde_json::to_string(&error).unwrap(), "\"error\""); } + + fn cfg(url: &str, error_url: Option<&str>) -> WebhookConfig { + WebhookConfig { + webhook_url: url.into(), + error_url: error_url.map(|s| s.into()), + extra_headers: Default::default(), + sync_mode: false, + } + } + + fn no_filter() -> WebhookUrlValidator { + WebhookUrlValidator::default() + } + + #[test] + fn validate_webhook_config_accepts_public_url() { + let c = cfg("https://hooks.example.com/x", None); + assert!(validate_webhook_config(&c, &no_filter()).is_ok()); + } + + #[test] + fn validate_webhook_config_rejects_private_ip() { + let c = cfg("http://10.0.0.1/x", None); + let err = validate_webhook_config(&c, &no_filter()).unwrap_err(); + match err { + crate::error::ApiError::InvalidField { field, .. } => { + assert_eq!(field, "Gotenberg-Webhook-Url"); + } + other => panic!("expected InvalidField, got {other:?}"), + } + } + + #[test] + fn validate_webhook_config_rejects_loopback_error_url() { + // Primary URL is fine, error URL is loopback → must reject and + // attribute the failure to the error-URL header. + let c = cfg("https://hooks.example.com/x", Some("http://127.0.0.1/err")); + let err = validate_webhook_config(&c, &no_filter()).unwrap_err(); + match err { + crate::error::ApiError::InvalidField { field, .. } => { + assert_eq!(field, "Gotenberg-Webhook-Error-Url"); + } + other => panic!("expected InvalidField, got {other:?}"), + } + } + + #[test] + fn validate_webhook_config_rejects_localhost_hostname() { + let c = cfg("http://localhost:9000/hook", None); + assert!(validate_webhook_config(&c, &no_filter()).is_err()); + } + + #[test] + fn validate_webhook_config_honors_allow_list() { + let v = WebhookUrlValidator::compile( + &["^https://hooks\\.example\\.com/".into()], + &[], + ) + .unwrap(); + let ok = cfg("https://hooks.example.com/x", None); + assert!(validate_webhook_config(&ok, &v).is_ok()); + let blocked = cfg("https://other.example.org/x", None); + assert!(validate_webhook_config(&blocked, &v).is_err()); + } + + #[test] + fn validate_webhook_config_honors_deny_list() { + let v = WebhookUrlValidator::compile(&[], &["evil\\.example\\.com".into()]).unwrap(); + let blocked = cfg("https://evil.example.com/x", None); + assert!(validate_webhook_config(&blocked, &v).is_err()); + } + + #[test] + fn backoff_delay_respects_min_when_attempt_one() { + // attempt=1 → shift=0 → target = min_wait → jitter ∈ [0, min_wait] + let min = Duration::from_secs(1); + let max = Duration::from_secs(30); + let d = backoff_delay(min, max, 1); + assert!(d <= min, "first-attempt delay {d:?} must be <= min {min:?}"); + } + + #[test] + fn backoff_delay_caps_at_max() { + let min = Duration::from_secs(1); + let max = Duration::from_secs(8); + // attempt=10 → 2^9 = 512 → would be 512s, but capped to 8s + let d = backoff_delay(min, max, 10); + assert!(d <= max, "delay {d:?} must be <= max {max:?}"); + } + + #[test] + fn backoff_delay_does_not_overflow_at_huge_attempt() { + // The shift cap (31) prevents overflow. Just verify it doesn't + // panic and stays inside max. + let min = Duration::from_millis(1); + let max = Duration::from_secs(30); + let _ = backoff_delay(min, max, u32::MAX); + } + + #[test] + fn backoff_delay_zero_min_wait_is_zero() { + let d = backoff_delay(Duration::ZERO, Duration::from_secs(30), 1); + assert_eq!(d, Duration::ZERO); + } } diff --git a/crates/server/src/webhook/validate.rs b/crates/server/src/webhook/validate.rs index ebee2bd..b4fc496 100644 --- a/crates/server/src/webhook/validate.rs +++ b/crates/server/src/webhook/validate.rs @@ -79,6 +79,62 @@ pub fn validate_webhook_url(url_str: &str) -> Result<(), ValidationError> { Ok(()) } +/// Compiled allow/deny regex sets layered on top of [`validate_webhook_url`]. +/// +/// Order of checks for `is_allowed(url)`: +/// 1. SSRF check via `validate_webhook_url` (private/loopback/etc). +/// 2. Allow-list — if non-empty, the URL must match at least one regex. +/// 3. Deny-list — the URL must not match any regex. +#[derive(Debug, Clone, Default)] +pub struct WebhookUrlValidator { + allow: Vec, + deny: Vec, +} + +impl WebhookUrlValidator { + /// Compile the user-supplied regex strings. Returns the offending + /// pattern + compile error on first failure so the operator can fix + /// their config. + pub fn compile(allow: &[String], deny: &[String]) -> Result { + let allow = compile_patterns("webhook-allow-list", allow)?; + let deny = compile_patterns("webhook-deny-list", deny)?; + Ok(Self { allow, deny }) + } + + /// True if neither list has any entries — used by callers to skip + /// the regex passes when they're a no-op. + pub fn is_empty(&self) -> bool { + self.allow.is_empty() && self.deny.is_empty() + } + + /// Run SSRF + allow + deny checks against the given URL. + pub fn is_allowed(&self, url: &str) -> Result<(), ValidationError> { + validate_webhook_url(url)?; + if !self.allow.is_empty() && !self.allow.iter().any(|r| r.is_match(url)) { + return Err(ValidationError::BlockedHost(format!( + "url did not match any --webhook-allow-list pattern: {url}" + ))); + } + if let Some(matched) = self.deny.iter().find(|r| r.is_match(url)) { + return Err(ValidationError::BlockedHost(format!( + "url matched --webhook-deny-list pattern `{}`: {url}", + matched.as_str() + ))); + } + Ok(()) + } +} + +fn compile_patterns(field: &str, patterns: &[String]) -> Result, String> { + patterns + .iter() + .map(|p| { + regex::Regex::new(p) + .map_err(|e| format!("invalid {field} regex `{p}`: {e}")) + }) + .collect() +} + /// Validate IPv4 address. fn validate_ipv4(ip: Ipv4Addr) -> Result<(), ValidationError> { // Check loopback @@ -192,4 +248,53 @@ mod tests { fn rejects_ipv6_loopback() { assert!(validate_webhook_url("http://[::1]/webhook").is_err()); } + + #[test] + fn validator_empty_allows_public_url() { + let v = WebhookUrlValidator::compile(&[], &[]).unwrap(); + assert!(v.is_allowed("https://hooks.example.com/x").is_ok()); + assert!(v.is_empty()); + } + + #[test] + fn validator_allow_list_blocks_unmatched() { + let v = + WebhookUrlValidator::compile(&["^https://hooks\\.example\\.com/".into()], &[]).unwrap(); + assert!(v.is_allowed("https://hooks.example.com/x").is_ok()); + assert!(v.is_allowed("https://other.example.org/x").is_err()); + } + + #[test] + fn validator_deny_list_blocks_matched() { + let v = WebhookUrlValidator::compile(&[], &["evil\\.example\\.com".into()]).unwrap(); + assert!(v.is_allowed("https://safe.example.com/x").is_ok()); + assert!(v.is_allowed("https://evil.example.com/x").is_err()); + } + + #[test] + fn validator_ssrf_check_runs_first() { + // Even with a permissive allow-list, private IPs must be rejected + // because SSRF is the first check. + let v = WebhookUrlValidator::compile(&[".*".into()], &[]).unwrap(); + assert!(v.is_allowed("http://10.0.0.1/x").is_err()); + } + + #[test] + fn validator_compile_returns_pattern_in_error() { + let err = WebhookUrlValidator::compile(&["[".into()], &[]).unwrap_err(); + assert!(err.contains("webhook-allow-list"), "msg was: {err}"); + assert!(err.contains('['), "msg was: {err}"); + } + + #[test] + fn validator_deny_takes_precedence_when_both_match() { + let v = WebhookUrlValidator::compile( + &["example\\.com".into()], + &["evil\\.example\\.com".into()], + ) + .unwrap(); + assert!(v.is_allowed("https://safe.example.com/x").is_ok()); + // matches both, but deny wins + assert!(v.is_allowed("https://evil.example.com/x").is_err()); + } } From e48c500ca3cb476fca94585ce9db090465142b30 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 18:10:23 +0530 Subject: [PATCH 45/93] docs: replace README, add comparison.md + markdown-plus, archive specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README.md: leaner, ~265 lines (was ~615). Drops marketing comparison table and inline 32-row spec list; foregrounds operator console as the real differentiator vs Gotenberg; calls out deliberate gaps (TLS, RBAC) and empty placeholders explicitly. - comparison.md (new, root): in-depth audit vs Gotenberg in 16 sections — endpoint matrix, per-engine feature tables, what-we-did / didn't-do / shouldn't-do scorecards. - docs/markdown-plus.md (new): design proposal for an enhanced Markdown route (front-matter, math, mermaid, syntax highlighting, includes, themes). Sits alongside the basic markdown route, not a replacement. - docs/specs/ → docs/specs-archive-2026-05-01.zip. 32 legacy spec files archived; fresh contributor-facing specs will be re-introduced under docs/ in better-organised form. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 718 +++++++--------------- comparison.md | 559 +++++++++++++++++ docs/markdown-plus.md | 307 +++++++++ docs/specs-archive-2026-05-01.zip | Bin 0 -> 135717 bytes docs/specs/00-overview.md | 114 ---- docs/specs/10-engine-types.md | 291 --------- docs/specs/11-engine-chromium.md | 442 ------------- docs/specs/12-engine-libreoffice.md | 337 ---------- docs/specs/13-engine-pdfops.md | 353 ----------- docs/specs/14-engine-pdfa.md | 204 ------ docs/specs/15-webhook.md | 270 -------- docs/specs/16-bookmarks.md | 197 ------ docs/specs/17-watermark.md | 280 --------- docs/specs/18-screenshot.md | 234 ------- docs/specs/19-encrypt.md | 223 ------- docs/specs/20-bdd-testing.md | 600 ------------------ docs/specs/20-cli.md | 362 ----------- docs/specs/30-server.md | 478 -------------- docs/specs/36-chromium-wait-conditions.md | 377 ------------ docs/specs/37-libreoffice-advanced.md | 396 ------------ docs/specs/38-pdfengines-backends.md | 295 --------- docs/specs/39-config-flags.md | 276 --------- docs/specs/40-bindings-py.md | 425 ------------- docs/specs/40-special-features.md | 408 ------------ docs/specs/41-bindings-js.md | 360 ----------- docs/specs/41-github-issues-analysis.md | 358 ----------- docs/specs/42-smart-pdf-optimiser.md | 368 ----------- docs/specs/43-font-doctor.md | 391 ------------ docs/specs/44-crystal-clear-errors.md | 389 ------------ docs/specs/45-live-preview-mode.md | 292 --------- docs/specs/46-pdf-size-estimator.md | 301 --------- docs/specs/47-one-command-install.md | 430 ------------- docs/specs/48-interactive-docs.md | 312 ---------- docs/specs/49-template-library.md | 363 ----------- docs/specs/50-batch-api.md | 414 ------------- docs/specs/50-testing-bdd.md | 464 -------------- docs/specs/51-health-dashboard.md | 412 ------------- 37 files changed, 1077 insertions(+), 11923 deletions(-) create mode 100644 comparison.md create mode 100644 docs/markdown-plus.md create mode 100644 docs/specs-archive-2026-05-01.zip delete mode 100644 docs/specs/00-overview.md delete mode 100644 docs/specs/10-engine-types.md delete mode 100644 docs/specs/11-engine-chromium.md delete mode 100644 docs/specs/12-engine-libreoffice.md delete mode 100644 docs/specs/13-engine-pdfops.md delete mode 100644 docs/specs/14-engine-pdfa.md delete mode 100644 docs/specs/15-webhook.md delete mode 100644 docs/specs/16-bookmarks.md delete mode 100644 docs/specs/17-watermark.md delete mode 100644 docs/specs/18-screenshot.md delete mode 100644 docs/specs/19-encrypt.md delete mode 100644 docs/specs/20-bdd-testing.md delete mode 100644 docs/specs/20-cli.md delete mode 100644 docs/specs/30-server.md delete mode 100644 docs/specs/36-chromium-wait-conditions.md delete mode 100644 docs/specs/37-libreoffice-advanced.md delete mode 100644 docs/specs/38-pdfengines-backends.md delete mode 100644 docs/specs/39-config-flags.md delete mode 100644 docs/specs/40-bindings-py.md delete mode 100644 docs/specs/40-special-features.md delete mode 100644 docs/specs/41-bindings-js.md delete mode 100644 docs/specs/41-github-issues-analysis.md delete mode 100644 docs/specs/42-smart-pdf-optimiser.md delete mode 100644 docs/specs/43-font-doctor.md delete mode 100644 docs/specs/44-crystal-clear-errors.md delete mode 100644 docs/specs/45-live-preview-mode.md delete mode 100644 docs/specs/46-pdf-size-estimator.md delete mode 100644 docs/specs/47-one-command-install.md delete mode 100644 docs/specs/48-interactive-docs.md delete mode 100644 docs/specs/49-template-library.md delete mode 100644 docs/specs/50-batch-api.md delete mode 100644 docs/specs/50-testing-bdd.md delete mode 100644 docs/specs/51-health-dashboard.md diff --git a/README.md b/README.md index db61948..2669e74 100644 --- a/README.md +++ b/README.md @@ -1,614 +1,318 @@ -# Folio -

- Folio Logo + Folio

+

Folio

+

- - CI Status - - - Crates.io - - Rust Version - License - - Release - + A Rust-native, Gotenberg-compatible PDF service — with a live operator console.

- A modern, Rust-native PDF generation engine
- True browser-grade fidelity • Gotenberg-compatible API • Memory safe + Rust 1.75+ + Gotenberg parity ~85% + MIT

--- -## 📖 Table of Contents - -- [What is Folio?](#what-is-folio) -- [Why Folio?](#why-folio) -- [Quick Start](#quick-start) -- [Usage Modes](#usage-modes) -- [Features](#features) -- [Documentation](#documentation) -- [Project Structure](#project-structure) -- [Development](#development) -- [Testing](#testing) -- [Roadmap](#roadmap) -- [Contributing](#contributing) -- [License](#license) - ---- - -## What is Folio? +Folio converts **HTML, URLs, Markdown, and Office documents** into PDFs using +real Chrome under the hood. It speaks the same HTTP API as +[Gotenberg](https://github.com/gotenberg/gotenberg), so most existing +clients can point at Folio with only a base-URL change. -**Folio** (from Latin *folium*, meaning "leaf" or "sheet of paper") is a high-performance PDF generation engine built in Rust. It converts HTML, URLs, Markdown, and Office documents to PDF with **true browser-grade fidelity** by leveraging Chrome's rendering engine via the Chrome DevTools Protocol (CDP). +Unlike Gotenberg, Folio also runs as a **Rust library, a CLI, and a single +binary** — and ships with a live operator console at `/_/` so you can see +what your PDF service is actually doing without wiring up Grafana first. -> Like a printer's folio marks the beginning of a new page, Folio marks a new chapter in document conversion technology. +> **Status:** active. Core conversions and PDF ops are production-ready. +> Webhook callback delivery, batch ZIP output, and a few advanced Chromium +> options are still in progress — see the [feature comparison](./comparison.md). -### Key Highlights +--- -- **True Browser Fidelity**: Renders using real Chrome/Chromium — full CSS3, JavaScript, Web Fonts support -- **Gotenberg-Compatible**: Drop-in replacement for existing Gotenberg deployments -- **Memory Safe**: Rust's compile-time guarantees prevent entire classes of bugs -- **Multiple Interfaces**: HTTP API, CLI, Rust library, and language bindings (Python/Node.js) -- **Self-Contained**: Library mode requires no external HTTP services +## Why Folio + +- **Gotenberg-compatible.** Same routes (`/forms/chromium/*`, + `/forms/libreoffice/convert`, `/forms/pdfengines/*`), same multipart + contract. Drop-in for ~85% of workloads. +- **Memory-safe.** Rust core; no GC pauses, no parser-level CVEs from + malformed inputs. +- **Four ways to run it.** HTTP server, CLI, Rust library, Docker — pick + whichever fits your shape. The library is the source of truth; the + server and CLI are thin wrappers. +- **Observability-first.** Prometheus metrics, OpenTelemetry traces, and + a built-in Svelte SPA at `/_/` showing live RPS, p95 latency, + per-engine health, concurrency, and active batches over SSE. +- **Slim deployment targets.** Multi-stage Dockerfile produces full, + Chromium-only, LibreOffice-only, Cloud Run, and Lambda images. + +For the honest comparison against Gotenberg (what's parity, what's behind, +what's ahead) read [`comparison.md`](./comparison.md). --- -## Why Folio? +## 60-second quickstart -### Comparison Table - -| Feature | **Folio** | Gotenberg | WeasyPrint | wkhtmltopdf | -|---------|------------|-----------|-------------|-------------| -| **Language** | Rust 🦀 | Go | Python | C++ | -| **Rendering** | Chrome (CDP) | Chrome | Custom engine | QtWebKit (2012) | -| **Modern CSS** | ✅ Full | ✅ Full | ⚠️ Limited | ❌ Legacy | -| **JavaScript** | ✅ Full V8 | ✅ Full | ❌ None | ⚠️ ES3 | -| **Usage Modes** | 4 (Server/CLI/Lib/Bindings) | Server only | Library only | CLI only | -| **Memory Safety** | ✅ Compile-time | GC | Runtime | Manual | -| **Gotenberg API** | ✅ Compatible | ✅ Native | ❌ | ❌ | -| **Screenshots** | ✅ Done | ✅ | ❌ | ❌ | -| **Structured Logging** | ✅ Full (tracing) | ✅ (slog) | ❌ | ❌ | -| **Prometheus Metrics** | ✅ `/prometheus/metrics` | ✅ | ❌ | ❌ | -| **OpenTelemetry** | ✅ OTLP HTTP | ✅ | ❌ | ❌ | -| **Process Supervision** | 🚧 In Progress | ✅ | ❌ | ❌ | +```bash +# Run the server (Docker, full image) +docker run --rm -p 3000:3000 ghcr.io/__deesh_reddy__/folio:latest -### Architecture Pattern +# Convert a URL to PDF +curl -X POST http://localhost:3000/forms/chromium/convert/url \ + -F "url=https://example.com" \ + -F "landscape=true" \ + -o out.pdf -``` -┌─────────────────────────────────────────────────────────────┐ -│ USAGE MODES │ -│ Server CLI Rust Lib Python Node.js │ -│ │ │ │ │ │ │ -│ └────────┴─────────┴──────────┴─────────┘ │ -│ │ │ -│ ┌──────────┴──────────┐ │ -│ │ engine │ ← Single source │ -│ │ • ChromiumEngine │ of truth │ -│ │ • LibreOfficeEngine │ │ -│ │ • PdfOperations │ │ -│ └──────────┬────────────┘ │ -│ │ │ -│ ┌──────────┴──────────┐ │ -│ │ Chrome (CDP) │ │ -│ └──────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ +# Open the operator console +open http://localhost:3000/_/ ``` +That's it. Same multipart contract for HTML, Markdown, Office, merge, +split, watermark, etc. + --- -## Quick Start +## Install -### Prerequisites +| Surface | Command | +|------------------|---------------------------------------------------------------| +| Docker (full) | `docker pull ghcr.io/__deesh_reddy__/folio:latest` | +| Docker (slim) | `docker pull ghcr.io/__deesh_reddy__/folio:latest-chromium` | +| CLI (cargo) | `cargo install --path crates/cli` → `folio --help` | +| Server (cargo) | `cargo run -p server -- serve --port 3000` | +| Library | `folio-engine = { path = "crates/engine" }` in `Cargo.toml` | -- **Rust** 1.75+ ([install](https://rustup.rs/)) -- **Chrome/Chromium** (auto-detected) or set `CHROME_PATH` -- **LibreOffice** (optional, for Office document conversion) +**Prerequisites for non-Docker installs:** Rust 1.75+, Chrome/Chromium +(auto-detected, or set `CHROME_PATH`), and optionally LibreOffice for +Office conversion. -### Option 1: HTTP Server (Gotenberg-Compatible) +--- -```bash -# Build and run -cargo run -p server -- serve --port 3000 +## HTTP API at a glance -# Or with Docker (full image — Chromium + LibreOffice) -docker build --target folio -t folio:latest . -docker run -p 3000:3000 folio:latest +All routes are `POST` and accept multipart/form-data unless noted. -# Convert URL to PDF -curl -X POST http://localhost:3000/forms/chromium/convert/url \ - -F "url=https://example.com" \ - -F "landscape=true" \ - -o output.pdf +### Chromium (HTML / URL / Markdown → PDF or screenshot) +``` +/forms/chromium/convert/{html,url,markdown} +/forms/chromium/screenshot/{html,url,markdown} ``` -### Option 2: CLI +### LibreOffice (100+ Office formats → PDF) +``` +/forms/libreoffice/convert +``` -```bash -# Install -cargo install --path crates/cli +### PDF operations +``` +/forms/pdfengines/{merge,split,flatten,rotate,watermark,convert,encrypt} +/forms/pdfengines/metadata/{read,write} +/forms/pdfengines/bookmarks/{read,write} +``` -# Convert HTML to PDF -folio convert --html index.html --output out.pdf +### Operational +``` +GET /health → JSON health + per-engine status +GET /version → plain text +GET /prometheus/metrics → Prometheus text format +GET /_/ → operator console (SPA) +GET /_/sse → Server-Sent Events stream +``` -# Convert URL to PDF -folio convert --url https://example.com --output out.pdf +For the gap analysis vs Gotenberg, see [`comparison.md`](./comparison.md). -# Batch conversion -folio batch --input-dir ./docs/ --output-dir ./pdfs/ -``` +--- -### Option 3: Rust Library +## CLI -```toml -# Cargo.toml -[dependencies] -folio-engine = { path = "crates/engine" } +```bash +folio convert --html index.html --output out.pdf +folio convert --url https://example.com --output out.pdf +folio convert --markdown README.md --output out.pdf +folio convert --office report.docx --output out.pdf + +folio merge a.pdf b.pdf c.pdf --output combined.pdf +folio split input.pdf --mode uniform --span 1 --output-dir ./pages/ +folio flatten input.pdf --output flat.pdf +folio rotate input.pdf --angle 90 --output rotated.pdf +folio metadata read input.pdf +folio metadata write input.pdf '{"Title":"Q2 Review"}' ``` +Shell completions: `folio completion zsh > ~/.zfunc/_folio`. + +--- + +## Library + ```rust use engine::ChromiumEngine; #[tokio::main] async fn main() -> anyhow::Result<()> { let engine = ChromiumEngine::launch().await?; - let pdf = engine.html_to_pdf("

Hello World

", None, &Default::default(), &Default::default()).await?; - std::fs::write("output.pdf", pdf)?; + let pdf = engine + .html_to_pdf("

Hello

", None, &Default::default(), &Default::default()) + .await?; + std::fs::write("out.pdf", pdf)?; Ok(()) } ``` -### Option 4: Docker Compose (Development) - -```bash -# Copy example environment file -cp .env.example .env - -# Start Folio with all dependencies -make run - -# Run tests -make test-integration - -# Stop -make stop -``` +The engine crate has zero dependency on `axum` or `tower` — it's the same +code path the server uses, just without an HTTP layer in front. --- -## Usage Modes +## Operator console -### 1. Server Mode (HTTP API) +`GET /_/` serves a Svelte SPA driven by Server-Sent Events. In one screen: -Gotenberg-compatible REST API for document conversion: +- **Ticker:** RPS, p95 latency, error %, in-flight count +- **Routes table:** per-endpoint p50 / p95 / p99, error %, load % +- **Engines:** Chromium / LibreOffice up-down + restart count +- **Concurrency grid:** active vs cap, with warn/crit thresholds +- **Throughput strip:** 30-min RPS + p95 trend with SLA overlay +- **Resources:** CPU % and memory MB +- **Batches:** progress + per-item state for active batches +- **Logs:** last 20 requests, last 10 errors -| Endpoint | Method | Input | Output | -|----------|--------|-------|--------| -| `/forms/chromium/convert/html` | POST | HTML file | PDF | -| `/forms/chromium/convert/url` | POST | URL | PDF | -| `/forms/chromium/convert/markdown` | POST | Markdown | PDF | -| `/forms/chromium/screenshot/html` | POST | HTML | PNG/JPEG/WebP | -| `/forms/libreoffice/convert` | POST | Office docs | PDF | -| `/forms/pdfengines/merge` | POST | PDFs | Merged PDF | -| `/forms/pdfengines/split` | POST | PDF | Split PDFs | -| `/health` | GET | - | Health status | +This is the cleanest lead Folio has over Gotenberg today; it's where the +last 30 commits have lived. If you've ever bolted Grafana onto Gotenberg +just to see whether it's healthy — this replaces that step. -See [API Documentation](./docs/gotenberg-spec.md) for full details. +--- -### 2. CLI Mode +## Configuration -Command-line interface for batch operations and scripting: +Common flags (every flag is also `FOLIO_*` env-overridable): ```bash -# Convert various formats -folio convert --html file.html --output out.pdf -folio convert --url https://example.com --output out.pdf -folio convert --markdown README.md --output readme.pdf - -# PDF operations -folio merge --output combined.pdf file1.pdf file2.pdf -folio split input.pdf --output-dir ./split/ -folio flatten input.pdf --output flat.pdf -folio metadata read input.pdf -``` - -### 3. Library Mode (Rust) - -Use Folio as a Rust library in your applications: - -```rust -// HTML to PDF -let engine = ChromiumEngine::launch().await?; -let pdf = engine.html_to_pdf(html, None, &opts, &ctx).await?; - -// URL to PDF -let pdf = engine.url_to_pdf("https://example.com", &opts, &ctx).await?; - -// Markdown to PDF -let pdf = engine.markdown_to_pdf(markdown, &opts, &ctx).await?; +folio-server serve \ + --host 0.0.0.0 --port 3000 \ + --concurrency 8 \ + --max-body-bytes 52428800 \ # 50 MiB + --request-timeout 120s \ + --chrome /usr/bin/google-chrome --no-sandbox \ + --soffice /usr/bin/soffice \ + --log-level info --log-format json \ + --api-basic-auth-username admin --api-basic-auth-password secret \ + --otel-enabled --otel-endpoint http://localhost:4318/v1/traces ``` -### 4. Language Bindings +Run `folio-server serve --help` for the full flag reference. -**Python** ([Planned]): -```python -import folio - -engine = folio.ChromiumEngine() -pdf = engine.html_to_pdf("

Hello

") -``` - -**Node.js** ([Planned]): -```javascript -const folio = require('folio'); -const engine = new folio.ChromiumEngine(); -const pdf = await engine.htmlToPdf('

Hello

'); -``` - ---- - -## Features - -### ✅ Implemented - -- **HTML/URL to PDF**: Full Chrome rendering with print CSS support -- **Markdown to PDF**: GitHub Flavored Markdown with syntax highlighting -- **Office Documents**: Convert 100+ formats via LibreOffice (DOC, DOCX, PPT, XLS, ODT, etc.) -- **PDF Operations**: Merge, split, flatten, rotate, watermark -- **PDF Metadata**: Read/write PDF metadata -- **Gotenberg Compatibility**: Drop-in API replacement -- **Health Checks**: `/health` endpoint with engine status -- **Concurrent Rendering**: Thread-safe browser instance sharing -- **Screenshots**: URL/HTML/Markdown to PNG/JPEG/WebP -- **BDD Testing**: Port Gotenberg's Gherkin scenarios to Rust -- **Webhook System**: Async job dispatch with retry, full engine integration (spec 15) -- **Structured Logging**: Context-aware logs with request_id, engine type, duration (text/JSON formats) -- **Prometheus Metrics**: `/prometheus/metrics` endpoint with conversion, queue, and engine metrics - -### 🚧 In Progress / Partially Done - -- **Advanced Wait Conditions**: `skipNetworkIdleEvent`, `failOnResourceLoadingFailed`, etc. (spec 36) -- **Advanced LibreOffice Fields**: 30+ missing export options (spec 37) -- **Full CLI Flag Parity**: Many Gotenberg flags still missing (spec 39) -- **Actionable Errors**: Structured error responses, room for enhancement (spec 44) -- **BDD Test Suite**: Framework exists, scenario coverage incomplete (spec 50) -- **Batch API**: CLI batch works; server-side bulk endpoint pending (spec 50-batch) -- **Health Dashboard**: JSON `/health` works; visual HTML dashboard pending (spec 51) - -### ❌ Not Started (Spec-Only) - -- **Python / Node.js Bindings**: Empty placeholders only (specs 40, 41) -- **Multi-Backend PDF Engines**: qpdf, pdfcpu, pdftk backends (spec 38) -- **Special Features**: TLS, auth, cloud-run, remote URL download (spec 40-special) -- **Smart PDF Optimiser**: Automatic bloat detection & compression (spec 42) -- **Font Doctor**: Font rendering diagnostics (spec 43) -- **Live Preview**: HTML→image debug preview (spec 45) -- **PDF Size Estimator**: Pre-flight size prediction (spec 46) -- **One-Command Install**: `curl | bash` installer (spec 47) -- **Interactive Docs**: Built-in `/docs` API explorer (spec 48) -- **Template Library**: Pre-built document templates (spec 49) - -> **Note:** This README is a high-level overview. For a ground-truth audit of what is actually built vs. spec claims, see [`docs/implementation-status.md`](./docs/implementation-status.md). The `20-missing-features-roadmap.md` spec is currently stale and should not be relied upon for current status. +**TLS is intentionally not handled in-process.** Put nginx, Caddy, or +envoy in front. Cert rotation, OCSP stapling, and ALPN are not things +Folio is positioned to do better than they do. --- -## Documentation - -### Core Documentation - -| Document | Description | -|----------|-------------| -| [Technical Specification](./docs/proposal.md) | Full architecture and design | -| [Gotenberg API Spec](./docs/gotenberg-spec.md) | API compatibility details | -| [Gap Analysis](./docs/gap-analysis.md) | Research findings | - -### Specs (Implementation Guides) - -| Spec | Description | Status | -|------|-------------|--------| -| [00-overview](./docs/specs/00-overview.md) | Spec system overview & conventions | 📋 Reference | -| [10-engine-types](./docs/specs/10-engine-types.md) | Core types, errors, options | ✅ Done | -| [11-engine-chromium](./docs/specs/11-engine-chromium.md) | Chromium engine (HTML/URL/Markdown→PDF + screenshots) | ✅ Done | -| [12-engine-libreoffice](./docs/specs/12-engine-libreoffice.md) | LibreOffice engine (Office→PDF) | ✅ Done | -| [13-engine-pdfops](./docs/specs/13-engine-pdfops.md) | PDF operations (merge, split, flatten, metadata, watermark, rotate) | ✅ Done | -| [14-engine-pdfa](./docs/specs/14-engine-pdfa.md) | PDF/A & PDF/UA conformance conversion | ✅ Done | -| [15-webhook](./docs/specs/15-webhook.md) | Async webhook callback system | 🚧 Partially Done | -| [16-bookmarks](./docs/specs/16-bookmarks.md) | PDF bookmarks/outline read & write | ✅ Done | -| [17-watermark](./docs/specs/17-watermark.md) | PDF watermark & stamp overlay | ✅ Done *(via spec 13)* | -| [18-screenshot](./docs/specs/18-screenshot.md) | Chromium screenshot API (PNG/JPEG/WebP) | ✅ Done *(via spec 11)* | -| [19-encrypt](./docs/specs/19-encrypt.md) | PDF encryption & password protection | ✅ Done | -| [20-cli](./docs/specs/20-cli.md) | Command-line interface (`folio` binary) | ✅ Done | -| [20-bdd-testing](./docs/specs/20-bdd-testing.md) | BDD test strategy | 🚧 Partially Done | -| [20-missing-features-roadmap](./docs/specs/20-missing-features-roadmap.md) | Feature parity roadmap vs Gotenberg | 📋 Reference | -| [30-server](./docs/specs/30-server.md) | HTTP server (Gotenberg-compatible API) | ✅ Done | -| [36-chromium-wait-conditions](./docs/specs/36-chromium-wait-conditions.md) | Advanced wait conditions & options | 🚧 Partially Done | -| [37-libreoffice-advanced](./docs/specs/37-libreoffice-advanced.md) | Advanced LibreOffice form fields | 🚧 Partially Done | -| [38-pdfengines-backends](./docs/specs/38-pdfengines-backends.md) | Multi-backend support (qpdf, pdfcpu, pdftk) | ❌ Not Done | -| [39-config-flags](./docs/specs/39-config-flags.md) | Full Gotenberg CLI flag parity | 🚧 Partially Done | -| [40-bindings-py](./docs/specs/40-bindings-py.md) | Python bindings (`py` crate) | ❌ Not Done *(placeholder)* | -| [40-special-features](./docs/specs/40-special-features.md) | TLS, auth, cloud-run, remote URL download | ❌ Not Done | -| [41-bindings-js](./docs/specs/41-bindings-js.md) | Node.js bindings (`js` crate) | ❌ Not Done *(placeholder)* | -| [41-github-issues-analysis](./docs/specs/41-github-issues-analysis.md) | User pain-point research from GitHub issues | 📋 Research | -| [42-smart-pdf-optimiser](./docs/specs/42-smart-pdf-optimiser.md) | Automatic PDF size optimisation | ❌ Not Done | -| [43-font-doctor](./docs/specs/43-font-doctor.md) | Font rendering diagnostics & fixes | ❌ Not Done | -| [44-crystal-clear-errors](./docs/specs/44-crystal-clear-errors.md) | Actionable error messages (replace generic 500s) | 🚧 Partially Done | -| [45-live-preview-mode](./docs/specs/45-live-preview-mode.md) | Live HTML→image preview for debugging | ❌ Not Done | -| [46-pdf-size-estimator](./docs/specs/46-pdf-size-estimator.md) | Pre-flight PDF size prediction | ❌ Not Done | -| [47-one-command-install](./docs/specs/47-one-command-install.md) | Frictionless install (`curl | bash`) | ❌ Not Done | -| [48-interactive-docs](./docs/specs/48-interactive-docs.md) | Built-in API explorer at `/docs` | ❌ Not Done | -| [49-template-library](./docs/specs/49-template-library.md) | Pre-built document templates | ❌ Not Done | -| [50-batch-api](./docs/specs/50-batch-api.md) | Bulk conversion API (100+ docs in one request) | 🚧 Partially Done *(CLI batch only)* | -| [50-testing-bdd](./docs/specs/50-testing-bdd.md) | BDD integration test suite (Gherkin→Rust) | 🚧 Partially Done | -| [51-health-dashboard](./docs/specs/51-health-dashboard.md) | Visual health dashboard beyond JSON `/health` | 🚧 Partially Done | - -**Legend:** `✅ Done` = fully implemented & tested. `🚧 Partially Done` = core working, gaps remain. `❌ Not Done` = spec only, no code. `📋 Reference` = meta-doc or research, no code expected. - -### API Reference - -- **Chromium Routes**: `/forms/chromium/*` (convert HTML/URL/Markdown, screenshots) -- **LibreOffice Routes**: `/forms/libreoffice/*` (convert Office docs) -- **PDF Engine Routes**: `/forms/pdfengines/*` (merge, split, flatten, etc.) +## Docker variants ---- +Single `Dockerfile`, multiple `--target` stages — pick the smallest one +that does what you need. -## Project Structure +| Target | Contains | Use case | +|------------------------------|----------------------|----------------------| +| `folio` | Chromium + LO | Default | +| `folio-chromium` | Chromium | HTML/URL/Markdown only (~30% smaller) | +| `folio-libreoffice` | LO | Office docs only (~40% smaller) | +| `folio-cloudrun` | Full + Cloud Run env | Google Cloud Run | +| `folio-lambda` | Full + Lambda Web Adapter | AWS Lambda | +| `folio-{cloudrun,lambda}-{chromium,libreoffice}` | Slim + platform | Mix-and-match | -``` -folio/ -├── Cargo.toml # Workspace definition -├── README.md # This file -├── Dockerfile # Single file, 9 named --target variants (see Docker section) -├── Dockerfile.test # Test environment (poppler, JRE, verapdf) -├── docker-compose.yml # Development environment -├── Makefile # Build/test/docker automation -├── .env.example # Configuration template -│ -├── crates/ -│ ├── engine/ # Core PDF generation engine -│ │ ├── src/ -│ │ │ ├── chromium/ # Chrome/Chromium integration -│ │ │ │ ├── launch.rs # Browser discovery & launch -│ │ │ │ ├── render.rs # HTML/URL → PDF -│ │ │ │ └── screenshot.rs # Screenshots (✅) -│ │ │ ├── libreoffice/ # LibreOffice integration -│ │ │ └── pdfops/ # PDF manipulation -│ │ └── Cargo.toml -│ │ -│ ├── server/ # HTTP server (Gotenberg-compatible) -│ │ ├── src/ -│ │ │ ├── routes/ # API route handlers -│ │ │ └── app.rs # Router configuration -│ │ └── tests/ # Integration tests -│ │ -│ ├── cli/ # Command-line interface -│ │ └── src/commands/ # CLI subcommands -│ │ -│ -├── docs/ -│ ├── proposal.md # Technical specification -│ ├── gotenberg-spec.md # Gotenberg API analysis -│ ├── gap-analysis.md # Research findings -│ ├── assets/ # Images, logos -│ └── specs/ # Implementation specs (32 files, see table above) -│ -└── crates/*/tests/ # Crate-local tests (unit + integration) - └── server/tests/bdd/ # BDD integration tests +```bash +docker build --target folio-chromium -t folio:chromium . +make docker-push-all DOCKER_REGISTRY=ghcr.io/me VERSION=1.0.0 ``` --- -## Development - -### Building from Source - -```bash -# Clone the repository -git clone https://github.com/yourusername/folio.git -cd folio - -# Build all crates -cargo build --release +## Where things stand -# Run tests -cargo test +A short, honest scorecard. The full version is [`comparison.md`](./comparison.md). -# Run with specific features -cargo run -p server -- serve --help -``` +**Ready to use:** +HTML/URL/Markdown→PDF · Office→PDF · screenshots · merge · split · flatten · +rotate · watermark · metadata · bookmarks · encrypt · PDF/A & PDF/UA · +Basic Auth · Prometheus · OpenTelemetry · operator console · CLI · Rust +library · multi-target Docker. -### Docker Image Variants +**In progress:** +Webhook callback delivery (scaffold ready, delivery TODO) · +batch API ZIP/merge output (endpoints + worker exist) · +advanced Chromium wait/fail conditions (`waitForSelector`, `failOn*`) · +long tail of LibreOffice export filters · `embed` and full `stamp` routes. -All variants are built from a single `Dockerfile` using named `--target` stages, following Gotenberg's pattern. Each platform-specific variant (Cloud Run, Lambda) is a thin layer on top of the base variant — just environment variables. +**Deliberate gaps:** +TLS in-process (use a reverse proxy) · OAuth/JWT/RBAC (use a reverse +proxy) · workflow/DAG engine on top of batch (out of scope). -| Target | Tag | Description | -|--------|-----|-------------| -| `folio` | `latest`, `vX.Y.Z` | Full: Chromium + LibreOffice | -| `folio-chromium` | `latest-chromium` | Chromium only (~30% smaller) | -| `folio-libreoffice` | `latest-libreoffice` | LibreOffice only (~40% smaller) | -| `folio-cloudrun` | `latest-cloudrun` | Full + Google Cloud Run env vars | -| `folio-cloudrun-chromium` | `latest-chromium-cloudrun` | Chromium + Cloud Run | -| `folio-cloudrun-libreoffice` | `latest-libreoffice-cloudrun` | LibreOffice + Cloud Run | -| `folio-lambda` | `latest-lambda` | Full + [Lambda Web Adapter](https://github.com/awslabs/aws-lambda-web-adapter) | -| `folio-lambda-chromium` | `latest-chromium-lambda` | Chromium + Lambda | -| `folio-lambda-libreoffice` | `latest-libreoffice-lambda` | LibreOffice + Lambda | +**Empty placeholders (will be removed if not built):** +Python bindings (`crates/py/`), Node bindings (`crates/js/`). -```bash -# Build a specific variant -docker build --target folio-chromium -t myrepo/folio:chromium . - -# Build + push all 9 variants -make docker-push-all DOCKER_REGISTRY=myrepo/folio VERSION=1.0.0 +--- -# Run with Docker Compose (default: full image) -docker compose up folio +## Documentation -# Run Chromium-only profile -docker compose --profile chromium up folio-chromium -``` +- [`comparison.md`](./comparison.md) — in-depth audit vs Gotenberg +- [`docs/markdown-plus.md`](./docs/markdown-plus.md) — proposed + enhanced Markdown route (front-matter, math, mermaid, themes) -### Development Commands - -| Command | Description | -|---------|-------------| -| `make docker-build` | Build full Docker image | -| `make docker-build-all` | Build all 9 variants | -| `make docker-push-all` | Build and push all variants | -| `make run` | Start Folio via Docker Compose | -| `make test-unit` | Run unit tests | -| `make test-integration` | Run integration tests (requires Chrome) | -| `make fmt` | Format code | -| `make lint` | Lint with Clippy | -| `make check` | Run format + lint + unit tests | -| `make clean` | Clean build artifacts | - -### Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `CHROME_PATH` | Path to Chrome/Chromium executable | Auto-detected | -| `LIBREOFFICE_PATH` | Path to LibreOffice (soffice) | Auto-detected | -| `RUST_LOG` | Log level (trace, debug, info, warn, error) | `info` | -| `FOLIO_PORT` | Server port | `3000` | -| `FOLIO_CONCURRENCY` | Max concurrent renders | CPU count | -| `FOLIO_OTEL_ENABLED` | Enable OpenTelemetry trace export | `false` | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP HTTP trace endpoint | `http://localhost:4318/v1/traces` | +> **Note on specs.** The previous 32-file `docs/specs/` tree has been +> archived to [`docs/specs-archive-2026-05-01.zip`](./docs/specs-archive-2026-05-01.zip). +> Fresh, better-organised contributor-facing specs are being written and +> will reappear under `docs/` shortly. --- -## Testing - -### Test Structure - -``` -tests/ -├── unit/ # Unit tests (cargo test --lib) -├── integration/ # BDD integration tests (🚧) -│ ├── scenarios/ # Test scenarios (ported from Gotenberg) -│ ├── common/ # Test helpers -│ └── testdata/ # Test fixtures -└── e2e/ # End-to-end tests -``` - -### Running Tests +## Development ```bash -# Unit tests (no Chrome required) -cargo test --lib +git clone https://github.com/__deesh_reddy__/folio.git && cd folio -# Integration tests (skip gracefully if deps missing) -cargo test -p server --test bdd - -# E2E tests (skip gracefully if deps missing) -cargo test -p server --test e2e - -# All tests (skip gracefully if deps missing) -cargo test -- --test-threads=1 - -# All tests with Docker -make docker-test +cargo build --release # build everything +cargo test # unit + integration (skips gracefully if Chrome missing) +make check # fmt + clippy + unit tests (run before PRs) +make run # docker-compose up, full image +make test-integration # BDD scenarios in Docker ``` -### Test Coverage +| Command | What it does | +|-------------------------|---------------------------------------| +| `make docker-build` | Build full image | +| `make docker-build-all` | Build all 9 image variants | +| `make test-unit` | `cargo test --lib` | +| `make test-integration` | BDD + e2e in container | +| `make fmt` / `make lint`| `cargo fmt` / `cargo clippy` | -We're porting Gotenberg's comprehensive BDD test suite: - -- ✅ Unit tests: 50+ test cases -- 🚧 Integration tests: BDD framework with 25+ feature files (scenario pass rate unverified) -- ✅ E2E tests: Server + CLI smoke tests - -See [BDD Testing Spec](./docs/specs/50-testing-bdd.md) for details. - ---- - -## Roadmap - -### Phase 1: Core Features ✅ -- [x] HTML/URL/Markdown → PDF (Chromium) — spec 11 -- [x] Office documents → PDF (LibreOffice) — spec 12 -- [x] PDF operations (merge, split, flatten, rotate, watermark) — spec 13 -- [x] PDF metadata read/write — spec 13 -- [x] Gotenberg-compatible API — spec 30 -- [x] Screenshots (HTML/URL/Markdown → PNG/JPEG/WebP) — spec 11 / 18 -- [x] Structured Logging (tracing with text/JSON formats) -- [x] Prometheus Metrics (`/prometheus/metrics` endpoint) -- [x] OpenTelemetry Traces (OTLP HTTP exporter) -- [x] CLI (`folio` binary) — spec 20 - -### Phase 2: Advanced Engine Features 🚧 -- [x] PDF/A & PDF/UA conformance conversion — spec 14 -- [x] PDF bookmarks read/write — spec 16 -- [x] PDF encryption & password protection — spec 19 -- [ ] Advanced Chromium wait conditions — spec 36 -- [ ] Advanced LibreOffice form fields — spec 37 -- [ ] Multi-backend PDF engines (qpdf, pdfcpu, pdftk) — spec 38 - -### Phase 3: Server & Infrastructure 🚧 -- [ ] Webhook system with retry — spec 15 -- [ ] Full CLI flag parity with Gotenberg — spec 39 -- [ ] Batch API (server-side bulk conversion) — spec 50-batch -- [ ] Actionable error messages — spec 44 -- [ ] Visual health dashboard — spec 51 - -### Phase 4: Bindings & Ecosystem ❌ -- [ ] Python bindings (`py` crate) — spec 40 -- [ ] Node.js bindings (`js` crate) — spec 41 -- [ ] TLS, auth, cloud-run, remote URL download — spec 40-special - -### Phase 5: Unique Folio Features ❌ -- [ ] Smart PDF optimiser — spec 42 -- [ ] Font doctor / diagnostics — spec 43 -- [ ] Live preview mode — spec 45 -- [ ] PDF size estimator — spec 46 -- [ ] One-command install (`curl | bash`) — spec 47 -- [ ] Interactive API docs (`/docs`) — spec 48 -- [ ] Template library — spec 49 - -See [Full Roadmap](./docs/specs/20-missing-features-roadmap.md) and detailed specs in [docs/specs/](./docs/specs/) for planning. +**Useful env vars:** `CHROME_PATH`, `LIBREOFFICE_PATH`, `RUST_LOG`, +`FOLIO_PORT`, `FOLIO_CONCURRENCY`, `OTEL_EXPORTER_OTLP_ENDPOINT`. --- ## Contributing -Contributions are welcome! Please read our [contributing guidelines](./CONTRIBUTING.md) before submitting a PR. - -### Quick Contribution Guide +PRs welcome. Three things that make a PR easy to land: -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'feat: add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request +1. `make check` passes locally. +2. Conventional Commits style (`feat:`, `fix:`, `docs:`, `chore:`). +3. One feature or fix per PR — split mixed work. -### Development Workflow - -- Use [Conventional Commits](https://www.conventionalcommits.org/) for commit messages -- Ensure `make check` passes before submitting PR -- Add tests for new functionality -- Update documentation as needed -- Keep PRs focused on a single feature/fix +For larger changes, open an issue first so we can agree on the shape +before code. --- -## Acknowledgments - -- **[Gotenberg](https://github.com/gotenberg/gotenberg)** - The original PDF generation API that inspired this project -- **[chromiumoxide](https://github.com/mattsse/chromiumoxide)** - Chrome DevTools Protocol client for Rust -- **[lopdf](https://github.com/Hopding/lopdf)** - Pure Rust PDF manipulation library -- **[Axum](https://github.com/tokio-rs/axum)** - Ergonomic HTTP server framework +## Acknowledgements ---- +- [Gotenberg](https://github.com/gotenberg/gotenberg) — the API contract Folio implements +- [chromiumoxide](https://github.com/mattsse/chromiumoxide) — Chrome DevTools Protocol client +- [lopdf](https://github.com/J-F-Liu/lopdf) — pure-Rust PDF manipulation +- [axum](https://github.com/tokio-rs/axum) — HTTP server ## License -Folio is licensed under the MIT License - see [LICENSE](LICENSE) for details. - ---- - -

- Built with ❤️ in Rust 🦀
- Folio: A new page in PDF generation. -

+MIT. See [LICENSE](./LICENSE). diff --git a/comparison.md b/comparison.md new file mode 100644 index 0000000..d73a1c4 --- /dev/null +++ b/comparison.md @@ -0,0 +1,559 @@ +# Folio vs Gotenberg — In-Depth Feature Comparison + +> **Snapshot date:** 2026-05-01 +> **Folio commit:** `spec/operator-console` (HEAD: `209a444`) +> **Gotenberg snapshot:** vendored at `tmp/gotenberg/` +> **Companion:** `docs/markdown-plus.md` (the new Markdown variation +> referenced in this comparison's recommendations). + +This document is an audit, not a sales sheet. It records what each project +does *today*, what Folio has chosen not to do (deliberately or not), and +what is missing relative to Gotenberg parity. It is structured so that any +single section can be read in isolation by someone deciding whether Folio +is ready for their workload. + +--- + +## 0. TL;DR + +| Axis | Folio | Gotenberg | Verdict | +|-----------------------------------------|------------------------------------|------------------------------------|------------------------| +| Core conversions (HTML/URL/MD/Office) | ✅ Implemented | ✅ Implemented | **Parity** | +| Screenshot routes (PNG/JPEG/WebP) | ✅ Implemented | ✅ Implemented | **Parity** | +| PDF ops (merge/split/flatten/rotate/…) | ✅ Implemented (single backend) | ✅ Implemented (multi backend) | Folio behind on choice | +| PDF/A & PDF/UA | ✅ via Ghostscript | ✅ via LibreOffice + engines | Different paths, OK | +| Metadata read/write | ✅ | ✅ | **Parity** | +| Bookmarks read/write | ✅ | ✅ | **Parity** | +| Encrypt | ✅ | ✅ | **Parity** | +| Watermark / stamp | ✅ (watermark) / partial (stamp) | ✅ both | Folio behind on stamp | +| Webhook async delivery | 🚧 Scaffolded, callback TODO | ✅ Production-grade | **Folio missing** | +| Batch API | 🚧 Endpoints + worker, ZIP TODO | ❌ Not offered | Folio ahead (in spec) | +| Prometheus metrics | ✅ Rich set | ✅ Standard set | **Parity** | +| Structured logs | ✅ JSON/text + request IDs | ✅ slog | **Parity** | +| OpenTelemetry traces | ✅ OTLP HTTP | ✅ OTel SDK | **Parity** | +| Operator console (live UI) | ✅ Svelte SPA, SSE, charts | ❌ JSON only | **Folio ahead** | +| Auth (Basic) | ✅ | ✅ | **Parity** | +| TLS | ❌ (rely on reverse proxy) | ✅ (cert/key flags) | **Folio missing** | +| SSRF / download allow-deny | partial | ✅ rich | **Folio behind** | +| Multi-engine fallback per op | ❌ (lopdf only) | ✅ qpdf/pdfcpu/pdftk/exiftool | **Folio missing** | +| Python / Node bindings | ❌ Empty crates | ❌ Not offered | Both miss | +| CLI (convert/merge/split/…) | ✅ | ❌ Not offered | **Folio ahead** | +| Library (Rust crate) usage | ✅ | ❌ Server-only | **Folio ahead** | + +**Bottom line.** Folio reaches roughly **85% of Gotenberg's HTTP-surface +capability** while exceeding it on observability, in-process usage, and +CLI ergonomics. The remaining 15% — webhook callback delivery, multi-engine +fallback chains, TLS, fine-grained SSRF controls, advanced Chromium wait +conditions, the long tail of LibreOffice export filters — is what blocks a +clean drop-in replacement claim today. + +--- + +## 1. Architecture comparison + +### 1.1 Gotenberg +- **Language:** Go +- **Framework:** Echo HTTP, modular plugin system +- **Concurrency model:** Process pools per engine (Chromium / LibreOffice + supervised externally), goroutines per request +- **Rendering:** Each Chromium conversion launches/uses a managed Chrome + subprocess; LibreOffice spawns `soffice` per conversion +- **Deployment shape:** Container-only — the project is explicitly a + Docker product +- **Distribution:** Single binary inside a Debian image with all engines + preinstalled + +### 1.2 Folio +- **Language:** Rust +- **Framework:** axum / tower +- **Concurrency model:** Tokio tasks, semaphore-bounded; engines wrapped in + `SupervisedEngine` with lazy-start / idle-shutdown +- **Rendering:** Chromium via `chromiumoxide` (CDP) — Folio holds the + client; LibreOffice via `soffice` subprocess +- **Deployment shape:** Container *or* binary *or* Rust library *or* CLI +- **Distribution:** Multi-target Dockerfile (`folio`, `folio-chromium`, + `folio-libreoffice`, `folio-cloudrun`, `folio-lambda`) + +### 1.3 What this means in practice +Folio's choice to live as a *library* is the real architectural divergence +— it is a strict superset of "PDF microservice", whereas Gotenberg only +exists as the microservice form. That choice shapes a lot of what +follows: the supervised-engine wrapper, the operator console, the CLI all +flow from "we are not married to the HTTP surface." + +--- + +## 2. HTTP API comparison + +### 2.1 Endpoint matrix + +| Route | Folio | Gotenberg | Notes | +|---------------------------------------------------|-------|-----------|-------| +| `POST /forms/chromium/convert/url` | ✅ | ✅ | parity | +| `POST /forms/chromium/convert/html` | ✅ | ✅ | parity | +| `POST /forms/chromium/convert/markdown` | ✅ | ✅ | parity, see §3.4 | +| `POST /forms/chromium/screenshot/url` | ✅ | ✅ | parity | +| `POST /forms/chromium/screenshot/html` | ✅ | ✅ | parity | +| `POST /forms/chromium/screenshot/markdown` | ✅ | ✅ | parity | +| `POST /forms/libreoffice/convert` | ✅ | ✅ | parity, filter coverage differs (see §3.5) | +| `POST /forms/pdfengines/merge` | ✅ | ✅ | parity | +| `POST /forms/pdfengines/split` | ✅ | ✅ | parity | +| `POST /forms/pdfengines/flatten` | ✅ | ✅ | parity | +| `POST /forms/pdfengines/convert` (PDF/A, PDF/UA) | ✅ | ✅ | different backend | +| `POST /forms/pdfengines/rotate` | ✅ | ✅ | parity | +| `POST /forms/pdfengines/metadata/read` | ✅ | ✅ | parity | +| `POST /forms/pdfengines/metadata/write` | ✅ | ✅ | parity | +| `POST /forms/pdfengines/bookmarks/read` | ✅ | ✅ | parity | +| `POST /forms/pdfengines/bookmarks/write` | ✅ | ✅ | parity | +| `POST /forms/pdfengines/encrypt` | ✅ | ✅ | parity | +| `POST /forms/pdfengines/embed` | ❌ | ✅ | **Folio missing** — attach files inside PDF | +| `POST /forms/pdfengines/watermark` | ✅ | ✅ | parity | +| `POST /forms/pdfengines/stamp` | 🚧 | ✅ | **Folio partial** — overlay-on-pages variant | +| `POST /forms/batch/submit` | 🚧 | ❌ | **Folio ahead in spec** | +| `GET /forms/batch/{id}/status` | 🚧 | ❌ | **Folio ahead in spec** | +| `GET /forms/batch/{id}/download` | 🚧 | ❌ | **Folio ahead in spec** | +| `GET /health` | ✅ | ✅ | parity | +| `GET /version` | ✅ | ❌ | **Folio ahead** (Gotenberg ships version on root) | +| `GET /prometheus/metrics` | ✅ | ✅ | parity | +| `GET /_/`, `/_/sse`, `/_/metrics.json` | ✅ | ❌ | **Folio ahead** — operator console | +| Webhook headers (`Webhook-Url`, etc.) | 🚧 | ✅ | callback delivery TODO in Folio | + +**Visible gaps in HTTP surface:** `embed`, full `stamp`, complete webhook +callback delivery, batch ZIP/merge output. Everything else exists. + +### 2.2 Request/response shape + +Gotenberg insists on multipart/form-data for *every* conversion. Folio +follows the same convention for all core routes — operators using +Gotenberg client SDKs (`gotenberg-php`, `gotenberg-js-client`, +`gotenberg-go-client`) can point at Folio with only a base-URL change for +the parity routes. This is a deliberate compatibility choice, not an +accident. + +--- + +## 3. Conversion engines, feature by feature + +### 3.1 Chromium — PDF generation + +| Feature | Folio | Gotenberg | Notes | +|-----------------------------------------|-------|-----------|-------| +| Paper size (named + custom WxH) | ✅ | ✅ | parity | +| Margins (per side, inches) | ✅ | ✅ | parity | +| Landscape | ✅ | ✅ | parity | +| Print background | ✅ | ✅ | parity | +| Omit background (transparency) | ✅ | ✅ | parity | +| Single-page mode | ✅ | ✅ | parity | +| Scale (0.1–2.0) | ✅ | ✅ | parity | +| Page ranges | ✅ | ✅ | parity | +| Custom header/footer HTML w/ tokens | ✅ | ✅ | parity | +| Prefer CSS page size | ✅ | ✅ | parity | +| Tagged PDF / outline | partial | ✅ | Folio passes flags but limited testing | +| Cookies (with sameSite) | ✅ | ✅ | parity | +| Extra HTTP headers (scoped) | partial | ✅ | Folio: flat headers; Gotenberg: regex scope | +| User-Agent override | ✅ | ✅ | parity | +| Emulated media type | ✅ | ✅ | parity | +| Emulated media features (color-scheme…) | ❌ | ✅ | **Folio missing** | + +### 3.2 Chromium — wait / failure conditions + +| Feature | Folio | Gotenberg | +|--------------------------------------------------|-------|-----------| +| `waitDelay` (fixed) | ✅ | ✅ | +| `waitForExpression` / custom JS predicate | partial | ✅ | +| `waitWindowStatus` | ❌ | ✅ | +| `waitForSelector` | ❌ | ✅ | +| `skipNetworkIdleEvent` | ❌ | ✅ | +| `skipNetworkAlmostIdleEvent` | ❌ | ✅ | +| `failOnHttpStatusCodes` | ❌ | ✅ | +| `failOnResourceHttpStatusCodes` | ❌ | ✅ | +| `ignoreResourceHttpStatusDomains` | ❌ | ✅ | +| `failOnResourceLoadingFailed` | ❌ | ✅ | +| `failOnConsoleExceptions` | ❌ | ✅ | + +This is the most concrete Chromium feature gap. Spec +(archived spec) already exists; it just hasn't +been implemented past the stub. **Recommendation:** prioritise. + +### 3.3 Chromium — Screenshots + +Both projects support PNG/JPEG/WebP, dimensions, JPEG quality, viewport +clipping, optimize-for-speed. **Parity.** The only gap is that Folio's +"capture beyond viewport" code path has fewer integration tests covered +than Gotenberg's. + +### 3.4 Markdown route + +Both implementations are minimal. Both produce a wrapped HTML document and +hand it to Chromium. Differences: + +- **Folio:** `pulldown_cmark` with `Options::all()` + a single embedded + `markdown.css`. No template injection point. +- **Gotenberg:** `gomarkdown` + `bluemonday` (sanitised HTML). Requires + the user to supply a wrapper HTML file (named `index.html` in the + multipart) that pulls the rendered Markdown in via a documented + mechanism, so the user can inject CSS/fonts/JS. + +Each has a different opinion: Folio is "we own the template, give us +markdown"; Gotenberg is "you own the template, give us markdown + a +template." + +This comparison's companion document `docs/markdown-plus.md` proposes a +**third route** that combines both philosophies plus front-matter, math, +mermaid, syntax highlighting, includes, and named themes. That work is +designed to ship alongside the existing route, not replace it. + +### 3.5 LibreOffice — input formats + +Both projects exercise LibreOffice's full ~100-format input matrix (DOC, +DOCX, ODT, ODS, ODP, XLS, XLSX, PPT, PPTX, RTF, CSV, EPUB, etc.). The +difference is in **export options**: + +| Export option | Folio | Gotenberg | +|---------------------------------------------|-------|-----------| +| Landscape | ✅ | ✅ | +| Native page ranges | partial | ✅ | +| Single-page mode (Calc/Sheet) | ✅ | ✅ | +| Password-protected input documents | ❌ | ✅ | +| Update indexes on conversion | ❌ | ✅ | +| Export form fields | ❌ | ✅ | +| Export bookmarks | partial | ✅ | +| Export notes / placeholders | ❌ | ✅ | +| Bookmarks → PDF destinations | ❌ | ✅ | +| Image compression (lossless / JPEG quality) | ❌ | ✅ | +| Image resolution reduction | ❌ | ✅ | +| Viewer preferences (initial view, zoom…) | ❌ | ✅ | +| Native LibreOffice watermark | ❌ | ✅ | +| PDF/A-1b / 2b / 3b output | ✅ | ✅ | +| PDF/UA output | ✅ | ✅ | + +Spec (archived spec) lists most of these as +explicit TODOs. + +### 3.6 PDF engine ops + +Gotenberg's killer feature here is **per-operation engine selection with +fallback chains**: `qpdf → pdfcpu → pdftk` for merge, etc. If qpdf +chokes on a malformed PDF, pdfcpu retries transparently. Folio uses a +single backend (`lopdf`, pure Rust) for *every* op, which is operationally +simpler but means a malformed input has no recovery path other than +"return an error and let the caller deal with it." + +This is the largest pure-feature gap. Three options for closing it: + +- **(A) Re-implement engine fallback in Rust** by shelling out to qpdf / + pdfcpu / pdftk binaries. Cheapest. Loses some of the "no external tools" + posture but Folio already shells out to `soffice` and `gs`, so the + posture is already mixed. +- **(B) Stay single-backend and harden lopdf** — file upstream patches for + the malformed-input cases that arise. Highest engineering cost, slowest + return. +- **(C) Punt** — say in the README that Folio is "well-formed PDF only" + and let users pre-validate. Honest, but caps the addressable workload. + +Spec (archived spec) exists and points at (A). + +--- + +## 4. Async delivery — webhooks + +Gotenberg's webhook module is mature: middleware POSTs the produced file +to a user-supplied URL with retry logic, allow/deny lists (literal and +regex), private/public IP filtering for SSRF, configurable retry windows, +sync vs async modes. + +Folio has the **shape** of this — `Webhook-Url` and friends parse, +`crates/server/src/webhook/` exists, the worker runs — but the actual +callback delivery path is marked TODO. Until that lands, an operator +sending `Webhook-Url` headers will see a 202 and then... nothing. + +**Status:** spec (archived spec) is the source of truth; the +gap is implementation, not design. + +--- + +## 5. Batch API (Folio-only) + +Folio has a server-side batch surface that Gotenberg has no equivalent +for: submit a JSON manifest of N jobs, get back a `batch_id`, poll for +progress, download a ZIP when done. The endpoints exist; the worker runs; +ZIP packaging and per-item-failure semantics are TODO. + +This is a real differentiator, not just parity-plus. Worth finishing. + +--- + +## 6. Operator console (Folio-only) + +This is where Folio is unambiguously ahead. + +Gotenberg gives you `/health` (JSON) and `/prometheus/metrics` +(Prometheus text). That is the entire operability surface. To get any +actual visibility you wire it into Grafana yourself. + +Folio ships a Svelte SPA at `/_/` driven by Server-Sent Events that +shows, live, in one screen: + +- RPS, p95 latency, error %, in-flight count +- Per-route table (RPS, p50/p95/p99, error %, load %) +- Engine status (Chromium / LibreOffice up/down + restart count) +- Concurrency grid (active vs cap, with warn/crit thresholds) +- Throughput strip (30-min windowed RPS + p95 with SLA overlay) +- Activity strip (error % + queue depth) +- Resources (CPU %, memory MB) +- Active batches (progress + per-item state) +- Last-20 request log + last-10 error log + +The recent commit history (last 30 commits, all dashboard-focused) shows +this is the team's current focus and it is in active polish. + +This shifts the value proposition: Folio is not "Gotenberg in Rust", it +is "Gotenberg-compatible PDF service that you can run without immediately +needing a dashboards engineer." + +--- + +## 7. Configuration / CLI flags + +Gotenberg has a wide and stable flag surface (api, webhook, pdfengines, +prometheus, basic auth). Folio's flags cover the same axes but are +narrower: + +| Knob | Folio | Gotenberg | +|---------------------------------------------|-------|-----------| +| API port / bind / TLS | port + bind ✅, TLS ❌ | ✅ | +| Body limit (multipart) | ✅ | ✅ | +| Per-request timeout | ✅ | ✅ | +| Root path (reverse-proxy mount) | ❌ | ✅ | +| Correlation ID header | ✅ | ✅ | +| Basic-auth user/pass (env) | ✅ | ✅ | +| Download allow/deny lists | partial | ✅ | +| Download deny private/public IPs | partial | ✅ | +| Download max retries | ✅ | ✅ | +| Disable downloads entirely | ❌ | ✅ | +| Enable debug route | ❌ | ✅ | +| Webhook allow/deny + SSRF filters | partial | ✅ | +| Webhook retry waits / counts / timeouts | partial | ✅ | +| Per-op engine selection (merge/split/…) | ❌ | ✅ | +| Disable specific PDF engine routes | ❌ | ✅ | +| Prometheus namespace / collect interval | partial | ✅ | +| Disable route telemetry | ✅ | ✅ | + +**Recommendation:** the gaps here are individually small; add them one +by one as `--root-path`, `--api-disable-debug`, `--api-disable-download`, +and SSRF flags. Spec (archived spec) already exists. + +--- + +## 8. Auth & security posture + +| Concern | Folio | Gotenberg | +|--------------------------------------------|-------|-----------| +| HTTP Basic Auth | ✅ | ✅ | +| Token / JWT auth | ❌ | ❌ | +| Per-route authorisation | ❌ | ❌ | +| TLS in-process | ❌ | ✅ | +| `file://` rejected on URL routes | ✅ | ✅ | +| SSRF: private IP block | partial | ✅ | +| SSRF: public IP block | ❌ | ✅ | +| Download URL allow/deny regex | ❌ | ✅ | +| Webhook URL allow/deny regex | partial | ✅ | +| Multipart body limit enforcement | ✅ | ✅ | +| Memory-safe core | ✅ (Rust) | ❌ (Go GC) | + +Folio's Rust core is a real security advantage at the parser level; +Gotenberg's mature SSRF/download/webhook filter stack is a real security +advantage at the network edge. They are not the same thing and Folio +should not pretend memory-safety substitutes for the network filters — +both matter. + +--- + +## 9. Observability + +| Surface | Folio | Gotenberg | +|--------------------------------------|-------|-----------| +| Structured logs (JSON / text) | ✅ | ✅ | +| Request ID propagation | ✅ | ✅ | +| Prometheus counters/histograms | ✅ | ✅ | +| OpenTelemetry traces | ✅ (OTLP HTTP) | ✅ | +| OpenTelemetry metrics | ✅ | ✅ | +| Live operator UI | ✅ | ❌ | +| SSE event stream | ✅ | ❌ | +| Per-engine health endpoint detail | ✅ (per-engine) | ✅ | + +**Parity, with Folio ahead on the live UI.** No gaps to call out here. + +--- + +## 10. Distribution surfaces + +| Surface | Folio | Gotenberg | +|----------------------------------|-------|-----------| +| HTTP server (Docker) | ✅ | ✅ | +| HTTP server (raw binary) | ✅ | ❌ (officially Docker-only) | +| CLI binary (`folio convert …`) | ✅ | ❌ | +| Rust library (in-process) | ✅ | ❌ | +| Python bindings | ❌ (placeholder) | ❌ | +| Node.js bindings | ❌ (placeholder) | ❌ | +| Cloud Run image | ✅ (`folio-cloudrun`) | ❌ | +| AWS Lambda image | ✅ (`folio-lambda`) | ❌ | +| Slim images (Chromium-only / LO-only) | ✅ | ❌ | + +Folio has done real work here that Gotenberg has explicitly said no to +(Gotenberg's stance is that it is a Docker product; everything else is +the user's problem). The *empty* Python/Node bindings undercut that +narrative — the placeholder crates (`crates/py/`, `crates/js/`) imply a +roadmap commitment that has no actual code. Either ship them or remove +the placeholders; the worst state is "empty crate that suggests a +feature." + +--- + +## 11. Test coverage + +- **Folio:** ~43 unit tests passing across types, engine, pdfops, routes; + ~25 BDD scenarios ported from Gotenberg (runner partially complete); + 5 e2e smoke tests; Docker-based PDF/A validation via verapdf. + `TEST_STATUS.md` and `TEST_ISSUES.md` are surprisingly honest about + what is and isn't passing. +- **Gotenberg:** mature integration test suite that has been running for + years; thousands of cumulative production deployments worth of + battle-testing. + +The maturity gap is real. Folio's BDD harness is the right move (re-using +Gotenberg's scenarios is the cheapest path to credibility), it just needs +to finish. + +--- + +## 12. What Folio did well, with credit + +- **Library-first architecture.** Being usable as a Rust crate, a CLI, + and a server is a substantial superset of Gotenberg's positioning, and + was clearly an early decision rather than a retrofit (the engine crates + have no axum imports). +- **Operator console.** The SSE-driven Svelte dashboard is a genuinely + better operator experience than Gotenberg's bare metrics endpoint. + This was the right thing to invest in last. +- **Supervised engines with lazy-start / idle-shutdown.** Memory profile + on idle should be substantially better than Gotenberg's eager + process-pool model — relevant for serverless deploys (Cloud Run / + Lambda images exist for a reason). +- **Atomic concurrency tracking** (commit `209a444`) over sampled + semaphore reads. Small fix, but it's the kind of correctness work that + shows the team has actually been driving the dashboard against real + load. +- **Honest test status docs.** `TEST_STATUS.md` and `TEST_ISSUES.md` + exist and are not propaganda. Easy to underestimate how rare this is. + +## 13. What Folio did not do, deliberately + +- **No multi-engine fallback** for PDF ops. Single backend (`lopdf`) + keeps the dependency surface small. Defensible until you hit the first + malformed-input bug report, at which point the answer becomes "punt or + shell out." Decide before users force the decision. +- **No batch-of-batches / DAG job system.** The batch API is a flat list + of jobs, not a workflow. This is the right call for a PDF service — + workflow tools belong elsewhere. +- **No template engine for Markdown.** The basic Markdown route does not + let users inject Liquid/Handlebars/etc. The companion proposal + (`docs/markdown-plus.md`) preserves this stance: front-matter + substitution only, no full templating. +- **No cross-request server-side state.** Includes resolve from the + upload only. This is a security posture, not laziness. + +## 14. What Folio did not do, but should + +In rough priority order (cheapest-impact-per-LOC first): + +1. **Finish webhook callback delivery** ((archived spec)). The + Async-202 path is half-built; finishing it unblocks Gotenberg client + compatibility. +2. **Wire advanced Chromium wait conditions** (spec 36): `waitForSelector`, + `waitWindowStatus`, `failOn*` family. Each is a single CDP call. +3. **Finish batch ZIP packaging + per-item failure semantics** + (spec 50-batch). The endpoints already exist; finishing them turns a + stub into a differentiator. +4. **Add `embed` + finish `stamp`** routes. Last gaps in the + `/forms/pdfengines/*` matrix. +5. **Implement `--root-path` and SSRF/download filter flags** + (spec 39). Small individual changes; collectively close the + security/operations gap. +6. **Decide on multi-engine PDF ops** (spec 38). Either ship qpdf/pdfcpu + shellout or commit to "well-formed PDFs only" in the README. Current + middle ground is the worst of both. +7. **Either ship the Python/Node bindings or remove the placeholder + crates.** Empty crates are a roadmap lie. +8. **Fill in LibreOffice export filters** (spec 37). Long tail; do as + user demand surfaces, not preemptively. +9. **Build Markdown+** (`docs/markdown-plus.md`). Net-new feature, not + Gotenberg parity, but uses the operator-console + observability + investment as a foundation. + +## 15. What Folio did not do, and arguably should not + +- **TLS in-process.** Use a reverse proxy. Adding TLS to the binary adds + cert rotation, OCSP stapling, ALPN — none of which Folio is positioned + to do better than nginx/Caddy/envoy. The current "not implemented" + status is correct; it should be made *explicit* in the README. +- **OAuth / JWT / RBAC.** PDF services are not where you want to be doing + identity. Stay with Basic Auth + reverse-proxy auth headers; document + the pattern. +- **A workflow / DAG engine on top of batch.** Out of scope. Forever. +- **A web-UI document editor.** Folio's UI is an operator console, not an + end-user product. The line should stay there. + +--- + +## 16. What we did vs what we did not — concise scorecard + +### Done +- Six Chromium routes (HTML/URL/Markdown × convert+screenshot) +- LibreOffice convert route + 100+ input formats +- All standard PDF ops bar `embed` and full `stamp` +- PDF/A and PDF/UA via Ghostscript +- Bookmarks, metadata, encrypt +- HTTP Basic Auth +- Prometheus metrics + OpenTelemetry traces + structured logs +- Operator console (Svelte + SSE) — distinct lead over Gotenberg +- CLI with convert/merge/split/flatten/rotate/metadata +- Multi-target Docker images (full / chromium-only / lo-only / cloudrun / + lambda) +- Library usage as a Rust crate +- BDD test harness (in progress) + +### Not done +- Webhook callback delivery (scaffold only) +- Batch ZIP output / per-item failure semantics (scaffold only) +- `embed` route, full `stamp` route +- Advanced Chromium wait/fail conditions (spec 36) +- Long tail of LibreOffice export options (spec 37) +- Multi-engine PDF op fallback (spec 38) +- Several CLI flags (`--root-path`, full SSRF filters) (spec 39) +- Python and Node.js bindings (empty placeholder crates) +- Cookie/header-scope regex filtering on Chromium routes +- Emulated media features (color-scheme, prefers-reduced-motion) +- TLS in-process *(deliberately not done; document the choice)* + +### Should be added (new) +- **Markdown+** — see `docs/markdown-plus.md`. Builds on existing + Chromium pipeline; uses existing observability stack; ships standalone + without blocking on webhook/batch/bindings. +- **Stage-level histograms** for any multi-stage route (Markdown+ is the + obvious first user). Genuine new information, not just parity. +- **Operator console "Markdown+" panel**, conditionally rendered when + traffic exists. Avoids polluting empty deployments. + +### Should *not* be added +- TLS in-process +- Identity/RBAC inside Folio +- Workflow/DAG engine on top of batch +- A document editor +- A second Markdown route that is "just like the first but with an + option" — extension, not duplication + +--- + +*End of comparison. The companion proposal in `docs/markdown-plus.md` +implements the "should be added (new)" section's first item.* diff --git a/docs/markdown-plus.md b/docs/markdown-plus.md new file mode 100644 index 0000000..9456cbf --- /dev/null +++ b/docs/markdown-plus.md @@ -0,0 +1,307 @@ +# Folio Markdown+ — A New Markdown→PDF Variation + +> **Status:** Design proposal. Companion to `comparison.md` at the repo root. +> **Scope:** Defines a third Markdown rendering route for Folio that sits +> alongside the existing `/forms/chromium/convert/markdown` (basic) and the +> Gotenberg-compatible template-based path. Targets document-quality output +> (reports, dossiers, technical writing) rather than the raw GFM-in-a-box +> baseline. + +--- + +## 1. Why a new variation? + +Today Folio offers a single Markdown pipeline (`crates/engine/src/chromium/markdown.rs`): + +- `pulldown_cmark` with `Options::all()` (tables, strikethrough, task lists, + footnotes, smart punctuation). +- Wrapped in a fixed `` shell with a single bundled stylesheet + (`markdown.css`). +- Rendered through Chromium → PDF. + +That covers the Gotenberg-equivalent baseline, but it falls short for the +target users implied by the operator console + observability investment: +people producing **report-grade PDFs at scale** — incident write-ups, +generated dossiers, customer-facing one-pagers, weekly digests. + +Gaps observed against both the current code and Gotenberg's +`/forms/chromium/convert/markdown`: + +| Need | Current Folio | Gotenberg | Gap | +|---------------------------------------|---------------|-----------|----------------| +| YAML / TOML front-matter for metadata | ❌ | ❌ | both miss | +| Math (KaTeX / MathJax) rendering | ❌ | ❌ | both miss | +| Mermaid / PlantUML diagrams | ❌ | ❌ | both miss | +| Syntax-highlighted code | ❌ (CSS only) | ❌ | both miss | +| Admonitions / callouts | ❌ | ❌ | both miss | +| Auto table-of-contents | ❌ | ❌ | both miss | +| Themed templates (named styles) | ❌ | ❌ | both miss | +| Header/footer driven by front-matter | ❌ | partial | folio behind | +| Cover page generation | ❌ | ❌ | both miss | +| Cross-document includes (`@include`) | ❌ | ❌ | both miss | +| Asset upload + relative paths | partial | ✅ | folio behind | + +The "new variation" — **Markdown+** — targets the bottom half of that table +in a single coherent route. It is *not* a replacement for the basic route; +the basic route stays as the cheapest, fastest, GFM-baseline path. + +--- + +## 2. Route design + +``` +POST /forms/chromium/convert/markdown-plus +``` + +Multipart form-data, same auth/observability stack as every other Chromium +route. Discovery via `/_/`, Prometheus, OTel traces wired identically. + +### 2.1 Form fields + +| Field | Type | Required | Purpose | +|----------------------|----------------|----------|------------------------------------------------------------| +| `index.md` | file | ✅ | Entry-point document | +| `*.md` | file (repeat) | ❌ | Additional documents resolvable via `@include` | +| `assets/**` | files | ❌ | Images, fonts, custom CSS resolvable by relative path | +| `theme` | text | ❌ | Named theme: `default`, `report`, `book`, `slide`, `memo` | +| `stylesheet` | file | ❌ | Override CSS — applied **after** the theme | +| `math` | text | ❌ | `none` \| `katex` \| `mathjax` (default: `katex` if `$`s) | +| `diagrams` | text | ❌ | `none` \| `mermaid` \| `auto` (default: `auto`) | +| `highlight` | text | ❌ | `none` \| `prism` \| `treesitter` (default: `prism`) | +| `toc` | text | ❌ | `none` \| `auto` \| `front` \| `back` (default: `auto`) | +| `cover` | text | ❌ | `none` \| `auto` (renders cover from front-matter) | +| `frontMatterFormat` | text | ❌ | `yaml` \| `toml` (default: detect by fence) | +| ... (all PDF options from basic route inherited unchanged) | + +Anything in the basic route's PDF options block (paper size, margins, +landscape, header/footer HTML, scale, page ranges, cookies, headers) flows +through unchanged so Markdown+ does not become a parallel options surface. + +### 2.2 Front-matter contract + +A document opens with a fenced front-matter block: + +```markdown +--- +title: Q2 Reliability Review +author: Folio SRE +date: 2026-04-30 +classification: internal +toc: true +theme: report +header: "{title} — {classification}" +footer: "Page {pageNumber} of {totalPages}" +--- + +# Executive summary +... +``` + +The renderer: + +1. Strips and parses the block (`serde_yaml` / `toml`). +2. Promotes selected keys onto the PDF: `title` → ``, `author` → + `dc:creator` metadata, `date` → header substitution, etc. +3. Substitutes `{title}`, `{author}`, `{date}`, `{pageNumber}`, + `{totalPages}`, `{url}`, `{classification}` inside header/footer HTML + *before* it reaches Chromium. +4. Anything in front-matter beats the matching form field — front-matter is + the document's voice; form fields are the operator's voice. (Inverse + precedence is wrong: it would let an operator silently relabel a + classified document.) + +### 2.3 Pipeline + +``` +markdown bytes + │ + ├── front-matter split (yaml|toml) + │ + ├── @include resolution (recursive, cycle-detected, depth-capped) + │ + ├── pulldown-cmark (Options::all + custom event stream) + │ │ + │ ├── inline math $...$ → <span class="math math-inline"> + │ ├── block math $$...$$ → <div class="math math-display"> + │ ├── ```mermaid → <pre class="mermaid">...</pre> + │ ├── ```lang → highlighted <pre><code class="lang-..."> + │ └── > [!NOTE]… admonition → <aside class="callout note"> + │ + ├── auto-toc injection (heading walk, slugged anchors, configurable depth) + │ + ├── theme.css + user stylesheet inlined + │ + ├── KaTeX/Mermaid/Prism JS bundles inlined (or skipped if extension off) + │ + └── Chromium render with extended waitFunction: + () => window.__folioReady === true + set after KaTeX + Mermaid finish. +``` + +Each stage owns one file under +`crates/engine/src/chromium/markdown_plus/`: + +``` +markdown_plus/ +├── mod.rs // public render() and option types +├── frontmatter.rs // parse + extract +├── include.rs // @include resolution +├── extensions.rs // pulldown-cmark event-stream rewrites +├── toc.rs // heading walk + injection +├── theme.rs // named themes (embedded CSS) +├── assets.rs // KaTeX / Mermaid / Prism inlining +└── ready.rs // window.__folioReady wait protocol +``` + +This mirrors the existing module layout (`launch.rs`, `render.rs`, +`screenshot.rs`, `wait.rs`, `pdf_params.rs`) — no new architectural +patterns introduced. + +--- + +## 3. Concrete syntax additions + +All additions are **optional** — a plain GFM document still renders +identically to the basic route (modulo theme). + +### 3.1 Math + +```markdown +The continuous form is $\hat{f}(\xi) = \int f(x)\,e^{-2\pi i x\xi}\,dx$, +and the discrete equivalent: + +$$ +X_k = \sum_{n=0}^{N-1} x_n \cdot e^{-2\pi i k n / N} +$$ +``` + +### 3.2 Diagrams + +````markdown +```mermaid +sequenceDiagram + Client->>Folio: POST /markdown-plus + Folio->>Chromium: rendered HTML + Chromium-->>Folio: PDF bytes + Folio-->>Client: 200 OK +``` +```` + +### 3.3 Admonitions (GitHub-style) + +```markdown +> [!NOTE] +> Folio does not require LibreOffice for this route. + +> [!WARNING] +> Mermaid renders client-side; render times scale with diagram count. +``` + +Recognised tags: `NOTE`, `TIP`, `IMPORTANT`, `WARNING`, `CAUTION`. Each maps +to `<aside class="callout {tag}">` and is themed in CSS. + +### 3.4 Includes + +```markdown +@include shared/header.md +@include sections/methodology.md +``` + +Resolved relative to the multipart upload's logical root. Cycle-detected +(error returned as `400 invalid_include`); max depth 8. + +### 3.5 Auto-anchors and TOC + +Every heading gets a slugged `id`. `toc=auto` injects a `<nav class="toc">` +where the first explicit `<!-- toc -->` marker appears (or after the cover +page if absent and `cover=auto`). + +--- + +## 4. Themes + +Five embedded themes, each a single CSS file under +`markdown_plus/themes/`: + +| Theme | Use case | Notes | +|----------|-------------------------------------------|--------------------------------| +| `default`| GFM-on-paper, neutral serif headings, sans body | Matches the existing `markdown.css` look so basic-route docs render identically when re-routed | +| `report` | Quarterly reviews, post-mortems | Letter-spaced caps headings, classification banner, page-numbered footer | +| `book` | Long-form, multi-chapter | Drop-caps, running headers from front-matter `chapter:` | +| `slide` | One-section-per-page | `page-break-after: always` on `<h1>`; large body text | +| `memo` | One-pagers, exec summaries | Tight margins, no cover, single-column | + +A user-supplied `stylesheet` is appended *after* the theme, so themes are +override-friendly without forcing the user to start from zero. + +--- + +## 5. Operability + +Markdown+ is louder than the basic route, so it earns its own observability +labels. No new metric *types* — just additional label values on the +existing histograms and counters: + +- `folio_conversions_total{engine="chromium",endpoint="markdown_plus", ...}` +- `folio_conversion_duration_seconds{...,endpoint="markdown_plus"}` +- New histogram `folio_markdown_plus_stage_duration_seconds{stage}` with + stages: `frontmatter`, `include`, `parse`, `toc`, `theme`, `assets`, + `chromium`. This is genuinely new information — KaTeX or Mermaid blow-ups + are otherwise invisible inside the chromium total. +- The operator console grows a Markdown+ panel only if any `markdown_plus` + conversion has been observed in the last hour (avoid empty UI noise). + +OTel: a single span per request named `markdown_plus.render`, with one +child span per stage. Wires through the existing tracing layer — no new +crate. + +--- + +## 6. What this variation deliberately does *not* do + +- **No HTML sanitisation regression.** Raw `<script>` is dropped at the + parser level (Folio's basic route inlines it but Chrome refuses to run + it; Markdown+ tightens this — `<script>` becomes a comment, no + exceptions). +- **No template engine.** Front-matter substitution is `{key}` only; no + Mustache/Handlebars/Liquid. People who want full templating compose two + passes: render a Liquid template themselves, then POST to Folio. +- **No multi-file output.** One Markdown+ request → one PDF. Bulk + rendering belongs in the (separate) batch API. +- **No cross-request state.** Includes resolve from the upload only — never + from a server-side library. Templating-by-stealth is an exfiltration + vector and Folio is opinionated against it. + +--- + +## 7. Migration & compatibility + +- Basic `/forms/chromium/convert/markdown` is **untouched**. Existing + Gotenberg-compatible callers see no change. +- Markdown+ ships behind a config flag `--enable-markdown-plus` (default + on) so locked-down deployments can disable it without touching the + binary. +- The existing `markdown.rs` is renamed `markdown_basic.rs` only if no + external code imports it; otherwise it stays put and Markdown+ lives + beside it. (Non-breaking is the priority.) + +--- + +## 8. Implementation checklist + +1. New module skeleton under `crates/engine/src/chromium/markdown_plus/`. +2. Front-matter parser + tests (YAML, TOML, missing block, malformed). +3. `@include` resolver with cycle + depth limits + tests. +4. pulldown-cmark event-stream extensions (math, mermaid, admonitions). +5. TOC walker + injector. +6. Theme bundle + asset inliner (KaTeX, Prism, Mermaid as opt-in features). +7. `window.__folioReady` ready-protocol; extend `wait.rs`. +8. New route in `crates/server/src/routes/chromium.rs`. +9. Stage-duration histogram in `metrics.rs`. +10. Operator console panel (Svelte component, gated on observed traffic). +11. BDD scenarios mirroring the basic route's coverage plus math, mermaid, + admonitions, includes, themes. +12. Docs page under (archived spec). + +This is a tractable, ~2-week single-engineer slice. It does not depend on +the webhook or batch work-in-progress, so it can ship in parallel. diff --git a/docs/specs-archive-2026-05-01.zip b/docs/specs-archive-2026-05-01.zip new file mode 100644 index 0000000000000000000000000000000000000000..5b7ddb886339d9c80f485492872033e161ebaca1 GIT binary patch literal 135717 zcmagEL#!}Bmo0j1+qP|g$F^<Twr$(CZQHhOpZoXCOTYA;bPcL%P<tnPP|1?K6fg)B zz<({y$Q<qed-?x1FaR6?CwmhkCwdiSNC4o|y9Wz{-v<j9cW3}WkQYDz01%Y_yp;b> z{ePVh|F>Rq1P_Y_5&&Qx2><})zv~$oXzg5099=C;+~{nK|GOJ4*Yp3|Ek*st5xWib zr%%oSJH{yTG_mx!m~zLpQrx*PqjYtB@;Gda*uP7iu5f1rtzU5y^KZP4N1$wO9{efk zSzBKsp^{gE6K`vHY+$74EZ1v**wci{=G>-=NetibgS}^$qHE!jPU06!bZPrC#**x~ zQ-x}+@;_s>SUy&kY1=59Y+?m1t2P!4sy~)s3EV~ZxYSH9vq;3oo|iR_6}uMSwmi{E zq%u)Y9Kc*=YHJ{MOl(JkZ%ZSfNn*w2R7SEe$E0;Cc=VGPHF)#DjY&9mYN%07R6j#0 zDQ9(8Eho=nExKq?vcXGHV92?g@zkJ9sFaLni6l;!J&R7@m}Qh+0#30*n8s{YIvrS* z^1{Kts&sa^6l0e;iEQG_B_>hoXy9ZT<P7A~mynb9xQ3{<)A4K?l~guZf&VTqywgl6 zUzsFTFmk4N*knpLLOFG|+j`e%KVPRCCn-^~dy}**a=t%j=44b&JGYZ|uY4@3_gWap z{Mfr;+E*y<9=xWkVZ_baFXVJeXj+)4R7ApLhw3*&FnCde7$L#w1%bs@8#+>4xyF*4 zaYa>S1$Ku(MzGuegB8veQnRy15jyiG8Q7}lU=_haQ>sWwVWa*c`KVBq;bF)&+Xuwf zmhEtLv`G=F4@Q{4lNn`!N}g;9`HQ}Zi#VU7)L%2X&snjLxFgrPt7m7U5^7#;9-Xk% zJbOL|4%g=FF|~8&FcfOJ&S0CJvRv<FOV^<6lHEuJMzH>~<(4v2OB)7hWqNWhD_>p3 z%Ien0)%mXf9^Qb*$WdaWH6CSj(b19Gf)|@&ONpuWEZZRlvDTWncC~>`j5IwkcDak_ zz-7-$nc|}UTf5v_pRSdogNEZF7b{YZJ!UZOx0{+Ec*UV2gTQrC*=b7={S=^kU6-Ry zdV>t(1io|6x%hS+Sjh^Y?nbZ(0=b8BM0}*U{yi9LTVn|avU9M^;-?GrM-n74^o$$Z z!_**b(%6?CisG!y$n+<O?AuQt6)cl=f7qICwp8~C)Aq#@Fta{PUUK5;y;N;S5pz{9 zz2$7TQ`bsoU3BX&L0mrny1Jqc<mc_3JO92*3ojOtUU^{g7*OA%DrQyZBH*ms<c@)E zk#z}2hlaY!Hf*py>R?!ZtpQ{$n^22xFc$5op&gKm3^OOLN%ih=oG*o;+JQ5CfoW5k zVfo+^IJU}lv4jSGGgTE-G=UQs6M#@&a^*WE|EKvf2bYm{#9zxg5aefE555zrjtY_o zT4;rEVMbWC%65)~bEh80(oEpLG^Y1AQY^@!!Y9*f(9W9^?Op|EctT07;ySfXA0|~d z{Uf{IGiwW;P=@6JvGqHwU)QA{c|<@=6jDLul;(Of>jNJ=qXNhdr$X-dC{$GF5nQNf zpDvRK@9ZJ?Oqlqxln3vOXSp;mFEXz_7Kq)emvYbRBhFX+9pUG9`V=uS_HXO1zv#nh zRBZ+el;y<-nuxDeZdjv%eYloGn<eg#>ChXVky^pVS?!OEXzKy9JBhbjvaXs*q=5P5 z?;wY7Kj-$K#lv7zOzl_4o!-@*d>}}p<boN0-#`@yZYEDIu*`MOOb^z*3=sYhZqXNx zeim<Y%)Q9$DQV*S8AJ8v+6(bsm14Ubh0f+nZoj3+o#lDGw+{dQ6x^MkpA6>c=h~NE z7peRvv{UTRE-tDr|KUWi2`OnHrX%c%@RY(&;S8DFio!@dg#a^#P)$;H!DcS;Mev9K zAiFuee^$q>N$5xZ8}`L`ZLTwY<E(VUz6=8M=uS*<CW)lPq}Ib(@bLI!0$Pw5{6a9S z&C0F-^gO8GOdUJe9dU)rwRj%cNP}6zUI(g^MqswqcY-domWqArGgAfK;0?0D8gk>g zq(h4))d4xpfG?wDEi2VTdYK{si0k!oh4dKS1X~73%Q3W(D;fIYldK4+WnoGy`GUTq zOf<k&o#mk=lqj@-Qb#C*77Mz(JdIkXz$K8<s;C`S6jdv&J)~pBv_W5f09Li8qr}ns zTpqn>{2EIh3Egs~1~geOTP?H+E@UiJl?~ka2_?`b<yh94LLKl1|6X8?Lqi`J%G|vD zBeQmyHIiD%6Db@+n?CmU9`4~z49%g7(}VBtRP=0f*<8=+rd_Sa;1IXZj34fWh+{lO zUm_e8ig(%Uj!0w;MhCRj)f=4-Oqz!=Yi3vq?{rfy3@p1#Hztr7DWlm6SrWU1a<x7r zKxyBDU?>{C5X{<jd#nnacM}W9a3?VL8)kF1$2iT<tK70&=w$cU@ZlltFWh4IQ2PuC zH<1OPQ)dAjwyAsU!mxdu`}@y_A>>y`59QnOODMlWuAj-~pUK%O6n)&{J|D&96<jE% zeCWcV6?L=2eGJ6ch`p9AFizRL`!})Lul$vQDYH_B^k;hZ;0DZGPfVzZ*j=L`@1e=q z9VX{3!V-YPN4ph(qBjShF*XkQ;I{R8v-RV07RsXk%9(bhGS;H8InU}qbV}g(u{U2P zyV1(mIe(NJ2FcMk5+_2A3xfK0#8iQPqrf2yxV`%RZHy}estgPvh0in_K#l#ImLa(E zm;1o(JiCrgcc3N#FI(fHM{}K?zM16St8U3GvD&50R}?h=K&&$#s`4QwG_}xq9*;(^ zFX=r=Qhsqx=nYWZOSd`=9T*cmT1vu)OwHv&8nr-|kzm2g*pYORX(wDvniFVe4Ihy< zBk9BbaTrUR;CDA{k0!(v!mE?gKV*b=aETF}CJwHM95uY=bT6`IIR#kbsTlMmFCoj$ zT)K45LixowqA|0lj|W3J)!DFFBTi$1i>u4gEbMKCdsWw~0o9B%h#<3rRZej)azV*= zMyPKAw%rLKMkspe>p{?sqNk<j{33*hM)b;gnQeH^-u<<aauEdgH;2UR5vf4LBZpXt z`uMEdcRG#ROx5FofjU5S{7kKsusn3ITol$NI%yb4ys-tZW{OXXPx-|39Q*2K*fdl` zUnYn?S{z09Hp1F{_NdOe%GfBx2wq=$xzsUa-+CS%A6J-lKRBB`gW#$ffk@9m`sAyd zraFBe5NLUu_gEe5kD7-{>%lt-uG6<1=WvuH_!DcSMELKb^q)k=%mbOBVj;h{knMv% z^#6&x;?Zz88qfd$p!fg)(El5Gu`tjYTG$#}*qS-f+I##j;A>jz%5jS$^|!99pP~iw zq~UzFxXn#7g88U8r`alL{iJ<KB}+1a0a5}-i6ji*#(Y$#1-?P9U9NGD6Tm&e*o)iG zRY_?H4G;$>`wP^agin<UanFzZ_ly5eD!XQ;k!ILLwOFHJo+!M^g(guGixo3QH!_YY zPcEsEfr(l<@}g{RScbMraz)6eMlwc)XjWB41DlclsX97@l6PISN;3@LsbJW;=u+tP z@GL5dIUwMQMxjvE&#n<Gz68-lDc!ncaN&G`_@OoV!`wOf>|_Q2OAwrI4eIO1`{6f- z1EDW_>8~&CMMZNLrTa(DPFPjCs#7|7^^hxxxJg-PDg~*53XLO*yLkUj0PGCVyN6zN zkwl6MH6|M$|L6G^qkT#xg}NVS#E<tq<enqEP?@qP+TXh~wh-9*l|2Q+2Vn6W2|NZZ zgR>mlrf`aEO3A1Hn6sW>NQLX`VeDe5Y;Wwf8$YR0v1nm;SAubC3p=Ki$hW6@N@cY= z7I!cfMuN_vG;5khq?@IRhsi{_<jGxcSZ3!33lgyoN5)g>xsmZX;#Zd{BkKeULwe+4 zO<08t_}iq^QiFVoRa`+u7?KqE+GK&F_Np;<q@|i!149!tujENdw-WN4lcTcP12zM2 z>SC&@vm0>Y-XxdSkeY4S;YL<C{g*XG^l{>_g3W3^(~m-&q`gLFhXl+@eBYcr<IGB7 zby&l}h4?BY7l8`>3Tm5Wh7^)1iMx7Lg9p(wL(o`Biq-IXMzJD0^;^=<T#$z8(2-U* zPaxVVL3wTve<=R<={W;H<+0klSGMo>{wILs@cW(@(Va9&aVzcxDi5G9KaY^{v6(S8 z>8^1~GiUaU<i4?=a?wM>q9DtYjxgUxq?t(;W4}!GIqQM6WFS;lQ6^*3g<?0k7;mW* z35!amjC1jd=%GGP{c%otMlf)ftVL5WCjdZ{&`4zC{OnK^5ufgfsUvcdD70G3XQm+m zv??odg=a}9v39G+Vk@px)lsBSC{vBC)Di}x%{r2r$TJ7CgchuYNk&JX7Zc8&vsc@Q zrWD!e>n(4#nk}0(%_B9us0sP=W1RSwf(!`MqK2Ayvg7>8gC)`kg8{9QGg&O16}rAj zPHe<ePhBD9gO3I*>WwcNN4Qly-&eJ57eN^9IbS7e0_Gi(5id?p;-)z10|W}vN-?;Q zRCAe$_#>1-W4S(bjm)Id6k$%Y-7hW}3cUa$&->l^`S!T%{Q3Iu_<6`(FPdI<7eDXU zQP9tq<J0-^bsRR>;5>ncveqQG<dp+$1MY4jx(v=VcOrx<ZA55?J0m9cQ$Ca3M5*{M zs^ZQVn6lH)P;-a-S5Vgsy5kE+nsc3%4Cl|ke4ux8&-Dki8!Y(Nmna#2eq8bkH)*?? z8S?0t@hhy+9M^{*`RFyv<gx20D(dJ-XKxR$XY1Gd*?O$aZ|4^b7fm4<H6x->V2CoQ zMq&mj&(ttCZSgv)WD-qk$tNS6(qmU>gBc+`hV|RVDTAK|hpJd}tLS&^;e)c?agRop zAGeS5^JHh_^sLVkSeJ|%lvP^mIEyH#wK@=Hu+SNQ$kiq6G<GSo(Gp%0dGMxgYBAk$ z<#&2zdt1gIE;^+IkK~sDU{ufZp2x&D1@ogVS!dxo_n(mh*7`rV)#unwF9Xe5qR38V z10tZpURddZX&DVY5sl0$J0O5f;?e+763wPjA7})QZ!80hQZT<SSBi?s6Rbgi#^Qe; z8=zy<*4UZTt)N!NH}xs?JwLDiL?+p<mUVED=54)4khq_y8acCvCayrD%;L+K#_q8* ztX5?D^=r2=89rIQYunsl=dg7qk1Z9FAZ-n_mt!vFl$=z=_N9~u>cujv7jd^LRFa5S zV^pbTieCi645zUN%Hr`I`G;#Tu8LJFG2YAp&sjfU<#)K}=KLQ(1;jf#eGueI`zI(s zrq=HYE}j}$`iwqd*Lkonb@T4ACvSD+G;~ca4NlR;VbhtuAZ(sS|8|lf%Zo{J_u`C6 zXC4bU;}1%ElQg7^s6g~TJlBGE!f-;j`=k~rccc3c26=*63D%ezX>O$gqYC+$d(2N> z7lSkA^^S>a=TiB(SEo%<gJ2Lh0!_~pA+AR|ca|}W@%y4UE~z<8j!k~^t&ubwMc5q6 z8Z|c+GMTJ`WZ)^%ou49^7VB}-PS$oqK^k@x>N^+n-i0jx$)?N_XKgpzN5h5_iD%wU zPs8tda65(7!aUC4CO3je(hzckfP=iI+u9xkyfCfGOfJp0?&=V2$8rF4=Yf_qJ?%8l zzT3^!)=WN|!l#&r={uA*I%Luv?D9mzYk-@5@DDf?LDWZ-<Nx{v)>KK<S8qrl{Katg zhAzl#A~cH)+C(m)*4w&db%EV?i@))LOkS_7O-p|+)>5F`AKDT}7noXuR)Q3$#PJ@N z%BOp97I`}%cToua$`;6@Y`}m)R>?TbVcT?qu%BKg^OdiHcgn&&UNcp<NZD7EvJ?&q zw9w_vn1+Kqf@Pdo-*FQ7)UH-B**LzKu+UorVJY8}vIY4*ka$9-&J7<my?Eu0Lgd!W zrGx@{AWG{-`aMpl7DzAYZx5jt1X9)hAw=up=k)Q$%>Mq^JYT$j6O)!EP3YyiRS~g~ zH*G{&;t~RaN154>yfC?&S4LIf<#L3B;N|X4+zvU_sCl~Y$`9h_^4BN7Nw>X#>@&u9 z*#gQOXbxGcR*94n*6A~0T#;w%f({rnea=Lt$=Qaazzh#6w?3JVXs1U=IAVm*H`Lu* z5^{o^KqZe{M6^C6?3WY-HDT*Pr|VlF;;&3ZamS)MDM%}NSW#A>;b6!24R2TrVmP<@ z4d3{ByF=qsK@embxEq>BJ-A9Nsix^xlyuKdZc^g`qcec3cT86<oR6oKA#h|_Tc)yp zcj;Gqr({;@x0<I<`57z61h<;7I;XQBk4Ias*k>?TNO7#yN*9Z$GWgc=@LnZvlJOmW zr@<=&1X7A*-`7gl>TV0hr`1>Z&RVOMXX!s?FZZ&sZFzkU3w9gzEV;Dlwab%HS-rjv z+|*znyBivPP*a%`|0|>=E>$qLK#|^LeWq6i6_G=P>*F*$6GL$WH)2y-vgF-O9}an< zP1mD7E*@|nhs(WO{acm%?P%^5pRv9gqy#wDgv?S4va4@fp0&RgK+uAsG?ZJz$Zp*M z2CkjtLe<DjsX9|-E2pkkDG&P?)ULcnTGJ?xI4JTBI$AhwoDb6w__7F3*G>Cqu`H@t z{}mLu3qXY=n~&rVIlgZvH<a8vNS|@lz@7F*i%cgWc^=u-Z!?@V<y?Cus>}qR@MbLN z>4hH=Q#urWt70*aFKKn%VW<7Q{uvG7z;LEJKQlclR6-PgQ-%0Q=dEjs239kJ*2Yoe zE4?3Gb!Q`*L}xiZwH@Z6$9vlz{WTZMemZ;zMPA2;)w$fj`tuS=n3cNIaxyU|uEcKt z8BD1T+<H_^CWKQJu&hIcabHI<=98e*373Pq6$C&Quzy#Fc&RD(0|!xS<2#mTn1;gW zXP7{&c_wG<nBXW)7w0zz{@uwF5gutTqb&k%6oS|>o_4{3B5i@o9-03q#hfg))u*1a zR>>ant1&#(OTu$|ziq}MRN-){Ig?-zF(z#H4ZfTioG1xN+r6h*G;j(Up5zA^uM5QR zm1#@Tev<y8%8{yBBvt#y2rn(9x$4wYXBA8y&MBf|IP9TIo!_+K568%nK`lW`ubA0J zekZ)S+>2;IDLBGo`_x};EV3dHoUTLZVkK#if#S!~OtWe{ZcbZDP-`5A>ZdF+Y5Tm) zW^pJ-<+1SnL3+cN0?BT~4KiM4m;wuKjy#wHj~7HDxLqa$qohJO<rllMNFW;z*`e3? z*L?p|))b-kYjg7}p0go|6{%Ft$k7a+CeZA!^E8NRWEq2+zO#v(#wI<k3=>jLzI8)` z!SvviQPxwW$v*Q;R24ICxXcZB2-qIcYBaf<Cq%q;hS|zN_{8>STceXzFKu0&1k;3! z7TL-uRd2whrXq2&Cyz4&+w$$79z*Y(6rY#L&S?bYx1w!HM-?x<^&P4upPx}6%O)nn zCl#?1z3UsSP*7e6fw`aJSybfGq`X{`0uqn?$5&sdsxN!kMTR%$L{%0=K#!h@_4yD} z4Sfa2IMS5&Mb+CjP5WzLr_uJ~^DmZz(kk3o<q;Hg55k%eVx&PjwFr(FTLf7|4r)JB zP^29h!;Kd=^>3}Qmici$Ipl#9%yKRO1B{>0p5to|`@E)s>-cj=n;ZqO<whXRy~zpP z3H=l1T`?~erM?20RVFo2i0_c2LLP+_setey(3T8Bk&1vV%VJoM2ZedW%b?99SY1+x zqg8+7DNg%CW}Rup+_`I^eDB?jyIZ85(N)@|G8P1*4|hlQ?#FHKzP3SY(0;xvn!O>s zO)IMbzbM@AN}4y`CW(0$(s3O)<_?_GIwaC>P)ZRAFy)VS7wfhlf|1{e%#nmdyFT(u zz-Ea>{bGa!Z@*(jDQ2so>p{9oMjSZ4_N$ZC8-Bc&ILc_~@V2X#T9yuM2s_@6GqFet z-_`dTuoKND{<gmndv`R+V#DrZICswk;fAL{=XkTHi>*s6*gCU2D!u1`w%;uPN02Sv zy&W2Ah?t1d|KWvQKet^QX*B?L8gJ$AO&exrLhSlhbpr(K$s)JbxG=Yio0xq{vDiM* z`o_Jr>y-iP);b4^`$Aru=0^q0k&7p1Cug}x$2=@(3nN*VLrVsgsF8Q|d0i6?-V3UO z^!IWduC?11(1zhui)QYJIep7Uf(q|&0sovS4Z!#(jEYQ8{AmjSdlm&zP$Xe)b27e4 z*G6jYHeeT~H@o#;WWB5WMPwJLC>>h8Xr;NTVG3fHb^K{~45pLB&Q?qMP_BL9LeOV! zXMnSjWe~tDK3FZe`#yJIk}Fst{msxo?Ese*vibqUwe`*ZJO;+{gBwEej=!zj!VKBN zGq<=m{J!-lv!U8^hGue;N2tgui6fvL@@o$84<#kh{xHF+qK75smZJ5Mr53o$TR(!O zVX22}%YY$Rmjiz-aF>@pc%v3@(L$Z<QahMHWlj|;k#HoRrOL`MsTX>byNn>h6|Y+o z#Ee22SU?|(<NMjY+}~=KspI>z#rsekU2zG((;Y}kpmSwqGz}<c<m7aw%}gdVJ-#gc zaHRuwVvFTn)r~wmKC7;M>M-KbGX43AhGpjtry5G_hNl$voRcFdQ17$Z6Gmivxod+? zQ$7f!M=I<Q-6pDw8L>$KhB}sCN`07vlKHW!clp0_om$lTv;2%&uE3a431@37$Alkq zm9bx3#H@1lS8zKEJz`>-OiN?pcjXgdgZzdzaF*9su3jKlg1V{dDRVeQlR6n9+iE7@ z=*J0DGI`xEO$*71WvI`pfK8T$El2jO)$D1?>uoA0i-c7Zue!!!=SH`l<bK6a<ufb8 zEc>?+@0^$~x=v_kLE$0q2pid{`isx9t(I@e-uO<{@l1abM;iwaE$u0geKY1s)?Wdk znBahlcnhr?leH4DA|o7ES}L!md5QQQBckygsI(7)tVcDu3s(brn|1sVW4auC*z?Cd zswM5?7B~3>P1a-pLgHgfVlRQ)pT>ur>!?P&k-T!>UGT*UO*LSYNfJW#0c_cED~<E) zlUNqh<+um4CIb#$hcQ|7<jF&v<8*$qN&zUPU5oeIFSjXnnD@-r66zd3;P^162)mcl zK6r8LpteA1iH{kkDN4OpCa8r_=WJY^@9rQpRaw`Ozks+ec>XYh_mD~$w*^D_Lx@o$ zp+K6*s>P4NeIFXatC^d0niH=!?Eoae>R?Uzr}*ds8GQ1t2Lcw|Av*g{?(%l=T27ct zG`E6-@&-iQHuRb$CX4I<#|oN5RaiMZxOw-xs0+SUXIE~AbHZuq%ucX;?;dDaUyA(2 zGwmsdkmhCd`S)KgLC;$U^+75n{Cv=?>VKwCM?Hl2`X|AWis2_VIwLM}t|UObN1E&+ zb#v0B)y>1P^;?FdFDa~Xoq95mT9(}@FC`=|v`(FVVvG$b@)1o55-0XOwC&akMR%R4 z+NX&jO*OS-=jZ(|*dUF8vdLRSN=D`C>RZ1-a5@ZYqaFkMY#Rq0O`~SsY2oUf&Z6`f z@Bjoz{yTljhmc`ea^(XL!=l-8qduUQBQAiX{9#88T~#g|zn*)yZUqK*q#MCe2?VPE zh%J@Vp5AG<NLXg}@NCwF^|9I*pg3Ctfj7#6aBk35)KQvuL$Uf8Vxd9QYW-))Rd(86 z%fBZu+!@W#A>n$)zw`iMbP%XLTcEJsQT_%Do>zf@#@V*$@?I&~h<fDF?<b(yHL>ps z(N<5JbTldR7J)L7L}}*2+w`#rv1+cFSF*yjhnTRjD2N}0bXkX}+82?X%}j>^3$LCL z;%@w+8*MTI3n|Noy3eFcwW-Kf=_feNw#(n4AGAIG>dUQAjWVpigMauR=n%q!>tNU? zD#kGh(Bq&zEezFKtp?L}a>4>I;5rk+1DASL-PP-Ef0u@oXOTcYCVREU5QB7xMvEq> zL@0JPSAYwOmVOFDiV5Iu4UPQSv&3hnMA-e-4RL3juyJ6X*c<kghOo*u8Y{K;j9E$P z<vxQ&#PK;u@r|Q<JTfBl-0|^jrTPqo`OJmpoAyb7e<g)yCLQMcrn@=aT>N}KPp8K( z4#Kw^s#8{t?`s@*=>Ik^NWh0oE!>Lk?7iia!8;{V<z3`nQ%_^P2wkXz3<cQW$9WKP zXjhm2oii7n@eyaKTl{YI*b!c|{9;=BGi+WTIDXUHZ!bB-IqML!I3^;Wkb0{PzX{!Z z<?TMls7e~|1i4Bs9f@D@6W9KUp@_EDlnwB#Dy?ONj|T|KS%8-leYEZx-|ZAii|LFN zvKN?N1t%z3X}O#!X-e!<8MF=VX*Vz3NMTW<{Ze@j*F=U(*N{}1tGpIj(AiK7Z!zkr zT9Mo-(ZTggx@B77Lv)<E^Btao3$I!Xt*8wX2mjT{ZKojnQUKg_P$di!4@%X7Mb3<1 z`?{M3-4Snb9<qxlJ!p6%@=iTqRf_uTs^I;5Fi4~rjN?DsI6;p(Nk$I)_u!FTY~_vT z7xBZ}m2R_EP_ITdD&C~15^#o7H|k6{U2^oBVkE%(j}FE0wJkuh#dQu1m+LhVuncFH zZDh6blC9V&T_75L(p}5r`Epoyz~Zi0@xbdW)(vTof6-~pXkGf#sjAMNLC7pBupY?s zhIyvvwr2f5%-d74ncv@rtGCjNCj$Zhy(yh&t>up3#}PlwmTt3o{V=Yk!cLAGp*vkI zN#Sw|TDXK5?kM4y<7d+_aK!L4DL*+?_e4=NI(VD@T|N@Sk?KsJ-Z|t>W1FVLK~1~; zkr1s?UijHHZC9puU2%@pQwtEVZ~`?X$F@*!QjP?)1MxwK=S#MGP8U{Jcu-Ud!Tknl zZYfknY$-_9VV)OdIZKZ4?;KwUPVa5wEnZV0+i3Z=EJok;&%@|XGTiG@T!bcAWszzm zxh#?2V!<QUy)Hda<bIX&kk7Uo@vztT&<?GncyvfD_BMq0!{b6BlbEj!m9IRR60x(5 zUhxOq_fr%5%QoKCO`*MJ!9gv7>v^c^A^151SYNN>w-_ZPW3`Blm6h^_dCdO``QmRH zH2YG(RbWdaGhReCUjR7f$>h}E(w3L*|H&QzX%5+V!vO%iQT`7FoSD|t&eoaM*v`n= z&hdXyz<V?=9Jj_1f9J~5OQ_f`;`6<SulmPCsWrMJHByZ>x>6jqQ6S(X)B(UU07;x^ zRZa*WgFO<xdBGX>38%Cw?!#ySczxd1c!96$mjcJG7yiBG`Tb<!Ms^#66VZiSJL}d5 zkz}3;IiQ^=u}SeYVdlb<$`vKbWgLGbPm$y`30Fi`0aBx9-|+MgsvC=7k?Nz3C{m2< z;@~uL&UHjj3LjUa5UBuq5s!#4okRvG5en4agG`lmA3IDi-IKnOpl++M<~gY(5|KnZ zcZM$-Lz%EZN{O(5`pjQkU)SdeC%bZeCI08fT9JGNo>6twA1fK@WDG}Elu&tq0T(Vx zss+W8+7F`f&@OjkM?cU^OpMDK8TY@IDovoS{>w-_L#E5ATA$6s-^Q=c`GW5&`^)9& z-Rsb{pU^024`wR~uAwwblj!j0V<u!4pR9u6$)AZd%GU;;Jz|oQ9H;CVpDrY+$q#;j z{N|M~IMu}~kxHTH>=@d8q%gT7O#i6~%#D1(a*1p1^pOm`wIa^tUEOsLbSLVzP9&gO zbXO+rb9gkE1yK|<Oi;-XVJ;6NI7V+LIs*ap3bUrH7LAy^OC8mgEX>=(?ZeXN1pjE% zjwc-@8{t#o0(FG@t7TY`GO$h|qEnZVV3*n#Z9zyAuMsKC?$3`pOMciCz=g9pl0q6! z)(7f#N<Sl|Bg#j$?tTJcUqrcDq~J4C$jl5s7j|C$Dl5CD=Q1!=n4Y*L=7@ZfHprnm zm=u@2>69XzlR>5B;1E}G6N=A3Y%~IDBNEw%t8U?GBGC!GzMbRg*%v<v?>R;7<JNzP z0raA-mxo!;l`c%)t}N7sMZ7nDO~x&#jTA!fNX3^>5VWMJbe<%GDD#&hS4m71lVI&1 z>TvLifI3_BcqCA8C~?Md7E0oNL!?R2xN=mmghY`+*vO}RJ(RfL_qE5b9Y8`}s!W&C znIid>xVL9thjr;qXQ7_hNlSRu2P&2@h1}*%LU^%%i>vNGf{-^rOBz%el>kn@UKAgj zJPOW-bTIt_>g8?b(p7|Qj80>U2n)udFg1o*1zIqy#jN>{!q!kj3KDfArXO(FSqXg` zi5q}Oa1)7Z?o3FJ`{YPidR^(nTJ5K1<>$^Xj}=TNN5;7wUkW1Nfeb_2KPWQ6p9bto z8S)Q`XIsy{eYP;C2eP*BF#??=t9M_E40Ei~D2ja;G0iMZK@ktiV^T@B<}mta0fcOI zJL2FMXh6JjdN=NVSP<X)z$oP(g(BTa0?RP)dzUb&Ws<>ZFp(`wu-yU6E0ty>R7btI z=_nIaN-@U|vSPV9Jpi|Z2H<x1RF(KFV0lY{+V$_Pna(a&OSV;WFBpq`R`<Vt53Hy_ zH?Fr^+>?VIubkQVoS)}MTx=Ny%%=oX_tDU66(DOOjC)+^VLMq_E6cT}&=)3R&J0cz zfKv5#EuAZ_MSBHPN?P7(n)BvPOeBGS6sh>0F$oIW@tceYTnd<1gxLr~C}jQzLt{Si zcPb?qo1#*jlG&O&U@Tw&&2cnLP!*6yb(<3!<SG_DB`x)sR%HHBB!*sgvFdUU?THc{ z#!L!lvLP_S4J}gIr?P)|gZ0T1-U^DNW7?;cckNY{Kq*s_PH~GJ0860O!{r8}h`$|m zMjpa`N|;t6(i^+**XK&;W_G;&>b|b7WCq4&EQX9OAgn-<K;xp4qr?E@I}1`kknjlc z^NSB2ilGHaDbXGnxtH4<D{6Aw`mCGT?4>>c;Y7w#D9(|VgG4RU2Wi<Q>Wr(#J$%?e zY*EKGw%&6P@D+m~wb1?4egnPE7`~L7Eeip>X1z!T=}%L-e-<((!~AW_fGAFdfizU3 z+LODeeT~)^;1~2IC`>Re7Dk=th+rZoZauezms$Pd<~gu;@je~5a2R)QtWj6L@b<y< zKGMyEUm;_26m()A(%t?-WKG%21Oe5$eI22WJym!BUX1g_KEP?sn@Xv^9?1{#OCZnW zFw`R$O6ZBzYxvFLI$<lrr-t7xtd-iOnSCz<Y4_lbYEAQVo<Aw5#m?n_Htn%W(z8?8 zCjf<`Nu-T*!P6EPv&tBq(F?KXZRgnZCTcX$in(u}*c6rDibS(QudciSYq@1c2}sXM zi|1M7XFYvX32);&Tp4}s0&5GypT1{UC$9mbOVGRACoHl3fxlyLu-)ge)C)`sTq~v6 zyxI)kUaK3wv_~^&31=0T4@CnuER3NT9i!>0d`R&70f6z~Xac^B`?d(jV9Rz!6ZE#& zMQ`|}-}62e&d-^E`qGl_ygII%`upx%R4Z6!BIB_E;E<Vyoaf%q;_{n?>-F*v4yW$V zjfMEa%YxsAGxLEJ=og2{8veY3byDn|KLoNOvt@ks2rkviQ5b937StQ2kymIrWWjg3 zzI3EAs-+G}z9i;bo@l{;qMfj8sf3tTnP<^iEk~<AcoqBvyx_&dkqhzWo2+B$g_y0? z%w6z@G1?Y@)U4u3y(pWlWUw3-@uFsx;URcs(T)qC`Yf6L-P*6W7<{TtWiN)*JQP$d z^CjmdM`<(prGM<YI-uy(iI!95D9dh}!7n70Dm4{bZEF5ixeS(%r>XFqqwQBN$rjR1 zl-v5T(19X2b;WNy?3!urrn14}G50D-vQUZ7(*%>y(3H8bRda+bwX-$~&W!EAKh7Su z3inq&T{-ElvBT{<=?2l67P|L^uS$Iv_mc3BMq4{%tpgi0Pm16;y-QX>!Z9AI{gJ<t zrUuq^#=SGCv>zb=HOI@P1ldrEk;8uLKkWr}Z)+OwjGi$#c1qgpB~CYInA#tmEkjtV zW^(JcLKYM9(@K0%@uo7-nH3+Lxbo6tsp?gC-4;bIL4G`a(}l_&Ty<Q<HVJlBE-9G& znrv<^S)E@epSE7;nCA)Bz~a`_+oqBjPUah3orXKaLouOW@ggFe#l%F^fhi}}>6Fh5 zwMHDyg(m3o3Q&GX4h7w>TGvAYcNW_P#k=Rvw(GbpTGhK0HMa?c+x5jg(~@#7-J`=( z?e{1GBVHhC-T0YRW?mu%fA#72ewUqFT(ul;Lk%8$YeRtp)J<@!PtKKZGZDv2t}L5H zoGu)DVRIXE`upI;2^-PUXl0qgozcdzkvosg5)H5*Yp|OJbElqt+#Xe5e{C+`j55$< z^KR#RcDL&;6Th*gGU=LicAD2v`kuP**<aFmd+I0bDxPk*WHFXCTR*=`t1UKoSn8P& z3h)-mZwU6dw?5F_2-w6tu{I5BIWK+QvZdH7!0@kk@~5K)W>eQljj)32&0<#Lt*{)_ zO3;d^UBGUgBt69Cxp=qa75(#nr~CDHozFyyM)DgDyT!$3ORw7I#66*GToKR0a9PN8 zG=<%<q;U+~GO5=Cx3~D(5A5RK&PD4j0Ij55eEYIPM%w2@Ok!evMeNm7%kOA51-pDL zbDIkVM$LM>B(;P>(_^nZj~-rs#d#WKYvWxjH>V&qZIjL}de=2SpLFibrM1?AGNoIs zOF+vuN|%>PbuE02U2A$5-uLaC_|9eRH>#hFE|wUXN+PhSgWUs5XEDx=A*SFwEIgta z>0pW-J=fKfFM?WQ;Ap{n!(r-3t>J|vX5=92rm>E<08e^r$Fl9bUA09&6E<vG2$XJ} zFMEtUMsM47(;e&tGFdDwcd#_$`SPVweoZCdI(7z6)m=*s9hPy#r#$=PjgesC+<D0| z)wE?6^iLj%PFwYQV(1gzOK5OfOYtr)jmY$B6gK09hov6%oH0*vK^M+?$|gQoXM*ZW z-#siJO8a>~zsIw_E{@VfE$7$mS@(rv9lmVcL%Y-*q{hGRr@BqBWiN^dzZ9kYxAddR zE3g}3w|q!crezAX`L1n0r&V7G^#!bxoV`b(brPUDnXf*8WL%xztK~ndJnj!RM%j1? zSWAF(JioX9k}$*U<YG6rdjxS-{6e0YGzg=u!hG;;&^n~aae9ne;Jy>{@e8fvNP$=o z88o-@KX#4g^sFzJTRy?Iy6pD)Y7x7rSQd9XnnDkTN+RfbSfmaKdyJ~hCTwJMsGE`c zw<udg^{u%*p4>d;?A~FW?xE+k*2@(gi_sE}Pig#wxn~_U?iVzJ9Qv?b@b+XUYML@s zUBh~~IXNINm;+wB5IGo_?R-x@Yo~uMi^@_L^)T}?gOc6Owdwr4a(DBD1APYgA8qy6 zskFZOz4LifUgEp~g^^NN;_H7?g^LErlNH0hmy~PJuC-T7cDQd{U#KrO2Wv4G*m&l{ zx}E=6JC?l!{$cLy;T?o?D4@A-UoPp>{QOQFiNWJLXD9J}hPy2x-rm62&^#(k@z#V* z+)kSIZQ;>9aEyVqGUnf24sXDG!v3|?{KR}cwiNb+P;YYIWbroODbID*@_u&^#>;#+ z%N_^SV!K;Xu7XqR30iNzf7f=jr(Vw}#oYfLbZ@zGdx?d~+AWUvmU-tH(G_?}_66DQ z0>#d70FAGK2ODJ-=5I9O<8IYukG|g2mi8F9;ZP6R`l!~5IAy9Q%b<HAa<u7ru|l=U zs*E~|t5WY*Z2RUdq_4K?Cb5>Y*6#g_Gx`}w!?MED9b>=Ujxy3sR={&oJOln}zu@it z!kAImet!HV+e}G&p)Te!_z6!>0{+R&)&2Hd-9)`5hW!f2^qq^LJFpy3+_}B!E?Bt` zHVWf~tBO;&w!c09*2i+JDJ+H`E6)^Ncjts3PRNEvE0a*5*{Ih)1?9ws+qlnPeSrbl zCSmoOGQ-IVOus_W_)wJf%Rp@u(LkfZi8)2kk^%BxF&~0qWy-2U^qFo6K=vtls;PcX z<K?Tf_@XY;da_!rK=i?7W!baKAqM!A3JgVbM9YF@b(|B+*V$NKT%FSDr+24<q6?D# zPSp>G00DXQe*aZw#wK*25Go4&ch>hm<6HrUoUkm=004;O008*^9p_@^ptUzPHL*3b zur+a_H8e1?GO;yw`d>-yj_no)TJM^YJx+cKw3KaG4~F|D&Gs;5;KbgrZPZA9yh(&B zOR7jpaiZrc?)A*>&57=9?>5gw?mxvwlBw7o-0A?1#^;Co`)_<jG2`?_|L*UA?}s<P zgsdfICUH{*p$RSi^d%CMR2S(`jZ7xG38E;I<Wc2=@=+O>*meG)9XLL&4v#%T3yzd0 zHke!{*+Fxzl5DcX`ea8vQ5r-#k1-M%vgEmN{IP361VJOLaf*zDur7-BDGOl4330OW z!WMtP=6}LiVvYYw8|VWh!{jmBQ(c@vlz-M6XVLr5fXq-rJy>1GfVeWD({~o7ndt0y z_hT8;HFp7%p&;ATtHTxW)oE6bG}C%vZvVppt||zO#sCG+GlHL{8P$xAy~tRGh5*9V zSDVbvB<?ntryR#2rAilQj($n#ATdle<;0GeN_e9X!n6worY(izSjq(lLMiG^(>;Al zNk2FU_iu9_u_z6P)Y|q`!v&G<O*$DjGKWc1e9xu)xDRQKo<io%wh-}sBs@?T>&-9d z7j^u#$O2@aXz~-Uq_J|InJ2R2C0XPV__L7ycH<5sBLQ5&M1%Pk91X#o`;!IBA&H-% z@3Y<;*zye-D!GvpX;&kA<Gd3`G3|wi6~#N{O%*XhGXG$1#u`;G1FHp&-;RUkh)3os zk2U|LJ^<L|C?eids1&KKPAcF(%~^b|fcQgJk=1lN*sR`TfN->Xy7{tVc%}tyxdG38 zE5a{*RCnncpeZfrC|!VA+T2FJBs0RV9?Py99&iVi^3i06lEn6d-pnzy0{aPOx;W<( zW>r@}G{m$V%#3`TGpr^9^NwzkVUr2j7#&|1@?*i)!WoUcUmi?@Y`V2EIyw5h7}$H| zYfJM`Fh1!T?}JG_Qe&_Q>af}mR^SJ|oO9I2<ZTK5p;VhM^WVgMhB>%EKR+=j8P*){ z!AkSqi!X|Ge7bw_VR;j4;X8x|eH3!#+ZvhMyEuNWWdX0420rid1Ur+%mj&7FA0*?A z0_#Ydx2r&<*@2gVPXb)5nC_WYAtxma%NqH3`TQ)I{JoYP9IR%R@i^pn@J*(-uGt++ zV_vh@r<$GBNWX{$X&s*lxH@){%2H|cP$KFh*0Ul;f2Og6lK~aG^?z%xU}(*pdV&;k z%xj~J)J*m}4kSO2`(DZsiEAS&m&zABo<pm`u8H7gVE1T%YGY89dN<6e<1i)yeCD>U zrTpvQQeUYG2^AVthVN9@N#Otei7T}eIOl44z2^U;A$*i{K}S`IK`XKRPj;*Kp(8pU z=ih%sB(`Zad6ouCa<CnDo$Xs8s<ut|%5XHicJLM629clbd+!=p6@fdUbSTd?Wvc1K zCWz|}1yrmsRjp>5NsfnAM2~|&-^P&3j%by*)OE&~F(WwC(3P7+fGWI&mE1*C9)UiC z3ITRUPLxTpzTo5_(5*7(#2Kz*5>rU$1acZ3S4Slb@7wr+vsZHJ8M1lA{$%z8<+*U= z#6H0>f$=$YuS#lnPasfpU*62CDQGZ$u3!Kylf8ysO;wO7E0RcNS!AXY>jTIMn{(mZ z{y#Q{*a0;Kw7M{sTs&+JU$k{wqyx!Q!7WLTeQ3)z9X$%58zdxtTACrvV_23aYe5^5 z)vE)=%^WIOwH23QT}xoqS?v5G58Mhv3ET=0m4BY$9YQ-OZGD=bT{Ptq$?x75q<jco zME;qH%SmNGu;c^;0WuPRm1M~+DE6wfg|fgxb-D)8?ydQvPdFfs7K7!a)X@>ONi>lq z$fP%`x1eH!)S9D<k>u-Aw>^u7t`p3aw9WYZ?@|huV(iHUt%jbwFLR=cwi@p@t<cpC z9Xq?nf7G7kuLpNKntxm%Xh0jsLxGOr8<Sx&ga7_S7$I0qZ}^3hXgcCAMSBV}{se;7 zQ0=&HQRG)}Z^2n{w|(`O3~s<j2N$QdVtzu)Gw=EtOMTYituMcMDUFQz^=m|GJjV^; zag)hgd0_#Ww{xZBD{R@>;vUPKFU=ZuUAGMz_E!r;HzqT}RQhmjI~`_v?=0?j=uyF( zS+3$YWs)keY~xPzDjh0M(ZOXEh{1GkI%{{cb`WuOJ3S2XFLCuD5YVmBK*LVnEu-6I zLlSb~h<jjJohK<hVDARCGvS$H+T%-N_?k?jxoB;^v2-qXtcbaAi@2|1M#xnteW}V` zD`8WdZAH8GLE{&x3th?-kT$71iI!_f!dzj|>sbm5Fm+_8CbJ!7i%4dQO_$T$?3yY} zA5x(h=GV+GH{i!`i^>7dqSH8IEP8jOcFRGI*}{id0ecKbSzR}_jkb#zQJAY#JX&?{ zcow+JU9Tc7cAqGtR@$~H5@3wyf2ZP9X=$;nSBkKtv6FHSRhuGI0%}5(s#LIe^Vu}l zpub|*UjDhXXWy}Qj^C6lRF1CW=v2@<GS6RqFIgKTNFdP&v4CN|dQzGJq86?xeI-L~ zOPmMQP}jc3jfi6$e0rO<;e^pCc38K($fZi?kF%Zw#rl@3hu94{$@Gv0R`3@MA}Fi> z`wDr|;)^*-&iLrerHC`qGmjUnLp~Qli6e#Ha8rS&9=3$p|D#b?DrJj61HwCc#6Bqp zee0VlNBPzhv~YK*g=eWpMs^2kbJHmwe3R#`JX=LEr~JqgI2r&7j?p9d<NH=B+)K=I za<C!_1%mJaJ@WF{9!0xxS3yPv*rxH=_k{|Wwr#0=faR~9Y<OLoT?#c*ItQnCL-Vy< z<KeM`!v+JT8P(sqqC|d-jgy)<w31lhMvKbR^ia^~Ldy2K8^e5ASJqJP?Ni|dwrtso zVf7~p6{|iTsp3I7A`DsA#qifES2AL-=aH?-v#402znqFzlk2w61GLrNhf%{sDc4qR zt$F1-VR{K~ZSJmIH?o3bKbmDyKSAlJ_w#%B=A=Ph!l6t+70GDIIsROX|M@xmI-AQ$ z6F{J>(BW=o;z8S$1tq=)(=rW$Y5T$}%l{`q4BMmBjt<Fbm#-g_ZV4?pi7eT|%MuVl ze_KsJvh%k8S$O3^SaLXf!~&C+yB0G3Mh$^jKBNR@62Km1j2;vkCgVcUskH5LG3&Ug zx*x>PU&z02KWZVF{VTOdg)iC)RfXd%VW5kZ*!Dcz7l*OtFkSu{I+P<$SA$N6`A|Yh z7&GwCT_me5AD1sLF*wCa3foT<vYB{)%AgBa<};DxIjUI~%eiCz!hgU25m|^)!W`}~ zmG~@bU7oz6eX|wkakpKsClhrX^@#9<*G09gHQ_;DrK+vyE#L*_w765Z8hw*Xv8kR! z*Yb;Ica7k6867Djb_J1@q4@>nW5$d(4=ImPiF5=mBUN?{)ot|^d5_)V!n<957eeo# zJ;JA{?VT^8xRd-o+OImy^W8QiElf*Jgzf+|+DYBE8)q%y<WVa{LAO9Oq(*r#cQxyN zIdK&jJHB1>;J}l2f`<h(iKzi0HzOvQ^@+>VY`n@4NROv{VJ)6YhqlxqCQz~SYzXum z)*Vfr*&OmU$iH+^jsbxC82vFKnmWR=x=j|J$fNA~CdE@l#9Chl3d_ovm(rXC4eU|| z(?mi%W);Xhb{nFa@ii{f)s_`ZGa@@A#5Lu?c}6uNU^UPfSNoa;!<`kcb|KWfZGD`< z-NXk8wOo&)vSuKEa&uL`g$g43QzucV8L-$~G>}7biPYh7O*i}q;VhZwohM*YG8#~z z<s<Epy3!Fhc+E~s5S8J#>Cnzi>RMz;i3?IA9@XoQ*OA<W%2Ow2kU?gg8w%k(R0BN} zW5*iPxG^=<vd3DGkB6t7OHgoPv*j21e}ZucR$Ydl|2Ru#|8bW7e=yF%MC)W@;OP8c zket@e-r2&&!pY=+cbIIPw*I5Y&gwaomSN2k4xnqL4hc_=CtjDg(2gV@PP_9UK;TG3 z3*ZC-3p1y3{Ve0pU(ip}Uo^ResVTIcSVX}iL0sS7eBIt05M~W;8_9`Y&-s04$)wsQ zHZBdJUz5lNnGkp^THZAL(4b1O!}74htWBR$zA2uH@=3Mld1OaW=Z^2g-y&BUJK;SH z%RD%xhLhg=@p=7J`u)D5J_#2Kh>+N@As(Qn(dgK}+J~!6aX1H2;5M}uK3)?;?-;|u zy#gGGM*D%3<!_p<N+mp+PbP6f7f%ZuAH15cgJul8D!trY$d1{z;PDyLhhVpAf<uDy z>t`4M;)MdLFFQifk_RIrCRt@XlGcDdR5G<SPbbR!D*#c#Ndz0V1`9|Jcb|g>zEhk? zu$N$JRTb>ipQ4ct;yAv3n}!W?o;hcgEAVd;A;`cd$%kp_?3HuZB(6~&rV09#>S0)u z3LdUBDCc0;B<oco6skDkl^JFUhWSdst~DXJAhaM{TWeTNdr9pg<SqmCow!$Y9iP2% z!y$Xg$G#^X=xJjRHU844a<te{PCmBYGEMML1UL=!hdEVX2%7D;MevyK40fAdr}6(j zCJbhf879p&jbIwx9vA2Y0u69MqD9c61+p7(XF-O{o~Es+M_`k>VcVauN|BxfO-7)H zej|2;)TO$-zRdVZ=zuiy!+*g$+b!{qQg=)1KJ1ueCvt5u@*~L=o6FUe-AFO!-m}w_ z#fDDmvUpjbq7O+AnnTTN%ph>X<MkQ*ecd*_w4rcD08Nl~81mWAh$j%CTw5|98v7?S zbV!dbRJte?$YCO16TwCBNR8J}BA%=%#(n+b^m@ksHxgQOIE#1anv^?Ey$hAl2+U5L z$Ng&Hk`4#cQv34HbtX2gAxgflRDC8nqgHE;*CoR*1$!IlB_~0PsW;nkFN`ioyy7X- zhaC&%w~*F-2F?X*!;qRiqEp)Ri8HN2RlXLll!6Xu_;&e)?kr*)!KopOZhsIQn!_}n zc7r9$E&S6}!aGq&tIZ*N8(Sep;dImiq5|^IA*9QPM^}w??~nJ@j8&S*H63uPXd8)U zhttkpq_&}{$Q^r?<Solw_=+cR(Wi(fLneWQqP-Faj+%&Q(Wsq10Q;OJ{4`(KLjUl< zr0V5-!GXp1xqRiaA!;dU0`rPEHMJ35!JP%3i0+FF@DUm$6CXO5#Gmru&3TqNRzu2r zb(al?Mn?b{8u1fXKl@;;28G=eT^{djK_pG*z5*f58JQgAZ347DcSt}zRRZs_WR%sc zt@J)zHZ9?@3&yXT19;UU&{#_usRkYZOe#l$%2E0p^#k3)bU-K1(M14}#Aum-4$w&& zO+aDFMu>z>12LZ-s}l^IY6}sL@{2?&rZp;3gPtKdR5<l=I`@8$`OzJEBQ_wSzlb$| z|F?JcGXR_LP9{Nw-hy=sUAOg^s)Z>+a~(Sd;s~{JP?KA6hM#uAD6J7QtKicF@{VFn zK)Oi4KiVLRX<**e?mZG0b0((mnzm+jWJDx!qIu<xIZidf2Ld$a+2rsA(fPS}xEVRQ zkWc?<RH!7lk*@jBdQ!V1mWM6MvBaTj#9pSKs*U95Ci<lgA2ILJw601Z^&!1SH3SV- zx^2aP*O}JgK2?h6o6&##nnQ9#%jk{wRiYpSf^lACt|{uRJ?Q#Kyt;Z8e#C1LP&cUl zc$P_TNOKf-OQ%Eda|xan^gomGl|r6K8RAc&kT`SrLo76&@w9AyDs&l{El@caiIJ#k zjX~v>3?_){FLaNBJCh0qU5VhlY;6)H=46Knq-k1&@icN>571#_NnA*BbQ2hgNf-*U zzmq3EymNF4O@ER{npF9hROMs|c2Iqy96!g^TB^?9p@3O$*<%{GKyAk!e3r^f+dd1W z3uA&UQdFU(v*CyiH-6tpk{>kvAHvS5Nfek(!eiUEZJe=f+qP}nwr$(y8QZpvJ$tiT zyEk8cLMm0slYYA!%BHC9B56>gHTmim9KX|A>aq<;r3tlR&#d%uZJAF0aXu!&zXq;` zs)IadeUaQaKs!>+aOVttpxZQ68ptbV46ri=yacPvDmVano`}<FgKZlO!X!keD3vVu zpy?j1VXz<XlKm|6l+=-~w29=DGT3)mJ^}0{AH-U1!+>S|!h?=``rwFjalzVIWAiR3 z?OOIS6e)0)jCj+U@TkVviOb^bD?c%m%{ReFc`~0@xb(;UpDk3#5Ic?!H~bi>#tl8A zAAT5Cy?86Ye*(d?qs+oDb@UUG-)p$tpF^duYvi+{R)o}_QdTE?#q$lXgt+}+g-}f7 ztT6}y(ch0q*NwOQ*(2Ceq09y?ue|;SO}2S*$~f&hdTsyQAx&~|xwF-U;EcF39V!k% z&nXW|F(69UKOXg{g04ab_T7m=FWLUg@`-5enPy>w=oOuYeoeDcvgW2TB~OW`s@lJm z4uu-@Gpcg`gleP$a8A2DG;u5scUYqhz<1eLXULE>QJ};vKYl3XrMD=3z9q!hNY&F9 zVT5AK`qJm+O6Z;CHI3Fi)wi-Tl9IB|@pkNLT^F4-=}V1tu}8ADro_h?Xl?u~FL|o~ zIoEv+J3~8(3}OpOO{9nt%-XGPU*)jDq5My`WgT0f6?>&n7M?Y+_l-0wOc((*Xfe@? znH7YNa^cofz%p+foq~3eU`w8Dr!;6gQhA;5*=e<l26GrSw(`S4%!KTy#B8Dq-f-mv zGNg8v=c=WW@nE588IHyDcqOdbZqRpa`|F-hObpn(yEbgdoKPkI8+W+FFVLBgh&%<e zS$Z%HNNpE97U1J3T=Ai9hrpSvknhyYndMS#9t;uES4VL#vtEp9ZE9aD<>T^{JH&{Z zLK^OcC$7+l;(#j3nXT+Li;_zudr5^9sP!FhyVOx=%n9gcw`Rla2u1QhwRak)o%Q2~ zXVu^J`E_ONEG^7Zx^wo@EScws+OEr_gDboaiLxRK+LY7PQ=BYC{_RH8k>gNSfvTBz z)B^h~3XMIj^7HZz89D4nXcRkMyS2*vUy&|fEfI$;V>)b;V>sp&DX{YsSGuCA?Z>~3 zGO@F>vAf;bS1&Tp=e9t?zo^8;rGTUhYG09IT|%D|rfWjQUgwI)?gWAf<vp!iB2}Ze zg*ie3)TSrRUCbLW(pgvN0^w(MNO>IvUqmi3t($z@99)sy`|OCk)-NrZ65pOS7aL#C zlUqOdRLC}m-^Q%h{i{-1D0g+Kujf2(Vq9MAzss@y2PP3ZDQ!=P)TWF6+dO(2*B#Yp z^!#fOlvUeFVPbuF_&C=@Ce?`q$Jd^Z2Kis?<Yk|yNkv>cKaRfM&aLPFEZxsX5~)NQ z{m}0>W!TMs1ixL)B`rP!qJ`XST>i9K=nv^l0AjDmOO#8L6`=NDBnt*!;;RNNEyUo) z3VO}dt*fDEbwZt2q8f#2PWfCs5s=umI`XMt2#r1PKN>E!{z+<`=H9s9zR<Y25xgD2 zQd7@tipXq@9&=vWH$K0@)O4m?T~QrC)SM_@JnC~Ukn~H8oZ|OKX|05G`_2w+jG*Lt zU1qJ54UWVsZann0zEn6>-~^p9^Jw8=hkbWvJX&PtW#&At`GBDFyP9{u`DG028q2;; z)?XShFoCfitpMd=4yG3OG%dbNN00f78FL#w?pes~Vu3|Zq_a9@pj(T9K$e3pUXpZz z(A$(9n{gVdi<U_Ccxq}wteVj?osad2kYX;Q+eL}Vt!{s-_`Lsqo;W?`3w=d=Kd%5k z1B9*K_{e(2krX95<}C{@0BW)#6x2W$ci*hI5<P2g%RV|2p*K;<yI=lL0k?zm{e`W; z^Ka&J!_6l=EwAut9N##<)n_?o^0b4eqxpxb!EOfq#@R9`0rJOS?hX9b+dPAC5v*&~ zMpt@IGD*>^Y;jt*4XabmI#mq#b3I#Lohp47b4!*s_|+#bR{v3HPZQuM42zZ`Q`?0o zm8CWdweHUdO2Z$3F~dF0K1KZ(<+pO|8HvMrOc=0OEbP?B6CAHNo6Vq&`t^CHF**nf zWlVL!2a*~srKs;w9mk{q$`O#<<)0d{$TLilX0B~m^S(|4)dSLvJc{+*8;XZR{TB;s zc)PRcWWOhlu@laHyboYwQQ<E(-L*6HXkwpz0%M_5Lzabd)RDbTuT5BJH+^~v$X*Ed zGpRpeDsESqIW<N;_8d+VuFhpu>eu3}tK6A2xiguE>(H!py#SCdFOE5JFBT~W!eq3q zv)Vb*x@WmT_0Fh+GK~41r8|}Bbw`^yGQ_wtQ&(J<!)I2|M9+e_&*)m)Tx=&N@sH!) z2!pwgAzrBAG=B+Ia2Qiq?aiBfoRJ+gq26=$TUZ+(o6P^%R!@UJ;5Mq*V81L`ko;s| z3P880;iIXINsHVwSd~+#*+?-OkjrXrvRxFQoBDpA$>P!|!&5yDNzv*Tt*DJC<uR5k zUUXGRHE2yBc$}w^H2nw$XFZJ4e2AA#!I34^N|fp7fF}osz7tIy^N_s%ffOU0+@j1e zL;)?<lA1k93KFeUs;GI^x+Q@`P_r!u@|_M1`kMr3v|{hCe>MN9Z3U*!;{E_rU=%a` z843Nelc)%BME_^H+N@n^S315C;zK7P<&v}xVu;x>cyXR}tmsrBzS2xFr`RqNieqCR z7nzck!4U&|b0HpQQiB#ibp!-vW*tSg=5pb%xUiBMV0$W^$6wOZ*jh;{mU?&5THlRB zUDg8YAU_L-whX;I?SeV~B7q-uC;Q;AFCShOhc7=a$32<q#yH2I(s@CFZeE}B)j;gC z9@#uBO;pqdns|UxPWh)a=r#uFh^QRcu@-(cdknbZx-`hY9`csw)4p#G4?@6iT;99C zo-z(o9E%Tyv+ymjAeYuufM`)RCr#Vrc%f3x(tA|jeuoXo|0hvGwbv>hOp%ikH$q$_ z7Asf-&EtbaGf4ScNQlZ3k`vHpE#dK;&=&M@kRW!o8lAa@W#zJA$DrW~6rep8!M*}# z=x|aurtWbGUCgLed{I62^P4kux|9uOE$`Gnh3i{DFOOGogFk}zu8W>>kWdq~=}1qp z{Ua+Wd&jb+kvgFQ0=W6Nr{{YK?`5tcOCl}5M6w@O_$)_;i|<Mffase4N$VHz|57z( zw^*MnJOF?f9RL8q|43XcEVM?B9!|~%*0e^}CI*hQCXSAFj{iyHn$i3hT`-FJZ*X%> zkQwnTF`q=rb$a7s9?67xnYI{5?sCE(p`8RV03Z;QggVvq1HosISE8>89br!-_1YQf zCtiH-YTc>t+O}R`(5JO)>gV(Jw?61e)mV3p8=@nv(Kd6Gl51bk9huIu#b@PI;_N+J z#(j9P-)v=P1?^G<Q;wM47&uqgsb|`dU4YFbHxJa|NGG&A;Fw6tiEm1Y*HHUFwnO|t z+<w1));w76ZE|NAGmg&Vk3j$eC!V!6-?o?NCA;pGHT&J;$Skuf^D(7a$6f^hCuS7# z?P@l4x1bH)h;<@3G5te_Qk9K0Kok0kC3*#VghTc$3!)M|R)_$L!68d7YIRRfI|ulH zZYuQ27;${U2y1sbo1`>t<3M9YT0?YLLFqqkw|Cd-DmZ259?WAIRb5MzImF&GKHMXQ zW(w1h1_q1`2yBWeTXO=_09R0b{9-{1^x*gObuWf^3J0o=6)I5Yk~5lTHo~PiPz4FZ zv*p*5pW>1q^qnSDsSv6{e>dPI3blJ9#U4`#^&w_E-huGfH^TvD3xR1Ux(z@CV>K#i z3`2VX(oodbMG#DLeLSCCSyA0~z*#XenjK1n8)2CpLR1A$q6i%gl%^t|Wlp_IUgG~S z7@!F#Hh;ygU9IQF^uQz!(0`|RkpPW<<~Z^vk>Enpjo*G5mru^2Kx41tT*?FG9Y?;8 zZW5jl(+b5Gt5=>FV#x{#X2*=#+~X!?E&-){1&P4e2!B{7rF_g#-h5ry;&#Q9CgV<d zFd}-3F$a-BjA|fZwMrHj&Lv?SK5NM0=y_P+0LRmNaG#i{eQDS>!P{fMcEk8o;|C;S z#sH8*Zx2QmC!=qV$La3j;0#rT0|Tx=pe->cQ7EP)2PXML;PIXdp#uo`rw0=f_DE1} zvJU_PUz~ZKk~ZKe9TPd&df#vo)bx)e3Y7=^`?jQ%f^GGdYR0iZtjxV(e1h-w>Q1?C z6>Qj$9>M)a8J1D1uw_d|%j`-uSU`dilJf0S;U?cTj#i7;V38~55mCN;k(&qd7`(NT z3r&j#wkl+9vGgFu4rArb#x30J&NF=HjmT}W*UODy5AJ<*F$};45c~rR$(cneY2miz zd(pUny1$zfxa>}%7l#B(!W3X0C^Ouvy;<-f<v(?mUKlP4S(sp7sNZ;|G}<VOT{j;0 zVZO_!g`907;*0XNZmsKwF)#&w86-^r!?+C%VVj!Dh%Zk7JXgSM9DB?Ffs5F8n<&o8 zgh7=9j+=>$)mq##D_&el%9zf9?d~vm)lx3^sqiI<yhRhaP!Ihs1%H+k$zSz8q<D(y z)(B^$Ny{|p&Tm3wG&=S&*sT}tK558~<PWAKaf->m!^4yZWAGyxpT>Oit|X)63SF6X z6@JGe<QsG6avd^`?HD0R;l6uHG^><nh0vl(owd%cR18LGZ^D&0IUR)5n9M{5bOk}j zYrt$s9~+NMP~OD7n0VnU98ACkFU=;?Z4EUX_~`+)+`xjM7W_;hrp}vAqz3v9<I0)v z7yI6(xE)NKya%S<##75hHf7J9ML-_VkRGa$pMs8fR!o?sLZM>c|Ezo7a^9A@sFdKX zHc|3gc3`{0nN$A!Ay#`%E=ahrm)as~d-*2wq3D;D?-QDO?XLMz=~m{n(9QJ^NN-OK z?iY_DARr>uLv2vRG)lt~K)9yOk2KNZP9?)N(o;75Ll0unk#kt*Xc<ZWdi}aIXC!n2 zYl|HmYU$3oT+ZQ6ftRbT_@)St^BBT>y0f%NR()wUx^jgX)4hob%V`d~PTMZ0&tgrX zq_`V}=AGKsU^3qUXJY4ofergB)*Nk!6(iZqG3z+}Kxm@@vJ4@E<6&TH(9ojlF!Q7$ z^qFg$>uNp`IPO$5SWZ8n8N};CJf&hdS_x^y#h%UYRq^Z1FwdDAm>p_knWLG_B!j^M zDmG}g{lKpA31#7W^xT2+<z4%0=PnP|pNoKIQ17t;lgYW!L;cxOgE^3%$fvkjS^BM{ z9sPMOA0hR4-8H@Sm^9I0Q1?O*IcY^>0YtL<=Owq4?|t7tCQ`w-U3EGUPtW%nr-rDL zubnr8k|u_NstB4j23KAMwNp3Ar{64bVp6%UGQDJwE7M(KJvaCJ@{vEQ+NH84Rm0rL zPPow|x5@6IMi8nn5}ZOuZ3Us)45yE_ySPt3q%#_rOp?53=zkEHT-C1Myv*tvdTbCC ztD3A-8giUJ>^#fB7N?~UN1)~s)%7RF7Y9(o#5lB|r}Xl2a2F4h?gGF1wT$RdOe;j> zmU<uV2RHD+%tl6^a!_jDmqSVOuy&ILn#JCG#JMG-HsLNsm8jgr{*3F&Ule?05DhJB z(w-=3kZMwiwh3Rf8H2cqA7bOn*MqKZ@fHg&=z|;J+=IF308SsW>;8769;J0o80L_~ zdp)^~E-ym}h$q`f&N7UN-lrqkJ)e()H^ULzMpY3wgzSLZZR=0#GX5J&(CNa3U<aZ^ zgJ5WP*0p8^eN;?zEXTX46Qjcm@z!NUc|%>^jqXrHr6unBRU}Pbr=tYGYsz%=<fQ+F zO8;87h2+oN`FLI+U+H6W*i;FT0enM#)yqT@OYHe&g;8UmaqKp?uk3P7*~V0D%S#E@ zsaK8<;$@_<?rI)L?=)CVPJ3|+3_JW|b;GQMN{T{*eFd)kLyyj5)K_~y4;}#Ff>T4s zQyizUY@1i<7$kT_Zdh&LIpXyK^{Jxz*G!hb>$}tGtT6t(_x7u)0YrRh-RjHrzKFC) z@Kn#bN;ug<mv>t$wE5$~^hqJH?PNLSIR7j9nsqNk34WenliJ#AS<E6AZ7=IE>3Qv$ z6?=Zn0q%WmYN&b}uY%syhxG0ni{$u>;FXn0v#X>euswU*yR!^4x&rHOVFUCMPGc7Z zyT*zqSpUhS+hw|LIA`L~b1`qDOca!pIh7MbrjE=8lgX=!IDhqhC^TyAl|rrO>Bngf zKbJqHf*-nAy(;QGd{$Gd?_&4wPpMw)kz-Cm!Xcy^TJX4}YmP@CVejt*cj1{23%pJ1 z67YZuo?{SG-I+neBDwWx+$)UC_wNk8X95mhxwGlq@DU=MfJVXY_q~qW-6sdID{I1w zz+&A?9lTe6BBBpnw(5sAmY*`2tR98$#~0nn9_aws-=*ZEh`NG9g`YP}FM$d<VhxX{ z{$q1J8OvIpWEJx-sJ+k353NkA8xD~Sm1#@C%!nz`q9y4kBInu44%?Xn#7u~l;0sb~ z1@KHjH5(PsGDP&0)C$bBs1Xl`7lJ^>m`q!1jSSdIk<<^3ox*L^dQq^4yfC&Ya_%M$ zDF3yR^YHI&7{G|i*3j(K=5@EzH#NE|g1AF|WA4O5J|2vDLK`|;&8s^r^$cuDbGS6$ zbv1p<gXX?`o3Wz7{0|7Nb{2!?Tawx?)SF0MlB0}9Llp>Oc?QUvbf%T%Q@N@fT#TpB zZ#75FW?tk3V;oWq@`;fpU6r!B&vB?(1TxTlMancW_KLLjz2>{X=XHK01-jQMZ@zBp zhJ}<QQH@Ps*LR<9yDH@4#xB*|beD}IeQOj_?P0iO)^!oLf-W@V+*heWlIP^%Slgtz z#H*EB`tVN_2hW?E=rF0jI+K>U22`EMt`<q2-v#aWOMjVfj5zyq=aMT0j6{p@YQfVZ z(k2aZ@#^NtkH{>~cB1y$sZY7+Vj7Jj0O`FiBhDkz&e)m@xH^0&x$Xd~JS^h!uG!Yo zB?($7#2ye>4?RfF9mu$wXgg*Ay3}3Bdx@Y)3fA3B*tOE)sRMJ27S^Fk{3^}#q%s72 zy6S$v?Ix)Rs4ZcM;v!`ZG*C#=$h7O6#V9-CTjWz!rTJw$%LUigcN|->tWQUr%&LqR zR~YA*a<S5RPC@l^`|Mq-7?A0!{kgX{opMw7wLLXhsb~D3d+%2kZd5$vvia*WVqbRy z9(?YKI1nW;j9*NRnr_ab4e2?suk>nN1@Qt7`??LesDWfAo48YyWIh$BnDfSx>yaR- zowrQ0@^091Iqx^Gu58)XN)u^&2_b9+;j&6~8#<UB0P;l>v!21BbSUGNpNAF}KLVp~ ztfY|o0>VqSc&)#$#M=ac0^JC%IhcpFIC6bXYbPl#k6!c3Q%6Ab@J*D6tC9bBb`=*{ zxfJ$@B7`2G;?N7L9`c&xmVwS~`XS6I;YpXSt1<R`qe^eG!?Ni>tzey9P3D5fdvXTe z{((Nt7<10mfZWaDj><am?$u<fTAb6g>6VY`dfG|_)FC%>bWtgsu&+6;1zN|c#Tk|) zCV^tn<13{AWnE17gA`ocPMN1+;e<EPvWAmXPo4ef3#L-Cs?%@uq<Rrim$BJ>QZm-C zB3$7^iqdtpYux@~>HB09P!_ZGnkkb<#*h~kf2DBVRY*?wU~s*Dj@-#R#1om>@_l>B zLV?Rk86>r(l+_Mx@DqtO{D2($D3wAB>A`TK0}p8Yk|9u1u<LnKD;}KPX)7LFn8}KC zt69l*CJ)S2v(*%1@{q*6(y(eKzEn;zQHXGsr>0Uo=c~*`8mOZ5sYXJ}>7#jk{Wx1G zJrtt+6wxSaOecN1PuWnze@(>T6*rY>r!iLJ^`P5YT}Wdk0mwnL!|R{&VY?&3Fh0id z>@B5YbmGv-mTJpLGnVOWMN6Ko5m_XkmI0hrojEGVlOpuDO>cOdUp!5_owuJd3$(3< zQ6Z!7T&6^A1I4XPK8W9|B$ut#gQ3>OH$Ha1u53TUdQF%j0dn(29#bhPv@he99`!i0 z6p+|E4nCQ52HkoLE=iGiRU!W<T+Pn%pmKyYvg8WJWe?Z3S#o4Y)<DTO)olM@$&<yJ z;Xw?9OMdNeP#lEQ>qCs>Qd@t4(tzX)wl82HCPW}kw*3<>Dj^y+A^ZdW!i-G^2N`yv zEUPm@+9I{Ma+gz5kyJ4%_39i2t>vlE$HZpnWRlpDQH6u$Iulcg%H9X@anIfYp}Aow zYN*El&=aYSBFL(&vFx<>-Y)aT4RqU3U~uMjPGahdK{7#WMRvA#oN{8C#0ozH3daZn zpe76G5$!Qq0BRuut(LYNv&6=XXa+R<D3wt|2G;l_qvK{fp;b+7-?O&<qp0kx(%f-E zbt0+#8}4_gYt@^dA8}*Lrty2`&-3eh>Cx40XDurrUhBxT$ax#VY3U-wxwpO+A%LdS zYExpAXTS6V{(m{$?0KE&;-C6W@lXB6`Cm?Fp#AS~Xp4WGZfauS?BZzhpBQ}^>-ry~ z-}B`j2+ncnhXNc8%QA(R#8$@%$Jkg1Q(F9Ebg?~gU62C=9D<03t#hF#$WM~(Z7u*Y z#n$ZDvK&iMB*?4l>(|w7Vm3OF$=sWIzwg7mCyj?}TxebPl=<AYv6m(*-C`mm=Fj+L zXx*k;723#(@)K!xlZr~sCJUt|qZFx;VpFM#7Mc@A{qZH41nS%YHMS&9{d5(}391GV zoU4bg61)a<?WyEz_Gdwq7HJP!6-GmJ`Y~dD`}is{qf;5go}(#?(M?*hXmm^>eeSts zndAt=5yuKET+f{%F@EAXo?By$6GKSzjMl3fCn6}`nUIohRck7BvPtb4;ggBLl$6O# zlyks;*##9q4i>J&m!OM8HdGpQqBNvfCJIp=Cgrd398)BXGwzvCH5!vryeS>kCH2m+ zN-9uup9fJ%L_Y`6-cR8?kn060S%|A1^mKF3*=aq(e(miXQFn9c{cF-HG%6!4<tzvw zQKO=^)?drvK7I^%4d1&0a91L=rC<*IbVrI5^2LO`wfy;(bV}MZe&__-CDo7)RVp@T z-%Q<VVd7MZ!=+D&LsFVOey%_V(~_V_LpJcrj7g8BL@F$r%Ak6Y<wu&k<y64_MOqCs zR;>u!b?6Bu69NLl-l&BW&DO16aT8?1gj7Y%%E~Uzq^fsV;VJ^8Od1i*Y{7w`^F+y@ zdzNmJT0pRB?VF49nkR%A(3zmN>l)OLeS93)w$;Hret67QW6<%AbX3JjZ-(cB#cDii zBvcCz$){%_DJD-E;--Carheh3zeqE=??zVfLd8eg(<3$jiRhzJ{mPaK0oa9XA`<~5 z2HIgu+g<P*YM&v?Ur;dBAdry$;uG?6S(!Af`eo&}^hJw`DCA+~qd=tRWl%jgt{`}Z zuMM*7!K_j!)|paI63U_31ugw#zJy8vSn79Hf!t#$#!eXAUpB@&LA4LzKmyp+o%;IE z?qjhf<IJw7{n08)fS7Isz&WaoB)ff&H$abzKW@G&_<(0%0u*0dY?O5;e~}6yIjocE z_Cl;vx5Mmf5BmDNTbq!*dzzR5@1$V=CDGcV;Q?dp-G|-H+P6$h5qrOBZq*Z8@iAs) z=fu*hgP1luw-u+$i^-(OrPJF6+($=f$+L-L8h7TY1)5PqsgtTN2LqT<lq0YKSXHWA zbr_0)0b0difj%$qRiUJFLqLO@w$bTBAUEN7+H^*Ed5`(X7m0&8ys0FuA}zu5vSmnD zimvLSG@#4~h#@fO$BC<1C<{C}9$^<w@$(1hzih_LyWrr*@B5s3XxBw1&qT&Hg1kNL zvzx}b=)|lg?vh~@>P~0Y<-uvxgQu1RM4$3SJb64`??907q=j%)QmI@wwBZHiX)o2- z+%h$(;h-Ok;`^@lgl#ER0)=n1<nL=QwLix5-;ponqwK!V+O=1I)=BA-6;^(6Ih-mY zj@7-lWG9KJ2X5dS!u$m5V@~47uIL61won<ua7gL;oxpnnpX5i;nA+{aib``#?ZEzm z2oB5ThXF%r*FYW>rmsSKlA8_RtJ8+S$A$GfPWhM*AOqjP;|5x247~iowPpaWsHlJf z<NmbT%0pS@PFttstm$h7@%!^K-`}r@I_1HNK8~AJ+i&YHR_tiAKW`bE{xZEQo6<aK z5{dVfM{)BsN$xnLNxDk(942&&>zVdEW7KbPV6+8J<Tw+7%}K9%9Q4<bOVE}81jG+7 zuh+D@@(rp8zmq385iOD_nP#DT0wgr3w|2nZ-Fb=;V^sXBjsX4^_KsbB$))0u0_lWy zZIdZciWl8LV;Y<oUDyxxf%I1g^-C0at68E>824Z5J;QLQ2^+S1U-O`cU&o_<=fwLL zym3~XBwG@}UI|S3FjW08)CQDgV}>gL9TO<z3AQ01XS?&>=G>L<l8wwuL-`!e+sh+3 z^BQ%AfP(W-0Yi!8eX9m9!b3tW-K>*;+@VPr2o5yUmfI}Xk_S#74|*SKJc6h-%qIH> zzsW%k;85iGWf=g`INzar><IB(Qe%Z=i3s*9<|<79tM0nAMw*PcD&#`)lXAMeat5u@ z%8iU1bUwYQc**+?6}>50G!Oo&dh>NgTg_M7ZYL(xVDOMyRu;eKA+^Sf$(z@d1$Gea z0O3;d4+9OS$oaMoTln-OU{}{CK>P@oJy0g>7J|nYTkW5}$qOJ6aCc^fOy`67>~aLn zij6q=HWiXTvZc%zObJ#EJ@91Vx^NEO@1DO82VskkNqxAut($&syFc;(dna(T7$p0Y zS9P^ta`pJ(uT8F1@~iUeXCR-*e)>dH`fqs!w;chNItCprQ+RFIJ@0>D{LY~f<%P%~ zATqWY9y##zuKIYl`1oL8<LwZAi42BLEJAohU}ZW)fubqL7}0zsl;(P`D?Xhsp8xiV z9*GHUEfo+hkv<(q1k#QDa{qiSQe?1=@YDbThW*6j6uUUTyfp*}X13;QhPj<F>=?0a z6yFC8Ye1hTeyNUq@k#5s)B<0D0bjx4jnkob@B+*mK}&oa?}|6VR?lDhku2bkOpa)% zK49Gg2Ev!p1czjTD5YRBN?W>+s`yh<j~Si^okR0?UWr^HHPMMizEprKrA$a)N40_T zv=O_Sj)NQ!o+0Zp%Ql=#uVS{NR12<Tiz$xWa|W|z^%J-#AcJ6&h*I!QvJCjc;r_+^ z!Y&}$0=J!XaQk<qUc7j;QccSaDz}%)IVS#<qPfBuaf$7;o;D^Z7+9x!4sxiLRCr=B zK<xtMvoQsL`(D?aTP}cVXpre2s_*c;s&#DGXSUw)9Q=Z-w(|&g9}`>&7vQv6+>x|P za`yJ+JEqOG8N9|qN6kF1HG=hpJ`CnB(ui(}?L2Vb7{_tU-fYV!B+r}^PK>|=hrO(6 z3z3xml{}t(F@!Me5SMr5ZAQ=Q8IbFXN9>>tC$#8!W>_+SR9cp?$cE~^0+iEq)QG2E zuU#xFkL)t!49;_~g8BF{x_mXhuJMRY|7=hBu4h+wc80*~DR`L$62EoIi`9zS4+-Wi zH8P|19NKKc;b3qQ-vSF`zE&=cTYsI!RWQd+5~H5vRn3daD4&L?^K0K@*@B)2vUDxa zzplV=kud#QBaG?smpQROl|-G{JpL}@5VKiIdWB^!2x%5E+SNT6_F>KkO2b4H5GoW) zC3*nNY*3Tg$IZ*@EmX(ggY5GNHED0ryFFce#D|xg#SDH~;Nsx<q=wcw%hBsKrDXY- z>~A0_7lw;z(ifPB{&okmAS+Eo--F{kGa-eEB<K$`UcC`$FTy$nb1}XbW<DZiC|6%P zqncEid^D$u3U56%!R-fHNyZ$79)G(=*5JQSJEc)?a5W!s<g5wK6u3j&`Kvc8J0^Cu znPcbH9i$d^HPgQh<M}Cop#Lx`2f<RWbD`j;IZ*3C>d@?)CpjlONAK#eAN<|}YN2rW z9=@awR7E-l$3CYvTAq#hGKQUz;}P^@3CSAAGi0!u7^D#(#G+)`T>v<?OF(|7UrV+R zeiiaQ7Lx-av^z;=s$$5Vk&YoCofp(m|NKbo^zo3Z@$p-YW*I|}KjH1q{8lqKf_uDi z5pw2d5pW)r^C2`?Q82q?jm$Uu$%n-QRXPHfcg*(inkEMEh8%nBiSFkIV^_(|2#z{? z=1OPVpc>%UsvZE<Az|gJaXbcMB}5THBamw-T{348VUwM(?WHTwM-p&GtK3R5s4cBt zaNuF|fjq7sKIKub3wGS3AoZ|e+&+WJldz_LkS4!k)sd!nmYDXV<6$Q2Agv$|$F8rK za!OQl_}0OkA^Rdqv%dM*g|lh9B?0g<GWQ!|L)cTqzor#-nIJUY)k^AMLgz9V1Na%o z8qtJ3nN(aRc^oU<b?@ZnKVsS#*!OetS7i<i1tOgDilU(r!lU8gmMTCg2I0)VKaI|u ziLcuah$pC3q5aVytvV@#N0+#eAoT!~4CVY~8aA+3PHY|r=;X8*|E|i0$m=hmMDUOw zJm((TZOKWqZuo`h!Ix#kfW2hUQf@z-tJPwdaMQA?tAxL4dlq71U>WD5MpPb4a$nG$ z7Q{=Z#vt>xbmH&X1hz~(-@y_rv8LOdsKA4WpQ?}C5Jca0C3*eD1alLV)eu_+PxIBf z>|M!rHDCI-yJ5im9(Gr++<JXCeJa8oMkR*RGeF^E$Y4|8bWgj$WPZkb@?}VxDb3)1 zT`UsCdx$Pc%6OwnqOeJcFR0(D0;HYFz#|BfYcL~sL7{loM;SdBk9p>rP&YuxqhvD8 znqT7(*o(K*{QCS~8%Qs~Tn|FDFU`tH<Mf&&748}~ore`r2!`7kBd|Xs&bM;SV<{(a z{*`B%{2C|JrJvC7y*2?DXCEov0%D7!`{gYnax+^_=vq8aVj$mv6N@?ISB-nF-jt1y zwRFi`VVYrN{@4`9C)f;^O<T3yrA3JnT;8n9Y%bUWyY`3Q;KTx*e2sL2jII&xvG7x{ zwB<he4G`uF3w_4C)>}Jy^(};0PYq6s`F#@nuz<L~iq5*@oFF9D1t}ek2<e?807~gr z$i)#U2dU!LL&ZIN^Q#0X2>^U~PBfM4FxU9wgif9QCm^5HUZPGK{+jog+oc)pV*L>j zC4UN<8YWh(NQ8_tV@Z)4;R78`epXA@5a%IZpf*8}oQp)rMXe+@$r07{KuC2$B}?ZO zwa;f>eX?cyxdJe3SbZ0EMLOdY7!rqdLBywNON<rhA7!Yh7kXr|#tf0U63NxC+^$6e zf@K$x#(4i2vUi9_-ZU?|cQNwG`Uv&!)a$X~Y<JLx+V))80%Pct8^4CpmCms_ic-oa zGT+(O>vo|GU&*jSyX_t`HThF<Lobi*@dC6#%+J;{velq(ICy<e3$}Y+04AX1L;_*4 zFAK^_!)Ki|(x~}dLVZ(0!(rNM1MDLlDxOJ1#c)R}ZbN+|kn$?@$8G7yX|}D>N(bOK zy=0?mdf~W=zPJSQ#wjVykFg=(t%{gd>+|weQr<j&TeSs?0>>*YZRI`+f{VMa&}$nY zuhDDX{&C{fx#UjR>}N~d>HB{=x3ows=_m{UK;J)|8{>cYnVFN;$j;W(!i?6`+Q95T zvGa`Pw&P|yYVWC%{uMTtk)&(m%#~&4^gI);lUizXakBM>jYVWIi7iDUslet&X%4S& zUcKCc6Tn^GheT%72~h;Z9v8lSf6y(?L9#ld%S09axZlsS9aHpRN#7(=*t>#wC~?ph z*esPwa@%}!1afDo!3Ccp(i<i5-$XYJL-d1Syn_9rJmLM&&}i;I5w|oznS<)Bt{5KZ zj97H$jYJk`V+w<cjpG;{F+hAozLIRdnIHR=Vy0G^iU*>EQiTAtbxG!jW?9fo59p9N zG-<t3^x<r*;*v>XncDV;i&<I(^$$XlXwKK%9|{Xf$%{%1S0Isw)(8HMrm_e}qp|yP z>cTi~*NbA6G|U*6D`=FVzSdH?-mP?H6fj!0C`uw8_k$4N^tu$0D;DmMP1Is=_D2?s zW=s)(B_(HTb)$YxBOfxnLcGa5-^6#Ma_^e74X7uP(DAqDOOimNf9YM2^NJGYCe2cQ z6MoBe`mo}#bOMHhDmxQPGv|{zA(e7Siza~6y)bq%lDv$3?Xo&q7pF67wr8D_*hlhI zzh#b0>f66@3g}f^+9JV9`xhL`r5w=)56DmN{MJR=ahXK*_@yS($|o!ixvTu9%te=% zesfkNwypZs_~xEFY_&%7o{ZH0g?jT5r;|4LtDu4XjG8S8na<0$vj(pGnE*)7B87zA zw#k~YXTqD6QNoAk!;>&m55qRg+IP;Cqza)G-J^$JDHoZTP%nNsu5S8!(iAcCuW14i zR=yf1m=yEoPYpjEyW?W#8vXR{JcShk!l#Mp)@9PW0#5t42ITVtQd$5X>4KowivR*o z1;!Xw6MhUC{#AkjF1wd7rbK)b0~F;A=ufhzv282?FT+~p8yLB>NZy>5FgKPhm;2V) zVVu}vJ76>#wEG#p)F5nmQaRml$7mE2&UvN<e9Qi}g53myLn!C;0;J*={1@f^8OUH9 z3|g1C@L%%0(i|4Dq&%4IKe^(N%lSlDFA4WwC_|MWS&wJo<Lh^E1{SNt+dm3kbs9p8 z>3F9U0Ut)NET?4Hkgb~CE@QRNL&<}iGRp*xIr_LRw+2Sm=Lm|<UM!fFMO_o<SLQ-k z7%KNpncd(MobiD|QPq7=#02LH!SD8_T+x**5{1S4T8G>2Kr~^UoXoZ{1#F8p+brZK z3chUZF$NZb^fX7_BItoE;q`rs>vGNo4;BLnJJ&bxA1yO!%jvkNWhHWa^C2n&Hl8?F ziCZ^tdGtPer|onyN~om3e(|pF-zxyQ8}{W|sXoxOUjqtJ6Fgqf?O1J2(71PMoA2gX z5KOe4?Wpqb!hqS+mWeIu2eQH{446HuGMm}>5;t)6GLTj$<s$Xirj)?AF3hbE?CXW7 z<bNMkQ3D%h%`9bo-yvjKck#>QO2ds&`9VQ{v?Ui_y7mG-7hFcW&nT~WU!t+^vRv5n zwxxNa`;Ok20X7roQ<M-;BDYqx^Y!TynagpawCz-rT_`z!@lnnEg^6XGg`8uft(5Y% z1X2FNpUpzewk74NIO1M3P2TteT$jGOU^P!x7Q9zRzl`_VvVkY-xk$M%SX}l|Wqc#m zXU(CP_fsWszmU(<38|z=;JwKZEFhM5R*bjXqQ%en>*?Sav`1~+<SUrCYj2&AeZUHW zpX?K1r6wGP{SJ-q5Bu(4vbq4ZqS2{gjtl}w4M)H)QhNKASrx74*!S=(ihcC(GH;IY z614gFjmg~MGE`xi-+%~&ppb}zZ<9o>2{fJ<GkyN4B<sf~izInjd^#i2!aedR(wp1$ z!TpQPVxLZV+K^Wny#+oINKzj?@pgIEi!KU>oNC=kxme%P4l3odrTrIp_^G}(T7{`l zh7PZ7NJE>e;ueX6eT4+r2Kf(mW>(({jZygNXbunfJIl%q{Q9ZBy=e6*QYBgbjcF65 zhVy#WV|WgWPb5-Z(3o;lH9Yd;i8`JVWJ{Wu1-$#TXsS4AJ6I~r9^sr``x39d7g}+8 z?a^RoeJ1b}x9EXXRNh&u$sPIkVZw`V;ijw;X(hz5y5O=0Bx?-{ULSQ}2O7!Jed}=4 z7EDy}QV>ggF{8_}+OFQ6qpkS9fx;Hf6*qvZuuH?WksT#fhsEkS49)XBmr=FW^-u?^ zFR&O)an6C7(_4>iZ;-Ll*9BJ2XuzCnOlv8zkt%*n)CkcD2zosDytm2h$VXx!$jm<~ zoT&2@_6seVCx%~@cmm<lUE0I9H^JWmaTVM^2l=o#NPWQ1-VWkcQ^3Q+LlXB*<_N+; zGEScohmH8sA-pTwCGXLVn>XV#QTNyVV7XKDE<L(dMH}M0F`_t#Lr}1^&Z^o0oY3v* zCOF2&Xs&|LCh$jS?VxB;NCpD=acY1rqRX-(NO`b;E`e1%*O9hAqOa(32|_}DuAk9> zA18_oyg1ruFY%-U7`_mfU?E_CXzHgkWnI>y>z4kO(o8qmR7b&ye_i@68bP{S-;Ba- zj#M<&-KF-gZ*K=L15{2X(&Ff5F?)p4Jr!JbpjsX#3#90F(RU^a-#NAxW~iUa)W$7m z%u)rdYv}cWoT8Id=hR;PXpOQ3&8Bc(PdT%R5>#T}ne9}!DwK0JICUwwQ0^kvtkGpH zja1k{1I5O!68jo}^ZdM!8A-)e0uwtZ8-%G1%Te>O)(ylQXD;4yapA%ZDFljDv$9r@ zvN&}1=+fM!R5v_O^n~<X^FUMTZA9YsbJAkrN@Do8L2e=tej1^7lA3LN(P~W%J_+5R zZ!<;j0UY0?4UhWrH2uPcTN^hA@pFHF$jMc4pDLZ>#g)0diP>T~3`3kZjuWYd?|1tD z*^ggqBBDs-r+4W(S<_myOT%$D)27~yxTd&|<lPbylnb)xs7E{*!!Iqc@z*<Q?RwU9 za-8nBZ?dt0Nwr{%1ty0W;_OD^D~489*BcE1J_A0smubW3Zc&OKn*1;epi(#pl<X)? zn%2<Jy%~P%=}GlnxO`WsZB_MWhTwf_o2O>Lj9#xKL+nOPAjm6`H)V11a*$JY=hpUc z!5WB<>jnsl<;=*Xx{U~p4kI|0xY}s@;^eP1*Gt|4r?R3_bCarPn~h0joTK_98F|UV z2bUXsEZ*E*7kr}f*O!q~h(G^AGI_c>dN}&}#+&Jw;}LZBIptXNG=7pQm+;;BPA*#= ztgQBIRB7^TU8dwLKis_IZRgCnn>Bn>pMGU3U%5?V37ze%<JI1|$bC-zYbU|4MPUo$ zr0YF5dpm#w>(@H$0|$<3)JXh$WAa|WbWaqCkVHpG#(v<d*pxZBXnVo%#j91*vJ;6k z2th9q(=;Qf3(Z+unMr;o!jC|Beg{q^oo#o2f>Ott{rz|WpSEWio_c^M=)&$M<hoLu zRld8*?=aIjV7Z)t){@4W?D}O_m@?LnH$Mec&J+cqs!;j5-#!O+rp58L5_y@xi#EQ{ z3mZlwBY+`4x4GepFMMOSWTKCzSu_uMl`{9?ZoU>Z4L<Ytw4k?dvH^9RZrt}4U|mJf zsL+yrLgE>CO`>7_$&6#9e#42DekCF(I!}KQ#MfxhVVU}Z_sIIXZUq4Jk8X1=b$KFJ z?J6a~?86{m9Q+Z??d(#hqmg?|h@bb(p$U3mo6dsai0lLjOe@i9z~l6QpwwK*A-dc! zi<bM}(SR?RbLq_8>@SxY^=!D0@0}<;FUPk!jvo+fWZO8>*iT6K3Al118(V^!Lh}#_ zlsAhhZ}yb<5QM!vm4`a|l#ZjgNp=};r|wP?Y`!D>D0wjIu*76`wKQyAKn>HEsyqj{ zxL%qJD4sCTMJO2!23ut*RE3rk_<H?DRi}>E(R+Vhi<VQUTiByoNRyG;tbMxlf0dTW zGXmuIX7*I&`J1F3U8#%y+R}<pSPYU#YmEahfS;xk`e~%7S*vVUF+<vGMIvE^%7*j= zL%+!z9aA20kwl=0^J(vJ=Hj-qQ{R|Oor$Z0Qiadaq$Ub5PbBYjt_z)Gc6O{b<jH-% zu4o5x?tH#!E0DVD;Tgz=cWM)P6MCAUB9l%8?osbLDiusv7qYQe<3^iBdTwV3oV~fG zEn%v2ER4kvxb1~+x%a*Hvxdd}b|?ct-Gp3^{YhhCfcT%OuRc=6EAhCD=65C6MQ$TC zCO1BVJ@Br^W0R{PF}DF@x!J*!Y}LtA)Klq<mUHW5VPmut^NYrqq~4|@I0XEsRr`U` z5&X5E4&9`d*?NcyDup`;3@AWxPXTJ<gQNSgy)^Jp9v~ANGed2ENIsT<4@8b}fDg-6 zgxQz~Jggsy*h7Si<7|A+f~W(lmTva>rTF@lLnD6k1P?%ou^11-14`6B%1o~0|G%r& z9^4A+0~Y|GQ3U`1>3_NCpRV|?D)v7bX|J?49XG`jea6(5%85sd#Yjl?*jx?Tj7%b0 zIf)}Qb+&l87y*<0i6YP-@)i8)7F~yVXTYVe&|lK7CaQrT{Fg>}J~=MJ*(8*6CjTw& z43#(8Y!fO3dbi=a-^aeeRjNMK4tmcYnwTPId7r*$q>SBX+|!)5bTq0C<#%s{;wfpg zzb4hgWsi@@0x?DW5mK1i74q6U5rzf9b%`Fy6nY>XG)o8&;2xQ<g~CTvQ6~ARRFuOs zGiu{=&39sNCKX}>L~>~g9XLF%ZGT%nIAiIYQf5stVsjxff}fjcvNH%-@glfsp-aMI zAGh5w_(x-zduJ4D=10VP+?WEg8xf``<`%WvhT9_?Nds9Z9WnXE#}D3qIXnBMpT{2s zOP&U&R4{ftb||J6^OWWJ0k~z5F})tkF^)^<x~G^Tg?J2eVh35~i#%#XV87wh3O+A( z!G9ZqBrcT8H^tEvnDiou0yzRRn;{5Vdm|$C1wLTW5>5Z0TOeWE=t)XnA3V)VztM;@ zA9Kv*$sxVIJB?w6J2i7^<p0%odm5w?8^(x5lO8#bIa8rRk21uJo8-o$HSt9)WuFiS z?@)};Egoql7B)f@7h^~^)T(a&_o<^D<BTQ*+oX<`K4o43g*hqs5{7UBykb>Pb4WC4 ztPAlaw`lAs74hD3$`_@K6DdrWa#Fh}4A~Pu-*fj`({E&DZVeMgvXvZC<vZ31L8F-8 zOcwsl0y)X8CSe~|K~p5CEv7hq_@p>G$fqQgLA#*+zVsq|9HjlNO{D{|>w8y-kXjbi z4HKCNN=O~B)*HUqu@fplmmD1xB8Ojqi(3L&<Ri?*N^LZqPb1!-&KJ$`&cVt1Iug#8 z8z&d%trpxdEE|zHKoffcea0Sc&@JDq^1y~EMd%vZJh|9dKlK&Vsa<@o($e2!X}6WW z>$9@2(UWO)ePPq4>0n*T%LS><cx5ba8q6pg_(exe@C57XCYu^K*BL(r^Lt_uphxX| zA&pHn>}<ef{AT!i%p9}6l+GYZgHMQGK@<lx?hK@WV%@dGpKVITsm7Qs3g|Z+uQ_0E z{x9^EXv590l%xit;3{P#P>Yn32CS0%!<&f&<tcv#4muEw5Yon<sDu{IPML;i<MWJ) zP`lRr9%X&@I;UNuJh)RIQ!pVV%>iZD&9p1$j{Sk+>6y!6^3gS$o+U#BE0X>|80d-8 zPj8r<f+%!iXLtCWsow87Z0t989;N@_@NFwxLF@hy=`*h73P8>4z+rDp6X|Z^>1qqe zEqd)w86syh8Azt8P4`Wc9Z+bJ^|K9-Tb>WH{jtY4Z2z{I9FXnR=}L4~e-#^T4L4>x z>!Fu_mQ60qR^~lV{|p;;K-SVgWk-zwi%s)ATgy7c)*dd-`-&>AjG9e%j13;}iT($~ z-6&fe;O5&EA4qq@%wZ)1pkFdI=0{dQ6`O9;Oj3Z|LwPx>2fTgL4Eor5lu?ikF|r6? z9~lw%ivP#E(EV7`c|*JO&^VhE0<8%`ISk+sqqGPshmoJT_V3TW@EKDc;XGID?Wb&# z9J7+@0sUgq<HXb3Rb4Fe@qCDWS>mNYB$)?_B*G;-kYt{f!Z>v#AC)}eTjWI-gjCP~ z5h$+5CnU1Rs&*nUYf?J2IN)!aC;!>O(33sWbGcHUKqJc1<@LmqGxPXVPEO9rtqj72 zF@GRfny1%SEp(!O92h3h8(94J`Tftx`T3js?;MyFr5(8Z+xFf=>`G~1RlV+Qc+uIn zX+Z3Y@6(PPJWc$uMG%oDRdtiQm%YP>Eok$np%`=n=9%-C9{ORE)2+87ORlbR5{X+; zepSz-9ITGaj3Z@(U9Oo*xY@tj#InVr1~DHPNg!%|<34FS0W+}YQ?FkvMO?X_rk=x~ z*F}8T<kq7uVg*|H=kCPt0yy$pxv@ofe6mlOihgx)Mt5179lY3okB8kBbbD$^({oj| z%0=k%Gy4w*PysIRilt5X#l<2XBl(a{+Im_)K}nYC`!})poySMMpn*{WwXdxXJy<zK z1mpYZd7m9;;mMt<knY%pt*faX8e&(?bRCI_3}84j4Q?JDqtOp&Da8?VwX5TSP1nqO z+pH`Wq7aLgRJXNIO^tM-o=C;jWjBevPM6esTWlNABC2omn=*b=(ae7b+et0E;UC%A zZQ|+x1iUaW09ie~1%C^}UCo9o^bb_bCxt}K6<jKEt>%!p#}ib2T0WhqW!{0N*Z1L< zCG$TM8Rut5VCqrqi~3AY;J<%bxSqnBayBk8fH8)taD}5G?Jyp7Po}lW7e~32DPu94 zIbYJol;%%oK-~mlL^VMt=3-b(S!>w?ZO=Suv!|LM(Qq4H^t^g_7E$-K(;Z**=(F9v z`ebsX^O-<8z(AWB_T>_&+V_kmdCj{Ex)S<H79F{zk3*-b17gGNfDkYyDM)+JAVCN9 z$W#!LUqnX2W>-1_wQ)J?GV!N?#a@sgXlR%)|HV0vxdaoz^Af1Ljp7TBBI!gu2{@?- zpm0zv&WKTD@h-F-_e3)0B%53t*<(10@?gikeiR)J_Ki=VoLPb_ua{McVk)wb0D%x@ z%@1Q!>!BIypuzxTuH@XP2fBR@dk{quO4tHVWP4AY<I#g1X`i&3;BQydw*+XF+~dG6 zR9At-!A7NtS&3Ngs-%iP7xGPI)hfA<)hjn)0}j2+JzNw>Cf>j=OQ-O)()94(S~>~? z8@cC`=;26Q-()r}hkVI@f5la$N(~=pM|6fcMN8E;1f0&6M4RP2jzIv7s8BCYk3=jO zgc4?oWg#0>oSKFa)N?HESY1m_nZ&&;mZVFRub@*NF4`wqkPl#gK7yfihi#76)DA=| z`pw5952iRx-kvS8g3(kIv8Xukp~7ahr0G6mj<;lUNvgP&*SP8GyKDD*#?2r}-cAx& z7r7u&igi+^4^$|V`WFuGnOsZ!OEi1SWWp!dmkDz<&?gG^WncO^{FTm8CU;p^AVY-# zV?h?^d!8h3jV0PkF)$k{Bi`hgHyZ~>89Beu45|${$q+3aQm7}Bj`z%Bge(FYF>ESg zK0N$Q0l9oVZ>-;pQx9JLnF%9Q<{%+%7c2+Lmn37Ml8!q8+*$N@(=a1M;m3AK*f4?# z^6`@pYSqz29Hk6m@9Qrgq*-cuC!HJSjyyiS<p}_Fzg#4D@UM(N^v$B6F@LecUH+-X zNz}f*x(^>KuyQ`G@S^EkH#0e~xCv>Dd}prXfKYM68o~az4g`YS!86Y70Ojs(hzrCa z<<8WeWo$Kx#<3*4__$8@)HAQE!pVg)nG;iQY;a3SE$yVX$9pI*Ogq14spCUx15GA! z2mZYCePj!-saf%ORxj+la|b*E<pK;2|0S~Y5zU7dF@3jQ*wP4e^NagS=f#LWQ~<~j zP6b2aQO?&GX|qneccGk86pVPmEdbYhtrfT)0=?r)8HjiS`q2;v%<A@<5oq|Gdntu@ ziH8s&EhLx!<xH3Vp_aKQdzCFbP%V2Mpj2>lPlx166cTN}67q?^#CpJ`BD<XHM}|m& z`)>tBqnx!4wGyj^Xv?Gy|IG>Qh7SgVxIq0%>qL=1b{FOl*nJim3DNzQ;w`bDQ&$*Z zMOGSF?uo1~=vg)@RnZxwWgGgJ_Cf(l66T*~{mDZoS!Urx0f$i4yBJDCu?;+EP&Hs( zf_Zo%bxd`uzh*0*L>28i$ffMl?9&K1w-m}@*Y-<Qm$<<rsExtX`ExWcJ%pE4JO**b z8%@t@#xaeeg)~Co-M7r{lH!~gNmnPMovm{G-eI$s1#72YL(|=*86^2?#ky#-o=+ZH z%7~6yU%WFEgdaZgTS&uwhzheQEQ0Blz?6}Oo%GB?I}9u;Io`8a=B>O_c_=|a;${_W zC-l;_LSh+q>Q=?gw{7|M<u2j>Mc6rY2^MxqI&IswZL89@ZQHi9(zb2ewr$&<nl)>6 z_t!V`7tY0d_KqD9&yQA5S)?e)LK!+?E_LwlVV|WLLjby3Kamcx3d)fIlt`9%AKQ9? z6G6e}9%+YvNR+cGGW>)%${6<2I7aB{bCXG}Yx`gir`Yk)P!T;KW!eM3b0gRD>QDuA zjZBZ3BRtc3z?u}7c4%}3xuAQV(Lv=(^6IJQ9@k_NmE)j^d4^TLY=)$scg<BV=z9l| z*weQv>raY$-u$ap9<<gig;Wq(8HCVdy|Ca5<j4WCnXX1-v8;q1iwd){Ods|-5Huv+ zFm~uByS~;UcHkZv%;?BNS>9fHXv1Z=$Ru_rq?I^^GRiRtQcTWlfj0Uqb_L_9f`le? z(DTH@6P6PE``#rJt83+Cv?MUqmac`aF6A~2^}|cZEjrZ5){+M)w+8e{#^Z^$22^yV z<^AREub^kyH&E_oxNf*Rb83i8sEknkqzTYuk(y;K`X%Z$tW|<3xq_SN#!wtK_jW7i zHx{Ot+TSAG?$~QrIj*1NhNYqC3-`<WI^+g5@-bLnQ_q(MW4;ecB_}SYnGdj+QzW`B zb#f*eZ3Uk#5Utm%YZzBISDKxwXd^>UD}S4gRtf1R;~q5^Rvv=bhJD)X)yLB0JX+b_ zI5UQ^JOH^+$4Y{_o;kG8#1bs${0<3eaQf)ITo!c8mAe{Z7vat|L*F2v${gM0#+cT# zBU`!AO0O2c&URUD>J5Ol6rArUx>}icJ?Hn<1rr_A4O*L@n5ouryH)Eq!i>#=HtYy1 zax`8sT|H!Y$_bwOp=1h!rlF6xJ@@hTH2R}}d)n!L*D35@CF2+LJ->I#Oh3Y+TCL0A zI;V-=hz8d~+i6{?ieM(G=H)TeY!~Y!W}4)(@)9CxtEw<#i#ugBio!wbvRcx$i<dqV z4fUEqMjy~6l=Gv#AdTr33RX$d*^*BXc*hu6?#s>a5nz@>El#mSgDQz-3VksNu<Q^E zdAj$C1Jm-G%AscB7ET|JGNf05kjiUvZh34fZ7`~==15k#04OuvQaMnS8Y5l~!N7|r zMw2-&y1y)Wbv97}-7DsfrvAm4%fgj2@b<N?S-0ZN`BAhpJF;WxSt}vtzl;>l_iKP2 z9)-w-q}t1T=QJi#j|jk<Ns1dMk+l^F-!+^3jLixBO%E;19@WyOO6kmYd=sSBjq_TL zF1OMD@^&5dE@7^hhJNI3yhV0LVf!=W%$@QFUJ6IWfl(6$RTCQ4LhCxoIo&^Nb{&q1 z;7HIcrSkTheo+mAT-eGQ3|-EFY0Hfodi|-^=NvGe2RLIQjq2&w;r{o#iO77Fz|NXz zPuSXcpFgG%z=LmDbdJmB*P4^eT#`(uXUnju&z;gTai*+Skq2be$ewLd5GbT8t)-k@ zm-5o@LrsnWzAk3qR0V5xH}0xx-1}b=XBX1#3zT}d<#wNMv78V_<!s_sbmH@X1<xHp z$!!;^V4~jS6p2K)va@a9h!l`HK`;Oxnj;(ND}WY9U(`xXgw_(%8qP{k0RxnX?4?$N zzOY^?;~;gxT;#O&c{O3`uzm?5jod6pF|+6v;{anD95@U1+U1NuHks{@1>jLk?FFEx z3n{!1tYTu!D3@+&APISuKbCjMU@i%B^r?UCq84?F^1-eot}(<si2<cRwy_7<B>9+w z9F5$%2S!qTsV`Z>Z@`F)H{X~fqo+Sj<6vD~UCSrLx=MeJL%u<=UADn0I<1>gm-qsv zs}(|s3UMPD+hHlzUJeKM<V{2nkk%%#$=Q)@EYTpE-kH3}DkEHuOuEKNHre-bsFGA{ zwIrG>yi;pi=l+D%G+5U&W7NgAXx~SzQ56uWzKyAr+0>Ix0$sJ;3(H>+=_nd~-KltQ z>-Kk*8JwBlSh<)T?Qo_~x@Ep@ZWNsvbSG?gaE!m+g&u@YZQu<xlAA@Sx~?eymR0*0 zujWq;u0XAQPE_9fL}lYLekWFw&o2kmkjc~tMp8A>s!FEM<6_BG`upzq+&`fHYCA}7 zqU;z(Rr-C^A*yuDUQ%<Q`pboINb)~_fMPHQbbiPVV;h;Qh0A^_f`U?dv4?DdG%ng8 zqaZ9$ETpTsySpxlpex{T7s2}_Mwu^trBgY9Zf`X~OlO1tP%d;-HS6dc*p_876)B)e zas3|Ol*Inqm3E(7US4JSWxEskeV+K@<Yvhh537BL%i;<cqL*QHX8p(Rtav-d(xdc# zf8fRA%erP+`sf$1jfbI0Qfj2!M9vz(znv&<&zi}chV?MC(6M^bfQ)LtL&4JziH_w= z@aFm1cDKmrwB~SW?aF<e(iX5BK!s6*2gpJb=VEt+%g$7P3e(%GLDVLPJ$kUiigfR> z;S{yCrQvGy(uHiSXncc)U1(Rl&K}}@_w%*c71((T^oxw*ki{l-U_#r$z)*#FPlOBg zSfMwI?7HTUb;ww|fdpH`tP6Z)`nsZ;J$H{?{wxqoBR0I=`*A~<9cc!EEF_ocTI$W1 z?*tViim#U3NxK=gp3A^F6brnAr0qZz&VqA4<sj?bR7dt0_1J~L@ElGGv!T5syZ=?1 zte9ej-o2u=y>*g&(Ktz{D!VS}r5{M5y$e9`^BV+<@)g>7meJ5mE|WpxBV!)&qIDwB z&`XEzHjm=yTb;|W#W%?8k&WfQ#g&EQaT4{A@@_WX9$2;R<{n@l$i4Ex@hbHi#UHIC zj1^=)Eopd-+FJk*BvJ|JQBCi^%tnuccj$WccyNd-9<A-OXkzqVhJPMgdGRvv3p%(O ztHr}#*TvG^&xq<DwYU$UvydN7_2W3A4&WQ)6Er(Kj7vPMcMFKPmVtZS!Dn-B!8bkQ zB|H$P7uYwta3l3wXvq2}tGvR|GVCU@qoY<%4eliC5#yMS7JCGD503K4TH!<HLP={U z9ar|ILcz_9K<K5DWdU~8XC>PrIsQGA&qhfop+74qp0*c^uJC$^iE0qxG%`e2iUkdr z+8Xil9!Y1VTNwaW^(cU$nd~cre%wIiffI|Jl(%^#9fpyEJq`74iAhU-%1H-+UJ^f> z@OQH3;w!{t49JmZa9rJ02u_qb6LAe)r%jV&Rw|FhwsCEPraUPI8K*;ODn6EGtp$68 zh}o>DnYOjb`PgD7Z>yFHwU2xhy*!JJldRsankZ5YMCSo(T%W4q)zjbNZ+8#E@t6EC zxMZ^?d``Y3`nyB2W0D1?_Qh8efjE|7lR%O3p5>7)Jw&H>x?0|*KqQ@L#sGI=Ci^`R zToCKg8A~fM=t3PbJ?DI?C8SHFj)Bcmt4?6hT&t%H8B`*ysEcvB!Qos(F_&!W37lw% z)qUzVn(As1n-uB)MqwNzrDY$~M%(CmcE!d@;<{3jE?w%*G3Y){yMS@Ms-wb+#l!}s zH|{;`?!gb!erc8Xa7>x&2ec-S)L~3DtRnQVi0fzD^|^reNra%FqSDXyeWQg6(MV=c ze`}%SjwkOvvOqxg3cGN5F+Dya)!{OnW+%L!3`q%|w9$3xj#dT&<$HwlwBJjA(4;7= z;`UZ8E(h5x2U+t=PZ2G08?^GFt@Yw1S)J)D!e#>TNS`dVyP+?($2qHNhB4dHGjQ+X zq6`mxAt4)Q7X=g6<KYIfl>U|#jjzY*3Hk#o!RFm|ZItLllUf1A)Uake9CsMb``NI< zPcElaq{HX9KXlH%KZj@2&0#A<18T6pcqz5>{`AY$-h$g|L-5g~C)h-xPu^plNRu%j z;9t;=)}C+i@E0seP1V}KMgq0i`1>{6DYS@W^V~KgHO3|a=kH*9PCENbu{*yUoZLkk zk)HF=N0UxZq<`{lVHy4M5Stz$21U&hXsp7eOjkKA5@vpC$UqV?m)O$}R{CGAOv`*= zNAdL<%d<2=jseZpi+fFy2{myq6uxM)37>hdw+_JGP&6QU4x0GhWvUyO*ZnyK{EtJZ z!G2zd<UN79BvHCw=1Q9BV;~9x&6ogfX1ZkIAdoo!_nL3snzyh{yhw_hT<(tpP}e`! z;r_jm@L^1dgcqV!Lvtwz0C2`b4|-X<?1ep_Y-~pr@~`>Tz|I=;)|S$-5GiYpn>9^` zEtQ7y*4czZ*Uc0#>7p>+e^8mjlW`lL*c+B0>=rlYh^98>K_Vyy<geF-$xV?$j`p=7 z++oZL<8jD3B|svnHiT4a>fE#S8vLKgI(A-l6V_Kk($ozvLygllbIxr1ZAqM+<@CJe zpF$P9_cIl*DOza95>i><r<m)D`NH>IiA85$RVZmj6ZPh}_ogFK2wI^)8DWENJGVYR z>cfPJAkg~s;(-iz(JhmEQ!`0I`P?m%>TaVlD|P<SRk+Y=i7}aBC|<A|U(;n23{9QP zloCMr(EA0Xax_7(EOQM@qh>r=oYeLW9?bNLu&7IAKt<A)qXQbpdbEspt1%o>C_Jb~ zRiAU=5o48=Uf8=19S{e20dWkRo+D|s2``EJ@1=@GcAuw)B3Y$O>qrFsa1StP+T8EB zXNNosSJ@z<NfgQ|&iU;-y0W2p_9|qU<3cYPb@)%*)G)VfQdGj%{ss50FNFpjKK$po zZC)NhIAAzoj=GT>wFl?WWUB2B;)hAx;`h7sblJl{yJ3lP;CAsekn1@Z!gw}`4w$(f z(p`&RP$$?#{;@+5*?>hbRFDUt#?^HIz6ssT2BtfnlG}sOINjQmWOk*@i7V;8_{yt- znQVP@RxrKkI9KvgO^<Yz)$iLx-_<$7*@k+xX6gBm(D18jR$6vpO~a3_@sP<%l7(aC zcNOKZKle<ivs#ZH!g7ZMGk<tKL2tgw8AWLfQS7iav4U2BHA{rU@An(uB6G&d9q4E9 z&obzr0f_GTRNX(ay-If}4Yx)uMfBeu08NWF$q64qaXmjj(*KM{|Lv{C|Dnol2A*jZ zpmwnDGfUDOX4On(B`<wDyiT-@eY`A{n|^=(=Ne&@>I)w#M@e(yVBvc2IwpYGj+|>n z`M77TCv_>D+XM3{JTlc1<Lmr0gCA@UNhD$gpUF*|`+kkX{+cWg;de9|S5eDHib#!D zB^&DLlk5Rxm(-uIz?yM1eiNaM&!%oLh^PGgfgPUOV3~LwAFO}<Ai~W&_B;c<L<3yH z#aID^5k98_*D`!%PL!34F5;>c>DgH3+Dycn_-2nEPyBHA59h;BS-pNhOp0J>YEw5) zQ>jPzyv%b+?_$wHs>6g4Yxq8?r;(PC;$R`2ij%TVbgXH^+>k6z$9SKW=CIxX!MY-r zaDn$Dh@B9r-SqSwC7<?gtEOvWX4adYXm)*fIc0+q`C~@iXq(c|JoAq%H!&P8r<hln z1tvb@2^AcTHvF>y?f%}MONSw{dW!L9?oA3^I+#Njt5xago3+rwaoAKy3~(>_{Tr0y z$V9hk^@&%%O7O4M-oJ#fNdOtP(&M~0Q@Hv^37&`lN1<HeERdpEMgmuR(;2*%{{4ty zk=meu%yp3OUKf=X8==5qo0urnLdXj*43|jX66>eU%CY0%XO#chtN0+dzYdFfo?Q7@ zFW(D;;>d-Kac)`Up&v%CL60QG>+UC#*-4G2apOQ+)qx3Jl*v%^zN{<?s+^%(W4RT5 zKU_o6P+Ws{H4xIFm4;rs<joL``Uhq7iNFJaHu^s4y$IUC=>VP1q5x*9#*j*B?x6fx zRe_Ac4VJSn0`aJY#1%T*+rcGnnCmiZBs=725aASY?45XtkrosqKA*A652u<?%J$KK zlfmi7HG2HP;Sh1c_}Q?kH|8iS{-?K|XV9e!zT}~MUpb80K!0ULce?66n@p+chdR$F z7G0Y?5~-m3xeKM4Hi5_#m#en`sVI|NQ?CZl8$7k4NI_}tD)AJ@56bwnt!TwQ@8VVE z6@p*CB((NA-Q*3?A6!7zJA%W|MC5m%@8#2XjRPd`3;C)?zHk>lfV#ht?ru6;?Y}x% z%Q~FDysHdx_{kSHj|gCy(S#*kut?#YH?^gbQZH>_Tf6pxBd^kc3#o|P>fZaneF)BW zEa3-pvkKv78^_xf>-Z`Pshzv#fMd3RJ?Je$@wlMe0`Ze-+`Im_7TCs=l@?v=J{^-M z6pMWRi8ZhUGl@O`<UO!?+Hu>Y6LEAQ6g>MQ-6{T(O1j7;Ltx<l)at5t8c9)Zwcr!~ z-5bcU6>rwzvzTc>%7fAdTKm6y9(pETFmH0yAeWy7U7CHaO`v<qS^y0ad2`{6IjPgi z>VclRvf?OJK|j!dRSHTqDWAxaSg!E70e{FyY|!L`Z9rQml0__rdV&ue7ASX#4j<|N zGo6|?`*DNz&s}Sa_aEt04q6LaXA?&QBWDX&lm83<{Qo&@V>B*pH^)$aYD?4R`Xe1_ z7aSCwE7yRKxW_#+V_-KSH910Ai2ke$p@bd7kX8l+{7`%V*!;e3zd(LKe9yp8Dx#B9 zUlE<ypJ?|uWe?)IrbmQ#za@Nsy#CfLE`?|P5t{+wR#0F>E#DWba4ucGXj~1EmR2jd zT7IWQe$^;&OkmC#k!G72rdj~>DvcKZL)1Im4!J6goITcKOeKQ`=v9eSo|aQa6j2XK z-d*Y4Uu008;l;s56|GRdV+5JNDukWBb7ON?8B+fjta@bzZtW3-3I8+@JAn8SX(fZP zdrnL`J>7l~$$*8rY0M?+Etx5jWd;Sp9BootgiNE(q2?@HzfgO+p5NT3ugB|7++!eG zF~YPQp*~zA)rV5Rl8jXuJ2bd&jtWrRaWPVv3z@SVt*$ZxBrZ!1k~+s<uHXb=#Noh$ zE})e#K)T01GUwDXN#ILuuX9fEcJ2a>o9+RpbFG;0`+Jq=rj4Du*~C>-=d*_i`U!tW zl|D#)DywSkIRT;mqs~D2Xq*AZR5FuNE{p_DFhoZ{e+Ip2PmqTV$><H-KP*S$z+mu( z4$&|n!xR!cr?Zo{<ubdWeF@n$(&YgpsuQR2Jn-B3dD4qb?LAwU7_6>qTM^fcs8_kI zu?D4U*#Pe^QK~nU`UyeMK*xRwS_^W%!G2rdb+#f}E8XW&6+0u@Wr#t6el{e+8Sn1y zuHJw%<({R)!d+@R3vwH1*np7Hr~^@9=fo`U%jLf=Wp@K*H=4vD4NObf2xZ2MKth|j zJWFB%bS_~Y2v6R9fkKigKHV;Q4b1-16<RvtWJvLzPOpp?S2u}}{Z4kZD(BuC$)eie zMmvRH_YDnBqzCCxg7kyky(m@6d5%Hmnb1@xU$3Y_taOmloylhP8Y0YqS&%HBdXaDf zg?jxBLXj1s-wmTvXl8|TJZ~zry)Ym2;unV8GzmttpZD=i-j~PY^)Ozt(l1qKDt(Tr zP9Qklth=vR8QNN+NfaRXwJg*UkW(ALP8CmY9uq#HG#QXw+82WEU_CJiLsn8ekkxE; zhBp2-MIwV9G|bs?vk`F)QCpMtyi<{Z*gLf%IM`Y}27Z+iNmz!K=Y`njb;|M0skWnp z*+3}M)Uw?O<)h`$efu=wDpibz@U}~Ydr*Jj>_0Qs$>WZaFyhvCB|K=|JH=z9a!0#E zQg!XI_bkXij{m|93uN>}hwf$z4L=)(Z^<ZL;R#s}^*TxC>d$*hM#nkX7#*X05odP` zd{5h%(V37deg~cp(6ew-L{~VIrQ(BY&v!Nv;S!XQ31o<sfe32)jU5=!xW`P))bxF1 zAhx7Ah7k{s`nz0?a}$w>*riu3TZ|N}uiRCeyFf6O;F)M67Gtqgs9xR5Q{Qm*B{F;N zJc8Hp!%ecttPFriIFSK%YG<b*NeIpu$zcNx+QH#S@SQOa++LI0sFB4Y%W;mZC~8Xk z!q%s}R$5i_k>^RLQFxEBxX6*XlBW<&GP=uhDk$Jgbm{KJ@%UDltvFM|deBE7{o=z> z4|^e6o-xU^5A*Oz{{U&Qo{{58W^#DzX7!n95eeZ^MuJx(TlR6d&+GI=DPNWe5q3B& z{vybX$Hmq+1^|*l6Bt2?a%Ha+*<saocx?ObYfuT39uR5AxvGIQcy^7V2vsz!5?q{E zwiqCQhYnqubz7S<yCu6!cf7&kb-m|^E=cNw&CyD;$(A7Nu6CLuHR5J+-nVbd!<~)& z@3}j$eBJGBJiMQKE-iRk5w><3bDc}3eT?O3eF2pWWUISWf`cTJSFFhMiI=FRbg4G? z*h9o^cGvKDaoNj5|B5boafhSQ#g)Z|_Yxo~zAs9A4V=QgyafV<J?uq~2-M48O+ws2 zjSWjAF7G5-J5`0+R?I_nP!lX@Zh$w&JGOnHH|1txYkwSnLgzJmUDUsc-M3n=;H#%U zrJ@#Xgz!Uc|6RdM6~1kB99tC%|3C{coP_H&VK)6OH4j<F-GogA_iPs;)oB)K<?gty z&o=wQ<QK5e@+pq;o+9Qrm=W|w=yk9E*~#-8m2x2P#ksBRJyI}!oSDHdIMp7?Y`WLA zI_T#AIE<UstpVLJP_OX9;EIQ%yiOLE&UyZQ-Sztos?Jl{mA5}i-#l*_bsQ-GdEtDh z=zF%rfhPz5er%L_vWZiJljkwd8v9TgI6W^&c!D3S+EKA{+s4Ug5ARqfwtSI`*{#8F z>CfA~|8N0z$LJv53{PNU;(N#_?Uv+~znLg;d<mCkPSObaiovZ*H-lVrI;$gMRDi@e z83QX6V@b)@_YdBy$CQJr|K7&RbYX3_8Q%q3lF^a+VnfxLN94r8yMBfju41{X#mDwb z5j1=h?sn5IN|-i)bH;RAA<FC61Zy4Wa=1Ra9JY|=2EGgwTn@*pe}Ra&sOi3`qy{_i zOb}z;1cS)z+BazH1Vozim5yx@rKvMjhb^Ej8Viz>Zx)1?LGjY!Q)FM>(zbzi55-b> zSR)qX+BH#FR;k-_g7d?LMwa<{VJ`qLVt@Qq-Wv!6E0d{T-I0E-nJg#Kp<djLM`AbV z{mUrtb{W4K*_TpSj5LYd)B&gFGm;Jkh%e$Q@5uH+L=<F}J7dGi_J>sd(O(nL4|xZi z45_=i+;NLSm7TO`*#?R4LVzxEV+kg&K##LP#6%`-8b*5rsbJ|u*H}=iCB@{yqi-h( z5_Q`Pi=60phm=|kw0bRl^&xinHp-$_&0_8H8-$I1+hy<t9{aE|IUuRiJ;@<;<8xpC zpvXZDp2+P@xHGG4UvtTBOTapT4=9X+(vY!Ft4UE1;h^^#b(^}Lyn>I`xuP;5vAF|% zK0BO@%z0lIx&8`9t@ul$(2<pETvA-~mHk#Z+qCzANFN=<+IBN#bJulO^o|?3t?;;e z@&TqQ-Fx=p<~kpp#TR*XCdCFDIqqRAqv3eIAz#%LC_h_i>Eeq|Jyw`+j$<g6hi-dp z(Be5{w#`Xyp_&n?d_y(4HHEVc=oSO<ObxdW4_hi>sAKUi#2jl7^=c^A<#)<jt*%+v z=cAeEv5(L1CkrlHYR-LtTxiwJu>Mr?6jsUT<UvHhNSv}s&(ZdT%<!B0u4E_tFP_&2 zDhpn0SE(LtI(Y5)n5a3ObO7Hho{3GCGK@gM3V7R1o@75QJA=Jb!5;+*3Ap1VF}~$w z<kc*(jaoF|4zkY7*B8E?fWN0mU+M#afAn&Fx8cP7p(}3ncCV*xxtwotgg!gLYt4X1 z$SLr;X6lf=0cG%2inl$?T{Le@syV-AGUM#u(@&9h+NHCORoa-hVqs)vdZMDFroMEk zbchI@_FJ!oup{zm2C3;sUt}t4=Cmpv?Zkp-=qK6og&7bS_Uc7WoFvDZs+4CWuWvCS zjKf(#&HU4(zc==L&JOFmp&xaK!rre#bFY_+Bf!9L$L<?uWUi)p9n%pRy=ck2$y5?j zgF5~#p*>L-E3F+Ls+XP`);m%*wwgLG-96d$mmQTqH-K7DD{=%<0Zxx>?x3gW?=sLP zy`wd)EF&&5-4;5j**CDAKO(|w5TzBpIy9<sM}F)saN4nUv)gYgvda#mXZ30<0DsgH z$RbBKd@=J8>IU^6s0aV7Nt%@Y35(HiZp@-GzcSq^6AImJjOfZHdCLv0Uh|Vg-p5-j zn41^TD~XT}Ni4=gTC{I#T0SiN&Yq}@&9M1}fBQ9u)|8SX)L!=(sFb)FMN!QYwQ<2$ z4GD(2%!}0I74S^D?~7ftP%A2n)Vc^Vp~YB>V~FdWsUnp;!X|AR-&yH}A%La1Nx`B+ z1-1aQPY#$w$RLv$ENLQ1OPsfOhkD(ax?@2~xnk|qc%Re1Y}B=I=gYCZ+I(=yKhpD2 z%KMJ<QB=>?Z|}a;L(O5vIB4<jScZGk6_-WbumfLK3wby`Sp{nCx!^o5V5RYCS!gj- zg;nCUr+Q_DRj_N-D<6YHr9zywL$&Q=nnbJ0OkSi|Qdr?NaLOsQw<o|R4J=&&K{td! z4+G6Ixva(DRedjYwF*$q|7O#mb%w@l>6l)3JA{Glmnu9HIBv~9;L4i6yXBmr0k@F_ z-fb?-{`fj69jbfw`_ElNQ3gJs3JCzfj}HI<{l6TmOboP!#>TYHCQi;4wr2km#>o0} zZ{gw&4FCx83<v-Kf&u^lu&j0IxYd#H!!H*wrR>2up-r>S>6OxuolLsEA{MLiH|sD- zmGvM#4HM4+A{2c*vZ_Ma_X^lLJJ%!Fhl+pP7uKIdU`Cv4r*XVyf;3U2uyg(ld#)Of zTcT~YC8wd*?>(Y>XVa*ZXL8Vere@H)-xa!7UDjhFi#Ny1U<lZ?PMWCa$l>NVKYV?Y z2*$m4(PKDPB%`sF=#-Ic>X*{U4xOhP#t3bMGq)6q2mB@aIItU0fkG_J_3xuGM>nSF zTW$#3L%O)eL?Xo#?|5QFHUr=@&2s|}?N^uhd)eao^I8~nj&8_T$0DarFy^GWj|5ds zpV>&DBc?%BNq{ujIf;I+#Nb|6AxCbpAtb^{7ivISqn;F-H(=;sTIsqy-A7Z$A<z_O zPs$F%^hUhJirQBOh{RibxGubttVqHK$xc*M)Jzu$4su0(|5n@Mb7`;7_c{IBsOeT= z`+C|kKX1!3F7`PhTMz(Km4J;LzSF-lLaf0+FNJ?@8m&PIhQoWDs4+4rhCwZVev7_& zbK|?@cb=C8`DiVO$%J`raa{@GI)z3CxaAjtH5)oBg@rD>P2?X^l;)3U6A?%;Eo)jv z9zsY-PhQf;rf>_{kw>EQr8*Ea_jYzR5s#)<FrqJ-5SrHzHiZgi?q>pcBjDwp_cfBM zKVZ#A<y)QBJg|XbY%)ljs1gu1J_B8SQjR<ppi3Edz3pyO0Sn?f=b4+~)8iXnZ7BT^ znzr|JuZ+i!2q`7j=GS<tPF^<52CL<%`sABd(~m<$8%DRXa&c++A$J6kiz-NUm@3#8 zCTn=*Hna-LrtJ5_O!tl%vZ~xu!}1&LPufy<5U&a81yo3mR5k`WQ7jEZ%ayp-@GK)q zc9!)G2nS4!<UJ?$y53#PlM=@kTYGucJtc~~d6E(Ar-{>uAT6HelUNNgJ59}@x-2qq zTFE<op99${D<rIDizZ$?`wo3Z{3Kz`(3ePs+BC!*L)iobf4mH^)nFa>B@YWnXEge1 zLjwXXyA~7c;rfln>#<X_7gQQkt3I8Zn%6<`i>2yW8;p7{AhotfUI}Rm2>3t$ZZp-T zDDWiKWoB**n%uoPuVjl6fE=IaG;$YMSu%!@oilM!{w}hPSI5{_M%ZhJc%->o#0K2N zrH4yR6<w(5qJT}{5eLvFK}bMo9K7S$&GEw7!@$0qrPbt8bL~pyUm3I4%Vnm9N!iw= zMoGE%v9@tFol2<(g>|R!fgWg*JvG@z7DH>%ZOYUO&AbdGeYOK&ZBEK|ThD9gf*DMN zLTa5l+DS3O7P(=VBjYB8I)<5`{1{$)SI@!=P~v-3dLQ&TI|5%ff=$5kiqt$2v=)zI zB;n-|=9<xAKFiolPYk`(6=`z$4%|o(Qt8zTx=#Oe154?Qs)1TL-EXN#>|tk{cO_tN zCuDgz<QZZd;;DKoh~+?+!x6Xqt`}~}^^3>q_hDl{2jV1zhHL9Cxv{f_-)JAxXSeR7 z#;pTtxvQnwhUdwG8q;Oci|Z7MYb#}p9#%4-G9G8tg|j@BAi6d5M49**aQ^{M><&uI zUJd(9bo=BEE4?M_ynS)=W9R(2yLa|;?C@**_5FQ%iv!5^P#9T9Ck>B*O$O)S(&X=} z0Qzxdi{sD9l7-{%8YV_EP;gYR9ktuy(pYsErev*9F5@#9*N&qTK{*@ZnjBx_;YZad zAu4|;r3BI~?TeHOJMVD3Y56MCNxHm|JT2xEQqf)=jcl7}(Rsyfy6;XcGuX5-w0!T1 zN$zthjmMP5Uo_;Cw|<%BmS9{aR2-MD_miTU963z+1<?w5QecsFTE?&z`{9q@ogC-h zL+VtzjYOWw>t_G<{8x+I01rufE;{@e)r!+A{Hq@U@qUpU^Nv7{I*g4Y4<vC1^)JRV zpf2&C9>-#zoV%_J!fx#eOsP6{rWA4pzvZXky=z?TIwE*1C#6>N?GzF7^#meMNBvKH zSa9Yr#5N-Z0ka!iA3Yq*Aofl}u~2pZ-G#jdQ-XkXJ`pa;^?=a)8?llyW5@+J*S2UT zjDzXHvMBtt=w6KP6{N#{vMTGRAGFl@=7s3wRksuO@~@Zm^OH(&9+{8xTLDoCYty&@ zEjs45rqDO_KqLRy2%|mqq%wPqs#U?RoLrcrC<8H6f_2rSfkyuW;nbg@O&Ysmrv`9w zbA`RAdm`9QhT4DG)8I#}$Zd1E=z?&-f|7({$j9mLM!jvHN6VUL$8)9wO(>%y>x3Cm zq=ZMS9i*ekWR7;w0tnTS`QMt(BMq_fig5Bf;exU38^Rf49<tNOuCiUdepC;@qSVU} zie#jW3+;zYKvHGU+d<I9=un~sAaeCA?IJ@Y0pvX9(|?4@9@0WjFDYfm%RDu_>h^^i z8!I#qPw6sb1RPm%=!9crtn}dVWvA2i#ZtLdqjEfPO0nV=pwkF-M{pu_&-dCaIuq6# z_h^=tD2QG!udr+)q7rXXI44phiDyCKzYAIhSKQ3e^XNFB%Av(bG{JR79%CS~^pQ&r zQ*C_e*li?qsKLeshaFs89W^j<HCmatfrw?x1zRb#O}5X$Nst7_+6z_w>YY@c-U^2v z#m_-4f3PyOXQe<(zn5OHS}Wk16#qW4WGJ!iD<52|dW5*9$gIv)e@k6|X~SxJ0fH8{ zyZbM+nlV(Sm*bHam#c571NKnHUWSYtO`Lu)OL^_Noa-Rf>YQHxoTnDTlb3$mrX$TE zHe5#gZr{3ku?j0@>twLa+fy6=)jH~EI%BdNyDA^6s{@#YR_L>l<Lnge@L9F_dyl@= zn^lI1oKf#M>}+pE*ETJ<LG%Zi(8f`gEDQ{NZtE{3Phb#{?~x;^Tx6CqxM=(h*wr^H zWjwuYV%a%M%x|vFAOOuFdz^|rAcWG2Ood&hj0YvO+1)S)(cCmqgIRVnGJr0(UT942 z2)IC9)E(|hSK(s{l+o}N=$vko^<^NY(`KTt|HAF4nxpL|%9HW=`znI9m?3k?g7YLY zUwqnXtRlW%{A<EJ-;R-HyO<POY{r1`g2F6yBD9N;g(*db@HM5&-bM+}IME8)4wfZw z%3{WyPEGZ${Vd9y9#dwSqJ~Ee!s<Uv@(Bd}I)w;=ko*kY;Lth<6Vo<w*IYMEER(9U z*cZ61H1<1BWJ6==DRDY%^cO_CK6A<>A+)a(El(R>2&0a9OJa1VS8$X^s0c0h*N%$p zDwCj3`uy*(H5J0>#IB@?Z5El-GkyYcBESbirB4Pc-$n?z4j_wSYzk7TwW77}>64b} zJ@jQardXEdn~hA8kyH*n2$;*rw!$Xn*ya33WqAn|ex+jlCU?45xk@S|HZ9vR^CO0$ z0jmzfer4dAR9(&|^fXd_U&1D%(=Q#6Woox~ae28gI%)b0GC~NZzZkV>^`TIzp@Wxi zAjpFQ1Q`qa{lVPm0RM=moV&b)LZndlnT6YkrOh<O<NQN+??5Gs@Uv#=06)2X1&;6> zqf;vnKRwSzU8rUYTUm8kq?1Zn+8zJ*ghHF8AqRFgM#L!7)Df8LC#}^Fx*C|>WZH7f z5xv=Dq(VHW(A9czJpHtSE@$UWj(@^<A!oJ(1?^Z8Wd<{4ivw?*BeD<nb;pOn-$R2C zP<C_g!hJT!g3&7%w4Zh;!n>~+MGx6@CWeHo@|LLztB(&M^X7?Vi^bXFKxbK9#AK{L z^foq|HU7Z+z%4sD;_m&%<Ed~m+N}8t=2JMBnVgaF41AIn5`s;}H}mV$r8ON(e5x+Q ze|CpoS3PbUicGhJ$aH(`w~4=eb*7#YWT}I>Moccm%l=Jxlu(5{4arKLW(`0>r=bjd zw-`VC(YQ|B&fAwJDvwS&kN#zi(G%mz=*Q`gN77C=9m*X}9nT@&kc-#3*v!kE%DG}O zw|n{r!+=+mQ!<j3!oMdaQcS4>vlo^^aou!6ML!+lCAJQx29Z7xOQq#F_(G)_s~j~Q zg9b|ggbs`C*-(xZNk>Yka@(SCVGsOO^&rK*vG!;mCc{>&EO;)oStkm*uyiHESrJ5X zk>1}$`wVD3sbw9CaypXpDsP>OuVG73KrwB04UvN!8??k-l!l&~s%la@m4@|J?wZF@ zy+5OV6+4LUlEui5gaoydlhRW~Rb@<e&Q4|A@aF6bavuBlA-Lt}Vmn#gl!oaCO4I&z z*tcAUX|mswK0V#OsXZMor*#EiMBlHic&TfvzaQ9*64ljr_CC%ZCx^)rRb8xa#(HR9 z^_AjXqj#}0s&b;DQg9iRxgpPpsUW#aoVNXw*uKqzNi#X(d8<^_HpfJlkmZeG+7dFQ z+Tnud*}+<ZqJ-*l0$><1n#a+<t`GaKGEcRD#qqu5X;32bc~sBA2(07kZdkc1TV_d3 z3zFiw6k}L#l^i>oVvksdN**B!M4<iyl9taA1<4_8k<-+SpHyK6_D~>@SId?#UtEjp z<B*?`xN{;E&SOMI+Ab7J32CWq)Z1#}@Xz4`P;FHsWSePCN}LPR_J1rW78rWZosA7z zEt%pE;F-x3DZsv_Pr_}iacIkK8YS4-mO5X6PgAWMd+P-OO~>f2R*ImHvKuzxDT~48 zWMEN)f3lzwR(H9ySj%aXM{X)($z&WVOWJBS&kJ~SNEOm{nfao|a$DS)2^0lMm4fEY zSPPYN^azZ@h83OOt4<bHiJN-WlE@V9%#kbs=gVzzrB6z;M&(1j8w?npx=hd7CtU-n z>0cbw6=8VwC|mv^1f}S^r*1UbPRbx1rc0Nq>}N&rKF?(gZ)jQUU-i2>N8zGJq#`re zcbJ4&@TT`GqbOBs>CF<_QpRV`z>JtG%RbZ^W5ab^Q&83o`~DI|O4@nvyemjESL7HK zP&4C`2(QQYMNwL}VN@&?^&~jE__7o!s+}nINTvQOY88UG`}KU*7FPDpNLTe#){{lg zdu47YN`8YkboV;~DmGnTYa~K|@tBIoLK24M>M|()3sdTBprRweYOhqFa%zNj5SEyW zAH^}XPW{0b+$bP2E0$aThnmty`YgDgB}+q<#aKr?k5E=}A7<Awff@5{`y$4o3e3Vt zy5yI(Wui#>1!HRbGCMTMOCncQk8hMn?O9R4+B#m)=y&oOmZt~hUcjuson6fqs?o@$ zJrZ=LuEgI|(Lr$sKcl<i2-{A1GvozFRgEaq!=Q-rH=uO?N-W#49+z<?SrJuL6vyO* z$%#D0EL>V}x|S;HOj-fd%3bycQ!&Q22qX3##sDP;kO%{-UG@A^$JId&U1535(?CWw z#rbxsf`82%oQ%X2nN=E0{cMBLY1GI_ggX?m86AF;w&)pWz+d=q4Gh!G{G7LP_ipE< z-?ekEqeh_7%1$&D`-Odv>0JZ-evPkv#3bnHF<ny?H4VPX^_@y&@!$7x(s-&-6@%4^ z5lv^BQ*u_zc>;E!mJk&ZX5EZ;zKxaMm^Q@~bxL0<(}b|hj2OUtweuDiYwp&B*$<B- zHf%(h!dVYs{V@p8D}7sL2W={5PGy4pn@}`CB}q6gQkFbL@y9}lr7<0q!0Iva)8JZL zbP(&3p%K;zb{5OZo#4I=eMypERj5W<Xk#iPeJqoyn??yKO7E@Z9!?3yczO448iGm7 z0^a<1_|GG<8D#CWvRdI1zzetU;8_)|;aS>=TzQ3!>a$MbeBjE}WP9s()s?g*3Om^H zm8paeU}3oO#hfiqVxLO1r;3rrqSeb8U*kP5uj%VkY7G9a){DMg`hckK@FedbaoKnU zpbFvKs$tN_iKL#+^>PXQts+9<rKA0GtC(1j_^os^7tfZ|JbTUC4ZRItL50N%qnBAj zf*|NcDt~2AzMT#?mPsiZcaUkm#HrZDv}udTgNO`f9XW_qBTXkT-Y&2Klclb4mEjY< zq9kK&u9q12fET@3;2+7wbMGk%HS1F>YM)@6GEY<{E$8ZyR>8%&)ut3G4iA1AN6fYC zKpCOy68`0EXPU)5bn=G7+a=9$-;BtoWQD8G-t~&t@RPRPpSX+z-_F3ZFsVP!SyYZe zSm~CYMs7ZZE4tSJ|DILjZ1N?TQtqL#BN##cITiBGGh?gl5(5gVY)sdSUgHK7#HHqe zMU^xKg#xwnEdw{R;n&lYao$ZP8CldYP13FkG39(s6^g$u`7SO*cVa`Dp_BnvmWV59 zRh1ak>g79`(x}jzQN^?F0oho|opJX1C%e4_-X{A@(OpYrvGRnHc<7lS3B78f!l~#K zZD7ay&-mKwYrJp29&V4-scg)Stu-z(kJWAz&AJ-ocfFJEYLGQ9RJuMt^bQsoJ?gQS zPTdXze}3qH^)0_?1yBEXjg@Our$!4D0KhvY008oTiC>JYv~DJb=5}^g|5Ny)<$C(x z!dJ5Tr7bo)+CSk-a7}>9!=)bAOkt`6uFF-MJKRXcF+&F=ge4=NWQ2K;lG8{l2I#8R z$A6g5Ah&3jkQ1F!vHlW2MdSe|XVQ(1=hHgrJ|4H*u=n?}_lh`|uE<-zuz1Gfz{BiH zn|{$g-<S$|nu%1nrdj;-O5zCG)yI!t-vD8Bk-&S77soFVY%$F$Wul2Y#^g=F3_Mj$ z+Eg2~J-)o9NbaUY`NWV+sB`(T>)Yq05XNv8o8p~<Xa)$$pYq45-=&AFKahH+#+ark zL9o#uHKWqjl(O&v?so7FH(NGyDPOuF?G&N8I4WK>T{)VXRHbv&7Sv}Rf%Nn|sq)=z zgPJr4)k1s$tbQ!j8mvOS<gtcoAqitOQqx9^P?hI)Z;$L59*(EhN@fBH@)%FFk8GJ# zr}3~+8`XONjVHYAA|UTc(&Ai(sde`|?|^I_dm*3c2@cDtOk!M<sqvy2Ht|#&bY<RE z4?9_^W`;f^wg-2++w3c$)LyAB+5|yEjk+d^!whmZh++(w%QMjxIY+$NIQKXyK(t?j zS_>qjv5@Wad-vC%t^f^yFs<4KUziHxv1TlrRqD{3Q7)(pfCR6-f?!y4we=L;2KXjD z+UR|WmZ`);o#G(}>?IRTl_yME%<?B|9A!0%z|`28GZT|;Q6Op#H_4)lMT|+(N~0{E zV6Jw`s45>*u232ZryNtcr-=uGGRP3i=+xVOP?Oza2KBw~(bhzH2#0JMoi7t$Z*KN= z8#WP-`k(EGsdG|@AU^2am`NdIhBp`#IwxxA72PA}{ipf<$R_@K`tjr27nUJ?W~i&~ zEd+G$gW(Usbdq!hf-}RJ$7k*xC7#FSU%4ocFdYIo_fK?s>Jn+-K?7Z;9tQ7q^tk;v zIvk1w5Mv8BMp26X3Yg)TDgoo6Wn6r8s+V;NH3rwx1CLXEXkueP%)A530#9_gMhi|& zxYCq+jp_9f0Z6|zjUoeW0&md{QUt0I5mr}azDjG|M_e>YtD;qRi@SlLs-p_(xIV~g zE*o(U4Z`8(%T049=}f*%Nldq{+LdOzDr9O-+Cf}AQnAK~`@@B;loFNlhk}LT=?=5v z^%ZZQllW(}{ceZ_2mms<HDle<{T;MAgz;2Kdc<n4jFk!$DLN;;OC9R!-q`L5JU18I z33R}~mNBMsM{14UrqA4<%u-QBX2${DzNiQaq%H8<qg4;2MtbW2_}uTQ!Nr5mMmUmL zgI+<FMz+N(D>H1y1xE8sid~6!S!vYXOu)4*#2`m9!=LHumw4jaG-iwVyNAfXgf(rV z0ei7AE#5e_jU(j>p+IlfCTKyXk12JU$X7}5?se20&U|(J;WO>lNuYA&L2${?eGw0K z`HGK^Cx0VI_7eogl&DDI*9ZL@$TpzHIChv8_M&$CGEo&y_x2heX|U&8W6kDxS(Y!G zo`Q&(Od3JnN#j6^w@atFwkiCu9Ys@$lBOh0aDIU1^^fa=)g9b4C9ZbF96jd8=w!;Z zuCI|+pJ$Rx)oR@GdIrVRMLHihU#@I#Er)2TxOdQ6jrV{S1`dcHU980I6qg0IQ*tX& zdVIWoktGd>v_1rljjosaUN9tHz+22Dy!Fl)xm0_=za>i&sDlng)T*L-bFwhiZ|W99 zrcy3fsBYE7LKyu8R|{bGsYJLNSEneFHL@uGv|wn8Mt`2!E;%pKrlr7i1AVW#ErU*9 z&7C+u!BV%5sYauLF2|$RRS1(tb6Fo(cihx|G`uAH2F#j<aRKJOBK?xJD2a+5mE5|$ zv9VeP*;oJ3srPgA@sEq+zW9YZ5No_3!LX@*?!=T|bFQ5SamgO0k}dH{UYy10OJT;= zSDDt!Kdp*}IJ-JqXZKdHRQnw2QDylMoCb3%^kUL!1TP5QuWi3MC7^p_lf#5fP%Ia{ z*`a5a&5#a+iCBH%_@31)LH1m(sy3asZm`JlO7ar+BTPQ1r?ncZERl+roC1ATG3nF~ z|DAWybg?5UV9D?)trX5Hal!}itFl!`mLpj7EW{_4*K8z=$vYG)-DrR(sla1c|Kr)l zYHyN&=5kRar!gh}QF2&}wpw_0hW{M9cE8Wa4PM6dRKF8C!p9A371Bw>_1H!4#pZSW zZe2@pw{GCYW;ZVhHcRMrrDD*gC+NPH!$XVSVnYn`-dAY<6G~gX9F2Q-?U)Q>LGD}) z=whJEH@c*_=44`8WK=WJ*XdfD&{OB*{Pj>Yg^OQKF`vjY_DG=J2N+<g<c{(ErYt6| z?=MY~t5^t^kK}Lnlust>R36BAd(99KvG_X+KxW%o1|Kp4`AqCOi#Pb80&q+miBAIH z4+iyqHj1C%PW)2!rpnyK#ba$ehn)dH0)Zdpwgl^M(bL5B4{{aj?OD0A(Rv~GLda!* zt2_(bkpO%g6yK8z{?Y#`)5~b!SDqr{dY33oY!!MItc3UZJb1NfXn9))>i+<;;~8D% z<7e!fy`nRzu}&78<q7FbN5#`k>-ghsNf~NeD&gd+ii+s0xiNa_LNbPc%M(u_d#%NE z0OFDp8fb;n4u!CA<PIg6Xg5Be6ZU1K?%z0q1lMosZ-`fhGbvsLT@<$)Uwoz$+5^$w zXu4~};`1!!H{CbNaCcVf29&ygKxnArR$dvnIxV8#iD_jkBPC}3D%M*-dJO!^q1h^s zk7q!k6Ddoj2U4Dp#;zQm+B|WpK(ZCo$docTL&}snf(A>4qy7~XG%`0R%OdTj4yTo- z)STfJ%+@hh4s9LGvcZ7mMR%XUDDmD!Ej-ub8a?w?h|JEM$?IdL<EcCUG3Por7B{_! zU+gKc?Lg0>4x+HR#V$JO5_8!+Ns9glo;oo>mck(z)X`d#Ul7-cYO&h#z&~pTtiM~l zjb`qthesQqr9IRmf>}7Ddk=(^A!pCVi1uA_F~9GvZ@Q=E{Mdq^)sdQlcVFzb(Xxe$ zoi2za>LD^42d|-*xE>Z8wXVtCmXu8gZ8|tMm)l*Vz;rr3e?>NjhyPWNm;ZNM;z7np zY&?4m<-KHF6Z@?C%Do=P1d&cuna`H@wrms*PTadNUIP?t+_PkOe6B|kXBXxUEt=H7 zC+}|CevFY3GbU7x1TCQM$no751S8K<#5ApAZ$Icocflf~b$O7(MpmsbUD0)ed_a{o z(aMWlgqNjVlmw#M+14C!ZrgH9@!GQRi#N+)>yvO;iKX;CYBwu&L*rjnWc;d(@Zkmm zjy5~I6)1`H?o_n{Y+JL9t<mvhe~Ub#-siOkOJHWFg#hVrCd<W1gH%uBbrkJh62^+y z-mbJId;Uy`HzE&IdGt)`^pvz<>LkevA1g9j4z4W=J~UKUwL`Gjbwu44@Xleex_buB zOKj}?<5ZgqehS~U0_^T*h!5yy>vNlG(s|W&x@PP6xp^WiX21qzGPmjF$?M%0X~$uA zh0v}bxujOd$dfr?RN6sf##T~>!mSW;dxy$Yv9}CDPk~3EmBv*cUmhTM0ULEZFCHr@ zJRXVvf)&vHFPowmh(u)9YVek36+tB1Xs>C3l=}tu<=~&|Rk1<BTnEu{-9?(t3pp#d zafDepON-{++u6x)-68zXrqx=(V0LrF?s|4br|y#%M*_P_lH|Yq0RMUabsZNLm4XBS z*h2>ZK>P0zn1j~I$kD{a*2&z?`TsKoH>>}5k88T}gLqQ*b;u;i&DDhKUmfn1Az9D) zb8!Sn1#^l(LV<D1tn~la<9ZA5m3(wF<60p>lfd!szxd?~K6=H6Ovue`_WN_%yXKx= znM|X4#Wxiq{;Y`ka(L8zs#C`}E1dw{g6NcTug;*8Xg<Q4>FqwLJmZ_A-EHCs3UnQG zIXV>1b1YT^${Z;}SF1szm~a>)xef%Al2Sfk9CFWl`<B+N!gJpxQM1BiX*Vd8bKfpq zlpxe7G|6I%5_)I<M_6%^I}fch*`vlWCMH_^lql{uZ*w!8Wg<!1p=w%@Y+z6hF>z$g zj(8er;zXK9(uNCb%sIiy=7ETWc1z(#I2m%5o6{o~jGR1&-5T2!Q0(Pq|0OgaN<?s0 z4&TmOK4FC?CTnAc&A1AcJtL(yio`({U{W(uro@q1*`lF3T0q$^!J>$*kRd@s?4bE8 zOg~=|orx|Bm<Ba9bVuy(j`@ldN*ovss%hZ^J(HwJ?$}5Oo4gyPJBi~zzVrtWI}8T> zlwy%sXt4;+OAW)Z#rW<u$2saoMID;Q<cw?3tbL1NJA?|Jpq8gg<(^GHQGH6jzM%>U zowX-)GKnQK4b!AYayTjr2ALu4-y2sH;&=iaP-5ukY%{W9o`ihpe*q}eyoojqFl({} z^%5e8frWa(x$vy_s|!Ry<nwQUwg&HbzRYPy^~pxFkbJ^PYv%l*z0AV)f;()6*E@wX zB1YgBr5ZNPBcsdQ+gctw7A%*7;f~$c*k34cf^5%JL){@@Hd$q=yW)mxe;IdM61kcl zp^w#{-L9&%!KrNTo)*V7_PBj!iNk>JdnYo(mlz*fjs+|1x`Qkqd<JuSE*@^)=bqbg zCDKhv(Suhil@ydTy`+yhGL?J{DcaMBvcpZ!*;eD+Nwpx}647i-UUygn!$pW#ru~N7 zG)fP$JPaI;<Oype9;ZA-x70)07Vgnyu0b7clkJwF^PC`FyQ|#6^yY)Y(mb1*(0nFP zDy85B5~Y;!KM7}{>vVmE{9QbK%_vN|8N$}KCSkV=#RYJjIE{Pxg5T%a@1<9;RX|dY zIK_v>J~N;VL9f^WbTOqVZWr#rE#^|UDMW1Ob*bi5nmz>aGFA<J4KbCYDU>?Hp09Td z<rAvJ!aNySa+9loUZc+sDQ2~|HiUQ#uRilgClk2Mn6#RBw3GUMd{KAJ#Fh@<Lxc1V zHBiBOZ$CND!T6uRuE8yaLkZWuREK)d^9>7Kru~S>ObJmntId+(n(Y%vZ-)H}oK<P3 zh7Jh>S1DDiW`oeK^nXR$MRK$MOx{a}g9++TSIp`vg9)-TR?1=iAHvS5O_V@Mwr$(C zZQHhO+um*4wr$(J+qP{R`}TdBGxKtOL48#(l^GeaBEbBQm*<9VxzW|tlaKkaK|R5o z8_2&l35Y=K3lrSXAv^d$6&tT_I?K%_aL5<l=YU8(*~e|?Kd)6&c89OFn)_Z^p+^m+ z+cIfpDNohejnob3+ir)g#7s1TnciZUK?n3Ng+_bp6c+5ct0?Uw?;|4tvBFkb0uR|< zqCeWrz{~$6JG68E01iv$tvaYGeSxu3FZ@$oWhy{2tk$=oltHv_p;Io(Hm*YdR!=ov zy_MSp^cIu{u9Dt8MR^RT^oNNu-YG)Sa5&6ItONlN?=EREewi>j(hM<KB4$8a!%H=K zEdi#Yan9Fnx<3wnu1*0#{RS^V68O@=4AHn{#tRiH%Eki#5#XgrY6fQ2?DN@1n5n)} zs8KsZ7m%i)uc_S@vJY@SX=<xCYQmDtR@wY$P3_(yL^}z!2TZgFZKPc4`I+)QFKW#I z<I1;bdjWuZ@w>a!+q^peo;SZMRrCbr&JJxU)&;f9(#a3CCN4z7yauG@yK9M_Xao_i zzJ#;4*%l4O<Q_T$6I0r1n>m;-2#^fPP^;5%*rej7u)f$+0L9HWK`z9r2fa8MjRn`k zRQl6O=_a7xrVBIs8b1!(;%!gO;3XyxY`lo4{8dHmr0(Wv6US7<YS&{Zr-DhjXf}~E z=}H1E7T3taSD0~ncH3Vi?$!}p?UtiOShEJ2>3yXQe&Mm;QHB+f?E~_zg3f6}Lc_b4 zW06tvx7j<v*v1hV*z>XyOTW6Rdt&<=7(1cSOXYZ_oO3cf@l8F#U|+waqEf76jj;%B zy0%1u!X|f%{$&`C!DX*%5b_DysJI2p`yn^<=5gu$;;T_Uo<p0!>IsJE47@k=O&-=p zMb)5fju?DRf7)iO8UQDHZBlR*Qo!0W?%Iy{@(QlP`D3y65!d(J*Qnp_&D$@M-_3?D zZ42bE+%}S>8ARf)1-O8=WU>(L{M<9Fd3ajx)G43ff)k1~BZ55hY6(+%OJMNSZ7F&Z zc=M|{b!5xd{XxE_>Ls9+8z>Oz=SC}B?4{@f9Kp=mt87f7CO#=$8x}FVoJB9lU=+mQ z1?M+gCxo4)0n6HMyVc{&@%QnDa|2}cGs)Ng-bYQp2?pQkXs!BlCPr$=T0|C#v8ksE zO#K2oGzqh!X`nnHFzSs^o6A?BllckE5ZTF6<^=H#Kj+>2id1Ot2HpOunxS|JV#{|9 z!f?cES#&Afs7ilPS4v`uwDV~6&Ox&0B?Z<=>2+E$DT5ER{In<6_-3N*ctmX&wy)ee zJm(9Ldy%5x0hqj<z224g<;v77DYWJ?h`?d{Jrv#2!nm_rfPlQ1V&ugyMtC~>`Z3iM zk7YI0M6J{~;RMLMnXz;B+5aVqPkNZUNGp=77wH5*-a5A2y^wFU*WQ_|9?T2jMQP-b zuVk%%Z(>vd+FWDHrfz?QC#`aByR?dX)%bG7O4A%}xAs~;*mLLeVh0|)nziCEo))C7 zBS#Rnlc@qxwJJzf{wp#1;N#m0eK=f)?r#?JLKan7l|P3L5pkP<TL%hez&OjW1kfSE zXeE8VTY-+syxq3ys(1r}e?Flj+`4l<ok21q7e+@Zu|GZx;v1ul?v!tZ*m9Mj+C%hs zI=JG_ZKB!Sw#3#MMnkj8yo0>W7+?aFYm>ZA&nuyah&3?^_1kqj=z29TpTCm(tr6?t z-BIEL(t9DO4-`VO&33wATY7D?r8m7I!BXn50E4>nbrpR8b9Q(%%<UjLXHz*>0<|Tt zsOi(5sIxqe848LuV6)Or>??P|R__W7r*164z8cjo+_~3AL!t*ohiSy1*$dRxC;a~& z&DexK$t4f~0Ir4pvrL|uoz}+E$jQ{+%*@i*l-AJ1&Ct%+)a1VmY125rMRjZseYrrV zGTNMsq};zif*R(-HOC8V@)r#`?nSxKLP-fBVMLz5=);xst-4<%o%bN$#5LU;U}8Sa z#4B;GB`#3Z_6X3Le1F^;#|hu&ztr#Nx_^z#jyZRa=PGa6xM29*<EMb!2@`=i5O`pf ztKKuQM?`XO+2T?@do(CircPSeoFjnBSp5sX?epqP$MdZ6W(+!}jx<Rs4VN^aO9k;V z6s-tS5f*F)>dv(so4dYB1b3#%WK1ULW8@5(e})HSMl0eIHVjvldi6afyx+`0MD|v} z2kbot{YNO}Ib;Afx+x42(t!H>4@RXug^a;^e*5eCv(aC?%Az=qpz%tkpoao;ZZsuN z-kk>o0TyBQjOh{2ZEwYjAFUeoD>gjR$H8+c_Ojmfu5_amiUUt&qJ1$R^=|JE7cHLN z9P8X6B)Y=okvq!EEg;!JIfbg@)}n-XuB6?tK);AD|Kt>{-*DO#j%W(WrVr^^L8b+c zbQE4~&Z$2TrQ<oLKqc2iN6#~y#I*VM7`qUM(!dFP3`3Gz*;fRBF3d{23Aqzir<EiU zSYP1^RS7GAMSX)p1TIF`Qt$CW#GK<{cqCV#9I3$;jwCKt98@eUFtKrsUlKj}@TD07 zM2&uY+BOijhq9U7P^JUhFwBl!m#$umfiRN<CP4{%I%kg*+!^k$V1bsDh5FJ^aIp%m zwL#Ou%Pt-LCBRmnhA9KrCdgaD=0Rh~S=D1gMw~j*^d<u_HYh4u(((xGyfZ_a!_e0J zhr$}f#F!_(U*nn`4Cm|+G-X-*ZmWodI0NtXbR|XVTFVM_`Nv#NKJV9^_m>)DSb3xI zBQN}2!o4W~1}}&#>d6j&c%a$n6ef$_Q{v)KWIHoyPS{`~sk;yBr3eBDFGd(~sg$?O zthBs`j^SBvm>%8HOFPmErV^<ObL>P)P7fiQ9LQuZWbA!_Q{|9le<$!o5rKiUX}HWe z<cjiwn5dpnC>CYZ8O}R#TmielJW>mhDOoB+;L8Q@TI-ZLCGVjvzc9*g&e{||R@x)} zJzGrIiFBx10XA)E0OZBLKeE;*E%9w_w#3ft+kPr!X`}0^_W<7#a1tz4C<^pWB9w{n zqD~K>?9LF73lRB{7`=&ef0&-FOdk5GL}ouPjDk@pDzAKj*XF!GhP-<?`m`gk3yBb3 zYq4<Uufq96LiV0OV_tJeS2+#N<t?g}?^r#|E^O|PZ~bR52nA6+n_jqa!eGS@iS+~; z(;gVi4IFYPn^-a}0J+e>hp15`6_x(lsu?i!07WRoYgckJ?w^2NRca#cUwK&!SyYLp zSpF(IQlw<f4KL@uLJ!tvfd=8hvXVUmsqZmw0B$^s+EeTFGm-0_1lky^7>=XTj5m6Q z-#5r_6buA_7$--J++bnMZ_x7zizLYZbWSc+PmwtgOekQXCj^voW-mqJxs+o?H$_#t z0UUhb{?zMT?~$VG#lkwUa?hZwFj`O;_d@FLT(H!$JFdj+sl=ybW$<zn)fYL}V#a}b z1b8fV?b76V@~0N?o~PU-+^DNOVf^4v?mkwf5>5!|vUSy*C+DovgI@MvWTJiI-5)ce zeb>{60~8(#{O2*&zLdSZ04z=ayc2&p&_#4F=4F9Hz)1g~O2I>nd_o39Kkm1;U>?fF z@K^m)KOBIhu;v>1`JMc0%x5omFN`i@^)|*Z{bflkYgaFIVox9$A>?)rKcexk`mUXv zqCYoQeN*_-%0;mC8ahqf$sXgp`9Nc5-!>3qY{0NQFbU+6Jh1b-1fmS-6Z4a7kr9U$ z!!s@GTl-Ip9BIvXY_S_RyFr}I1<eju{?HI?8;}?ADYp^TZC~=d5uxCne3SNB5dMNO zONgq*Vfs~`^`5Pl%uW^~g}R1Lb*=l04r%rTCdBR-@F4e!n?V0C^7=G0UYHWy#I>y* zu5E!`c|zl?a1eZXy7AIGCrlo)fOhv*@r?iU|B1FufZN7us440|FIw5z+?eML$KrZ< zkPn|61h^t%`Ie&fFT~;nn-0w~v<EnxY>;=qINb3qOt{_qm*;gGk6Xi$3zrH4nO6qJ zSd=UaZ-FcBN`$zSk&62oCh-1q3lqr30V?ePSS0e7(wJ(x8W|NqT8K{hbUCP2wq$Ks zX@b%2i3alGp=sy^e0u!mam&xsqN8H!?|w>sVc`T)btBm^PpTKfJCb-1I5{0x>c}9O z^t6SCa_fc%)rtbF``v1EhxS5c;SD(Cl*nRoMtHX9x@Fu|6zz}A9Mmm;8*eX5Uwto3 zTxt=p<_QQcWl55f_&!VtsfN4SkdA}2p7Ek+TVW+yu7*P-N(A#XV5buWS<*Aj-_kGE z29@e_m*}Fuwa1oN(^|}mO?=viXkZ4A^IXa26>zn*z{#6)rkwe(Q)f;t7Lq~z37p}| zZ!NK9II2Rfq`6KFEx#N9h?ziOQSoUKHt`hP>+V#uV(z9EK4J6U4`K{T8P=#5s}FuA zRb<J=jbwTG=&>FGfw3gw^pdD$FWy@_H=_@4BgrlNJXi_h^Xa^}wjy$XYo`JgrG|2j z&5ed&k<skD(A|(*WWq-GKy!@yKFxgh!gf?NtZY0?uQiE7ae3?4-R<*yF%o4<&2J|3 zG$6B_u*{Jql*UZ|89hbq1>6Ag%Xl`Rh5j^v!(OwZAzM225H;uBpZoyKBh9X|<GeV! z6?RXLsGuFBxTQp%B4ckwc?`W;Qv~Fvbklh0rA^SKh_?sJS+|jf@+M_{V8{_<syd!n z-PJ66-mhWd&h8E}=6h~r*f#VAr>}f?UJR=8Y;BL_K^$ED8OU&lo;T>Nv&wjn>7WYL zM*6JoODn4j%r+sO?&F(Zl?6%V5){TECmN9v<Pvw>19u7^T-$Wby}t2FXey<KzI1+) z=BurgsP~&fu7>i^AR#+43o!CULT{ND^DJUi9%3KpFnZ7v$(XV@m*>InbYEjG%8{*L zG;dU;(A=fwuq?7z)q;{QZrW&!U~dG;=y56PC`Y$vgQ&$iv>w5i9n|~L%v<sV&lSUz zM82jWsgZwe$4==XE8(nHDOoA|E`pV;X<jB#Lu<nmni4Ql8_#UcWNi$Kw>fNF-h6Nh zYB^{XTrL2P>v2-QJ~6wt^Nn${8!@T^D2$9U0fDl;od5(`^SHc_-D#_-;iIn8^i;k6 zV-H}+zV_fR>jDu^?oD<EBKHN-4bFOmD3a?_j?_0wRnIT66ifvsS-PN?ty^TD^iEkt zI5`P1M2HOo7X1fB13eQ}y)wT>{i%&QXf43Qb)la&3XNE6G-t+-J;e@3Mz=_A8K;n1 z+)fYA>cyx);?)(ImFr36&s)SNKT49vk55^4x@TP708?#ymFfHepz@Jj2GOblUCB82 ziM?49bbKKAUwAffg`YZL%;%2ex8I_)>JumfEFqCvIVLttYNnXudY;7-DN5ZOZR%9z zKd$iwE~(l_jXE-Jt^yZ0Nmb4=vKTU89m!7(@_(U$+QvuQL*(3fk1wL7p>aE~ga0m9 z#FL36ggH>|xX0LtAS;!@IJHyJ&uN{TIpvGw4<WThL+M0A{oUp+?LT^#W9JB|C-rIl z_w){@IDEIFTn%B{g=jE!bivUSTk0JS2doSA@t4u)9fK$c{CKBB@y(@90BT~Rs1(Wk zhO-01*`;2~f6wm>prYd+F2h-vyUV=XVy-<nNJqVw_}_6+^QNBpIZT%pT~|ruOq<n! z7WZ^ucJ6tkgL(1Q$>rX(sCf-!I`@(01v)rXPAOT5CmJ2!<uzT4KjxScHz_p06J?t1 z@U+4O23g6WJJYZ|u7x3@4lU=l<NuUvGQWwC5tOse+lYPP!*Izwf|?nsfUa7X^B0uF z_S`x`HQfY-D}T}=!xn8%c+4#l9&EGWhw|dfY!dcIax;v;Ay}|Pze@YQed~f5uN~JZ zN?{b?GhbHvWB9|}5zMW2cCB^^*RlMGNj^6Ps{Q}D&;v#Z>W7sc@BEdrS;`4EMY%** ztR9!0XX6E$+=87o2&uqA2U5#iM>;jaY#~+sc3oL^QBh0JhV-kmpcWq!(m?0AEt7D# zoEmosQWLF<lD$N*<kF#5utF<Y%~L*aui>d{T=2QoTK3^j3i|ZvH|^e1uh10-K`V0t zWz@K+y+o*aFQbmHT2@jgT5@qpL$p*AS68N=>^Ul-GV|<kZh3F%zA0h0L1k1YTDWef zCtlG_*NLzYhpCS?5-+*;6SayVb~2j_!I%WS4Z@ch39)q`2^Y4GpP$%GzQO&0eXmAm zg!_4GZYg4Phb%q{I7qYub2+x9g|i4Z6C`eXP&i^y`lFDfBOX0xg8e<4XwB@Q5weQ2 zBj_<rQJ|e8KfJN%5Nh6Oztd!buBWthK(`<mNuIAuiJL4CttfA>%q)b#2nZwx|JmPH z&0AD78L`LgS)?aRrWZ~$umgB@@uPNl^SI(hhl5#Gc^!twAkYs7x2Be9;&bVpt_91x zV_S3B*J{kBIG9YR)n8DA_z){4V~jQadd32rpfl{qxqCrqN~q(eiMpY*URkJE5aVr` zx@2ubPP7#A5#GeE!lWb?VLNhx)oaC~&s3WpRJ;Mg*OX0!{Qa$)j-n?$TV*Ud45S^Q zYs}*;Z@9QciS?dm?%mZjv$(B0_^Nyfdd?O7;|b=G?K`;Km54Cn6+kHN%WoE3<#=7; zISapbM@v(K)rp|yzzXh)1=3c_9gX~sJ?b%p;YF%dpCJ*}UZR^>0<=&NKcxBIj^<<7 z3YFD=Ota7E4EMUOb_n&O)*+t{JpSn4v*&yp6fb!wS#d0S259#R`S}a=EFc-#QXYX0 zG;B#SravGQt&DlibOFX0W3!8Au5a)5joSJVTQUf^>7Df$Ry0k2osWsSs|m|%J<UnA zw0#&|G_jrNF`jb@pZDoq_rS3v_~1m;7+wGj0SiFA^y{#MnWk@9rVEZ7hi?MlG5oZa zF>dblxf)@7O*|=*iQ2E|6X0H3lqioBfZp7~!w*JU{g_5E%Yb|7-B}iXz@j?NeZZo7 z@|}P7nA}Sbk2qnjRpb1Nq3G=tB7Oh`<lg;|H~UCC6~3V&&u<G{bJ^+yR$S#MN{$-8 z%+2`mm>pjbsI#W(iNjZsgWU*`p0Ecgq5cJz-z$T}(YbGHCoA7@lyv4II${^47!SFj z#?IfEbdgdxP_E|~IYy95n2y#|doC&iqa`*6(MxR0Ut<mDn>R)YW;ni4ccoi}GF%}P zoF?he#Uhi1=cJ^C#^fI|Hs=6?@echS|MB^?5KYlYOi2xz5wsXsMj^;kI1E~&u2gRz z)fwnLYrn3txdh~J0E{{Dr7(O?>ugkdsXgVG?mT7JO)@z7gAB0e)rM(?EvkZ<9NUM) zV3hPPO&7D)<io#03+w)>`HdQum`#3BBPMi+6aFxu0}rV_to?j}FgVtJOqd5;nDy0v zeV-=ik$u<#JNSxr$sm{R4wmv_??vAeL$Pa+-^`JV&-~LMWS}_c5XTFXL)cp${IloZ z|EU4glCpgGiv|GT!2tk(_dmxYCR$TFb4xqZ{}d|zi#}1Uz3sLsme@O{{50N$sYEC^ znLA}hIOc3JkvTuy`kMgP;ouWMwnz*IU<m{hVQigUuk!Wh{S1C2`BZg13`08Ra8^T4 zgm{v7{Vk5tS7pZ!pHvz-*h_dj{qtQHuF@a_jS8a4RhO(svw(rGHy7sobC<m<p8Kk@ z22M2Zm{e7W_V4TVA0j$=*#TEdp&lh7=BB<(88vB=3??$<;?$(n*xT-FH#q#Te#;ck ztp@sPW#tHwD0Kpw5E?dPx~o-Y<C1C(Qp21ooi*c!qWXjgPhW&88zQ<_O(k>=+Ve0s zKW9$(s{P&N+e27{g6mbBXpLs`d+F%gW6Ydtw^C`XNe-H{p&EvFX2~i>UkcZQmtsmr zxT{v3h?NVOWaMbM)LsoL1`{MPy3}xIcvhv7Xm<ugkt8ghmXYlr7NX3AD>F8QYnoQ* zIFXVptq7hPDqT#A615Q>Rq3kYNsY9>k1wyYkp_QuA4?BNSR2G<W76k~qg>YKv|I?E zjHKTa(#t}s+4#70jcPJ76Q>j$h#ttQlt1VH8d~@x)~TeiWKM~|QKFp7qer@MlqS%r zu*R6CK0`(980DCURNN<nMrb`dd?qcf&p#Q!S;m#dUFD5A7uZ=SuN#16!KxPM!-8rP z!o=LMYmyeqIfL>V{)&&XI_#)sYQ;<mla0d<J_`$;%G|8;xoI0q%54eF>qR1_Rue+T zs-ZFe80`&*tz$ew=v^AJ)npYF()2PSR5)FdFGU*qD0YC$pa@3_mCRa9kA$LQ34%h& zNVP6>AQv=9N!nI5OIH_5QO<>?4B4x6)YQekw$izDZA7#_jmzxK-}+c6S<|axM$u~3 zJ(_E}4$;kx5++IvpRq)dDE^$wd(=>BY1Sd8(9}19ZG^8oS@6!$Y0gdJ(~Yh?(Pesu z;)$c1=e{bD!`hT2KU1wmpv;S?SXsHFUZjj(YHf!gMB|GphJ<+Cks%KG&DZWE{{<NU z-#p{IrtQw=?^Xi?gEmi-e$F&u&x7lzsz@R*s0f5de-oCySZ*?7^s#3X#Q_ml<?#@E zK>;D3afRcxXUmGfr%p$@hSx#D+BugwzzA1M{2s=9AE6lRNA#a8jtD%#>y}ml<-o*U zyuA0WvpJbD8^M(ghhHNKm<g!_3JA!}p6`<<2l}<>g`yDi8E`Kby8_yj22KFCCBGf{ zK73%xFLi4Iee!YNGYW@hrK8D06&*iO<0c5hyt-XQouPuo47&@lwaDRsyz@Xz0|OqG zrar<P<ThRMIbz?tEX<0`%@@UJ$I;6z2^o1KhT~=WNxX6)T$4Qm_aw7h?i}%+tN`}~ zvDLZK(E7L-i;E_OIj;Bjr&i*{6!{hE>;wZp!TH~v*I5Zw%04n(h7x4At0#P}p_1lS zq;$*8+x=N_?XzFBB&1D+Plg}S!RdL&E^>ivC)h=gKsC(Al@y~z3$b`LVnJIh)4q8) zQZkJyW4Pw_Wm*TY6JzrNaYuiCFOaxnvq}Ol7F}O^>p``4AHNXV3E!QYRrk*yDgGQM z20fsDIl7?~3K$Tk&S#}F-rY%g1V`n!?Dg%GUdj#!o%JVJ7;U#1QjCqqgAoEwg%y|5 z3f+Zu9PI&swrwT}2Uevt4I%U|g9R3HnM@?oC%`MPW=9B*0xGXjue3PLX%{fa#_O2) z?u53)%u^?zyv%v*ef`&orL79*`mRNIhb9J~`pfkji9h3PBg;h}lOhfEL~cgml#g|* zhSJDfu9^;C9dMdLq=&x^X_35)L3WI2V<MIxVP5nDxZ(8(laIh6wUxFBol=!Z_~33p zKm2#4fz{F$lb{{x$R}0D>`@qD3TZ;TPPQF|G(d4AVFi}I3RX^dg9vKHIUhX8`%<qi zmO&%59v4p&OJ}pFE!@qE#AHUrEZ@|;6Rt&<CgT%I%<4oz{`@UY-0`kja0+8Yl~+X) z0I9x!fGg=N0b9*;_k9b8$M*zk$gA>v&<&dF7pZ?`kkeitKhe*V2-ezvD0oQ_4!amN zKQXfY)-;rO>d;nyyE1$KHorJwE_0xfh7I+l3XARW11&K`uWx|2speR6;sj^3nH3^5 zj1aUffWgP*!G<UvceZ+H4$(`%IF+omRt66*;sP!o)K81ySXTDiVUuV`Lz<%by#pC0 zSYPgfhlQGjNOZc!)`dJpv#F^nF%&4EH_1|zFof2rloU6LEA~c!RunfPuK0#S3T~fi z1nY)!Vf9qTXd9~i3C!?UZHo@gwm)*?A2GwvI9T?$nUZ3kfJLt7$l4=aiipt)(GgLN z46T}F3|$CkFylyPW3<h@!5B<iD5NnUPDT$&$|R5ve~j#&C%6I4zn6on^~=-KvNH3r zS>ebE1GdU`wADr+dGBDfOVvoqt~qUFnyxV6k3;V`UkWz9K9JWO+!RuahGX=m0lbgB zSY{iNg|>|;J*CHtbFsIiI0kJNb`~MyO3XErS2re)R?!N{&lglYd(t?7BvaByKZwmG zl*Vpu7(Ih&9<aus&SZ6F=_S~<<#AT<>+gX~f;aZwH%3|{5Q=a=oG%wcbF{MLpC1;B z&!nVta`L1&TQ{fe8!|EW-5<}dtegb0Y+4b)@&*GTInz*!cs!5+%q3mI*ti$4paN-I zZ;+mX?nmE$-+1@E^VT6O{aegRA}|2PYgcy#bJmgW_1uF&!awxjV8E5#OT7frbqhG> zf;H4fHaqiy3N6l|5tlPt@R!-thdBFoDJBTwyyNmybB!#0(1izqp$O0(Oo@B+E0XKa zN(#`)1D*B$%MVPY@G`M5fT)~^{DXG=(=c<F3gs}AOEr+s6w8ABdA(sZAOqC8d5J%k zP#hbIG8I)ut>Ir(VcN)Uuunp)iIZ$KnA<=N4bT0?1{yIPm)#(COH}lbsepEG^pb-9 z`@6|GGKVq~K*`aO(Bvs}V^@B*?v=a27V^eNCGr!87FYkO7mb=%i;oY?;=r)VV`bmu zVu0_jG?UCC!Zjk-X^`yo`wad#&NgVj$^@~qKq`K%Nrjl~e*R_-7d$j+A;&DN0BC=n z*o^p56_P6`A&dVp_bh$IL9=}E2T_RBUbuc#RU>WA+rFR#KSzWFP-X($<ZZmlU6H@g z^KWGVu?KBZz0J`2ycDaRFY;P_4<l)Eo(kASfyr;C-0f;Vim3=>7@xChhIyPy38)4w z_u}(9#9S{nsQzs}4gesn&o$63@xVj^^qQHrMUw^t(+Gtn_z^}?&ns`=3kaSLWoO41 zFK)nC>Ti!#Qbc8FV9n~&&+W@BH6uOA$OUhGy?GCU_r^uv8Um-Xy8`vz%QUd@PU^AF zV13#%`0#j`-YJ8`c|d+Y{PT9un0y%+!(RD}>X|xe6SSvsy7v!~05>?<$^d1jL6P}O zQFX+kE7gHM47awA){O%M(-s&iRBPbO=K^hELwK4KI*|Y99gR{3kk(*49ZDY_xau1{ z*^qjLKA3m~Jm?P-xG#~?^HLa5IoSeNK*^6Xf=EXFx_Y*R!yh>``1Km%1cR#h+y-!E zrBP9~zeN2`|B^6|p43$>Q^zA7z+8$-$z4eW4=u+DZ#u9FLTsS9)ilFYI7H;zqPhr| z)OLOtHmnOWRqq2_?kN%QuO#wbG}OTjX6&_6-BJQe>dnZnjoQjQnw;5h3>v3j!7Qz# zre+@--QKGiFe642_P(rKY7Y3jy>Ihhlizwl3LMw$*4n)mD@!^e+aoQjawLYna`ycR zJ=Ulpe&86oR9q1dDiIUhK;l~a`(cKW^oW{z^+YT6Hab56b})1Fxp85c<y-S>s@Ixa zwFXs8@}sdoKf_->Ka-=JznM1v9wN}ucR;|SN!4dgzz$aZ-X~pXqdMi^Jokbp(K**T zXs_(913_(wB7AL;-Qa>?fwcM1h}D(F96Xu=9n_^#LVv9kjcd)cqEF_JrSm+VHH*RV zq;gg56C@TvR3kde)+N>6FzFdJkoVClp=2IU+Hf<=F3={quk&g&<K!xWCPpTs2P?DU ztB{qL31%`uoOZ35zwmRpKcA=28wLdX<Xk8E?1QV4v|p8a5PiJ^f2z1<H3~oTeJB#{ z?3sr8JP5w9vo1HK4+H9h?r#jY1WYv_>G8_DYx4c_SxyP+ooVnS$ffu7vx5I@Z}BTA zO+25HU=94mxJozOUT?G`z|bnSk}6&_3P^9?9)6TlxrX}u#TS^mh>3OBE@Xj>YqSzF zyJDtHkFA*%plQ#%xaC1=F%RkMhV(@EhOCXEX4m~d#QO6@Ls8Ra=WHNI|6Z*3sfH{> z-B!i4BFl5d9bD}N$RE`W62o(njbq4BHc|0_W&+`H1iK)v=RJa%T4XpA=bUVR(ko%i zjPnJ`PvCUog*u%Ck2}uH>~fm@(FKN3p7^eO>)3)I<fl5JZ%G!*#+j)#sr0*>!Bqh{ z&uTntRJhr2xMOX5m9lbXEoTA%eFNHlLKk;D<;*Z)sdU%Cu#vAR<-HS!o6Vw2anB=K zO=4`K%F|NXIFtP!ih|4&VD-S0tMGWO`1uav;_^gyMdOIEjuPRk=@LiaZEFzWnYZJ( zF**`-liOdzj(9nJy+O1>vh~m!tfNm=gn%W)gA{eoFPRTri++sN7q=U4Dt)dqfn@c6 zkMUOTUTY4o<+}3^?8*M6<|m??99VpPHGo-BZ8yR#4(;*Nw(F|^xL6su>!97p=XzqM z`P;o$h`%y}IG+)8A=?K>Gsn#Y-~k(M-70o3<y`K|(->*c5FRyh&dBX;Qd<y8F|@QF z$8H*)l2bf<{4lQgia|v^tuCibaS^ttJNDK5M<bw901DLs9GZ%0YbthhrYQ{oK`AeA ztd1C#BbTrU&}E_ECklI``KU9dK4AoPhz?Mx4e6y7*Xn!rDDiPMOYX)a5D0i27_&|y zTB4l3k!$#tA_>-IQDvA8O+D0}o@f>2Mi<&%l5I)O+2LjBTg*WasS|-X=Y0M=`yPC3 zLEtn^g0vHnl*^@@XCW_fc=h&q&C@`vw*M&gNgbPS!|Ak4G;DE~c5VcYV;d{x5s3ng zi(gH7I`~)(Y|sV#%Mfp>Uqu#`8n^z>B!3@hXCH9lpE3BZw~W}El)v}<blRts`hMvU z<|(a50%EpNf<Lz!RpA@WC6nr*O)qv8c4rXb2Zy+y9HqtKQ~0fTRg|PsdB}scEz@I8 zO*nEBO>p$XFjYFpj4~Sp()*3##VjfHnzhHDYQjI;nuw!|!=q={6=D1SwI%*EEo0<K zpA6rGvt76{+VtffKXEJYNFpjm)}b~ZOkz^HB_Go66)CqZ2btP|CWASW|E&4@qSS1( z&;M2RXh)T5Da8@m?`F&OiKfyHkl!v2g|MiU*0?n3AR0!@Op|(0=B;)*Ng<3|Vd4Ub zVhW$*Neen0?=B3@RT=bI%C0jd$ZOxU|DlgP^GDYfR3)!(WnBiN8Zk_u`U5l%fs;LZ za;0flV#VcBd8utSp`-wrQ%U2mVsb61Et?9{97>#k+Cbk|k22Pne_WH!0{-1Ka9x5% z)Y<MDCmODuVk(^=3Dj!tRJon$F?1|v7V9HpGgZ))VID>D1@*NJo@#8{C~HK4k|^Ml zbz{!^S`%iMa7N%j2RZY2_j66K+oO7g+4-)=4WSNrlB2EM;gd$zYI(J)FVVx5#X%CJ zaUNLvGws^*VZ^e7WF!znBkaEdGg_uheUxO?=IygZMAFAnsaS2vbuyPPW+Xe17oYVg zL3M_lYZE+U;Hpwhi)5ZpGW;5S^z`#^RcBte<5Tc<5W~zxwH#GhaL7lOJztO~6wS`E z^J-47x0Y#f`nw1S=?1GXM^__&<k@SmTm_Qy*+a*}V`$j+i@ld-WUN=anN@mRD$uS* z=wot4`;>&CM^b^8@NH9H!LbQjH;~!Hr3-OI!9-Mr&=rbY-IMCdp}IVbqsyFltt-Ix z9bH-pkauP~?P{uMx4A@AJGQ}fDbr#F-_VF@cX))$vKAC=>wsBGW2gj#ySYfnN+6OC zqdWzdFV~vMI;x(z&eP`D`~f5;#IxSiRt=@Ag5P!&DUZiYub(TZ`Tf;1US0L0o(YD# z@Sa2RLnJ``DJ6a{)a~in8kfhZfaR3mSYGY$kvIad{>)JRoNY~ZP8WdCc=LJG71^7i zu_<jnmUfSGZ)<s2Ea>6Vj``hn(_FBg_@v;pwYMD$qT9!EkR7cTY*UgdoeiN+35$%z z3u2})-ZAqyMAQ&o=clqCF(fgO(cj*dRxw*a-T8u@!gBw`o-wqvsF>6y<Aagk;CUjL zXptG#KK!61@T0_6`Wb@P7t}7W2{$o06}q4YNaFb$7ly<4s7O-fonsF+yGo6bJVPZ^ zov_$4pre8f(Ke2@9f8ZA?TimR8PHz{?*Z;Y6~4EZi?Pn$Y*YKS_2dUE^JQwsnbrj& zR%sFaNOIef^NP9!_rK7RUE~K4CCDU6AaF92L@%$XB=d&`1F68X6RIy=@40Y`xT+sQ z+&1WYLe^8t45@zJ6lV2w3Z^k?15F<i%q+Q7`E_aWr*F;wi)X-A5Wd6f5N_}6A^`Ip zEOG*-I{5&kOUTx<m&jY$fpaG=m6op7EFjm-r)&O&=2|YF05&6lpJ!+(IEdTG3|quL z%PkuTlDcqnc@lzWIcZib=t1A2S`P3YwS-o{rN}B;a%Qh1K^G}ZnYfO<cgMDl3J+WN z9UyFMU>R3JLaGjFg-NR#h&Bw#anC0v>Wi>7`nO0`37WPkCm3s7o5T_ZPay~$tr=>= zLv8}=m>;P`mS3@RpP<;Jhk13$4cJOJhb6G#G#nIbbagGcuoqTqXXtD(6G@^&cUt1A zXTH5_y|S@&_gN1hz8MH6L<)F9qNh7?s&6x#wcpA$lO|2RXrt>Uv=(4s-Az+Nr{JZ` zGRvmT+wIQ42L6cE(4VV3(T8o}?1a2z+{BL2y={0FtQ7ISaT4$DcsN~#9`9YK^Xquq zUqO5-uc)|_VEB>mM9~{lq@H23y(Hu0+0C7sTrMPbb&R+ocTUNE`>;c|lW%txZGHn} zXZN`^?dreomfcb%Z>x)rw}b>7kO1>_tm14YwH1k8tHMyNJ^&gX^-3afx<?HQIi5De z``jat9UMiibaplxq)-qgOY|r$M|ou(=x;U)%-h13h1^UbVhitH0&X4{Vc7B%lxA7( zuB5%d+Y$|cvRsHBv#XCc?vCw`H@n`gLOFA_QxL3)(d??SQ%mk{_)D|VZriN3$>z5w zL3#$)Tw8c$YAKCcJ);vdE`MprNV}_=Rc#WyErOnCqXdc<K9FoX+fjRA`z!A}2FcC4 z)pAHrIxWtUl`1px3X&<zQ$o2V&eCnl?M>W7v%8~zoSli!3-*NjTRR0no&j^|&bQgo zEVFMHyZ2!ag8538ap^}%cana!^qfeZ^1~2ro*2hv4#Ks{9`*J(+4apBoqZ}Bt^C;> zjjzIuuvty>X|*m$>~a2#;;$ZVtD9l$nZHLh2<VdpMd&ODuo3y}E3MH57eEOAj;sQ{ z$7lHgkKjUxVI%_nW3SEOvLtx0J@4Hj(pbhcu9DF2m5E2K!B+ua!%%9>Zn!CrO(?5D zbCENX)<Ve)sb1Dg4ofTYs9?Z8P$-4X>*LIq#;wm&3;Bcr*=|WaDRp`^(e-A+A)84^ z+5u?@)8CKZ=nBHN8ZB?r*A@_1DOH%zTGdQ;el!<MZwZWjJp?sR(BUW|QuXKmDCG3) z5)UlD3FC7t007kgQOMb8-3@<H-nNEL|1D!YjcIMa$??$T2f7aslGAidvVGun(JHXX z!xm+ROtO9hD5|)KbX_hT1trOO-vW9gzV+KA==7M6=Z4#S=G)GUpiN{R`k1?KBCeA{ zh5Gpi_5Y_UhDY`r-h{nO(HDLw@gk_{(M?f$5Kg5{TXZ6P2v7e)8i%OA>ZK223sE_8 zg7u?mr<NqHL{xN6M9CBbR6+5ZWKP@Zg=d7+a74Pf4M!w!MbcGko@5}o)6x>`LD;LI zR?Ny0y56W8fkp4#XGm6oD!2<1wy?Q-GR9Gr`SabGuXx_-f4MQC^y%T`z;HR$g9v$< zxTMZfQ<rIpmN;_aoet~X*auUZMxGWjtXP<RP_dRo7H)4Z60L8?6ee(tBx${jO?i3O zJE&@6U5+rNO{GfE3cwo)^ooV{4wf96J5)PJQ7Zuo>Vtq;ry?Z;;w9q_2GR-u3l}iO z!|YI>P6MUJOg{jfaS!vGM~v?L9ZXrMB+`xe(d!P&+>CoYi<dqm#a)<P+M+ssvpB+@ z+w0nwd=mUuJz2n@306k0%7_A&O_O&_X^-f2N%`%Hju~D8t+!COEu5~|gj-ZQOO2oG z`~16-qo=P*fz!Y9C#N5;cye7<FYw`~SgTp)fD!7U8GzR{!cnHq4^cBDSYNMgp)ach z5zy1i6`f6)q^J=j_el<mpgwjhNG`;u4^xDJA9H{^Q7pI9FJ#J)q1gn+{ymXL>Z3}L zPLvlHIUm;o4n0bfJ{Ab4pDncD!Hcp65Xk}AjV;P?$Asun`4(oaIT60s$`Trgqf{4; zAN<g{DAFcy<MzN$Sym}8)LJr>UE&i3;*<Cqz#sz!V;%<C@rT2{y1tK@@{5tHA3B4E ze$H&;V*K>i(H1Fp)TZQs7;uo1QRf5U6XOZt%bR7$6Cvx78P-nu(c$%F9Bu}hiNAYc z><Oo@3C1a~6#&~UMKJ6HxsPy?1`^U57Z(PS8)o!T{`)QD^e=t8GrD0I-2sUAORMsI zC>a>>)HdZKHBHUBoy5#3;WCBdNT`7)d?+f0;}Qez5Y|RFGaIN<Z17oR3J<^eE{(jd zACd*5u#cU~<3WTXV+TeHWkXt|&x;G?i`^nY9}||7^fmrRd7PH-8s}JD3-tToux;79 z+ziRcDoPH4p8~Y4)WHg9R}4Oj^`t-#7H`YsP(h(w;5&_RW6s1+&)LhIo%tA>H7FAb z-Vhg`{_#D=JiLi#ve}5h@5ZK=36BcM{=r!`c$X_5C09q_A~F1=bU~hMj6&a^3BkTN zfCQUt&j;!C3e-rnZ{VF$w;0K#hXIp1s>)Cy<5MvQah|z&-Rq@enlMgk`@_nw&gE{| ztLbosbIb#erX!w!>?`0FI)d=F;3WVL13$k3$8BC1*9SrI34JcPcqKU~Gj!!rB=F_t zVQM$p9wpP5c^nHASwVp*$j<5`E+%&{xd6#mywt*B7SqzY1>h0!n2jt5m)}5Fx^uad z_%OA2w-jhYnk~^MsvFau*6O-M5_r)lBv)J@jX4mlY>3X@hOD~t6L2}~2V5!5gAR(= zX6^E*&dV}Ls!SV5v4H0yHh&3~6vSDzO(`d`lT@UW6Rj<2<#f}AWL1lKxaTs}ZF{L2 ze@aHzEl9k3i7+&_;r(EYz0r>=>{maJt*0(7lU9-J6KV^o8$citqi>8)LTSeN{zi1c zyU;V-pjXo6G;mgsenVPbL#%ue7+6ij2!Oa#djA`^iFOg2i01ydZzNEuped6$4aGJS z7HXBpnlp`bf2RBV#QO+44~*vBjEOO1K_EuI;?<fbtbc!?xp?CL#ObnOz{*8!(sMEl zD`)Yl{e>{M@i&!@EV&Nc$awo3Kb1ILi&|dGU&4cc7hyh3Ng&aNCGO3W;2_DQN152r z*q)C@bpwA1R(re1)zn5k(B+rPTOJR=m9A~XD2dfKI%_seI-&IwMuz)`Z5x7y4JA&& zI7n`+>m14jAJ)=k8rtCy*w)4!z>W=HaPa3}qtn@apQYhV26np|$(NWmw-wK4&_33b zn0k+#KY-Bg#;53c$wU037vqwAlVmuI^!0_jeAmw$(~R%=4<Jj)^C}w_wy#B^$(Fet z-rD`jznsHbcD~)?4hQ>^sJrvRB`Eqi9o&6A0EkD_o9pmh+!t<STL5>WA0jf8M*nvF zsO@cbF|+hAcXv+Y--Q?_l-e`V!vC#aA?n8fNgNy&PSLVHolHO1aSw37#CQDr;P!Me zE<c#VD|#*d-&rAHe)H5oiF9F7emk)v5}MWNaIN?RsC{p!_-=DCtLFSz^hP(K(b>Ha z_+(NE;35Ytj#H@J7`W!KjH&Xm2fj_htQz)M5kW?Z`|~_xRs%Dq<nSa-&?ILuKk1V1 z4G)uo^7(9zPUZde+l!wM_Sij!7P<vm4c1aH1<S8v5OBKALWIBIrq&;(72jb(b-;#E zJHzM&>7sdXF1qm0D}O~!G0_5K2EhG}YSj<r1_q;fPJ|T^ay8W_fVgNiKSgI9kd>NF z-yjaCH!g^3&yF$VvCg){!eAin*6JcT4vYDoBQ7RTE>H?PLVdOHpHx5*@))pIS8l{l z$^o>#Mu6KPO!BnRvL^GDFUT3T^D3bOh)7(%=ss#D2o-ec!8}4`nQaVUY^Pi1hY0$q z`l?a1=w!^mt*715Xv_k~@c4khF&8nZoI~<HnFZS%D@$;(gvL;h+YyP__5}D0&*){7 zoLo_OFfQq!ycevOy^{PPgyBL_U{Dpy2quA6P60;BSWM3ZLT(S``AwL;3p==fd=El< z4%LBJ&WOWly<OG291$yiFeGdncaJkLBMM<nWbRTYIGF#3s*tKud3EX7riF)UXt3}! zChkdXP2z(0IDEQqZo-Yj$9=bX1;waA$xd+0-^ZF;CJM^97*Eipy^qz0#XEUsE0&SR zd>cIGg**8#q9_?<3tu?uyavnhx0OfE51s@0Z|PY8?sFN00#B+`rPFmwMKwPlltFI^ z)dEj1gzLb>tJ7Lk8ByS#ii*f7+kxTqkLfq#E%7C3S8c(YfU}bP8mNxsj!{T%0`X)C zid&KSThOg8-tL51dzgj$%+s~^dG-rK&-NL#!VKQFAX`Adb(m8EoPPQ@DQ!c%tEpMj zOf|LADsZq(MHeVO<~Er!!@FUBV%e0OD9mE~e8^L6ISqSfb;%ybA0f2%@CjPbIQp%a zf_$#u7akBX<SE`)3KBLc&J<QP3Zz}YITA7t{Opb08(g95i*x%O0j=}P^J9tFZk*3D z0zkvc_|bcM-u5Kx9D5WEu(G-`mf#{S%#U^JXifmOJ~b*mZ+qT=%JP2%Lc6P*t53h< zJ(^P52Z#hsedb&s(yU7F_<u<ezssaWnfmF${lL!7&KZDLf`hLQ*6pnzU(c8$w?+76 zWzf!#ec>!-Yt{8s@!Z9hhkmCCPwwiaV`-}K7Phv7;<4LC@(2F^j*&2mNpR`?a+(WR z|C8{{!bodmX=n0FcXp<=`fo)tE!x`7n_{VcV|8baR_euS#iMd&?4s*(7MiUo3pFGg z6<TRg00E^C&?3g2fD<y=mA>1r-_W09Cuvvia_)yhN=jyKh2W@=PsdLkd3Er8i0UEH zBkuAeyFZtIhBKs!(@Q)PPgcdA24~4o8e*l^;RDkSq|6=#s~?!8oHN3Cs1^d)4&=l| z30vazp-dSRgSCEIq*|`7r&LobbNnd0q>KX?bkU^J0`a3d)TH-AgMAgLcKqJf!~aUy zh6>(Ivmew%HWFZ%XLMfnmfq(ds~PpUCoLj3(C_Fk{9BaRCApw014OUe4QIhe(HdDu z-(!mr>4+Z`k){;N&_Bev@%I**Y+T@+uQN@KxC1&W5ewxo(4tdO1>2`lzMob(-p|z# zGwseFXx=%Ve|tVh#6X~pk}(=1_;UQmtJuMxL#C8DqB2RMDO>hQaNHCv-2@+tnIgXa z#6|n+YWnxQTB>RIidp#OI&sL9$?M~aKB#Q>N8IAw5w%JPY~bd`k#A!gHZTAJ9$mz; zg~#di{ak8T(}ZT5h^dMPO*(>@L@YSUDMNNilxYleQ0j9w0AYWDvDU8#0RQhuBwwbe z>H@a{BLX@mihxXQaOS<H2>rBl#VKO?R+s~8dfL=%6q9O4!Ft*pxaD8r^4Kq>jcq+k zS8%xfb)KG5!7!=q;zYi0oDvo`NlU;pxx`G3vPqV(Y$U~Fj}3CJQPm2tRi?B|{oo-J z4I#s6q7XhZ1Yl@#`qaYkgRC<xrh6@<B59>ZiLwQVlqiw~_qince?Qx~|8UMpeJ2;X zKZNMPRy{?7H^IMB@dGB`+kTB?3tOYjpxX1hKc9a_7H<uOO~`h`2rz`W>!iqAl3YrU z-+gjNl|(#=B28+ch-FOI*EEV$-M&tTy0c{HKyn4H3w9-&u>y<PBd3fNP@CW9m(LAy zt&}4RGfgWr{gLfd^_CidW}zBIiOrz2oFGaFnHaSDr6dI8$8#C?AVm_#JyinCUlJr> zk(&xUtKCF~RUxsp5<UCvA}fP6N293v0BP$g-*JYmEuk#JxGP$CC4izf00}SW&%>a# zZp=&Ui}8}7tD!ouQbdcfj0l;Ti1mQ5Az9m)#}(+#o*FRpaTZtYhfP$+smm1=SFX-P zt)W21$on*C1Bal8>RAG~og&T;EHn+xJ*kj6648yy<`lkg?^Uk^1WTse;ehyiTOk|~ z14gT!&?)^uwLzaqDbefo64WO%mcYLp2p2C!*Zn@S^daTo2D^Wsr7JhK8<Y7A*BZmY znJE)XhmZg4=JT+Yf8Q1t`zCEZe6qeDRe(8~)2$-v%U5vTKdo+ISdlvYDl;?bo#DvU z>*MX?#`-;5H2IjYdt~sLzAA1%dMi&>V~CsJ<`d&F;Npkc7m0l~C~;*%CFM43e;J3| zVq%_}Mvg(y20LNu3Esjh$JB9LlYq%nw!jTO_y-44*@`J6W*{H^#7KZ3lwJQOFMgax z%g{#2k}X;JP?OScv$C+I%`K5iqMY9)SUN6kY7k$mySNM!CoF;yiQY`gl<Ds^G|$wT zq4xZ`iQWLXuC?m%#0N???C2khDNr92+ipIARvCob4YYH${^3cAzSI-&Sps{b_~cxP zV2ddYZ%*~f?$={n7mzrvN+GU}2?^{W9Asw`%<T}nqG~N~5ZTD|lIt*06}lBXOiXpJ zAnd3{nwdwNWxw8-S~~!@Cq)y?q0ba+ZNuQ$9mclsurww$E>Ao0tBwTv>$&t<>7uI% zem|$H=!GkuKB}TdrWT;NN!wvpCP4A5ufWIDc^d6BP)Ww1ANw;<MBbIvm?d7?j?Tcq znWeO6O4H8daICVEu-2lA9*8o9ZHUuwWAGwsrVYI*72}W@$`3sry7h~Ba{0Af1yRYY zA1bqigj?5@>;%08+{Ei;F-UXE<FSP^iH9~munm#t4%lotCcc)-{h`#Df0~;vPNTX1 zUcl|noEG#hdyp%!_n4PoeuZ)}diIddP^N3*8q{Svhm-NRYj*mRVn((7pO@DV96k7v znfvvy`-%I@^Y@XXw=WxW`74=N`M8-~`Lo0Ix1sw@#=gCh7k78cdlm*Z63%t0En1Tq z>~M5?J(p4Vk(=JMz08|`#8m{wLK*k&bS`f<4_{9fUT%CDy_|pkymV^Lj7yA3P;#rS z98ibtz>BA;6BMo^XC%uG(kY&@oU4zgq^<w6d5MXAZE|5-;(IKNq{y#MTQFZz;Oa>! zLi7~`_&g!@eM2D_LR~>(he0(*F~;R@6Wy^`^8JT&D!5jSE~J}j+vdt$xXSeYLw;G* z@XCYU(e2D<KvJ#6f9nyLH#OOd(S2kbEta-G5aTBo!t&J6BG^<~vs%y85p7vfS`OnC zpx*aKw3T7Es!yxU$uy?MPfAQ(`Ellxz$upgI$t*O3cd0R5ur4EP!seRf4TIPp+aGK zjKY-tiJ;*28DFmKRXz@J^Q{4#lFydAk3Zh#_Ay;bFI#8FxXDU<P3CfL>9R@MWD{o9 zCAIQyRQ}Ojb5t1762)SM#O8kYA4+84oqzuZxN4{(Bpe!%Br8~dS90X!$b>5LQ%tzo zg&N#QmWml<nN+A6yphjeI7GAxn3~rQn^G1K*43nahqqw1kVzm$tp{h6;*MmAVcY9h zDZ0W<70X)}C#dIg!3=i6y#Q(;C>|*y3=|9mGk*~=#*$SwZ@dB~y@)wxHkd*lbyD?c zo>fH1Oo7m#Vhq3j4q_o=D+)=4fsWxqiD>5Jo31W%sM_{{l*}LpS#4w~x;CK=;uBOT z(<sg2J=S4t3wIu>-Fqq1wuRKwwRKyYZ*JbEM?q3R)w`a(RgvX0phKe-<4B4XC`Knq zb8@Gc_+Yi-a7+}2;`W5b{!+m=|E!S!rzA2rSFV^a2n`17w}JX&lY*qA5F2wXg1|L8 zvxSJjl@}bYd|#>J&>{j4E|fY5$wz=)3z3lAfEHH-a%g@e-4L}l$_AeysL+9?g7!hZ zd}j5O2^#9mlrC_Ln8xzKJ<1BvaVQ+frr~IRW^^FxYEKmqpWqc7&D}j5E8H{B2Il;~ zgTY!;1vA%M$dQ}|9m*-|_+Yz%O7#<sT1^qkwr9nVY6;m8B0Ra!(q!i6X7I=kHVRm5 z?+D=xN|Cx*a0BVk)h(O&Os(PnkFaxE5-nJ;ZQ0mm+qP}nwr$(CZQHhO+qP?0_3npz zB5uE&$hY+Y8L{TfImS~yBZN7>G1-~mBfqIdczipmlo$P`?|I2Ce?0vJSWq#Y4g@1a zY@xe>i066!?)=BD&TCsi3nOPE&pF%{i17p?S?CMy0HKblWH_D4k<z~*B@W0h!Q>){ zDK8>H$KL`egRr(+kGfwW^*F+3{|tR@K8q(fsSOSLL=@MgvG@`<@l2J*MXRV-5|~oE zwU&8-&Y}ITf_IIf9oG|F-}r~8ORv-TwPvqF_+S}K(PzOq-vmuqLzQaLmM;zYfI07# zV@60*qvfnbj!iOR_}_ysUmE=|tR!Kp{q%y%&l$#-ZcB;TJoEWXQQ`siv=G8(P)^+$ z7>Pd_RWFiooG^2Co;|*N$bL18@1#cB&fH?n6-W5QF+0w%AyyI(QyurBvOHN}N`ZVF zo!nS?_<3UXjKLX)6-{KC%JY?6ExK;jBbBufHTBs*+oGH?{Os7WiViT(i56B62Mt7r ztLnkR(8EY3q8xVd_~8b)ss2n9YRC}Tb?`fcw6yRyP^`c3Jj%;e+lV<5LT>z`6>>L_ zAZmN~$5)#at3SP~%9}KwGf_m96B){I>IQBBbpvYyL4I$&zQ(v<e-_3#uqs*p+6SGp zAXV$hZ~NTW^}L6GXKh}!q4uHo@nq`8&h6rc_>CP}Upz{u*g+5q(HvFLK4^*8tyMP7 z-(7~Wv}m;ZTp+#v_LOwowuv%J<wfu1;q+P&zj$59M!v9cMfLTrKc2qP`i4f|Zhmg) zRb;L^mC(2gMzOWnolRrwar2G;-I91g()Bb9$k%NQarCsg`m{d^NeCa6h^eWC*XL2_ zxlr|8M~R(l>-qXC3Go{`-V$?B9lRP4VnydWb~vzpO2cabNii+kfh-n(ML_&nzU&BF zgE*w=$sa)E1UX%Q0=M6xm~|IbPEDk0)9GUAHM9`keXvX)I$jXm7Dv#pC|lw}lKi9? z$+J(qq$3Q@bBw^&c(Ul?90u>xX<T{@y%5v-Z(kyi8}a1qb+Hw>762MS-VptWUNSk{ zDebqDQ<7}1<ZPo4;}sy>cFH4CkqeW#MWUqWqN+oO&~=Y8+l_snQWuvcE^!D|z!#@R z4RCJOdXhMUB%H7y>?ARehUhNaORnW6-j@m9px5vmkyW5h2f7`(yiu8g@2P;2Q2I?P zHG_DO+vCm@aN=ob)yU<`-qPm4URf;eMkB>(S4kn~kD9zMD@0j=y;~b73@Ldy6KE{g zSOX19IAt3K0r5%L)94Y=V+n47*K1e%6L<v`9GFln>ai2$Pdu0=Bn473-Sn!g@FBdl zc=D0@1Ck}8PPu9en&w&yTZLUHrd79dH8m%@XqHl*HqP-T&98$GsmOk74#5+)%zYg0 zQ~nOPQxWzv8MnQ<X_Hzt<{^2$&x&{CaJ)w0ldYoN!`KiL`B`;8H@cFg4YZ;*QnU5Q zLpvohE`1pF8!`7uh)zH0-U~VV4mMpm8Yc_)<&YZsXB0@%7TmmZ8Q)C=!nnZ!T<((p zB3aA2wb3{shCXUPGP<Xm1Q-p3Be^_0wRP7_7wcG?96|M!zV~Fn^hlp9CEBN;dFtz) z*f9<rAVfVVcE8(rzy17jo$L)i%}aZIb1~cLC4Gi!aSdg|wO`ZJJ2mV*;G(zkk)owb z{*LQ2A$5pb;Yy*?1x4yG(sNy6@2m?$BFr%;0uJ;89MyxG#fus6>sQ9k#6c@^qE{-} z$z+e654bL+`pM8Rq%U{3Z(X?k?UMLvL`=CVFMgG6+I*$DhfMA^>I^<&cz_Yk>To+D zE6Ey^)!RFPh4Ig4dpjgiXSTJD=e8VHf&q)4)x=XFg?na`9obQGVKh84PT@M`F%4+8 zbcG=oh7o!ba3$R8klglgKX`_qF*f5LKtY%y3hxUUHJKZ&y8)$1__o9b1;F}{Ut9%l zaWK6cBn+3lvAj3U8yvYVkiSpk`L0ZZ<AxFV@V^=ObCljenPY%=I{rFb>$0(!2+J;O z4C3M#ZgUY73X8{FC0pPs-i-E1f7WIBw4dQGM{%KGrGcvv7knksTFIv}R*~K%CqDPQ zK9sv<^j_{ttg&ipLm)PcZO?-$4%H=#9mCzR)s3{ssEbgHw82~E+A{UXMuh?hqX6*_ zWd~`*Uhz|B+rolftc5+-;NQa^G;$d)N7XAHb}0*|734D$A&tSEu_R*ThN*#r)Di2W z)FY2Z;_)8$#n1ep!gMET7qq5^25+V{kFbLfU2%gAxkI(VyMP@lv*Td5$tRb|E6k~| zLaHN*;hLx?wa~m&;B4_{_ODC4+Q8E!(Y(fmO;L?<Hl>;_%$QCl10P2y+JdDMI+3$& zgGCtgIG1mB1G2Rw;l8uyzvd+=AQ6o<9wmv%Hy2s$q`7cHly1qbRqg05@Ue7<Sh={R z8L;k_1NfCf<*yx!H6L}w&Ex9u5W!%%;^%Ym+XUOoKsB(Vg(&FUxZ4RwOFH;;TJC6C z!wor4%$6!{7;zTa)^1!iPFTabWG*Oxrd@<B2Y!Z3t!S&-AQ@V}H#P#6u5vpZV$z%t zX3iZu?2_ct1iE|${dlCwlM|%sHz2dH$?~RKF*M4voD9sr=@wseLtBwI&Zq0!@LYpl z_HFC<$}2*%7o~O7N>}Oj-iC7tTFPT>v>#=OoXKk9BE*F@nUaJ!?;pC~$kG#iBp`~p zoN-XDHVGeNq01Iw79UH0f%w?#pWUguPko5TGfcM6jy8F3#^K~|vDt3)S!N%WK+Uz@ zL}Zp*?zVV1b-*|DDblN0(jxF@9?~C#p%*)6S#0AUZ<u#Vuw56ckLZ)?gC1Q4ba%(B z54&Ntx7hDDPeWx$RxYpZ*QrbPgJ4K=G>Y^gQ^0hW`ZuQq6Q){1M<D<&zD<o7_9)gJ zldZficO$9P=DKP_fgRkf=~TyI-W6wV0KoHRrR*wSBPWIJGqi7vR{z1_FH37}s^#oi zEE4<kH2_kmlrHesQC2x&ShF%K@+%|1ESoz0nQCjiqaifuf5TbuM#@|#+#$Sz>*S}X zgWdk2_}StQ#;9|A8fTt>ZA-ifczGj|P;mJ^kUy!O=J&Q5hZ=f@LEART-RJ~PXp&5` zxX-JurtxeO8x?vs=@l)|db&``Px8q^RK}Rx^r0}yoR%YA{>G8ORFFN?q5YU+J$(G; z?;zuLzDRcvN(W#*YUqK-vNwsDoZ>u-J7%%!T;%@=g_hKG>ChTh8F5AilR(z7pCuTH zW=hP+UdqX>n)}Kny2`n$89l+IE%0G`S+dyzHev6fY3WLxw+4!9z+H>_bOSYz)--6b zmk*yKwVor2H}U}zfX3Fu?07_sS5~OLCN!k~1I&IM?iV4@$6K&k>nYg9U*~tq9|5yI zrw8U_&L<h60<KxW9>kwGYP$6hQTjP=24%}mrcG0v>KH|pC}nQ@kru7K@-r~k9n2Oj zZrVGv8$@}WR?&y+i0>>&AS~3wN?+bv6vHr+&Gpd5g!}#wU3T!#)w;pU6_7%|?39an z)<{7gzU%aGlQ&}VS$SlR4dr0Vb*@n`!h2)nr=q{=S9fox<yWImERv0g3OIGRRfaL% zBUv}mYCW*O`Pu+!ZIP=Wb8NI`n#E@nJmDE2UV9z67u^!8N?UmQMa3BynhypOWb*@K zKz!>wN#Lmeu2Mw%a)<D3M~;rV=*XWB;B7tagA~~>{i8A3WQNO?RkXOme8uwC72Y)P ze%Rh2YU4?_Es;;MiK;Ff?d(H{ih|XC8G^h-2t4qAH)Z+-d)f5x007X8006}QY0Q~e zX^hMr?QASuZ2nc}7S1$AcK^alXA3*q|CU{jXiELdE)jc=)SS;L=`Ii@K2m_Oy`&nb z#k69J8B^S=6e<3Igc1|};ivb<HQ{JU<GJ>U^V-%+)oa}0Vd~!(P>?l2!X-vbzrDD< z`04aA#qFm?0_6US==*o9@8E?~j!mH>0Nz)*|B4y1h)@y<68-?c`#Fg}*go@tXv{-} zuj;%uoVNVpn>bkeTN`R34%$J7zY?Q6(C`M$K6z9DDj`^g*d)2yew*~k=~%P)o|Q(p z@y~}P(3uqhC^Zp67-A!LPqbhFcd7!R<7rMALvh+W(UTg`;WB(gRw93?2`+^CQ5|}o zEcV#|u<`9!tW)=dVFQAM0;Ie}lk`(eMuG{;Qhg?L!z*18^PUssPns`w2yaDO8JVnL z0+L)!vjXr-5WW}@sIT-Ad%3(i7&DlpH{QFVc)KPvcG?MH_`L>#O|&54+*0>qXh%W{ zQFErE4Qg?QeYRL!EYjNo(HO>1wlS~dQ%1OmeDMeNsAY<SD?9gJ)Zo@Z6XGHfWU&!g zg^4I~E`$-4Og-$d26v|jp>XHWIRXVb{6V%qk%IL=G>t*-Nt1<A-1i@#NbvgaSA9QU z!Pi;jq>jeMKYvIJJXxr|3zK0B&eXn5316s~eJR2%FT@@!9(Cy^*+dd3F4ru`(?dK! zBazEsZ(4|O7bqp17ET<}ZNJG4Xzgl)+Jy(@c>+m1VL_}Aw8v|a?~=K24}=g9S$u%C zZzkQ`WoRU$c{2(ZYJm&9$4Wo9V1!=+&F2lG>i+H<rj(fsB4wZX5>mw5%b=}fZIVoI z$xaQnhh;X8se?Pve&2%;B9WnI^!=RWb%D@CF{06OI*RI<n^?>Tw_wNKdtghst`I>r z62UQk{>=~K8M1TH7L5RkN;ul4glZ3Y%97sigCf^2o%VnZ6EqBO_&3zvj$JuxeVF@U z#v>4so8B&*vgd}^n={3Kc|zdLGefJo01IMAb;L9W$v_OG_m@_!_$rvHaWqBp3HN|5 zF#Ka=NpPQ2uOD1;88@M&UTki_*O5oxL?g}4Dq)uyOKb)5Vc&;PgyX?E@UvBNY2ZS) z?QeOKIj%sn)3a77(+BFYe=?N@<!vADcmVYe^1y#G=X`|Y?;d6E=ozR%{APD+mQ$ny z)5pQb*R-H=CWi>p$AGP0nyOjm)qis~<=&lD@*^og#+=R+jx~TWRWKGz!~82v#@CsJ zX2f0k1ETa-CI?R(2sMQgORF6B`>zbp%VR}IMjO7jEDP2^JA_pm8+%FAFyGR}7RCdU zjpHUk8JR1o&WUepERb^Jxl9X@32Hf%w2F3p39ekdH-C*#dS1wslF?oGboLjhBuOHQ zYO7KP_!7_zF<D18*Q_`1oRkK;Mh8CoP$Yt-=leN;Z>dtcH0k+5f--(Bl)Cq6`Ws%R z2`3+EYH%iQ<^WU1%nH@VN_4~1iY`ks|K3u^01>t8Mz0na`SdN$m=h)ci}Jn;8H;1z z9qJ|u9uydz3^x+}E~b#K(=&RI_PZ{KUP@eF^Rj&Zl3Xz~m&hsuzZvh2gS%Ahacib& zdn{oc?cVgbri4-fhNh!Qoh!A_eZ;oF$5IlccUpl!u!`(JMqbuBlP-x!Z2OdMNHx^d zSJqF6AvW2%_=%#sX>i$Q0po%273Tt?p@ROj?Qnf<u&(18vqpp=yLpOSP{OOcb?9;N zk(K!7J`3a_Gs4iqIjH4kI;>hBhk~BSa(2y8Z9fN=6B`VD?C^dQsl^JZo3shX&n!mD zmWzz}8UYWh^{}jo@K4K5F!rCBirZ?$N{<Dm@w(uYIGy){jh`kS_uf7^zua$i=yoo0 z1rX;+d3QbuXUeoky56}K|LF+cq?kO9bdS)sL)2Y**4_f{<IXY2pn$+=g2HX{=e5{Q z&g3kpJ919fa&Cx0W5QY=!8eI`TQDzrcVRF(A!R}e(sLl-Ddac)Oq*8v#b4dk^Y&Mq z6`7?2SO48J@Rd}=QomJNSKpuDnZ|q$IcT%5bhYp=XGJnsQS~OP;#4EJp<HR*6wJyZ zqG=q4E^rqogZV}<quoD?$rmojP-`VLv}-&=eRSAkX>1YzQ^i@mVcc-Ii~T(1NuNT` zl?cW798hgApWbFM{do8&x5;0C9iQQSR(V*hIe{TC1Fpz$ob;PsfJqHBF=;)+Ni zTaS<1i9CqHetn*@FFP%f3Bd55J9~)PH40}xG52vENP91K5_WKL%6?CopJ){fIOkb* z%q_BqGE&r^v+%G}DhOi*Y&0Tb6|vHht4GbdT#$_5@Jd6i$t+e84AQ{%Xc|eE4<I3Y zWgs8egXj$yWHiB$4vOH1$-RlC9-a?WI}-EIh-w{3WM~`wy*R+6`>PI>mC4&dKFJ-h z6h%<f=kZ<fsalMtU|F2j@Moi*WQ{cPNE{CD?k?`RIC756gogQ|-eB9wq=Xf#XxH30 zWDa;`>dF}^St3BG!g>uqLX+{}qy3Z8y8ZdxP)hT6-C>}Mm0meQq_oy>?38smQti(s zQ&rAESJK_~iLn@ilU6so^Ua0TlFGD-0RW}dO6b+0K#{O{Kp<_$9VDb*_!kp%GRKP& zZTTQp*^voi^FrXQe@a3RI8!-|$w>UE07Pw75g)y{Zy0*TCQi%Xs_kOVM8F&<y|ngC zVeQvL4`&d^lY@J^STxf!Z?z8)f=*=oANjT<Q&}D~&x}Tlv;AFGNi^22E)o?iWfmbX z2oY4%#TzCfi0~={FWd91cph}7^F_^z)wx#2Z@apO&YFau#<hcmg-GjI!1}dE64;D# z$U6!Ige|{!dnMH5v@|dI(}u`FPEv(ARn`20B+XAq9PtG6p|Da9b4tnh5oX7nREKkl znNW5$FJd3c+g9DA>f|c|6nWo=5&QdxBxpQ7San^Z3LNek0L!X9<Rb$qCtaRP8=4LE z($hfcLcwE@nTeTuRh}*q(iWEGp#||d+p%O?mV#QAyXX>{7g<>rpa8*<Q|yac#Au>r zMPlC4Yuk=X6*FU2u%^`;sP}j=RR;cW^h`0jw%FC%w?bJfO=Oz8dJUfRg!KAdk;Xre zW+|zX_{zGgz;S6kW*otkzUY@V%1Svz%$1cB<GbomyYf|553Z+tT-<>$MV<Atayntu z0s=#J+>&GFaZH~Mxw#=y*2~{9!z5Hi?8I~)10G#%-D;&Z7&4b`xf|O{D=8`d2xX~2 zOOp7;N5PtQx{p7l_aF09yPMpF#_mzBvLM;XSl*;HIWu0imbfb5i{=W|VK0yyKCH|h zOl`xLt`mXmm+V8EITdk9Z4FUHBD9Mqj8MU*?^9i7UtdRNb8i_35jy507nD$~jg_uB zFnMI-XBWMPOHEU5exh;`_%q^bVw`VJ1%G=!)*~^d>$$&vymRm3@~k{IxyIjg+VpmL zL}}&HqnvVqaK<%jOR7&2gJ``wTm%1<?h)utRD^{S9b*n~!`N}#MFPAc(Y9urzO%}F zuWN~nBpc2i`6sZuV3#bV9Nd8LhHlbgC=9F6GNR*ghY3v8fT?VEcg)^LKpa^4I0&*V z<_M{p!GHr|U|ut+`goq5LjzQbG3}TTSGmG!Jq#_`Ii%CJ^y?Y6`j>qn)(4BHE<j1q zVY*{{Sl$Xz%i5hf@9p<gg0I{_3BwNYN>uEch8sqB!Rt#f@{$$w1ahR1P3S?LICFKy zGwci^6a+Ii#f^r?%47SmQLgewjneC^FN=GVr}C~|)8;@BXwDd@%SbK7bQ-q7tGPk= z#dTqx+fOmtMq!uzgK--u&7kKFrKpcHxBn5zTTkU<j@8ZKqV<vQ{pff$ia*yz;51g> z0Y1>hO;Ym(R?oF-7Cwe3&9f8d;o|ZqA%z!sT{ufobm0od^;Y@k4e!kh)(|&$xZ~BX z$=TTD&TeCswph2SgaK5d7<W|2`Mj7?I`3WxosJ|y!;3yh>+^fcg_$FBPF>31W1WR< zupTN*x~`EGuKJ>EwrIK9R=H|-T1Tb1YZH?^Zesjb7UzpZ4)xCorSoMjt(2KK%Zxsh zpdU#SCZs>n#vn>^^iD4}q0S_&v_?H?IB%)X2(1=CYKnp|zB&w!4I}32=p8LC<P9ZZ zBpD3uG3B;^7ebJ3>3DAq+^}NYv3r>u-Q!?coRtZ#*fx)H8ZHJ$oXOO|Hv@i%BJA-8 z)*pvzd3?FzXr5KsEV$I$Pru~FEmji!>F@SsW;IEJ#+;A`q@6#fhJx-Yy?5S%Z^$Yx zF+|AWqgnlA#h+~vab+*t3O8%0n=|YiVvNj_=!GbO`!`~!z-7BkemQ+9pM6!jE1xap zc(d)ExN|61H_FP1H7hi*y>-`kp^8)$mrJR$#_3@kEB}_Xp;NbXK}jSDtyWgsa4vjm zlFmoxG%J)+mYo->^VuntgRq4~bAqq8z@LbirDCcZt?uZMUadzIB!1u}-3I<H2x=m& zy1TIO@_=9ye*C--hFqSSKh=FP#)f8JefObzYaO8xBh_^5Y@uu5t>4K+JGdGWN(Wu{ zIZ=q*_X%Auy-ig%*$V&4LHWEANoF^#)++0)e6-jD8frbEG1OPoQx;*qr&I9Nv#E8$ z#~s?ZxX!M(?jOn2DQ-+0LH0TY6f2*~`NOv0isuLIEPf(eh8iq4lr_93Z&7EVd4k33 zk$+Uz(xs1NKVw4bLA!;8z%tOGC~||959!_q<;}>`%hOMoS&OtFcuwF64*(XXIlCsA z9$>W5?)0(PS@#^K;Z|0H=HW?@IBDyKiuvAKN#WZ)lx$4X^tIgnQ#sT2M>$@-RApcR zlaZa{{pbB=;N#&!LT9fE%AYnEY5yC5kM*t<qW3QF1)~h4(h1R_gs$xTLDeg3!ws#P z3aWn%mEX=G(}Q!L*-l&Sh9h?=IVLrwf6|(H{W(ShQ|sa>*T83os;XKF6;#sFN?5^p zb81w_GPe^{*r`&wBnKm*Up?qaV?cyx07AoagSmP<X5>a&Aqj#{y=p}P3&WG%AhVi2 zW|F1Z25C9EJH4r%2Ii1_%2q!+fD<_60ev(K0^px9AZiu*oLALKDI~k>)~1H)J$=}( zuGO`(>&8}GK_H;;qfe^v6VQqXF()jN5C}4YT%eC0vovOAvVs6n{Z%`+jH0xOsmVwq zfiVJN#6$p?lzg{STUws@?EmQD@#NpsjsK|u`2P`J;Q#YC{!b5YWNq<ZkS;Y^TaMe} zjy`vF<@tjQG97fq<P*E7BlW@xh-Mzp37HWu!yx`(|M>q40H~n%jYSUENk(}%KVW|A z-Vt*X)Xqf`mc%tX2#8(KDjFIZ9vm4e?#8lGSXpFPr)_<I+aQmqVP*KTQ8YAzO*2mw zT)K9zZqnmsRfjZ?%RV|HqCU7Mk&;R}SegnMMQp=HtV6*+Qfjm{k7w(LCbHT}skM2i zs|Z_X4ekM&OJy=w(1$My8JQz$7OAL+E}^Xiv+{x*%?~LnZ_3xap2xeX3ai(+Dk7_x z1>~A02|`6$8rWJRmCSPF6$l4+znX@CnOO1o_J4N`lyyi)Tw+6yHmGB6cxuZMq>K)F z$a5~{Y+P&?wo)`S>)@xkE03V7i^nv0uX{Y5+RZT03gj~12K}&<6m79VB3f<<%dHc) z!3NL~-<x8r*k~V`$BU@h<*po7+Bx&t5!q5vVp8_abER{C4c_at4)Wb{R?$S1vbHya z7tfmS=4NGKP678Q^OoAlZR+0MAAET|zT4NZ1X~-cIg?{b&Y8HfOEOsE!+$eQO_bnu z7L=(B(H-8Cr<MmKGy`6M9D8FMrL-#Ad3?YFj@v?$aftON%Oe}7O$CLG(cq-X2anP; zP*+-JK3r_C$RjSxxgt7h3lj+$LBoYk=VxYU7CC$BHCTf$Q!;7>iaFXkLIT}=<*Xpg zt}lZN9bHU{I6_8l=#~~0RdQadGTUwIX-KJN=QhkN8*(Bb`MkM#py6WoLWrOq+bgsr zBJ+igGAq`;6Vidxyi~L`>!O8r;^ym!yKpjoZU=aWowk_3rbazyiNwhRzwgZiUI}yH z^^w*O3QIvc2EzEVqgNT;;;>o6CItbj3P<U@8$7gkQd`1jcKiG5)2o;h113RQiyU8| zgFYD8)~6z`43q{>>lb!xRT9>8D^5vWMh!?JDwidgX7s^pEA(IIYv^IT8s(dMyu97t zFVAOW>)vRoN)^C_Dn`6N*Sld()zP6J+iJ?x^ZcU@R{CMW%yR<`j`+URBV+EdvJ9Rz zH1{%p?x7OXQP3S+K!53NW>2nCDyZn2WF7qoI{KI^YGw|dHj_<2#fXmcA?59s%1Bl0 zvEY!|6Y~!K@Py98sW)x-dVL?<uWZQ9aer6b$k^~^@D16DoyO#d$f(LSx9q#-=KQt5 zmy-eHP+hwhC>r5@5K)<~+2HkXdG#GjH=2vySaD^qX|E`vxNiLIq^xaV=n~<Tnj=d( zDI!_pScU=0L1kOCM>MFRs`=AXwk?;e6kgq6{ER=7l|@oPi{rhE>I`H<)aiDznqyEM z98K0otX}3Mb+zUD^Z=~t8R)OD!ClZdPX=rxc1G0n>OE8gJH46ou_>4hW|O>eAJ_$@ zME7w7UdP}pS?D)ECAPb*`5QFalNJ6tN^cyQ0V$B5Yv#-_WT5mx=XCe>qDD}w*T>Zf zhwhUG(-r=SkD03ABT$9`C-gveX}l_?lXO4rf6+S&SF*{sz7?<u0dI=6Am-N+?d(O) z)q4WSVvJ{i<VR%I$}v=yIv<-Jbk<g~`B5M6?Z@_AJM=s5U0;vOE=Ud1l`<WBhrv*W z5%lH2wE~@#dj^_Q7VLAN8RMlcYQJUr<@~#+X2!OlR1cpg^E*a&c8``P-8}M}d(hW{ zXSZ7i83BWNYB-;umyUFcfh_mxszzSw;SE@FUBze<om*4m<Y8qtMivDBb+?-btBaSH zgM$YL_qv-~<1<_Sva~rqYJLTuUj2)g^`JchDav>|KES{&<o55g6SL=sar43xn1-r_ zE_|7Sw}&o@Os;rW4x96bsaCG9qrDUB-a{fM_}3PmE>6!D*G=Los0~~j*c{j|qjHA! zIs__nHO*n}yj9y<ux11gqW#c0)sC5n!roo&lB#Q{ScB8xz<i0{3d5R$CLg}(a5bT1 zy?_Kmm?x3>nsvSPq>Yb`a+(F<($DM}5}^#~zCojxXJlTu+RCw)uaq_Fj_f{Fx$m;b z)uqlXaNA~InTPHa=AgWNUeZw$JQof|MoW>oE!k-pxz%;%^v^&lD<VAQ-^0Ro=^Blx zg}W1!t{(IlQL7zax@qWP2g7tsXi(*f>`4~MCWC|_h@wsh91EJT*zX^nt@V62FIT4_ zJ~=klP&u~TOH&|?%~-bcbn@r5uNa3lk!Y<hJ<MIgVR`FF{Q-5?s~Y+YSfVBz8(BUP zxqCaS&L(p^!jI21(n)lqwu;d#EG(|wP213mY|nsq4dWR8>Kit)FRnN2I>Mpaw<J`8 zqHshA2%|T?>hq<+9e`y35mGSniU6y=Mt?~h_}BgOll`IQN~i(dXICk6sBtr~-QIJn z{=Z#8M{8A-JrKKAad`iZZ^1D5yB)n8Sk1hj&eLRK5D!Q_dLiegN4(_jbXltJ{B^Fk zvPeLzI-_~3j!tskwu$ELHqNN%MYeL=P~`+3V>VJJEHKfmQ+Q=kA&h>UaAabj|0JJR z`HI8-QP(NRypL=(o1Q6Xc&38)K?%hE*%kv@y?rD#v}WQC9842DI!$$66IR&XeJ>=> z9E9Y`lluxd-bOHM-C{C+WqjR815L=Q^kCCoL3}N@l|OyD)Qn#Gdm%on_N)0D1;t_3 zfXt~an2E;TcPA>Q)>x5OX2pf58WW307Z!HgTbwL!?oI!@7te=ed@MJBdM)fLOsWsR zl8j+l8%ru7BC?bA7$Masws@?6k~wF`T%0M7j&E^E&TmzU??K-ZmW!8LvPpqPXO1(| zvqPy24%$~)@+s*3nGoDwI^`rnHmMCv;ZUh7>=UFA%agaTM+9Eq-5VEo4a_CZl;cit zK*H4qhAkcr&W(H;*lAs>AbU(=a479=bEApE%1}&<%xgQ{0c+0n<a%3+VsF=%?^wea zQ1th#Tfc%Pcd-VqnvM#s4RaycjdRW%(bM0H2Lu}thP54sHI+&9zw~*hWWhM*+wzQ2 zaO%$E?mz8sWodlfobT;#OeyN^uqP7Y2{eR=R$LVKt0g@l<3ml&vmnpCmig!?eL1X3 zt0aR0bl*iiBJy(BS#Ax*nqJw;B8$VVn#@9RyZGFXZw3kDYY84HdzDg+7)>069H)&3 z1$tM;h-~-aPR=`7X%KvfelmI;tb-d<_BrMKtI{iK1-@A?OwYZ0T<q%wz*%K2SKLeh z8Wf9%A?*#muPl9k|K3%bwL^#B)jj@Fr|_9Lz}8I;3YK%+S$QGol!%lx<(aQc&g(L& z0>x%vECV2n7z6dqG@CMd%sj@_kS1;Gg7f>hU@oIAhyQ#+n1=g6>`OifU2^|@`IB0N zLO*GSD{;%fT-R7FleS{f32o^c`kvnX2k*+Pcuu0SkQy|<zjZ0|2;Q58DD_$a3j452 z$aLEMPrIokpv=UjwQdl?4gaEjw>^d;rM@V4A$008Cx~xVOMdDAlCkq8$YZ<%#5-Q& znF={yMx9U$jFxv2@ol29PK!)ppVNBbW+1u~pw$RBu##9>ssnN_L2HCb3($qy2+-K` z>p{u&iJDzVnf(scOs?t=<<=i!7fJ)9bCaTppkwz$^0FE4xizdW=|{Ll3lo;Wk5?C3 zDBS4Ag@nviI2gs?>5O?D=9c1A=`0<js73mCnE58#%54`)OSklS#*_YRv1oe9^?wqJ zMBTM*8(cZkGFRLbc_^yN)jNP>7YGN`0rvCjy2Wy)=GcF+*P~CuX;~_0JGgpi`~o@W zo7uBb_s8WT4!!(Bs-}yMVoG40=?yY51t)n?#z6)qz45R;6jnL`o&}B0rCW7lWf3vB zjVbLdN@GNAmqWWw{<zrJ0xx5e+qWyR=D4&56kwF%I47WF@h3N%JNAcUxv2s_#te4F z2sEF&0%N{`de*yx^3kh;JQ^S>6>4`mM!G?H?T$-7u-L_+;8XjA%L}UqHOU#Vi!-va z52_OAk(UDXq<Un^CX{`ZDYbJ$dq=IwF3>;Oj&dRUWXWp>wK(nDiV!222rLxg+q!{m zQMOa{2}taNNRl4n9xZ4br0}+t|Dm(1eJ^n#ZxqUO_=xByHH~^a^eGukqr3(<Bw3-e z?-5FKqxEzRJ@sFIk2(NFW&gyGg(Z7zO9X-5Y%z=<QLXPgjqn^wk$$pkzsO<M<c>@7 zx{_+-ANMC-=G`Ryj&a?BIW4*VSH`WPu#SMn;pxT2Z3M;@`@So>>E=IP$>}9jD}=#& zt~4tCb0NGI&PaO_TbU>_pf#LW-ss)9*zRSPDE?TU5R4xTa-fl3i>7y^hUP~5y2?VV zqI0Mx)IYn}`6l?kZJguIyvf${!P4tO>MHJRVQHhXsC|D3T&x@h1z?bVAUws16O+G? z4NX*_8e*LTMO^!0o_)!f`~IH!?O+J>mH154MH%{kusnkl<JSNEdp3HzBhHhC`53hf z_Z>IlUPCB8@n8UDw+<MTBnl5rS_uZ|3yqSHp$b-RKDEhzes!wLq#Nu81$@z5F=9Bx z23qtk1t@j@`FN5%QS(0c=Awf;1n}pX_cV=T>GR~CwdT#cIu%23uIlR8c-~6Y`0Jo4 z23D9<IFdS|h%(?euS|my=HOrkw(Otx<{a3As&>s_$P7yhB2}C&{#;LDRBY>GV03Hd z^eG1v(}LtwnhZpbnV%fW8`UvdnW-5cjTZ^v!?;!*;Av)ukhu-tBz2QIAff4&z;s_> zTe6Z%p}NQK`E?y!^b?n-+S6jS7;IB{iu0VTMvT!1%?+iWS2yz}G=w(f#K&~Eq)ylH z{Iie>=cbTOb4vv@{N38`C%Hzm<6jM?xizzzgd=}Dqym(&{DTgrIu@^An1N4%z6=Au zhEhVOO05D!p8bvEpw>}tGlK1<D0pAm8swZNFzA*u1kx|IUWl{LrxK}qm$X5WBX>IQ z^<ekyed&WaIrjVU3QwL6OJE|)A{f(~1Kw1Dy!#ML7jsaFDNIz%m|U-__Nj4fNui>` zGMr`Nzb)eA3)q4RC6_!X#*v0o1=J>#VVE6-A#~#b)Z?2brns&Mg)9A8kTB<XP9p_5 zLZ3aG8bU*%=w69D``sYkx8=b2Fmuo|!Ntw31od@fGzXCIrz}N%MiAZr3=5gK8Y&Ap zmnuZ0xYg#t+M&NnLa@wU1Pf9uSI~~H?^hnN5HL_D*7fvyo=>0d5QLNdoyW{}WRQdl z;pUnL4JWo38y^kaALNv~i_EmvU|S{Y$zv1>RlRt8#IrzY=@5{4f7*Dkm5m~45nVTv z>?*3BWPYncO^AQ>lMW@MzX);vR(;4)J@*p#M~ViTBE`ATkJ=?34RhPtg{tfaDy3NY z7;32<)n)|r-hEY9)4dtsA1GhMDz1Ij0KEqeQz_Ug4W&aP36OCD@zizp^K^O8LgOe! z%_R;_b%3qAf&X?wm?Cz%w-Q27Bh!2uk@-?uN$@cHPd%xmGcS<#HTECeY0C`MNhqHX zxtuucuq#FNU881Y?q!Odd2-WrR~c$Zd=VaEdYv*z@!sWE{!KCe`Px%iA~CQD-lhx= z)1Ad~gJFIA+97doHuy9&-Z+m8>0+_+h357~A{>M6B4;VsD+}SX4=(>b(Eh?WhFuK1 zJ(XFUfTb8PK5jI57;b~AE;&Z58Se`2;Iww~+MzrDTE7hLPnKs5mzo#BSd5G&)#AMl z*3uEQZXEmY?sl~ZN^yf0$%ekp1FBq8$YEho$Zza`D&c@7bS|Zm5w=d<J62Q|vEAr+ z$8kRX;y3uP_+KNCPOO;C>pmX643rV^-t&%Yb7m+%In-!)Q@C<hBlk|VLC0g<IDMIg zAu>ILgIoGWX{4<$U_D}fVNq&M#yS!dE;`jq?ZVyn44}3K*T7~G$DuET170lkkRsn0 zkQVO%*j>H(ZCB%1C?!H7>MOhGhrv+yj})XLwW^hSHU8vSmFp3VxzEHHcvn#_&x5vn zp3;4f2wlieRn|_E_u_inaAjBW#Ep+?X@E9$S#_St!yAtF1NB^x{ISbDv!ZVu*3L3n zth}6eS}e0%*!K4syAdra>*ld{EH*tZkF=N_2#9La%>1`jR!+!{<!Zq`JuSWXQNkXb zE$nnMtLo{7!2W6-rTdq;STf_Q@?hO*V9Iw<oEK|q*=XpDwCrPkZ)#;f1xzo!E9~Cs z(Cm3SMH^F1>H@!nuwOk^Ze@gqM#l>N6Dr8&+pCZ|;t7!mg2LWVXVAM|>vs*^y9R>w zC8L8nqoFHNy=~7DhuMbc*!LUvoT;DKl3k-9TyKS?$~uqp4;$<1ow@|bVtpU$8|Z9z zR!%W_MA`2X{wnY^y@AnH#Jd(yc|+Y9FYeG!9NF<vfrPv#e6Ba}Ke!4~(cC+JZGO(L zB%+&%J#pGOpUwS0t#G-yX9}yH2{Sb@w0e0U5IGuUW2Zg1eS{E?mJClg9WvLW4ymzL zya#OxCxYVI{VK=)M_2f{WZW=R0e*|=ME;^AU#B^khPwJ!L^Rp3Wy^*hm2S7*LGfva zZtv%0H22@wk{`os1wNkmpnNymbg3rt5Oz6fo6m-1-k$2LOTG1dwQTd>IvQ{$+o{rm zRSbu{z=My$D(SXHfx${}0XsWn7PK0Hs6)>IpL_gO83F8l`4dV&8{nM{-4!|N&XB}@ zp7~oYpgaomdp?qPp&9A`P_$c2<6d(;Z(VQt8pz#m;+8=>sxD`MFFf&GJn-DvWsY(> zX-ht{AQa^INEI`niM}A~U->ix9j(MoRb%c#JPc1*+beX|cX2lh-KBPoA-mRrCn87v zXD{kM*m6F(#o8X&XwAAX-5dPb2KkU9XPp;4qbCOblSFjR_0Z<pwC;$sNj?2nHvPkT zrv;n!n5AxQD}jW-Hr*vmQ!8KZ6w5-O&+-B9fBk*;K<lsh^NSG^j0o`n+2`@tKrMiV zsMI@C5gtPah5mAd4F{Yo*!L6d@xzC>goAgl3+!sGO^V@*O^b7egzty&BQ~jUob!H= zO-KDbqAA(l1?fw&K8{CWAWly!4#FJF(c50QVGn<ic&lgBf8$p)68!cD=TJuoDw{?B zd2RdEkRXxdDRZ-tQA(SLSqS+-ay4$RL=yM^22}m?VA)v}R2^PK9d3(hcLpR4%r<rj z#NNYC`vO8OJuV01LWM)OlqkBGvhSoqpQlJCJkMeNmxse#K@9~|wmMubZJzfOrZIMZ zg1KAC2Dc5%PuDnNW`)oM(O5iy^bF8W!@UQ=t{isxNg6JU;)$K|<I=y56lPgVXg9G& zYKOlN<r>>`@F58@ZrZ$c(#4EdcN!|)qW{q{1D%#6Xx<%@b}Q#y3bO9FoOaTPW9Db# zyBl#z8zs);8^0i$wI3G9O5<pl^jL5pofc0vUGIAY=f%><qXy8FW`uqiv)80>41p8D zVNvQS%AniHgt){_mmDu0!x4_+rH7fX=7Ng5``-MiswHoxZpuTP(ERFaP>gIobA`W) zTUR~yjf9W)<?MIp<by9je`R$CjXM@m>#-j{PO0j0Vk8@-#BJiIhM<EerE_FT_%C@; zQs>FTw$6Ax^aV;$XF5}wRY{ZM+t!{Id<)#gp5UXMoVmf|GZ)l(+S!dPo%=WexT+9m zl!iNU(n1(<sRmFeqT=<dK1FK}A=Fj#nWygqzuhrXuhDx(u4YO*`KDsNn`&TSb{|LI z@*k*Ceqj?2t5A|Y0YuaHQEf34K!O#-b51)kY=Qtw$KErfSRtCLFlPC&Hp1~><=4&K zsUz5wE1m31y&U-*MV50g&6a<*@5rTCI*9=+>fJC0_`cmHZyM8=8QjO_I1P_gA!UzO z2R+FH>%EsXTjkWp+!QNG)c7qY27(gI;isSUI<@lxOqngfEq;>nYtQ7o`_|UJ#e9_0 zah=%SXURW$?3%R07RyJl@V}%f2^JVc$CN>|*5V6E&k`!xQ&&t&<}dO31M|=nloTjK zQDVQ)|NqiAUs2!K<zHBT#sL6;@t?4sf$9I@*zJu??d<>Cd%0?@E643o$DckqhA_#E zE>5olk=B!vVoJqgXY?#Yp+#3j4M5WX4O(ex03(1zD=C$al*v2g%EU2o-GgJ9Z{m+m zFR&<`gyfWTmBmBGwA^0b9VYkuStz868l0CczTYk=8P^13Q*PQr3*GhlC67|s%S9Mj zE3Jy8AhgPH8<u~M78qAaH$plko1vDlZ-xputDH>~yA_re(OGJm$|WDCE4hdGC0Nt5 zJalQ@^Ct=!Csj)poVc!?ak=H`oN`<_$xM7xhDB#&JPek_Hwa}Yzh#wlR7i(bwv{<o zQ$|l%PN~j1yOli*Bx#Z^pmU5{FRSMk)z&0flr(Cur;9J}>!?&tD8Dnp@2dXrDJUFK z(tVV64!6v3t-uJSKTam+X`#fHdT_K6b))HSEDXt~piil@$W0>V=$+y_1AVH@a8EWl zFBrg$meOljH<B~hE|?*Z-#O#*a!-!J<GFGIqdZPV;Olbgw6`pgs3L;r0zdC}N>eU* zE-JX6ZVd7`CQ7mo&wE)wi3SKM7)MH8P^*?yTrw(sxR@HE_}Uecy&RdHt6oSE`fLQ> zhG(JsDaw_VHt)v(*`ZQSp2<TKUF#gECLoi7CcUFPcxCeQptdqPMFZEGJmga*1*SjT zA6#`ha<fktIx57o6CAHIv+Y>qmVUhC)8?a*ZmMK3G^mu#;l;ed&CyFiGI}*DL-;j| zB^F2Bz49B01}<qBt1k(VlGD!2xhP`3ID)mbB9K>rwQ3fNqHJm=CMcGf8}U0_k7lQS zWUGDHFjCB1T`)(KbENLJSW9m4q^Pq@HFXP<HB-YXH0PbA5hGQJhZ3b?B`sD*XyVMU zRQ<g=E1TR_E-{5ZbRF*VuB5hzCZChwkw_B1$-#Ev9P`jF6C8gB=E7|}PkSwII8{N~ zAra(q!T>qevYSk&fEhyE+^QI8fKUx}s1h-_cK^O2m~#@h+*5OOvb4@o^>GOd$-cZE zTz~7@UQvB;$2MpXEya#|n-dsnpmpWSSuiioJ<|wYVER_VlmzKbYOgo7-yQVp*L=Vx z1dDONU2#e>C@f?ywzsx0v7RJ=cV(xm1pP$b&y<l6RZp4yX9x+)Da<XmAk{5}rrl`x zyq+<$yBxFIdtKh&nAFT=Z*m9Y#Ooe_nMk?7O_%UlP@7xgX6k9<&YigA*rtd^9i+ch zBac%Qp0ZL0C>ATOhi9>;mwFAR%v{+p!5jVreUDv+P?n=crHm|V3Sl<G7&XyFCQXI= zg2t$mI;IWtTfJgCVsM!3XGgn4aR#l^TB@{T@lKBBU4v!`m^Ss$@Q8$~*sHSbY1+Yj zOl{$ST)1CEunEsaQtI{p`^I(0mHT^r82&r#OW#+Q9|*<<i)EJ<nl{c<#!08`>>rEX znn88WdW{1&R@`91SK1V<O@IY(g?Pa}9d=Iq>;IE-KC588zi^J*t96kK<H~?fH=Vg| z9``gw^U0^<_IcSa-O`laDCz9^&Lj=_J3FslHbJ*N{E2nG8N}8eCBycLBeg!g4T8z~ zqimCHj&<?e(P}zGK?<agqjI?pF1sM@sYIdz<=SWZ_IX@Q`1?`4*S*TJ?dtL7r6uPZ z$g;PyzertMp#6G_ijc0g!v@|jsx5XJi%MLJHy+aD22O$ne&pA`_wX<<V9lDR^|kh! zJM)iS#J4A}qd2b)O&HJLAP5QufBv%5%+GOWF~AEGi)fmOzX@{)A{~a968_pX4CT-* zJVG@-r2q)M&E@6mJWMY@{s`2XCV}8SQDhM>Ez!Sg6FU|G3-iH&=-h-{s$cC*B}Vi- zvDC@jhr;Yohv~HwfV;#PhmjMq%!2%nC3x=hc$SKh9}Xz=A}BqN8K6~{|4hIT2rfC( ztjGdZUX&5x*3<JjYWA`1%*N`u4R}2JMCIrf`>k@_bSZlOhb{N|H%f<hE^4F<>!gxK zH!~s^rZAPpF`j+GX3#;0`k{lMjYQHp7i}+)%%{_S?&6WT<9{z^_K2fpaiR{}p6xj* zaXsD17Chlw?WMOjF~&1`-+Ok&=ABk-@OcAnUDkK7HYuWYY+JZ6xwg=(u1m=>emP_U z>H^P1!{p0<XnOPN?s)}8no-Nnrk>`OMUE?cRy3<RsIgv$q4AS#FOdY=aT}>*qqE%+ z$3No!U=yRtxMPT4{$wst#*bTl-&vdd%;{+JeT%ACJth#mvlOI)*}?90EP_C=t;D!? zK)BY`q#n7nkQ`#=z>Bm_#X`o|(h)UN6|tH-RLzk-YjNTsDrO-kviN8Gk++FIl-sd6 z9dB7>(PZ`MOrah3Y3fiFm+}cBf*M18>Ccn+gqQZ~&xNLKUR@{Y^?t+Odi06VCYK9G zD+oZIJBWpMUZ0MBara^6QcqC$350`K&3h_wyOMctk3#?-AnJE&vSR(NW)sW$7aN(# z<|9<cA|m{DI(2t1aMEOF^0F4B&@CLFo=(j#?oQ2rS4ba|pxrgem6A^vPFxIS?8<eK zZo!WM{xnwWRkbT6Hll21Bl@%o+xlG4fyD#3><E$4bC!X8nU>Zib{A)X)211FZw-Cb z6a<X`g-2A^hwP6X6~Vk_4iBc@h92+JN(kOp?$<&RCic=dh8p3zwkEtoA0jo&40Sdu z4y{)(;B#mAcbhd(@YIA0sJlu;s3$=s#&=1Fl$vgbyk~TzD&-WrcyK+SGb~YvZ#n?s z8$??OSATD5k4>JNpN^}m(vBVhqqUD7F`tFA-!Z1}G=Nm!b4lM^VpCJhTcWdcMu#*h zX&MzbaR<ScBu63$7>QIIdnaO+?PV&`aVZt#u^5A>&5`Mg&;F}TaMUCHJ}Ko48N-+x zn1Rh*gMy_JefIc2H*Mn{-!SIyi)9t}b%)nL98f;~h9gDFBRm`)^;1MTxrbb5DY|rn zPVQ$<o?$mvhrw?!@p_-PkHt1)ok68<)<I8&xFYNnvLod^+}0k~GCmNwLmShjyTkK7 zg3fD=lk;FBuo7baeVmY>@#yF1_ypnv5BCS<a2~%If<0c8;PnJrle5XQ3IFaDwu*`N z&wNJ=8~2+Ki2@TyapTJhQeF!mrgL3Brbx_ZH{vvi>DEga=7)pvz^)7qMv=~|2kc+G zA5=!<?(8v$6fOkUQ5dfi4jDjJP*1~)WEa7dKrLwz$@drTM?bw?1t1vMMvt(SY#wa< zfmdEH4kKCitR7s13&@q6=3Wzd)A^9)mIuJ!0oB%L^xF4the=>)CKQVf_ko&Zoqot} zv}2SAtcJMNW#RTzgHht}LVO6qPm?v3mMP8URmnOS^GSTx<SbFU+)!$1S$JZoYWOm} zq-T`aL}`sfE=9Vep&~v+{Al42`Jjj9v8lqKg0KcVa`^t^;~Fw;gScG(WUgarbOzW# zow`N>yU^$bgqow`6e|N|;$ZoZF<v|gJkV>r>8g_whLb*2;jZl}(AX_}%ifkIxYH7F z)J>0(A_?gqVcXWY&h@P6{0^R@OPc`G5wA1ZY25HU?X_7HNrt~vn!JVRPWMtcl4(N8 z>pE+cA6rUUFfCAZMI36-j480G6rU@iooG$+s_3{3YfoO$DXGE+OR`L&_SK1hBpjm~ z(!<L)I~=S@Q_KsqWHq`Ve5w8SuhqlobsC(b`)I0IF-nlO8oPZEOxB4zz<hOpl<7@n z%y3i`wk4#Dv!eOpq75QNL^sbyVo|ax?YdOEstw=feE5Fx(s$rXA~(LUudIW+ZgFU{ z3^~`&?X~ty{kSq|)Z+(s9EBCZ<fU~cz|ZKkTOc2f6fiY04pBmQzqQgyjHHd3J45oI zj?(wZPe196Wc0C1aaXA1Ob}^V<iQ;P`y(_|X*%qMkmx@1i|3cXqi5Y~ZXrv$b8t7a z_45R^hDr(mYUy^#IT>hMZ&B3E5?P{gUbI6OkHaE3X`vxr)r(20o>}s_(8X*;qu|RM zPi?`zB|CPbU)gsae&^IL#y!N_z&7?Lw&Asa<Sa(zK<C@B277u2mN-t5#0-BFuF~Dz ztR75j?>3+ES6!S0UR}6?$Es0-(@dVrf(oIsq|O1hU<Gopt)B2D(kcoOJA{|4U%Hu8 z;EpLf(?>}Km<PwcW#oC{8G8y4f0C=@mOIewy^z_AJSPQ3d%6p8eS(uX<z6TjR=z4W zaUbB!tP$M~Zwj1#Stj=cW>pw1MWZhnIo7@9h<Q;)=_0|2hjMM{gBtr)#+<c_3$EcN zIxYr8Nwt!Y<tT1YhQxziZB@9mK*;a2soi~?=lzt{TNlSWCwli^m!`g!zbjvTkP%>| zm$~O;xxREgtLQznh7OHm9^U+1+{j3*`2kqO+oj7SE)3`lC{G$G5ko?H6Hp!0v?#KM zJM~W{N0h>^H^P#P<YcdRIO1;rLnRLda`zmfEG7P=&8<(L@g5*4EylhXR8aW~lBry{ zQh{C4Bopb&U-l6|gQqE;q9<YVCmErOUtb)aSHV{rq@-9~RU9_Q8<J@&jzd?+D^eMA zq-f$`<b05R-5%d8xxeZ^>3`juO8km%y7mNSTix_!28Y3#?()ASwF}pQTN6ALu1;*r zp(SM*&$QvqU1bOVIz5V9-g7G>NowTm<~-4wdoXbNNT4AANvuIj{nc0nXE0$s5#xCf zIQWnw&{uK)X)7?62;_Hkm#<)WXKaC)r(}F~4S*t>M{-^H3TLI8-<1j`p?9Uhj@u|d zAbQt1-@sq8EP$0DM7=%rjA(B3hnQPF@Akl;5qB#8)<YA4qu}h(0ku<PD~wc7q88ML z$g+e7;9iRz%oXD)3ydi_YluMV$=iFE|KZLPN~BFK<2*c<nk|sFRmGPd2PK$=2#}+^ z-5<%37xYmeWuQ(r4_ycJ3DYb8{eb@;!rrM%5GdKYP209@+m*ImY1_7K+qP}nwr%sP zez<3hzAybJ_K3Y=#hk3^N9?d(_UA8(_&lW<>2QJ`z3XM>5CcK^^yR??Xzxyvc+QKa zk)A;LPgr;TL1)@4VQ2fZRXX!ozI#^Zrvz%g<TsVq+9}3yJ-g8sl6W(dLDPYRccOY1 z5GxDY?+(ZuIWqPJ9BKXW6}9)amyAD~KNgSO%<TULL{bzUA7<swhQms1x}NK*N-;rX z+x~8QSq2l(eHEB&{SBLaWXgf-kY^?w304WFm6~?J7tw+*N6T}3n&#-eFh_dKq0;P? zlAgcgq2+H^3Hzs>rS!Vs<hxxkJ^Vbp+#cUh=8VeN?kJ<rI-f><n^M;9_{R?B=z?QE zam{j<G%HaAN?-v8A@5dxM*-W?5yBU5pNYwsXLSM+!0k$yGw*X?Ifyi<NN{yN)e*%e z+}TQjWmalN=W#VwowhOD*{%?hez9{C@IO1{%o4pny}q|xw_vV6+m9EEKMc@cTM;EQ zv{nPv#`t<EI0rEHnGv7iO(>`romN^E3^c<%n_Z|jy&h25j4CWvvkObO-@><E>w{`= zUME#(4HCqS;(s$ASZl!Sl5N^%7&AmF9IK7nnrS92P+mk6b~~8xaRTBS3BrJUJjc1Z zHFJv{-K3Oc+x*apJz&sD8<oeim<Hi1*qZWBz#%<xIe#7!oqABS+eNHt%8!ylO-RiS z4Whzv$FXaQir@@4oJ6M@i<(X20a;`p<d%f_G85!`xb2O1`PH*;)#mx7XNx7j*Rj7_ zP77kp9LokhAzVq}FC)i*%OXz>P*7PMBUbkBZhod8zQd`}Z<!?hCL8F4EslV0(|+mx z?(L7%+y-7Vf(mR56#e1Bw3hPUp$QM6g&LaE7P+d_iS^!_@w0~R_+o@(n77JMMDVZO zj`#ae0Bbt6l?<|N0n^IN%(T5FKbm<5B~&ol%KwWv1LonX;Bfh+tM?N-CHpvOGoq=| zpc1lG?Ak<#s*`>uXS=gg125c9IY?U(|2gQQo!N-L|M#*CLZt}t)$rz265K_jS^h7d zc5>t8ez)kRrDfkmKc6Ohv0abT!QvVGNpClUJ^ylRq~0|_zIa1Oj-q?uZ1~oMUt>gU zJ77BdIMb`}$k*q1TQESMi*LfdS~KH(1N>bnYzx_Q4nx;rpZEDvDZ*TlUzg|B%jxUO z7qL))5@2Y^U!VR_JU-cWkDAL!OzTQ_0^#6+W4Z&kshxY`E;Ho0J$VE|flnWx87W5G zv%V6Jb}xV^3UVA$dIn&JX;sCuO-lDs`+J-gK}d;}a|DAUCREhKYw;MC7%R-^BjrN~ zFjg8`zdxFrGn&lWsw=ETqHyfKsib4&XSjsQtTHwf8m)N!Bi+Z4+MF85b?ERs5PODA zH{FBUf-@ap!_BIXhZNO@hQK>f@o-l=Kz#8CM*Z|9Q>&ud)_rt~V5`iDpjmt%E;Hl^ zKz7W~4KBIkL=+k`fbTr6cocE80OsV1)?=vWHL|xEM#|I$8<nsGt{cv4LycW`m1vVN zS%runXL)i0%iVf8cky=ST^3U|T$>1lCAiud!`0Kqh>A^)-1sfaYuFa@d(8rSeb$&# zh{nYVCUh*g5HJ~7^BWi6kc|KoY<eiz@(Jj70~*$2W%QD2qv+S~VLi`n3vfl_<hl|b zSK?T~g;<`7@ykn1#`c{bA1f?35S-YhIdVN~%DY#uBUR-I{f-hubFm>eB%Z9)(Vl5o z8$mDhW6X<SyV`hn8ulJbRAZkdGmqd`@1csKw{gxJ_LHI&mplkqRs<S={8UDn>0W@> zfgKcEuVS>8BxZ8C71t?@??a1VUAm;+?vol^q^b_QfGVCzGLCh}p`Wd5afKsWC^YAO zrp_$Q2*F-DcNga^QWjhl&D?F#MP8SxT8?6k`c4>p>16R*OzbWY1q#{*ZUo?)Mm`!t z87yCDh0deqg5#{o^GUm_`&eA5mhPSg#uY{WgfWgcJXf~AAGy=dizgBA>@WaANAdx{ zWRsE(a#=ux3C`Rlj#z-%i*fElqmMeHi_yJlEYs{CUKR2(PEO@}D(1Z=bzrsusK*KM zLD={fxVXQg$XsxMisdx>A{B3m5%Q8Bm!fhizXC6Bd5Y;ukpY(>)?oLXkQO|4M8hQ2 z)rEigx)f9`pIPpuNuYh@3-pwI^;gEk%M{0LDmWLASXd46he)yMl5KNQHMvZ<tPz#5 zLWC-|8t7V{L^D5cwBt7}ZV~X_JRWO*MMPto$rnW`ACgS*3LRlxN3(fx=R$$unySG0 zOyk%Uw`?*vUH9QO?_VL(=6qG>`u1MV<;7=c<Ge2=BzA8AMm$HMxGB!KtiTP!2xF0M zN2Okv(3N+JuW`h*bsp~Dvi;+?WvaL<%7VB-CH|R1YDsujUd>CtYCkO&E-TB#V#?h~ z(2+fM3LR@ON{<J^oUf|gl*;ariWL3jyIIhwZ_>D-+7H}GjxRrvnHe&EE=XpAAh56R zO}U$lWun{*cYffkhK@xze`T@FZV(zDc3mO=?ToNW#)ab&qPoA@gxTuzX!D$b5U0v_ zgb6~e1o$v9i{|{Ct}~JN6X@^mTm*^#ZeX-w#T`5+#TfR&+hwPww&XD<T*nq;Rb<Ge zNj83o8mIDIKpk`VLX0o`t?(rT>5qLI7s-%wx=`h*U4I`(xHR0<l0|FRq<Y*;70zW< zW(_X&Z*!@~V)6^Ss8Y_;xEm#BNAO6RbIBUaziz)I<tUzw)$XSZSU>$zhlL_d(9sur z0OWX2+WFmVX}=_Z08waqrDj_Y0Q?Ekw9qdAB}8<mum^4M1L@sqAM7(QXeU7?fO+gH z{rUdZ<MM5t=a^rTf?v^o?|EoNfyIOZ?kQ+&%Zh?SwFo7fnYZ6Va5A<%*brf+r`oGT zCVSr=#_bmW8i2HqGU9R3J{t!3l?hXB%p0V^BI~9Cy?_;98?^1;0?e@(r6wttbte~5 zD5>Sjp7}zjugHjbu-KFZS?AY*UX`mtcF8<YIa}-Fw9@f20OZZ7O7e1GeArvUrp`Y| zKL?Dg02fPWjjCWQZy3T0J}|=RbqryGF^qpK7HXypdm4Kn&KP{gi<pvfQ932MT+Im{ zqv1oV>iO>+|NgWes1X(b0GS*B0RR7Td=_?E+kZ|~LtATWeH$ZMa~nq|eJiW~QNmxw zT6fqKeZbY74^d_-hX^v_l#PJPrbxi4ltfcW>fT8@ND$By?*n3h0E-)1tC*Pl>F2vy zJ(g<mgu@{@TU*}_bxtO=FR4AZ?C@*@JjHz6A7{TCz5NZcpU48vA<@C=v!{ZhkD<Nf z?0Trd?Ao&*%7?&YkQ0_hv2%F?I-xOcR}<z54tT`y6(~Xg4|_wVSLZT*$8mVE#5|Vn zBYdsZfYa?4OnVI4$0wg>G5uGYf53!LO~TZtvLQM$#_3Cf-JhU;#K5ul0n%46GC1pC z4!{Jt8eydofxy?l2)~<XU}KBx4t>Bsnxe*VW0)k)C5cW)5S1k5>YP-^t4ATF5UTA{ zLLHz2odhMi2f0c@*H4<U=E~)bbnyP52=2y3**G;PxblDIii&Fh;E#<Mdo@VA$LaDj zO^}jf0Bc><j~@b^4Zw73#}{R%@f+oieXYtff_<j!_sTJ1?2L>M@g<0LN_jM&LRypS zb|WOJ7p%m#lRx2<g65z<E9+*amozorQtQNq<$fZw{(~^Kyo`E2WiilPbY{-@q52PE zmuwH?2z;gL3){<z;GQ0knD64^67pmS%kscuT;?Zh8g}+&?r*B!E@FxTx?vC^b>PVX zRUnkBk2VbRx@+{f1-<slpdW+6Alb!PxM)sGk$chXw9ME0;+{r8eI~*z(21>5muE|f z@6?c0n2#4-M9eo3OoYbX88~{B(>TE^!Z8*P_rcsTFZN{2buBoh6fz%YZy5uAcPtj@ zPp!|1o7b`pO7G~<w<MM3;<VNL1#kpT^lXT!Z}k*6@C6uga_k7F)PBOj8ObP#nGO)X zmG^^Hda2lQY#bbDV=D{I2Pk3;#0Y3_jJPI{)zsB!cm5(LaNG+k&g1K)g9!+iE^rGS zRcJ5(m~FR_;uh^r*4HOfMIYhGb;1#vITkqeSUdkiArR5)FrT7f&N!W5F`RxmOf!+v z*Ysp}^B9$%rlmD{Zf7~!fRo3MTGp303sv7t8mLnN=~1TQszCN_T?~E}kMMSGodou! zs%I3ub&6qPHJ`dP<8Ts(2%RQc9?Ch6K!Sz|bg&C~@Qs2>KS|lq(bPCMc1;{e(HM0) zyt0CzQ!pI<%C?u@3OL&ayI@RGAUnaN7p5;aq!0kdRLIaF$jB4LIc(TV(}r9ZFs>X? z=T(9`VLf7Vf|?%(LC>J0bq-=Lgl0NKUyV}TR4UsqvY+-0M30q#|Cb|!mI8lIHy~kw zcD5FD-9Q^{14FTe33{v<=_FJKD?Dg{?gODjevH^&rj$b#mws}Cv*r`S`NWaJX%YY^ zW!NiT9!PK?Jv0@6M;3@);dES9w_}_!A0eoyiuvg#FQV7cUG=P6qOdb%mEUgutR}`U z#FqQP;N^L^Y9Vc0G6{1RqG=PMhe7eK1~gP-CGsEP@$JD@Eb;O1{(Owg6eZXiH#etu zho7^ZhbJe>suiUj+p%jwTzE4BCp%i~#Ui1>IOdRX)GZSSHwN#!p|RDq{?W>o-QDP$ zg_Db`Iu-A5VOETs{IJ!-`gMH=uO=H0Bkc&0EKaEgLKAJrylXWS9?ckyg<k9*@%W42 zd`A~2w+Ewl^UKb|4nDK9#o1P)RVT-VkfP=l-S(Bd)EXw`l;>8ce{f3|M~3WB0$6Zh z?--^tNNPNK0El6?9H9NEpddJPd>B*_E4TOO{c0>^9^xu3A&6N{$`g90+f#H{#o~Ob zJdGM*n)0Rq(e0g9(})&uid)+&LVyTuK-B8(&E9!YcPjjY>!6h?8uKAw`Ljs8ZnuaF zTz>*T7rq}M)vhsYiH#56`i08-xj4CBwW6v%$uM}TH)UNuHPN4jEBJVEgfxUq3b^Wk z!IJIy2SECagPlqoj}JVxfN1!u#fyQo4P;QV#(;%3(uGw%J3moo|2VflIFBzj{DIyl zb7TwJFD(s*@-{rUm|tgtp-Qecsr<e!V0qoJ5%npszfW~^Ef=Rt*f9xZevibv{Y;5e z-*zD<8m02jmiOn=!+E05b32iqr^MY;`1Xd|OV!7j!uIFWxEQ_MO5Wm#=2JDBWN7Jl zN5)s_6eBHFe`guv{`<uiKVI&QoDbf*qGt?Mde$`j;iKo5#M>)y)3V-AW`QGzkEaLI z4u6*yGbiRc?lCrLRO5Yj<t2kZG;Q>5r+*9N4aLdp21q4YBJG$-VrfFb%qtvtt2k5k zZE}%WJsQ+->x#;;7&Co>?229$J>@|p>y61Fn!NP5adzjf2`)@F@KJlfUMM8|US;P? z&gN1R8iAZ@IMrw^u<q_Wc*oIFye-w7$r8JvjxF~hP9y54kr5Sb*Pd}jC(_U#{EO|7 zc>Egq3w9<;@WJ1#c`Pu;nN!9Tkcxy~B)Y1Td7_0|@dGE{0P7$}=Uo9v3sI2D2(WCC zlDG(S=V_Lk_82LnWyKQc_o479eg&|ufK`#*!O-jDiWc1}^_uKIDNCJiP3M|K2v*>^ zXbfa`Xq720OR(G3*)x2Tnnad0p<I#~(b*AkqLwXW;z2fBe2eK=G#0T7EGwUijV(XF zPlX{wziM<%F~h#2ksW39oC)U2XB=x-<Ka?;eSLCLprVHhW(Ue6U)q0uNQrA2l@+G4 zgx`2f6){RL($2_~Z{CD<e51>;ySUT}HZPqT7qT^<4ja~`=qOKFZxa7)sGNtJ1q<W4 z$~w5LX1*@nW$p9d*lr}Sc=dyUI~@vM<Zp6u@nhKVDKv>pXaafvq+u9VUJr{vsAHJ& zNJdiKsaz9qi_rWvvP3_a4@qdLtmhlx5sR3q)k{s^PKW(@Tjh_s5_&muTLQN{{4FlH ztduUUgs<KyS#r+RWjA<n-M?|%_?{W&w{okg09E|;GpU#qbNqobU9+j;F2E^jBP;EQ zFx*EXeJe)Vw1<E*8=?^9Z^BeHPSqX{Sl0xduX1yDs@IW_n%lPE>KkC9Wqv>B64Kog zmh&5(trRDur+-NJ=a15SN!iPGTiDihSHB~FGxQ#kx?F|p-iS1B67^jGu=;C3?YfLa zbxf$K0oiT>x_LLYtl;sF$c<`6{1TF#FIux8%pq?BM6cV+#fY5h?DJA;v*JScL26A* zj#%{QVfEn#VAA#~3I8gepuvJ}GGq(pHJ6wu%Bwapc|K4u?wXTSUrK%HW$|859<;LH zYJwe$$*>>z9RW@w*LsYxC86NWZuMRtTKq_1T$GN0X-bmT&E#BQy&$g^+Qh|Oky%{9 z-m#8(1?QsK9Z(2{7h}bUEt3w>J*vwOy2(-CAYdQfup#>Svfwc|&oVRh7RAEZvI3lV zaqTiFw8@*X(!BZ!ynJ)z_4vFB5ttQ5*G>&GaXBo6LIdb|2yC$1x|wHPhbtD^T|Iy9 z`yC_*e7QrJVS1-yhTMyO_JfkaR=$8|A#rW3$uwlAQlVI#kZcZ8?wW*6gSl3YA87WF zwtvB5Qs?VZWj2RlSEeMS1ukw+0G>Xrl9VdzL~s9G)-^KA@i$&^J}Bn^u=3p$xjtP5 z;uDX!SaoLo;0nXTG?M73Tve8gUk{}0-?63sxQV7uLLqS&66UFc$c*r06?rg`bqrPq z$lL@Dt8zJ!=%vF`DhuSvv7VDYz2&q|>-L1-397S`KlV2l?-i^7Kg88!eV1QOR`6xm z!i2h;n}kiDfJnYn>m1F9$r#9vp8u!QRld7g9Ze3LLO>PDzI^MiBjeI*GH|R-3!cV` zCttE<M%2d4q%ubs>~WKH<br~svGYzq$jM`HexjQQ-gdeifHus&P)gXtdQhbGT;Pc7 zGYsYHpP&Q{J?x{tvuv`NhLE5%gSd>+rh|#k;M8Gwc27%!B4Cauo7cf<8_>ceBK$2X z$UDD3Z;fiLY=GGVn<RW}5gMG@_r96^3pf{nEAxKPZN0X1ad>v)QF=b+2*6lldt52Q zBLlP@QUjQY*ha<ZS7#A4r|EXdAzAKbF&fkm(CAL(`hl%o3mfbejmDx}C!E)3bV|%S z_!?*1!fu3TtqC!;giUzh$A=k}I%vIb?sM8ne{cO$rs}q51C&22tr|WA#HU-mq?LZ= zHdr=l0#sZON`?zOTI^e<>Ai*&V)5oOQYOdBT~tMTsSxfnSO~7ebw7(o`)FdzNl=U{ zlj|j+^I1vC?4FYH0)G`B%?MVU<V`voLCtMkQg(6IvX2Y=KdTh$AFS7hvxalre>@XK zgSCqSrv0fC=T&Zcm2dG66gO@zkO7$RB>_5^WnjH1p$}jzxyYJQaZA$-o?(~vCY4;u zwXUGrO*Wdf9ATV~l(iJ&9iWL#+>@K?Za-<LyOvB7K0&)G;X-7+if#Eg8wy6{$zy8L zYvO`JYA-g@sT5_rD_bd*r=@&VFwS{3;FU8w=;l17R*Sv#ZMV`w1|}@OF0{J9ta~F} z`N^>>CW%21jFr>2sgwVFaZEIbcqiwj`a4o^3p?4`2Lr4DNYcu!MIApKlNuj>Nb#I# z=+<Mc>@qVVFjazVq0{Lgp%}yv(3|>iTGV8zP)|)#=&_0vFcJW;R90?6O7DMpMEw3G znQKav(;bT_V>8Pq9`hvh1P1k6$iwH-;EW~~-0g9wE6TIgJ7e9Q2N?%})gm^MY1hOm zQ?%=QY}d-Gei(}s>6Y0&cb7vv(fkGb@4sNmOy;dT1ONaq@_z}$*k}!GZ7u)f5Bpzz zu4Hv<+s!e=pBY`oIDa7~l8KagoxVv@aqH8WYgQNH(r-PCl?d(eD$0cIzY_|xfFI@j zd1wC|Vhg4w?<S)?FEAj4shy3?vok!8RWD{b7in<5V*J04{7&`{IX=25uGq#mG_bSO zsm1Lr|Cq~v@S7#Yk0G}+m(0^MQ0$xM+Y1YF#0QNVJua4*7L}_g=o~^NTu=rk%x4Bu z9GYcTCyGU6SJJ6mlKagv_!}$-3D!0XZ=sVx`-3T4%nQc*V#{9zhwEEXv<WLSs?XV$ zMbj~UpAE{a!DTaLGSD^ZccO(lx@2g}X{c{#tDR$<>84kYx)OO$M~nEm3k`m7n(SL^ zVnH#JNxC}Fv9&kN8dt^_(@yLaE(q3k`6mm%zX{UZyq-^&BEV%B7O^&Ls5R3K2J?f| zpVayZS6{CK&|>oEjY|yJR@8Ct6Y#N|43eK!w@Yu(LaG95-_l4$PkFwDs_O-tH7tP7 z)UXB_4?P$p9)*2Jt_J4xKY_Yb&pVi4@~mD%-`~L-Hj1dEe)k16^oKhmnXSg>QA`T8 z$K?0QENJMG?9FF+D<*_FcU0$|X80m5kVgL)>np7b9^YH*GjxwN;wD4rC!AN$7+ruj z??29UspAZEsq}L)luI^HgvRq1Mp~p7Gj?Ku_);Z6ooiFMV$TbmT4qV0G~dw*WpKNt z+4I#ziE69hMcS`v(x8<z(ESs?U{Ho6+o)M!i|}Vh6e}4*F_Zm1C~Q(W>h{0}oI6S0 zjuDAkZ5#NLFm*DlbxQhOsKU!VHE713b5J`R>BDZH@9M+-p0GZ=ITU0X&J|kHjA|9c zlXBj*3Bt~~vV`4d;Zj$ErzxsVa$RdG=H&tn;B&hqXdbP`;1JJ>UD$Aw|4APK)4pKB z1;h<x7`eWX3U+jIV3Bcw?W8eq1emnrvUbjKn2F}f?4l~TFk4yw%=6wJV;n>YuopSk z9c3A;Bw?Lc8`ih3@bB9xK9&iDjoK=r9y#bbdW<Ys+Bxz|K{UrfXheL+tlb~WoJlm? zDOFP+>?q3+679FSLWW1`R^)hO?B1Kub)xY(lR0<s7R!)~hEURBF9osfdAmYXJoiGp zkdNhHJ92;4o`a4MWXrFP!IcI4Y_)Q&?AsJU7N>Zxp9qe#-QW!|ZiqNzS6i;UM3mX# znK)mK&GJ$*Z9A<K`+9DOls4Z3Oupr+vBJ4%u1P5;QKO-C-76z~;6mi?ZfexWek&1Q zP>Co!n6~8W`lZq~=batlz6sciAIU_wE7NI)=$cLXlWKm}TKyFB2$~3U@1pM2;Gtry zNOhIVq?{4(Nk{;BE>;e_glv`*&Rly*JYU>tZK&}c?K%G?1akEh7g<ATQqzZMaOOZ> zp#Rri@x>6(W9W-KXrp1Q3o<J7)hpJGKH4uNZ!=#0WY;PlTut10mJ^tfGtA7+t5UMb zo(t!#0{f2c%}%|SO~gN+8q+`A=2;t>eo;T{`Ff*}{!llA>r&?X8a;hngd$SM!S-dT z8(0TXo89heyq(>?cd{<za~>^E52mS?Qt;F3tcRNzG1D+A2jGc@jaS=@V02)1=lB;= z%y#_hJVD2=dk@cgK}5n72Q0PT_J_lR<jCU`aZmD+^pEaH{uu0J&Ojas!A@H@O-Xc? zRuh%&X+Ok<9E<S6qt3*Y=$+RGAM48e?C%HrveJw||JT0Q^Vgfb!tqZaHI9+^)UDuL z=JPUMDhwQKwQ@*Q-nIv}8q7Q_hWS(t$4n+=M^jBJ#kb)uhioro$+Pd4qZ-l{I}%nv zetKGFQEE4T9=TzE@Vu6Wo)QJ7axcyK!!5ESC~X4}PDuV>+{<gb;nl5}llc<b)f<Lm zOY_|iO<kenBcuB{Nr=%dsF6C<yj4|E@4US+O#_w?&4@BB5|$%qeel^yuwV0C(^j}N zMJZ+{32cBjmuO}AvCX1Pry;<nDhzbUtrL;+Adrp_njFm&7)Z{jCzYFwBL@#HW{J%v zqx<m}hm#ROn)3dRJhYp&Uo3iPRI$@W3*dWF7e}t{LK13CHjqIr)^$@SvHj)KXg`gB z8J~s$vDwlv)wJ6x<aGDP;ly4&fdDimA$7b}V<0fs?;(XnyI;ClU!(d$@LIib-)|t{ zM-^tsK5OF^tCMghZI9gV214=Qp9zfn>VKNlUBV<r`Am*^<I{d-b!koJp5WKBA{u5V z*A|~ETW92zy60>{q9!5KT_z=ODcG=G*eE9}z{SUOGv^OL_eoMz5cuaY;C_pPTYUKb z!J%K)-$~{ZI*$PUh?xi4e|Wo;RMdrELG4-<)oiON(6#~_<q-_xT3wa}9GFdWZ0p;R zf2d%@@gXbX<d^1hr4*lP_J@^n#PYKIE7lT@z=Jv&%ox4M`u3-5N(jE=^1*3<YHEv` z7CGfIm4zLIdh6Tz>!kWHNzIBk8yHfHh2<C+agKC9U8$Zy_u{1&05klaZL8u@p7!Po zkXgni{N2YQRJ$j`3FU8ycKZ-7^~{f%owjdpz$!g`8R|Q@Nd`Ny3zs$km+~F+(z=}H zzJ)`BL7qdUIMdSiio9`A?q<C?zd8Vy0;y<jIlR)mk3HniwTX?4RE>srPHwD<DF1av z?0qeTjgJbC7|N&9h<!0$h~!y941v(svwRf|G_ZsH^vGIUr7VXxajnZqtEZIo)<*fl z%HALE0Vw}Zt@jnSsdqLGC`e8Obcx#oDikAy*@7KMucRC!+Akk!eJ`uUWM5E@%-lsp zs82nnNE7gXXX4f^Xw;2&6g&m{5{|6rKIIS3z8F5l?C&<?m3C+|8@YiSx`K1v|NqK) z&%RPVB_aSo&p$p4`u|1MtPHeH#*R+^<WjT-Mn?Z5qQ2(-&ww(&`%e90#FFpjQJ?)} zKEc-C?%>6k!6KTqg=z=uE>c61Lzys2N_&X}EaHFqz9c8Y_ww%ePMV#*O`3{iJ_-Kx zQQQ$oY;S5}dTMQIlFEHWgUa{$jQ9DlI(r2#CU!+KGfZ>no;GyV9p{cQoAzm)5a~wL zfQd~Si2_Ehk8)C3bJ(R<Dv{ENv{!GmJ0s$PI`R#fN~D)BLJ#hda180GHkKum7==r& zkOt#7F{zH0t`}NLKGj;Pmx!trq>*AYCeDX0{SZtbDg8loqLDV{e|U8T0MFx)r9kFJ zhe6Jio))PdvI>;ClKM`Cgbl-}5#$#)>wd%e3H%ablGseTTxxi8JOu-1Z)P@+BdhPD z9)WWM_p~}$<H(@5drtx5$GTo8o{EM#Vh;Eq(MUq!r4RrxD-nMvzv|AUN1XHNn3vEW zB_x@q*g#5?cuydJIczWv13$9}$SwJ0KxjZfV<WK2d1gd(^2G&mAIg}C4vqIJXa+c% zE<g#LlgKmsMS>dB*y)Mn+1VMhNbae}Gz*1a&Ws0cl>vA<Nz!ScC@g@x1}M+~i6@eb z04rsMC0ft`h*b4f4^itL!~=B0tsrg?JaR%L32HXz{rvMy`B;dM=b?#D)QP4MYY(|= zXAl>Af_OCL-)y9?cyJt2a|bv8M-S-eWUvWF_htRh^O<Wx5J{tJUN{qNC?5X+b1f5K zX8*AY2_FI==@8{@4}NBUzbMB7v|&7sas_=tP#cy=+NRpOOU^qN5fhztKMw~1Sq0pL z1;89isSfjciihnlu0MS?lBa=EcbFAC1`5e79u$QiDE-dLC5~W{$8cCPr~v2!l+dsQ zSYQlY%oYQONP+_0!P|loE7-YD6g?CGpn)=T@~*h2Ymmo+97~d((0lE9#gs^qAoarf zh}t<)t4GBzb#z1LSbglAJvk`_5SfL0QuuWwPwB&Puug+HH|I`J^XV^+Nc{4LkRFE> z)^mo-Gw|CKmmHa&ZaFX(=9oeW&cS`gPiCGP7Yr}U)hAA3>%4xZAnXAlDaDm;tvZoh zt<Mmd3kFEaAA{Hc8>DIgX$TNvU=^rW`4Z^QpG<*L1uXx*q>?>xUN%5rz}u86?SqIc zcw%7^8-f6Ui%kyAdLbc<^Z*Y6n7l%CrYJ&jKeo5zG;#&Mo}ihS5LXQ_isR(5-T1pd zQIvShRKVVQJ9mH-sU2D&?_En;{9ud9eElUN(u@L%JjLmKOf?iga4XUuQ|e)V2Fj^Z zTO#rwk01Gay?&^_Y&*&Ley|Ozu(a<O@cJgO_&gdoXT=fps@5ZlA>%gl0@zHIbTf$| z^&(RG&E5XIG>#0#pvDlS%o@vqH%ZFVf}FbN*wmPXYUs}mji5nE#$mG%)ST)q{ITm^ ziYc^I=Ch<yv1_Khu3IcT)Nk8k?yp(y32AAVksps(=k!gn`sM_As8f&nCIJ(Dw2v2o z_scnr4E3XTb3rAUbc04YHw9XVbT?3^3T6)d8*t4Jpv(O3I8R&BIvR@lDU#w(+FZ*D zMV>BRvN(q>Ds<gv&VYIk13cpf$X&ArrC1UNXbs94?FN_mp7MB#(``eo7goAXHEmgA zP~>LE|3M;ABM;aT$^+mHsZamsD>oD_-n$-;63;J(lygdV)EA(`50=Jl?@Ed>J#b9( zz&ZS<$=ZcM7cf{rP;*8|myN0h)|<{PX_ozZ0ECCZe(To?fLYljQbsy9fd!>sbD><K zjthM6a&QDOjA$Boq&HJc5PuqY!v50Ggi+-wuk5QG1wQzW?Rf76P^*0XCKBfoeXLd4 z!?49w`j@fa4;L@WvPF^eQiD!$3P4bix|tr&pTYbww94b=sw_UbWAdsxCbz+7<c6YK z67;&Emm^}gq^bZ>q&n{i8L5e#hgPY+vEhX|8Jf<Isib0vv9BUzfXAirfTD76<Zm?% z2CtE6$r<P91*N#`Aw%>aUzaEBCCilimAEt~Nwo*gb=uG!Z%U}Vqk+f@CXwgz*B=>6 zh(}jt19pjk<kI6;$RYXy0pD3Q`TRZfwI}F#GlE=6VFI%peO5oP`*3H5`umVUccMqZ zxHq-9f;_K=G_jq=?(XiGk~^Jy7%<JgC;9TQxC%5$c7|_)vOi-1^^^d;uoNonpK&(R zLDZdOw<V&13)(8XaS=C-aa8W+62eU_Of;=7GvDV^B1GHJ;0;*Y4{zdA1`cqX-D5nq zudZ;s8=R#L1CIzd0eJT95#DUklU3$86xA`*)g6Cjkoe;nIB}G*%~DLC1|LbtJZxP1 zQhBC))dE*Bj~q)TiDt3qk^fcl7p^UCU|7&<pev%c;4&m?l?{{p6!PhkZ#vMQ&HLt* z#Bu9k6q!>B-!O~(&MtUR&pKQ;Oj@<X=qj0A6@wHQM3vR-JK(7D%^X&9;^i-5Biz#O zn_4`Qe`1DXb=ys7fe|cq#L+AE0=v6yy!#VXWJQc1rQxICsxU8*y^cd1q*Hi9^ltcw zs&2qA?g0S|S!@_WiDqD}Vy`XlHo?`=IzBl96cs;sP=6UKE6VrzkT0tRQ?H2O&^Fn| ze6R0!4PyrhKV^;forWdv8!zLOaNq_&+5XnIG;7$W>c`F21B~D)pr;8*)%Z=*WOgAC zdFC?;PSVQ|H~wpW%4eh0a~BhDtt=ufJJ-27NGmUu_(H`pSq&=&B!pYkIy-YNJ7>wM z@+;X+TyaE!Tv=N9DpYelA4M$zb*`8CJeoebzk|fB9cN<#SpxfgLyYu%{qC-3%1drI zVTUUWyOXp2O|%iKsS>y)F<&lsP5et;jU8^2w5ye9rTb|j(Zf35Njj%pwM(s~l-DZJ zOtr@@A-M7=R^JdqTXqXFBEfrYNTYny!Z>@*Zk*)p0*34teMlEXdrL+><kMksTY__% zye|jg8G&0EHOlN?{i7BD9_J{7d{+UZSS*62PX|W4`^U+^KkRMdtFNbx%RrpzGrdjX znNggTltdFVFrA?1b$t@MOh|dxO7@ZP8xbXsaN22VFpRVwNIoA04SQ}aSC4YlA{%va z?VYP?5*XjRjJI+Wg@R1SkocH(+3>eLY+-4-p0=`m)3MjseW`PY1@#248}~ci`@PpU zFRa6LZQ^`TBuZS7E|KP}6cZwz4TMW5FrU2%Q|v=pM$IVU?Yw?*@s;=Gbk&si<prWA zd<)-cmE~oqu==Xl!Vx1H_v-*_)2w{`!s4uz6)Y**MhhBoF`_dz%zUUhuKdGE_$>Dq znhwJNwW*zGyU6md^7>F`Ea~EaWxlfW+YPZY>!YUl`gYG}m`Zgwnt{vht<JJSVXcgF z2ecbL;`WVYz9Jj&Eh>BS_eM~)X8^2y;fbLfuM3hKvkoqWCA;TGWM{e!)CM%wXHawl z!(Son!V4U>NZUqR4P-qft;YDc^W2R!6}e_ak!^g8!;{!hXV#_XdePzMYm|><z7s-c zoeQPLhqm*C7-*H6>Wfi!o{+nk(;?Y)3W>;YKBATgslPrC1bwvIhNf%y6Y%?`6=z3X zV~mbir|fu($FwU=IjMg8f;PwQrfz&0-#gbmXM!KMdyvm3ARO*0FqIxD&$<A;^G?+u zC*~2uL@!}H@IG!i6*qg8+*>NJl50sN`%%|$*pUYi2DxD>nAfGX&-8i7LWpbY{BP5G z@IRZcc}>>U6Jf}ts04--aAhobhXBe>u**cre^cf>l0h1=tNJpG&t~0mBFjW41WLRx zJ0)ErK^zKWn-h|EQJ?{YG&(~j@h0kYt`wc6x<fTaycuZ=>h+};f7$?b7kym{MgFOa z4VZE_L(bVQEj_v@FSR_^Th1|HpRBlps1oC{7V7$n>Rj(25|+(lWxaV{Af*3lq+JB6 zPAa93tJR#lcy)cdZJz6waN{OD62{+r@FW9b&l4_qK%2s7k{O`QR{ZX{tX>*zW<S3- zI+#pEt5BCZ_-rQnLm#6VBL{8Z_xeuhwy}V-#`hQEXLnLW;(`3bTSUPlmUzxPit`xn zmK7bkLzJTjLu;Nn&D=2kdt0u_X!TAE^e!pMI^0-A>kY##{8KBODm!gFNegpcb+(S^ zoO}9Rby(KQPN07=k07K0S>;A5zN2Zegj(^d({HUekD8XfNtDhQk*n6NnTaVVg37hM zsCeOR4cm;Qs{KdE{D7?EA1J?7x=FH37kBTN`|=qd&Qm(2J||W|#jBfS01pmT(=kn1 zn(UwjK2hxJ%QCaju*#J=i*K+r9l#vDr}8K2NN+IFd#+jAWv)Ad9Pp~NWzR~6T#seK zVQ<TuH^pntsD~S&9iYcJiKiK&Va;X}mU^7YvvvDXzGYWw(8j8t!`LCv<ltyhp^)%h zlR@fuX0_3ux+wPd<_z-ZIxSa#!~@gE%(1K}0^IbFkCDu^vXr5LpLwnHV`pA%a=u$e zUu=B3Lw$35ih6t7&)DXF2~qh?0}c&IJf2{F@$lIbLyoAt>lAmgaqwL`uI7nZJ$|eh z*7)1>W^)_2B~Cw6eL1eHTFG}&?W0nP<<RD<<1=ibeJG)Oj{fi~R&!Y1nVqIxuq_ z_Ji3j4540{xBbt!_z+93igPpge0$~KEm8@VN!*MXL8dNh@ID4xl(B5@ni6(0E^to` zLpX3vMaN;F64M`V2G>9DFI#u{;D?IR>cJxj@-Ln3)36~&!{e({oad-kD6*9E4V-{F zhJxzHD%9%^ociq<fI}k35pLmS3yn0m!%0?-^g!dNkg9c;Q{Z+sx#gfC0jX&V%%Za9 z?_(eTFvYu-M6xWc+o%pT#6k>%7zodou4GQc#o03;o5T4__zqAH()|5;cseA&HPEFE zkS?Y_C5qsQL~67}utq40d9bF4uHhIL(QKF>9l?7eP-8z0`O$t;lkf3z{4T;Q$Q{>* zvw?!N#_)CQ)}4uU+1KzChdk2fWB^=bdx|RBXXMCGbPI<ddYS;XEr3Rhuep&BKMpWs zkV?=X0juNegE~AgDZAN%TYl_8($kgUT<P9o)CKu{bOI2yKui&dt0U;nsP`Fb88pe| zqh!fB@jjwniae9kQZ*F``#{PZud>>%i5HGt50i8**!;*9M7nRTZkVrhwcYK)$;IpO zJ+9X$Y$QZccJ61(`iVLlp{ZvPy6h(sX(d&bTj?+o92K~E71GwPY?+-|AI3GMYg;Z< zpC~b-7Im+y<CVM~+w*A(NsI?4<n<eDag45BG0D{-5PBceHIJ-UZd?)m&A@81RVr6; zlCLPMj@+J5G{kqtKC960fNk1F7jZ+JJ%<JK-&WYlV^g^5Ic8H@%1q3}Na4t}a@iG+ zk(1=q#APTd@?nDN(B#INv+*J<I{*2TEP-BqIY%2!C%n{qdFNsY?f+!k2>VExgPUwi zBtKQBljAnXm@-6R*6h&%t@4Zpf3NwSJG5Z*_Y)WuV@<X8O}k>16~OzD?z>;08#FL* zfzPqz<=9Z8`<LjxItQMz2Ycwjor027u^gJ{lT5ar0Ug?-7`*V3#Q)v`_l?WXBC4YE zedSY_;k22nvbNi)Xh>maa1m$eQHn8GWkw~=#;r7@J^a>ji8XD#;!@y#WwvB<w|(=Q zwB`v@isR`Y!fOH{$s9Mp7U#-)`y&Bqn?$RV$z;mmTBR)5hZ6d>K*TH9_UTT-ftKZ| zvRUdkxjVwIJyqql3EZZXoBpm@M+R#Y=N`jV9-?^jIxn@(cW|RPOGvVfNm!Nw(24?g zZ}PlzgGu7i=kR+)GA=n9yd$9KkYlodb8KAGwViN|>We~pQN<d!%6BM*XD|aon_om< zyr9gZ#^3G-^uHHF@{R9=i_ic7rT72<c>kBYu&~koyYi=XH1{y3{Z9g8t?y*(@IL~W zYK?7&%@Nd|Q>82+rOYOYxCGkue3F)w-vYL9?L`}|Lk<)O8VO+nKRxt<W%2Z#rw;b^ z3#XFqbM7bi2cT=>C+^NS4f!Ny$x6)3z8g-{*<YW|wE}x8FON1c{-1MjkLN4U=s|uc z4?2~$C*_1@O=6E^(wy1>N5sE#6O#rL#bYGyizI3pB21IYQwh>>xkIOEu!(D+mmJ|U z!6eNHP0i8m##rw9RUt=yatnv>DDLapMtOGzbG0*Q7MSHEuqJTLx@3|=O7YW_Nlc+x z)!W(EbR@7YuTQf(g)y5J*4FcrLy?-*rRjG`1hEU!PbLZ+x)ewwNN0Z@gsAF|ic#F_ z(f^7QYUTF4e>MIr-na2f8IXSFWg8OG!>NlSd`KcqNz+PguS~{8Q+4e%TG?VjUS~4o zNO5`m92`A6wq)Rjh-l5_J4FLQD#IZ0>qe+!I;-lV^amCt47m-ZfzJN9OUN;^{?k5O zsP%+Ky?E>3Cbl8VwIX|AH<sn$L}w81F0I+7>6!B_BZsch0P|>^nj^5fHVEUk{(`O_ zV&`M`H}ya`xCbJy0p`@61KJy2GqfJwR7!9>%0!=}uhbhjggp{rCOjUNDz=`X!6bv! zlwg|qj*@X!nnyKXQ$8S>*>3Pb+{L^Z;u{q5<b^YVX7fl5h3?O>Ujnj9X2qhQ)}&w% z2yQ{f;XVG<Rf8k6MZmR`VEP>*uo*>uRIgt;bK4M^aeXp#e`^U~X@R}l{z={hvN;To z@0AY$eDeLb?5$w>trf#13GdNxS3lHf7QaLDr7PZTw#>QqLZO_?Y{{zXj;GPWzH<VF z#|0riDK2;&xF8as!*PNR0dHayos0@Ty;9fVb-KLJDXll%5)tHCgY!D|PerID{V~Ro z4`oRabjTxp`rsk`TQ%_dt1DQb?n4Bh>6#l%N0iU>P<MK0n#GGq7+1E(&pMBkLk+7O zEkUKUc*cn%t)*I|M+d?W<^|%oqw+n@OqV#8{b-3<e0fq4MYH@s>Yfin-I}wplAlmi zJGaF2xTa+fL&`)X(Va+mY$)`}F5TOF3x^+9mIyNANMjg1()KD7p$3h+IaaXAdua5r z>61geIO#WybbSc|pwc-L{-*>LLapa(%;A2wXSHgqtZsJHu6*yA&Owrg2r@zu_sZsK z?zzx((8PSaa}PQ!w5B%tT&~BMEt*o3i{K=WqJG4jU<yd6Y&>Pf2aUMu{ao4_tIy!= z!sN_DlN6-KnvfeHM-K}ud^yY|x&+D?;kA#Cu=Pz)Hk~#6n~0G{g0q{y7oVy3-k1LQ zokKE{&9cLo$)>&@y~8@S$38+gP&TrRit#?nDOf8A=PI=)FbtVZzO)`pL|MfFU9Se| zObq|H%Kkff&qVjw#%S3z?MDquHB>jHcw$^)wzEYGd68X)aK7*SvV_Vluk<I08f5Q# zaeWDNOR*n>)j)SBH*Pi@g%XSRQau@k$4`rvXiRzzx}@I2(-X^NvBmS}(IwyR=ncT~ zC)49+844a{W8XAMQ8%=32yWH4v>($GQQ5BhZUWWhJkG?E8*R_9Nx@ks+CKek0bzDg z*GoUfPt0jyO^zP9P0l_aO=bBmbZ^$kS90<2n<3X2$~X}esU&p(q^DOy7LnL1UdA#) zjYincX(VB~bBC~9cq0ImUB?lP0`_}CZh_IU86Nd2=WFbQ8gc#Bbg0p}O-WJq*s6&) z^)=MxrSzBiA$A2y$|cdiY3-tj(?qg~Q#HksH{yBGOERY`%sXQ^S!*-Hr|M1N;Ws<q zo!c#;<RhQqj=iohJKUCWHWu8H@}F<=S@ekn7EypDJrDs>X0%>%!Y>A@lPG^KL>U*7 zYs<`c5ggcct~!L4pJjwrmt_R|2cxZ%OeO|n;%vM1%0}9=dDo&$1F-8)%!eIDq)KPw zE<wGbVYFnTez_?2=#N<e=OWU`xME);^Iuh52*1?7J}qB;PR8&cB7jEZ(#tDWHLVXq z&z0X1g%34aoWVEvszMX<1!Zja$vw7p_j{vh760T9PiE7qqxPzcG(4Od;panE4bA5+ z8Xe|z=+`4Cznr$1pw(1v3x9~Tm&}&4l<C44BMuAv$*yJph?@4^TLeFrfW}@&R|&)a z*#lV4jD%8;Q*7K({D3^PKzNbti~ml-Qp@x%vOg-~sM(qkk;?~=)gSiHD<g1&;#DpP zHHrQ{FI{d-682EL6C$mvt%&Y$CB}ziFH$AF`zb-$;LtLD9MPzpt3H4Z=!huAI~2Yu zVfCSmtk1uJ5E#K#m$l)-ctb1dHRIZ#{6<L2`m{N-(M8;gd)ov4kO(_scPu++Q@?Jm z439Z}h$SZtT@9Ki5KxtS0dy4Rfj9K?hq7>I2Ccw4(}s4gKS6~Q-B{gj`1E~*(p+!l z%!PE4+!-MhImymyer|CBb~1}7-wUK|*I67vBDJB=RzjA^H0yT}a8)X@x@YBkBMJ{* z_ut9l;l@OB99Ad<sv4P-1=nJq%jpl~uGta1s`QT~>Tj@ra*v(ksO!VdJ?KVJbgOJ9 zRM#7@dB0RQ8gwg+g*T5N?n$r9RO5^hP>nm<C}At&mRB2$08(1BaP<%6Y|VX?FvQg| z%E03_4b0DsTE1+L58K4mBVjA~<Gqms0|xwKY8YO^T{D<~eX_DuFkAm@waC75q_RCS z6`ugVvMj6SLRa0b>-5&5gx{4NW87pvu_5XyJ_e!iidmUisLb;(h!Nerg#+v5wa`Pl zhQTLyd}&@W7#EYRFA1uY*ihxBcB;=F59?1b9T0fMsAa9_*@rr8pnBPN;FI7DMnktG zU`59d8C*RHCDdp+_B+~MEqr}dzz>!E++Lme{KOqyzi4ysn&@g$P6+<giKyN}yD)$- zwY8Ev(@OW(v1EXCsDHvm=-Kqq#g3xT9~u71)xSJcwB?2_Run#ac=%A>qqJQM%Q<ca zgHeai6ImS-bGdEdUb78ydyI+cu^?f|m<2`;!|ZR4DT@AC?A=fCx6_lGQ`?><j|=Z9 zdRsq@JHdQxF-0&u5nY;r^h0JK2T0L^b|~k;pynoV3L669x#r#oqTBA50-Rn|>Rn^p z%sW$MvRT`0+N`y8NPda_8<68G9z5H59k1?os-IvwY#rd7ET6R@{ngWtRedt85IOD4 z(T!^r3jW2wrKiWr6?Z{|M0{y;%T1M&N@%#($?+P(7u~JCvwb>**MPk#eOQyQZdKYS zrv~m#e3EAyB%8Qr;1n0bhpLc-^R$R4#BO>rSc~Jx`Xj^jI|108zl6OpR=?P&pKTv+ zoYQiOh2dupZ-;f%?(yqt0gz`}A%l-h2Aze}YhYAr^&lIp#n|yTTGefN9(zEq6r(k+ zB4>U0E?K8m7zM+})Xdhti?V1J?mF<aTW2GH?)l>PQKNL_+*C+;Wfrcvr^!oc-_`s4 zZ=f`<=eU|j7W4|~>&G*j?h)P<6H<2({_l(F&)on+_J+OUKu1i^_4YO99$Z83&*ARx zhm<pTh36lI7-<fw^Pk68{2vLdbaguf^5hXx0!<@(^sf;5Z5<IE_uE%CxMVz|O%Er{ zXAD`-?mtW1Z|ATeFJ$@V6+Afv0X}$)>O(r2#%E~r!DQt#!ih|f<Yv`#{K$g)pN5xO zGi0y|m&lI%wB)BcC}4>nAsc1=(^iVqSHprJA}!!k7A)0P)XQlE>xDdeqzu;F_KKb8 z(4?pO@@5flpF}EnP@0x+F*~}wy7aZq?6aw>r<TgtwNsI{VctqN-j_XD8a8drw~v3_ zB@_#)^1h^%q`g$t?wUC-9sb^`wU|O7WlKf?7gJV96=~pEN;Kp(9$Jh&+k=>heoQHJ zjD>PRzAL1apf5>{XR(#77Vb1&FgQU0?GJOo)egg)2Qs8dYkYQ9@}I(n$H(Ekd^%R8 z%K@UNe!EMU@ut%cc>e&k2_pHXbXHS*Q`#0*b&Iv8Hou1P$0fxtMRP9Y?y^NlKC*}i z*sE6NLMg!Wx!*}#_Wt)*fvGH49qOM_GxXm*5yt<06&M-*KQ5S)yPfg>s*zP|o;&=f zMmAfTR;+}bAQF(!ytCccqNpjg<2(^Mv0zep-~dR#K!OO2K?8<RJSKUphDVT0K9zWM znbY4dr3#l!yMK0W_;J%uY#%=<kAoNH`+c1|kh)GZQ*@ca-oE5h>q<kHB1lNw4=)@i zB7_!9C0s$29>!TSQH(;bf<#(h8{5j1cpWVW|rpgJr;q6}tr%(NmW64Gklg1|Jd zcvf6-hb3&3=7&Z&WJq@Q<PnP(j;|8;<Z&9@5kc%jJ3yeMfde+KZl)pZfK+96{3)55 zvgm-BM3lfqbC8bW&%y6aiN=&DB0sWM6xXYY)JK9q$WryIs&($9G8HHvGfYL-j~54) z8@5l-3Amtb5<fM7jWiCQ8Z3E8rLw9wgAdKG;Lb7%I``$|?oM|i5;u`d#iwCTCzL#? zW6R=^++G-eH(U7LnU%KC^uaS`Y>)UGtk4Y!RX05A3wiJmJW-HF@SYuYu!q#}afqi% zd-O@5B_8sfFHda+-FJE)fR|P8WJ;px`Kfb3MVb2-?-S1cv4$!<JMn7dUx6gqk_oN$ zP=j!H`qZ9+JX#xh!8*z`rcIlQf+oDSD6A2$g_$l$(^&PhY1rJ3STs4M(E2z>t&s$7 zc>KLm=QcW`LO6nlBK4+(HbRh){DDhN1ky1pupMh4bVKM&B|@m&0ivnr7M&qjN906B zv}O`x+wtosKZD;oH`N{rs5H4A>N5al!r4}?-wY8P(uolb{rPx%QOA}myCK?lVrfcK zGiy2#icPSjq~1F&fTo5(iZW}b5dJOj8#8M{qZuO-J$%CNnTJ9$kN30rj~)$IP$+(V z5+;_g1D=4c;Q(ad0}#WV_R}A2a(W=zPrvC(%p<CbI+SXa@Xee)`0mN^HtqSfwKn&} zhT`@CP3=!2{|~1y(3&*L<TUgqK-)g)EON#!p0RU!90~scA<go7=ok(=INZ~B!_$Au zJ|Hb1tH;7o+GL!$Vs_{Oxkw=ujU`f(oym5ZE_usX3OB4N+kvqrNj(r9$rwNhi{CT7 z{9@3~aO$ZW9!uWSHc;&e03f$wX-A=T0No@K6>vMaKu?4{sGG|OR&u=88;@=b=zKd> zTCv5NogpU>%cKMFc9<H2E*zQRY({?7{4l+qbdx=f!Cr)!V+eT$(LAW3Mi_Pdlu`L% zK4!1{hs;n+!___u;)}Ias+fslF_)&2zaT-w4OY3U4!1&xNRJ0c*r|Aa@&Avob7~JI zT99aL+qP}nwr!gywr$(CZJ*%8w(U$_X1=*k_dj&^-qlsL$^?%3gue~<afdsu4?Bu( ze@OUbV+YdXgsX7XJT1-qX3HCa7cduA1l$O{5MN%e>&3A-<TNWMTrTz+Jq8rBj_`fO z^L_?SME+X*aro`q!Z*%hHN9x%4|BrAvVpEW_B&SOL__^VV0g4Bc@7T$m1b0PLXmO! zkGgZ!LR+d*<>tM#J7~jZM_xsvisgwQ(aBNhm+b)y_eWXN%gyFhjcFkGEFjym5;Hq1 zrL*}d&gI{)vwu@bdHVWxnMI;LAT8|SXTN^4JL^6%lXI}a-Rcm7uCi&$h{Lms!E>J~ zUV<SnuL|MgPxCCh{<O>mB&8y?W^hh0NfVWA5#ul^9mb2eqnsl?$)M0=b&PGYFb5g2 z33{j8i%Qv7xJTBENke)tm8e>rIb?}bWGf2aQbRo4qTG*z6U!f_Z@Wczj1qmSIf1u8 z<Y^H04!a8pz0Y`_uY4U{G<LkE=r4wfeEv{%0{&Kbwj#@%P%yt_!>p9pF!>+k7T31! zfdJaR-9!_G0Rj)s=VZIlK_1Vr$Y+`ZabYPgUz==MHa3OTO;;aKu(5T_{>9!<4E;#t z9M{`WCjS%I-W3>$h<~Wl3Kf$Cd)A}kl)aFQeF`NZvc)Eo6HMvEACWK<jsu{ZD$<j6 z3<*lpBm|u;<G?8&4qfy?pl8T2f`5OEG)Jj4r+(b1we5E|R;j{ej(JooQbKYiKOSpn zD$+bqNn7g&)TE`J<{Mkn!s$neuVKdjv7VFHWR%9w%#f{B$S9O1`C+}4zYuDss$!X_ zP<nsjAVrL;P1lxNn+rAa4}SJlZM49R*%d?L8epOg%4TG*^kfk0&4m1*Rp);}Rw{`$ zz%8TNoKV3H+t13=`4yffvU-$f7KJki-VE#!DiTIT9J}I~?_P^dM~oq%RDv#Dx@p!< zT_yQcO{SsPeYSq4tTI(k8l;ZP!A|;4IBM(k)iK%ej+h9wSOA6aiWj<NDj{wV<g|FD z7_eDCHeEDA?AlsN$!Y*y5A#5xWm37_M@bmKn<3C#-G{N_F%j1*QfN_|LY`SxM|dw0 zC{0xd_>|V-a{Sb($_xRuv$>vE6lRwV%CDVrgv42wjTnJAY9g^LS}--8d&zV~;zff2 zSC#zYna#p?L+4AAPa4x5b`~H(Mz26MY1iGyyw=k$J8NjCJ6F2h(J!#g+ROBI9w9RW zB#cRIXtp308Vbnx7%ubi&DC!+CCWEFwyy?sXYQ#VCexStEo?Zhuk`ZR&s@=C$^PC5 z8|zdVkFv}R*!5422u$S*Tm^hzwRU`dZPk}HT%+JLSa~EpMdK^b{F8LS8;&?*PRbWb zXd?3${La+BH2xkr>&UpRu8Y3+h1N={j7ZwHuOjLIjuZmZGKI};qrI&Gkia#dtVA?m z->pqj?7)?2I1(IFwM^Q$0%WTvbFiFtQqVqXja`Pj)2kRLf>(+4`Q56}SPpHoB_fD8 zZH12TUA9A^$<hsp?2L&(>aObDt_JeiIcbo;B6fKw=LT`s&4LCK&LijSxl$Y*#Tasx z?`7OGFpl2qY~5$G=Wc?5V?M6P5zoJB!o66b2^3QKvRlO|46@ll7<84buztIlCZwRw z7}Hh`dtP42F7RzJZrtrGjcl)S|5zB6aqy0FC;e+zs&%q>nCYh7$$20Zs_*-1k?U^k z#-Z;Ml31ZJzNnnZ4I26SRRFjZ*<LG67B!D^KF|g}<Z8)PdGd|QOD)N?$J2*2m#YkX zyi~rZ&*uK~{=v_d_~^BHd6FfZl6*YDjF<ZuHonLDZG88dFPz`;bwlYdz8lUgeH@*b z|8u=$P*tU0({dv1C=PutTT$hL<>6#1mvwK9NbrMhRIfE!^4AkV3Xz@(Wo%)db-isS z)gdoym;mg{aYqME{J3VfO+wM4clhMJFpke~tSPsrm=qt5yzk?651>y>EFhk4W(pex zlCu!UO~JIhv|VE1!@V9c_T^;w{tP?`lZ#8)-C}c5Y*yj$ZURW05r};tyc?bE52>qg zR%p{cC=_R<0t><!j@Djj;=T_Fxnc@=L&cZdmGja>y!^)Xh(@8kcdd=ZRp7(5^JT&7 zyY(kf5zNf}Yb!bQ{Nw5^4KzIv&kyOpY?fKDRG7MI@3!4-S60f@-MHogvMm@Yt2%S` z-TswHvxl!vKcVIP*HhpMX9(*vuyg7l@l3YZkCqk@1`NCpiV@RS$*yiv@L58}A;dIU zJUQl4^h<O(+oMDKP*1P*bZ*|~5dw*L*&=KsE*tV3;Xz^Hx4&?%r#uiPTr#=x1fK^d z&6)7^few_P=0-uMDm-9rnT;}@P|PMbe_BUE0;pB_SRXJ;Kat0-D<YN(W0$aW`GH13 z&ylQnpej=uBTOFH&u}MLWIMR8bPH)voK@(n)7km;b(9S9k>QmTXU9k;W|SF)e-wuk zL(vh~?YOq|sWm0}imhUCDHa;BErsH~%Il4;SjJUy8jklS!+9J{;`%x2&>O2GtMU5h z&(L$u9_yh&cCzQAeGdCs<m_-9#j>y~P$|X0D2Jj5BAeTRR?FL1Ddm(3C((GRLSP{R z`EW5(r~Hq@ObZIBNDL9i$9Zif2QSxyikd70!2`5}+ULsU$BS-0-Zr1fn>q#jPUlXx zPbSj*@PL}!X-(D;2R<YqteIZtHw>!OZklAD!mRr@m@bPM@Z2pg3mowG>6JLraa<)X zQwAO#vN@wa@Z^lv?pmU;2A&l??irvQpzmNr9Tbv3o~`gf9Vy86C89^%krm#jC*Fc$ z*24~pW)ZHs_Qt&3r<63P4Ln?A$l+%uT`_IGb~#?GUiqMW_!#-UE<5ezx0w=ua)e&W zC)F0jNx~1pxupvv5nbKZ$^NmQ`D@{^vU}#F2(xStYdu)7r%Dc+L;JORyrj-P&A>=b z&Fv#OhQ<af@~%~?J!p*Wq|?Ik+d(~N9E_9tg8Mt<Jb5Ma3k;nsneeji*;Yn5Six08 zUqwZNLkq+Px&ct^mc$W)=vqzIyK)^I_aC5$8qP@vG3j`rT>5;k1Qxzz&rWyHP>KSd z+2s4bcME-Mh6)6J2Ke~zdYVEN^f(BH>aemiu#tK_`v9=A5(sPyuj>7^$V?qJj(VA< zY&HLM#8()HGswLP6130qOf)y(-YH|`*(@rEUR5jq@V9>ZCW3w<#Ym=0<U@N!!Wo;j z&F^e}h)*m(xN8p$92d;A7j~{Xy{iZWzhEXNg+`m<Wo}-`Z4otw(HK3h2UYcs8LMq* z?di&o{xgtwk7QNfDwu&CS5|>nA~QtiXl;|wGug-bCArgc<*fQhb<q`juo-ifCMdN< zb`@>Og6(K@MQXw6K8UeyT7PSyWG7%&x<x|qm<5~Js{dFY{~;V_g{0gbnZ%IHsY8-{ z_=|}h``}g88*`APds8rd{mckz)Mt8ohE62OMXWZyQv_g0L4%^sa2m#J(JzlKmz_8e zS&!4o7cN+k1}#rih;JEE3A#dmKMnh+kvPt*B*=7m8)X@N9{gxh!-SfY&?Ag^opFc< z$b>v9W+f+P`&-R%7%pkYuXnFVZK)z`<}f{E2_;D*gnS@|O+kd(iZN`GSOIwlrK1x7 zs4aFvD;jE%f6-!}a$9Ai3ev&<q?kV#<%6|Xm_|X?*{C0sju+M2ZHczB5z|<+i?rhR zxEv;7#t!_hGv@8%@Y;he-XNvOQAYOYy{ka%f(`9qo)}yzU#<{7r<z#4Ts+B&8O$mA zS6X$&(lVcPInDyGeN>0mluS)2S@0E->#?j;lM(k#c>n(M@#rzQMOvLdZ_V?~-_;y} zp>l|rS$1EP9z}~pf|)f4;o)Ce#xaYuIlJ55p7KWsmLI*SXdOtRAMk%_Yn$&W4Vzz9 z2OAl{ud>DgaCR^?cBW_H{0%_cI@lPxn9|x<8aWv{dHxp@ZU$?^3A^nnN8hpA3U~1+ zF_D&?QkL0R`kwS)EwY5<svQq%6I4=UY6J{Gk}=ur{V)9KBlH6)FZddCYVp2w>C$!d zjPCC4?k2te^DfN+BF;DHpRcjLz3jQhU`)8WVF|{Tb!!$dhe{8Q%&1`jnxePpGB6pq zGD8|v5hu1J%4ycHaLZcm_EYbflu^(#bTFk^V+N$(&FFLVlbznchD7H11S7IcAqf7N zmI2X>aJqn$wG9R|Ng$$-bj%g7)KP{#FhZu<Q644-ug___)X2P(>{bslWWs_s9jx5g z_Dlj$9b{PLRssKt`5L*RhBojyQt9|E;*khlun1#}LkuR;F&%3{<<v`q4#U!Egr;%* zVWseRH%{a}2Ms9Z5#Dx%)Lppj36lV(!2+>cCiGELsx*cXKi7b87editSAmT<BtB=8 z+)j9pz~<`ad2g0ZaB=67@N#D1zwGiE5@Qd=Ax$sW35SN1%SRju(pz2bb&m55=k;Fm z6eC8=NcsnSi4z%-r4px!an(Y=dBuqj`FHuiV@?@liG+B*6N;YH@_FUJwh}fHOcsKT zTq2iZGvrCZhXlNakV_tG0mK<}?M!ZP6#@D`Z}bp<M5>F~g9Ah+r7;>_FZ?||>|!Hb zvs9`0(6AN~d&B<$=9i}lY0FZmKvgy9Ff0Y6{la(lfd7u_Sn61$X6r_}7-^p4<>U;6 z7jbZ4<`g)BF~(WI{?qlR9yfPG1oGDB-E^<yI@Y3S{?Sn-m#LGG50M)`WKGOY3RAG0 zR>4UQP7KT~z_z##vY*}VZf#u-igNY;lh)vZ`bgk`G~+QJ>OXVH!^+`Q-`D5oEQsYj ztJs}8gpm7@?@6+xp-s#kWincG)!j^F6Rb)=@d+gRz5h1B=_1YFE>KU!l04J{mM@I{ z$%5Y>NI9TXr0up(J6Fs!-`&TG=a{e;Y)ZvsTh)sxJt!0LH@|pP>Xt=L4elGY5(GS} zr~{4C%Q{Qr?_s0eH$;t8w5UnQY%0Yf+sH2k%A|lcZ$#!UWLFx2nN6^0<G8sC?M{{* zoVYbk5tw2$Poc2*GQ>cQxjcD^-Mk$s#;lZmwP<KmXW^ds0z?mC`C(czq!5aU2AxPO ziIBNk(Mpt!NL{O5Bp-M8y113yxv7F34>~A!lSS#+N5923_*k8SJ<^6F$wJ7ldW}S+ z3EG&HnZR|FM019@G~367Cr&0N5mWi}`&Euh?iJ*&fLAmeQ!!Dtr<j(~L5t|2K1594 zh~wBeA=2l5e_u!u>eMa32`CZ<G$5d$O7(EUk?bJ0k?|HckpivTb6WgC5<3<t!>E&E zNDyV3{x#B0-KTk*z3~l#Wt8yIKhd9c9TH$Byj@%oinm)gw!~(%P24z^6M81uXl8}v z-?~VpYTf1m1%8FWv<n}0i44h+Q$0M>_t%Cx3aIspMlCjU?r<kK|82RxfuERh5Y)@x z=|n?98hHNwZ?upphGBR8{9I%{qx7yx0OtqK`@=Jz))7m(<;^eL-x<;<_lx7}p|^VP zm(P~34isC9waGE%2fGw9o8hD@L+aZCP1E*aBCfGjE3Lb{6O<Ki1|jx)Sd;1Fh_eA~ zYmzT~4W^Tzx#RK4_D#N%5%MS2Ol87BV`^?M??@KM)Xumx6Ny=vTqD=#6XE`L|ItvE zf(_2GVVsBLL67i`0))J8kG^I~f#O;Ann$I{-yY++8T6pwj0ZR;Gc?|o=c@nbKZn_U zLU7o77`$^LQ8xzqakDC2U{@))NFFVXeY@REZt$`{Tk3zITT7>qY0-$0|BY4#&&~T% zAQ-16hLU=*zi$8w)u}(k@81;)??$*MJrJR~Ho7!=pZr={C0&%JJA!O^I<cH5!ta|s zs&|KfP;ibKy}kxexpk{oA7;ECDmLMCb8bQ@b#GbUR?)_1p6AWSgJ|}a4)37U<2QU$ zWwPP@1W1fohJQk2oE~sW+0cBmRU+RVZv3o?%$-KF*u>B!vIeGB`GY?`z(EabaErg> zJQY7*IT(8e*U|LWJ(ikV_?07TwEDy?*bp^z*5N+=-ZNmU@X3pufkMb@@7{0*UO6B8 z=)+&0=3)wEixrvT-Vj`!%P=={fw-FKENmMtY(uTCUAP#Ez*@Pw>q%ZKegf2!Hq^ck zBNeZ%8~JUQPIn-OVm{}ZM_cdNosvlr1|8=6kH+j{XMfzhj52Up-bdig@A{Gcu9~IX z3QE39jA}>@YLt@UG-XIIgzyzimcwS2a1}FIeuihpNAyqow&&^c24*>VhA|jJH-&0g z6vL~>HT-y*#AOV7pc+*L@^V|r7*|E$(u~dKm9e6r>>>_C>xL-Pktu_GTRCS1niI<m zLVK~Q+4Fih<QWiWNGu2S0K4^HJNwnt{5Iy5hRv+Hp7{BMT2T{Y{*ppYu^3TqQ>on$ z!`AB$v52w|qv^__cmZIYZ>KE)j~+O^9H9Wu5|3-(6m=cwrLqM18ibb|gCvmo*jQ04 zaPoRgxyfxvVA<$8B23I9c`K|Pz`v#Ws31o_ZFdS><0GMQ*H0gDCQ9m%i!6u)RP4;u z6z;gDE|NDnh&Afnyk)$S=_5vgia@R(AM|GkDEw2=pC<D+y*M!FsjY*JWM!)WmX3xO z281+>^zG_;&cQ>Wup&c*iDYThTTMEgfn{>tb+Ajoa}FZSf3}<hJ0+4TO&TCGBq8Nq zxG`%19b?O_US<47(%{>;*_ZqN-74xyDRr8%l7Vd<!~%kgt$Od+8M(<3*vVMFN-Bd2 zar$VEx!PFb*jE&JV9op}ZA{)5jB4<FIX*Vlh*F%@a~8N~zO(h`&sw$z{50&D4WEMV z*U0+^s%*Y;x43l(8bFnMSEO{F>e=!4Tx0{-&)gdGt5!G|%xcIbg946wt!D`lExXu} zjPb#zTyWT5gxECE;foMgNOsCNT(6ER(c`ZGkFE!4vmm{UKkYocO@6h)+H>NWDwbkW z2EnOW+FML%?}j~Vl-@6$WLePEzDU$oF-;ES&_)d%u@<^`h||4ZpL`JaG9^9Jtxnmj z^hi$_vCYe^{HC)0^Y18<2!`WILJmH<hFGr2KJBW8rorhX(xpkv@QqAq=VdPP_09-S z=thQjGw4l_H|LDQvW@DFal+_~wKHsK(7?B2OQ=U>|KX8BCE*xYv8S(Wavnv<MA*6x zSA|O%;fz-t)p9guwbSH1aSE8YEt2*Q$kmp1i<W*-&b;>T)CN=`&tIx`RiD7<-rGys zBWZn{Ryj`@ha6eaZCZM0D^)G*YpopB%{dLWLn+p}H}i+<XE3`ouID#enG<)ikrYT= zfB9XD$jhj=>O*PlvV&`0Z}Vfm8&~R4Q?qdsxr2rx1koh*#$FF1=MVw=j-Cz9!p^fv z8^F|gO&&3eE_KqJaq^j123{4P%dCGqx>Tue#BzsD;S^c!XGK@%*6q^42k=VA0ZpXH zr3gs11F+duEO)>K(h+s`TpW#ty0m>ne@<10%dU${(9SwtN|ZVjuln7HSq`1^u(0_? zzL<3)nCRa&DyLW3TOJCz4sySTF|H9XXN9kkw$;M)4TDSiwOp+uvpS>zW4b-FYQQ_z zsN!lnE!z%cx9r5Kc>YXC6MFYBt``m_>nRwfd>Fbrqhrg)KAp_7wl(jq;>Fq1Wu*}J zT6YpPta5ujynh}(y~SrHvvt=Oy;T5+rVwH_hi3+;I;It`S$X<qmu&mKr-IBWkk>90 zce`Kd?O|PNUbA{EBkQo&3cu*A{)rUS@f8$20b2cc{MFg0h*f5=OIO@K=*3k^Du?w= zglG&*QctYb?jO$EG3me>w?~=R7!vQCqQAc8((OpWW8}3SeoQrRYuTvRmNgzus$3y^ zGw}WW$JAF2?R7^!eH+UK<qU=r!Lj~AW>u20ek`Ud*73o!jt%<H$%Jie14q?UIaTST z^1of=SW|41gPW@SMDt3a*eoNMAE@VZhc*uO8|2?ENz>Sl<LWJF4(IweTk{Eae<Mvn z6e@~*+Ft~Yc8uO}S4Hl_8Tur~sq*Uh{!vK$oSmaE6HtHN#dAT&mG14o^Km}G&%L`5 z)2B-DwUrGvrQTLPO-GK7=2vnQPzxJc%QNvJeGbJ_LK7&H((c@2Fra0S)OOf?@*xdD zCe4I8hi5$or3mC(<7(eZIt_4D*lN%Us@1|_;_$>3D4jBmld3X+7KN8cej8pfTrPXc z@D2Svx8+oj6vqk7-1IsoTVNcU{LKKOu?+*37C&r569<wgcQCXh`>FBnw|_36cF^Y6 z(Kb@1xaPzB8r27$rOK%ovpOEv)4?ul@V+~-d3VXqfA<*peBj}DZiwzl+jPcGnl@fi zQRY0j5Gm&bC;MV<>BqK`TR?=V8vM2!qjdE>QB4$6mL+?_(=#P5%S2LZ2&Vg=co0Gn z@P-mAP$Go)#9ScFRqs;cIQJvln;_$|+BB3Ot2eA4KQzRC5#Q9-7f2i_ZKgc~TW{I5 zYB8uMDule3B8zUe@Iy5kf*##{kyJlQBf`uB{+#%Pv6}8m0$%ppnWKIH|Eb^tSh0NV zeid8`#s4R4{8m_v3|)*ZXbl}K|BG}x((-oRU_<)R&jU)~hRjH!C~<XipFSXK$r;U& z-jB*YUe^%|m5?x!L8Jj#cQl^a3~QJ7$UAhqBM^*mN^JefAv=g4rmJ83x&n^lWunv+ zx{jIf`}xAZ$5ow3<l*GpzIeh*0@l-EC>Y<mdzC+Ys_L6koJ_JBfg+VuL(pR&TBAoz zq9WRH9*M+6hTf$Hl}}HWA)1J<B*d+>86mn)KEdpxg-lTw6)KlV%`l3n1OcW~o7aTn zky44CZYF3gs@8~~lA)a-&7$SxCkIvk&CiujdJq=r+1ta9mT^M(5@4uCD{KHx2bYto z=1NpiscU91C!YS3JS{XgFECeC5`4%$3c?O0Nt0H4^oKe2$F0fd2YujfR8CjmB1{P; zixgfYB5k7Lzj=Gu+xN+Rkf#2Q*zNBil84IFpOCrUTjh09fCM}zLk?+zvLX$e|8p{H zJt4A%nBg3dx)>UHvw4=2lQUDU^ZXoMs;#6zOU{I-Ss_4)*-iIPs_`>$0*^_ZNF3A$ zx1Z)tRO~zm3O_#i1wyjk^;O>K-pNTSRVwAqqS;|T+TWK{d_w6~Rjo!$ti>vr=Lg74 zC{&naKo8%y#(O_ly|Vi6?FB$F$VMH;Kq>>(5@*M<G$2=xbS7msxZH}BguJbUp2NIF zY84@bjSOv7<DrmX7XKD1?P=h$*v#Q1!_tUQeMl?Wp0j@gh=#<{u}rKOnS}{$)Y1z- z)ix6-=d_8E<gj9RBQM#Pu@Xb-{;e9~uHGo!>(${crRJRw?L$>~_e8%?9QF-h#}Zag zwS;tTz+00L6&NhR8I<mkk_nU`NgB*BGg*ovzSew1mk6=9dmm;TY&l%UG|0HQU`Y-V z;4*-ipOsGes_ADoIg7qi#lHR6!QD7QQ3JN}lz7|DbAk>KZ%n`ZJFt>p`WgI<-G_|Y zlkji^uDL5~v1vpzF$P24!r`U>kQtMt$3!zV4tlwF{Z2;eLb$V7sEN{yT1KO@=bX93 z{Z~Q}hHtR^5{)kL*+3%c37l+xmee&tD1(RQVRq@bt3@8GQhzJP*7)2QdH+W#u^Wa; zhM>+az#=lV0{<bXf39b1IS3ePuu@@>pc5+45WJ6=6pX%qG!Y>4q?C@y?qs;BXowKe zEvA3Ra5|LL5MKc3e6kt3rk0|E`-ob3Z(2&@J2NH#Um9m2@Thdg{GtI5cT?{5`qAlh z0P<KCiFB$Or-8IQLNFxKK$Kx_P7|O(-Ug)~#6^OJRX&bHs2r5=EMcIq7(`1*TmlYr ztXghNq&x9cw3NrJaw_&?q*Ud$g$PoCD6l=~3ASW{#z&0@79>m!M@r@7BF7(CKYW2Y zd&g9C8V-{tD@Fov&h}4#nO)+UCjC9c?9ra!8!uB<;P7xNMKL6l!y-7BH6UE%OAiG< zMu$k)JCQz?n21@;v*gNjoWUi^!^yBZ)%!K#1_?$HdTEu>A+#FtXt9unKPD5xyxWZ3 z-ZLv>rMt$i>c}B%cCZ>nw4pMgRHrq5S-BdDHJ=gMqB#JK1~SZtl0olrGbUT({y3t} zA7U$^NBxQ!t7R;|SU?gP2s$Gpv*6)AMVp=O@&W9t3!)ZmcL9jqc+n^bpl#TH4f-$s zEKA8$SV`sx!Ffyf1s7IR9B{G}D`e?1WEU*8sT0tqvN+hjBGc7=X(8B#c5Juk+H+tZ zy!SEA-ndJ$THKA1KPmtS3by^|yq?Iw3J%I)v(OZH4AL^L&iSN^6O00~O$==I@OjB4 zf9gwL1@DRc7ZKO*7C1Jjm8F0rB|_sSl9;?pXzv+<Ad?P+96jg@LQUdoas^<ANzm>k zhNxnp-8g&J;0M7o^Gmf<Cpp*=0^O~-#DhU~;$a!CLN!JLbP{U!`~^V_gV~JAQwf?6 z_T^vDQc`rlvIOcRY1XhSz-pes-r0e8fslG9E&}k*TaF@A^=+eL`+CRZ@pwlm#UKZn zAheq=+)g(1kj7|bI`eLrkb8{)BSn|h2U<yHy?s_S<KBa4rHtnRC$wk_*ne7BH#=>O z4L~mJNKnneH>|&Eh-+M?7v|{WsrutT7u!U*JGuq@?176tZI9rB(_aQ|=B4o@j(o!_ z_m$?ICPf`~ChMOK;AXiManMvXDmy3}C8~U>8b}xYg0uw?#8A{B{5B55&d2JC9+3z+ z^#x!k7PS^S4=P3xDK=q@6;0ojKY0!uX{5b0LCBP<UqK&ajK=B>hb|lpXiY(zL!Y<a zjy&i4r!=kspfWiAI5{}DIC;6OSty5LVr$DBuH7ccs<4bRxH&o4&2Q9!+BkD6-0W8= z?ko?i^Mw`QH}|;;c1-}}saI<rIV~X8a--lQqvzyUklBRjC6ByXep;)d|8B0*rbB6! zKB&NwdDk7xeVEOP3xOzqDKb!KMcE1n+b1xFI$x~I_hg!Hpc^yUg*H6FKbsP{%|_;5 z@#gK3Bl6pqpeXdF449$z?Crf#O)!r~#*7Krd5GjfWO<Om>^~umGSpFI_lY$SbN0|% zChV__yB%AV(4&Z))S1<$_m|F(%#wr(_jiJ&dB!P7oQxvM{;4o1;Rc;=WO2}90346` z{<qhk2LlDB^18#O7_}~gZNZN#i!;@n8|CSdPx};oQjuk<T0VU(^*XJUjb&ZatS)>d z7*+JNKLF@%q6Bo5B7Lnbl$jmDq@_MQ5Tsp$;USH^1Rs!dfTiDzYiI7~(8ZJ^Z?29& z+FWLvq;V#@&w%T?=&|^gyC@mOe;}m1WSqYo#o2>s&R&-LczryI%<k6}qmeef5q(iZ zkCDCxd%n&aO+DfcWiRc5MhW*m#O2#mT{ZHaTzs)rULG`{Ht?6I#c(9K{@Apu7RaW1 zKqFQ#^w^V{?q5zHE^3Rbez-W@A6Bo1?C*JO#8TzNRny2W9IjT^Fg|i6W))nR9kUyo z&9n1&PSlf`(nRCSSw3tFriHEw-~gSpLD?RR?_3^d|14uWeby}Gwx$soUNHT6JTH;D zLXc0S%hfujFCEAnIoqT;HiBFRIe9(FM*TYC7&ETb_@Wgz0yS8%IK?g`$H;y%<@p?i z78h2L>K!~IT%*<KJPox!dzlThD(ou^ErUc7YswBgyIx)~ML3r7v2jt*8Z%{PZ3L$W zjYYesv<h@xuId=?s%*oDrBtk1m*ukVHELK2ornK=)E3gKcid~#*%G=M+#=Y2udKiV z&;zb+wuO2vT8fU#4aYNu*O^$_Zjw8}pvr>giVv-_J#d1zJmwLV)*WtFK68LA+s1Hz z`1l~%9oE<V-5Q221>FOemv4Xg9YSvJmnUr`{7_j=u`k2J4XMYCHS3nn$Vtl;*CZBD zORf+})5lOYuQ&5kQvb|I^)N0br12nGg!DOnI;-^}hT92KsA7$2*y5=OIepxv?%2M9 zw!B==5Yt;>Z-#WybSnzKMHChR<qCrq!h_(mS}QCK+#X=ffg8SM*#4DgXp+eDmPz1t z(A{-A$_6sWDLW=CZ`f<vkHE?bqzu${qrzM8T8bQJoQs4(`Z3e7QG2ovA}yj^KioZv zS}H;>TUyGKiv>H(_<kpzq1ND>0o6ZyOdu7^OEtBNix6WF;fL8_T^P);^G=Y>-Cy{( z6n#Wln_M?T+(A~RP8#&%AgzS|s(Y|q!$&h*vvjfjH2+zv{bS3skNxNwniXL_KLVeW zMh}4+s5dYruYjn#N}hGyti0q6iTO{a1OlF(<CC+V+;iM&yr+aX@t~rt#8;00?@1d; zy;}6d7XpUkvpBvKL;*w3&03<KNjK7S`Z(T~CHYG%d*3C)1Lhh2tx$sytYQx1UCbk1 zqQTC5$zdxX)3w^<ADL~fspXTLOW=`LgIGa!Z1ik;n=HG=&>L^9V7`y8IT715U-`Ix z2X`3#)iziq9hKNSRMDG)VP9mQh8ORb(M$S|gS&X&ht+=$KX#q`#eTise(imJKi@sx zAM;Fj7mn{U&q(+GaI@yjUtThL>x0aoC+Ct&)tD1}mj1G*zcz`n6nzR(0pmd<gIWyY zWwv-43#Ex0InDi9;udHTbPeMYS`~0sY_>m)EG!4q<aOzrb(A$$F7QYNcs2|3<~d&l zyv?a|b?@~Rs*881TS&ao)&+m<(~rVdelsR84P_uLFt5;h!3C@2pMC1Y?6rXQTzE^C z6qR#2m#fo9M@YUUCUTXS{cI|$sBk|*ad8ff`K8TPU~3qaE?&{`<(n0J9*N2t+kzZn zXhS$Z^<)2iba&?w^PMO?Xew=3DX$1PL}7|wwGsB$xtHdoy%G&dl?fz;u_uxF`hXWB zmro2%i9~~3+V3X4VWYFdC>dYrft6kMbSD;kNt-E&(M{eSf#s(3RtCoGtTnh?Lu7@? zS>@i%bFTrO;JYXVL$WfU@P&t!Zd^g-prc8!p9^)<+k0Q%262XxDAy2Bnr&+ACK^gi zr*&h(N!%msC@%EJ6q1s65`(r)QMZ|O$ei{s?hsG5+a!*Z%xM!4OwsfAY#Ycd3ssDL z1T8M8^?u6rTauha#)OPuorAcs@8RRy6b)=+6YKc_{7>;V?fA|64hH~`N&ElpH&#Yk z3sXZI7YkYwLuU&kdqb!H3L)a!u*056x%rHQ3c+RVxjYtKj4l;*G|-N{n((-Uxsn7L z%8%R<Z4{fimQ!t^7$qiva0?)Sh^KL@hX|x`!+$2e=lu?tAwSHnv~zQ9BApVXqbCH( zs^)UNZtJ{Ekemc^Vj;Ns^Ns5{Xi=Pc7(IDJQ$Vz7-7<OzSd_8<Ktm+2$M+E!JVKH% zWioRBida9X>N7I_k`zvEQqkaIcos=H0Q`@X1x|AOj7Y9H5HOBM4kgN5hDHKO9J{eN z(TC6To0Rh=WBH^$bHp(7R>PB1FF#F`uwRc!Xo55iGls_PI6@#%a$1!ro?bS2rf~%5 zk7v!wHeP%QBE%DdhMGS~ZB%ipie*P}K?i-?VzCTB2x<(lT8gw?u@N;9s%i!a4_ULR z#|4tu(Wq)f)(9ao0D?80I*N$bV4+H)%pk{`<!Dfp#nL`ul;9F}Vx$RLLkh|4P>F;Z zk9L9pK$0$ySvWwhDNKD;{{=>eUOMREADN<<LnnCgm+>JP2mwhls1eszshj@K9`6#^ zR;``$%|7=!*FS&0V`TPMHqZ!diS;(fuV`%D9ZAMDo5qOvkZCgJJp1qWR#^81ojBu= z6uS<oJK4}ypWP*=G0OP~DN&J7aj3BZq;&2{IZ)Cp76r1j75A$LblZtErDDe;#}em7 z?={B&tdIP+lpRcr!M~X+ImQdh)NNA2WpDPP)I(4WKU3I(%mQ>N$poVMrI7*v@u0cN z7_aj85b*Qv!mD7Rf~&^indgZjS;(%+OlFQ2@2^N<2b4+mOAwoSV0<zd2Tx89(`0P^ zB7LPu-+zMRd3KnzX!K(mfcqFE3B7vQYJ}}+A#oirjTeJZPHf+m<zZdqF;0MnC;$GE zTwrTm0H0^N>HT=>`h9SE`*wgf=^@^hem+n7jutfLP>VllQl!50=%xCNq{BUa@^@}d z1|OwZ5d<JDejLE>f6T}GK8E^|Y(@G{MwXbFFz|5pUxo-F6ET`2wBk9KIL?#I;cv4D zlZjt}d>=cW6@&Ip59DK6efnYnU$+NtKYqS`_`gH_w-#om%^gIPAaI)Z*e7HXvH4Kx zaxM8;ki~-t6VDO|JwN?9hKl^oHoGxoZmI9*_m?8dRemuBYq~Ac?|pyX0{ngqzcW#E z#(uVNY2LV#;QD#V1rwQa!gEJ41Q5bb;_!P&xMQ8*&I&Ti82xOb<Whxx(>lM|-+v^K zbm`}^WN{4GGBRef2b<&wk6S!|E(JIf(Egq%!mb9S(WFM`3dA9FUXcFZx2e<j#WIQm ziBP6lpd|kYxXo2`1bm?|6Eri)ga+1{K<*)fkf@aN$LTT84?IhqLVWO-d~*Zyn<-im zGKm~Xf@C0_K1757-QnTl2fxB0L+sL5NyN=#K$hlEXZfdyMz%0CDTd|~>VZ7`c`998 zhh)&nFJfC4m369@7#C+QCi|QA@nnsiCX{LlS5Ccaqn4p$7K1<_CodH#GZIS#y>ZU> zQo?qJUB_rOoS#4Urt-QyL>z23v3}_*Sh3ot2E-$(!=p15=6{)+z?8rO%M?aL9o9!> zNC;6u4KIC(h0*S$_eORJ9Xro0g$Epd&zKBRP_P!t`n@r9$-?oC)T`TCZtYhSdjW8l zaXBkxCS+$Uf6M34Jq8}0>N#o(CWeX7OONdxT^tY@DUt0wUoW2Wxw?w*0Rskp{`cf3 z^CwR-5h8?-&)a!2-<~fv=|NaeX)~5~jc^bz<UAdvy?lurWQR0})hxtAl+k0!>P=4F zI}6}%G2^p9)3?vy>T>_vJINmqD0d92-q5ed^I@Wsl-g%BG@TA>yvh>zh^{)qHV%l# z3`tW{1GForOlu5{EK(pZYW4Ye^ZaJxVGZLF0)}$t)HWOY7@x!+7#TJm8<6|_RS)fD zS-WyMWS8{n1Fmj18Ps)?94cUNVG{xPYD|5a0cA5u6txvv5b+)XC~0CSvVk4)cl~+{ z5E_s?=;kI>7%D`X5W7{uqfY1A&NapW%xqv4zciTRLSP+Q3$0~@;b6!h#kt@;h%d6Z z_;~$90HkalTl<;~I%2bCoM?MXRDgqg5IS{D4Mj9)LtiZ1B3TcQ&MTp<{7IpSNe7)~ z7y<V+0K>hRu1%_AABRiN?Q-z`wju_$8fJC1l;^b`(5bb`%QnO+19?St*m+oC_xbYW zp`B8ds+f0%p7qxsr)9PFPio7Tpr_?(%q{Y6eezZouciI-l|tU@9kK!Fs)f&u2!qZg z_@n_AH9NAiz%4s4Y#x)y`%}7W?T>%yY*-?Tko|UPef_<ckgZc)_c1_N`uoR7LsdG5 z=V}GAsa%A4t>9mLPB#A7oXx%Kx{{F4YXPt^Y1xA-&al|nuh%MTkVynX+f&*MPvgyo z@NDwJ=6+#qUvh1Y_gAs|%VZ(vjrf9XT{~MDBfc#=^-%nXISmj{6Bq1V7m+P%yMQ9h zfwV~Z_8El%F;v{_h{JIeAMmmPTRR^l9QicepvM1%QLKlJ#Bv61>!o93yG%T8CDR^z z1kL*pN1n2B_Ab;O>SiZu<CHogfSKm@1>A4UrW$L)<jP7t4{+W}{R8~wS&t&8GoVsu zzh}WJ!p_%kvRQWLPe@1JfeOOLxa44a?~7K6>saD8uAXtf8nu{TxbfnQ$@GM<CFQVl zW~oyR!k#eyjrH7Bx#al=Y=6DfO<=MpeUST6I_9AE7S~H|lJ@4g`+XehM=T9Xnzi?< z)z)sjG#P-Nf>RD?y%dlXKzqPf`0`u{cvi6(?&atVRKB`Gl<#;<j}TK`2TGPzSGJw^ zq#sWeV>@VlO=W-CcB9JXkaFTCm4tcUcQ&O>McF)d{)|rb{E&$N76UJbPk@x;60LR# z>P{BH-;E<Cz+9qF(hkdIr@i`*l-1{<r?EI$8N|oGfBk{ewv!d-UGf)RUhF#f#4(M7 zx!QWgjQphFA7R~aN|0OzGONmRTEl-Kz~2v_AkXbLKEMNzLnw4R+;3U5Q9EpC$H}9! z#1WGbfO8%@gmhqgmk2|<v@S+E;As+-IqTuo2pSVi4zYo1+HqdbT@C8fmV;RDAVO#f z0?t`!F??WDUSSMvb#;u{-NhXJn!EXF=fFMR9OtiPR#neS;u`0^cs*V{{gZi4e_$sK z{mN-FyP&pFZlgU&R9qS_DEAZtKs_{V;d$V~lM~ZJqX(TShrBm!Ndg@w#-e6#ZQK|- zRYQ=XJa8;GwyqKw<cCeVlStu7FKeyI%>9m!vR(mUyIdv2$Ima7b(nQ7rD9s&(VVPI zyA<#D+1Y4NZO{p2LppAx_{XhHm#4*mL6@s#dp!GGmQbUOVpqRFgQH~97*UpV>2FCW z;M{2J?ZFVzl<V6v)1)`6Ld!*rRDFNzagD|=*XCfxFt1KS2mJ;O_8-hAZ%T0+doQPe z?cdn#x4#|BRiz$Oi^B#~NIZWQPIzQ5Cg0wzq8OVX?;e(3&4vQ9mzJl={Y|ZQ%e>-% zgIse}3N4yJXZ<orRSlv-y6G}VJ#=tvODW>2-+x@o+9ijQ{pyqM^GFG!s(t!iu-1+L zOyEcy<H%QPp2Wxn#Je>KB$PUgo*g?}+nv}yz95;bt{>?n4Nm>5q3bmpDCbe~3uu91 zVmSPIiC9Jm;D}mamrg7vDLJ_EjJ$fK;KqoZ%8>fB;axXqFcO;E3@Fkq8=KhNqdv8b z>!p4M8(Ypslgs)CBK0zT9o}mO90g<r6h*S#ywMpHYc=(?q6^=%Pj{RVvGBr5=1K7~ z%^P|qgTSwHJ<2e=2*Y&rdDR1V{0mG&9VA<mYBk)*(rw{3Lstjm$h!#q_#6bR5~fpa zESj=C{ct|}2wXrI#Lz}XSh9SX7wI==xj7<Nf6Bpr#%&)Nbq4^If(JjGC9BJaPa<oX zJj<*qS^D;RS~l6g$*1ERcZl32li^#`U9pTMk7>`WO~ZN2SOnnGuy8602d(VgYT}q5 z?}ng;#LP?FgLSzhPM;L9QR~L*zI(Loxrq~aG0s<gM)<-W*S(3}ZjNEwshy%L9;l?o zB5_)+TuUEneZye`Lg&dPKLM$Bg6l;oKiv$?+@0acb1vO6MQ{_nYqf?>koIQ5$o-pf zX8fpu*O7Elg$LQJC(trFhEKP>3OM6`JGra{_yX!xDIZ-0D1?%q4(ypH?_)$lIK`UW zjk+Sv>nC%aAn4~&P8fGb#%bc5DLNTDOgz`g>8rY#z3*#DO7g(#??C?QbGSg-*`StC zuHUu2rXHlt)Y<86&YNHpj5UxA1T8MlsGI%!lypt7g&R@<&xjNVZjkPr|MbmhYLeCm zQSu6rqVwGO<}~|YGNy8wXLLw~NQ6b2XQmg)={aM^IuNz;%2wFo(VTS?NEwB9>hiQ_ z7tn;r^yX!aop0S8SW=YPK5o>?S_Q^a82$u@aS$xBar*{4i^2dw!!pSvqB`Tq=38f$ z`hYvwwtXPOoTb;`7Fkx^5yaK@dlrC`!2e2nr&G0i?7g~<!^(Sy%RGDO@3+=N{HIov zxojYo?2zexVJ$$Gf9@_MzRgziYPmz2aXT`p18sJE0kshscI7c?PQB0hKK;EV_k#O& z4R#wUgSP`?Uw^1~@YA&m5(7K%fU?CwBW#DlsoSPxP#ZazX9!^YnGdC-)aql>4#URU zSD+r!V5;Fjt2}P*s6ux~1uutmob+V<m6gd!Z74(hAM=@)I0seVS>~PNMY&GSB}=uR zDlCf)__MXIE1o7x5=<h;3xh-NyA#kE2&h=<<gvu0Qdf(NJSyWZOT`x<eE1;*I$qyJ zmAz%RH(${t-7B141Q%zlo8fa#wt_kHC-<=N7zHUu?MOpvVK3Jw0PQ^|l7)TxxyeB4 z`qUeVdBH8cYJ(5D<Wr~H-H<}imU4uiOz6TIVm1J@${GF1=m`afmt2H5|DNg~tp3uc zCh+^modFD3R-Qj1XEF>7_zq~{AJTaFDDePlR;}fs-(wz;8cz2v>T}@AK;V-E25XDN z>l#(z^OPk_d(qV?cqtOYi}zq{BTN-#ya>Dga(K^E?*r4a^Glxz8X-xhpII012KB?H z(zIKAVg9F{4$L)rt;7TX2o(hY!2Z8DD<k9oRz;01oa}8aU2Xr1CAz7-@r#s6^&L}p zIb=p&Bow6Vl>wT{Of=<mn`~4`MB*=20)SwVj22*l!-^l)$jvN!^RKALgwN#X)Mv83 zvD>6rbd&on=xWeqh`8Tu_uQn70{_-VB`&Wb`o6~eq6EwyNRo2VX`xzVDoajq|Jjf6 zAaTqFwChMXwnDVv+(abh&FMH6H(F~ZYjyYXcv6$?mpQ+CBcZ7`bWVs=4F8)_-{*o9 zB^**=kbo2*?NxaI{Z4x>@Sv=tPmyZjp#EJbN$HGz*bR6U45>KS{pX4#jR=)v@}LrF z0L!gd>jddw7?HC`l3hxjfKF9QRM8MwpI$3<c8U~EGXfI@JL>@u?iFk*tt!O@_@}&( z7bKx<(Vt?b(K0bal&FQ7!|TJ0q?tw_umGYdJ=7?W%r4QXpb`mQeZLgqK?jWpR=trS ztTSAN4)<hlh&|)ww%{OoBuZ$@A~Si~q&c#Fl>oMiMhn3xC3VeQG37VtHu7koNT=MV z@&|7&Fae^n@Wa|tu?~MB^2JaoQUb7#NihS=19fy)wK~;9YqGi+JQ2^dv0HIO6$v;B zayD(U97K>zX!|(iFV=-+!EgXZ#t;kYfF=+345kY~Efec<s79u#jXF&PSVO;@6iyG% z(5D047EUugJ!<<92Ogy>=wNKUqGs^!+);*xgn{a=rpg%-0CgeKidhMUf8_y%jIsfs zR<2qJDOoWS@NBR-Tmq2zFbueXN2sn*e>m^D>9qxfTw#bJV1fw(hL$;;dWx0Iu!&cN zCT(qy#;l3rt_%r&Q}H~3z3Af0Dix*1DG@WW28bUgcef8NM<59FE%1~b0hjb~F1bDF zN@6%@HzfoUCrHfMPaa%^ZNDF;SweI84KEg!Bv)0bB~rvt6ML*PkR$lieZ7vy3n~|Z zBvw#JB}IU1`39wRC&;)U0!n@xuvJt!gtQD1=u+-WLD;)QI_rs&x+N!44T_J?Xp}1^ zFlb6?C_ca~*Wailh8vg*RW#3SLquNZ--I%e9J4iwe`31Uhy;@^6W}8eNoAN~L35~% z;E+IGIS@S)k)PQ9K2tt1guI*>g%qj*9MB~q0!cd3{LKJ;dZy0Q03s~BpX<Gq)N_8M z)B<_LE5Hh5^|Z+zq`*HvVSRWppe}*%Izhsdq63mCo~`cWdCw^**n#mwS!cRNl=%`( zh<C=Kq>d?Sh>Um%VzUf}Rs#l<gCM+-7kU#eZh)<oXM^%xRukfifk;7WGa8b5N(L=u zS8gQJ#^ic+;=Rsmhl_8%09*`<+*8QG6Gm?H*?uCoE4wSgaW?C=3~ZS_V;>Awh^rYP zAw#-+$yY$ykhS{9#EWoM721L4x|V#@%RkLV?DPGB+z`?Znjjw&PJaZR9r4Nb&Iu42 zqjjRKQ;ayW!$^#YDA}|7jP1aAr1JbiEwf`X^H|1)h8ivMmQQYj6{CEy_$<a$^{lEb zmrHSpgo#`u7Mvfh!lxf?EzLH|UgntA>w39Q6F+r5a;6D0MNFL#0l6uQh_ESkxE(JP zTk+E8mice;yz!`otoK5ww*{_9CIltCy>gHn%Lm5BiGuPpSmVLOtvXaN1(9|j)F5N% zNs`qY;p8j?^an^Ul;CTy<6^w$rTwk*&zwS@peYLkNF%5@OM2tB1@>6!ds3#Ky8UQs z;s(2`p*m{o2sznsQI*WR3Z&YGuw}$`9G|bl5?=2vU%}xDx2MiB5E<1%(sKwu8kt^& z|M~*!YG3;7_vaPVq0gZGaJ0l&g>Dl6v@RK+?G$wRUH@Y%XNXEu&D!xE)?)wGJ9=}5 z{{X5QbprFGHx#Wk4c*OXOsrtxL>GcJp_{N7UDOo^Nk>=^;V8wq>$K}n;Ls?$m^FQ4 z-p9$usc+grP|?ckop>amlbRZ_>L1=Ol0m$2YvEVba%%`oh&f_XPo`+!R~_*oVixyE zJZ_t`DR<IAsl4rGUwMXevvHBNj3$V^gkd*SGv^EfYr;za7oLA9f<&G}_|lfP_ar46 zB0{H}kZlL55tcm`4tyM~--S4;M1Jc@UV;*&0rw{^&c_LTA|Bv-vE>jyNRD{Tj$m2Y z4~!{pL1zg=#@sn($gOXv%V9@)ITj}az6X&^k_@|~$XO#{xOH07$}&MO)GldA50RX_ zWCC#<VZ>FTF@;FVu5d$?wV9CX^GSI#Tf5Y{;vGrNzY`;>5uJ!O3?tKQPtN64^RB#L zsA1y~JCBIB=Dw35YO+-!PNfA%A!+mNq?p1Xmr{T7M@>~*0Cgb>gTbP2Rz-_d>1_;4 z!Mu-0>!;SUfcI6yKONIqx4-rDY^r0W&h~3{t7>)8cDq4-%Z)iNMk(8Ba*&hyeS5WX z7`9=&UuNf}&^Rw4xi9+E3^N5>z`724wzd*11pc&b1X!4}ZU*dG2P_v1RSTg63lo1d zagOP@Ib?@ar0UWzZk2KB<|1|HA$tmIR)rfbFxgI`!>0G#^lkNQZCn+&+o}^7Mo5NM zitof;!_%2C=_ltXu3wgP%TY`P+YUhr)%v)wId^T`lHe3~eN)IfSr`VJq@1e&kAOfe z6;N*{fi9wuB>HD&3KO2Fl=ibzBRN9z0a5(47TjH)9n_ZO4=k5~UHxm5FQ41VQZ#Q^ zL2KN#OA=VKmc~rVP?|IkJ8gPSQnyJEnX^Wz_+}w`j(u!^FG)c`EBrp?dXN-ooi3*o z*k8&s(uDr=lDEp!5vMW}PfLuJL0iO1^tBfk`}UD=&Zu+kNr(;srz*&g_jlr(MwLCb z>10qfL**Iyh%`>X=sYU}vJw0Z!I3S+$Y%S4)(1Qx`5s5LpCd-3IesAp;`@I8^YLrs zu4Ar+rg(up3Z%>ZTzZQfV&{sJ!eb^O2>E90h)P~yUCXXHhZ%w_j8m1GBWNiJKbh!? zv4ssiV)bwwh4Sf;BwCLC9XYcmP_iUyf@Olt84rK8+(nG*Rq5OQ?;D~rNfFh;&i6y# ziV;TB1b5;xl=&L~E*vxhDH=ZF;4cKvGhAT>4U7`&L4*M>X;oaSJkR_-lmR2|Ab4v0 zz*7%1*-{N@s(~?aseayUzme*Qqk3Ot&plTy6VJ@b=7JT6`QA&LVKP}Ce);!v1}QLT zR2y(k+(sAUB#HO@EhIYbKHxWb6`09#jwa!mn?f|lyqwpMS{9xkPS*Uw#)VmcL-Ujk z)IG-PDs-r}u-t8c<fIqlz(%$@<|u7%-Oz8SH0;V8%M1vlrm<Km8Bz3Ao_P7|qO_+^ zwzT_0@eAVefiaY=IZNpUfn+U@FpZDS5R4!piZRKKPjkRMvDrjVCMe}$1+2ODp^Nnz zRwF)1QbmoHPWG$}I-H}0V*H-}*opPz88ux)YogGFt-8vN0r%_sk2l_Nx$XNo-2;2J zO~Y^lM_0UF?=uJhY7oXriBqR2|57(U>hBElVjB{<J~Ar{6Ee*w?CWV2Ez)tN>)8S# zzG776HiJb+Yob^>67kgib6(K*WkYvqBM>F6X296trVwH|LK~Bh88#2w;Q(RE%QMOw znX>07FH76chG|&PE`M)I`JN)KUgx9(=fUB~`ECc8{iSs3#Wc2%l#wS%)rCWpg1nH7 zDJ}rx(2yXCzjbg@6TricRV55UoFf^m{Qs%!Eu-pOvaR6_!6m^xxVyW%ySux)ySqyu z5Zv88xCVC(8r)sqeY!jS_31nAchBL+9uEx0oK;WNT2;H&sx>R>l+qv>$SMstiV<k> zx0#F0CUGWA3y4Bn*v%Nr;gx={&{#-KXkOqerO2wS1?wYgGh7W`TOCP!|M^ysDZ99l zKV)=~p<|UyD2!g(yAUMG{3K1>lb@t1fRQ-B5lxtpMl;*8vFh*l6oXNrpqK_7$UvBj zoA~*sD+1BfYWb)UB{zM`xHy69*|43Q9EJ_;_%IUKhcVz5mt84PF;oPafQ*j+z;cP! zf#1+JxlWD5hz?EO_N;CRB(3K{?*qfZU1F%Ulbln=vSM2VRS>7r0MW;f5L{F5Wj93@ z={f|fICUVzZ1qS2eC15G4&%DDGRJGe&tJTWTgA6qpf)lV(xb>f!qI%RSfP;BijT0@ z!q>Y+;sfo^_+=_S#GK~XzG&(R50#q`Pr#upThCg-{Fug2-(aKDb!3n|zehY<Kq=#s zWwkf{>u0F(R;zn??@~*lEPRp+3{c~!knK411LZE_FO5u7@EKmvEaP|4$UbhCV)lW8 zZPG0WQXF_11J~tjB5LS?(VtKp1^CVFkFM_g{=+ff$+OkyZ->SxO#)2YE<a)B46{Rj z@Jj#83g7}!!3+AF<hZ3qyKI5EoM@Wcy&F<>J$LlM#o`AXA#}g2l!a*QmqChx13m4_ zJjr+$J8R=@Z~QPDKgNbO-?SoYYs*uP$HVPJd&Y^_;Y8yTMd`RNZ~+t`32az5w+%J( z3-MH`a(Ku%TDf1Vj(#3tb_r9mx(I7!6Vg0PoX)vLUrDb@&NC@m?d&(EJJyEUylO^( z*mt?v%r~!!+vA7rA}p~cL9vvqEEx!Q>VMl+C|(%7BDUmeLd<U>XbZpQEWubLQT!t3 zt}<u;87xDX(81gHj1Mjp8^0k6V=xy=vLuc%rLIX=l-F8|rqTi(8GS4`2uUc;tN@X5 z;DB_OE>km$!Skzc=Z<##ojhNY6->rWTXL(rwCtzRib~&cL@hXgzWFXp3%d=-x;Wmr zL(fDG2YTWNQ<g@(SN38x032mCp3BsVWqScq8l=D1VqBIc=@(;S_{R26OpTi^ng>m` zJS!<VFm{8aEd+T|1>x7Vxw``OSl%8wq2}96_!IgQ5)>=Jc<h;I2Vp0xa{X)7$spm6 zp`@06pH#otPN1x4$0DyDk~_FFenKem*&goU&A1Hmkx%h_f|@b$_}ON?ao2k5bkSqE z#y<13^VZM3mXq$Hgj)TP7lQ(rIP?f23f*)oNoPWtndm162lvL}XHY~TLhPwsx5*;m zx$3{o$&|$Tv#T>3!-{EIWUhcW=}R>21w4)+U<sL6BU!aJm>NY7TyLm~Jz!B!Rm!p? zgdxoy!J>@gpvm7p-U3rj$rxb*i6CB7ACk&$WlTx-bEC=X$~y8;D>4X=RfVNK@<w~3 zYW71upWDQ``?cBXHQ$MoSg|Rd*Cz^xbfgfqKuWqbU?t9i9)TfL2tz_pMV|tL^W;tf zA=}Z{Jf*0gg_@ebUQ_9wz)X*$EP?V8(lH0MOi`H`Lnt8Kff-RjO>O+FX++SmaeVbW zb~Z9Y*b_@DN_I<at#AEYi?2tfoBl^*+mBAQNsah!fj`(U7SL}`NsLd$7mq6^QNY=t zD28j=6Jh31tj@e|Ro0E14alNYI=#Pzh2tOg4@hz^hj<kiTCDeicpnq2xgPJ_HO)a1 zobI!;d?pxr+pS0lmiG&e=^O~O6QM3>J($~Q31n4W+lm(v=1pHA5N4xHX*(|Vm2j)C zvC>|?$YJf)x_0ACVZ@tL3e_rk;No?Q6;kVA&nj5y+&{6E#N^Av>L*#{Qnn`LWO=e^ zo&ay}UYKQZ^>ZSpgE&R4RrvLkepmq)vaxF;gDIB!`twGKf#Q<;nAK7XeSBq$Rbv5N zG$ro1q;8h<ASCg=tj1+Qk!?<9t+uv-GxrV*_2K$^RqdWiy}8z-pE;RQ%*;4<<DNCG zR+GyaRze%S6{%ktB0sRuelbSqcld=5P6xRWV>bR7)Dd(p;U03N;U{0&Ep;?k)Q?sx zYv<m>9!lhrS4QrG3cSAF$0*Exn@+q9A2Z(Ubl<~mZ62hK-rf(65K?U;fq6D7lcPv+ z$)iux!LH}qU+7$g<8;VFtg4hgdy%9FoRwxeI$tEv$wZH8=VCG%q+%VnA72^c#5y_e zhFD_IZ<5ws?c3_>HXqCF9N|7_9#9-=$XnpOwZ?UJM96cO`y2|d<UR$IC(AK5J+nGv zv(B_?LuAHW3;V89<~_S%Iz^*m`>Ru)L}z-P(QY)~^+=0@syRrS!ICW7PIOL|+I)^c zg*WVTpr#$?jP?_FoS_PD3)LAg&ft?o_bF89BIxW2S4TVET$i|egqP2JN~~Z!^~WNH z8-lk9R-7Dp=h;b%(aC(SZ;!>J?0Sm_|C8BSi6~XL8(n|S&6U7YYnAhfxC?u|NUD{K z$ypxP@uFU7O;u)jw3gji`*!-Vq_LmM$90#I8d=qVS3Dir+_o6z{VI4VFA^i8?k(-D z%Tj4BH*xbJoSOLgW;NuRbfl-62;Om*O;~7CYGn{Lg}pn~H~;aR&Tl`K$HbScR*05R zoF=ggjIYC}x3#|r`V5gnbZY(5mS9tX%vDjDY6rM@i#3lH9cg;XPMx32Ll&)${M>Yq zeRqKaX2Egr4D4idI9S8u5GOGfiP92bEAfL^{A~C|n0RyLzk<MH^SXA4iUQM{S#xkt zy$P_lLb}O-+y68X&Q4;8CyZI!nyI8#%PW+?bZsd{wWYmla_D1fiQBYtV<JF9Ab2~~ zrh2=3hX!}{?AubSmwj^9c6Lp+>Gy7>kGQ;_faon$z_Kp7UVEH#!n=|iyOoBYm_7=> zZkjQLM*{(+s{J#$U?x*KaEns}!J{0zEqkRS7r`+OU?B1+y*Aty4S9>R*yz%43`@M4 zNx#jUcT3OyLgdEw04mu_u54UOv8G403yBS5)YNU<D+y+aRGWN)im<MWi_YI##i2+Z z&|ZbUBy9+vLc`KBnPbT1bW-T+n~TpHa@8X5&G3NCT)KG{KwP1pT`DTnDLi#c5c@RM zm4k10Sag}TfBi$I)!@4`C<KHt6f50>BF<(^z7(m7s10Eg@m*I|!u6(u+PQ`ki`)TB zB8ic{WH>Vj$)4D*n=s-=xPFe}a|bsRtI{rGR(%=Mp1A4)(bQ9*zk-=l5D4onG71Bt zzE+n}H@u$|?e+(WpJHA+-C@HTB`(*TSj1K^w>rF5Pu}y&X%ZR=8<!qk^n}JYbcc+D zHN(;8?-W7jVBv|8b2*e_rAsu<^t;>Hw#un%5<aMbk^2H(Iem0reDm!<QBN1Eee4sw z#?~;`*LLxfgy4vebH2!&ng$QtEd<FyM^5s$T-mBRE=9`IXFrk#T(_1TCGizuh|bf6 zDcB1vGQw_KVr1~L(&b*{7&LCh?G;X%xcR>JH>0ZOjMloy3M6th7smc@-Y<qP<dCZ^ zAbkY)34-h<zJ_bsRSR$PiGYoLzkNI$scpR^G<KEI=+-4TEcbdVq6@(pg(eIxh-^mR z>9D<u9(phX3<YfIlHFZb5<XdjJwpF#36<R6;AWGzk-U`~P8-xl73%##SKx4$inPpA zeKzfZ3m?EgrvIuVuuW;0pJ=Ed{Dd?9<wr&1l2je=lE_OfFrn{FzuMo+n(pBUy0IK2 zQlQzP+%8C#Po+lG*IkPSUOy%})|Mlfeun;-S-v}1ER67H5}4O!TNZaLHEF)4xV1=; zp!4%f>(Y)*Y5evg9qH5DxLg9CAisW`j|I#yZ)cSNn<WNpsNqVI<`U-8%ACf%YiXWX zL9o6C+hxT@PUY7H>^qX;e4lZaXIf;L9&x$eW|x4HB10HO-gJ%o2p`!8Z^kGHnAy6b z)M&KGY7hPgai_yhw7qayxsQg@TgpLgItEYz(fPceHw|&83^HO~u;p96jel;*6{(h` z^nRB8ibsXFKOA?cn-p1Pc{g*KeJ;HZQbdqn@>p9m)(F7T-{;g!Q!R2rkZ4od__k&! zdX{?TiafP~`dB2Kmr(XppPZ!np&RnyU9<JeMo|F1=Xo`Kb3x44Z)Jqw&;jDB$c1@g zC5AgK5r{6-iQzY!*Bj%$Ca0o;+vzbp^&9IMUwy7kk4fkGk~Se%b1>U~3hPsaNS`bP zkR1EnG6{U{^Sfl-<|phRaV2zxc3m4<KZVymsiTz>Z+Y&@(ajTqmE~geYnpdFvlp3g z+A|+y4??2%UYzFF%08*CP8#uL{gDKcqZsp<#2F{_vaL3ppdmQivgJW66<5j^)ORs% z5;siAnSKGmFkz$Hrj>uW%o{9Dov;^`nos`QxBNhY5w9UE7Mfb#Ee9lo+Z~tigxCsg zA^v;oef8veOe1Q_+7p3`7b#WFHoIM-3#BvF^9zh`YNRXo>Zap{-{bulB0S+Q6`u!l zwYTC;azbEP_5*c>mj^HL!@#(~DoX9E5MZaQ5Lh0Jl<;fLT!@^fKXVvEK)zqx9_^f5 ztK2oXb9IW-ZR(HO(^g^EmR&h;_zTp35W<<cGUXAwDR&4okeN1_itQr#G?Y}ZqJW%Q z4qdAjX!_iCJRZTh3yairD%>~_Sc(u>6c)+H;5yu7Rm931pB$s}pgCS{8dbKZX)bAv z*{U*PJvK3vYJX8od&QlyjgHKA6G959C>=br^HVr7osd+b0JT<5k)fGd)e1_Hf-tce zeE&>kBBU?SN@cR37LVZ&t+K!;*03mJr)h6A!HI4FosZthN6v6g!*P-@kEt@(|C!-T zPc+f&qAklR2`o>DY&r>eC7_+L*ciWjm>13Z0~C*%RW8op)O|@(s{Pz9udy%rlxAUj z+_4<nbbb`Y#K~^x0|jhDaVDK~k+fk)<#*1mli;q_t#y<UjEU@U!8R*4=2U*_z~ss) zo58oF!QPetWauujYfKD9(X6{{xGR$zpBJm|okok)!+S6)xfTU_?9BTj55ykkg}d_N zd(LcR_ATS<&`2-yBy8(C$H`s1L!1<ene$kEgpl*f>?jgZp~-#8;AxH*jN!%?!8_*b zoHMPJvqrMDdLxg*o?&utAbmOx3y;gNi4s&C^Co0||CLTjD8gxK3ltS7zy$z4|2zD{ z%mOT8;%Y);?`Yy`Vd6$(V`psge*}PjXxRL5C+@9EFJN>weN?^RYcpvZdCbmIrCs7u z<GL=FMQ9Q5N}LQ5oPg%Sd`2stL-&R62v0jek9c*FwKJ2$Cth41{rT?15X2^YRy%Zi z+UT;{??tI2g07IAcGrX&eY-N!xcVOS{D?8OKzw_-LtFd_t+h}=>GE`Wm2Kmm%&uQp zR)t`oK@!Q~<q43*lrcg`Zj{oxt(CE>DeS2DLztjm9L<3hxppCC&X62b!Q^rXSj9D^ zAErYIGZ{6N>K8C(rBFCdT9+O{Kg_5CgL%NNVVKJu(f2Lcwz|0%qz?b^_70{-vLZd6 zrLoAfR4U`kUbOdmnQocxVjDZW=@iU<c$^B3E=Hr7{1R$0Woy4SM_OKYI-N*C!C9eH zojS3kPPhbu&w7|Tiv<3pg-UaiEw@)qJqc?z?=mJvWgN|+EgG@Z=VN%Rb%aeTjc}fn zeF+4!!n3Ly&IN-)mX|U}l-#$sLh4jOs{Rkm^j{llH_{T+`}aC_`ie2ckuFF;tYHzV zN(rGOVDi3rp5G{U6l`rVLlqvkZC9v|LP;|)Czg&&Ms5;@k9|-%(d+q95f9a{6s@d| z7!oBeJT{xy%YYl#^Wwxu9A!-5cV;(JcAgAq0<GoOW^GKT&{Q$;nL@(Xl3F&hK53`? zDu0t5jvu8zE}dHAjx=d%k5NkP{Ne^7ONz3s>`zh84f3KCE^lIwQ7K7@1H8-DIs=vw zLP;AasG`oUtRi?PTayfGOsbM4j024DbkLV4L&Z>AntY=8j-!X%gA8792A(2{rNG>n z4#{s^^s|YCrmWDo&tqO97Y*e~-R){;{;g+v#J5_OI2ys?t0B~=HZ2>)VIql)Y$AIU zT$!v;iWL9h+iRb-^Sf^~8_KWElN{(t(qJ~a+1=wZ@xhB84#pZ;uTe;_1SkLW*f`n6 zmbSTMLHJe#wt@ly2gFhl*UW-u*N@V6<pt3JQKUKvUKa943B&cvsShM+54yt=8&=Et z7#KUm7|>Y5Dh7hxB)!BFTLYM+JvCY>mg>V4_?*8AVx{g#4X24&hshT~9~ec}v6n6P zHNFF+N}zUs<d-#^an~rAJG)92_G?3=je!(>VoF4+OXqA_A&mgm!;3tS?Gx;c<)}fb zgQz8ip}7=~2ogyG3A?G8;HE8Yl0IpP0ct9et0_~_1G8pzmD^4{vY{+6PX&1S&kqT2 z0QOk5p6%dp<xp>GDx6p&<%x5+F<s^I%5R_)=g`q<GafXxqBz9%mp_H%(nL^G8|_Ka zy6?bvp>D;qQo(-G;L`WtMC50o_}LceT2G_gXvNXP(`}bzvBAyvL58l-GxwNM7Uei? zb%o?g*@mZQ1Ge>8Zf42ibgdha$J$9%=QT66XM4tdj|jjvDo6J2t}FwaP0%7ORP%?! z?s0UJkBjmTz33V3Cf%;H@G7+L+O8A21bXcz+d)8<`Qh}!*SUEUusH6r%Gd;yws^#j zhS6EgOGZHJeikw8UnYZqW@xg1;Q#J<1=-I_DE-Bs)q5W`x+20#cb)$k5SzvMp?MTd zB>uU~{wdrQPYTJ38&$94R{+g48DNS|;M;po<A4g+{kY|aa#A0dhzWe;XOXS%^e-ib z%$(!25?njp#kn?H_aU>*%<#Buey<IOGon%$H{u`S2JAE<1?tDYm%Wr;4p41;bk6LE zBsm_Wuoy7Hvb9iJD=8Ms7nl9e-)EsCz+Ft4GGRF7o_M)Xo3-F%#f{Az=&Eqs)HTzg zGva?eQFwBrKE8Om2J3j%rzwRE3mb}(j*<Uk)Rvu28B5KA3zVE5VFfWL{7jt6U+HJ| zG<KZUHZH{~0es1+xt<cg6e!%{m1cu~x*)XJM+iNwm&8E@lNE)0CxtdfXh#|%eiTYe zOiMfqEy$1~e3*H26f>n;ktm9TVDK($RBO{O!VfdtjZBTkd?ftLo@?k_tE;PLu2M_Y zQX;cmw;okVp=1#6reRew`N2I4Khwr=ex&+e*y|q8imn%AVC2Ubx=t48a7{j(k7s(D z@4rr<kW~pp&7U6hT6ErQ^r(h|(qo-|?&MnabKB2(b?)d62?E6AB`eQ>jgUR`=gtC? zw;rZoSlF4)gFqxHS=s6zdF*9AC_YDPsomrsj($9-bfucb@ENp2F|c&<bwicCPO#Aj zl=7)MQ1FMNVKm;%dcu?afP~o9xk!}#MuLXUQ$0&2<lLQvegsOgkQR|T;lhThYGu@Y zD@fqbMFC(yw+V8DC||x>My;S-rW(Cc_p8wj{E}STE}C8<%~`&8=cj4SK6ld?(kD*( zE;S$81I{Bo{2A7uzDetx_A@b`^(y=EmR2TvMy^K6TK{~gS0&Bb2x7L4ohy;$!lSU_ zc|emK^4H1FJeUqX6@UVySA|>WkdO@5P;%2XeKswNzLqwNfcLE+S+V;SeTfsG=l4k} zw5`z0rX9PRO$~XfDFRvT8;gLG$}F^I)^z0)GoISauiVg_@1BjghK7w*kwB6@P!}w` zn~iij-s9pG5sEm?M{U;jYHq5Io*%r_heU{5+vgWVXXssyp$(!=p4P8ynpfTvg!U?% zXNL$dsoUM;lFZ_;9)548G_y-+E9`^w5zSp?&dO|FY}L%;*meQG8vZqGG|1I|Ynx}C zcXAmr7e2cztF!T?YyqWyQ=u=qRtB{mdWK@|n5A;(1C-xIad7>fpS0_&!Ix*wz$5!Q z97bm@vaMUzV&DWJM=IWoeUv^~BhnLuuRE%WTw~mt>Iq9^eQpUsfUK1!qy*{(C}>A9 zSyokLt_aE53y*K1FLoXS%ZF9JRB&Hnt8$BNy2pYur|EIerT65wCRkUYrf1!I8vgY0 z3h7v%u%mZ~(L?Mj+P*KLye_kL4M@3;GiWNzZV52-=35<H)Qh|CzI-Dk8@g4+$Dw`x z(ASSzWs>U|n}etN@g#I+uJa?sk$c))ws`dClEI;HTm_|dQZ_Jg(U*mxq*a(EgCd@u zPQyhKN_+)kv&l7t5cXda{BK5Ku8-Iad@knLURF>0bv<=1)TW^eC++N5ebC|0Y8<<S z<JG&|Ezv3}OIA4(A)t1UN&J&u*xOuPRZaK@A<l}2lVgl{9=dtG_!(3>vAEmA)7n!3 z!RPzol0nkC;LbmSKe(@;_d&?hB7lx;Ymu{62+8R`)C-<G`br<Po1x?Kd36vzr)8O( z8;mJg?9}klWEe5|KFsfU4gT68lj|~UE&YQ3b!pd>?+lkfGp-({4-Z@~@y$%8#pfNf z(42kuHJ-`yg>iz=V{ap!SvCN|)S2aDjN<yot@`9T8Gf~fWzj)k@%452sPdNw69}Fa zig}7ro!kD5xk7f?z^AgSXKGpx`D+{JU%g{&YMd*O?R*+fuB+_li3$8UAKiHc2AUgV z$PMdZS947(#a36j*KZO|Lu&DMJ&?wR+Y+iQ&F+LJo@~}iza{j0jmVsGW)kLflN?mq zlQwPK9fmHmWv1O(bQ5JBrj>pno&O}`Fzc(e<n1NNin{?Xl#w=vRyTM)5C)VQ+At~T zM6aT-ZJ*XFZoNf%hgc5a=&;E6CfVa<g&k37Y3Yd#F!-QPu|P@vj;Ayy*mi&A-FBe6 zf()`#R?u?E{F<wBTuLLs)*G5w4TBI^(3Mq9^C`%%=z%zaZh#(re**^-KeT#)kem@} zP!@-jahM&7%#ji{0s~{I;k{m@+)p<~SSQ#tUPq<`!lV96+x6U)McmuXQ57cJJHsnb z*!w*e_mzeP#jC0LctiUP7q2PG;hK!dL*kvwe6i{?RW&Zweh8&X36h=J!&JV_@~XHA zhuZYicH^l%LHwICg3BVwz5p7DbMeA(lyZrK?QQsk6giRPwf41p=YN+Z^n~uTN`(Rd z$k6}*<bQAC890DBTt<!__Rjyu);&f2*mjj2?X9{PA4`8>MBO#XmGiR<GXAg$DMkas z_V`)cGNCE)=)Ni%YN8|-Gx#0B9rV4#9Tzc)RFjv+2N{y9tUjk-ZVi1!DdNj-bSK^K zZ=W)=vg_*^=i^dR%A)g#Shohb$-mZ0Qc)>tns)S;Pf1z8brzL1XrfZ9tw0YeYto5W z{D6A#!!lUXN!hd2iW0n96{MfW?Zqw7DOsr#L{uqDRUzP&d}fc#`}+AVb6W&OrlBn@ z!+yJ*1}gzh6+3t6C>N!zkfFeed9kAcwyK%jMpRd*9zg^#2}`x{NR*wRd>y(Ljoa&_ z<g_ufs0>M=q1jD=)XPf&C8=Tl()!zg4o%~*IJNlL0!KR$g5F*7sZuel?u3Y#$&NZH z74lW!Bs{jVW~%FODrM5+4nnBP!`PUjhHc2sGN1eBP08bJ*Na)Idv9;nQw`*Ls#@jy z&SAJLt!La2{&X*mRsHjbDb;<(0gX!<wy91k{i~sn*`LZ2g?r#kj=GLoRtDy`1+1zx z8Hg5@i1rXs^@8R*K3t`Cuo-nbBO)lBA~^9ZsLfHqKZH5b3dIxcXRP~+jI9Ww4O-Cx z_goJ1L=#QxGX^;j>w*fgj3o^5s0<Q!XETzViW+Bx_VucQOJScM7s;{WhQ2E5ccfF1 zx2nhHeeGt<-Rs22id^;NH6{z81b=gi&9Q$7LN{}ZcW-{?Ej!Y3FVJ;0{>+xQR2;q~ zM#e&rR|8iit6L|ntQoHctRd<`4Hq6tBgPgljxz^8LwTGSWQgH&mug)AuVQq8XBgV{ z6sl89`0GNtW>`I@?LeI6vsP@BAP;?7YF$=6$c8*GDY@v0EuBHLBx)Z^Q%DxwcKa9f zWQ3S#mI-XaHfZ710eO%7QEwG_*EV9if(Ol3;Gal>VJx3Mzcd=RQb%TU%4FD}PwyFg zh{*1R{Sa4H$4~W09&;^6E0W;SDTKq;Z`Ea+`C!g+|7ypt!Y3Nj7HJ0t9$3A)%uE~# ziaw@g&Z-W>6ZWM}n693@&4bQ8psEoQlXSYiJ06a}uFZ#9fC2SJjwigr$n|j1_QI`Z zY@3J`RRmXf+U|7z@=;ijHF9fzP&3<FvGP|B=>X;TOs{ozn=@N>zo?6aq}R^Lg!ffW zM4b`K2>H#ZE~4{7cl*ORC!)%YsiXHp(VXnfn;o<|7cpA`4G+gL)5R36CN}R@8Q<ga z#vg;3IbJ;L0?=;VHa*BhdCqe~-$_7Cz{y~X99fCt9cF9BG1?Bsb>hD^l2l>4Qi<aR zq>f7%p+AxqGM{`suAO;<#}HOGeNg<4vcP4E^4R7@tpsCdzRJc=@d59ST5jo+-+c9^ zSGm_Xbh~r&L?&LmkXZJ&>LJ$1yEdsN9qsI*w1}@qc%rnYZRkgt-BC9(uTKZ$+7+4V zM2q~OXIPfnetSLf8Xxej0Xd62$srS(W&99iXH}0>$eS07qlmi!=ko8y1T&wUsYwt6 zmvjr>jI$_y$h5Poe{H77@FedCEo7f^5;#a(sqOYCN<|oZNw{<Mr#b9ON8|=?6e@+? zjeJN4?P$X`*ppLIOPOJH06=}WMm%jO-OSrtaHOM-4FWVvcx{xPL7RhuwV;%(#D-(( zb8w3u*KuWBeocHo9Ak;q>Eoa-UE5x2PT8k%4(O}PeM_V*=Kc9&Iwm2koM?xgc)%5t z0E<g!=_XYTbq!BMZhgL(o*JOEl`+#Z#eR#zGw^kD_U;#1qR3qxAOYWSp#>nt)PdaZ z)uWpH5w9{~-$kLq+z%|>8P4wOy=Kc#j6ktW(>41}XSgAQ+wy{HoI?(*rarbJF=;CJ zExOo?amXJ^EQsFL=Dt_ILKjXWzKC|^t!zSOU^fSMi7m7x`1j5duWMwez+~Hx;L6Yz zFIt}@V-vCaYSQn-2k^H1!u^7HB}pGKZVv*@WW<n3I2eM*Y!P!uZbD`=8LLKgIcU!& zU-jT#E9FhQ-o$A8`Fx>e^`mswWQ)-I<Uv=iS@Jj&>o9i{XtpC&Y$Z`Vh8#WG#Zq{r z648h{$W+ER&+tS?Ggi1ym)o(~J~)S^G0#&T#o=Ft4rzp^5ku5}WDQHnSQ4RP|8Nx$ z9FQsilQaY+x#0EG0|y6heXHOKjt3{>)!R{mCL|gmshI-qYD*a2j<yxIv$?&CbUqa( z`k=wECcoXA2p@oK4u`{om&=N;x5RzBIWOr75h%v@zgmjPI&s5t6Oy{5k_%VM;7H=4 zDOlv;=Q<v*lyFqo_>g9TJ~vdNzfp>t=KvLKQ&(ww>-15oUSoDNc+ohBKz-WiAxyO; zer1O>V}oL~Ee_+$MS(bS2{_Yn9a~7KU^kjAYh)gu)Li#K=Hc#WDy;R=+EvTLjB@yR zMN)Mx)M)?PFBIogS7qZ5#98WrYLEx;w=WeUDov)%Xzy1o43V_)?M|mA4VLc^+8P^% zC5El`dVJp{@wg9OX0I=6-w#MUGC-gAR1Y@TQ0nDf5<K`R;9S02FLE3zD>GlFm7+jk zsu?Nkg1&ir`wwb3#u&t+zs;slFPBR4@waFj=B>J<7|hp&)8AEtbq>996vi3&qY_qr zRZ;bckNjLG2e<D_j~bR8W@`#kLCU63>21+1z&g86^~o{tW*x7#v$Kc@Oa6L>x{yRn zNQI5lZ0)L(N6+Ns{`hz#xk(joFYiO*RvoMQD!-j?98Te<nAr6`me{o#?bWtOU4%^* z6tyoc+NEBS{;x<_b1G0^D{XP6H;L{Wpv{3j7uri=L$!5)Q>6O`Z(2sP^OWI`P`Qx> z_T&Z1vh+5PFX#)Zq?mCQZo&q2umW5}rS>{8xwO(4ZoIQJ)Z2S?p`W9(y(%0SI*sW* zX7l;s;t}tfkiGw!real}f3T&4eGZ$<yj37Q8){%%_%7Ic!e7pQGkW0n=QMQ)MEB|o z;1=f%1pq+*|F$^Hzthx#8mI>U$2Mm`9jJQRh6q$WJy~L^+$EB1@}42(9GX~4{pwB{ zp|dPYHxmt=5FgAS!U!N2laVd{G);Drbfe{;O#Ysg)$U1(2&JF){CrB=d{`FGaQZ6! z{(A36p17=R;z&=P7!e-$GoCigtdj^yV_TI?4CU7{@uE}0K;j~7Y0M~-Cb@u}X=^R% zLRn3+)e|VGPEAW)@2E1C5Hbyk%qVpeJ_8tIN{AE3<Za^eth)sXW_e+HrY(ZC-E5|I zfq;GKb%<mEI_Vm=TbYVq<I2!cm>|Rw(W44xd0nZOLE_M!w&{ZNp=S}?h;%dog>U6t zh^lDf)TK0yFbwZCp~^VpPqqm5P)W-QMUylHEH6Q_lqEF>-C_KtWsXS*E-nFtx2gjf zuBL2vy>=~z7LYL6&Lg`-mH`JaJdrY_p41|5K@W?lnZ$IM6ilga$kqUi{u1D|6Vt@< z4Cz?}wUK=k85!ZqP`OoaF2ick{#2A&8lZk2N^HoSdmu;>p;|^^$q=x<q5R_#BafUu zQJb2pXaX#YDSL55qQ#6!yH0Ww;~I2c?j1UzL*6xq0ItCfW+SDl@BQiRvs-7>h;Yv# zPAnPINtxq@SMi|cQ&0+pfkf@qfMp|H2@A!nGm(E*x&^NaLxYHO>Xeq3ERhCOp+;M) zcm{tP0<*DMuOf9Fbu)9SGL((+w$V-<PBCRs{@69Xdk&e&=#VR`d%X9$*)@c99{EgR ztw038M9Y7e7S;kH$gVhcq;#S6EZz@8LaFGkSugP0;<1XWHD&r}h;+>T;m*M^#A*&w z>AYZ5sH3(lmviENM!%dcwE=r>CDP#`dI$XbPe1Nbzin@Fnb)Z|I8t{>V=1Dc{VUb) z!;lRwWJ88HgT2EvYZRjlgvD92CX5_{Gjb@=*EIMqmBeBn;Sba?(rZeEjz3cg4A~Sz zgx%@h|EeM~F%|ECw0`nS|Dm^N{S>!!^6no4i_3vGMl4`mBN!^JfzsM!)x=-@v$@gD zHrq{(Y3yAtwiQv%E40UgpDn|XnIG)44Z!&L<p@-z=>1BiWl`6{m*|f3Q^rkC0l<Fb z7k$%6`R4Atp2lTCFZh-=6ulS9jgH&otDDi!B#4REWdz0xT`H7(VOnK)knFI8)U9E6 z2s8S$+}iN-2b$=Z0#fV{1eL<^enEhdf@a%9a#Dq$jF;85mh3&+8}?KVZweisTgk=U zXXe>&GFw}sobOKxgWaigpFa6LeojG@$Yk|i0Tq)l#MRCMxCA&3s?3Mbk+^$;1x=DG z^Hxt&`udhAs`MBaD(SQf??LB$t1ueq%AFP$WN*NTazUI+Oqyt~Lx6B!%lu9S#RLym z^}>;;Cplm_R($|qG`GCwE>z9e<s_?qY*Irjsl4f+TO@h(K(45Y&g!s^BAe|Pl{|;? z6j$9N-7HC1`)CfQ!&x4bVy>1dwe<8ob8jKTg#RQNF%?fGyNwN}%5DSps{`YMR`z7> zST7&`HN<S__m>T=6z){21m0r%*UMZGNkWq}_EOc!u}flMR)Z>21&O`#QmwajtMLwn zU>yOzFdq`P1YJa^Q2cb-N9#=5tQIh1>p;O{#q!pvF;@g@P&s8HIc1?qX@H(&%8I;+ z6-GH{Re%-vVDETfX)b-w<8%)$+K|{Hncd~{*7nuG{!y}EQ25RZ@5ron{%QPS&s3-W z&3^h@Pg#hm`_tvi?u|aUFFt5Da{njh>lXWrd^e44RMe3WE2>EZI>@fR6Hch{vXo{- zT^opl)QNg9nJ8T%56ERGiBvFikd2*DEgbuZXbtHv^;th4cnfw`P?B)g3+XD>{05jX zH#6`7aBXg#$eyDYI^U2}G@?Oos?poe<5t)5iXM41l^e`43B|_jl^M$lXt!t#2drB@ z-VPUfN7ZI0;L18-pJbr?sK1rLTZGVC(z(YB$+b(t<iGT9L)_*-QRuUXjq4VVp|big zvr?AX#4S0EPA0Y1k1Es0-4!GYRYuYONg*xzl>HQHD;*Q^sRDR>o^pT+H`lwfEA*E2 z`c)^CQT5HjtT@u><_P=pM`z@5{&8yMBHlgy!QA=Ma%s#+vaAm&rdo{hx@TVbg4;z^ zPU(yB*3<r{D_<VNanxcm$ezr#QETekxlHz!j+V9}Ge`uxsrvb!TNOyHXf%Yes<`PG zHy-6C?9OEd;6%bPUll41=1Lb|2R;38RE+nKo!1!W-CD1EI#}pDN(3c`s~t44gpHh} z1rKs%Q7*n28<c-J->ELM#!4a^WX9=1W9=AJr^i?Lo>vPCU=2<x?P5^YH%G~tC(>91 zucBaJ^l@o&0~{pHw(fW}UVjx4$D#50)D`wt{pMn|7K6n!qHmwwDtI`K9T3#a^hvTe zO}=>Cs4YnaBfIsfzab|5xRpFnw?eExW!<ih5|qbdeYh=ck!A&XZqbs~iSASr<}N;Y zZJrAOfT?w)*;WlJ;4iwwsm%m0mw>2EU53d(MzO8&)t|dA|3EtRw&J5$UXVfDh42}( zku03^gm5Dc697aT_rqk-8Z$c1hl6tl?mVckePc?eQc1{RnnQgCnIW2mQtPLeSx1uQ z4HK!Wlv-``pJTtaVQX?<Mg%ZunglA~UWYJMzGxQkC-!5Ss|(c`W{JS8cxOZ8Bo4Bg zBE3_9CNt80wqAab-NTUXs+=2)uWO#yU@L>Xu_(U~u{~zWy$Sk7=slb^uy2vI6-W?v z{8Atp<KKfGlY@33``x$s*>NnWzhs=+5WrJl^=X}qC=BgWcfwTpS=4&YiZer5cXGp> zYrEfL>3WSOm0O5N!DqUjmGA{fxmC;~(7@SE$qm6S$R1sE(UCFcT(3je&Hb!|c@h5r ztu+Zn9Kko4>L6eAA?b{fHtowaGW54&N==iYV|~Bdx=?qcv;LUi#}<m}%lpP8S7I_G zWaDvJaMvsUJ4?5Fk(MysAtGZhqi((Pd$#-g<JZ@D-cO#s_xs^&nVQdSnY#>{QyGmR zm^YrYw;t$?1!1$eD@vJGo0sWl;}gxTjnHq9f8Ic?)aT-ZA^-rTz`8kv|K30`Gtih> zIGei|(pWe-xtKW77}y$EdpKGAU*eEIG=Df^k0f;MP>fC)03zWNe^z6pno>j-=qI6w zeWSMP$CW>z1@KrqH@0I1fBQMlYtt`keKN8Yvp&`plw+p*-51O+L**6Hzrj>A_EvZt z4tVbKHKkk6bb-kM(bUdNXqw5iD4IROq=7z{1C7mYVjv_^g|c3LXj?S&A@1YOI5Spt zq>Er&|9FY=gXb6FL|s@+NW%hSR;d*6`E!1>PhzFnk+5G&-AiUJNyW#{Ntc;Nospth zJtoCSer%J1$7GepXB>s!AL)(^hd&CNE=k}qgLb2y=NA)ZmV~5BosjVMJh$xf%yKe* z72R28a=4Rttf%f!%j3(GHEA<v)|JW!4KP_hJ^gh{7sMU=h*@2jV`)SQSyH=MQr|7H z_OpDX%v{lB&HTi<e{ir+dMJ|Pb4UUb=}X1<V5qrE=hss?!n|gRgJJa&xri$!)CO#w z756HKVda5<-sV9<iCcO_r425%KE#bLG7JMC2bCJ>(lX$DHf*jIes+{YWrF&|eNbhD zpa|=$tL0;OZx=cJY1KPkP*_OTC)0zo(9ReWMZG-WF${Opax2`@DIG+CM@r_5PYjEP z>*O;|vrEOVY?Pl5ohgm<A^=(@-;a7!IY#Z5Gz2G;EpRR@E0uC*P~N|86aCDH8@7b^ zsqOBx+1yqSYj~^d-Eg)lV41#BvLNj+!Zk(SDrYA8FqrV|d&M;SqC$zt@$0y;je26w zG#L!zNHE-)fyfO<uEUh#UY<*Cvm<7M#vUp*LZt>b3CWJ0$RPcTOM0V$bM`NwK-nQl zDT!S^c!Qx>RuavP0qpkRp-O}7EIq6bW+QeCWV;e~_$qzW!TU*C+C`i=H#08x^W@e0 zDc;hnb$5G^3<=SM)c2@*cJdANdaAis{I@5*_glYh8}@7{(z^OiKmTH5%9^V-_^ABN zh3IC=JeJ^(A0s{w34bqGLhgT6_?2|v39Lxh!q-2`nK3vx=yjl-CCQ%2v9V<CPVCd< zo?9e@3Wk3YC|K@QbAP-D3PJ1Y>UI%TNi-90dq%Xx1C6K=I@66^><Pg_#+dotEx%P8 z;}<*rG9?&uR*Z$-r5nYZLBW`l$>DhMH*speZ{IHv-v}O&$M5y&F?ubrCxz))Goo>% zQajBB%-r)|xl&k-6FrPXawn?B>Jr#kx@1w$FV>tYz-qB=7(XfWhM_NC>q=h?KUkz2 z$J8QbdpO-?ZX7O49hhg&EO-7yVSwG>fXk=Xs`2PtJ3NW-vw{j}W|w_GxodLsnf7Z1 z7nB@u_k2Cu-xYd?>+X7<;%aw^`q8eDI?l*=lJ>>Vbr`L8={3(NNw`8Rdw0VO^|3F< zmrnMU8iqVk5TStyN-0=qI3M*`ai99IY1NU2qd$p0I0HUrlpJ@Dj)ieBF*LWQS7`*X zJuv^|nNXZF%qC&l2cFNn$Jd=F$cHcmJQ9@Xq(4;qW46k<rc2hk(28H=MWK!}IzyH} zA+3$5y{o`VxSH5k+K^OKLNUmji^}X-Gf;3$ek!Q!S^9kZ5#wgY+tNgvE}bD5$e`Xm z{7?xVlq93n%O}{iW_inRDG}X0wZ`Hn1%z_;{R}9$`j`H?*~bmlgy|u&kcMNt&lrON z+4oEUL?->iakwb<#=D@-+bl%$C$t0+n7$8SW(4rjLnydjngx#AH!0@X0^_g-%=QSz zq{gV8KaCdFBh9^nA?+8TLdnYBXo9>?ExPI6LU6r^4$DaeDot%kAfsJl!92Q9`s}3* zrdQa(bw9>q4HKc(t{XJp8}fYt&x%uXCT3RnIm={LST!N8_Ji!RlgbqY{~k6>%uU}a z&+zLd3l9CS6j`ZpFv>n!Q1lQjSyiPLnd#o)gi*B9FL7ruQO1&FJZ|Em4OPm3=gY3t z=CczV+&$=OVG+VCMrr(6$JnU)O+23WXW1(v?_Lv#(YzSFx87?<kS=S{tLr=qgER`C zA~4tN`?AY1dl-cDC%VjWYqE%;A0#l<S5V_<Z|iWqZiDModEx9QCoZmW*T|5$0oc@% zdPm`-n*Eul6SB1!>1iUyq@K8ZHVUkBV;V&~#$)t|L7e3u2$;C>CKy$kD>!Ar5VC{c z`We4JsDf`U#F$m1EIlI)L@q!^2@D*EGgUrPF4W%#g3=Ub;?GAwR)A&4CDfT(l%JxA zstXLd^{N-sA6p-Q2avQdG22EO_Yh9@Qb@GH<g3|4-b!_F-TErm2*G$feyo~4`$W;Q zfftcmry2=E7iI#5bGO4<mqp7&|EobjxeX`J?e17wAW07UJIe{NJhTK@vNL$_eu}s) zIfFivS!scy3IbsZaGdhB3G*&w2_6<uyXhu$ES`AjWVG-~{a?O|?1|Yo=>D=O35Y?H zvsyd!^83Uk8_3_m<?{)UNX+?Tf!z5#eBl=H!JvY=NuxrdoVa}qA^X*q8OA3>(y)@l zdgqYDG4Rqv@PG?9Xg=7wOYz0?L@vCDQ-Yk5U654}E!sIk`BCH-r$pJA5Q|>BnOhF8 zAFc!_tZ`JnJ$L=)+SBJP=HyC7(;f8<-8tbBv+`Skk9D$&exNUC-w4mowgGsQDJrX! zb+G)`4t4gLTmhVPDIH|-_t&p3=k{LRiYXZ8At>YuvCD*k*Xq>8qZ~F|d<MFu9EhC< zic{SxMF3~5<V_0vS}*lUy8HuA#^-hP{t4``kQ(JUBget>1kAqaUMG>$vWOG6IC2{w zH@NTZ`t$=a&}pH!pSMzT;THVcY~9jiuG!GbzlPEQ?6I-W^Ex0ze>8T^+T<hMlMl(U zlng{WWf@&~c08tZr1{WFmQ3&+pZC7!i)89KG5J423_}yz_ubG^hM<8@Dl&9LE#VbC z$qsaQ`ez05b$DPHCmGc;)mWsneTYY#q3PXKVQ*^6Di(yr&1Hs(Xk0+8LySV*e#Euh zwe5|kB+SUHZE%EqX$#^GHJ}c_tc{)<p)!;BZjVR{w;9xN$0otXLoJBeCnc`8RvCa# zoE-RaTuw3f=FH!ziP&tu9;TS=1J{1$4RNDp`7JNg<Rz5~fM0z=lVV;K-DKFTWOKMT z;e(mi^<5*Pu-+Xm=Z%u%?)YJ4vdK#=fr1xfnSlUr=_iipo<pil^$M3fe9DJdVG(}o zrq-QbPuTLopCI_TKu|c;6dE+MQ82p@-qRoWU7&jVz+w4R5@?c;Z3GoRPplx4WmS3g zhGh>eZ0}7SK*g80O=)uPLFp;s2%BtrtRU&Bg~%t^T2)dk?0n$SHOdMpJasuZnDxxW z@&F6AExkWZ9K>Z=8rB@!SP5e%Hs1PT#THUD_T|)|G(;tVVg@g#iFvBcVqx5Q#GGpI zxC}I^rX?;RbETV7>HV<}@BN<6@9mQV3A&IYk{Vm1p{jAF&4Z=PuX_bg;Q4k6j6A-q z!OcC6Ub=bVv^<Kvo?w0SXJM04C!VaBcUmqVb+x^3P4_~OE-<H&mTJp`=|jxRplB~h zE^Sp8Mw+jR^Qyq(V#(vMmvg^jBNe};AVZ#GMHv>wfk>HTUZq;-uqxGeh!H6sH_ihw zPhIB~yaZn=eu53%@?fV3%PgrYLGbn_HT;dX{)_it;^<WM0eA)vY>Yny(;%zHe3!)i zN^|e8-)sTs1Y;1nZV|!>3eoCa$#XW8KCZQl{ij43s-wwM(*2k}G?Z(&d+PGbWdyFH z?M}wWWya09U9IPv;^f`ISv>8a4O$Y1Tc%M|Xne4ZI6f#8Vo+tmY3B=l+YdKdUEY7O zMCtj8LzG$cRfXf1*^r->i0uz#$}1YnpG|0PM^*ym7DFn*Egtw+v;qm3z7uAm{b=$w z=Kan+H7~Wb%gr)eU5*{<MxB&mlK9H3hAq(ONl#3oAMrmei>R+R2vro;RX`q`#^;fw zsM#->56>5z(#E0x(7w=di1~pV+fDrZfeUT?m?L$HVw6>?MUUb<&rm_*faMfp{I1_t zdrQ>3wzoFF07YwnEm&z}JaS*9-k~2^RBi67qk<V5$Du-H!qIT-V9WR{^gY#=81#l) z_;&bQ**8{-4Qi^fJZBwaByw}J)l$#nej#oHt#1(lt6rzklUO5m)wGq_Yfq+!BnSjz zNH{Sy@uPyG?BF;KNlhQr-z%@k7>K%$R>SgmF60t9SXs4^4xC_7uE+_|NT65YJ>J${ z+cb=kX<KZPjKA=Q;@+AD=c^=tNJUVkF(Z`Xg3L`}k^ZJyP)Q~)W3o3`u8|x94VSJ? z@5-clry`juoRaP3=($|}Cb1y3msr?5bZaDg?Shf?nmm_^QQ~MwQtu*@`WUYzfL=<I zR~10+X*BRTENnE#R@yK{TI;(YUHPF$Wt9{Q+N^J43bx#`!@~6*g9>!Y8VT6k8=M#W zD9AzZ2(@9_CJGfY{l*&AQ#S2k#d#vH9?+7jju{PW{R^1S2&d5`nzEk<og;q{Qq?U5 z4L_F&EvgmrSW1@p#l|_Vu(uwnAa4-pJo7t%z*(L{W1uU1`%unD+aKAbT|OKbLc2<v zb1RRkL9KN-q~M_H$<B7V!d+AFY8Wpd8D8Pb`VDi0|G5-%N}N#1by@?5*cfZb(Ehs0 zkE#hO_zO+NDkUd#Q0R|9o~WWUY>ZTS7DUzA9G_=}UY$M#-Zn&~EDoo#P>v-LA7ajn ztx($RbL!swM>v--(5e+_Gc5mn!cxc9Tq}wBL@!7E@y<?8JK+;lM_Ox}y!`K<si5## zq;%l>$(0k}w`6TEYAjWCyAHNKs(wk)@s}fJk8qr+&8HbPojV(n;*omo*y$E!ea^0z zMFI~2w-WzI`KxG7J@c?nv#2_ALcx|3EY9g=E8gFywkoJD@zUdBYc*Q$`TJRv&3ypd zg8WtLW<1@eCADS?_QoWeByl|!YTO!Vk1u4q2{Oa3Lxs3p)60|zq%5u&8fq8fqKGnd z)X#HJRO>h4YtF)IW-r|BRo(kn8LpLg9zU_|>xw<OQ@p6s;?@<GIY&q-Q;*H|?wUsB zTGFlt+~jfb4%)rQ_&ojJhikf;xpS(MXUjKhl*Tsr%q1TWjE||L>lVBZi^r9HM==WR zd%aI5za-ZPRb)7LgQwXWMSha%i`bKWnC**j;hFIpN7?4i;l%jDQ9=96P^pG=WNGzC zHGFf(dNpZ_`Ags${FaKs?NWT!#m?phyiLZvNMP$rTQNU9S@_N#7~DtzZ9}B$e1%{n z2V*zkO3u|O-N)oc?ZCE!Wmyk``E@9rv@G*gYP(oa7{Yjm4cqqiZ-_^)EFPJjV#;Td zUQQJ@N{2`%plDK<L^G%Q{91vgr*$4USZ9`A9_`BuzM=YTjbCFNn>pij%+l>CKC(8t zBz97Qs%v1v(ZLI;R!Q!jUt@(-3rvIKXxX+blJWN4`G+EL8gN4+UM9EZ!SD{VtbYMT z5jUsDKYFM)sogi-Fs>Z)Q5ycffnFh4FS}zWF9iaM`vC^<#}7y3SM7g1{Obb(fbp*< z1i-1lACLbO$y8AW2YlE+zqD|1hXsIu-GTrBV8D0|P@d%vtUsQh{(+@AfQv;94FF6b z0RSlf!UFy;pdf%B`1wyPdU_f=R};rS($oJD_xWqc@n|^gwZJSH7~Vg^NdIi*Katyj zeE*X21PZ}e*cw~dnmN(f|5L^DuYrSV_Stv?OB3Hy{5N<9{=dPQXiV*FooS5ijGXNp z|3=FLwmIOLfr<KvWB>r(pIRP4_Zysvody^OGqE+Zumv9F7#bK^nb;ct^C;)9^#F=R zm;M#l12bR`{!D_#^_zs5k;ci!z|r{+DjGX`XA2t(CzHR^6U+{=ZrM)&fEO(Qfd8kS zSWEt!gqa4Ix#Z+*U`=CWZDQa^W8&y&=lFNJVs^Vscm(VUIj}1@e_}z={5K0d%^yQ< z0c0^XF>rQqH2FIW(ju`WgKz*qH?SWVe_~L!_|3q?L1ScRYieOeV`^<+_IJ?jA+4~U zJ_7)CDgXe|pU|yc|BVi8KhOmJ*4s~?d^$q|w%QhW;p5Nmr7rn5E;Bogg{`xRqk)n0 zAGbh&bM^1ETv6szKn2ip{r-oI;pYBkU<BHju`y6G*U1@ZXMe*CT%+1Gnt)#C5m>Se z`A@F}yWuxD0}G9tiJ`fjoz>p~9uX3ef(8J7q5}YEe*(ts{x>i?jgyh1iHWU~x!pgL z>i(Kpu?jwui387GPX+&b#<vdt#%5yshi<TmovEpXkqM1~v8#ctk;y-+nEy2vO-YNZ zQJ`;gX9oaq|I`Dm<=-p}jK2fse~1+Si|lW_B|0|oD`w#9Kg9Y&I_p3Er_aT|IoN32 zfHKIAKqv8c27p0SoKqLL2IR8*xAz1*|Aq!0SO42JTmGFnsh_)<MGy2ju#5lz@t@jV z{R!k>exHez=6A6>U<WLm|FB$R3ug;E+rPtvCx5SQ44gy!z)|@0Cng@e-%P;SZ)E*X zk+HuvwBHo<ja`7Czc>H@%s+u1vHk{SVER3@K*wxn|99Ga`Kt%C9w>;0{6E%~I@#aw z%xpAvwkE)LVq;@qYYg-ce>i9BzcF=ECo<3Fff*bhkpFw?bZP%4VPK^(1e)z1Gw1KL zziUe=mjV$0=mNg;Kd<UKCcmM9@^1g~Hh*}YztL{_`bUCU;KEys_upo<==vL+ne`v5 zp_7HD3C$la&BoyGE$)mZIqERL=@|wbfIlxKph3Sm80deGud|2!-&&~ttXTe^Mys3n z8~uONFaK-v^UsQU|H**=?Ki{!(AWEG(0|r@_)qBd%6~)uFX|6}jp5G~F#nT*rt>$$ z|56Y0uMzzDj^%$6P%r)__+M{a{xym}XMq1F#pU*IivP+7|7*a1&b#_g;JM>}1OLw~ vtiJ~S=a}Swf};Yz8~kfG{{I`H{NH%MA%I^LfN@SjKo)SX;{*;0{O$h%AxwrH literal 0 HcmV?d00001 diff --git a/docs/specs/00-overview.md b/docs/specs/00-overview.md deleted file mode 100644 index 12f8554..0000000 --- a/docs/specs/00-overview.md +++ /dev/null @@ -1,114 +0,0 @@ -# Folio Specs — Overview - -> Spec-driven, sub-agent-friendly development plan for the Folio workspace. - -## Why this exists - -Each spec under `docs/specs/` is a **self-contained work order** for a single -crate or module. An implementing agent must be able to: - -1. Read **only** the spec (plus the cited docs/links inside it), -2. Produce code that satisfies every item in the spec's *Acceptance* section, -3. Run the *Test plan* and have it pass. - -This decouples authoring from implementation, lets multiple agents work in -parallel on independent specs, and gives reviewers a single source of truth -to compare a PR against. - -## Source-of-truth hierarchy - -When specs and other docs disagree: - -``` -docs/specs/* (this directory) <- highest priority, authoritative -docs/proposal.md <- design intent, may be stale -docs/gotenberg-spec.md <- Gotenberg API contract we mirror -README.md <- user-facing summary -docs/gap-analysis.md <- background / context only -docs/obscura-spec.md <- background / context only -``` - -If a spec needs to override `proposal.md`, do it explicitly in the spec body -and call it out in the PR. - -## Spec template - -Every spec MUST contain these sections in this order: - -1. **Goal** — one sentence, present tense. -2. **Scope** — what's in / out. -3. **Public API** — exact Rust signatures (or HTTP routes / CLI surface). -4. **Behavior** — stepwise pseudocode for each public entrypoint. -5. **Errors** — every error variant the code can produce + when. -6. **Edge cases** — concrete adversarial inputs and the required response. -7. **Test plan** — list of unit + integration tests with input → expected. -8. **Acceptance** — bullet checklist; every box must be tickable to merge. -9. **Out of scope / follow-ups** — explicitly deferred work. - -## Dispatch ledger - -| ID | Spec | Crate | Depends on | Phase | -|-----|----------------------------|------------------|---------------|-------| -| 10 | engine-types | `engine` | — | 1 | -| 11 | engine-chromium | `engine` | 10 | 1 | -| 12 | engine-libreoffice | `engine` | 10 | 3 | -| 13 | engine-pdfops | `engine` | 10 | 4 | -| 20 | cli | `cli` | 10, 11 | 1/5 | -| 30 | server | `server` | 10, 11(+12,13)| 2 | -| 40 | bindings-py | `py` | 10, 11 | 6 | -| 41 | bindings-js | `js` | 10, 11 | 6 | - -Phases mirror `@docs/proposal.md` *Implementation Phases*. Anything in the -same phase with no shared dependency can be worked in parallel by separate -sub-agents. - -## Conventions - -### Rust - -- Edition: `2024` (set at workspace level). -- Errors: each crate exports a `thiserror` enum; binaries/bindings convert - to `anyhow::Error` only at the top of `main` / FFI boundary. -- All public async fns take `&self`, never `&mut self`. Internal mutability - goes through `tokio::sync` primitives. -- Public types implement `Debug` + `Clone` where it doesn't break invariants. -- No `unsafe` outside FFI shims (`py`, `js`). -- `#![deny(rust_2018_idioms, missing_docs)]` on every published crate's lib. -- Public functions documented with `///`; doc examples compile (`cargo test --doc`). - -### Imports / lib names - -The `engine` crate's package is `engine`; importable path is `engine::…`. -The `py` and `js` crates produce a `cdylib` with `[lib] name = "folio"` so -their respective host languages see a module called `folio`. - -### Tests - -- **Unit tests** colocated in `src/` via `#[cfg(test)] mod tests`. -- **Integration tests** under each crate's `tests/`. -- **End-to-end** Chrome-bound tests gated behind `#[ignore]` and run by CI - with `cargo test -- --ignored` after Chrome is provisioned. They never - block local `cargo test`. -- Test PDFs are validated by: - - Byte-stream contains `%PDF-1.` header and `%%EOF` trailer. - - `lopdf::Document::load_mem(&bytes)` round-trips successfully. - - Page count matches expectation. - -### Commits - -Conventional commits, scoped by spec ID where applicable, e.g.: - -- `feat(engine/11): implement ChromiumEngine::html_to_pdf` -- `test(engine/11): add networkidle wait condition tests` -- `docs(specs): expand 13-engine-pdfops` - -### Definition of Done (per spec) - -A spec is **done** when: - -1. Every box in *Acceptance* is checked, -2. `cargo fmt --check` and `cargo clippy --workspace -- -D warnings` pass, -3. `cargo test --workspace` passes (excluding `--ignored` E2E), -4. Public API matches *Public API* section verbatim, -5. The spec file itself is updated if any deviation was necessary, with - rationale in the commit message. diff --git a/docs/specs/10-engine-types.md b/docs/specs/10-engine-types.md deleted file mode 100644 index 542ae3b..0000000 --- a/docs/specs/10-engine-types.md +++ /dev/null @@ -1,291 +0,0 @@ -# Spec 10 — `engine::types` - -> Shared types and error model for the Folio engine. All other specs build on -> this; nothing else should redeclare these types. - -## Goal - -Provide the canonical, serde-aware Rust types that describe a PDF generation -request and the engine's error surface, without taking any dependency on -`chromiumoxide`, `lopdf`, or HTTP frameworks. - -## Scope - -**In:** `PdfOptions`, `PaperSize`, `Margins`, `WaitCondition`, `MediaType`, -`PageRanges`, `BrowserConfig`, `EngineError`, `EngineResult<T>`. - -**Out:** Anything Chromium-, LibreOffice-, or HTTP-specific. Those live in -their own specs and may *use* these types. - -## Public API - -Module path: `engine::types` (re-exported from `engine`'s crate root). - -```rust -use std::path::PathBuf; -use std::time::Duration; -use serde::{Deserialize, Serialize}; - -/// All knobs that influence a single PDF render. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct PdfOptions { - pub paper: PaperSize, - pub margin: Margins, - pub landscape: bool, - /// Multiplier applied to page rendering. 0.1..=2.0. - pub scale: f32, - pub print_background: bool, - pub prefer_css_page_size: bool, - pub emulate_media: MediaType, - pub page_ranges: Option<PageRanges>, - pub header_template: Option<String>, - pub footer_template: Option<String>, - pub wait: WaitCondition, -} - -impl Default for PdfOptions { /* see Behavior */ } - -/// Paper dimensions in inches. Constructors enforce > 0. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct PaperSize { - pub width_in: f32, - pub height_in: f32, -} - -impl PaperSize { - pub const A4: Self = Self { width_in: 8.27, height_in: 11.69 }; - pub const LETTER: Self = Self { width_in: 8.5, height_in: 11.0 }; - pub const LEGAL: Self = Self { width_in: 8.5, height_in: 14.0 }; - pub const A3: Self = Self { width_in: 11.69, height_in: 16.54 }; - pub const A5: Self = Self { width_in: 5.83, height_in: 8.27 }; - - pub fn new(width_in: f32, height_in: f32) -> Result<Self, EngineError>; -} - -/// Margins in inches. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct Margins { - pub top: f32, pub right: f32, pub bottom: f32, pub left: f32, -} - -impl Margins { - pub const ZERO: Self = Self { top: 0.0, right: 0.0, bottom: 0.0, left: 0.0 }; - pub const DEFAULT: Self = Self { top: 0.39, right: 0.39, bottom: 0.39, left: 0.39 }; // ~1cm - - pub fn uniform(inches: f32) -> Self; -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum MediaType { #[default] Print, Screen } - -/// Page ranges parsed from the Gotenberg-compatible string form, e.g. "1-3,5,7-". -/// `to_string` round-trips canonical form. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(try_from = "String", into = "String")] -pub struct PageRanges(Vec<PageRange>); - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PageRange { Single(u32), Closed(u32, u32), OpenEnd(u32) /* "7-" */ } - -impl PageRanges { - pub fn parse(s: &str) -> Result<Self, EngineError>; - pub fn contains(&self, page: u32, total: u32) -> bool; -} - -/// What to wait for after navigation/setContent before rendering. -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "camelCase")] -pub enum WaitCondition { - #[default] - Load, - DomContentLoaded, - NetworkIdle, - Selector { selector: String }, - Expression { expression: String }, - Delay { #[serde(with = "humantime_serde")] duration: Duration }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct BrowserConfig { - /// Path to chrome/chromium. If `None`, autodiscover via $PATH then - /// platform-typical locations; finally fall through to `EngineError::ChromeNotFound`. - pub executable: Option<PathBuf>, - /// Run with --headless=new. Default true. - pub headless: bool, - /// Extra command line flags appended verbatim. - pub extra_args: Vec<String>, - /// Disable Chrome's sandbox. Required inside most Docker images. - /// Default: true on Linux, false elsewhere. - pub no_sandbox: bool, - /// Per-page navigation/render timeout. - #[serde(with = "humantime_serde")] - pub timeout: Duration, -} - -impl Default for BrowserConfig { /* see Behavior */ } - -#[derive(Debug, thiserror::Error)] -pub enum EngineError { - #[error("invalid option: {0}")] - InvalidOption(String), - - #[error("invalid page range: {0}")] - InvalidPageRange(String), - - #[error("chrome executable not found (searched: {searched:?})")] - ChromeNotFound { searched: Vec<PathBuf> }, - - #[error("chrome failed to launch: {0}")] - ChromeLaunch(String), - - #[error("CDP error: {0}")] - Cdp(String), - - #[error("navigation failed for {url}: {reason}")] - Navigation { url: String, reason: String }, - - #[error("operation timed out after {0:?}")] - Timeout(Duration), - - #[error("io error: {0}")] - Io(#[from] std::io::Error), - - #[error("internal error: {0}")] - Internal(String), -} - -pub type EngineResult<T> = Result<T, EngineError>; -``` - -## Behavior - -### `PdfOptions::default()` - -``` -PdfOptions { - paper: PaperSize::A4, - margin: Margins::DEFAULT, - landscape: false, - scale: 1.0, - print_background: true, - prefer_css_page_size: false, - emulate_media: MediaType::Print, - page_ranges: None, - header_template: None, - footer_template: None, - wait: WaitCondition::Load, -} -``` - -### `BrowserConfig::default()` - -``` -BrowserConfig { - executable: None, - headless: true, - extra_args: vec![], - no_sandbox: cfg!(target_os = "linux"), - timeout: Duration::from_secs(60), -} -``` - -### `PaperSize::new(w, h)` - -- If `w <= 0.0` or `h <= 0.0` → `EngineError::InvalidOption("paper dimensions must be > 0")`. -- If `w > 200.0` or `h > 200.0` → `EngineError::InvalidOption("paper dimensions must be <= 200in")`. -- Else `Ok(Self { width_in: w, height_in: h })`. - -### `PageRanges::parse(s)` - -Grammar (whitespace ignored): - -``` -ranges := range ("," range)* -range := number | number "-" number | number "-" -number := [1-9][0-9]* -``` - -- Empty input or only commas → `EngineError::InvalidPageRange`. -- A range `a-b` requires `a <= b`, else error. -- Result preserves input order. Caller is responsible for de-duplication. - -### `PageRanges::contains(page, total)` - -- `Single(n)` → `page == n && n <= total`. -- `Closed(a, b)` → `a <= page && page <= b.min(total)`. -- `OpenEnd(a)` → `a <= page && page <= total`. - -### Validation (used by `ChromiumEngine` before invoking CDP) - -`PdfOptions::validate(&self) -> EngineResult<()>` checks: - -- `0.1 <= scale <= 2.0`, -- `paper.width_in > 0 && paper.height_in > 0` (already by constructor), -- All margins are finite and `>= 0` and each `< paper.width_in / 2` (left/right) or `< paper.height_in / 2` (top/bottom), -- Header/footer templates, if `Some`, are non-empty after trimming. - -This function MUST be exposed publicly; binaries call it before queueing a render. - -## Errors - -The full `EngineError` enum is the *only* error type returned from any spec -in the `engine::*` family. Each downstream spec adds variants by editing -this spec rather than introducing parallel error enums. - -## Edge cases - -| Input | Required behavior | -|-----------------------------------|-----------------------------------------------------------| -| `PageRanges::parse("")` | `Err(InvalidPageRange("empty"))` | -| `PageRanges::parse(",,")` | `Err(InvalidPageRange)` | -| `PageRanges::parse("0-3")` | `Err(InvalidPageRange("page numbers are 1-indexed"))` | -| `PageRanges::parse("5-3")` | `Err(InvalidPageRange("end < start"))` | -| `PageRanges::parse(" 1 - 3 , 7-")`| `Ok([Closed(1,3), OpenEnd(7)])` | -| `PaperSize::new(0.0, 11.0)` | `Err(InvalidOption(..))` | -| `PaperSize::new(8.5, f32::NAN)` | `Err(InvalidOption(..))` | -| `PdfOptions { scale: 3.0, .. }` | `validate()` → `Err(InvalidOption("scale out of range"))` | -| Missing fields in JSON deserialise| Treated as defaults via `#[serde(default)]` | - -## Test plan - -All in `crates/engine/src/types.rs` under `#[cfg(test)] mod tests`. - -- `paper_size_constants_match_spec` — all five preset constants. -- `paper_size_new_rejects_nonpositive`. -- `paper_size_new_rejects_nan_inf`. -- `margins_uniform_sets_all_four`. -- `page_ranges_parse_single_number`. -- `page_ranges_parse_closed_range`. -- `page_ranges_parse_open_end`. -- `page_ranges_parse_mixed_with_whitespace`. -- `page_ranges_parse_rejects_zero`. -- `page_ranges_parse_rejects_inverted`. -- `page_ranges_parse_rejects_empty`. -- `page_ranges_contains_handles_total_clamp`. -- `page_ranges_round_trips_via_serde`. -- `pdf_options_default_matches_spec`. -- `pdf_options_validate_scale_range`. -- `pdf_options_validate_margin_too_large`. -- `pdf_options_serde_camel_case_roundtrip` — JSON `{"paper":{"widthIn":...},...}`. -- `wait_condition_default_is_load`. -- `wait_condition_serde_tag_kind`. -- `browser_config_default_no_sandbox_on_linux_only`. - -## Acceptance - -- [ ] `crates/engine/src/types.rs` exists and is `pub mod types` from `lib.rs`. -- [ ] All public items in *Public API* compile and match signatures verbatim. -- [ ] Workspace deps added: `serde`, `serde_json` (dev), `thiserror`, `humantime-serde`. -- [ ] `cargo test -p engine` passes with all tests in *Test plan*. -- [ ] `cargo doc -p engine --no-deps` produces no warnings. -- [ ] No `unwrap`/`expect` on user-supplied input paths. -- [ ] `lib.rs` carries `#![deny(rust_2018_idioms, missing_docs)]`. - -## Out of scope / follow-ups - -- ScreenshotOptions (separate spec when we tackle `/screenshot/*`). -- PDF/A and PDF/UA flags (added when spec 13 lands). -- Cookies / extra HTTP headers (added by spec 11; types live there). diff --git a/docs/specs/11-engine-chromium.md b/docs/specs/11-engine-chromium.md deleted file mode 100644 index 07b800b..0000000 --- a/docs/specs/11-engine-chromium.md +++ /dev/null @@ -1,442 +0,0 @@ -# Spec 11 — `engine::chromium::ChromiumEngine` - -> The Phase-1 MVP. Converts HTML / URL / Markdown to PDF via real Chrome -> through the Chrome DevTools Protocol. - -## Goal - -Provide a single `ChromiumEngine` type that reliably produces a PDF byte -stream from HTML strings, remote URLs, or Markdown — usable from binaries -(CLI, server) and bindings without any wrapper layer. - -## Scope - -**In:** - -- Browser lifecycle (launch, reuse, shutdown). -- `html_to_pdf`, `url_to_pdf`, `markdown_to_pdf`. -- Wait conditions (load / domcontentloaded / networkidle / selector / expression / delay). -- All `PdfOptions` knobs from spec 10 mapped onto CDP `Page.printToPDF`. -- Cookies, extra HTTP headers, custom user agent (per-call). - -**Out:** - -- Connection pooling for HTTP server (spec 30 wraps this engine in a pool). -- Auto-download of Chrome (deferred — first cut requires a chrome on `$PATH` - or in `BrowserConfig::executable`). -- PDF/A / PDF/UA conformance (spec 13). - -## Public API - -Module path: `engine::chromium`, re-exported as `engine::ChromiumEngine`. - -```rust -use crate::types::{BrowserConfig, EngineResult, PdfOptions}; -use std::collections::HashMap; -use std::sync::Arc; - -/// One Chromium browser instance shared across many concurrent renders. -/// Cheap to clone (`Arc` inside). -#[derive(Clone)] -pub struct ChromiumEngine { - inner: Arc<Inner>, // private -} - -impl ChromiumEngine { - /// Launch a new browser with default config. - pub async fn launch() -> EngineResult<Self>; - - /// Launch with explicit config (executable path, sandbox, timeout, ...). - pub async fn launch_with(config: BrowserConfig) -> EngineResult<Self>; - - /// Render an HTML string to PDF bytes. - /// `base_url`, when `Some`, is used as the document's base URL so that - /// relative `<img>`, `<link>` etc. resolve against it. - pub async fn html_to_pdf( - &self, - html: &str, - base_url: Option<&str>, - opts: &PdfOptions, - request: &RequestContext, - ) -> EngineResult<Vec<u8>>; - - /// Navigate to `url` and render to PDF bytes. - pub async fn url_to_pdf( - &self, - url: &str, - opts: &PdfOptions, - request: &RequestContext, - ) -> EngineResult<Vec<u8>>; - - /// Render Markdown to PDF. Implementation: render to HTML internally - /// (CommonMark + tables + strikethrough + task lists) wrapped in a small - /// stylesheet, then call `html_to_pdf`. - pub async fn markdown_to_pdf( - &self, - markdown: &str, - opts: &PdfOptions, - request: &RequestContext, - ) -> EngineResult<Vec<u8>>; - - /// Capture an HTML string as a screenshot. - /// Returns PNG, JPEG, or WebP bytes based on `format`. - /// `base_url`, when `Some`, is used as the document's base URL. - pub async fn screenshot_html( - &self, - html: &str, - base_url: Option<&str>, - opts: &ScreenshotOptions, - request: &RequestContext, - ) -> EngineResult<Vec<u8>>; - - /// Navigate to `url` and capture a screenshot. - pub async fn screenshot_url( - &self, - url: &str, - opts: &ScreenshotOptions, - request: &RequestContext, - ) -> EngineResult<Vec<u8>>; - - /// Render Markdown to HTML then capture a screenshot. - pub async fn screenshot_markdown( - &self, - markdown: &str, - opts: &ScreenshotOptions, - request: &RequestContext, - ) -> EngineResult<Vec<u8>>; - - /// Best-effort liveness probe — `true` iff the browser process responds - /// to `Browser.getVersion` within `BrowserConfig::timeout`. - pub async fn healthy(&self) -> bool; - - /// Close the browser. Idempotent. Future calls return - /// `EngineError::Internal("engine shut down")`. - pub async fn shutdown(self) -> EngineResult<()>; -} - -/// Per-render request context. Always passed even when empty. -#[derive(Debug, Clone, Default)] -pub struct RequestContext { - pub user_agent: Option<String>, - pub extra_headers: HashMap<String, String>, - pub cookies: Vec<Cookie>, - /// HTTP statuses that should fail the render. Empty means no statuses fail. - pub fail_on_status: Vec<u16>, -} - -#[derive(Debug, Clone)] -pub struct Cookie { - pub name: String, - pub value: String, - pub domain: Option<String>, - pub path: Option<String>, - pub secure: bool, - pub http_only: bool, -} - -/// Screenshot output format. -#[derive(Debug, Clone, Copy)] -pub enum ScreenshotFormat { - Png, - Jpeg, - Webp, -} - -/// Options for screenshot capture. -#[derive(Debug, Clone)] -pub struct ScreenshotOptions { - /// Output format (default: Png). - pub format: ScreenshotFormat, - /// JPEG/WebP quality (0-100, default: 80). - pub quality: Option<u8>, - /// Capture full scrollable page (default: false). - pub full_page: bool, - /// Viewport dimensions (default: 1920x1080). - pub viewport_width: u32, - pub viewport_height: u32, - /// Device scale factor (default: 1.0). - pub scale: f32, - /// Clip rectangle (optional). When set, only this region is captured. - pub clip_x: Option<f64>, - pub clip_y: Option<f64>, - pub clip_width: Option<f64>, - pub clip_height: Option<f64>, -} - -impl Default for ScreenshotOptions { - fn default() -> Self { - Self { - format: ScreenshotFormat::Png, - quality: None, - full_page: false, - viewport_width: 1920, - viewport_height: 1080, - scale: 1.0, - clip_x: None, - clip_y: None, - clip_width: None, - clip_height: None, - } - } -} -``` - -## Behavior - -### Launch flow - -1. Resolve `BrowserConfig::executable`: - 1. If `Some(p)`, use it. - 2. Else, in order, check `$BROWSER_PATH`, `which chromium`, `which chrome`, - and platform-typical defaults - (`/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`, - `/usr/bin/google-chrome`, `/usr/bin/chromium`, etc.). - 3. If none → `EngineError::ChromeNotFound { searched }`. -2. Spawn Chrome with: `--headless=new`, `--disable-gpu`, - `--hide-scrollbars`, `--mute-audio`, plus `--no-sandbox` iff - `config.no_sandbox`, plus `config.extra_args`. -3. Connect via WebSocket using `chromiumoxide::Browser::launch`. On error - → `EngineError::ChromeLaunch(msg)`. -4. Spawn a background task to drive the chromiumoxide handler future. Store - its `JoinHandle` in `Inner` so `shutdown` can abort it. - -### Chrome Version Compatibility - -The engine uses `chromiumoxide` 0.9 which is generated from Chrome DevTools -Protocol (CDP) definitions matching Chrome up to version ~135. Newer Chrome -versions (136+) may emit CDP event types that chromiumoxide doesn't recognize, -causing deserialization warnings like: - -``` -WS Invalid message: data did not match any variant of untagged enum Message -``` - -**Impact:** These are non-fatal. PDF generation continues to work because core -CDP commands (`Page.printToPDF`, navigation, etc.) remain compatible. The -warnings only affect event notifications Chrome sends asynchronously. - -**Resolution options:** -1. Use Chrome 134-135 for clean logs (matching chromiumoxide 0.9 CDP version) -2. Accept warnings with Chrome 136+ (PDF generation still works) -3. Wait for chromiumoxide update with newer CDP definitions - -The engine logs the detected Chrome version at startup and warns if >135. - -### `html_to_pdf` - -1. `opts.validate()?` (from spec 10). -2. Open a new page (`browser.new_page("about:blank")`). -3. Apply `RequestContext`: - - If `user_agent.is_some()`, send `Network.setUserAgentOverride`. - - If `!extra_headers.is_empty()`, send `Network.setExtraHTTPHeaders`. - - For each cookie, send `Network.setCookie`. -4. If `base_url.is_some()`, navigate first to that URL with `wait = Load`, - then call `Page.setDocumentContent` on the main frame to inject `html`. - Otherwise, set the page content directly via `page.set_content(html)`. -5. Run `Emulation.setEmulatedMedia` with `"print"` or `"screen"` per - `opts.emulate_media`. -6. Wait per `opts.wait` (see Wait Conditions). -7. Build CDP `Page.printToPDF` params from `opts` and call. The engine MUST - handle paginated streaming responses (chromiumoxide returns a base64 - string by default; decode to `Vec<u8>`). -8. Close the page (best-effort; log errors but do not fail the render). -9. Return PDF bytes. - -If any CDP call returns an error, map to: - -- Network/connection close → `EngineError::Cdp(msg)`. -- Navigation failures (`net::ERR_*`) → `EngineError::Navigation`. -- A `tokio::time::timeout` of `BrowserConfig::timeout` wraps the entire - render; on elapse → `EngineError::Timeout`. - -### `url_to_pdf` - -Same as `html_to_pdf` but step 4 becomes `page.goto(url)` and the -`base_url` parameter does not apply. - -If `RequestContext::fail_on_status` is non-empty, listen for -`Network.responseReceived`; if the main frame's response status is in the -list → cancel and return `EngineError::Navigation`. - -### `markdown_to_pdf` - -1. Convert via `pulldown-cmark` with `Options::all()`. -2. Wrap in a built-in HTML template (`<html><head><meta charset>... - <style>{default-css}</style></head><body>{rendered}</body></html>`). -3. Delegate to `html_to_pdf` with `base_url = None`. - -The default stylesheet lives in `crates/engine/src/chromium/markdown.css` -and is `include_str!`'d. Minimum: readable typography, code-block -monospace, table borders. - -### Wait conditions - -| `WaitCondition` | Implementation | -|-----------------------|------------------------------------------------------------------------------------------------| -| `Load` | Already implicit after `set_content` / `goto`. No extra wait. | -| `DomContentLoaded` | Subscribe to `Page.domContentEventFired`. Resolve on first event. | -| `NetworkIdle` | Subscribe to `Page.lifecycleEvent` and resolve on `name == "networkIdle"`. | -| `Selector { s }` | Poll `Runtime.evaluate("!!document.querySelector(s)")` every 50ms until `true` or timeout. | -| `Expression { e }` | Same polling pattern but evaluating the user expression. Must coerce result to bool. | -| `Delay { duration }` | `tokio::time::sleep(duration)`. | - -All wait paths are bounded by `BrowserConfig::timeout`. - -### Screenshot behavior - -#### `screenshot_html` - -1. Apply `RequestContext` (user agent, headers, cookies) same as `html_to_pdf`. -2. Set page content via `page.set_content(html)` or navigate to `base_url` first. -3. Wait per `opts.wait` (see Wait Conditions). -4. Build screenshot params from `ScreenshotOptions`: - - `format` → `ScreenshotFormat::Png/Jpeg/Webp` - - `quality` → JPEG/WebP quality (0-100) - - `clip` → Optional clip rectangle - - `full_page` → Capture full scrollable page -5. Call `page.screenshot(params)`. -6. Return image bytes. - -#### `screenshot_url` - -Same as `screenshot_html` but step 2 becomes `page.goto(url)`. - -If `RequestContext::fail_on_status` is non-empty, listen for -`Network.responseReceived`; if the main frame's response status is in the -list → cancel and return `EngineError::Navigation`. - -#### `screenshot_markdown` - -1. Convert Markdown to HTML via `pulldown-cmark` (same as `markdown_to_pdf`). -2. Delegate to `screenshot_html` with `base_url = None`. - -### `Page.printToPDF` parameter mapping - -``` -landscape <- opts.landscape -displayHeaderFooter <- opts.header_template.is_some() || opts.footer_template.is_some() -headerTemplate <- opts.header_template -footerTemplate <- opts.footer_template -printBackground <- opts.print_background -scale <- opts.scale -paperWidth <- opts.paper.width_in -paperHeight <- opts.paper.height_in -marginTop <- opts.margin.top -marginBottom <- opts.margin.bottom -marginLeft <- opts.margin.left -marginRight <- opts.margin.right -pageRanges <- opts.page_ranges.map(|r| r.to_string()) -preferCSSPageSize <- opts.prefer_css_page_size -transferMode <- "ReturnAsBase64" -``` - -### Concurrency - -`html_to_pdf` / `url_to_pdf` / `markdown_to_pdf` are safe to invoke from -many concurrent tasks against a single `ChromiumEngine`. Each call opens -its own page — there is no implicit serialization. Callers wanting -back-pressure should impose a `tokio::sync::Semaphore` upstream (the -server crate, spec 30, will). - -## Errors - -Reuses `EngineError` from spec 10. New error sources documented above: -`ChromeNotFound`, `ChromeLaunch`, `Cdp`, `Navigation`, `Timeout`. No new -variants needed. - -## Edge cases - -| Scenario | Required behavior | -|-----------------------------------------------------|--------------------------------------------------------------------------------| -| HTML body empty string | Produce a single blank page; not an error. | -| URL returns 5xx, `fail_on_status = [500..=599]` | `EngineError::Navigation { reason: "status 503" }`. | -| URL is not http/https (e.g. `file://`) | Allowed if Chrome accepts it; we do not pre-validate scheme. | -| `opts.scale = 3.0` | Caught by `opts.validate()` → `EngineError::InvalidOption` before any CDP call.| -| `Selector` never matches before timeout | `EngineError::Timeout`. | -| Engine cloned then dropped | Browser stays alive while *any* clone exists. | -| `shutdown()` called while another render is running | Render returns `EngineError::Internal("engine shut down")`; shutdown succeeds. | -| Markdown contains raw `<script>` | Tag stripped by `pulldown-cmark` defaults; not executed. | -| Header template references `{date}` etc. | Pass through verbatim; Chrome substitutes. | - -## Test plan - -### Unit tests (`crates/engine/src/chromium/mod.rs`) - -These do not need Chrome. - -- `executable_resolution_prefers_explicit`. -- `executable_resolution_falls_back_to_path`. -- `executable_resolution_emits_searched_list_on_failure`. -- `printtopdf_params_built_from_pdfoptions` — assert exact CDP param map. -- `markdown_template_wraps_with_charset_meta`. - -### Integration tests (`crates/engine/tests/chromium_html.rs`) - -Marked `#[ignore]`; require `CHROME_PATH` env or system Chrome. Run via -`cargo test -p engine -- --ignored`. - -- `html_to_pdf_returns_valid_pdf_bytes` — bytes start with `%PDF-` and - load via `lopdf::Document::load_mem`. -- `html_to_pdf_respects_paper_size` — render 1in×1in page; check - `MediaBox` in lopdf. -- `url_to_pdf_against_local_axum` — spin up a tiny axum server with - `/index.html`, render, assert page count == 1. -- `wait_selector_completes_when_element_appears` — page injects element - after 100ms via setTimeout; assert success. -- `wait_selector_times_out_when_missing` — assert `EngineError::Timeout`. -- `cookies_and_headers_round_trip` — local server echoes them back into - the rendered HTML; assert echoes appear in PDF text (via lopdf text - extraction). -- `concurrent_renders_do_not_deadlock` — spawn 8 tasks, all complete. -- `markdown_to_pdf_renders_table` — assert table cells appear in - extracted text. -- `shutdown_cancels_in_flight_render` — assert in-flight render returns - the documented internal error. - -### Screenshot integration tests (`crates/engine/tests/chromium_screenshot.rs`) - -Marked `#[ignore]`; require `CHROME_PATH` env or system Chrome. - -- `screenshot_html_returns_valid_png` — bytes start with PNG magic - (`\x89PNG`). -- `screenshot_html_jpeg_format` — set format to JPEG; bytes start with - `0xFF 0xD8` (JPEG magic). -- `screenshot_url_captures_page` — navigate to local server, capture, - verify non-empty image. -- `screenshot_full_page` — render tall page, set `full_page = true`, - verify image height > viewport height. -- `screenshot_clip_rect` — set clip rectangle, verify output dimensions. -- `screenshot_markdown_renders` — convert Markdown to screenshot, verify - output is valid image. -- `screenshot_quality_jpeg` — set JPEG quality to 50, verify output - smaller than quality 100. - -### Doc tests (`engine/src/chromium/mod.rs`) - -Compile-only example showing the canonical usage from `@README.md:85-97`, -behind `#[cfg(doctest)]` `no_run`. - -## Acceptance - -- [ ] `crates/engine/src/chromium/mod.rs` exists with the full Public API. -- [ ] `chromiumoxide` and `pulldown-cmark` added to `crates/engine/Cargo.toml` - via `workspace.dependencies`. -- [ ] All unit tests in *Test plan* pass with `cargo test -p engine`. -- [ ] All ignored integration tests pass locally with a system Chrome. -- [ ] No `unsafe`. No `panic!` outside test code. -- [ ] `cargo clippy -p engine -- -D warnings` clean. -- [ ] `ChromiumEngine` is `Send + Sync + Clone` (assert via `static_assertions`). -- [ ] `shutdown` is idempotent (test). -- [ ] Screenshot methods (`screenshot_html`, `screenshot_url`, - `screenshot_markdown`) implemented. -- [ ] `ScreenshotOptions` and `ScreenshotFormat` types exist. -- [ ] Screenshot integration tests pass with system Chrome. - -## Out of scope / follow-ups - -- Screenshot routes (`/screenshot/*`) — implemented in this spec, server - routes in spec 30. -- Auto-download of Chrome — feature flag `auto-download` once stable. -- PDF/A and PDF/UA — picked up in spec 13 + a Ghostscript-style post-pass. -- Browser pool (multiple Chrome processes) — picked up in spec 30 once - benchmarks indicate need. diff --git a/docs/specs/12-engine-libreoffice.md b/docs/specs/12-engine-libreoffice.md deleted file mode 100644 index e961e44..0000000 --- a/docs/specs/12-engine-libreoffice.md +++ /dev/null @@ -1,337 +0,0 @@ -# Spec 12 — `engine::libreoffice::LibreOfficeEngine` - -> Office document → PDF via the `soffice --headless` subprocess. - -## Goal - -Convert files in any LibreOffice-supported format (Word, Excel, PowerPoint, -ODF, RTF, CSV, etc.) to PDF bytes by orchestrating short-lived `soffice` -subprocesses, with isolated user profiles for safe concurrency, so the -server's `/forms/libreoffice/convert` route mirrors Gotenberg. - -## Scope - -**In:** - -- Discovery / configuration of the `soffice` binary. -- Single-file and multi-file conversion (with optional merge to one PDF). -- Per-call isolated `UserInstallation` profile. -- PDF/A-1b / A-2b / A-3b export via LibreOffice's filter options. -- Hard timeouts, structured error mapping. - -**Out:** - -- PDF post-processing (delegated to spec 13 for `merge`). -- Long-running `soffice` daemon mode — every call is a fresh subprocess. - (A pool may come later as a follow-up if benchmarks justify it.) -- Per-format quirks beyond what LibreOffice's CLI flags expose - (e.g., specific Excel range selection — out of MVP). - -## Public API - -Module path: `engine::libreoffice`, re-exported as -`engine::LibreOfficeEngine`. - -```rust -use crate::types::{EngineError, EngineResult, PageRanges}; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; - -/// Wrapper around the `soffice` binary. Cheap to clone (`Arc` inside). -#[derive(Clone)] -pub struct LibreOfficeEngine { - inner: Arc<Inner>, // private: { exe, timeout, semaphore } -} - -#[derive(Debug, Clone)] -pub struct LibreOfficeConfig { - /// Path to `soffice` (or `libreoffice`). `None` = autodiscover. - pub executable: Option<PathBuf>, - /// Per-conversion timeout. Default 120s. - pub timeout: Duration, - /// Maximum concurrent subprocess invocations. Default `num_cpus::get()`. - pub max_concurrency: usize, -} - -impl Default for LibreOfficeConfig { - /* see Behavior */ -} - -impl LibreOfficeEngine { - /// Discover `soffice` on PATH and platform defaults. - pub async fn discover() -> EngineResult<Self>; - - pub async fn launch(config: LibreOfficeConfig) -> EngineResult<Self>; - - /// Convert one input file to PDF bytes. - pub async fn convert( - &self, - input: &Path, - opts: &OfficeOptions, - ) -> EngineResult<Vec<u8>>; - - /// Convert many inputs, optionally merging into a single PDF. - /// Inputs are converted in parallel up to `max_concurrency`. Output - /// order, when merging, follows input order. - pub async fn convert_many( - &self, - inputs: &[PathBuf], - opts: &OfficeOptions, - ) -> EngineResult<Vec<Vec<u8>>>; - - /// Returns true iff `soffice --version` succeeds within `timeout`. - pub async fn healthy(&self) -> bool; -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct OfficeOptions { - pub landscape: bool, - pub page_ranges: Option<PageRanges>, - /// PDF/A profile, if any. - pub pdf_a: Option<PdfAProfile>, - /// PDF/UA accessibility tagging. - pub pdf_ua: bool, - /// Quality knob for embedded raster images. 1..=100. None = LO default. - pub quality: Option<u8>, - /// Reduce image resolution (DPI). None = LO default. - pub max_image_resolution: Option<u32>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum PdfAProfile { A1B, A2B, A3B } -``` - -## Behavior - -### `LibreOfficeConfig::default()` - -```rust -LibreOfficeConfig { - executable: None, - timeout: Duration::from_secs(120), - max_concurrency: std::thread::available_parallelism() - .map(|n| n.get()) - .unwrap_or(4), -} -``` - -### Executable discovery (`discover` / `launch` with `executable = None`) - -Search order, first hit wins; record the full searched list for -diagnostics: - -1. `$LIBREOFFICE_PATH` (env var). -2. `which soffice` then `which libreoffice`. -3. macOS: `/Applications/LibreOffice.app/Contents/MacOS/soffice`. -4. Linux: `/usr/bin/soffice`, `/usr/bin/libreoffice`, - `/usr/lib/libreoffice/program/soffice`, - `/snap/bin/libreoffice`, `/var/lib/flatpak/exports/bin/org.libreoffice.LibreOffice`. -5. Windows: `C:\Program Files\LibreOffice\program\soffice.exe`, - `C:\Program Files (x86)\LibreOffice\program\soffice.exe`. - -If none found → `EngineError::Internal("LibreOffice not found: searched [...]")`. -(Reuses `EngineError::Internal` since spec 10 owns the enum; the message -is the discriminator.) - -After discovery, the engine probes with `soffice --headless --version` -under `config.timeout`. Probe failure → `EngineError::Internal("LibreOffice probe failed: ...")`. - -### `convert(input, opts)` - -1. `input.exists()`; else `EngineError::Io(io::ErrorKind::NotFound)`. -2. Acquire one permit from the engine's `Semaphore(max_concurrency)`. -3. Create `tmp = tempfile::tempdir()` (auto-cleanup via Drop). -4. Create `user_dir = tmp.path().join("uipfx")` (LibreOffice - `UserInstallation`). Build `file://` URL. -5. Build `outdir = tmp.path().join("out")` and create it. -6. Build CLI args: - - ``` - --headless - --norestore --nologo --nodefault --nofirststartwizard - --convert-to <export-target> - --outdir <outdir> - "-env:UserInstallation=file:///<user_dir>" - <input absolute path> - ``` - - `<export-target>` is built per [filter rules](#export-filter): - - - Default: `pdf:writer_pdf_Export` (or the appropriate exporter — see - filter table) with options expressed as a JSON-ish blob: - `pdf:writer_pdf_Export:{"PageRange":{"type":"string","value":"1-3,5"},...}`. - -7. Spawn via `tokio::process::Command`, capture stdout/stderr, - wait under `tokio::time::timeout(config.timeout, child.wait_with_output())`. -8. On exit code 0: - - Locate the produced `<basename>.pdf` in `outdir`. - - Read and return the bytes; `tmp` drops, cleaning everything. -9. Non-zero exit: - - Try to extract the LibreOffice error message from stderr; map to - `EngineError::Internal(format!("soffice exit {code}: {stderr}"))`. -10. Timeout: kill child, return `EngineError::Timeout(config.timeout)`. - -### `convert_many(inputs, opts)` - -1. Empty input slice → `Ok(vec![])`. -2. For each input, spawn a `tokio::task` calling `self.convert(input, opts)`. -3. `tokio::task::JoinSet::join_all` with the same global semaphore - gating concurrency. -4. Return `Vec<Vec<u8>>` in input order. - -`merge = true` is **not** part of `OfficeOptions`. Server / CLI layers -that want a single merged PDF must call `convert_many` and then -`engine::pdfops::merge` (spec 13). This keeps responsibilities clean and -avoids a circular dep between the libreoffice and pdfops modules. - -### Export filter - -| Input extension(s) | Exporter (CLI suffix) | -|-----------------------------------|----------------------------------| -| .doc .docx .odt .rtf .txt .html | `pdf:writer_pdf_Export` | -| .xls .xlsx .ods .csv | `pdf:calc_pdf_Export` | -| .ppt .pptx .odp | `pdf:impress_pdf_Export` | -| .odg .vsd .vsdx | `pdf:draw_pdf_Export` | -| (anything else) | `pdf` (let LO infer) | - -Detection is by lowercased extension only. The full table is kept inside -`engine::libreoffice::filter::for_extension(&str) -> &'static str`. - -### Filter parameters → CLI options blob - -For `pdf:writer_pdf_Export` (and equivalents), append a `:{...}` JSON-ish -blob containing only the fields set by `OfficeOptions`. The serializer -produces LibreOffice's expected `{"Key":{"type":"...","value":...}}` -shape. Mapping: - -| `OfficeOptions` field | LO key | LO type | -|------------------------------------------|-----------------------|----------------| -| `page_ranges` (formatted as range string)| `PageRange` | `string` | -| `pdf_a = A1B` → `1`, `A2B` → `2`, `A3B`=`3` | `SelectPdfVersion` | `long` | -| `pdf_ua = true` | `PDFUACompliance` | `boolean` | -| `quality` | `Quality` | `long` | -| `max_image_resolution` | `MaxImageResolution` | `long` | -| `landscape = true` | `IsLandscape` | `boolean` | - -If no fields are set, the blob is omitted entirely (`pdf:writer_pdf_Export` -without the `:` suffix). - -### Concurrency / safety - -Concurrent `soffice` invocations are safe **only** if each uses a -distinct `UserInstallation` directory. The implementation guarantees -this by always allocating a fresh `tempdir` per call. - -The `Semaphore` is a backstop against fork-bombing the host when many -calls land at once; it does not affect correctness. - -### `healthy()` - -Run `soffice --headless --version` with a small (5s) timeout regardless -of `config.timeout`. Returns `true` on exit code 0 with non-empty stdout. - -## Errors - -Reuses `EngineError` from spec 10. Operative variants: - -| Variant | Source | -|-----------------------------|-------------------------------------------------------------------------------------| -| `Io` | Input file missing, tempdir creation failed. | -| `Timeout(timeout)` | `soffice` exceeded `config.timeout`. Child is force-killed. | -| `Internal(msg)` | Discovery / probe failed, soffice exited non-zero, or output PDF missing. | -| `InvalidOption(msg)` | `quality` outside 1..=100, `max_image_resolution` 0, or `page_ranges` empty string. | - -## Edge cases - -| Scenario | Required behavior | -|-------------------------------------------------------|-------------------------------------------------------------------------| -| Input path with non-UTF-8 chars | Pass through as `OsStr` to `Command::arg`; do not re-encode. | -| Input file is itself a `.pdf` | Allowed — LO will rewrite it. Useful for PDF/A retrofitting. | -| Filename collides with an existing file in `outdir` | Cannot happen: `outdir` is a fresh tempdir per call. | -| LibreOffice produces an empty PDF | Treated as success; bytes returned as-is. Validation is the caller's job. | -| `OfficeOptions::quality = 0` | `EngineError::InvalidOption("quality must be 1..=100")`. | -| `pdf_a = A1B` + `landscape = true` | Allowed; LO honors both. | -| Concurrent calls on slow machines | `Semaphore` queues them; total wall time is bounded by oldest pending. | -| Killed by SIGINT | Tempdir Drop runs; child receives SIGKILL via `Command::kill_on_drop`. | - -## Test plan - -### Unit tests (`crates/engine/src/libreoffice/mod.rs`) - -No subprocess required. - -- `discover_returns_searched_list_when_missing` — point env to a bogus - path, assert `EngineError::Internal` message contains every searched path. -- `for_extension_maps_writer_calc_impress_draw`. -- `for_extension_is_case_insensitive`. -- `for_extension_unknown_returns_pdf_fallback`. -- `office_options_default_emits_no_filter_blob`. -- `office_options_with_page_ranges_emits_pagerange_key`. -- `office_options_with_pdf_a_maps_select_pdf_version_long`. -- `office_options_quality_zero_rejected`. -- `office_options_quality_above_100_rejected`. -- `office_options_max_image_resolution_zero_rejected`. - -### Integration tests (`crates/engine/tests/libreoffice.rs`) - -`#[ignore]`d; require `soffice` on PATH or `LIBREOFFICE_PATH`. - -- `convert_docx_produces_valid_pdf` — fixture `tests/fixtures/office/sample.docx`, - assert bytes start with `%PDF-` and `lopdf::Document::load_mem` succeeds. -- `convert_xlsx_landscape_orientation` — when `landscape = true`, - rendered MediaBox is wider than tall. -- `convert_pptx_page_ranges` — `page_ranges = "1-1"` produces 1 page, - full doc produces N pages. -- `convert_with_pdf_a_2b_writes_pdfa_metadata` — rendered file's metadata - contains `pdfaid` namespace. -- `convert_many_preserves_order` — three inputs, timestamps ensure - parallel execution, output order matches input order. -- `convert_timeout_kills_child` — set `timeout = 100ms`; convert a heavy - fixture; assert `EngineError::Timeout` and verify no zombie soffice - process left behind (best-effort assertion via `pgrep`). -- `convert_missing_input_io_error` — non-existent path → `EngineError::Io`. -- `convert_unsupported_format_falls_back_to_generic_filter` — give it a - weird extension; assert success. -- `concurrent_calls_use_distinct_user_dirs` — instrument by setting - `UserInstallation` to a captured path via a wrapper script; assert - paths differ across two parallel invocations. - -### Doc tests - -Compile-only example mirroring the Server's expected usage: - -```ignore -let lo = LibreOfficeEngine::discover().await?; -let pdf = lo.convert(Path::new("doc.docx"), &OfficeOptions::default()).await?; -``` - -## Acceptance - -- [ ] `crates/engine/src/libreoffice/mod.rs` exists and is `pub mod libreoffice` - from `lib.rs`. -- [ ] All public items in *Public API* compile and match signatures verbatim. -- [ ] `tempfile`, `tokio` (with `process` feature) added via - `workspace.dependencies`. -- [ ] `OfficeOptions::validate()` exists with the constraints noted under - *Errors*; called at the top of `convert` and `convert_many`. -- [ ] Filter table covered by exhaustive unit test - `for_extension_covers_table`. -- [ ] All unit tests pass with `cargo test -p engine`. -- [ ] All `#[ignore]` integration tests pass locally with a system `soffice`. -- [ ] `cargo clippy -p engine -- -D warnings` clean. -- [ ] No global mutable state. No `unsafe`. No leaked tempdirs. -- [ ] `LibreOfficeEngine` is `Send + Sync + Clone` (asserted via - `static_assertions`). - -## Out of scope / follow-ups - -- A long-running `soffice --headless --accept` daemon mode with UNO - socket multiplexing — separate spec when warranted by benchmarks. -- Bulk format conversion routes (e.g. `.docx → .odt`); this engine is - PDF-only. -- Encrypted document passwords (`--password`-style flags). -- Custom UNO macros executed pre/post export. -- Page count reporting without parsing the produced PDF. diff --git a/docs/specs/13-engine-pdfops.md b/docs/specs/13-engine-pdfops.md deleted file mode 100644 index 70ddd31..0000000 --- a/docs/specs/13-engine-pdfops.md +++ /dev/null @@ -1,353 +0,0 @@ -# Spec 13 — `engine::pdfops` - -> Pure-Rust PDF post-processing via `lopdf`. Stateless free functions on -> in-memory PDF byte streams. - -## Goal - -Provide merge / split / flatten / metadata / watermark operations against -PDF byte streams, with no shell-out to `qpdf`, `pdfcpu`, or `pdftk`, so -the server's `/forms/pdfengines/*` routes mirror Gotenberg using only -Rust dependencies. - -## Scope - -**In:** - -- `merge`, `split`, `flatten`, `read_metadata`, `write_metadata`, - `watermark`, `rotate`. -- All ops accept and return owned `Vec<u8>`, taking and returning byte - buffers so they compose with the server's pipeline without filesystem - round-trips. - -**Out:** - -- Encryption / decryption (follow-up spec; needs RC4/AES wiring). -- PDF/A or PDF/UA conformance — these require Ghostscript-style passes. - Requested PDF/A from the LibreOffice path (spec 12) is honored there. -- Bookmarks read/write — follow-up. -- Image / OCR extraction — out of scope. - -## Public API - -Module path: `engine::pdfops`. All functions are free functions; the -module is stateless. - -```rust -use crate::types::{EngineError, EngineResult, PageRanges}; -use std::collections::BTreeMap; - -/// Concatenate a sequence of PDFs into a single document, preserving order. -/// Empty input slice is an error. -pub fn merge(pdfs: &[&[u8]]) -> EngineResult<Vec<u8>>; - -#[derive(Debug, Clone)] -pub enum SplitMode { - /// One output PDF per `PageRanges` chunk, in order. - /// Pages absent from any chunk are dropped. - ByRanges(Vec<PageRanges>), - /// Split every N pages, in order. Last chunk may be shorter. - EveryN(u32), - /// One output PDF per single page. - OnePagePerFile, -} - -pub fn split(pdf: &[u8], mode: &SplitMode) -> EngineResult<Vec<Vec<u8>>>; - -/// Flatten interactive form fields and annotations into static page content. -/// Idempotent on already-flat PDFs. -pub fn flatten(pdf: &[u8]) -> EngineResult<Vec<u8>>; - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, rename_all = "PascalCase")] -pub struct Metadata { - pub title: Option<String>, - pub author: Option<String>, - pub subject: Option<String>, - pub keywords: Option<String>, - pub creator: Option<String>, - pub producer: Option<String>, - /// Wire format: "D:YYYYMMDDhhmmss±hh'mm'" (PDF date string). - pub creation_date: Option<String>, - pub mod_date: Option<String>, - /// Custom info-dict entries; keys are PDF Name strings, ASCII only. - #[serde(skip_serializing_if = "BTreeMap::is_empty")] - pub custom: BTreeMap<String, String>, -} - -pub fn read_metadata(pdf: &[u8]) -> EngineResult<Metadata>; -/// Merge `meta` into the document's info dict. Fields set to `None` are -/// left untouched; fields set to `Some("")` are removed. -pub fn write_metadata(pdf: &[u8], meta: &Metadata) -> EngineResult<Vec<u8>>; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum Position { - Center, - TopLeft, TopCenter, TopRight, - MiddleLeft, MiddleRight, - BottomLeft, BottomCenter, BottomRight, -} - -#[derive(Debug, Clone)] -pub struct WatermarkOptions { - pub kind: WatermarkKind, - /// 0.0..=1.0; values outside are clamped. - pub opacity: f32, - pub rotation_deg: f32, - pub position: Position, - /// Apply on every page (true) or only odd pages (false → "stamp first"). - /// Most callers want true. - pub all_pages: bool, - /// Tile across the page surface. - pub tiled: bool, -} - -#[derive(Debug, Clone)] -pub enum WatermarkKind { - Text { - text: String, - /// PostScript font name. None = `Helvetica`. - font: Option<String>, - /// Point size. Default 48. - font_size: f32, - /// RGBA in 0..=1. - color: [f32; 4], - }, - ImagePng { bytes: Vec<u8> }, -} - -pub fn watermark(pdf: &[u8], opts: &WatermarkOptions) -> EngineResult<Vec<u8>>; - -/// Rotate pages by 0/90/180/270 degrees (clockwise). Other angles → error. -pub fn rotate(pdf: &[u8], pages: &PageRanges, angle_deg: i32) -> EngineResult<Vec<u8>>; -``` - -## Behavior - -### `merge(pdfs)` - -1. Empty slice → `EngineError::InvalidOption("merge requires at least one input")`. -2. Single input → return a clone of the input bytes after a parse round-trip - (validates input). On parse failure → `EngineError::Internal`. -3. Otherwise: - 1. Load each input via `lopdf::Document::load_mem(bytes)`. - 2. Use `lopdf` page-tree concatenation (the canonical pattern: assemble - a fresh `Document`, renumber object IDs to avoid collision via - `Document::renumber_objects()`, then build a unified `/Pages` tree). - 3. Copy `/Outlines` if present from the **first** input only (do not - attempt to merge bookmarks; out of scope). - 4. Drop `/AcroForm` and `/Names` to avoid name collisions. - 5. Set `/Producer` to `"folio/<version>"`. - 6. Save to `Vec<u8>` via `Document::save_to(&mut Vec<u8>)`. - -### `split(pdf, mode)` - -1. Parse via `lopdf::Document::load_mem`. -2. Determine `total = doc.get_pages().len() as u32`. -3. For each chunk, build the **inclusive** list of 1-indexed page numbers: - - `ByRanges(rs)`: `rs.iter().map(|r| pages_for(r, total))`. Empty - resolved chunk after clamping → skipped (do not produce empty PDFs). - - `EveryN(n)`: `n == 0` → `EngineError::InvalidOption("EveryN requires N >= 1")`. - Otherwise produce `ceil(total / n)` chunks of size at most `n`. - - `OnePagePerFile`: produce `total` chunks, one page each. -4. For each chunk: clone the source `Document`, call - `Document::delete_pages(&pages_to_remove)`, save to `Vec<u8>`. -5. Return the chunks in the order they were generated. - -### `flatten(pdf)` - -1. Parse via `lopdf`. -2. Walk the page tree; for each page: - 1. Iterate `/Annots` array. For each annotation: - - If it's a widget annotation referencing a form field with a - rendered appearance (`/AP /N`), append the appearance stream as - a Form XObject and `Do` it from the page's content stream. - - Other annotation types are dropped (the goal of flattening). - 2. Remove the page's `/Annots` entry. -3. Remove `/AcroForm` from the catalog. -4. Save. - -The implementation MUST handle the common case of unfilled forms by -simply removing widgets without crashing. PDFs without forms or -annotations are returned re-serialized but logically identical. - -### `read_metadata(pdf)` - -1. Parse via `lopdf`. -2. Read `/Info` reference from the trailer; if absent, return - `Metadata::default()`. -3. Decode each known key (`Title`, `Author`, ...) as PDF text string - (handles both `()`-literal and `<>`-hex encodings, and the - UTF-16BE BOM convention). -4. All other entries land in `custom`, with keys as ASCII Names. - -### `write_metadata(pdf, meta)` - -1. Parse. -2. Get-or-create the `/Info` dictionary. -3. For each `Some` field on `meta`: - - If the value is `""`, delete the key. - - Otherwise set it as a PDF text string. Strings with non-ASCII - characters use the UTF-16BE BOM encoding. -4. Custom keys: same rule. Reject keys not matching `^[A-Za-z][A-Za-z0-9_-]{0,127}$` - with `EngineError::InvalidOption`. -5. Always update `/ModDate` to "now" in PDF date format unless - `meta.mod_date` is already set. -6. Save. - -### `watermark(pdf, opts)` - -1. Validate: - - `opacity` clamped to `0.0..=1.0`. - - `rotation_deg` not constrained. - - `WatermarkKind::Text { font_size, .. }` requires `font_size > 0.0`, - else `EngineError::InvalidOption`. - - `WatermarkKind::ImagePng { bytes }`: bytes must start with the PNG - signature `\x89PNG\r\n\x1a\n`, else `EngineError::InvalidOption`. -2. Parse the input. -3. Build a Form XObject containing the watermark content: - - Text: a single `BT ... ET` block with `Tf`, `rg/RG`, `cm` (rotation + - translation), and `Tj` / `TJ`. Use the chosen font (default - `Helvetica`); embed via `BaseFont`. - - Image: embed the PNG as an `Image` XObject. Use a transparent - `Group { S /Transparency }` to support opacity. -4. For each page (or odd pages if `all_pages = false`): - 1. Resolve page MediaBox. - 2. Compute the placement matrix: - - If `tiled`, repeat the XObject in a grid. Spacing = 1.5 × bbox - of the watermark XObject. - - Else, single placement at `Position` with offset 0. - 3. Append a content stream that runs `q ... cm ... gs ... Do Q`. -5. Save. - -### `rotate(pdf, pages, angle_deg)` - -1. `angle_deg.rem_euclid(360)` must be in `{0, 90, 180, 270}`, else - `EngineError::InvalidOption("angle must be 0/90/180/270")`. -2. Parse. -3. For each page p in 1..=total: if `pages.contains(p, total)`, set - `/Rotate` to `(existing + angle_deg).rem_euclid(360)`. -4. Save. - -### General - -- All ops set `/Producer = "folio/<CARGO_PKG_VERSION>"` (overwrite). -- All ops preserve the input version unless an op fundamentally requires - bumping (none in MVP). -- All ops compress streams with `FlateDecode` on save. - -## Errors - -Reuses `EngineError` from spec 10: - -| Variant | Source | -|--------------------------|------------------------------------------------------------------------| -| `InvalidOption(msg)` | Bad PNG header, invalid angle, empty merge input, EveryN with N=0, etc.| -| `InvalidPageRange(msg)` | `split(ByRanges)` chunk yields empty page set after parse. | -| `Internal(msg)` | `lopdf` parse / save failures, encrypted documents in MVP. | - -Encrypted documents are detected at parse time (`lopdf::Document::is_encrypted`) -and rejected with `EngineError::Internal("encrypted PDFs are not supported in MVP")`. - -## Edge cases - -| Scenario | Required behavior | -|-------------------------------------------------------|--------------------------------------------------------------------| -| `merge(&[a])` with valid `a` | Returns a parse-resaved copy of `a`. | -| `merge` with one corrupted input | `EngineError::Internal("merge: input #2: ...")` — never panic. | -| `split(EveryN(7))` on 3-page doc | Returns one chunk with all 3 pages. | -| `split(ByRanges([1-1000]))` on 3-page doc | Returns one chunk with pages 1..=3 (clamped). | -| `split(ByRanges([5-10]))` on 3-page doc | Empty resolved chunk → skipped; result `vec![]`. | -| Repeated `flatten` calls | Idempotent. Second call returns identical (modulo timestamps). | -| `read_metadata` on PDF without `/Info` | `Metadata::default()`. | -| `write_metadata` with unicode title | Stored as UTF-16BE with BOM. | -| `write_metadata { custom: { "bad name!": ... } }` | `EngineError::InvalidOption`. | -| Watermark on encrypted PDF | `EngineError::Internal("encrypted PDFs are not supported in MVP")`. | -| `rotate(pages = "")` | Caught by spec 10's `PageRanges::parse`. | -| `rotate(angle_deg = 360)` | Treated as 0 — no-op write that re-saves bytes. | - -## Test plan - -All in `crates/engine/src/pdfops/mod.rs` plus -`crates/engine/tests/pdfops.rs`. - -### Unit tests (no fixtures required) - -- `merge_empty_input_rejected`. -- `merge_invalid_option_message_includes_index`. -- `split_every_n_zero_rejected`. -- `split_every_n_clamps_when_total_smaller_than_n`. -- `split_by_ranges_skips_empty_chunks`. -- `rotate_invalid_angle_rejected`. -- `rotate_normalizes_360_to_0_noop`. -- `metadata_default_when_info_dict_missing`. -- `write_metadata_rejects_invalid_custom_key`. -- `write_metadata_empty_string_removes_key`. -- `watermark_png_header_validation`. -- `watermark_negative_font_size_rejected`. -- `producer_set_after_each_op`. - -### Integration tests (`crates/engine/tests/pdfops.rs`) - -These use small PDF fixtures committed under -`crates/engine/tests/fixtures/pdf/` (each <50 KB): - -- `single_page_a4.pdf`, `three_page_letter.pdf`, `with_form.pdf`, - `with_annotations.pdf`, `unicode_title.pdf`. - -Tests: - -- `merge_two_singles_yields_two_pages` — load result, page count == 2. -- `merge_preserves_order` — first page is from input A, second from B. -- `split_every_n_yields_expected_counts` — 3-page doc split N=2 yields - chunks of 2 + 1. -- `split_by_ranges_extracts_specific_pages`. -- `flatten_removes_form_fields` — input `with_form.pdf` produces output - whose AcroForm dict is absent. -- `flatten_idempotent` — flatten ∘ flatten = flatten (byte-stable - modulo `/ModDate`). -- `read_write_metadata_round_trip` — write Title="Hello", read back equal. -- `read_metadata_unicode_title` — `with_unicode_title.pdf` decodes to - the expected Rust `String`. -- `watermark_text_appears_on_every_page` — flatten then text-extract - via `lopdf`; assert the watermark string is present per page. -- `watermark_image_png_validates_signature` — corrupt header → error. -- `rotate_only_targeted_pages` — three-page doc, rotate 1,3 by 90°, - verify `/Rotate` on pages 1 and 3 only. -- `encrypted_input_rejected` — fixture `encrypted.pdf`, every public - function returns the documented error. - -### Property tests (`proptest`) - -- `merge_associative_for_two_groupings` — for any 3-element vector of - small valid PDFs, `merge(merge(a, b), c) == merge(a, merge(b, c))` in - page count and ordering. -- `split_then_merge_round_trips_page_count` — split EveryN, merge back, - page count equal. - -## Acceptance - -- [ ] `crates/engine/src/pdfops/mod.rs` exists and is `pub mod pdfops` - from `lib.rs`. -- [ ] Public API matches verbatim, including module-level free functions. -- [ ] `lopdf` and `proptest` (dev-only) added via `workspace.dependencies`. -- [ ] All ops are stateless; no `static`s, no `lazy_static`, no global - mutable state. -- [ ] All ops set `/Producer` to `folio/<crate version>`. -- [ ] Encrypted-input rejection covered by an explicit unit test. -- [ ] All unit tests pass with `cargo test -p engine`. -- [ ] All integration tests pass with `cargo test -p engine`. -- [ ] All property tests pass. -- [ ] `cargo clippy -p engine -- -D warnings` clean. -- [ ] No `unsafe`. No `.unwrap()` outside `#[cfg(test)]` and `#[test]`. - -## Out of scope / follow-ups - -- Encrypt / decrypt with user/owner passwords. -- Embed missing fonts (would require font subsetting). -- Bookmarks read/write. -- Stamp (similar to watermark but not opacity-blended) — likely a thin - variant of `watermark` once the latter is solid. -- PDF linearization ("Fast Web View"). -- Image extraction. diff --git a/docs/specs/14-engine-pdfa.md b/docs/specs/14-engine-pdfa.md deleted file mode 100644 index f9ea434..0000000 --- a/docs/specs/14-engine-pdfa.md +++ /dev/null @@ -1,204 +0,0 @@ -# Spec 14 — `engine::pdfa` - -> PDF/A and PDF/UA conformance conversion via Ghostscript or qpdf. -> Stateless free functions on in-memory PDF byte streams. - -## Goal - -Provide PDF/A-1b, PDF/A-2b, PDF/A-3b, and PDF/UA conformance conversion -for existing PDF documents. This enables enterprise archival compliance -and accessibility standards. - -## Scope - -**In:** - -- PDF/A-1b, PDF/A-2b, PDF/A-3b conversion (archival compliance). -- PDF/UA-1, PDF/UA-2 conversion (accessibility). -- Validation of output against veraPDF or similar. -- Shell-out to `gs` (Ghostscript) or `qpdf` for actual conversion. -- Server endpoint `/forms/pdfengines/convert` with `pdfa` form field. - -**Out:** - -- Creating PDF/A from scratch (convert from HTML/Office via Chromium/LibreOffice). -- PDF/A-1a, PDF/A-2a, PDF/A-3a (full conformance with logical structure). -- Repairing malformed PDFs that cannot be parsed. - -## Public API - -Module path: `engine::pdfa`. Stateless free functions. - -```rust -use crate::types::{EngineError, EngineResult}; - -/// PDF/A conformance levels for archival compliance. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum PdfAProfile { - /// PDF/A-1b: Basic conformance (Level B) for PDF 1.4. - PdfA1b, - /// PDF/A-2b: Basic conformance (Level B) for PDF 1.7. - PdfA2b, - /// PDF/A-3b: Basic conformance (Level B) with embedded files support. - PdfA3b, -} - -/// PDF/UA conformance levels for accessibility. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum PdfUaProfile { - /// PDF/UA-1: Universal Accessibility (ISO 14289-1). - PdfUa1, - /// PDF/UA-2: Updated accessibility standard (ISO 14289-2). - PdfUa2, -} - -/// Convert a PDF to PDF/A conformance. -/// -/// Uses Ghostscript's pdfwrite device with PDF/A settings. -/// Falls back to qpdf if Ghostscript is unavailable. -pub fn convert_to_pdfa(pdf: &[u8], profile: PdfAProfile) -> EngineResult<Vec<u8>>; - -/// Convert a PDF to PDF/UA accessibility conformance. -/// -/// Adds accessibility features and validates logical structure. -pub fn convert_to_pdfua(pdf: &[u8], profile: PdfUaProfile) -> EngineResult<Vec<u8>>; - -/// Validate a PDF against a PDF/A or PDF/UA profile. -/// -/// Returns validation report with passed/failed rules. -/// Requires external tool (veraPDF or qpdf validation). -pub fn validate(pdf: &[u8], profile: PdfAValidationProfile) -> EngineResult<ValidationReport>; - -#[derive(Debug, Clone)] -pub struct ValidationReport { - pub compliant: bool, - pub profile: String, - pub failed_rules: Vec<RuleViolation>, - pub warnings: Vec<String>, -} - -#[derive(Debug, Clone)] -pub struct RuleViolation { - pub rule_id: String, - pub description: String, - pub severity: Severity, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Severity { - Error, - Warning, -} -``` - -## Implementation Strategy - -### Option 1: Ghostscript (Primary) - -Ghostscript's `pdfwrite` device has built-in PDF/A conversion: - -```bash -gs -dPDFA=1 -dBATCH -dNOPAUSE -sProcessColorModel=DeviceRGB \ - -sDEVICE=pdfwrite -sPDFACompatibilityPolicy=1 \ - -sOutputFile=output.pdf input.pdf -``` - -Pros: -- Industry standard, widely tested -- Handles color model conversion -- Built-in font embedding checks - -Cons: -- Large dependency (~50MB) -- Slower than pure-Rust alternatives - -### Option 2: qpdf (Fallback) - -qpdf has limited PDF/A support via `--qpdf` and `--set-pdf-a`: - -```bash -qpdf --qpdf --set-pdf-a input.pdf output.pdf -``` - -Pros: -- Already in our Docker images -- Fast, pure transformation - -Cons: -- Limited profile support -- No color model conversion - -### Decision - -**Primary:** Ghostscript for full PDF/A-1b/2b/3b support -**Fallback:** qpdf for basic compliance marking - -## Server API - -New endpoint mirroring Gotenberg: - -``` -POST /forms/pdfengines/convert -``` - -Form fields: -- `files` - Input PDF file(s) -- `pdfa` - Profile: `PDF/A-1b`, `PDF/A-2b`, `PDF/A-3b` -- `pdfua` - Profile: `PDF/UA-1`, `PDF/UA-2` (mutually exclusive with `pdfa`) - -Response: -- Converted PDF with proper `Content-Type: application/pdf` -- `Content-Disposition` with `.pdf` suffix - -## Error Handling - -| Error | Condition | -|-------|-----------| -| `EngineError::InvalidInput` | Input not a valid PDF | -| `EngineError::ConversionFailed` | Ghostscript/qpdf error | -| `EngineError::ProfileUnsupported` | Profile not available | -| `EngineError::Timeout` | Conversion exceeded limit | - -## Testing - -Unit tests: -- Convert sample PDFs to each profile -- Verify output opens without error -- Check PDF version header changed appropriately - -Integration tests (BDD): -- Gotenberg feature parity: `pdfengines_convert.feature` -- veraPDF validation of output -- Binary size not exploded - -## Dependencies - -```toml -[dependencies] -# Shell execution -tokio = { version = "1", features = ["process"] } - -[dev-dependencies] -# PDF parsing for verification -pdf-extract = "0.8" -``` - -Runtime requirements: -- `gs` (Ghostscript 9.50+) OR `qpdf` (10.6+) -- veraPDF (optional, for validation testing) - -## Open Questions - -1. Should we embed Ghostscript in Docker or make it optional? -2. Do we need PDF/A-3b file embedding support? -3. Should validation be a separate endpoint? - -## References - -- ISO 19005-1 (PDF/A-1) -- ISO 19005-2 (PDF/A-2) -- ISO 19005-3 (PDF/A-3) -- ISO 14289 (PDF/UA) -- Ghostscript PDF/A docs: https://ghostscript.com/doc/VectorDevices.htm#PDFA diff --git a/docs/specs/15-webhook.md b/docs/specs/15-webhook.md deleted file mode 100644 index d47d524..0000000 --- a/docs/specs/15-webhook.md +++ /dev/null @@ -1,270 +0,0 @@ -# Spec 15 — Webhook System - -> Asynchronous processing with HTTP callbacks. -> Enables non-blocking PDF operations with webhook notifications. - -## Goal - -Provide async request processing where Folio calls a user-provided webhook -URL when processing completes (success or error). Mirrors Gotenberg's -webhook functionality for long-running operations. - -## Scope - -**In:** - -- Async mode via `Gotenberg-Async: true` header. -- Webhook callback via `Gotenberg-Webhook-Url` header. -- Error webhook via `Gotenberg-Webhook-Error-Url` header (optional). -- Extra HTTP headers for webhook requests. -- JSON event payload with result metadata. -- In-memory job queue (phase 1) → persistent queue (phase 2). - -**Out:** - -- Webhook signature verification (HMAC) — follow-up security spec. -- Webhook retry with exponential backoff — basic retry only. -- Event sourcing / webhook events endpoint — basic callback only. - -## Public API (Internal) - -Module path: `server::webhook`. Internal to server crate. - -```rust -use axum::http::HeaderMap; - -/// Webhook configuration extracted from request headers. -#[derive(Debug, Clone)] -pub struct WebhookConfig { - /// Primary webhook URL for success notifications. - pub webhook_url: String, - /// Optional separate URL for error notifications. - pub error_url: Option<String>, - /// Extra headers to include in webhook requests. - pub extra_headers: HeaderMap, - /// Run synchronously even if webhooks configured (sync mode override). - pub sync_mode: bool, -} - -/// Extract webhook config from request headers. -pub fn extract_webhook_config(headers: &HeaderMap) -> Option<WebhookConfig>; - -/// Job handle for async processing. -pub struct WebhookJob { - pub id: String, - pub operation: Operation, - pub config: WebhookConfig, -} - -/// Operations that support async/webhooks. -#[derive(Debug, Clone)] -pub enum Operation { - ChromiumConvertHtml { html: Vec<u8>, opts: PdfOptions }, - ChromiumConvertUrl { url: String, opts: PdfOptions }, - LibreOfficeConvert { file: Vec<u8>, opts: OfficeOptions, filename: String }, - PdfMerge { files: Vec<Vec<u8>> }, - PdfSplit { file: Vec<u8>, mode: SplitMode }, - PdfConvert { file: Vec<u8>, profile: PdfAProfile }, -} - -/// Spawn async job and return job ID immediately. -pub async fn spawn_webhook_job( - job: WebhookJob, - state: AppState, -) -> Result<String, WebhookError>; - -/// Deliver webhook callback with result. -pub async fn deliver_webhook( - url: &str, - result: &WebhookResult, - extra_headers: &HeaderMap, -) -> Result<(), WebhookError>; - -/// Webhook result payload. -#[derive(Debug, Clone, Serialize)] -pub struct WebhookResult { - pub job_id: String, - pub status: JobStatus, - pub operation: String, - pub filename: Option<String>, - pub error: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub duration_ms: Option<u64>, -} - -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum JobStatus { - Success, - Error, -} -``` - -## HTTP API - -### Headers (Request) - -| Header | Required | Description | -|--------|----------|-------------| -| `Gotenberg-Async` | No | `true` to enable async mode | -| `Gotenberg-Webhook-Url` | Yes* | Webhook URL for success | -| `Gotenberg-Webhook-Error-Url` | No | Separate URL for errors | -| `Gotenberg-Webhook-Extra-Http-Headers` | No | JSON object of extra headers | - -*Required if `Gotenberg-Async: true` - -### Headers (Webhook Request) - -Folio sends POST to webhook URL with: - -| Header | Value | -|--------|-------| -| `Content-Type` | `application/json` or `application/pdf` | -| `Gotenberg-Trace` | Correlation ID from original request | -| `X-Request-Id` | Folio's request ID | -| User's extra headers | As specified | - -### Response (Async Mode) - -When async mode enabled, immediate response: - -```http -HTTP/1.1 202 Accepted -Gotenberg-Trace: <correlation-id> - -{"job_id": "uuid", "status": "pending"} -``` - -### Webhook Payload (Success) - -```json -{ - "job_id": "uuid", - "status": "success", - "operation": "chromium_convert_html", - "filename": "result.pdf", - "duration_ms": 1234 -} -``` - -With PDF attached as binary body, or download URL if configured for storage. - -### Webhook Payload (Error) - -```json -{ - "job_id": "uuid", - "status": "error", - "operation": "pdf_merge", - "error": "Failed to parse PDF: invalid xref", - "duration_ms": 500 -} -``` - -## Implementation Strategy - -### Option 1: In-Memory Queue (Phase 1) - -Use `tokio::task::spawn` + `tokio::sync::mpsc` channel: - -```rust -pub struct WebhookQueue { - sender: mpsc::Sender<WebhookJob>, - receiver: Arc<Mutex<mpsc::Receiver<WebhookJob>>>, -} -``` - -Pros: -- Simple, no external dependencies -- Fast for moderate load - -Cons: -- Jobs lost on restart -- No horizontal scaling - -### Option 2: Persistent Queue (Phase 2) - -SQLite or Redis-backed queue: - -```rust -pub struct PersistentQueue { - db: SqlitePool, -} -``` - -Pros: -- Survives restarts -- Can scale horizontally - -Cons: -- Additional dependency - -### Decision - -**Phase 1:** In-memory queue with optional SQLite persistence. - -## Architecture - -``` -Request → Extract Webhook Config - → If async: Queue Job → Return 202 - → Worker processes job - → POST result to webhook URL -``` - -Worker pool: -- 4 concurrent webhook processors (configurable) -- Timeout: 30s for webhook delivery -- Retry: 3 attempts with 5s delay - -## Error Handling - -| Error | Action | -|-------|--------| -| Invalid webhook URL | 400 Bad Request | -| Webhook timeout | Retry 2x, then fail | -| Webhook 4xx/5xx | Retry 2x, then fail | -| Job processing error | Send to error webhook | - -## Security Considerations - -1. **URL validation** - Reject private IPs, localhost (configurable) -2. **SSRF protection** - DNS rebinding checks -3. **HMAC signatures** - Optional webhook signing (follow-up) -4. **Rate limiting** - Per-webhook rate limits - -## Testing - -Unit tests: -- Webhook config extraction from headers -- URL validation (allow/block lists) -- Job serialization/deserialization - -Integration tests: -- End-to-end async conversion with webhook -- Error webhook delivery -- Retry behavior - -## Dependencies - -```toml -[dependencies] -# HTTP client for webhook delivery -reqwest = { version = "0.12", features = ["json"] } -# Job queue (in-memory) -tokio = { version = "1", features = ["sync", "rt"] } -# URL validation -url = "2" -``` - -## Open Questions - -1. Should we support webhook body in sync mode too? -2. File storage for large outputs vs streaming? -3. Webhook signature verification (HMAC) priority? -4. Should we add webhook events API (list/deliveries)? - -## References - -- Gotenberg webhook docs: https://gotenberg.dev/docs/webhook -- CloudEvents spec for webhook payload structure diff --git a/docs/specs/16-bookmarks.md b/docs/specs/16-bookmarks.md deleted file mode 100644 index 5e94276..0000000 --- a/docs/specs/16-bookmarks.md +++ /dev/null @@ -1,197 +0,0 @@ -# Spec 16 — PDF Bookmarks (Outlines) - -> Read and write PDF document outlines (bookmarks/table of contents). -> Enables navigation structures in PDF documents. - -## Goal - -Provide read/write access to PDF bookmark hierarchies (Outlines in PDF -terminology). This allows generating tables of contents, extracting -document structure, and adding navigation to merged documents. - -## Scope - -**In:** - -- Read existing bookmark/outline structure from PDF. -- Write new bookmarks to PDF (replacing existing). -- Hierarchical bookmarks with nested children. -- Page number references (0-indexed or 1-indexed configurable). -- JSON serialization for API wire format. - -**Out:** - -- Partial bookmark updates (merge with existing). -- Text position anchors (only page-level). -- Named destinations (follow-up spec). - -## Public API - -Module path: `engine::bookmarks`. Stateless free functions. - -```rust -use crate::types::{EngineError, EngineResult}; - -/// A single bookmark entry. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Bookmark { - /// Display text for the bookmark. - pub title: String, - /// Target page number (1-indexed for user convenience). - pub page: u32, - /// Nesting level (1 = top level, 2 = child, etc.). - #[serde(skip_serializing_if = "Option::is_none")] - pub level: Option<u32>, - /// Child bookmarks (nested outline items). - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub children: Vec<Bookmark>, -} - -/// Read bookmarks from a PDF document. -/// -/// Returns empty vector if document has no outline. -pub fn read_bookmarks(pdf: &[u8]) -> EngineResult<Vec<Bookmark>>; - -/// Write bookmarks to a PDF document. -/// -/// Replaces any existing outline. Bookmarks reference pages by 1-based -/// page numbers. Returns modified PDF with new outline. -pub fn write_bookmarks(pdf: &[u8], bookmarks: &[Bookmark]) -> EngineResult<Vec<u8>>; - -/// Flatten nested bookmark structure to a list. -/// -/// Useful for linear processing. Level indicates nesting depth. -pub fn flatten_bookmarks(bookmarks: &[Bookmark]) -> Vec<(u32, String, u32)>; -// Returns: (level, title, page) -``` - -## Bookmark Structure - -### JSON Format (API) - -```json -[ - { - "title": "Chapter 1", - "page": 1, - "children": [ - {"title": "Section 1.1", "page": 3}, - {"title": "Section 1.2", "page": 5} - ] - }, - { - "title": "Chapter 2", - "page": 10 - } -] -``` - -### Flat Format Alternative - -For simple lists without nesting: - -```json -[ - {"title": "Chapter 1", "page": 1, "level": 1}, - {"title": "Section 1.1", "page": 3, "level": 2}, - {"title": "Chapter 2", "page": 10, "level": 1} -] -``` - -## Implementation Strategy - -### PDF Structure - -PDF bookmarks are stored in the `/Outlines` hierarchy: - -``` -/Outlines (dictionary) - /First → OutlineItem - /Last → OutlineItem - /Count → total count - -OutlineItem (dictionary) - /Title (string) - /Dest → [page_ref, /Fit] - /Parent → parent OutlineItem or Outlines - /First, /Last → child items (if has children) - /Next, /Prev → sibling items -``` - -### Using `lopdf` - -1. **Read**: Traverse `/Outlines` → `/First` chain, following `/Next` pointers, - recursively collecting `/Title` and `/Dest` page references. - -2. **Write**: Create new outline dictionary, build linked list of items, - set up parent/child/next/prev references, replace `/Outlines` in catalog. - -## Server API - -### Read Bookmarks - -``` -POST /forms/pdfengines/bookmarks/read -``` - -Form fields: -- `files` - Single PDF file - -Response (200 OK): -```json -{ - "filename.pdf": [ - {"title": "Chapter 1", "page": 1, "children": [...]} - ] -} -``` - -### Write Bookmarks - -``` -POST /forms/pdfengines/bookmarks/write -``` - -Form fields: -- `files` - Single PDF file -- `bookmarks` - JSON array of bookmarks - -Response (200 OK): -- PDF file with bookmarks applied -- `Content-Disposition: attachment; filename="result.pdf"` - -## Error Handling - -| Error | Condition | -|-------|-----------| -| `EngineError::InvalidInput` | PDF has no catalog or is malformed | -| `EngineError::InvalidBookmark` | Bookmark references non-existent page | -| `EngineError::EmptyInput` | Empty bookmark list (valid, clears outline) | - -## Testing - -Unit tests: -- Read bookmarks from sample PDFs -- Write bookmarks, read back, verify round-trip -- Nested hierarchy preservation -- Page number edge cases (first page, last page) - -Integration tests: -- Gotenberg feature parity: `pdfengines_bookmarks.feature` -- Compare with `pdfinfo -meta` output - -## Dependencies - -Uses existing `lopdf` dependency (already in pdfops). - -## Open Questions - -1. Should we support named destinations (/Dest as name vs array)? -2. Should we preserve existing bookmarks and merge vs replace? -3. Unicode bookmark titles - any encoding issues? - -## References - -- ISO 32000-2:2017, Section 12.3.3 (Document Outlines) -- PDF 1.7 spec, Section 8.2.2 (Outline Hierarchy) diff --git a/docs/specs/17-watermark.md b/docs/specs/17-watermark.md deleted file mode 100644 index 4a2e0b4..0000000 --- a/docs/specs/17-watermark.md +++ /dev/null @@ -1,280 +0,0 @@ -# Spec 17 — PDF Watermark & Stamp - -> Overlay images or text onto PDF pages. -> Watermark appears behind content, Stamp appears in front. - -## Goal - -Provide watermark and stamp functionality for PDF documents, allowing -users to overlay images (PNG, JPEG) or text on pages at configurable -positions with opacity control. - -## Scope - -**In:** - -- Image watermark/stamp (PNG, JPEG support via image crate). -- Text watermark/stamp (with font selection). -- Position control: center, corners, edges, custom coordinates. -- Opacity/transparency (0.0 to 1.0). -- Rotation (degrees). -- Page range selection (all pages, odd, even, specific pages). -- Watermark (behind content) vs Stamp (in front of content). - -**Out:** - -- SVG watermarks (rasterize first). -- Multi-page watermark documents. -- Animated watermarks. -- Pattern fills. - -## Public API - -Module path: `engine::watermark`. Stateless free functions. - -```rust -use crate::types::{EngineError, EngineResult}; - -/// Type of overlay. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OverlayType { - /// Watermark appears behind page content. - Watermark, - /// Stamp appears in front of page content. - Stamp, -} - -/// Content to overlay. -#[derive(Debug, Clone)] -pub enum OverlayContent { - /// Image file bytes (PNG or JPEG). - Image { data: Vec<u8>, format: ImageFormat }, - /// Text with font specification. - Text { text: String, font: FontSpec }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ImageFormat { - Png, - Jpeg, -} - -#[derive(Debug, Clone)] -pub struct FontSpec { - /// Font family name. - pub family: String, - /// Font size in points. - pub size: f32, - /// RGB color (0-255 each). - pub color: (u8, u8, u8), - /// Bold, italic, etc. - pub style: FontStyle, -} - -#[derive(Debug, Clone, Copy, Default)] -pub struct FontStyle { - pub bold: bool, - pub italic: bool, -} - -/// Position on page for overlay. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Position { - Center, - TopLeft, TopCenter, TopRight, - MiddleLeft, MiddleRight, - BottomLeft, BottomCenter, BottomRight, - /// Custom position in PDF points from bottom-left. - Custom { x: f32, y: f32 }, -} - -/// Scale mode for image overlays. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ScaleMode { - /// Original size in pixels. - Original, - /// Fit within page maintaining aspect ratio. - FitPage, - /// Fill page maintaining aspect ratio (may crop). - FillPage, - /// Custom width/height in points. - Custom { width: f32, height: f32 }, -} - -/// Watermark/stamp options. -#[derive(Debug, Clone)] -pub struct WatermarkOptions { - /// Watermark or stamp. - pub overlay_type: OverlayType, - /// Content to overlay. - pub content: OverlayContent, - /// Position on page. - pub position: Position, - /// Opacity 0.0-1.0. - pub opacity: f32, - /// Rotation in degrees (0 = no rotation). - pub rotation: f32, - /// Page range to apply. - pub pages: PageSelection, - /// Scale mode for images. - pub scale: ScaleMode, -} - -/// Page selection for watermark application. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PageSelection { - All, - First, - Last, - Odd, - Even, - Range(u32, u32), // start, end (1-indexed, inclusive) -} - -/// Apply watermark or stamp to PDF. -/// -/// Returns new PDF with overlay applied. -pub fn apply_watermark( - pdf: &[u8], - opts: &WatermarkOptions, -) -> EngineResult<Vec<u8>>; - -/// Convenience: apply image watermark. -pub fn apply_image_watermark( - pdf: &[u8], - image: &[u8], - format: ImageFormat, - position: Position, - opacity: f32, -) -> EngineResult<Vec<u8>> { - let opts = WatermarkOptions { - overlay_type: OverlayType::Watermark, - content: OverlayContent::Image { data: image.to_vec(), format }, - position, - opacity, - rotation: 0.0, - pages: PageSelection::All, - scale: ScaleMode::FitPage, - }; - apply_watermark(pdf, &opts) -} - -/// Convenience: apply text stamp. -pub fn apply_text_stamp( - pdf: &[u8], - text: &str, - position: Position, - opacity: f32, -) -> EngineResult<Vec<u8>> { - let opts = WatermarkOptions { - overlay_type: OverlayType::Stamp, - content: OverlayContent::Text { - text: text.to_string(), - font: FontSpec { - family: "Helvetica".into(), - size: 48.0, - color: (128, 128, 128), - style: FontStyle::default(), - }, - }, - position, - opacity, - rotation: 0.0, - pages: PageSelection::All, - scale: ScaleMode::Original, - }; - apply_watermark(pdf, &opts) -} -``` - -## Implementation Strategy - -### Using `lopdf` + `image` - -1. **Load PDF** with `lopdf::Document::load_mem()`. -2. **Load image** with `image` crate, convert to PDF XObject. -3. **For each target page**: - - Get page content stream - - Create overlay XObject (Form XObject containing image or text) - - Insert into page resources - - Modify content stream to draw overlay: - - Watermark: Add before existing content (gsave/q/qx/q.../grestore) - - Stamp: Add after existing content -4. **Save modified PDF**. - -### Text Rendering - -For text watermarks: -- Use built-in PDF fonts (Helvetica, Times, Courier) for simplicity -- Or embed TrueType font subset -- Create text object with: - - BT (Begin Text) - - Tf (Set Font) - - Td (Move Text Position) - - Tj (Show Text) - - ET (End Text) - -## Server API - -### Watermark Endpoint - -``` -POST /forms/pdfengines/watermark -``` - -Form fields: -- `files` - Single PDF file -- `watermark` - Image file (PNG/JPEG) or text string -- `mode` - `"watermark"` (behind) or `"stamp"` (front) -- `position` - `"center"`, `"top-left"`, etc. -- `opacity` - 0.0 to 1.0 -- `rotation` - Degrees (optional) -- `pages` - Page range (optional, default "all") - -Response: -- PDF with watermark applied -- `Content-Disposition: attachment; filename="result.pdf"` - -### Stamp Endpoint - -``` -POST /forms/pdfengines/stamp -``` - -Same as watermark, defaults to mode="stamp". - -## Error Handling - -| Error | Condition | -|-------|-----------| -| `EngineError::InvalidInput` | Invalid image format | -| `EngineError::InvalidPage` | Page range out of bounds | -| `EngineError::FontNotFound` | Requested font unavailable | - -## Testing - -Unit tests: -- Image watermark on single page -- Text stamp on all pages -- Opacity verification (PDF structure) -- Position accuracy -- Page range selection - -Integration tests: -- Gotenberg feature parity -- Visual verification (manual or screenshot) -- File size not exploded - -## Dependencies - -```toml -[dependencies] -# Image processing -image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } -# PDF manipulation (already have lopdf) -``` - -## References - -- PDF Spec ISO 32000-2: Section 8.10 (External Objects), 9 (Text) -- Gotenberg docs: https://gotenberg.dev/docs/routes#watermark diff --git a/docs/specs/18-screenshot.md b/docs/specs/18-screenshot.md deleted file mode 100644 index d0bfb76..0000000 --- a/docs/specs/18-screenshot.md +++ /dev/null @@ -1,234 +0,0 @@ -# Spec 18 — Chromium Screenshot API - -> Capture web page screenshots as PNG or JPEG images. -> Alternative to PDF generation for image output. - -## Goal - -Provide screenshot capabilities using Chromium to capture web pages as -PNG or JPEG images. Mirrors Gotenberg's screenshot endpoints while -integrating with our existing Chromium infrastructure. - -## Scope - -**In:** - -- Screenshot from HTML string or URL. -- PNG and JPEG output formats. -- Full page or viewport-only capture. -- Window/clipping size configuration. -- Wait conditions (load, networkidle). -- Custom headers, cookies, authentication. - -**Out:** - -- PDF screenshots (use convert endpoints). -- Video recording. -- Mobile device emulation (follow-up). -- Element-level screenshots (single element only). - -## Public API - -Module path: `engine::chromium::screenshot`. Extends existing ChromiumEngine. - -```rust -use crate::types::{EngineError, EngineResult, BrowserConfig}; - -/// Screenshot format. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ScreenshotFormat { - Png, - Jpeg { quality: u8 }, // 0-100 -} - -/// Screenshot capture mode. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CaptureMode { - /// Capture visible viewport only. - Viewport, - /// Capture full page (scroll and stitch). - FullPage, -} - -/// Screenshot options. -#[derive(Debug, Clone)] -pub struct ScreenshotOptions { - /// Output format. - pub format: ScreenshotFormat, - /// Capture mode. - pub mode: CaptureMode, - /// Viewport width in pixels. - pub width: u32, - /// Viewport height in pixels. - pub height: u32, - /// Device scale factor (1.0 = standard, 2.0 = retina). - pub device_scale_factor: f32, - /// Wait condition before capture. - pub wait_condition: WaitCondition, - /// Custom HTTP headers. - pub extra_headers: HashMap<String, String>, - /// Cookies to set. - pub cookies: Vec<Cookie>, - /// Background CSS (e.g., "white" for opaque). - pub background_color: Option<String>, -} - -impl Default for ScreenshotOptions { - fn default() -> Self { - Self { - format: ScreenshotFormat::Png, - mode: CaptureMode::Viewport, - width: 1920, - height: 1080, - device_scale_factor: 1.0, - wait_condition: WaitCondition::Load, - extra_headers: HashMap::new(), - cookies: Vec::new(), - background_color: None, - } - } -} - -/// Screenshot from HTML string. -pub async fn screenshot_html( - engine: &ChromiumEngine, - html: &str, - opts: &ScreenshotOptions, -) -> EngineResult<Vec<u8>>; - -/// Screenshot from URL. -pub async fn screenshot_url( - engine: &ChromiumEngine, - url: &str, - opts: &ScreenshotOptions, -) -> EngineResult<Vec<u8>>; - -/// Screenshot from Markdown. -pub async fn screenshot_markdown( - engine: &ChromiumEngine, - markdown: &str, - opts: &ScreenshotOptions, -) -> EngineResult<Vec<u8>> { - let html = render_markdown_to_html(markdown); - screenshot_html(engine, &html, opts).await -} -``` - -## Implementation Strategy - -### Using `chromiumoxide` - -The `chromiumoxide` crate provides CDP (Chrome DevTools Protocol) access. -Screenshot capture uses the `Page.captureScreenshot` CDP command. - -For full page screenshots: -1. Get full page dimensions via `Page.getLayoutMetrics()` -2. Set viewport to full page size -3. Capture screenshot -4. Restore viewport - -For viewport screenshots: -1. Set requested viewport size -2. Navigate and wait -3. Capture screenshot - -### CDP Commands - -```rust -// Set viewport -Page::set_viewport( - width, height, device_scale_factor, mobile, fit_window -).await?; - -// Navigate and wait -Page::goto(url).await?; -Page::wait_for(selector_or_condition).await?; - -// Capture -let screenshot = Page::capture_screenshot( - format, // "png" or "jpeg" - quality, // for jpeg - clip, // optional viewport clipping - from_surface, // true -).await?; -``` - -## Server API - -### Endpoints - -``` -POST /forms/chromium/screenshot/html -POST /forms/chromium/screenshot/url -POST /forms/chromium/screenshot/markdown -``` - -### Form Fields - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `files` | file | - | HTML/Markdown file (for file endpoints) | -| `url` | string | - | URL to capture | -| `format` | string | "png" | "png" or "jpeg" | -| `quality` | int | 80 | JPEG quality 0-100 | -| `width` | int | 1920 | Viewport width | -| `height` | int | 1080 | Viewport height | -| `fullPage` | bool | false | Capture full scrollable page | -| `scale` | float | 1.0 | Device scale factor | -| `waitFor` | string | "load" | "load", "networkidle", "domcontentloaded" | -| `backgroundColor` | string | - | CSS color for background | - -### Headers - -Same as convert endpoints: -- `Gotenberg-Trace` -- `Gotenberg-Output-Filename` -- Custom headers via `Gotenberg-*` forwarded to page - -### Response - -```http -HTTP/1.1 200 OK -Content-Type: image/png (or image/jpeg) -Content-Disposition: attachment; filename="screenshot.png" - -<binary image data> -``` - -## Error Handling - -| Error | Condition | -|-------|-----------| -| `EngineError::ChromeLaunch` | Browser connection failed | -| `EngineError::NavigationFailed` | URL unreachable | -| `EngineError::Timeout` | Wait condition not met | -| `EngineError::ScreenshotFailed` | CDP screenshot error | - -## Testing - -Unit tests: -- Screenshot HTML with various viewport sizes -- Full page vs viewport capture -- PNG and JPEG output -- Wait conditions - -Integration tests: -- Gotenberg feature parity: `chromium_screenshot_*.feature` -- Image dimensions verification -- File format validation - -## Dependencies - -Uses existing `chromiumoxide` dependency. - -## References - -- Chrome DevTools Protocol: https://chromedevtools.github.io/devtools-protocol/ -- Page.captureScreenshot: https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-captureScreenshot -- Gotenberg docs: https://gotenberg.dev/docs/routes#screenshots - -## Notes - -- Screenshots are handled separately from PDF conversion but share the same Chromium pool -- Consider rate limiting for screenshot endpoints (expensive operation) -- Full page screenshots can be memory-intensive for very long pages diff --git a/docs/specs/19-encrypt.md b/docs/specs/19-encrypt.md deleted file mode 100644 index 5f36511..0000000 --- a/docs/specs/19-encrypt.md +++ /dev/null @@ -1,223 +0,0 @@ -# Spec 19 — PDF Encryption - -> Password protection and permission control for PDF documents. -> Uses qpdf for reliable encryption without lopdf complexity. - -## Goal - -Provide PDF password protection with user/owner passwords and -granular permission controls. Uses shell-out to qpdf for -production-ready encryption. - -## Scope - -**In:** - -- User password (required to open document). -- Owner password (required to change permissions). -- Permission flags (print, modify, copy, annotate). -- 128-bit and 256-bit AES encryption. -- Remove encryption (with owner password). - -**Out:** - -- Certificate-based encryption (PKI). -- Digital signatures. -- Custom security handlers. - -## Public API - -Module path: `engine::encrypt`. Stateless free functions. - -```rust -use crate::types::{EngineError, EngineResult}; - -/// Encryption algorithm. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EncryptionAlgorithm { - /// 128-bit AES (RC4 deprecated). - Aes128, - /// 256-bit AES (recommended). - Aes256, -} - -/// Permission flags for encrypted PDF. -#[derive(Debug, Clone, Copy, Default)] -pub struct Permissions { - /// Allow printing (low-res). - pub print: bool, - /// Allow high-quality printing. - pub print_high_quality: bool, - /// Allow content modification. - pub modify_content: bool, - /// Allow annotation and form filling. - pub annotate: bool, - /// Allow form filling (if false, only existing fields). - pub fill_forms: bool, - /// Allow content extraction (copy/paste). - pub extract_content: bool, - /// Allow document assembly (merge, insert pages). - pub assemble: bool, -} - -impl Permissions { - /// Default permissions: all allowed. - pub fn allow_all() -> Self { - Self { - print: true, - print_high_quality: true, - modify_content: true, - annotate: true, - fill_forms: true, - extract_content: true, - assemble: true, - } - } - - /// Restrictive permissions: view only. - pub fn view_only() -> Self { - Self { - print: false, - print_high_quality: false, - modify_content: false, - annotate: false, - fill_forms: false, - extract_content: false, - assemble: false, - } - } -} - -/// Encrypt PDF with password protection. -/// -/// At least one of `user_password` or `owner_password` must be provided. -/// If `owner_password` is None, it's set same as user_password. -pub async fn encrypt_pdf( - pdf: &[u8], - user_password: Option<&str>, - owner_password: Option<&str>, - algorithm: EncryptionAlgorithm, - permissions: Permissions, -) -> EngineResult<Vec<u8>>; - -/// Remove encryption from PDF. -/// -/// Requires owner password (or user password if no owner set). -pub async fn decrypt_pdf( - pdf: &[u8], - password: &str, -) -> EngineResult<Vec<u8>>; - -/// Check if PDF is encrypted. -pub fn is_encrypted(pdf: &[u8]) -> EngineResult<bool>; -``` - -## Implementation Strategy - -### Using `qpdf` - -qpdf has excellent encryption support: - -```bash -# Encrypt with user password -qpdf --encrypt userpass ownerpass 256 -- input.pdf output.pdf - -# Encrypt with permissions -qpdf --encrypt userpass ownerpass 256 \ - --print=none --modify=none --extract=n \ - input.pdf output.pdf - -# Decrypt -qpdf --password=ownerpass --decrypt input.pdf output.pdf -``` - -### Permission Mapping - -| Permission | qpdf flag | PDF spec | -|------------|-----------|----------| -| Print low-res | `--print=low` | bit 3 | -| Print high-res | `--print=full` | bit 3 + 12 | -| Modify content | `--modify=annotate` | bit 4 | -| Annotate | `--modify=annotate` | bit 6 | -| Fill forms | `--modify=form` | bit 9 | -| Extract | `--extract=y` | bit 5 | -| Assemble | `--assemble=y` | bit 11 | - -## Server API - -### Encrypt Endpoint - -``` -POST /forms/pdfengines/encrypt -``` - -Form fields: -- `files` - Single PDF file -- `userPassword` - Password required to open (optional) -- `ownerPassword` - Password to change permissions (optional) -- `algorithm` - "aes128" or "aes256" (default: aes256) -- `permissions` - Comma-separated list: - - `print`, `print-hq`, `modify`, `annotate`, `fill-forms`, `extract`, `assemble` - - Or `all` (default), `none`, `view-only` - -Response: -- Encrypted PDF -- `Content-Disposition: attachment; filename="result.pdf"` - -### Decrypt Endpoint - -``` -POST /forms/pdfengines/decrypt -``` - -Form fields: -- `files` - Encrypted PDF -- `password` - User or owner password - -Response: -- Decrypted PDF - -## Error Handling - -| Error | Condition | -|-------|-----------| -| `EngineError::InvalidInput` | No password provided | -| `EngineError::EncryptionFailed` | qpdf error | -| `EngineError::DecryptionFailed` | Wrong password | -| `EngineError::NotEncrypted` | Decrypt called on unencrypted PDF | - -## Testing - -Unit tests: -- Encrypt with user password, decrypt succeeds -- Encrypt with owner password only -- Permission verification (attempt restricted action) -- Wrong password rejection - -Integration tests: -- Gotenberg feature parity -- PDF/A compliance after encryption (should be preserved) - -## Dependencies - -Runtime: `qpdf` binary (already in Docker image) - -```toml -[dependencies] -# Shell execution -tokio = { workspace = true } -tempfile = { workspace = true } -``` - -## Security Notes - -1. **Passwords transmitted in form data** - Use HTTPS in production -2. **qpdf binary must be available** - Check at startup -3. **Temporary files** - Cleaned up after operation -4. **Memory safety** - Passwords not logged - -## References - -- qpdf encryption docs: https://qpdf.readthedocs.io/en/stable/encryption.html -- PDF 2.0 spec ISO 32000-2: Section 7.6 (Encryption) -- Gotenberg docs: https://gotenberg.dev/docs/routes#pdf-engines diff --git a/docs/specs/20-bdd-testing.md b/docs/specs/20-bdd-testing.md deleted file mode 100644 index b1dc10a..0000000 --- a/docs/specs/20-bdd-testing.md +++ /dev/null @@ -1,600 +0,0 @@ -# Spec 20 — BDD Testing with Cucumber (Detailed Implementation Guide) - -> Port Gotenberg's Gherkin integration tests to Folio. -> Step-by-step implementation for replicating Gotenberg's test infrastructure. - -## Overview - -This spec provides detailed instructions for porting Gotenberg's integration -tests from Go (Godog + testcontainers-go) to Rust (cucumber-rs + testcontainers-rs). - -## Gotenberg's Test Structure (Source) - -``` -gotenberg/test/integration/ -├── features/ # 26 .feature files (Gherkin) -│ ├── health.feature -│ ├── pdfengines_merge.feature -│ └── ... -├── scenario/ -│ ├── scenario.go # Step definitions (Go) -│ ├── containers.go # Docker container helpers -│ └── main_test.go # Test runner setup -└── testdata/ # PDF fixtures -``` - -## Folio Target Structure - -``` -crates/server/tests/bdd/ -├── features/ # Copied & adapted from Gotenberg -│ ├── health.feature -│ ├── pdfengines_merge.feature -│ └── ... (26 files) -├── steps/ -│ ├── mod.rs # Step registration -│ ├── container.rs # testcontainers-rs wrapper -│ ├── http.rs # HTTP client steps -│ ├── pdf.rs # PDF assertions -│ └── gotenberg_compat.rs # Go-to-Rust step mappings -├── support/ -│ ├── world.rs # Cucumber World struct -│ └── hooks.rs # Before/After hooks -├── testdata/ # Copied from Gotenberg -│ ├── page_1.pdf -│ ├── page_2.pdf -│ └── ... -└── main.rs # Test runner entry point -``` - -## Step 1: Dependencies (Cargo.toml) - -Add to `crates/server/Cargo.toml`: - -```toml -[dev-dependencies] -# BDD framework -cucumber = "0.21" - -# Docker testcontainers -testcontainers = "0.22" -testcontainers-modules = { version = "0.11", features = ["blocking"] } - -# HTTP client for tests -reqwest = { version = "0.12", features = ["multipart", "json"] } - -# PDF validation -lopdf = { workspace = true } -pdf-extract = "0.8" - -# Async runtime for tests -tokio = { workspace = true } - -# Temporary files -tempfile = { workspace = true } -``` - -## Step 2: Create Directory Structure - -```bash -mkdir -p crates/server/tests/bdd/{features,steps,support,testdata} -touch crates/server/tests/bdd/main.rs -touch crates/server/tests/bdd/steps/{mod.rs,container.rs,http.rs,pdf.rs} -touch crates/server/tests/bdd/support/{world.rs,hooks.rs} -``` - -## Step 3: Copy Gotenberg Test Data - -```bash -cp gotenberg/test/integration/testdata/*.pdf \ - crates/server/tests/bdd/testdata/ -``` - -## Step 4: Port Feature Files - -Copy and adapt each `.feature` file. Example adaptation: - -**Gotenberg (original):** -```gherkin -Given I have a Gotenberg container with the following environment variable(s): - | API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY | false | -``` - -**Folio (adapted):** -```gherkin -Given I have a Folio container with the following environment variable(s): - | RUST_LOG | info | -``` - -## Step 5: Implement World (support/world.rs) - -The World holds test state across steps: - -```rust -use cucumber::World; -use reqwest::Client; -use std::collections::HashMap; -use testcontainers::Container; - -#[derive(Debug, World)] -pub struct FolioWorld { - // HTTP client for requests - pub client: Client, - - // Active container (if any) - pub container: Option<Container<GenericImage>>, - - // Last HTTP response - pub response: Option<reqwest::Response>, - - // Response body bytes - pub body: Option<Vec<u8>>, - - // Temporary directory for test files - pub temp_dir: tempfile::TempDir, - - // Container base URL - pub base_url: Option<String>, -} - -impl Default for FolioWorld { - fn default() -> Self { - Self { - client: Client::new(), - container: None, - response: None, - body: None, - temp_dir: tempfile::tempdir().unwrap(), - base_url: None, - } - } -} - -impl FolioWorld { - /// Start Folio container with environment variables - pub async fn start_container(&mut self, env: HashMap<String, String>) { - use testcontainers::{GenericImage, WaitFor}; - - let image = GenericImage::new("deesh2025/no-name", "latest") - .with_wait_for(WaitFor::message_on_stdout("Listening on")); - - // Add environment variables - for (key, value) in env { - let _ = image.with_env_var(key, value); - } - - let container = image.start().await.unwrap(); - let port = container.get_host_port_ipv4(3000).await.unwrap(); - - self.base_url = Some(format!("http://localhost:{}", port)); - self.container = Some(container); - } -} -``` - -## Step 6: Implement Steps (steps/mod.rs) - -Register all step definitions: - -```rust -use cucumber::Steps; -use crate::support::world::FolioWorld; - -mod container; -mod http; -mod pdf; - -pub fn steps() -> Steps<FolioWorld> { - let mut steps = Steps::new(); - - // Container steps - steps.given( - "I have a default Folio container", - container::default_container, - ); - steps.given( - "I have a Folio container with the following environment variable(s)", - container::container_with_env, - ); - - // HTTP steps - steps.when( - regex r#"I make a "(GET|POST)" request to "(.+)""#, - http::make_request, - ); - steps.then( - "the response status code should be {int}", - http::check_status_code, - ); - - // PDF steps - steps.then( - "there should be {int} PDF(s) in the response", - pdf::check_pdf_count, - ); - steps.then( - "the PDF should have {int} page(s)", - pdf::check_page_count, - ); - - steps -} -``` - -## Step 7: Container Steps (steps/container.rs) - -Map Gotenberg's container steps to Rust: - -| Gotenberg (Go) | Folio (Rust) | -|----------------|--------------| -| `iHaveADefaultGotenbergContainer` | `default_container` | -| `iHaveAGotenbergContainerWithEnv` | `container_with_env` | -| `startGotenbergContainer` | `testcontainers::GenericImage` | - -```rust -use std::collections::HashMap; -use cucumber::gherkin::Table; -use crate::support::world::FolioWorld; - -pub async fn default_container(world: &mut FolioWorld) { - world.start_container(HashMap::new()).await; -} - -pub async fn container_with_env(world: &mut FolioWorld, table: &Table) { - let mut env = HashMap::new(); - for row in table.rows.iter().skip(1) { // Skip header - let key = row.cells[0].value.clone(); - let value = row.cells[1].value.clone(); - env.insert(key, value); - } - world.start_container(env).await; -} -``` - -## Step 8: HTTP Steps (steps/http.rs) - -| Gotenberg (Go) | Folio (Rust) | -|----------------|--------------| -| `doRequest` | `reqwest::Client` | -| `s.resp` | `world.response` | -| `s.resp.Code` | `world.response.status().as_u16()` | - -```rust -use cucumber::gherkin::Table; -use crate::support::world::FolioWorld; - -pub async fn make_request( - world: &mut FolioWorld, - method: String, - endpoint: String, -) { - let url = format!("{}{}", world.base_url.as_ref().unwrap(), endpoint); - - let response = match method.as_str() { - "GET" => world.client.get(&url).send().await.unwrap(), - "POST" => world.client.post(&url).send().await.unwrap(), - _ => panic!("Unsupported method: {}", method), - }; - - world.response = Some(response); -} - -pub async fn check_status_code(world: &mut FolioWorld, expected: u16) { - let actual = world.response.as_ref().unwrap().status().as_u16(); - assert_eq!(actual, expected, "Status code mismatch"); -} -``` - -## Step 9: PDF Steps (steps/pdf.rs) - -| Gotenberg (Go) | Folio (Rust) | -|----------------|--------------| -| `assertPDFPageCount` | `lopdf::Document::get_pages()` | -| `assertPDFContent` | `pdf_extract::extract_text()` | - -```rust -use lopdf::Document; -use crate::support::world::FolioWorld; - -pub async fn check_pdf_count(world: &mut FolioWorld, expected: usize) { - // Implementation to count PDFs in multipart response -} - -pub async fn check_page_count(world: &mut FolioWorld, expected: usize) { - let body = world.body.as_ref().unwrap(); - let doc = Document::load_mem(body).unwrap(); - let actual = doc.get_pages().len(); - assert_eq!(actual, expected, "Page count mismatch"); -} -``` - -## Step 10: Test Runner (main.rs) - -```rust -use cucumber::Cucumber; -use std::path::PathBuf; - -mod support; -mod steps; - -use support::world::FolioWorld; -use steps::steps; - -#[tokio::main] -async fn main() { - let runner = Cucumber::<FolioWorld>::new() - .features(&[PathBuf::from("tests/bdd/features")]) - .steps(steps()) - .run_and_exit() - .await; -} -``` - -## Step 11: Run Tests - -```bash -# Build Docker image first -docker build -t deesh2025/no-name:latest . - -# Run all BDD tests -cargo test --test bdd - -# Run specific feature -cargo test --test bdd -- health - -# With debug output -cargo test --test bdd -- --nocapture - -# Generate HTML report -cargo test --test bdd -- --format html --output reports/ -``` - -## Mapping: Gotenberg Steps → Rust Steps - -Complete mapping of all 26 feature file step patterns: - -| Pattern | Go Function | Rust Function | Status | -|---------|-------------|---------------|--------| -| `I have a default Gotenberg container` | `iHaveADefaultGotenbergContainer` | `default_container` | ⬜ | -| `I have a Gotenberg container with env` | `iHaveAGotenbergContainerWithEnv` | `container_with_env` | ⬜ | -| `I make a "X" request to "Y"` | `iMakeARequestToGotenberg` | `make_request` | ⬜ | -| `the response status code should be X` | `theResponseStatusCodeShouldBe` | `check_status_code` | ⬜ | -| `the response header "X" should be "Y"` | `theResponseHeaderShouldBe` | `check_header` | ⬜ | -| `the response body should match JSON` | `theResponseBodyShouldMatchJSON` | `check_json_body` | ⬜ | -| `there should be X PDF(s) in the response` | `thereShouldBeXPDFs` | `check_pdf_count` | ⬜ | -| `the PDF should have X page(s)` | `thePDFShouldHaveXPages` | `check_page_count` | ⬜ | -| `the PDF content at page X should be` | `thePDFContentAtPageShouldBe` | `check_page_content` | ⬜ | -| `the container should log` | `theContainerShouldLog` | `check_logs` | ⬜ | - -## Feature Porting Priority - -Port features in this order: - -1. **Phase 1: Core (Week 1)** - - `health.feature` (simplest) - - `version.feature` - - `root.feature` - -2. **Phase 2: PDF Engines (Week 2)** - - `pdfengines_merge.feature` - - `pdfengines_split.feature` - - `pdfengines_flatten.feature` - - `pdfengines_rotate.feature` - -3. **Phase 3: Chromium (Week 3)** - - `chromium_convert_html.feature` - - `chromium_convert_url.feature` - - `chromium_screenshot_*.feature` - -4. **Phase 4: Advanced (Week 4)** - - `pdfengines_bookmarks.feature` - - `pdfengines_convert.feature` - - `webhook.feature` - - `pdfengines_encrypt.feature` - -## CI/CD Integration - -```yaml -# .github/workflows/bdd.yml -name: BDD Tests -on: [push, pull_request] -jobs: - bdd: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Build Docker image - run: docker build -t deesh2025/no-name:latest . - - - name: Install Chromium - run: sudo apt-get install -y chromium-browser - - - name: Run BDD tests - run: cargo test --test bdd -- --format junit > bdd-results.xml - - - name: Upload results - uses: actions/upload-artifact@v4 - with: - name: bdd-results - path: bdd-results.xml -``` - -## Debugging Tips - -1. **Container not starting**: Check Docker daemon, image tag -2. **Connection refused**: Wait for container healthy state -3. **PDF assertions failing**: Verify lopdf can parse the PDF -4. **Step not found**: Check regex pattern matches exactly - -## References - -- Gotenberg features: `gotenberg/test/integration/features/` -- Gotenberg steps: `gotenberg/test/integration/scenario/scenario.go` -- cucumber-rs docs: https://cucumber-rs.github.io/ -- testcontainers-rs: https://docs.rs/testcontainers/ - - -### Dependencies - -```toml -[dev-dependencies] -cucumber = "0.21" -testcontainers = "0.22" -reqwest = { workspace = true } -serde_json = { workspace = true } -tempfile = { workspace = true } -``` - -## Implementation Phases - -### Phase 1: Infrastructure (Week 1) - -- [ ] Add cucumber and testcontainers dependencies -- [ ] Create test directory structure -- [ ] Implement World struct with HTTP client and temp directory -- [ ] Implement container lifecycle hooks -- [ ] Create basic step definitions (Given/When/Then) - -### Phase 2: Core Feature Tests (Week 2) - -- [ ] Port `health.feature` tests -- [ ] Port `version.feature` tests -- [ ] Port `pdfengines_merge.feature` tests -- [ ] Port `pdfengines_split.feature` tests - -### Phase 3: Chromium Tests (Week 3) - -- [ ] Port `chromium_convert_html.feature` -- [ ] Port `chromium_convert_url.feature` -- [ ] Port `chromium_screenshot_*.feature` tests - -### Phase 4: Advanced Features (Week 4) - -- [ ] Port PDF/A conversion tests -- [ ] Port bookmark tests -- [ ] Port webhook tests (mock server) - -## Key Components - -### World Implementation - -```rust -pub struct World { - /// HTTP client for requests - client: reqwest::Client, - /// Folio container handle - container: Option<FolioContainer>, - /// Last HTTP response - response: Option<reqwest::Response>, - /// Response body bytes - body: Option<Vec<u8>>, - /// Temporary directory for test files - temp_dir: tempfile::TempDir, - /// Test data directory - testdata_dir: PathBuf, -} -``` - -### Step Definitions - -Common steps to implement: - -```rust -#[given("I have a default Folio container")] -async fn default_container(world: &mut World) { - world.start_container().await; -} - -#[when(regex = r#"I make a "(GET|POST)" request to "(.+)""#)] -async fn make_request(world: &mut World, method: String, path: String) { - world.request(&method, &path).await; -} - -#[then("the response status code should be {int}")] -async fn check_status(world: &mut World, expected: u16) { - let actual = world.response.as_ref().unwrap().status().as_u16(); - assert_eq!(actual, expected); -} -``` - -### Container Management - -Using testcontainers-rs: - -```rust -pub struct FolioContainer { - image: GenericImage, - container: Container<GenericImage>, - port: u16, -} - -impl FolioContainer { - pub async fn start() -> Result<Self, TestcontainersError> { - let image = GenericImage::new("deesh2025/no-name", "latest") - .with_wait_for(WaitFor::message_on_stdout("Listening on")); - - let container = image.start().await?; - let port = container.get_host_port_ipv4(3000).await?; - - Ok(Self { image, container, port }) - } - - pub fn base_url(&self) -> String { - format!("http://localhost:{}", self.port) - } -} -``` - -### PDF Assertions - -```rust -pub fn assert_pdf_page_count(pdf_bytes: &[u8], expected: u32) { - let doc = lopdf::Document::load_mem(pdf_bytes).unwrap(); - let pages = doc.get_pages().len() as u32; - assert_eq!(pages, expected, "PDF page count mismatch"); -} - -pub fn assert_pdf_contains_text(pdf_bytes: &[u8], text: &str) { - // Use pdf-extract or similar -} -``` - -## Running Tests - -```bash -# Run all BDD tests -cargo test --test bdd - -# Run specific feature -cargo test --test bdd -- health - -# With output for debugging -cargo test --test bdd -- --nocapture - -# Generate HTML report -cargo test --test bdd -- --format html --output reports/ -``` - -## CI Integration - -```yaml -# .github/workflows/bdd.yml -name: BDD Tests -on: [push, pull_request] -jobs: - bdd: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Build Docker image - run: docker build -t folio:test . - - name: Run BDD tests - run: cargo test --test bdd -``` - -## References - -- cucumber-rs docs: https://cucumber-rs.github.io/ -- testcontainers-rs: https://docs.rs/testcontainers/latest/testcontainers/ -- Gotenberg features: https://github.com/gotenberg/gotenberg/tree/main/test/integration/features diff --git a/docs/specs/20-cli.md b/docs/specs/20-cli.md deleted file mode 100644 index 2fb0cb1..0000000 --- a/docs/specs/20-cli.md +++ /dev/null @@ -1,362 +0,0 @@ -# Spec 20 — `cli` (`folio` binary) - -> User-facing command line for one-off conversions and PDF post-processing, -> built on `clap` derive and the `engine` crate. - -## Goal - -Provide a `folio` binary that exercises the engine for HTML / URL / -Markdown / Office conversions and basic PDF ops (merge / split), matching -the README usage in -`@/Users/__deesh_reddy__/projects/personal_git/rust_builds/folio/README.md:69-83`, -without needing the HTTP server. - -## Scope - -**In:** - -- `convert` — single-file or single-input-source conversion. -- `batch` — directory walker that converts many files in parallel. -- `merge`, `split`, `flatten`, `metadata` — direct wrappers over spec 13. -- Shell-completion generation. -- Stdin/stdout streaming for pipelines. -- Structured logging behind `RUST_LOG`. - -**Out:** - -- `serve` subcommand. Users invoke `folio-server` directly. (CLI may - later gain a thin `serve` shim, but not in the MVP.) -- Watermark / rotate / encrypt — exposed via the server first; CLI - follow-up once the server fronting them is solid. - -## Public surface - -``` -folio <COMMAND> - -Global options (apply to every command): - -v, --verbose Increase log verbosity (-v info, -vv debug, -vvv trace) - -q, --quiet Suppress log output (overrides -v) - --log-format <FORMAT> - text | json. Default: text on a TTY, json otherwise. - --chrome <PATH> Override Chrome executable (BrowserConfig::executable) - --no-sandbox Pass --no-sandbox to Chrome (default true on Linux) - --sandbox Force sandbox on (overrides --no-sandbox / Linux default) - --timeout <DUR> Per-render timeout, e.g. "60s", "2m". Default 60s. - -h, --help - -V, --version -``` - -### `folio convert` - -Exactly one of `--html`, `--url`, `--markdown`, `--office`, `--stdin`. -Exactly one `--output` (path or `-` for stdout). - -``` -folio convert - (--html <FILE> | --url <URL> | --markdown <FILE> | --office <FILE> - | --stdin --as <html|markdown>) - --output <FILE> FILE or '-' for stdout. Required. - - PdfOptions (apply to html/url/markdown; ignored for office): - --paper <SIZE> a4 | letter | legal | a3 | a5 | "WxH" - --landscape - --margin <SPEC> inches (e.g. "0.5") or "TOP,RIGHT,BOTTOM,LEFT" - default 0.39in (~1cm) - --scale <FLOAT> 0.1..=2.0 - --no-print-background - --emulate <print|screen> - --pages <RANGES> e.g. "1-3,5,7-" - --header-template <FILE> Path to HTML file - --footer-template <FILE> - --prefer-css-page-size - --wait <SPEC> load | domcontentloaded | networkidle - | selector:CSS | expr:JS | delay:DUR - - RequestContext (html/url/markdown): - --user-agent <STR> - --header "Name: Value" Repeatable - --cookie "name=value;..." Repeatable; ;-separated attrs - --fail-on-status <SPEC> Repeatable. e.g. "500", "5xx", "400-404" - --base-url <URL> For --html / --markdown / --stdin; ignored otherwise - - Office-only: - --pdf-a <a1b|a2b|a3b> - --pdf-ua - --quality <1..=100> - --max-image-resolution <DPI> -``` - -### `folio batch` - -``` -folio batch - --input-dir <DIR> Required. Walked recursively. - --output-dir <DIR> Required. Mirrors input directory tree. - --pattern <GLOB> Default: "**/*.{html,htm,md,markdown}" - --concurrency <N> Default: number of CPUs - --on-error <stop|skip> Default: skip - --dry-run Print planned conversions, do nothing - - + every PdfOptions / RequestContext flag from `convert` - -Each input is converted individually, with extension switched to .pdf -in the output tree. Office files are accepted iff `--pattern` includes -them; choose `--pattern "**/*.{docx,xlsx,pptx}"` etc. -``` - -### `folio merge` - -``` -folio merge --output <FILE> <INPUT>... - -INPUT may be a path or '-' (read PDF bytes from stdin). Order is preserved. -``` - -### `folio split` - -``` -folio split <INPUT> - --output-dir <DIR> Required. - --prefix <STR> Default: input basename without extension. - --mode <SPEC> ranges:1-3,5,7- | every-n:5 | one-per-page - Default: one-per-page - -Outputs: <prefix>-<NNN>.pdf, zero-padded. e.g. report-001.pdf. -``` - -### `folio flatten` - -``` -folio flatten <INPUT> --output <FILE> -INPUT or FILE may be '-' for stdio. -``` - -### `folio metadata` - -``` -folio metadata read <INPUT> # JSON to stdout -folio metadata write <INPUT> --output <FILE> [--from-json <FILE> | --set KEY=VALUE]... -``` - -`--set` repeatable. Special keys: `Title`, `Author`, `Subject`, -`Keywords`, `Creator`, `Producer`, `CreationDate`, `ModDate`. Anything -else lands in `Metadata::custom`. Empty value (`--set Title=`) deletes. - -### `folio completions <SHELL>` - -Emits completion script to stdout. SHELL ∈ `bash | zsh | fish | powershell`. - -## Behavior - -### Process model - -- One `tokio::runtime::Builder::new_multi_thread().enable_all().build()` - built in `main`. -- All commands are short-lived; the runtime is dropped at exit. -- Logging configured with `tracing_subscriber::fmt()` with the chosen - format. `--quiet` sets the level filter to `off`. `-v` to `info`, - `-vv` to `debug`, `-vvv` to `trace`. `RUST_LOG`, when set, takes - precedence (parsed by `tracing_subscriber::EnvFilter`). - -### Engine reuse - -- `convert`: launches one `ChromiumEngine` (or `LibreOfficeEngine`), - performs one render, calls `shutdown` on success path, returns. -- `batch`: launches one engine, gates renders with - `tokio::sync::Semaphore::new(concurrency)`, fans out via - `tokio::task::JoinSet`, calls `shutdown` once all are joined. -- `merge`, `split`, `flatten`, `metadata`: no engine launch — pdfops are - pure functions on byte buffers. - -### Stdin / stdout - -- `--stdin` reads raw bytes from `tokio::io::stdin` until EOF. - `--as html` (default) treats them as a single HTML document; `--as markdown` - feeds them to `markdown_to_pdf`. -- `--output -` writes PDF bytes to `stdout` *unbuffered* and disables - any other stdout output (including the success log line) — so - callers can pipe directly. -- `merge` accepts `-` as an input meaning "next chunk of bytes from - stdin". Multiple `-`s are not allowed; stdin can only be consumed once. - -### Option parsing helpers - -- `--paper`: `a4`/`letter`/`legal`/`a3`/`a5` map to `PaperSize` constants. - `WxH` parsed as two `f32`s separated by `x` (case-insensitive); both - values are inches. -- `--margin`: a single value sets all four; `T,R,B,L` sets each in turn. - Unit is inches. Examples: `--margin 0.5`, `--margin 1,0.5,1,0.5`. -- `--wait`: - - `load` / `domcontentloaded` / `networkidle` map to the matching - `WaitCondition` variant. - - `selector:<CSS>` → `WaitCondition::Selector { selector }`. - - `expr:<JS>` → `WaitCondition::Expression { expression }`. - - `delay:<DUR>` → `WaitCondition::Delay { duration: parse_dur }`. -- `--cookie`: `name=value` followed by `;`-separated attributes - `Domain=`, `Path=`, `Secure`, `HttpOnly`. Unknown attributes ignored. -- `--fail-on-status`: parses individual codes (`500`), wildcard families - (`5xx`, `4xx`), or ranges (`500-503`). Resolved into `Vec<u16>`. -- All durations parsed by `humantime::parse_duration` (e.g. `5s`, `2m`, - `500ms`). - -### Logging fields - -For each completed conversion: - -``` -INFO render - source = "html|url|markdown|office" - bytes_in = <usize> (skipped for url) - bytes_out = <usize> - duration_ms = <u64> - pages = <Option<u32>> (extracted via `lopdf` after the fact) -``` - -For each error: `error.code = "<EngineError variant>"` and `error.message`. - -### Exit codes - -| Code | Meaning | -|------|--------------------------------------------------------| -| 0 | Success. | -| 1 | Generic / unexpected error (last-resort fallthrough). | -| 2 | Usage / parse error (delegated to clap). | -| 3 | Engine error (anything mapping to `EngineError`). | -| 4 | Timeout (`EngineError::Timeout`). | -| 5 | I/O error reading inputs / writing outputs. | -| 6 | Multiple errors in `batch` with `--on-error skip`. | - -In `--on-error skip` mode, a non-zero count of failures yields exit code -6 and a one-line summary on stderr. - -### `batch` ordering - -Walks via `walkdir::WalkDir`, collects matching paths into a stable -sorted order, schedules conversions in that order. Reported errors carry -the input path so users can correlate. - -### `merge` / `split` correctness - -- `merge` reads each input fully into memory before delegating to - `engine::pdfops::merge`. Inputs validated as PDFs upon read; bad input - fails fast with the path in the error. -- `split` filenames are zero-padded to fit the chunk count - (`width = chunk_count.to_string().len()`, min 3). - -## Errors - -Mapped to exit codes per the table above. Error messages on stderr -follow this shape: - -``` -error: <one-line summary> - caused by: <next layer> - caused by: <leaf> -``` - -`anyhow`'s `{:#}` formatter is used. The error's source chain MUST -reach the originating `EngineError` variant. - -## Edge cases - -| Scenario | Required behavior | -|--------------------------------------------------------------|--------------------------------------------------------------------| -| `convert --html foo.html --url ...` | clap mutex group rejects → exit 2. | -| `convert --output -` on a TTY | Allowed. Bytes go to stdout. Stderr still receives logs. | -| `convert --output existing.pdf` | Overwrites. No prompt. | -| `batch --input-dir A --output-dir A` (same directory) | Refused; exit 2 with explanation. | -| `batch --output-dir <does-not-exist>` | Created recursively (`fs::create_dir_all`). | -| `batch --concurrency 0` | Treated as 1. | -| `--paper 0x0` | Caught by spec 10 `PaperSize::new`; exit 3. | -| `--margin "1, 2"` (only two values) | Exit 2 with usage hint. | -| `--cookie "novalue"` (no `=`) | Exit 2. | -| `--wait selector:` (empty) | Exit 2. | -| `merge` with one input | Allowed; bytes round-tripped through pdfops. | -| `merge` with zero inputs | Exit 2 (clap requires at least one). | -| `merge -` consumed twice | Exit 2 ("stdin can only be used once"). | -| `metadata read` on encrypted PDF | Exit 3 with the documented engine message. | -| Long-running conversion → SIGINT | Engine receives `shutdown` via `tokio::signal`; exit 130. | - -## Test plan - -Tests live in `crates/cli/tests/cli.rs` using `assert_cmd`, -`predicates`, and `tempfile`. Network-bound and Chrome-bound tests are -`#[ignore]`d by default. - -### Unit tests (option parsers) - -Exposed as `pub(crate)` for direct testing. - -- `parse_paper_named`, `parse_paper_dimensions`, `parse_paper_invalid`. -- `parse_margin_single_value_uniform`. -- `parse_margin_four_values_in_order`. -- `parse_margin_wrong_count`. -- `parse_wait_simple_keywords`. -- `parse_wait_selector`. -- `parse_wait_expression`. -- `parse_wait_delay`. -- `parse_cookie_with_attrs`. -- `parse_cookie_missing_value`. -- `parse_fail_on_status_codes_and_wildcards`. - -### Command-level tests (`assert_cmd`) - -Without engine: - -- `version_subcommand_outputs_semver_string`. -- `convert_requires_one_input_source`. -- `convert_requires_output`. -- `merge_with_no_inputs_exits_2`. -- `split_default_mode_one_per_page` — using a tiny canned PDF. -- `metadata_read_round_trips_via_write` — pure pdfops. -- `flatten_idempotent_via_cli` — pure pdfops. -- `completions_emits_bash_script` — output starts with `_folio()`. -- `usage_error_exits_2`. -- `engine_error_path_exits_3` — invoke `convert --html nonexistent.html`. - -With Chrome (`#[ignore]`): - -- `convert_html_to_stdout_pipes_bytes` — `… --output -` produces - bytes starting with `%PDF-`. -- `convert_url_to_pdf_against_local_axum`. -- `batch_smoke_two_files_into_two_pdfs`. -- `batch_skip_on_error_exits_6_with_summary`. - -With LibreOffice (`#[ignore]`): - -- `convert_office_writer_doc`. -- `convert_office_with_pdf_a_2b`. - -### Logging / output golden tests - -- `log_format_json_emits_valid_json_per_line` — capture stderr, parse - each line via `serde_json::from_str`. -- `log_format_text_does_not_emit_color_when_piped`. - -## Acceptance - -- [ ] `crates/cli/src/main.rs` compiles, plus `commands/`, `args/`, - `parse/` submodules as needed. -- [ ] `clap = { workspace = true, features = ["derive", "env"] }`, - `clap_complete`, `assert_cmd`, `predicates`, `humantime`, - `walkdir`, `tracing-subscriber` wired via `workspace.dependencies`. -- [ ] Top-level binary name is `folio` (already set in `crates/cli/Cargo.toml`). -- [ ] `folio convert --help` matches the surface above (golden test - against the rendered help). -- [ ] All listed unit tests pass. -- [ ] All non-ignored integration tests pass. -- [ ] `--ignored` integration tests pass on a host with Chrome and `soffice`. -- [ ] `cargo clippy -p cli -- -D warnings` clean. -- [ ] No `unwrap`/`expect` outside test code. -- [ ] Exit codes match the documented table (assert via `assert_cmd`). - -## Out of scope / follow-ups - -- `serve` subcommand fronting `folio-server`. -- Interactive TUI mode. -- Configuration file support (e.g. `folio.toml` discovered by ancestor - walk) — defer until users ask. -- Watermark / rotate / encrypt CLI subcommands — once spec 13 covers - them server-side. -- Progress bars in `batch` — defer; logs cover it. diff --git a/docs/specs/30-server.md b/docs/specs/30-server.md deleted file mode 100644 index 0b315a0..0000000 --- a/docs/specs/30-server.md +++ /dev/null @@ -1,478 +0,0 @@ -# Spec 30 — `server` (`folio-server` binary) - -> Gotenberg-compatible HTTP service backed by the `engine` crate. -> Drop-in replacement for Gotenberg's `/forms/chromium/*`, -> `/forms/libreoffice/*`, and `/forms/pdfengines/*` routes. - -## Goal - -Expose an HTTP API that mirrors Gotenberg's wire contract from -`@/Users/__deesh_reddy__/projects/personal_git/rust_builds/folio/docs/gotenberg-spec.md:48-90`, -so existing Gotenberg clients can switch by changing only the base URL. - -## Scope - -**In:** - -- The Phase-1/2 routes listed below (chromium html/url/markdown/screenshot, - libreoffice convert, pdfengines merge/split/flatten/metadata). -- Form-multipart parsing (matching Gotenberg field names). -- One shared `ChromiumEngine` and `LibreOfficeEngine` per process. -- Concurrency limit via global `Semaphore`. -- Per-request `request_id`, structured `tracing` logs, `/health`, `/version`. -- Graceful shutdown on `SIGINT` / `SIGTERM`: drain in-flight, close - engines, exit 0. - -**Out:** - -- Webhook routes (`/forms/webhook`). -- Screenshot routes (`/forms/chromium/screenshot/*`) — follow-up. -- Encrypt / watermark / stamp / rotate routes — wired once spec 13's - follow-ups land. -- Metrics (Prometheus / OpenTelemetry) — separate optional feature. -- Auth — none in MVP. Operators are expected to front this with a - reverse proxy when exposed publicly. - -## Public API - -### Routes - -| Method | Path | Handler | -|--------|------------------------------------------|----------------------------------| -| GET | `/health` | `health` | -| GET | `/version` | `version` | -| POST | `/forms/chromium/convert/html` | `chromium_html` | -| POST | `/forms/chromium/convert/url` | `chromium_url` | -| POST | `/forms/chromium/convert/markdown` | `chromium_markdown` | -| POST | `/forms/chromium/screenshot/html` | `chromium_screenshot_html` | -| POST | `/forms/chromium/screenshot/url` | `chromium_screenshot_url` | -| POST | `/forms/chromium/screenshot/markdown` | `chromium_screenshot_markdown` | -| POST | `/forms/libreoffice/convert` | `libreoffice_convert` | -| POST | `/forms/pdfengines/merge` | `pdfengines_merge` | -| POST | `/forms/pdfengines/split` | `pdfengines_split` | -| POST | `/forms/pdfengines/flatten` | `pdfengines_flatten` | -| POST | `/forms/pdfengines/metadata/read` | `pdfengines_metadata_read` | -| POST | `/forms/pdfengines/metadata/write` | `pdfengines_metadata_write` | - -All POST routes are `multipart/form-data`. JSON bodies are not accepted -(matches Gotenberg). - -### CLI surface - -``` -folio-server serve [OPTIONS] - -Options (env-overridable; flags take precedence): - --host <HOST> Default 0.0.0.0 (env FOLIO_HOST) - --port <PORT> Default 3000 (env FOLIO_PORT) - --concurrency <N> Default num_cpus (env FOLIO_CONCURRENCY) - --max-body-bytes <N> Default 50 MiB (env FOLIO_MAX_BODY) - --request-timeout <DUR> Default 120s (env FOLIO_REQUEST_TIMEOUT) - --chrome <PATH> Override Chrome path (env CHROME_PATH) - --no-sandbox / --sandbox (env FOLIO_NO_SANDBOX) - --soffice <PATH> Override soffice path (env LIBREOFFICE_PATH) - --log-level <LEVEL> Default "info" (env RUST_LOG) - --log-format <FORMAT> text | json. Default text on TTY, else json. - (env FOLIO_LOG_FORMAT) -``` - -## Behavior - -### App state - -```rust -pub struct AppState { - chromium: Arc<ChromiumEngine>, - libreoffice: Arc<LibreOfficeEngine>, - sem: Arc<tokio::sync::Semaphore>, // global concurrency cap - config: ServerConfig, // ports, timeouts, max body - started_at: std::time::Instant, -} -``` - -`AppState` is `Clone` (its fields are all `Arc`/`Copy`-friendly) and -attached via `axum::extract::State`. - -### Engine lifecycle - -1. On startup, build `ChromiumEngine::launch_with(BrowserConfig::from(cfg))` - in parallel with `LibreOfficeEngine::launch(LibreOfficeConfig::from(cfg))` - via `tokio::join!`. -2. On either engine failing to launch, log the error and exit 1. -3. On `SIGINT`/`SIGTERM` (`tokio::signal::ctrl_c()` + Unix signals): - 1. Stop accepting new connections (`axum::serve` graceful shutdown). - 2. Wait for in-flight requests up to a 30-second drain budget. - 3. `chromium.shutdown().await` and drop `libreoffice`. - 4. Exit 0. - -### Form-field parsing - -Files are extracted from the multipart body into a per-request -`tempfile::TempDir`. Non-file fields are collected into a -`HashMap<String, String>` (last wins on duplicates). - -Then a pure helper deserialises the map into the relevant request -struct using `serde_urlencoded` (after the map has been re-serialised). -This gives us camelCase field names for free via spec 10's `#[serde]` -annotations. - -### `chromium_html` - -Multipart fields: - -| Field name | Type | Maps to | -|---------------------------|----------|--------------------------------------| -| `files` (one .html file) | file | inlined as the HTML string | -| `files` (additional) | file(s) | written next to `index.html` for relative resolution; `base_url` set to a `file://<tmpdir>/index.html` | -| `paperWidth` | float | `PdfOptions::paper.width_in` | -| `paperHeight` | float | `PdfOptions::paper.height_in` | -| `marginTop` ... `marginRight` | float | `PdfOptions::margin.*` | -| `landscape` | bool | `PdfOptions::landscape` | -| `scale` | float | `PdfOptions::scale` | -| `printBackground` | bool | `PdfOptions::print_background` | -| `pageRanges` | string | `PdfOptions::page_ranges` | -| `headerTemplate` | string | `PdfOptions::header_template` | -| `footerTemplate` | string | `PdfOptions::footer_template` | -| `preferCssPageSize` | bool | `PdfOptions::prefer_css_page_size` | -| `emulateMediaType` | string | `PdfOptions::emulate_media` | -| `waitDelay` | duration | `WaitCondition::Delay` | -| `waitForSelector` | string | `WaitCondition::Selector` | -| `waitForExpression` | string | `WaitCondition::Expression` | -| `userAgent` | string | `RequestContext::user_agent` | -| `extraHttpHeaders` | json | `RequestContext::extra_headers` | -| `cookies` | json | `RequestContext::cookies` | -| `failOnHttpStatusCodes` | json | `RequestContext::fail_on_status` | - -Steps: - -1. Acquire a permit from `state.sem` (await; this is the back-pressure - point). -2. Parse multipart; require a file named `index.html` (Gotenberg - convention). -3. Build `PdfOptions` and `RequestContext` from the form map. -4. Validate via `PdfOptions::validate()`. -5. Call `state.chromium.html_to_pdf(html, base_url, &opts, &ctx)`. -6. Stream the bytes back as `application/pdf` with - `Content-Disposition: attachment; filename="result.pdf"` (matches - Gotenberg). Set `X-Request-Id` echo. - -### `chromium_url` - -Same as `chromium_html`, except instead of `files` there's a `url` -field (string, required), and the engine call is `url_to_pdf`. - -### `chromium_markdown` - -Multipart accepts: - -- An `index.html` file (a wrapper template). -- One or more `.md` files referenced by `<link rel="markdown" href="...">` - inside the wrapper. - -Implementation: - -1. Read all files into the per-request tempdir. -2. Read the wrapper `index.html`. Find all - `<link rel="markdown" href="...">` (or the simpler convention of - reading the *first* `.md` file when no wrapper is provided — both - supported, wrapper takes precedence). -3. For each referenced markdown, render via the engine's markdown→html - conversion (delegating to spec 11) and inline into the wrapper. -4. Send the resulting HTML to `html_to_pdf` with `base_url` set to the - tempdir. - -### `chromium_screenshot_html` - -Multipart fields: - -| Field name | Type | Maps to | -|---------------------------|----------|--------------------------------------| -| `files` (one .html file) | file | inlined as the HTML string | -| `format` | string | `ScreenshotOptions::format` (png/jpeg/webp) | -| `quality` | int | `ScreenshotOptions::quality` (0-100) | -| `fullPage` | bool | `ScreenshotOptions::full_page` | -| `clip.x`, `clip.y` | float | Clip rectangle position | -| `clip.width`, `clip.height` | float | Clip rectangle dimensions | -| `viewport.width` | int | `ScreenshotOptions::viewport_width` | -| `viewport.height` | int | `ScreenshotOptions::viewport_height` | -| `viewport.scale` | float | `ScreenshotOptions::scale` | -| `waitDelay` | duration | `WaitCondition::Delay` | -| `waitForSelector` | string | `WaitCondition::Selector` | -| `waitForExpression` | string | `WaitCondition::Expression` | -| `userAgent` | string | `RequestContext::user_agent` | -| `extraHttpHeaders` | json | `RequestContext::extra_headers` | -| `cookies` | json | `RequestContext::cookies` | -| `failOnHttpStatusCodes` | json | `RequestContext::fail_on_status` | - -Steps: - -1. Acquire semaphore permit. -2. Parse multipart; require a file named `index.html`. -3. Build `ScreenshotOptions` and `RequestContext` from form map. -4. Call `state.chromium.screenshot_html(html, base_url, &opts, &ctx)`. -5. Return bytes as `image/png`, `image/jpeg`, or `image/webp` with - `Content-Disposition: attachment; filename="result.{png|jpg|webp}"`. - -### `chromium_screenshot_url` - -Same as `chromium_screenshot_html`, except uses `url` field instead of -`files`, and calls `screenshot_url`. - -### `chromium_screenshot_markdown` - -Same pattern as `chromium_markdown` but renders to screenshot instead of -PDF. Calls `screenshot_markdown`. - -### `libreoffice_convert` - -Multipart fields: - -| Field | Type | Maps to | -|------------------------|----------|------------------------------------| -| `files` | file(s) | input documents | -| `landscape` | bool | `OfficeOptions::landscape` | -| `pageRanges` | string | `OfficeOptions::page_ranges` | -| `pdfa` | string | `OfficeOptions::pdf_a` | -| `pdfua` | bool | `OfficeOptions::pdf_ua` | -| `merge` | bool | post-process via `pdfops::merge` | -| `quality` | int | `OfficeOptions::quality` | -| `maxImageResolution` | int | `OfficeOptions::max_image_resolution` | -| `nativePageRanges` | string | alias of `pageRanges` (Gotenberg) | - -Steps: - -1. Permit + tempdir. -2. Save each `files` part to `tempdir/<name>`. -3. Call `libreoffice.convert_many(...)`. -4. If `merge = true`, pipe results into `pdfops::merge` (spec 13). -5. Return the single-file or zip-of-files response (when not merging - with multiple inputs, ZIP up the outputs as - `application/zip` — this matches Gotenberg's behavior). - -### `pdfengines_merge` - -Multipart `files`: two or more PDFs, in field order. Other fields: -`metadata` (json) — optional, applied via `pdfops::write_metadata` -after merge. - -### `pdfengines_split` - -Fields: - -- `files`: exactly one PDF. -- `splitMode`: `intervals` | `pages`. (Gotenberg uses `mode` — accept - both names.) -- `splitSpan`: integer for `intervals`. -- `splitUnify`: bool — when true and mode is `pages`, merge the chunks - back into a single PDF (matches Gotenberg quirk). -- `splitPages`: comma list of page-range chunks for `pages` mode. - -Returns: - -- Single chunk: `application/pdf`. -- Multiple chunks: `application/zip` containing - `result-001.pdf`, `result-002.pdf`, ... - -### `pdfengines_flatten` - -Fields: `files` — one or more PDFs. Each is flattened independently; -returns single PDF or ZIP per the same rule. - -### `pdfengines_metadata_read` - -Fields: `files` — one or more PDFs. Returns `application/json`: - -```json -{ - "input-1.pdf": { "title": "...", "author": "...", "custom": {...} }, - "input-2.pdf": { ... } -} -``` - -### `pdfengines_metadata_write` - -Fields: - -- `files`: one or more PDFs. -- `metadata`: required JSON. Merged into each input. - -Returns single PDF / ZIP per the standard rule. - -### `health` - -Returns `200 OK` with body: - -```json -{ - "status": "up", - "uptime_secs": 1234, - "chromium": "up" | "down", - "libreoffice": "up" | "down" -} -``` - -`chromium` reflects `ChromiumEngine::healthy().await`; same for -`libreoffice`. If either is `down`, the overall HTTP status is still -`200` (matches Gotenberg convention) but the body indicates the issue. - -### `version` - -Returns `text/plain` body with `env!("CARGO_PKG_VERSION")`. - -### Middleware stack (outer → inner) - -1. `tower_http::trace::TraceLayer` with a custom span - (`request_id`, `method`, `uri`, `status`, `latency_ms`). -2. `tower_http::request_id::SetRequestIdLayer` (use `X-Request-Id` if - incoming, else generate a UUIDv4). -3. `tower_http::limit::RequestBodyLimitLayer::new(max_body_bytes)`. -4. `tower::timeout::TimeoutLayer::new(request_timeout)` — bypassed for - `/health` and `/version`. -5. `tower_http::cors::CorsLayer::permissive()` (operator-overridable - later via flag, MVP keeps it permissive). -6. The router. - -### Error mapping - -Single `IntoResponse` for `EngineError`: - -| Variant | Status | Body | -|----------------------------------|--------|---------------------------------------| -| `InvalidOption` | 400 | `{ "error": "...", "code": "INVALID_OPTION" }` | -| `InvalidPageRange` | 400 | `{ "error": "...", "code": "INVALID_PAGE_RANGE" }` | -| `Navigation { url, reason }` | 502 | `{ "error": "...", "code": "NAVIGATION", "url": "...", "reason": "..." }` | -| `Timeout(d)` | 504 | `{ "error": "...", "code": "TIMEOUT" }` | -| `ChromeNotFound | ChromeLaunch` | 500 | `{ "error": "...", "code": "ENGINE_UNAVAILABLE" }` | -| `Cdp | Internal | Io` | 500 | `{ "error": "...", "code": "INTERNAL" }` | - -All error responses are `application/json`. The originating -`EngineError` `Display` text becomes the `error` field; the chain (when -present) joins via `: `. - -### Concurrency model - -- Outer cap: `Semaphore::new(concurrency)`. Permit acquired in handler - prelude, dropped when the handler future ends (success or error). -- Inner: `ChromiumEngine` opens a fresh page per request (spec 11 - guarantees safe concurrency). -- LibreOffice: each `convert*` call serialises through the engine's - internal semaphore (spec 12). -- PDF ops are pure CPU; offload via `tokio::task::spawn_blocking` for - any input larger than 1 MiB so we don't block the runtime. - -## Errors - -See "Error mapping" above. The server's surface contains no error -variants of its own — every failure ultimately maps to an `EngineError` -or to one of the standard HTTP errors: - -- 400 — multipart parse failure, missing required field. -- 405 — wrong HTTP method on a known path. -- 413 — body exceeds `--max-body-bytes` (returned by tower-http layer). -- 415 — non-multipart `Content-Type`. - -## Edge cases - -| Scenario | Required behavior | -|---------------------------------------------------------------------|-------------------------------------------------------------------------| -| Multipart missing required `files` | 400 with `{"error":"missing required file 'index.html'"}`. | -| `files` includes a `..` path traversal | Reject; 400. | -| Body exactly at `--max-body-bytes` | Accepted. | -| Body 1 byte over | 413, structured error code `BODY_TOO_LARGE`. | -| Chrome dies mid-request | `EngineError::Cdp` → 500. Server keeps running; next request triggers re-launch attempt (out of MVP — for now we exit). | -| `/health` while engines are down | 200 with `{ "status": "up", "chromium": "down" }`. Operator's monitor decides. | -| SIGINT during slow render | Graceful shutdown waits up to 30s, then forces engine shutdown and exits. In-flight client receives 503 (TimeoutLayer) or connection close. | -| Concurrent identical requests | Each gets its own page; results returned independently. | -| `extraHttpHeaders` not valid JSON | 400 `{"code":"INVALID_OPTION","error":"extraHttpHeaders is not valid JSON"}`. | -| `cookies` JSON has unknown attributes | Unknown attrs ignored; documented in OpenAPI later. | -| Output too large to fit in 4 GiB Vec | Hypothetical; 500 with internal error. Not optimised for in MVP. | - -## Test plan - -### Unit tests (`crates/server/src/...`) - -- `app_state_clone_is_cheap` — `static_assertions` for `Clone + Send + Sync`. -- `parse_pdf_options_from_form_map_round_trip`. -- `parse_request_context_from_form_map_round_trip`. -- `extra_http_headers_invalid_json_returns_invalid_option`. -- `cookies_with_attrs_parse`. -- `fail_on_status_codes_parse`. -- `error_mapping_table` — for each `EngineError` variant produce the - documented HTTP status + JSON shape. - -### Router-level tests (`tower::ServiceExt::oneshot` against `Router`) - -These do not launch real engines; they use a test double -(`ChromiumEngine` mocked behind a trait `PdfBackend`). The trait is -introduced *only* for the server's unit tests; production code uses the -concrete engine. - -- `health_returns_200_when_engines_up`. -- `version_returns_pkg_version`. -- `chromium_html_returns_pdf_bytes_on_success` — mock returns - `b"%PDF-1.7..."`. -- `chromium_html_400_on_missing_index_html`. -- `chromium_url_400_on_missing_url_field`. -- `chromium_html_504_when_backend_returns_timeout`. -- `chromium_html_502_when_backend_returns_navigation_error`. -- `chromium_screenshot_html_returns_png_on_success` — mock returns - PNG bytes (`\x89PNG`). -- `chromium_screenshot_url_returns_jpeg_when_format_set` — mock returns - JPEG bytes (`0xFF 0xD8`). -- `chromium_screenshot_markdown_returns_webp` — mock returns WebP. -- `body_too_large_returns_413`. -- `nonexistent_route_returns_404`. - -### Integration tests (`crates/server/tests/`) - -Marked `#[ignore]`, require Chrome and `soffice` on the host: - -- `e2e_chromium_html` — start server on ephemeral port, POST a tiny - HTML, assert PDF bytes returned. -- `e2e_chromium_url_against_local_axum_app`. -- `e2e_libreoffice_docx`. -- `e2e_pdfengines_merge_split_round_trip`. -- `graceful_shutdown_drains_inflight` — start a long render, send - SIGINT, assert the in-flight request completes (or 503s cleanly) and - the process exits within 35s. - -### Smoke - -A `crates/server/tests/smoke.sh` (or Rust harness) script `curl`s every -documented route against a launched server and asserts non-error -responses for a small fixture set. Runs in CI on Linux runners only. - -## Acceptance - -- [ ] All routes implemented per the table (including screenshot routes). -- [ ] Multipart parser handles repeated fields and named files - (Gotenberg-style: `files` repeated; the *file name* matters for - `index.html`). -- [ ] `axum`, `tower`, `tower-http`, `multer`, `tempfile`, `uuid`, - `serde`, `serde_json`, `serde_urlencoded`, `tracing-subscriber` - added via `workspace.dependencies`. -- [ ] Error mapping matches the table; covered by the dedicated unit test. -- [ ] CLI flags + env vars resolve in the documented precedence order - (flag > env > default). Verified by a unit test on - `ServerConfig::resolve(args, env)`. -- [ ] Graceful shutdown verified by the integration test. -- [ ] `cargo clippy -p server -- -D warnings` clean. -- [ ] No `unsafe`. No `unwrap`/`expect` outside `#[cfg(test)]`. -- [ ] Output content types: `application/pdf` for single PDFs, - `application/zip` for multi, `application/json` for metadata read, - `image/png`/`image/jpeg`/`image/webp` for screenshots. -- [ ] `Content-Disposition: attachment; filename="result.pdf"` (or - `result.zip` / `result.json` / `result.{png|jpg|webp}`) on success. -- [ ] Screenshot routes return correct image format based on `format` field. - -## Out of scope / follow-ups - -- Webhook routes (`/forms/webhook`) — Gotenberg has them; defer until - user demand. -- Full route set behind `/forms/pdfengines/*` (encrypt, watermark, - stamp, rotate, embed, bookmarks) — wired as their backing `pdfops` - functions land. -- Prometheus / OpenTelemetry exporters — separate optional feature. -- Multi-tenant API keys / quotas — assume reverse-proxy fronting. -- Hot-restart of crashed engines (today the process exits on engine - death; a supervisor is expected externally). diff --git a/docs/specs/36-chromium-wait-conditions.md b/docs/specs/36-chromium-wait-conditions.md deleted file mode 100644 index 4e46665..0000000 --- a/docs/specs/36-chromium-wait-conditions.md +++ /dev/null @@ -1,377 +0,0 @@ -# Spec 36 — Chromium Wait Conditions & Advanced Options - -> Advanced Chromium wait conditions and request context options -> that Folio is missing compared to Gotenberg. These fields provide -> finer control over page loading and resource handling. - -## Goal - -Implement missing Chromium form fields that control wait behavior, -resource validation, and rendering options. These are critical for -production use cases where precise timing and error handling are required. - -## Scope - -**In:** - -- `waitForSelector` - Wait for DOM element visibility -- `skipNetworkIdleEvent` - Skip network idle detection -- `skipNetworkAlmostIdleEvent` - Skip "almost idle" (≤2 connections) -- `waitWindowStatus` - Wait for `window.status` value -- `failOnResourceHttpStatusCodes` - Resource status code validation -- `ignoreResourceHttpStatusDomains` - Exclude domains from checks -- `failOnResourceLoadingFailed` - Fail on resource errors -- `failOnConsoleExceptions` - Fail on JS exceptions -- `omitBackground` - Transparent background rendering - -**Out:** - -- `failOnHttpStatusCodes` - Already implemented ✅ -- `failOnConsoleExceptions` - Future: capture console.error() calls - -## Form Fields - -### Wait Conditions (Missing in Folio) - -| Field | Type | Gotenberg Source | Description | -|-------|------|------------------|-------------| -| `waitForSelector` | string (CSS selector) | `pkg/modules/chromium/formfield.go:WaitForSelector` | Wait for element to be visible before rendering | -| `skipNetworkIdleEvent` | boolean | `pkg/modules/chromium/formfield.go:SkipNetworkIdleEvent` | Skip waiting for network idle (0 connections) | -| `skipNetworkAlmostIdleEvent` | boolean | `pkg/modules/chromium/formfield.go:SkipNetworkAlmostIdleEvent` | Skip "almost idle" (≤2 connections) | -| `waitWindowStatus` | string | `pkg/modules/chromium/formfield.go:WaitWindowStatus` | Wait for `window.status === value` | - -### Resource Validation (Missing in Folio) - -| Field | Type | Gotenberg Source | Description | -|-------|------|------------------|-------------| -| `failOnResourceHttpStatusCodes` | JSON array | `pkg/modules/chromium/formfield.go:FailOnResourceHttpStatusCodes` | HTTP status codes that fail the conversion | -| `ignoreResourceHttpStatusDomains` | JSON array | `pkg/modules/chromium/formfield.go:IgnoreResourceHttpStatusDomains` | Domains to exclude from status checks | -| `failOnResourceLoadingFailed` | boolean | `pkg/modules/chromium/formfield.go:FailOnResourceLoadingFailed` | Fail when any resource fails to load | -| `failOnConsoleExceptions` | boolean | `pkg/modules/chromium/formfield.go:FailOnConsoleExceptions` | Fail when `console.error()` is called | - -### Rendering Options (Missing in Folio) - -| Field | Type | Gotenberg Source | Description | -|-------|------|------------------|-------------| -| `omitBackground` | boolean | `pkg/modules/chromium/formfield.go:OmitBackground` | Omit background graphics (transparent background) | - -## Implementation - -### 1. Extend `PdfOptions` in `crates/engine/src/types.rs` - -```rust -pub struct PdfOptions { - // ... existing fields ... - - // Wait conditions - pub wait_for_selector: Option<String>, - pub skip_network_idle: bool, - pub skip_network_almost_idle: bool, - pub wait_window_status: Option<String>, - - // Resource validation - pub fail_on_resource_http_status_codes: Vec<u16>, - pub ignore_resource_http_status_domains: Vec<String>, - pub fail_on_resource_loading_failed: bool, - pub fail_on_console_exceptions: bool, - - // Rendering - pub omit_background: bool, -} -``` - -### 2. Update Form Field Parsing in `crates/server/src/routes/chromium.rs` - -```rust -// In parse_chromium_form function: -if let Some(selector) = form.get("waitForSelector") { - opts.wait_for_selector = Some(selector.clone()); -} - -if let Some(val) = form.get("skipNetworkIdleEvent") { - opts.skip_network_idle = val == "true"; -} - -if let Some(val) = form.get("skipNetworkAlmostIdleEvent") { - opts.skip_network_almost_idle = val == "true"; -} - -if let Some(status) = form.get("waitWindowStatus") { - opts.wait_window_status = Some(status.clone()); -} - -if let Some(codes) = form.get("failOnResourceHttpStatusCodes") { - // Parse JSON array: [404, 500, 502] - opts.fail_on_resource_http_status_codes = serde_json::from_str(codes) - .map_err(|e| EngineError::InvalidOption(...))?; -} - -if let Some(domains) = form.get("ignoreResourceHttpStatusDomains") { - // Parse JSON array: ["cdn.example.com", "*.cloudfront.net"] - opts.ignore_resource_http_status_domains = serde_json::from_str(domains) - .map_err(|e| EngineError::InvalidOption(...))?; -} - -if let Some(val) = form.get("failOnResourceLoadingFailed") { - opts.fail_on_resource_loading_failed = val == "true"; -} - -if let Some(val) = form.get("failOnConsoleExceptions") { - opts.fail_on_console_exceptions = val == "true"; -} - -if let Some(val) = form.get("omitBackground") { - opts.omit_background = val == "true"; -} -``` - -### 3. Implement in `ChromiumEngine` (`crates/engine/src/chromium/render.rs`) - -#### Wait for Selector - -```rust -use chromiumoxide::page::Page; - -async fn wait_for_selector(page: &Page, selector: &str) -> Result<(), EngineError> { - use chromiumoxide::cdp::browser_protocol::dom::*; - - // Wait for element to be visible - let cmd = GetElementById { - node_id: page.find_element(selector).await - .map_err(|e| EngineError::Navigation(...))? - }; - - // Poll until visible or timeout - let start = std::time::Instant::now(); - while start.elapsed() < Duration::from_secs(30) { - if page.is_visible(selector).await.unwrap_or(false) { - return Ok(()); - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - - Err(EngineError::Timeout(Duration::from_secs(30))) -} -``` - -#### Skip Network Idle Events - -```rust -// In navigate_and_render: -if !opts.skip_network_idle { - // Wait for network idle (0 connections) - page.wait_for_network_idle().await?; -} - -if !opts.skip_network_almost_idle { - // Wait for "almost idle" (≤2 connections) - wait_for_almost_idle(page).await?; -} -``` - -#### Wait for Window Status - -```rust -async fn wait_for_window_status(page: &Page, status: &str) -> Result<(), EngineError> { - let start = std::time::Instant::now(); - while start.elapsed() < Duration::from_secs(30) { - let current: String = page.evaluate("window.status").await?; - if current == status { - return Ok(()); - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - Err(EngineError::Timeout(Duration::from_secs(30))) -} -``` - -#### Resource Status Validation - -```rust -use chromiumoxide::handler::network::{RequestPaused, ResponseReceived}; - -struct ResourceValidator { - fail_codes: Vec<u16>, - ignore_domains: Vec<String>, - failed_resources: Vec<String>, -} - -impl ResourceValidator { - fn new(codes: Vec<u16>, domains: Vec<String>) -> Self { - Self { - fail_codes: codes, - ignore_domains: domains, - failed_resources: Vec::new(), - } - } - - fn check_response(&mut self, url: &str, status: u16) { - if self.fail_codes.contains(&status) { - if !self.should_ignore(url) { - self.failed_resources.push(format!("{}: {}", url, status)); - } - } - } - - fn should_ignore(&self, url: &str) -> bool { - self.ignore_domains.iter().any(|domain| url.contains(domain)) - } -} -``` - -#### Console Exceptions - -```rust -use chromiumoxide::cdp::browser_protocol::runtime::ExceptionThrown; - -fn enable_console_exception_detection(page: &Page) -> ConsoleExceptionDetector { - let detector = ConsoleExceptionDetector::new(); - page.enable_runtime().await.unwrap(); - // Listen for ExceptionThrown events - // If console.error() called, add to exceptions list - detector -} -``` - -#### Omit Background - -```rust -// In PDF printing options: -let mut print_opts = PrintToPdfParams::builder(); - -if opts.omit_background { - print_opts.background_graphics(false); -} -``` - -## References to Gotenberg Source - -| Feature | Gotenberg File | Line Numbers | -|---------|------------------|-------------| -| Form field definitions | `pkg/modules/chromium/formfield.go` | Full file | -| WaitForSelector handling | `pkg/modules/chromium/libreoffice.go` | ~L400-450 | -| Network idle logic | `pkg/modules/chromium/chromium.go` | ~L200-300 | -| Resource validation | `pkg/modules/chromium/chromium.go` | ~L300-400 | -| Window status wait | `pkg/modules/chromium/chromium.go` | ~L400-450 | -| Console exceptions | `pkg/modules/chromium/chromium.go` | ~L450-500 | -| Omit background | `pkg/modules/chromium/formfield.go` | ~L150-200 | - -To read Gotenberg source: -```bash -cd /Users/__deesh_reddy__/projects/personal_git/rust_builds/folio/gotenberg -cat pkg/modules/chromium/formfield.go | grep -A5 "WaitForSelector" -``` - -## Expected Behavior - -### `waitForSelector` -- Accept CSS selector string -- Wait until element is visible in DOM -- Timeout after 30s (configurable via `waitDelay`) -- Return error if element not found - -### `skipNetworkIdleEvent` -- When `true`, don't wait for network idle (0 connections) -- Speeds up conversion for pages with persistent connections -- Default: `false` (wait for idle) - -### `skipNetworkAlmostIdleEvent` -- When `true`, don't wait for "almost idle" (≤2 connections) -- Useful for pages with long-polling or websockets -- Default: `false` - -### `waitWindowStatus` -- Wait for `window.status` to equal specified value -- Poll every 100ms with 30s timeout -- Useful for SPA frameworks that set status on render complete - -### `failOnResourceHttpStatusCodes` -- Accept JSON array: `[404, 500, 502]` -- Check all subresource requests (images, scripts, XHR) -- Fail conversion if any resource matches -- Ignore domains in `ignoreResourceHttpStatusDomains` - -### `ignoreResourceHttpStatusDomains` -- Accept JSON array: `["cdn.example.com", "*.cloudfront.net"]` -- Supports wildcard `*` prefix -- Case-insensitive domain matching - -### `failOnResourceLoadingFailed` -- When `true`, fail if any resource fails to load (network error) -- Includes 4xx, 5xx, DNS failure, timeout, etc. -- Default: `false` - -### `failOnConsoleExceptions` -- When `true`, fail if `console.error()` is called -- Captures exceptions thrown in `window.onerror` -- Useful for catching JS errors during render -- Default: `false` - -### `omitBackground` -- When `true`, render with transparent background -- Sets `background-graphics: false` in print params -- Useful for overlaying PDF on other content -- Default: `false` - -## Test Plan - -### Unit Tests - -- `parse_wait_for_selector_from_form` -- `parse_skip_network_idle_from_form` -- `parse_fail_on_resource_codes_json_array` -- `parse_ignore_domains_wildcard` -- `omit_background_sets_print_param` - -### Integration Tests - -- `wait_for_selector_success` - Element appears after JS render -- `wait_for_selector_timeout` - Element never appears -- `skip_network_idle_speeds_up_conversion` -- `fail_on_resource_404` - Image 404 fails conversion -- `ignore_domain_cdn` - CDN 404 ignored -- `fail_on_console_error` - JS error fails conversion -- `omit_background_transparent` - PDF has no background - -### BDD Scenarios (Port from Gotenberg) - -```gherkin -Scenario: Wait for selector before rendering - Given Chromium is available - When I POST to "/forms/chromium/convert/url" with: - | url | http://example.com/dynamic | - | waitForSelector | #content | - | waitDelay | 5s | - Then I should receive a PDF - And the PDF should contain "Dynamic Content" - -Scenario: Fail on resource HTTP status - Given Chromium is available - When I POST to "/forms/chromium/convert/url" with: - | url | http://example.com/broken | - | failOnResourceHttpStatusCodes | [404, 500] | - Then the response status code should be 502 - And the error code should be "NAVIGATION" -``` - -## Acceptance - -- [ ] `PdfOptions` extended with all new fields -- [ ] Form field parsing in `chromium.rs` route handler -- [ ] `wait_for_selector` implemented in `ChromiumEngine` -- [ ] Network idle skip options implemented -- [ ] `wait_window_status` implemented -- [ ] Resource validation with domain ignore list -- [ ] Console exception detection -- [ ] `omit_background` sets print parameter -- [ ] Unit tests for all form field parsers -- [ ] Integration tests for each new feature -- [ ] BDD scenarios ported from Gotenberg -- [ ] `cargo clippy -p engine -- -D warnings` clean - -## References - -- Gotenberg form fields: `/Users/__deesh_reddy__/projects/personal_git/rust_builds/folio/gotenberg/pkg/modules/chromium/formfield.go` -- Gotenberg Chromium module: `/Users/__deesh_reddy__/projects/personal_git/rust_builds/folio/gotenberg/pkg/modules/chromium/` -- Chromiumoxide docs: https://docs.rs/chromiumoxide/ -- Chrome DevTools Protocol: https://chromedevtools.github.io/ diff --git a/docs/specs/37-libreoffice-advanced.md b/docs/specs/37-libreoffice-advanced.md deleted file mode 100644 index e84d897..0000000 --- a/docs/specs/37-libreoffice-advanced.md +++ /dev/null @@ -1,396 +0,0 @@ -# Spec 37 — LibreOffice Advanced Form Fields - -> Comprehensive list of LibreOffice form fields that Gotenberg -> supports but Folio is missing. These 30+ fields control PDF -> export options, bookmarks, notes, viewer preferences, and native -> watermarks. - -## Goal - -Implement all missing LibreOffice form fields to achieve full parity -with Gotenberg's LibreOffice conversion capabilities. - -## Scope - -**In:** - -All LibreOffice form fields from Gotenberg that Folio is missing: - -### Bookmarks & Index (5 fields) -- `exportBookmarks` - Export bookmarks to PDF -- `exportBookmarksToPdfDestination` - Export to Named Destination -- `updateIndexes` - Update document indexes -- `autoIndexBookmarks` - Auto-index bookmarks (merge) -- `bookmarks` (for merge) - Custom bookmarks with offsets - -### Form Fields & Placeholders (3 fields) -- `exportFormFields` - Export as interactive form widgets -- `allowDuplicateFieldNames` - Allow duplicate field names -- `exportPlaceholders` - Export placeholder markings - -### Notes & Margins (4 fields) -- `exportNotes` - Export notes to PDF -- `exportNotesPages` - Export notes pages (Impress) -- `exportOnlyNotesPages` - Export only notes pages -- `exportNotesInMargin` - Export notes in margin - -### Advanced Options (8 fields) -- `convertOooTargetToPdfTarget` - Convert .od* links to .pdf -- `exportLinksRelativeFsys` - Export links as relative -- `exportHiddenSlides` - Export hidden slides (Impress) -- `skipEmptyPages` - Suppress empty pages -- `addOriginalDocumentAsStream` - Add source doc as stream -- `singlePageSheets` - Single page sheets -- `losslessImageCompression` - Use lossless compression -- `reduceImageResolution` - Reduce image resolution - -### Native Watermarks (6 fields) -- `nativeWatermarkText` - Watermark text -- `nativeWatermarkColor` - RGB color -- `nativeWatermarkFontHeight` - Font height -- `nativeWatermarkRotateAngle` - Rotation angle -- `nativeWatermarkFontName` - Font name -- `nativeTiledWatermarkText` - Tiled watermark text - -### PDF Viewer Preferences (15 fields, Gotenberg 8.29.0+) -- `initialView` - Initial view mode -- `initialPage` - Initial page number -- `magnification` - Magnification level -- `zoom` - Zoom level -- `pageLayout` - Page layout -- `firstPageOnLeft` - First page on left -- `resizeWindowToInitialPage` - Resize to initial page -- `centerWindow` - Center window -- `openInFullScreenMode` - Open fullscreen -- `displayPDFDocumentTitle` - Display document title -- `hideViewerMenubar` - Hide menu bar -- `hideViewerToolbar` - Hide toolbar -- `hideViewerWindowControls` - Hide window controls -- `useTransitionEffects` - Use transition effects -- `openBookmarkLevels` - Open bookmark levels - -**Out:** - -- Fields that require LibreOffice API access beyond command-line flags -- Fields that are deprecated in LibreOffice 7.x+ - -## Form Fields (Missing in Folio) - -### 1. Bookmarks & Index - -| Field | Type | Gotenberg Source | LibreOffice Flag | Description | -|-------|------|------------------|-------------------|-------------| -| `exportBookmarks` | boolean | `pkg/modules/libreoffice/formfield.go:ExportBookmarks` | `--export-bookmarks` | Export bookmarks to PDF outline | -| `exportBookmarksToPdfDestination` | boolean | `pkg/modules/libreoffice/formfield.go:ExportBookmarksToPdfDestination` | `--export-bookmarks-to-pdf-destination` | Export to PDF Named Destination | -| `updateIndexes` | boolean | `pkg/modules/libreoffice/formfield.go:UpdateIndexes` | `--update-indexes` | Update document indexes | -| `autoIndexBookmarks` | boolean | `pkg/modules/libreoffice/formfield.go:AutoIndexBookmarks` | (merge only) | Auto-index when merging | -| `bookmarks` | JSON | `pkg/modules/libreoffice/formfield.go:Bookmarks` | (merge only) | Custom bookmarks with page offsets | - -#### `bookmarks` JSON Format - -```json -[ - { - "title": "Chapter 1", - "page": 1, - "children": [ - {"title": "Section 1.1", "page": 2, "children": []} - ] - } -] -``` - -### 2. Form Fields & Placeholders - -| Field | Type | Gotenberg Source | LibreOffice Flag | Description | -|-------|------|------------------|-------------------|-------------| -| `exportFormFields` | boolean | `pkg/modules/libreoffice/formfield.go:ExportFormFields` | `--export-form-fields` | Export as interactive form widgets | -| `allowDuplicateFieldNames` | boolean | `pkg/modules/libreoffice/formfield.go:AllowDuplicateFieldNames` | `--allow-duplicate-field-names` | Allow duplicate field names | -| `exportPlaceholders` | boolean | `pkg/modules/libreoffice/formfield.go:ExportPlaceholders` | `--export-placeholders` | Export placeholder markings | - -### 3. Notes & Margins - -| Field | Type | Gotenberg Source | LibreOffice Flag | Description | -|-------|------|------------------|-------------------|-------------| -| `exportNotes` | boolean | `pkg/modules/libreoffice/formfield.go:ExportNotes` | `--export-notes` | Export notes to PDF | -| `exportNotesPages` | boolean | `pkg/modules/libreoffice/formfield.go:ExportNotesPages` | `--export-notes-pages` | Export notes pages (Impress) | -| `exportOnlyNotesPages` | boolean | `pkg/modules/libreoffice/formfield.go:ExportOnlyNotesPages` | `--export-only-notes-pages` | Export only notes pages | -| `exportNotesInMargin` | boolean | `pkg/modules/libreoffice/formfield.go:ExportNotesInMargin` | `--export-notes-in-margin` | Export notes in margin | - -### 4. Advanced Options - -| Field | Type | Gotenberg Source | LibreOffice Flag | Description | -|-------|------|------------------|-------------------|-------------| -| `convertOooTargetToPdfTarget` | boolean | `pkg/modules/libreoffice/formfield.go:ConvertOooTargetToPdfTarget` | `--convert-ooo-target-to-pdf-target` | Convert .od* links to .pdf | -| `exportLinksRelativeFsys` | boolean | `pkg/modules/libreoffice/formfield.go:ExportLinksRelativeFsys` | `--export-links-relative-fsys` | Export links as relative | -| `exportHiddenSlides` | boolean | `pkg/modules/libreoffice/formfield.go:ExportHiddenSlides` | `--export-hidden-slides` | Export hidden slides (Impress) | -| `skipEmptyPages` | boolean | `pkg/modules/libreoffice/formfield.go:SkipEmptyPages` | `--skip-empty-pages` | Suppress empty pages | -| `addOriginalDocumentAsStream` | boolean | `pkg/modules/libreoffice/formfield.go:AddOriginalDocumentAsStream` | `--add-original-document-as-stream` | Add source doc as stream | -| `singlePageSheets` | boolean | `pkg/modules/libreoffice/formfield.go:SinglePageSheets` | `--single-page-sheets` | Single page sheets | -| `losslessImageCompression` | boolean | `pkg/modules/libreoffice/formfield.go:LosslessImageCompression` | `--lossless-image-compression` | Use lossless compression | -| `reduceImageResolution` | boolean | `pkg/modules/libreoffice/formfield.go:ReduceImageResolution` | `--reduce-image-resolution` | Reduce image resolution | - -### 5. Native Watermarks (LibreOffice-side) - -| Field | Type | Gotenberg Source | LibreOffice Flag | Description | -|-------|------|------------------|-------------------|-------------| -| `nativeWatermarkText` | string | `pkg/modules/libreoffice/formfield.go:NativeWatermarkText` | `--watermark-text` | Watermark text | -| `nativeWatermarkColor` | integer | `pkg/modules/libreoffice/formfield.go:NativeWatermarkColor` | `--watermark-color` | RGB color (0xRRGGBB) | -| `nativeWatermarkFontHeight` | integer | `pkg/modules/libreoffice/formfield.go:NativeWatermarkFontHeight` | `--watermark-font-height` | Font height in points | -| `nativeWatermarkRotateAngle` | integer | `pkg/modules/libreoffice/formfield.go:NativeWatermarkRotateAngle` | `--watermark-rotate-angle` | Rotation angle (degrees) | -| `nativeWatermarkFontName` | string | `pkg/modules/libreoffice/formfield.go:NativeWatermarkFontName` | `--watermark-font-name` | Font name | -| `nativeTiledWatermarkText` | string | `pkg/modules/libreoffice/formfield.go:NativeTiledWatermarkText` | `--tiled-watermark-text` | Tiled watermark text | - -### 6. PDF Viewer Preferences (Gotenberg 8.29.0+) - -| Field | Type | Gotenberg Source | Description | -|-------|------|------------------|-------------| -| `initialView` | integer | `pkg/modules/libreoffice/formfield.go:InitialView` | Initial view mode (0=Default, 1=Bookmarks, 2=Thumbnails, 3=Layers) | -| `initialPage` | integer | `pkg/modules/libreoffice/formfield.go:InitialPage` | Initial page number (1-indexed) | -| `magnification` | integer | `pkg/modules/libreoffice/formfield.go:Magnification` | Magnification level (0=Default, 1=Fit width, 2=Fit page, 3=10-400%) | -| `zoom` | integer | `pkg/modules/libreoffice/formfield.go:Zoom` | Zoom level (percentage) | -| `pageLayout` | integer | `pkg/modules/libreoffice/formfield.go:PageLayout` | Page layout (0=Default, 1=Single page, 2=Continuous, 3=Facing, 4=Continuous facing) | -| `firstPageOnLeft` | boolean | `pkg/modules/libreoffice/formfield.go:FirstPageOnLeft` | First page on left | -| `resizeWindowToInitialPage` | boolean | `pkg/modules/libreoffice/formfield.go:ResizeWindowToInitialPage` | Resize to initial page | -| `centerWindow` | boolean | `pkg/modules/libreoffice/formfield.go:CenterWindow` | Center window | -| `openInFullScreenMode` | boolean | `pkg/modules/libreoffice/formfield.go:OpenInFullScreenMode` | Open fullscreen | -| `displayPDFDocumentTitle` | boolean | `pkg/modules/libreoffice/formfield.go:DisplayPDFDocumentTitle` | Display document title | -| `hideViewerMenubar` | boolean | `pkg/modules/libreoffice/formfield.go:HideViewerMenubar` | Hide menu bar | -| `hideViewerToolbar` | boolean | `pkg/modules/libreoffice/formfield.go:HideViewerToolbar` | Hide toolbar | -| `hideViewerWindowControls` | boolean | `pkg/modules/libreoffice/formfield.go:HideViewerWindowControls` | Hide window controls | -| `useTransitionEffects` | boolean | `pkg/modules/libreoffice/formfield.go:UseTransitionEffects` | Use transition effects | -| `openBookmarkLevels` | integer | `pkg/modules/libreoffice/formfield.go:OpenBookmarkLevels` | Open bookmark levels (0=none, 1+=expand N levels) | - -## Implementation - -### 1. Extend `OfficeOptions` in `crates/engine/src/libreoffice/mod.rs` - -```rust -pub struct OfficeOptions { - // ... existing fields ... - - // Bookmarks & Index - pub export_bookmarks: bool, - pub export_bookmarks_to_pdf_destination: bool, - pub update_indexes: bool, - pub auto_index_bookmarks: bool, - pub bookmarks: Option<Vec<Bookmark>>, - - // Form Fields - pub export_form_fields: bool, - pub allow_duplicate_field_names: bool, - pub export_placeholders: bool, - - // Notes - pub export_notes: bool, - pub export_notes_pages: bool, - pub export_only_notes_pages: bool, - pub export_notes_in_margin: bool, - - // Advanced - pub convert_ooo_target_to_pdf_target: bool, - pub export_links_relative_fsys: bool, - pub export_hidden_slides: bool, - pub skip_empty_pages: bool, - pub add_original_document_as_stream: bool, - pub single_page_sheets: bool, - pub lossless_image_compression: bool, - pub reduce_image_resolution: bool, - - // Native Watermarks - pub native_watermark_text: Option<String>, - pub native_watermark_color: Option<u32>, // RGB as 0xRRGGBB - pub native_watermark_font_height: Option<u32>, - pub native_watermark_rotate_angle: Option<i32>, - pub native_watermark_font_name: Option<String>, - pub native_tiled_watermark_text: Option<String>, - - // PDF Viewer Preferences - pub initial_view: Option<i32>, - pub initial_page: Option<i32>, - pub magnification: Option<i32>, - pub zoom: Option<i32>, - pub page_layout: Option<i32>, - pub first_page_on_left: bool, - pub resize_window_to_initial_page: bool, - pub center_window: bool, - pub open_in_full_screen_mode: bool, - pub display_pdf_document_title: bool, - pub hide_viewer_menubar: bool, - pub hide_viewer_toolbar: bool, - pub hide_viewer_window_controls: bool, - pub use_transition_effects: bool, - pub open_bookmark_levels: Option<i32>, -} -``` - -### 2. Build LibreOffice Command Args - -```rust -impl OfficeOptions { - pub fn to_libreoffice_args(&self) -> Vec<String> { - let mut args = Vec::new(); - - // Bookmarks - if self.export_bookmarks { - args.push("--export-bookmarks".into()); - } - if self.export_bookmarks_to_pdf_destination { - args.push("--export-bookmarks-to-pdf-destination".into()); - } - if self.update_indexes { - args.push("--update-indexes".into()); - } - - // Form Fields - if self.export_form_fields { - args.push("--export-form-fields".into()); - } - if self.allow_duplicate_field_names { - args.push("--allow-duplicate-field-names".into()); - } - if self.export_placeholders { - args.push("--export-placeholders".into()); - } - - // Notes - if self.export_notes { - args.push("--export-notes".into()); - } - if self.export_notes_pages { - args.push("--export-notes-pages".into()); - } - if self.export_only_notes_pages { - args.push("--export-only-notes-pages".into()); - } - if self.export_notes_in_margin { - args.push("--export-notes-in-margin".into()); - } - - // Advanced - if self.convert_ooo_target_to_pdf_target { - args.push("--convert-ooo-target-to-pdf-target".into()); - } - if self.export_links_relative_fsys { - args.push("--export-links-relative-fsys".into()); - } - if self.export_hidden_slides { - args.push("--export-hidden-slides".into()); - } - if self.skip_empty_pages { - args.push("--skip-empty-pages".into()); - } - if self.add_original_document_as_stream { - args.push("--add-original-document-as-stream".into()); - } - if self.single_page_sheets { - args.push("--single-page-sheets".into()); - } - if self.lossless_image_compression { - args.push("--lossless-image-compression".into()); - } - if self.reduce_image_resolution { - args.push("--reduce-image-resolution".into()); - } - - // Native Watermarks - if let Some(ref text) = self.native_watermark_text { - args.push(format!("--watermark-text={}", text)); - } - if let Some(color) = self.native_watermark_color { - args.push(format!("--watermark-color={}", color)); - } - // ... etc. - - // Viewer Preferences - if let Some(view) = self.initial_view { - args.push(format!("--initial-view={}", view)); - } - // ... etc. - - args - } -} -``` - -### 3. Form Field Parsing in `crates/server/src/routes/libreoffice.rs` - -```rust -// Parse all new fields from form data: -if let Some(val) = form.get("exportBookmarks") { - opts.export_bookmarks = val == "true"; -} - -if let Some(json) = form.get("bookmarks") { - opts.bookmarks = serde_json::from_str(json).ok(); -} - -// ... parse all 30+ fields -``` - -## References to Gotenberg Source - -| Feature | Gotenberg File | Line Numbers | -|---------|------------------|-------------| -| All form fields | `pkg/modules/libreoffice/formfield.go` | Full file (300+ lines) | -| Command arg building | `pkg/modules/libreoffice/libreoffice.go` | ~L100-200 | -| Viewer preferences | `pkg/modules/libreoffice/formfield.go` | ~L200-300 | - -To read Gotenberg source: -```bash -cd /Users/__deesh_reddy__/projects/personal_git/rust_builds/folio/gotenberg -cat pkg/modules/libreoffice/formfield.go | grep -A3 "ExportBookmarks" -``` - -## Expected Behavior - -### Bookmarks -- `exportBookmarks=true` → PDF has outline/bookmarks panel open -- `bookmarks` JSON → Custom bookmark tree with page offsets -- `autoIndexBookmarks=true` → Auto-generate bookmarks when merging - -### Form Fields -- `exportFormFields=true` → PDF has interactive form widgets -- `allowDuplicateFieldNames=true` → Allow duplicate field names in forms - -### Notes -- `exportNotes=true` → Writer notes exported to PDF -- `exportNotesPages=true` → Impress notes pages included -- `exportNotesInMargin=true` → Notes appear in margin - -### Viewer Preferences -- `initialView=1` → Open with bookmarks panel visible -- `zoom=150` → Default zoom level 150% -- `openInFullScreenMode=true` → Open in fullscreen -- `hideViewerToolbar=true` → Hide toolbar - -## Test Plan - -### Unit Tests - -- `parse_export_bookmarks_from_form` -- `parse_native_watermark_text` -- `parse_viewer_preferences_all_fields` -- `bookmarks_json_deserializes_correctly` - -### Integration Tests - -- `export_bookmarks_creates_outline` -- `native_watermark_appears_in_pdf` -- `viewer_preference_initial_view` -- `export_notes_pages_impress` - -## Acceptance - -- [ ] `OfficeOptions` extended with all 30+ fields -- [ ] Form field parsing in `libreoffice.rs` route -- [ ] LibreOffice command args built correctly -- [ ] Unit tests for all parsers -- [ ] Integration tests for key features -- [ ] `cargo clippy -p engine -- -D warnings` clean - -## References - -- Gotenberg LibreOffice form fields: `/Users/__deesh_reddy__/projects/personal_git/rust_builds/folio/gotenberg/pkg/modules/libreoffice/formfield.go` -- LibreOffice CLI options: https://help.libreoffice.org/latest/en-US/text/shared/guide/pdf_params.html -- PDF viewer preferences: PDF spec ISO 32000-2, clause 12.3 diff --git a/docs/specs/38-pdfengines-backends.md b/docs/specs/38-pdfengines-backends.md deleted file mode 100644 index 6047e96..0000000 --- a/docs/specs/38-pdfengines-backends.md +++ /dev/null @@ -1,295 +0,0 @@ -# Spec 38 — PDF Engine Backends - -> Support multiple PDF engine backends (QPDF, PDFCPU, pdftk) -> for different operations. Gotenberg allows selecting which backend -> to use for merge, split, flatten, etc. - -## Goal - -Implement support for multiple PDF engine backends, allowing -operators to choose the best tool for each operation. -Matches Gotenberg's `--pdfengines-*-engines` flags. - -## Scope - -**In:** - -- Configurable backends per operation type: - - Merge engines: QPDF, PDFCPU, pdftk - - Split engines: QPDF, PDFCPU - - Flatten engines: QPDF, PDFCPU, pdftk - - Convert engines: QPDF (PDF/A) - - Encrypt engines: QPDF, pdftk - - Metadata engines: QPDF, pdftk - - Bookmark engines: QPDF, pdftk - - Watermark engines: PDFCPU, pdftk - - Stamp engines: PDFCPU, pdftk - - Rotate engines: QPDF, pdftk - -**Out:** - -- Auto-detection of available backends -- Fallback to lopdf when no external tool available -- Custom backends via plugin system - -## Configuration Flags - -| Flag | Env Variable | Gotenberg Source | Description | -|------|-------------|------------------|-------------| -| `--pdfengines-merge-engines` | `PDFENGINES_MERGE_ENGINES` | `pkg/modules/pdfengines/config.go:MergeEngines` | Comma-separated list (qpdf,pdfcpu,pdftk) | -| `--pdfengines-split-engines` | `PDFENGINES_SPLIT_ENGINES` | `pkg/modules/pdfengines/config.go:SplitEngines` | Comma-separated list | -| `--pdfengines-flatten-engines` | `PDFENGINES_FLATTEN_ENGINES` | `pkg/modules/pdfengines/config.go:FlattenEngines` | Comma-separated list | -| `--pdfengines-convert-engines` | `PDFENGINES_CONVERT_ENGINES` | `pkg/modules/pdfengines/config.go:ConvertEngines` | Usually just qpdf | -| `--pdfengines-read-metadata-engines` | `PDFENGINES_READ_METADATA_ENGINES` | `pkg/modules/pdfengines/config.go:ReadMetadataEngines` | QPDF, pdftk | -| `--pdfengines-write-metadata-engines` | `PDFENGINES_WRITE_METADATA_ENGINES` | `pkg/modules/pdfengines/config.go:WriteMetadataEngines` | QPDF, pdftk | -| `--pdfengines-encrypt-engines` | `PDFENGINES_ENCRYPT_ENGINES` | `pkg/modules/pdfengines/config.go:EncryptEngines` | QPDF, pdftk | -| `--pdfengines-decrypt-engines` | `PDFENGINES_DECRYPT_ENGINES` | `pkg/modules/pdfengines/config.go:DecryptEngines` | QPDF, pdftk | -| `--pdfengines-embed-engines` | `PDFENGINES_EMBED_ENGINES` | `pkg/modules/pdfengines/config.go:EmbedEngines` | QPDF | -| `--pdfengines-read-bookmarks-engines` | `PDFENGINES_READ_BOOKMARKS_ENGINES` | `pkg/modules/pdfengines/config.go:ReadBookmarksEngines` | QPDF, pdftk | -| `--pdfengines-write-bookmarks-engines` | `PDFENGINES_WRITE_BOOKMARKS_ENGINES` | `pkg/modules/pdfengines/config.go:WriteBookmarksEngines` | QPDF, pdftk | -| `--pdfengines-watermark-engines` | `PDFENGINES_WATERMARK_ENGINES` | `pkg/modules/pdfengines/config.go:WatermarkEngines` | PDFCPU, pdftk | -| `--pdfengines-stamp-engines` | `PDFENGINES_STAMP_ENGINES` | `pkg/modules/pdfengines/config.go:StampEngines` | PDFCPU, pdftk | -| `--pdfengines-rotate-engines` | `PDFENGINES_ROTATE_ENGINES` | `pkg/modules/pdfengines/config.go:RotateEngines` | QPDF, pdftk | - -## Engine Capabilities Matrix - -| Operation | QPDF | PDFCPU | pdftk | lopdf (Folio native) | -|-----------|------|--------|-------|---------------------| -| Merge | ✅ | ✅ | ✅ | ✅ | -| Split | ✅ | ✅ | ❌ | ✅ | -| Flatten | ✅ | ✅ | ✅ | ✅ | -| PDF/A Convert | ✅ | ❌ | ❌ | Partial | -| Encrypt | ✅ | ❌ | ✅ | ✅ | -| Decrypt | ✅ | ❌ | ✅ | ✅ | -| Read Metadata | ✅ | ❌ | ✅ | ✅ | -| Write Metadata | ✅ | ❌ | ✅ | ✅ | -| Read Bookmarks | ✅ | ❌ | ✅ | ✅ | -| Write Bookmarks | ✅ | ❌ | ✅ | ✅ | -| Watermark | ❌ | ✅ | ✅ | ✅ | -| Stamp | ❌ | ✅ | ✅ | ✅ | -| Rotate | ✅ | ❌ | ✅ | ✅ | -| Embed Files | ✅ | ❌ | ❌ | ✅ | - -## Implementation - -### 1. Enum for Engine Type - -```rust -// crates/engine/src/pdfops/mod.rs - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PdfEngineType { - Qpdf, - PdfCpu, - PdfTk, - LoPdf, // Folio native -} - -impl PdfEngineType { - pub fn from_str(s: &str) -> Option<Self> { - match s.to_lowercase().as_str() { - "qpdf" => Some(Self::Qpdf), - "pdfcpu" => Some(Self::PdfCpu), - "pdftk" => Some(Self::PdfTk), - "lopdf" => Some(Self::LoPdf), - _ => None, - } - } - - pub fn binary_name(&self) -> &'static str { - match self { - Self::Qpdf => "qpdf", - Self::PdfCpu => "pdfcpu", - Self::PdfTk => "pdftk", - Self::LoPdf => "lopdf (built-in)", - } - } - - pub fn is_available(&self) -> bool { - match self { - Self::LoPdf => true, // Always available - _ => which::which(self.binary_name()).is_ok(), - } - } -} -``` - -### 2. Configuration Struct - -```rust -// crates/server/src/config.rs - -pub struct PdfEnginesConfig { - pub merge_engines: Vec<PdfEngineType>, - pub split_engines: Vec<PdfEngineType>, - pub flatten_engines: Vec<PdfEngineType>, - pub convert_engines: Vec<PdfEngineType>, - pub read_metadata_engines: Vec<PdfEngineType>, - pub write_metadata_engines: Vec<PdfEngineType>, - pub encrypt_engines: Vec<PdfEngineType>, - pub decrypt_engines: Vec<PdfEngineType>, - pub embed_engines: Vec<PdfEngineType>, - pub read_bookmarks_engines: Vec<PdfEngineType>, - pub write_bookmarks_engines: Vec<PdfEngineType>, - pub watermark_engines: Vec<PdfEngineType>, - pub stamp_engines: Vec<PdfEngineType>, - pub rotate_engines: Vec<PdfEngineType>, -} - -impl Default for PdfEnginesConfig { - fn default() -> Self { - Self { - merge_engines: vec![PdfEngineType::Qpdf, PdfEngineType::PdfCpu, PdfEngineType::PdfTk], - split_engines: vec![PdfEngineType::Qpdf, PdfEngineType::PdfCpu], - // ... etc. - } - } -} -``` - -### 3. Engine Selection Logic - -```rust -// crates/engine/src/pdfops/mod.rs - -pub struct PdfOps { - config: PdfEnginesConfig, -} - -impl PdfOps { - /// Select first available engine for operation. - fn select_engine(&self, engines: &[PdfEngineType]) -> Option<PdfEngineType> { - engines.iter() - .find(|e| e.is_available()) - .copied() - } - - pub fn merge(&self, inputs: &[PathBuf]) -> Result<Vec<u8>, EngineError> { - let engine = self.select_engine(&self.config.merge_engines) - .ok_or_else(|| EngineError::Internal("No merge engine available".into()))?; - - match engine { - PdfEngineType::Qpdf => self.merge_qpdf(inputs), - PdfEngineType::PdfCpu => self.merge_pdfcpu(inputs), - PdfEngineType::PdfTk => self.merge_pdftk(inputs), - PdfEngineType::LoPdf => self.merge_lopdf(inputs), - } - } - - fn merge_qpdf(&self, inputs: &[PathBuf]) -> Result<Vec<u8>, EngineError> { - let mut cmd = std::process::Command::new("qpdf"); - cmd.arg("--empty").arg("output.pdf"); - - for input in inputs { - cmd.arg("--pages").arg(input).arg("1-z").arg("--"); - } - - // ... execute command - todo!() - } - - fn merge_pdfcpu(&self, inputs: &[PathBuf]) -> Result<Vec<u8>, EngineError> { - let mut cmd = std::process::Command::new("pdfcpu"); - cmd.arg("import"); - - for input in inputs { - cmd.arg(input); - } - - // ... execute command - todo!() - } -} -``` - -### 4. CLI Flags Parsing - -```rust -// crates/server/src/config.rs - -impl ServerConfig { - fn parse_pdfengines_args(args: &Args) -> PdfEnginesConfig { - let parse_engines = |arg: Option<&str>| { - arg.unwrap_or("") - .split(',') - .filter_map(PdfEngineType::from_str) - .collect::<Vec<_>>() - }; - - PdfEnginesConfig { - merge_engines: parse_engines(args.value_of("pdfengines-merge-engines")), - // ... parse all 14 engine lists - } - } -} -``` - -## References to Gotenberg Source - -| Feature | Gotenberg File | Line Numbers | -|---------|------------------|-------------| -| Engine config struct | `pkg/modules/pdfengines/config.go` | Full file (~100 lines) | -| Engine selection | `pkg/modules/pdfengines/pdfengines.go` | ~L200-300 | -| QPDF wrapper | `pkg/modules/pdfengines/qpdf.go` | Full file | -| PDFCPU wrapper | `pkg/modules/pdfengines/pdfcpu.go` | Full file | -| pdftk wrapper | `pkg/modules/pdfengines/pdftk.go` | Full file | - -To read Gotenberg source: -```bash -cd /Users/__deesh_reddy__/projects/personal_git/rust_builds/folio/gotenberg -cat pkg/modules/pdfengines/config.go | grep -A3 "MergeEngines" -``` - -## Expected Behavior - -### Engine Priority -1. Try first engine in list -2. If not available (not installed), try next -3. If none available, return error - -### Default Behavior (No Flags) -- Use all available engines in order: qpdf, pdfcpu, pdftk, lopdf - -### Custom Engine Selection -```bash -# Use only QPDF for merge (fast, reliable) ---pdfengines-merge-engines=qpdf - -# Try PDFCPU first, fallback to pdftk ---pdfengines-split-engines=pdfcpu,pdftk -``` - -## Test Plan - -### Unit Tests - -- `engine_type_from_str_parses_correctly` -- `engine_type_is_available_qpdf_installed` -- `select_engine_returns_first_available` -- `select_engine_falls_back_to_next` - -### Integration Tests - -- `merge_uses_qpdf_when_available` -- `merge_falls_back_to_pdfcpu` -- `merge_uses_lopdf_as_last_resort` - -## Acceptance - -- [ ] `PdfEngineType` enum with all 4 types -- [ ] `PdfEnginesConfig` with 14 engine lists -- [ ] CLI flags for all engine selections -- [ ] Engine selection logic with fallback -- [ ] QPDF wrapper for merge/split/encrypt -- [ ] PDFCPU wrapper for merge/split/watermark -- [ ] pdftk wrapper for merge/encrypt/bookmarks -- [ ] Unit tests for engine selection -- [ ] Integration tests with real tools -- [ ] `cargo clippy -p engine -- -D warnings` clean - -## References - -- Gotenberg PDF engines: `/Users/__deesh_reddy__/projects/personal_git/rust_builds/folio/gotenberg/pkg/modules/pdfengines/` -- QPDF documentation: https://qpdf.readthedocs.io/ -- PDFCPU documentation: https://pdfcpu.io/ -- pdftk documentation: https://www.pdftk.com/ diff --git a/docs/specs/39-config-flags.md b/docs/specs/39-config-flags.md deleted file mode 100644 index ece866c..0000000 --- a/docs/specs/39-config-flags.md +++ /dev/null @@ -1,276 +0,0 @@ -# Spec 39 — Configuration CLI Flags - -> Comprehensive list of CLI flags and environment variables -> that Gotenberg supports but Folio is missing. These control -> Chromium, LibreOffice, API server, and PDF engine behavior. - -## Goal - -Implement all missing CLI flags and environment variables to achieve -full configuration parity with Gotenberg. - -## Scope - -**In:** - -All missing CLI flags from Gotenberg: - -### Chromium Options (16 flags) - -| Flag | Env Variable | Gotenberg Source | Default | Description | -|------|-------------|------------------|---------|-------------| -| `--chromium-restart-after` | `CHROMIUM_RESTART_AFTER` | `pkg/modules/chromium/config.go:RestartAfter` | 0 (never) | Restart after N conversions | -| `--chromium-max-queue-size` | `CHROMIUM_MAX_QUEUE_SIZE` | `pkg/modules/chromium/config.go:MaxQueueSize` | 0 (unlimited) | Max queue size | -| `--chromium-max-concurrency` | `CHROMIUM_MAX_CONCURRENCY` | `pkg/modules/chromium/config.go:MaxConcurrency` | NumCPUs | Max concurrent renders | -| `--chromium-auto-start` | `CHROMIUM_AUTO_START` | `pkg/modules/chromium/config.go:AutoStart` | true | Auto-start Chromium | -| `--chromium-start-timeout` | `CHROMIUM_START_TIMEOUT` | `pkg/modules/chromium/config.go:StartTimeout` | 20s | Start timeout | -| `--chromium-allow-list` | `CHROMIUM_ALLOW_LIST` | `pkg/modules/chromium/config.go:AllowList` | (none) | Allowed URL patterns (regex) | -| `--chromium-deny-list` | `CHROMIUM_DENY_LIST` | `pkg/modules/chromium/config.go:DenyList` | (none) | Denied URL patterns (regex) | -| `--chromium-clear-cache` | `CHROMIUM_CLEAR_CACHE` | `pkg/modules/chromium/config.go:ClearCache` | false | Clear cache on restart | -| `--chromium-clear-cookies` | `CHROMIUM_CLEAR_COOKIES` | `pkg/modules/chromium/config.go:ClearCookies` | false | Clear cookies on restart | -| `--chromium-disable-javascript` | `CHROMIUM_DISABLE_JAVASCRIPT` | `pkg/modules/chromium/config.go:DisableJavascript` | false | Disable JavaScript | -| `--chromium-allow-insecure-localhost` | `CHROMIUM_ALLOW_INSECURE_LOCALHOST` | `pkg/modules/chromium/config.go:AllowInsecureLocalhost` | false | Allow insecure localhost | -| `--chromium-ignore-certificate-errors` | `CHROMIUM_IGNORE_CERTIFICATE_ERRORS` | `pkg/modules/chromium/config.go:IgnoreCertificateErrors` | false | Ignore cert errors | -| `--chromium-disable-web-security` | `CHROMIUM_DISABLE_WEB_SECURITY` | `pkg/modules/chromium/config.go:DisableWebSecurity` | false | Disable web security | -| `--chromium-allow-file-access-from-files` | `CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES` | `pkg/modules/chromium/config.go:AllowFileAccessFromFile` | false | Allow file access | -| `--chromium-host-resolver-rules` | `CHROMIUM_HOST_RESOLVER_RULES` | `pkg/modules/chromium/config.go:HostResolverRules` | (none) | Custom DNS rules | -| `--chromium-proxy-server` | `CHROMIUM_PROXY_SERVER` | `pkg/modules/chromium/config.go:ProxyServer` | (none) | Proxy server | -| `--chromium-idle-shutdown-timeout` | `CHROMIUM_IDLE_SHUTDOWN_TIMEOUT` | `pkg/modules/chromium/config.go:IdleShutdownTimeout` | 0 (disabled) | Idle shutdown timeout | - -### LibreOffice Options (6 flags) - -| Flag | Env Variable | Gotenberg Source | Default | Description | -|------|-------------|------------------|---------|-------------| -| `--libreoffice-restart-after` | `LIBREOFFICE_RESTART_AFTER` | `pkg/modules/libreoffice/config.go:RestartAfter` | 0 (never) | Restart after N conversions | -| `--libreoffice-max-queue-size` | `LIBREOFFICE_MAX_QUEUE_SIZE` | `pkg/modules/libreoffice/config.go:MaxQueueSize` | 0 (unlimited) | Max queue size | -| `--libreoffice-auto-start` | `LIBREOFFICE_AUTO_START` | `pkg/modules/libreoffice/config.go:AutoStart` | true | Auto-start LibreOffice | -| `--libreoffice-start-timeout` | `LIBREOFFICE_START_TIMEOUT` | `pkg/modules/libreoffice/config.go:StartTimeout` | 20s | Start timeout | -| `--libreoffice-disable-routes` | `LIBREOFFICE_DISABLE_ROUTES` | `pkg/modules/libreoffice/config.go:DisableRoutes` | false | Disable LibreOffice routes | -| `--libreoffice-idle-shutdown-timeout` | `LIBREOFFICE_IDLE_SHUTDOWN_TIMEOUT` | `pkg/modules/libreoffice/config.go:IdleShutdownTimeout` | 0 (disabled) | Idle shutdown timeout | - -### API Server Options (9 flags) - -| Flag | Env Variable | Gotenberg Source | Default | Description | -|------|-------------|------------------|---------|-------------| -| `--api-disable-health-route-telemetry` | `API_DISABLE_HEALTH_ROUTE_TELEMETRY` | `pkg/modules/api/config.go:DisableHealthRouteTelemetry` | false | Disable health telemetry | -| `--api-disable-root-route-telemetry` | `API_DISABLE_ROOT_ROUTE_TELEMETRY` | `pkg/modules/api/config.go:DisableRootRouteTelemetry` | false | Disable root telemetry | -| `--api-disable-debug-route-telemetry` | `API_DISABLE_DEBUG_ROUTE_TELEMETRY` | `pkg/modules/api/config.go:DisableDebugRouteTelemetry` | false | Disable debug telemetry | -| `--api-disable-version-route-telemetry` | `API_DISABLE_VERSION_ROUTE_TELEMETRY` | `pkg/modules/api/config.go:DisableVersionRouteTelemetry` | false | Disable version telemetry | -| `--api-enable-debug-route` | `API_ENABLE_DEBUG_ROUTE` | `pkg/modules/api/config.go:EnableDebugRoute` | false | Enable debug route | -| Basic auth username | `API_BASIC_AUTH_USERNAME` | `pkg/modules/api/config.go:BasicAuthUsername` | (none) | HTTP basic auth username | -| Basic auth password | `API_BASIC_AUTH_PASSWORD` | `pkg/modules/api/config.go:BasicAuthPassword` | (none) | HTTP basic auth password | -| TLS cert file | `API_TLS_CERT_FILE` | `pkg/modules/api/config.go:TlsCertFile` | (none) | TLS certificate file | -| TLS key file | `API_TLS_KEY_FILE` | `pkg/modules/api/config.go:TlsKeyFile` | (none) | TLS key file | - -### PDF Engines Options (14 flags) - -Already documented in spec-38, but need CLI flags: - -| Flag | Env Variable | -|------|-------------| -| `--pdfengines-disable-routes` | `PDFENGINES_DISABLE_ROUTES` | -| `--pdfengines-merge-engines` | `PDFENGINES_MERGE_ENGINES` | -| `--pdfengines-split-engines` | `PDFENGINES_SPLIT_ENGINES` | -| (14 total, see spec-38) | - -## Implementation - -### 1. Extend `BrowserConfig` in `crates/engine/src/chromium/mod.rs` - -```rust -pub struct BrowserConfig { - // ... existing fields ... - - // Supervision - pub restart_after: u32, // --chromium-restart-after - pub max_queue_size: usize, // --chromium-max-queue-size - pub max_concurrency: usize, // --chromium-max-concurrency - - // Lifecycle - pub auto_start: bool, // --chromium-auto-start - pub start_timeout: Duration, // --chromium-start-timeout - - // Security - pub allow_list: Vec<String>, // --chromium-allow-list (regex) - pub deny_list: Vec<String>, // --chromium-deny-list (regex) - pub clear_cache: bool, // --chromium-clear-cache - pub clear_cookies: bool, // --chromium-clear-cookies - pub disable_javascript: bool, // --chromium-disable-javascript - pub allow_insecure_localhost: bool, // --chromium-allow-insecure-localhost - pub ignore_certificate_errors: bool, // --chromium-ignore-certificate-errors - pub disable_web_security: bool, // --chromium-disable-web-security - pub allow_file_access_from_files: bool, // --chromium-allow-file-access-from-files - - // Network - pub host_resolver_rules: Option<String>, // --chromium-host-resolver-rules - pub proxy_server: Option<String>, // --chromium-proxy-server - - // Idle - pub idle_shutdown_timeout: Option<Duration>, // --chromium-idle-shutdown-timeout -} -``` - -### 2. Extend `LibreOfficeConfig` in `crates/engine/src/libreoffice/mod.rs` - -```rust -pub struct LibreOfficeConfig { - // ... existing fields ... - - // Supervision - pub restart_after: u32, - pub max_queue_size: usize, - - // Lifecycle - pub auto_start: bool, - pub start_timeout: Duration, - - // Routes - pub disable_routes: bool, - - // Idle - pub idle_shutdown_timeout: Option<Duration>, -} -``` - -### 3. Extend `ServerConfig` in `crates/server/src/config.rs` - -```rust -pub struct ServerConfig { - // ... existing fields ... - - // API telemetry - pub disable_health_route_telemetry: bool, - pub disable_root_route_telemetry: bool, - pub disable_debug_route_telemetry: bool, - pub disable_version_route_telemetry: bool, - pub enable_debug_route: bool, - - // Basic auth - pub basic_auth_username: Option<String>, - pub basic_auth_password: Option<String>, - - // TLS - pub tls_cert_file: Option<PathBuf>, - pub tls_key_file: Option<PathBuf>, - - // PDF engines config - pub pdfengines: PdfEnginesConfig, // from spec-38 -} -``` - -### 4. CLI Flag Definitions - -```rust -// crates/server/src/config.rs - -pub fn clap_app() -> Command { - Command::new("folio-server") - // ... existing flags ... - - // Chromium flags - .arg(Arg::new("chromium-restart-after") - .long("chromium-restart-after") - .env("CHROMIUM_RESTART_AFTER") - .default_value("0")) - .arg(Arg::new("chromium-max-queue-size") - .long("chromium-max-queue-size") - .env("CHROMIUM_MAX_QUEUE_SIZE") - .default_value("0")) - // ... all 16 chromium flags - - // LibreOffice flags - .arg(Arg::new("libreoffice-restart-after") - .long("libreoffice-restart-after") - .env("LIBREOFFICE_RESTART_AFTER") - .default_value("0")) - // ... all 6 libreoffice flags - - // API flags - .arg(Arg::new("api-disable-health-route-telemetry") - .long("api-disable-health-route-telemetry") - .env("API_DISABLE_HEALTH_ROUTE_TELEMETRY") - .action(clap::ArgAction::SetTrue)) - // ... all 9 API flags -} -``` - -## References to Gotenberg Source - -| Feature | Gotenberg File | Line Numbers | -|---------|------------------|-------------| -| Chromium config | `pkg/modules/chromium/config.go` | Full file (~150 lines) | -| LibreOffice config | `pkg/modules/libreoffice/config.go` | Full file (~80 lines) | -| API config | `pkg/modules/api/config.go` | Full file (~120 lines) | -| PDF engines config | `pkg/modules/pdfengines/config.go` | Full file (~100 lines) | - -To read Gotenberg source: -```bash -cd /Users/__deesh_reddy__/projects/personal_git/rust_builds/folio/gotenberg -cat pkg/modules/chromium/config.go | grep -A2 "RestartAfter" -``` - -## Expected Behavior - -### Flag Priority -1. CLI flag (highest priority) -2. Environment variable -3. Default value (lowest priority) - -### URL Allow/Deny Lists -```bash -# Only allow example.com and subdomains ---chromium-allow-list="^https://.*\.example\.com" - -# Deny tracking domains ---chromium-deny-list="^https://.*\.google-analytics\.com" -``` - -### Idle Shutdown -```bash -# Shutdown Chromium after 10 minutes idle ---chromium-idle-shutdown-timeout=10m - -# Disable idle shutdown ---chromium-idle-shutdown-timeout=0 -``` - -### Basic Auth -```bash -# Enable HTTP basic auth ---api-basic-auth-username=admin --api-basic-auth-password=secret -``` - -## Test Plan - -### Unit Tests - -- `chromium_restart_after_parses_correctly` -- `url_allow_list_regex_matches` -- `url_deny_list_blocks_tracking` -- `basic_auth_credentials_parsed` - -### Integration Tests - -- `idle_shutdown_stops_chromium` -- `url_allow_list_blocks_denied` -- `basic_auth_rejects_unauthorized` - -## Acceptance - -- [ ] `BrowserConfig` extended with all 16 Chromium flags -- [ ] `LibreOfficeConfig` extended with all 6 LibreOffice flags -- [ ] `ServerConfig` extended with all 9 API flags -- [ ] CLI flag parsing with env var fallback -- [ ] Flag priority: CLI > env > default -- [ ] URL allow/deny list regex matching -- [ ] Basic auth middleware -- [ ] TLS support in Axum -- [ ] Unit tests for all flag parsers -- [ ] `cargo clippy -p server -- -D warnings` clean - -## References - -- Gotenberg config files: `/Users/__deesh_reddy__/projects/personal_git/rust_builds/folio/gotenberg/pkg/modules/*/config.go` -- clap crate: https://docs.rs/clap/ -- Axum TLS: https://docs.rs/axum/latest/axum/#tls -- HTTP basic auth: https://docs.rs/axum/latest/axum/middleware/#basic-auth diff --git a/docs/specs/40-bindings-py.md b/docs/specs/40-bindings-py.md deleted file mode 100644 index 0308d22..0000000 --- a/docs/specs/40-bindings-py.md +++ /dev/null @@ -1,425 +0,0 @@ -# Spec 40 — Python bindings (`py` crate) - -> Self-contained PyO3 wrapper exposing `import folio` to Python users. -> No external HTTP service required at runtime. - -## Goal - -Allow Python users to convert HTML / URL / Markdown to PDF in-process via -the same `engine` crate the server uses, matching the README example in -`@/Users/__deesh_reddy__/projects/personal_git/rust_builds/folio/README.md:99-114`. - -## Scope - -**In:** - -- `ChromiumEngine` Python class with `html_to_pdf`, `url_to_pdf`, - `markdown_to_pdf`, `shutdown`, `healthy`. -- Exception hierarchy mapping each `EngineError` variant. -- `PdfOptions`, `RequestContext`, `BrowserConfig` exposed as Python - `@dataclass`-style classes (constructed positionally or via kwargs). -- Type stubs (`folio.pyi`) shipped with the wheel. -- Wheels built by CI for cp3.9..cp3.13 on linux-x64/aarch64, - macos-x64/arm64, win-x64. - -**Out:** - -- LibreOffice and pdfops surfaces — Python users for those use the HTTP - server today. Follow-up spec. -- Async Python (`async def`) — Python remains synchronous; we - `block_on` internally. Async support is a follow-up. -- Streaming PDF output (chunked writes) — return a single `bytes` for - MVP. - -## Public API - -### Python surface (excerpt of `folio.pyi`) - -```python -from typing import Any, Optional, Mapping, Sequence - -class FolioError(Exception): - """Base class for all engine errors raised by folio.""" - code: str # e.g. "INVALID_OPTION", "TIMEOUT", "NAVIGATION", ... - -class InvalidOptionError(FolioError): ... -class InvalidPageRangeError(FolioError): ... -class ChromeNotFoundError(FolioError): ... -class ChromeLaunchError(FolioError): ... -class CdpError(FolioError): ... -class NavigationError(FolioError): - url: str - reason: str -class TimeoutError(FolioError): ... -class IoError(FolioError): ... -class InternalError(FolioError): ... - -class PaperSize: - A4: "PaperSize" - LETTER: "PaperSize" - LEGAL: "PaperSize" - A3: "PaperSize" - A5: "PaperSize" - def __init__(self, width_in: float, height_in: float) -> None: ... - width_in: float - height_in: float - -class Margins: - ZERO: "Margins" - DEFAULT: "Margins" - @staticmethod - def uniform(inches: float) -> "Margins": ... - def __init__(self, top: float, right: float, bottom: float, left: float) -> None: ... - top: float - right: float - bottom: float - left: float - -class WaitCondition: - @staticmethod - def load() -> "WaitCondition": ... - @staticmethod - def dom_content_loaded() -> "WaitCondition": ... - @staticmethod - def network_idle() -> "WaitCondition": ... - @staticmethod - def selector(css: str) -> "WaitCondition": ... - @staticmethod - def expression(js: str) -> "WaitCondition": ... - @staticmethod - def delay(seconds: float) -> "WaitCondition": ... - -class PdfOptions: - def __init__( - self, *, - paper: PaperSize = ..., - margin: Margins = ..., - landscape: bool = False, - scale: float = 1.0, - print_background: bool = True, - prefer_css_page_size: bool = False, - emulate_media: str = "print", # "print" | "screen" - page_ranges: Optional[str] = None, - header_template: Optional[str] = None, - footer_template: Optional[str] = None, - wait: WaitCondition = ..., - ) -> None: ... - -class Cookie: - def __init__( - self, name: str, value: str, *, - domain: Optional[str] = None, - path: Optional[str] = None, - secure: bool = False, - http_only: bool = False, - ) -> None: ... - -class RequestContext: - def __init__( - self, *, - user_agent: Optional[str] = None, - extra_headers: Optional[Mapping[str, str]] = None, - cookies: Optional[Sequence[Cookie]] = None, - fail_on_status: Optional[Sequence[int]] = None, - ) -> None: ... - -class BrowserConfig: - def __init__( - self, *, - executable: Optional[str] = None, - headless: bool = True, - extra_args: Sequence[str] = (), - no_sandbox: Optional[bool] = None, # None = platform default - timeout_secs: float = 60.0, - ) -> None: ... - -class ChromiumEngine: - def __init__(self, config: Optional[BrowserConfig] = None) -> None: ... - - def html_to_pdf( - self, html: str, *, - base_url: Optional[str] = None, - options: Optional[PdfOptions] = None, - request: Optional[RequestContext] = None, - ) -> bytes: ... - - def url_to_pdf( - self, url: str, *, - options: Optional[PdfOptions] = None, - request: Optional[RequestContext] = None, - ) -> bytes: ... - - def markdown_to_pdf( - self, markdown: str, *, - options: Optional[PdfOptions] = None, - request: Optional[RequestContext] = None, - ) -> bytes: ... - - def healthy(self) -> bool: ... - - def shutdown(self) -> None: ... - - # Context manager support (calls shutdown on exit): - def __enter__(self) -> "ChromiumEngine": ... - def __exit__(self, *exc_info: Any) -> None: ... - -__version__: str -``` - -### Rust surface (`crates/py/src/lib.rs`) - -```rust -use pyo3::prelude::*; - -#[pymodule] -fn folio(py: Python<'_>, m: &PyModule) -> PyResult<()> { - m.add("__version__", env!("CARGO_PKG_VERSION"))?; - m.add_class::<py_types::PaperSize>()?; - m.add_class::<py_types::Margins>()?; - m.add_class::<py_types::WaitCondition>()?; - m.add_class::<py_types::PdfOptions>()?; - m.add_class::<py_types::Cookie>()?; - m.add_class::<py_types::RequestContext>()?; - m.add_class::<py_types::BrowserConfig>()?; - m.add_class::<py_engine::ChromiumEngine>()?; - py_errors::register(py, m)?; - Ok(()) -} -``` - -Internal modules: - -- `py_types` — `#[pyclass]` wrappers around the engine's value types. -- `py_engine::ChromiumEngine` — wraps `Arc<engine::ChromiumEngine>` and a - shared `tokio::runtime::Runtime`. -- `py_errors` — defines and registers the exception hierarchy. - -## Behavior - -### Runtime ownership - -A single multi-thread tokio runtime is built lazily on first use and -reused across all engines in the process: - -```rust -static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new(); -fn rt() -> &'static tokio::runtime::Runtime { - RUNTIME.get_or_init(|| { - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .thread_name("folio-py") - .build() - .expect("tokio runtime build") - }) -} -``` - -Rationale: PyO3 modules are loaded once per process, so a `OnceLock` is -the standard idiom; multiple `ChromiumEngine` instances all share the -runtime. - -### `ChromiumEngine.__init__` - -1. Resolve config: `config or BrowserConfig()`. -2. Convert to `engine::types::BrowserConfig`. -3. `rt().block_on(engine::ChromiumEngine::launch_with(cfg))`. -4. Store `Arc<engine::ChromiumEngine>` inside the `#[pyclass]`. - -### `html_to_pdf` / `url_to_pdf` / `markdown_to_pdf` - -```rust -fn html_to_pdf( - &self, - py: Python<'_>, - html: &str, - base_url: Option<&str>, - options: Option<&PdfOptions>, - request: Option<&RequestContext>, -) -> PyResult<Py<PyBytes>> { - let opts = options.map(|o| o.to_native()).unwrap_or_default(); - let req = request.map(|r| r.to_native()).unwrap_or_default(); - let engine = self.inner.clone(); - let html_owned = html.to_owned(); - let base = base_url.map(str::to_owned); - - py.allow_threads(|| { - rt().block_on(async move { - engine.html_to_pdf(&html_owned, base.as_deref(), &opts, &req).await - }) - }) - .map_err(into_py_err) - .map(|bytes| PyBytes::new(py, &bytes).into()) -} -``` - -Critical points: - -- `Python::allow_threads` releases the GIL during the async work. -- All inputs cloned into owned `String`s so the closure is `Send`. -- Output `Vec<u8>` re-acquires the GIL and is wrapped in `PyBytes` - (`PyBytes::new` copies; that's acceptable in MVP). - -### `markdown_to_pdf` - -Same pattern as `html_to_pdf` but no `base_url` parameter. - -### `healthy()` - -`rt().block_on(self.inner.healthy())`. Holds the GIL across the call — -acceptable since `healthy` is bounded by `BrowserConfig::timeout`. - -### `shutdown()` and context manager - -- `shutdown` is idempotent. After the first successful call, subsequent - calls raise nothing. -- `__exit__` calls `shutdown` and never re-raises engine errors when - another exception is already in flight (logs at `warn` instead). - -### Error mapping - -All `EngineError`s convert to a corresponding Python exception. Each -exception class: - -- Inherits from `FolioError`. -- Carries a string `code` attribute equal to the variant name (e.g. - `"INVALID_OPTION"`). -- Preserves source-chain text in `__cause__` via - `PyErr::set_cause` when the engine error has a `source()`. - -Mapping table: - -| `EngineError` | Python class | Extra attributes | -|------------------------------|---------------------------|-------------------| -| `InvalidOption` | `InvalidOptionError` | — | -| `InvalidPageRange` | `InvalidPageRangeError` | — | -| `ChromeNotFound { searched }`| `ChromeNotFoundError` | `searched: list[str]` | -| `ChromeLaunch(msg)` | `ChromeLaunchError` | — | -| `Cdp(msg)` | `CdpError` | — | -| `Navigation { url, reason }` | `NavigationError` | `url`, `reason` | -| `Timeout(d)` | `TimeoutError` | `seconds: float` | -| `Io(_)` | `IoError` | — | -| `Internal(msg)` | `InternalError` | — | - -Note: `folio.TimeoutError` shadows Python's builtin name *only* inside -the `folio` module's namespace; users who do `from folio import -TimeoutError` accept that. The class is importable as -`folio.TimeoutError`. - -### Python type conversion - -| Engine Rust type | Python wrapper | Conversion | -|------------------------|---------------------------------|-----------------------| -| `PaperSize` | `PaperSize` `#[pyclass(frozen)]`| `to_native` cheap copy | -| `Margins` | `Margins` | same | -| `WaitCondition` | tagged enum mirrored in Python | factory functions | -| `MediaType` | string ("print"/"screen") | parsed in `PdfOptions::__init__` | -| `PageRanges` | `Optional[str]` | parsed via spec 10's `PageRanges::parse` and re-stringified | -| `Cookie` | `Cookie` | direct field copy | -| `RequestContext` | `RequestContext` | dict-like | -| `BrowserConfig` | `BrowserConfig` | direct | - -Wrapper types implement `__repr__` returning a stable form like -`PaperSize(width_in=8.27, height_in=11.69)` and `__eq__` based on -field equality. They are NOT mutable from Python (`#[pyclass(frozen)]`). - -### Threading - -- Python instances are safe to share across threads (the wrapped - `Arc<ChromiumEngine>` is `Sync`). -- The wrapper class is annotated with `#[pyclass(unsendable = false)]` - and asserted via `static_assertions::assert_impl_all!`. - -### Cleanup - -- `__del__` is **not** implemented (avoids the GIL/destructor pitfall). -- `__exit__` covers the deterministic-cleanup path. -- If a `ChromiumEngine` is dropped without `shutdown`, the underlying - Chrome process exits when the last `Arc` clone drops (chromiumoxide - semantics). A `tracing::warn!` records this. - -## Errors - -Every public Python method only raises subclasses of `FolioError`, -`TypeError` (for misused kwargs caught by PyO3 type extraction), or -`ValueError` (for `PaperSize.__init__` etc. failures translated from -`EngineError::InvalidOption`). - -## Edge cases - -| Scenario | Required behavior | -|--------------------------------------------------------------|--------------------------------------------------------------------| -| `ChromiumEngine()` while no Chrome is on PATH | Raises `ChromeNotFoundError(searched=[...])`. | -| `html_to_pdf("")` with default options | Returns valid PDF bytes (delegates to engine). | -| Calling `html_to_pdf` after `shutdown()` | Raises `InternalError` with the documented engine message. | -| Multiple Python threads calling concurrently | Allowed; GIL released during each call; engine handles concurrency.| -| `with ChromiumEngine(...) as e: raise RuntimeError` | `__exit__` runs shutdown but does not mask the user exception. | -| Garbage collection while a render is in flight | The wrapper holds an `Arc` so the engine is alive until the future resolves. | -| `PdfOptions(emulate_media="invalid")` | `ValueError("emulate_media must be 'print' or 'screen'")`. | -| `Cookie(name="", value="x")` | `ValueError("cookie name must not be empty")`. | -| Passing a dict where a wrapper class is expected | Allowed in MVP only for `RequestContext.extra_headers`. Other params require typed instances. | - -## Test plan - -### Rust unit tests (`crates/py/src/...`) - -- `paper_size_constants_match_engine`. -- `wait_condition_factory_round_trip`. -- `request_context_extra_headers_dict_to_native`. -- `error_conversion_table` — for each `EngineError` variant, build a - `PyErr` and assert its class name and `code` attribute. - -### Python integration tests (`crates/py/tests/test_folio.py`) - -Run via `pytest` against the built wheel (or `maturin develop`). - -Without Chrome (skipped if absent): - -- `test_module_has_version`. -- `test_paper_size_constants`. -- `test_pdf_options_kwargs_round_trip`. -- `test_invalid_emulate_media_raises_valueerror`. -- `test_chromium_engine_constructs_and_reports_chrome_not_found_when_path_unset` - (sets a bogus `LIBREOFFICE_PATH` is irrelevant; uses a bogus - `BrowserConfig(executable="/no/such")`). - -With Chrome (`pytest.mark.skipif(not has_chrome())`): - -- `test_html_to_pdf_returns_pdf_bytes` — bytes start with `b"%PDF-"`. -- `test_url_to_pdf_against_local_http_server`. -- `test_markdown_to_pdf_renders_table`. -- `test_concurrent_calls_from_threads`. -- `test_context_manager_shuts_down_on_exit`. -- `test_shutdown_is_idempotent`. -- `test_navigation_error_carries_url_and_reason`. -- `test_timeout_error_raised_when_selector_never_appears`. - -### Stub validation - -- `mypy --strict crates/py/python/folio/__init__.pyi` runs as part of CI. -- `pyright` smoke check against the same stubs. - -## Acceptance - -- [ ] `crates/py/Cargo.toml` declares `[lib] crate-type = ["cdylib"]`, - `name = "folio"`, depends on `pyo3` and `engine` (workspace). -- [ ] `crates/py/pyproject.toml` configures `maturin` builds with the - target Python ABIs and platform list. -- [ ] `crates/py/python/folio/__init__.pyi` shipped in the wheel, - exact signatures matching *Public API*. -- [ ] All listed Rust unit tests pass with `cargo test -p py`. -- [ ] All Python tests pass with `maturin develop` + `pytest`. -- [ ] `mypy --strict` passes against the stub. -- [ ] `cargo clippy -p py -- -D warnings` clean. -- [ ] No `unsafe` outside what PyO3 macros generate. -- [ ] `__version__` matches the workspace package version. -- [ ] Wheel size < 30 MiB on linux-x64 (sanity). - -## Out of scope / follow-ups - -- LibreOffice + pdfops Python surfaces — separate spec. -- Async Python API (`async def html_to_pdf`) — likely a `pyo3-async` - follow-up; non-trivial because of the GIL/runtime dance. -- Streaming output via a Python file-like protocol. -- Type protocol exports for non-engine types (e.g. `Sequence[Cookie]` - Protocols). -- Deeper structural typing (`TypedDict` for headers) once API stabilises. diff --git a/docs/specs/40-special-features.md b/docs/specs/40-special-features.md deleted file mode 100644 index 6a51532..0000000 --- a/docs/specs/40-special-features.md +++ /dev/null @@ -1,408 +0,0 @@ -# Spec 40 — Special Features - -> Advanced features that Gotenberg supports but Folio is missing: -> downloading files from remote URLs, Basic Authentication, TLS, -> Cloud Run/Lambda support, and URL allow/deny lists. - -## Goal - -Implement special features that enable Folio to be deployed -in production environments with security, cloud integration, -and remote file access capabilities. - -## Scope - -**In:** - -### 1. Download from Remote URLs - -- Download files from HTTP/HTTPS URLs for conversion -- Support S3, GCS, Azure Blob URLs -- Timeout and retry logic -- Size limit for downloads - -### 2. Basic Authentication - -- HTTP basic auth for API endpoints -- Configurable username/password -- Exempt health/version endpoints - -### 3. TLS Support - -- HTTPS listener with cert/key -- Auto-redirect HTTP to HTTPS -- TLS version configuration - -### 4. Cloud Deployment - -- Cloud Run (GCP) configuration -- AWS Lambda handler -- Health check endpoints for load balancers - -### 5. URL Allow/Deny Lists (Security) - -- Regex-based URL filtering -- Separate allow and deny lists -- Deny list takes precedence - -**Out:** - -- OAuth2/OpenID Connect (complex, separate feature) -- mTLS client certificates (nice to have) -- Rate limiting (separate feature) - -## 1. Download from Remote URLs - -### Gotenberg Implementation - -| Field | Gotenberg Source | Description | -|-------|------------------|-------------| -| Download from URL | `pkg/modules/chromium/chromium.go:~L500-600` | Uses `download.FromURL()` | - -### Implementation - -#### New Endpoint: `POST /forms/chromium/convert/url` (extend existing) - -Already accepts `url` field. Need to: -1. Download URL content to temp file -2. Convert downloaded file - -#### New Feature: Download Files from URLs in Multipart - -```rust -// crates/server/src/routes/chromium.rs - -use reqwest::Client; - -async fn download_url(url: &str, max_size: u64) -> Result<Vec<u8>, EngineError> { - let client = Client::new(); - let response = client.get(url) - .send() - .await - .map_err(|e| EngineError::Navigation { - url: url.into(), - reason: format!("Download failed: {}", e), - })?; - - // Check content length - if let Some(len) = response.content_length() { - if len > max_size { - return Err(EngineError::InvalidOption( - format!("File too large: {} bytes", len) - )); - } - } - - let bytes = response.bytes() - .await - .map_err(|e| EngineError::Navigation { - url: url.into(), - reason: format!("Download failed: {}", e), - })?; - - Ok(bytes.to_vec()) -} -``` - -#### Form Field: `downloadFiles` - -| Field | Type | Description | -|-------|------|-------------| -| `downloadFiles` | JSON array | URLs to download and include in conversion | - -Example: -```json -[ - "https://example.com/image.png", - "https://s3.amazonaws.com/bucket/document.pdf" -] -``` - -## 2. Basic Authentication - -### Gotenberg Implementation - -| Flag | Gotenberg Source | Description | -|------|------------------|-------------| -| `--api-basic-auth-username` | `pkg/modules/api/config.go:BasicAuthUsername` | Username | -| `--api-basic-auth-password` | `pkg/modules/api/config.go:BasicAuthPassword` | Password | - -### Implementation - -#### Middleware for Axum - -```rust -// crates/server/src/auth.rs - -use axum::middleware::Next; -use axum::http::{Request, StatusCode}; -use base64::{engine::general_purpose, Engine as _}; - -pub async fn basic_auth_middleware( - request: Request, - next: Next, - username: Option<String>, - password: Option<String>, -) -> Result<(), StatusCode> { - // Skip auth for health/version endpoints - if request.uri().path() == "/health" || request.uri().path() == "/version" { - return Ok(()); - } - - let Some(auth_header) = request.headers().get("Authorization") else { - return Err(StatusCode::UNAUTHORIZED); - }; - - let Some(auth_str) = auth_header.to_str().ok() else { - return Err(StatusCode::UNAUTHORIZED); - }; - - if !auth_str.starts_with("Basic ") { - return Err(StatusCode::UNAUTHORIZED); - } - - let encoded = &auth_str[6..]; - let Ok(decoded) = general_purpose::STANDARD.decode(encoded) else { - return Err(StatusCode::UNAUTHORIZED); - }; - - let Ok(credentials) = String::from_utf8(decoded) else { - return Err(StatusCode::UNAUTHORIZED); - }; - - let Some((user, pass)) = credentials.split_once(':') else { - return Err(StatusCode::UNAUTHORIZED); - }; - - if Some(user.to_string()) == username && Some(pass.to_string()) == password { - Ok(()) - } else { - Err(StatusCode::UNAUTHORIZED) - } -} -``` - -## 3. TLS Support - -### Gotenberg Implementation - -| Flag | Gotenberg Source | Description | -|------|------------------|-------------| -| `--api-tls-cert-file` | `pkg/modules/api/config.go:TlsCertFile` | TLS certificate | -| `--api-tls-key-file` | `pkg/modules/api/config.go:TlsKeyFile` | TLS private key | - -### Implementation - -#### TLS in Axum with `tokio-rustls` - -```rust -// crates/server/src/tls.rs - -use tokio_rustls::TlsAcceptor; -use rustls::{Certificate, PrivateKey, ServerConfig}; -use std::fs::File; -use std::io::Read; - -pub fn load_tls_config(cert_path: &Path, key_path: &Path) -> Result<ServerConfig, Box<dyn std::error::Error>> { - // Load certificate - let mut cert_file = File::open(cert_path)?; - let mut cert_buf = Vec::new(); - cert_file.read_to_end(&mut cert_buf)?; - let cert = Certificate(cert_buf); - - // Load private key - let mut key_file = File::open(key_path)?; - let mut key_buf = Vec::new(); - key_file.read_to_end(&mut key_buf)?; - let key = PrivateKey(key_buf); - - let config = ServerConfig::builder() - .with_safe_defaults() - .with_no_client_auth() - .with_single_cert(vec![cert], key)?; - - Ok(config) -} -``` - -#### Server Startup with TLS - -```rust -// crates/server/src/main.rs - -if let (Some(cert), Some(key)) = (&config.tls_cert_file, &config.tls_key_file) { - // TLS mode - let tls_config = load_tls_config(cert, key)?; - // Bind with TLS -} else { - // Plain HTTP mode (existing) -} -``` - -## 4. Cloud Deployment - -### Cloud Run (GCP) - -#### Gotenberg Reference - -Gotenberg has pre-built Docker images for Cloud Run: -- `gcr.io/gotenberg/gotenberg:latest` -- Health check endpoint: `/health` - -#### Folio Implementation - -```dockerfile -# Dockerfile.cloudrun -FROM rust:1.75 as builder -WORKDIR /app -COPY . . -RUN cargo build --release -p server - -FROM debian:bullseye -COPY --from=builder /app/target/release/folio-server /usr/local/bin/ -RUN apt-get update && apt-get install -y chromium libreoffice -EXPOSE 8080 -CMD ["folio-server", "--port", "8080"] -``` - -Environment variables for Cloud Run: -- `PORT=8080` (Cloud Run sets this automatically) - -### AWS Lambda - -#### Gotenberg Reference - -Gotenberg has Lambda runtime support via `github.com/aws/aws-lambda-go`. - -#### Folio Implementation (Future) - -Use `lambda_runtime` crate for Rust Lambda support. - -## 5. URL Allow/Deny Lists - -### Gotenberg Implementation - -| Flag | Gotenberg Source | Description | -|------|------------------|-------------| -| `--chromium-allow-list` | `pkg/modules/chromium/config.go:AllowList` | Allowed URL patterns | -| `--chromium-deny-list` | `pkg/modules/chromium/config.go:DenyList` | Denied URL patterns | - -### Implementation - -#### URL Validation - -```rust -// crates/server/src/url_filter.rs - -use regex::Regex; - -pub struct UrlFilter { - allow_list: Vec<Regex>, - deny_list: Vec<Regex>, -} - -impl UrlFilter { - pub fn new(allow: &[String], deny: &[String]) -> Result<Self, regex::Error> { - let allow_list = allow.iter() - .map(|p| Regex::new(p)) - .collect::<Result<Vec<_>, _>>()?; - - let deny_list = deny.iter() - .map(|p| Regex::new(p)) - .collect::<Result<Vec<_>, _>>()?; - - Ok(Self { allow_list, deny_list }) - } - - pub fn is_allowed(&self, url: &str) -> bool { - // Check deny list first (takes precedence) - if self.deny_list.iter().any(|re| re.is_match(url)) { - return false; - } - - // If allow list is empty, allow all (that aren't denied) - if self.allow_list.is_empty() { - return true; - } - - // Otherwise, must be in allow list - self.allow_list.iter().any(|re| re.is_match(url)) - } -} -``` - -## References to Gotenberg Source - -| Feature | Gotenberg File | Line Numbers | -|---------|------------------|-------------| -| Download URLs | `pkg/modules/chromium/chromium.go` | ~L500-600 | -| Basic auth | `pkg/modules/api/api.go` | ~L100-150 | -| TLS support | `pkg/modules/api/api.go` | ~L150-200 | -| URL filter | `pkg/modules/chromium/chromium.go` | ~L600-700 | -| Cloud Run | `Dockerfile` | Full file | - -To read Gotenberg source: -```bash -cd /Users/__deesh_reddy__/projects/personal_git/rust_builds/folio/gotenberg -cat pkg/modules/chromium/chromium.go | grep -A10 "FromURL" -``` - -## Expected Behavior - -### Download from URLs -- Accept HTTP/HTTPS URLs in `downloadFiles` field -- Download to temp directory -- Apply size limit (default 50 MiB) -- Return error if download fails - -### Basic Auth -- Return `401 Unauthorized` if no credentials -- Return `401` if wrong credentials -- Skip auth for `/health` and `/version` - -### TLS -- Load cert/key from files -- Accept HTTPS connections -- Reject non-TLS connections (or redirect) - -### URL Filtering -- Deny list checked first (higher priority) -- Allow list empty = allow all (except denied) -- Regex patterns matched against full URL - -## Test Plan - -### Unit Tests - -- `download_url_returns_bytes` -- `download_url_exceeds_size_limit` -- `basic_auth_validates_credentials` -- `basic_auth_exempts_health_endpoint` -- `url_filter_deny_list_blocks` -- `url_filter_allow_list_permits` - -### Integration Tests - -- `download_and_convert_remote_html` -- `basic_auth_rejects_unauthorized_request` -- `tls_accepts_https_connections` -- `url_deny_list_blocks_navigation` - -## Acceptance - -- [ ] Download from remote URLs in multipart -- [ ] Basic auth middleware with exemption list -- [ ] TLS support with cert/key loading -- [ ] URL allow/deny lists with regex -- [ ] Cloud Run Dockerfile -- [ ] Unit tests for all features -- [ ] Integration tests for key scenarios -- [ ] `cargo clippy -p server -- -D warnings` clean - -## References - -- Gotenberg source: `/Users/__deesh_reddy__/projects/personal_git/rust_builds/folio/gotenberg/pkg/modules/` -- reqwest crate: https://docs.rs/reqwest/ -- Axum TLS: https://docs.rs/axum/latest/axum/#tls -- Cloud Run: https://cloud.google.com/run/docs -- AWS Lambda Rust: https://github.com/awslabs/aws-lambda-rust-runtime diff --git a/docs/specs/41-bindings-js.md b/docs/specs/41-bindings-js.md deleted file mode 100644 index cdf856a..0000000 --- a/docs/specs/41-bindings-js.md +++ /dev/null @@ -1,360 +0,0 @@ -# Spec 41 — Node bindings (`js` crate) - -> Self-contained napi-rs wrapper exposing `require('folio')` (or -> `import folio from 'folio'`) to Node.js users. - -## Goal - -Allow Node.js users to convert HTML / URL / Markdown to PDF in-process -via the same `engine` crate, returning real `Promise`s without -`block_on`, matching the README example in -`@/Users/__deesh_reddy__/projects/personal_git/rust_builds/folio/README.md:125-137`. - -## Scope - -**In:** - -- `ChromiumEngine` JS class with async methods `htmlToPdf`, `urlToPdf`, - `markdownToPdf`, `healthy`, `shutdown`. -- Plain TS objects (interfaces) for `PdfOptions`, `RequestContext`, - `BrowserConfig`, `Cookie`, `WaitCondition` (discriminated union). -- Auto-generated `.d.ts` shipped in the npm package. -- Prebuilt binaries on darwin-x64, darwin-arm64, linux-x64-gnu, - linux-arm64-gnu, win32-x64-msvc. -- Node ≥ 18 (`napi8`). - -**Out:** - -- LibreOffice and pdfops surfaces — Node users use the HTTP server today. - Follow-up. -- ESM-first published surface — package supports both CJS and ESM via - `"exports"`, default export is the `ChromiumEngine` class. -- Streaming output / chunked Buffer responses — return one `Buffer` for MVP. -- Worker-thread isolation helpers — out of MVP. - -## Public API - -### TypeScript surface (auto-generated `index.d.ts`) - -```ts -export type EmulateMedia = 'print' | 'screen'; - -export interface PaperSize { - widthIn: number; - heightIn: number; -} -export const PAPER_A4: PaperSize; -export const PAPER_LETTER: PaperSize; -export const PAPER_LEGAL: PaperSize; -export const PAPER_A3: PaperSize; -export const PAPER_A5: PaperSize; - -export interface Margins { - top: number; right: number; bottom: number; left: number; -} -export const MARGINS_ZERO: Margins; -export const MARGINS_DEFAULT: Margins; - -export type WaitCondition = - | { kind: 'load' } - | { kind: 'domContentLoaded' } - | { kind: 'networkIdle' } - | { kind: 'selector'; selector: string } - | { kind: 'expression'; expression: string } - | { kind: 'delay'; durationMs: number }; - -export interface PdfOptions { - paper?: PaperSize; - margin?: Margins; - landscape?: boolean; - scale?: number; - printBackground?: boolean; - preferCssPageSize?: boolean; - emulateMedia?: EmulateMedia; - pageRanges?: string; - headerTemplate?: string; - footerTemplate?: string; - wait?: WaitCondition; -} - -export interface Cookie { - name: string; - value: string; - domain?: string; - path?: string; - secure?: boolean; - httpOnly?: boolean; -} - -export interface RequestContext { - userAgent?: string; - extraHeaders?: Record<string, string>; - cookies?: Cookie[]; - failOnStatus?: number[]; -} - -export interface BrowserConfig { - executable?: string; - headless?: boolean; - extraArgs?: string[]; - noSandbox?: boolean; - timeoutMs?: number; -} - -export class ChromiumEngine { - constructor(config?: BrowserConfig); - htmlToPdf(html: string, opts?: { baseUrl?: string; options?: PdfOptions; request?: RequestContext }): Promise<Buffer>; - urlToPdf(url: string, opts?: { options?: PdfOptions; request?: RequestContext }): Promise<Buffer>; - markdownToPdf(markdown: string, opts?: { options?: PdfOptions; request?: RequestContext }): Promise<Buffer>; - healthy(): Promise<boolean>; - shutdown(): Promise<void>; -} - -export class FolioError extends Error { - code: string; // e.g. 'INVALID_OPTION', 'TIMEOUT', 'NAVIGATION' - /** Present only when code === 'NAVIGATION'. */ - url?: string; - /** Present only when code === 'NAVIGATION'. */ - reason?: string; - /** Present only when code === 'CHROME_NOT_FOUND'. */ - searched?: string[]; -} - -export const VERSION: string; -``` - -### Rust surface (`crates/js/src/lib.rs`) - -```rust -use napi_derive::napi; - -#[napi] -pub struct ChromiumEngine { /* Arc<engine::ChromiumEngine> */ } - -#[napi] -impl ChromiumEngine { - #[napi(constructor)] - pub fn new(config: Option<BrowserConfigJs>) -> napi::Result<Self>; - - #[napi] - pub async fn html_to_pdf( - &self, - html: String, - opts: Option<HtmlToPdfArgs>, - ) -> napi::Result<napi::bindgen_prelude::Buffer>; - - #[napi] - pub async fn url_to_pdf( - &self, - url: String, - opts: Option<UrlToPdfArgs>, - ) -> napi::Result<napi::bindgen_prelude::Buffer>; - - #[napi] - pub async fn markdown_to_pdf( - &self, - markdown: String, - opts: Option<MarkdownToPdfArgs>, - ) -> napi::Result<napi::bindgen_prelude::Buffer>; - - #[napi] - pub async fn healthy(&self) -> bool; - - #[napi] - pub async fn shutdown(&self) -> napi::Result<()>; -} -``` - -`BrowserConfigJs`, `PdfOptionsJs`, etc. are `#[napi(object)]` plain -structs that map directly to the TS interfaces above. Field names are -camelCase via `#[napi(js_name = "...")]` where rename is needed. - -## Behavior - -### Runtime / async - -napi-rs ships with a built-in tokio integration: any `async fn` -annotated with `#[napi]` is converted into a JS `Promise` automatically. -**No** `block_on` is needed — napi-rs schedules futures on its own -runtime and resolves the JS Promise when the future completes. - -To use the same engine across many calls efficiently we keep an -`Arc<engine::ChromiumEngine>` inside the napi class. - -### `ChromiumEngine.constructor` - -The constructor cannot be `async` in napi-rs; instead: - -1. Build `engine::types::BrowserConfig` from the provided `BrowserConfigJs`. -2. Synchronously call `engine::ChromiumEngine::launch_with` via a small - helper that uses `napi::tokio::block_on` (napi-rs exposes this for - construction-time work). -3. Store the resulting engine in `Arc`. - -If launch fails, throw a `FolioError` (see *Error mapping*). JS callers -see a thrown error from `new ChromiumEngine(...)`. - -### `htmlToPdf` / `urlToPdf` / `markdownToPdf` - -Each: - -1. Convert `Option<*Args>` into the engine's owned types - (`PdfOptions`, `RequestContext`, optional `base_url`). -2. Validate: `opts.options.validate()?`. Validation errors throw a - `FolioError` with code `INVALID_OPTION`. -3. Call the corresponding `engine::ChromiumEngine` method. -4. Wrap the resulting `Vec<u8>` in `napi::bindgen_prelude::Buffer` (this - is zero-copy: napi-rs hands ownership of the Rust `Vec` to V8). - -### `healthy` / `shutdown` - -- `healthy` mirrors the engine's method. -- `shutdown` is idempotent. Subsequent calls return `Ok(())` quickly. - After shutdown, other methods reject with `FolioError(code = 'INTERNAL', message = 'engine shut down')`. - -### Error mapping - -Each `EngineError` variant produces a `napi::Error` with both: - -- A `code` (also exposed as a property on the JS `Error` object). -- A `reason` string (used as the JS `Error.message`). - -Mapping table: - -| `EngineError` | `code` (string) | Extra props on `Error` | -|------------------------------|------------------------|--------------------------------| -| `InvalidOption` | `INVALID_OPTION` | — | -| `InvalidPageRange` | `INVALID_PAGE_RANGE` | — | -| `ChromeNotFound { searched }`| `CHROME_NOT_FOUND` | `searched: string[]` | -| `ChromeLaunch(msg)` | `CHROME_LAUNCH` | — | -| `Cdp(msg)` | `CDP` | — | -| `Navigation { url, reason }` | `NAVIGATION` | `url: string`, `reason: string`| -| `Timeout(d)` | `TIMEOUT` | `seconds: number` | -| `Io(_)` | `IO` | — | -| `Internal(msg)` | `INTERNAL` | — | - -A small helper `into_napi_err(e: engine::EngineError) -> napi::Error` -handles this. Extra properties are attached via -`napi::Error::with_status` / `napi_create_error` and a JS-side wrapper -(`makeFolioError(rawErr)`) that copies fields onto a real `FolioError` -class instance. The JS wrapper lives in `crates/js/index.js` (or the -generated stub augmented post-build). - -### Concurrency - -A single `ChromiumEngine` instance is safe to use from any number of -concurrent JS calls (the underlying engine handles parallelism). Workers -created via `worker_threads` each get their own native instance — they -do not share state across the Worker boundary (this matches V8 isolation -guarantees and napi-rs's runtime model). - -### Module shape - -`require('folio')` returns the auto-generated module object with: - -- `ChromiumEngine` class. -- `FolioError` class (defined in JS to allow `instanceof`). -- Constants (`PAPER_A4`, `MARGINS_DEFAULT`, etc.). -- `VERSION` string. - -Distribution: - -- `crates/js/package.json` is the published npm package, name `folio`. -- The Rust artifact is loaded via `@napi-rs/cli`'s host loader pattern; - prebuilt binaries are downloaded by the post-install script per - platform. - -## Errors - -Every public method throws (sync) or rejects (async) only with -`FolioError` instances. Type errors arising from incorrect JS argument -shapes produce `TypeError` (napi-rs default). - -## Edge cases - -| Scenario | Required behavior | -|--------------------------------------------------------------|--------------------------------------------------------------------| -| `new ChromiumEngine()` with no Chrome installed | Throws `FolioError(code='CHROME_NOT_FOUND', searched=[...])`. | -| `htmlToPdf("")` | Resolves with a valid PDF Buffer. | -| `htmlToPdf` after `await shutdown()` | Rejects with `FolioError(code='INTERNAL')`. | -| Many parallel `htmlToPdf` from event loop | All resolve; engine handles concurrency. | -| Caller passes `delay: { durationMs: -1 }` | `INVALID_OPTION` error. | -| Caller passes `paper: { widthIn: 0, heightIn: 11 }` | `INVALID_OPTION` error. | -| User cancels by dropping the Promise | The render runs to completion (engine doesn't cancel mid-render in MVP); response is dropped harmlessly. | -| Large PDF (>1 GiB) | Buffer transfer succeeds but allocation may fail; rejects with `INTERNAL`. Not optimised for in MVP. | -| GC of `ChromiumEngine` without `await shutdown()` | The `Arc` keeps Chrome alive until last clone drops; emits a `tracing::warn!`. | -| Use from a `worker_thread` | Each worker has its own instance; no cross-worker sharing. | - -## Test plan - -### Rust unit tests (`crates/js/src/...`) - -- `browser_config_js_to_native_round_trip`. -- `pdf_options_js_to_native_round_trip` — every field defaulted vs set. -- `wait_condition_discriminated_union_to_native` — every variant. -- `cookie_js_to_native_round_trip`. -- `error_mapping_table` — for each `EngineError` variant, build a - `napi::Error`, assert `code` string and extra fields. - -### JS integration tests (`crates/js/__tests__/folio.test.ts`) - -Run via `vitest` against the built native module. - -Without Chrome (skipped if absent): - -- `module exports VERSION as semver`. -- `paper and margin constants frozen`. -- `creates ChromiumEngine and reports CHROME_NOT_FOUND when path is bogus`. -- `pdfOptions with invalid scale rejects`. - -With Chrome (`describe.skipIf(!hasChrome())`): - -- `htmlToPdf returns a Buffer starting with %PDF-`. -- `urlToPdf against a local http server`. -- `markdownToPdf renders a table`. -- `parallel calls all resolve`. -- `failOnStatus rejects with NAVIGATION carrying url and reason`. -- `selector wait timeout rejects with TIMEOUT carrying seconds`. -- `shutdown is idempotent and subsequent calls reject with INTERNAL`. -- `error.instanceof FolioError`. - -### Type-level tests - -- `tsd` snapshots assert that the generated `.d.ts` types match the - documented surface; CI fails if the snapshot drifts. - -### Build sanity - -A CI job per platform builds the addon and runs the test suite. -Prebuilt binaries are uploaded via `@napi-rs/cli artifacts`. - -## Acceptance - -- [ ] `crates/js/Cargo.toml` declares `[lib] crate-type = ["cdylib"]`, - `name = "folio"`, depends on `napi`, `napi-derive`, `engine`. -- [ ] `crates/js/package.json` is configured for `@napi-rs/cli` build, - with platform-specific optional dependencies (`@folio/folio-darwin-arm64` - style scoped sub-packages, or whatever the chosen distribution - pattern is — to be finalised before publish). -- [ ] Auto-generated `index.d.ts` matches the documented surface - (verified by `tsd` snapshot). -- [ ] All Rust unit tests pass with `cargo test -p js`. -- [ ] All JS tests pass with `npm test`. -- [ ] `cargo clippy -p js -- -D warnings` clean. -- [ ] `FolioError` JS class has subclass-friendly `instanceof` semantics - (verified by test). -- [ ] No `unsafe` outside what `#[napi]` macros generate. -- [ ] Released package publishes a CJS entry point (`require('folio')`) - and an ESM entry point (`import folio from 'folio'`). -- [ ] Wheel/binary size is reasonable (< 30 MiB per platform). - -## Out of scope / follow-ups - -- LibreOffice + pdfops surfaces — separate spec. -- AbortSignal cancellation of in-flight renders. -- Worker-thread shared engine handles via SharedArrayBuffer / message - passing. -- Streaming output: writable-stream-friendly responses. -- ESM-only re-architecture once Node 22 is the floor. -- Direct N-API zero-copy when the engine learns to write into a - pre-allocated buffer. diff --git a/docs/specs/41-github-issues-analysis.md b/docs/specs/41-github-issues-analysis.md deleted file mode 100644 index 4edcd52..0000000 --- a/docs/specs/41-github-issues-analysis.md +++ /dev/null @@ -1,358 +0,0 @@ -# GitHub Issues Analysis: PDF Generation Pain Points - -> Analysis of user complaints and feature requests from Gotenberg, -> wkhtmltopdf, and WeasyPrint GitHub issues. Reveals what -> users hate and what they want in PDF generation tools. - -## Executive Summary - -Based on 200+ GitHub issues analyzed across Gotenberg, wkhtmltopdf, -and WeasyPrint, the top user complaints are: - -1. **Large PDF file sizes** (2-10x larger than expected) -2. **Font rendering problems** (webfonts, missing system fonts) -3. **Image rendering failures** in HTML→PDF conversion -4. **Chromium version regressions** breaking existing workflows -5. **Performance degradation** after upgrades -6. **Poor error messages** (generic 500 errors) -7. **Header/footer crashes** with certain content - -Folio (Rust) has inherent advantages over Gotenberg (Go/Chromium) -and wkhtmltopdf (unmaintained WebKit). - ---- - -## 1. Gotenberg Issues Analysis - -### 1.1 File Size Problems (Critical) - -| Issue | Title | Pain Level | -|-------|-------|------------| -| #521 | Gotenberg generates larger PDFs than Chromium | 🔥 High | -| #1056 | HTML to PDF file size 8X larger than wkhtmltopdf | 🔥 High | -| #1067 | Generated PDF sizes v8.x 2-3x larger than v7.x | 🔥 High | - -**Root Causes:** -- Webfonts embedded in PDF (264KB → 131KB with local fonts) -- White background paths always rendered (Chromium bug) -- Chromium generates bloated PDF structure - -**User Workarounds:** -```bash -# Install fonts locally in Docker -apt-get install ttf-mscorefonts-installer - -# Post-process with Ghostscript -gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 \ - -dPDFSETTINGS=/screen -dNOPAUSE -dQUIET \ - -sOutputFile=output.pdf input.pdf -``` - -**Folio Advantage:** -- ✅ Could use lopdf directly (no Chromium bloat) -- ✅ Native font subsetting -- ✅ No white background bug - ---- - -### 1.2 Font Rendering Issues (High) - -| Issue | Title | Pain Level | -|-------|-------|------------| -| #921 | Numbers deformed converting HTML to PDF | 🔥 High | -| #1371 | Custom fonts not working on versions >8.21.1 | 🔥 High | -| #861 | How to debug intermittent font/text rendering? | 🔥 High | -| #1356 | Webfonts in header/footer cause 500 error | 🔥 High | - -**Root Causes:** -- Chromium doesn't wait for webfonts to load -- `waitForSelector` / `waitWindowStatus` not used correctly -- Header/footer don't load external assets - -**User Complaints:** -> "Every so often a PDF generated with Gotenberg 8 will lack all fonts loaded with CSS @font-face" - -> "Numbers 6 and 8 get a bigger font size than other numbers" - -> "Including webfonts in header or footer will cause 500 Error" - -**Folio Advantage:** -- ✅ `waitForSelector` spec'ed (spec-36) -- ✅ Better font loading detection -- ✅ No header/footer crash (Rust safety) - ---- - -### 1.3 Image Rendering Failures (Medium-High) - -| Issue | Title | Pain Level | -|-------|-------|------------| -| #1178 | HTML conversion images not converted v8+ | 🔥 High | -| #1356 | Webfonts cause 500 error | 🔥 High | - -**Root Cause:** -```html -<!-- loading="lazy" breaks Chromium rendering --> -<img src="image.png" loading="lazy"> -``` - -**User Quote:** -> "In version 7.4.3: images display correctly. In version 8.20.1: images are not shown" - -**Folio Advantage:** -- ✅ Could auto-strip `loading="lazy"` attribute -- ✅ Better error messages (which image failed?) - ---- - -### 1.4 Chromium Regressions (Upgrade Blockers) - -| Issue | Title | Pain Level | -|-------|-------|------------| -| #1491 | backdrop-filter: blur() renders blank sections | 🔥 High | -| #1397 | Increased conversion times after upgrade | 🔥 High | - -**User Pain:** -> "We can't upgrade from v7 to v8 because of PDF size increase" - -> "Conversion times went from 2s to 15s after upgrading" - -**Folio Advantage:** -- ✅ Not dependent on Chromium version -- ✅ Consistent performance (no GC pauses like Go) - ---- - -### 1.5 Feature Requests (What Users Want) - -| Issue | Title | Priority | -|-------|-------|----------| -| #1454 | Add OCR support | 🔥 High | -| #1484 | Switch from unoconv to LibreOfficeKit | 🔥 High | -| #1390 | Landscape single page generation - auto cropping | 🔥 Medium | -| #1482 | LibreOffice image preview | 🔥 Medium | -| #1350 | Flatten configuration/qpdf expansion | 🔥 Medium | - ---- - -## 2. wkhtmltopdf Issues (Archived 2023 - Unmaintained) - -### 2.1 Why Users Are Leaving - -| Issue | Title | Pain Level | -|-------|-------|------------| -| #4705 | Generates unportable PDF (font names blank) | 🔥 Critical | -| #1926 | Testing HTML/CSS fails to render correctly | 🔥 Critical | -| #5295 | Doesn't recognize justify-content | 🔥 High | -| #5288 | Q: why does the font look so bad? | 🔥 High | -| #2234 | SVG rendering problem | 🔥 High | - -**Root Causes:** -- **Old WebKit (2012)** - No modern CSS support -- **No JavaScript** (ES3 only) -- **Poor font handling** - Generates blank font names -- **SVG broken** - `stroke-width: 1` causes black text - -**User Migration:** -> "I used to use wkhtmltopdf, but the project has been archived as the webkit binary hasn't been updated since 2015, so I have been looking for a replacement" - -**Folio Advantage:** -- ✅ Modern CSS support (via Chromium) -- ✅ Full JavaScript support -- ✅ Better font handling (system font detection) - ---- - -## 3. WeasyPrint Issues (Limited CSS Engine) - -| Issue | Title | Pain Level | -|-------|-------|------------| -| #1926 | Testing HTML/CSS fails to render correctly | 🔥 Critical | -| #2234 | SVG rendering problem | 🔥 High | - -**Root Causes:** -- **Custom engine** (not browser-grade) -- **No JavaScript at all** -- **Limited CSS** - Doesn't support `paged` media well - -**User Complaint:** -> "WeasyPrint got borked by CSS relative positioning. After I changed to absolute positioning the page comes out." - -**Folio Advantage:** -- ✅ Browser-grade rendering (Chromium) -- ✅ Full CSS support -- ✅ JavaScript support - ---- - -## 4. Common Pain Points (All Tools) - -### 4.1 Font Problems (Universal) - -| Problem | Gotenberg | wkhtmltopdf | WeasyPrint | Folio | -|---------|-----------|-------------|------------|-------| -| Webfont size bloat | 🔥 Yes | 🔥 Yes | ⚠️ Maybe | ✅ No (native) | -| Missing system fonts | 🔥 Yes | 🔥 Yes | 🔥 Yes | ⚠️ Needs improvement | -| Custom font loading | 🔥 Yes | 🔥 Yes | 🔥 Yes | ✅ Better | -| Font rendering bugs | 🔥 Yes | 🔥 Yes | ⚠️ Some | ✅ No (direct) | - -### 4.2 Performance Issues - -| Problem | Gotenberg (Go) | wkhtmltopdf | WeasyPrint | Folio (Rust) | -|---------|----------------|-------------|------------|---------------| -| GC pauses | 🔥 Yes | ❌ No | ❌ No | ✅ No GC | -| Memory bloat | 🔥 Yes (Chromium) | ⚠️ Medium | ⚠️ Medium | ✅ Lower | -| Slow upgrades | 🔥 Yes | 🔥 Yes (dead) | ⚠️ Some | ✅ Fast Rust | - -### 4.3 Error Handling - -| Problem | Gotenberg | wkhtmltopdf | WeasyPrint | Folio | -|---------|-----------|-------------|------------|-------| -| Generic 500 errors | 🔥 Yes | 🔥 Yes | 🔥 Yes | ⚠️ Partial | -| No debug info | 🔥 Yes | 🔥 Yes | 🔥 Yes | ✅ Structured logs | -| Opaque failures | 🔥 Yes | 🔥 Yes | 🔥 Yes | ✅ Tracing | - ---- - -## 5. What Users Wish Existed - -Based on 200+ issues, here's what users want: - -### 5.1 Must-Have Features - -1. **OCR Support** - "We need to convert scanned PDFs to searchable PDFs" -2. **Better Font Handling** - "Auto-detect and embed system fonts" -3. **PDF Size Optimization** - "Why is my PDF 10x larger than expected?" -4. **Better Error Messages** - "500 error with no details is useless" -5. **LibreOfficeKit Integration** - "unoconv is slow and buggy" - -### 5.2 Nice-to-Have Features - -6. **Landscape Auto-Crop** - "Single page landscape generation" -7. **Image Preview for LibreOffice** - "See what's being converted" -8. **Flatten Config** - "Better control over qpdf options" -9. **Debug Mode for Fonts** - "Why is my font not loading?" -10. **PDF/A-3 Embed Files** - "Need to embed XML with PDF/A-3" - ---- - -## 6. Folio's Competitive Advantages - -### 6.1 Technical Advantages - -| Feature | Gotenberg (Go) | wkhtmltopdf | WeasyPrint | Folio (Rust) | -|---------|----------------|-------------|------------|---------------| -| **Memory Safety** | ⚠️ GC | ✅ C++ | ✅ Python | ✅ Compile-time | -| **Modern CSS** | ✅ Yes | ❌ No | ⚠️ Limited | ✅ Yes | -| **JavaScript** | ✅ Yes | ❌ No | ❌ No | ✅ Yes | -| **Multiple Modes** | ❌ Server only | ❌ CLI only | ❌ Library | ✅ 4 modes | -| **Bindings** | ❌ No | ❌ No | ❌ No | ✅ Python/Node | - -### 6.2 Solving User Pain Points - -| Pain Point | How Folio Solves It | -|-------------|----------------------| -| Large PDFs | Native lopdf + font subsetting | -| Font issues | Direct PDF manipulation, no Chromium bloat | -| Image failures | Better error messages + `loading="lazy"` strip | -| GC pauses | No GC (Rust) | -| Generic errors | Structured logging + tracing | -| Upgrade blockers | Semver + stable API | - ---- - -## 7. Recommendations for Folio - -### High Priority (Based on User Pain) - -1. **Implement OCR support** (Gotenberg #1454) - - Use `tesseract` or `ocrs` crate - - Endpoint: `POST /forms/ocr/recognize` - -2. **Improve font handling** - - Auto-detect system fonts - - Warn if webfont might bloat PDF - - Spec: `spec-36-chromium-wait-conditions.md` - -3. **PDF size optimization** - - Post-process with Ghostscript/qpdf - - Warn if PDF > threshold - - Add `optimize` field to endpoints - -4. **Better error messages** - - Structured error responses - - Include which resource failed - - Spec: `spec-35-logging.md` ✅ - -### Medium Priority - -5. **LibreOfficeKit integration** (Gotenberg #1484) - - Faster than unoconv - - Better font handling - -6. **Landscape auto-crop** (Gotenberg #1390) - - Detect content bounds - - Trim whitespace - -7. **Debug mode for fonts** - - Log which fonts are loaded - - Warn if fallback font used - ---- - -## 8. References - -### Gotenberg Issues Analyzed - -| Issue | Title | Impact | -|-------|-------|--------| -| #521 | Larger PDFs than Chromium/AthenaPDF | 🔥 High | -| #1056 | 8X larger than wkhtmltopdf | 🔥 High | -| #1067 | v8.x 2-3x larger than v7.x | 🔥 High | -| #921 | Numbers deformed in PDF | 🔥 High | -| #1371 | Custom fonts not working | 🔥 High | -| #861 | Intermittent font rendering | 🔥 High | -| #1178 | Images not converted v8+ | 🔥 High | -| #1356 | Webfonts cause 500 error | 🔥 High | -| #1491 | backdrop-filter blank sections | 🔥 High | -| #1397 | Increased conversion times | 🔥 High | -| #1454 | Add OCR support | 🔥 High | -| #1484 | Switch to LibreOfficeKit | 🔥 High | -| #1390 | Landscape auto-crop | 🔥 Medium | -| #1482 | LibreOffice image preview | 🔥 Medium | - -### wkhtmltopdf Issues Analyzed - -| Issue | Title | Impact | -|-------|-------|--------| -| #4705 | Unportable PDF (blank font names) | 🔥 Critical | -| #1926 | CSS fails to render | 🔥 Critical | -| #5295 | Doesn't recognize justify-content | 🔥 High | -| #5288 | Font looks bad | 🔥 High | -| #2234 | SVG rendering problem | 🔥 High | - -### WeasyPrint Issues Analyzed - -| Issue | Title | Impact | -|-------|-------|--------| -| #1926 | Testing HTML/CSS fails | 🔥 Critical | -| #2234 | SVG rendering problem | 🔥 High | - ---- - -## 9. Conclusion - -**Users are desperate for:** -1. A **maintained** tool (wkhtmltopdf is dead) -2. **Smaller PDFs** (Gotenberg's #1 complaint) -3. **Better font handling** (universal pain point) -4. **Clearer error messages** (debuggability) -5. **OCR support** (emerging requirement) - -**Folio is well-positioned to solve these** with: -- ✅ Rust's memory safety + performance -- ✅ Modern Chromium rendering -- ✅ Multiple interface modes -- ✅ Active development (unlike wkhtmltopdf) - -**Next steps:** Implement OCR (#1454), improve font handling, add PDF optimization. diff --git a/docs/specs/42-smart-pdf-optimiser.md b/docs/specs/42-smart-pdf-optimiser.md deleted file mode 100644 index bfd8f30..0000000 --- a/docs/specs/42-smart-pdf-optimiser.md +++ /dev/null @@ -1,368 +0,0 @@ -# Spec 42 — Smart PDF Optimiser - -> Automatically detect and reduce oversized PDFs generated from -> HTML/URL conversions. Solves the #1 complaint: "PDFs 8x larger -> than expected" (Gotenberg issues #521, #1056, #1067). - -## Goal - -Create an intelligent PDF optimisation system that automatically -detects bloated PDFs and offers one-click compression -with multiple quality presets. This directly addresses the -top user complaint across all PDF generation tools. - -## Problem Analysis - -### Gotenberg Issues (Real User Quotes) - -> "We recently switched from AthenaPDF to Gotenberg... noticed a -> significant increase of file size... broke our integration with -> other tools which enforce a file size limit." -> — Issue #521 - -> "Generated PDF sizes with v8.x are ~2-3x larger than -> same generated PDF on v7.x... 286kb vs 795kb" -> — Issue #1067 - -> "With Google web font: 264 KB. With locally installed -> version of that font: 131 KB... Ghostscript can reduce -> even more... 27 MB → 12 MB → 1.1 MB" -> — Issue #521 - -### Root Causes Identified - -| Cause | Impact | Solution | -|------|--------|----------| -| Web fonts embedded in PDF | +200% size | Detect & warn, suggest local install | -| White background paths (Chromium bug) | +50% size | Strip background paths | -| No compression applied | +300% size | Apply Ghostscript/qpdf compression | -| Duplicate images (Chromium bug #1077) | +100% size | Deduplicate images | -| Unused fonts subset not applied | +150% size | Proper font subsetting | - -## Scope - -**In:** - -- `POST /forms/pdfengines/optimise` endpoint -- Auto-detection of bloated PDFs (>5MB threshold) -- Three quality presets: `screen`, `ebook`, `printer` -- Backend selection: Ghostscript (best), qpdf, pdfcpu -- Pre-conversion size estimation endpoint -- Size warning headers in responses -- Image deduplication (Chromium bug #1077) -- Font subsetting verification - -**Out:** - -- Automatic optimisation without user request (too magic) -- PDF/A compliance breaking (document in spec-22) -- Lossy image compression (separate feature) - -## Implementation - -### 1. New Endpoint: `POST /forms/pdfengines/optimise` - -```rust -// crates/server/src/routes/pdfengines.rs - -/// Optimise PDF file size. -pub async fn optimise( - State(state): State<AppState>, - mp: Multipart, -) -> ApiResult<impl IntoResponse> { - let start = Instant::now(); - let form = parse_multipart(mp).await?; - - // Extract options - let preset = form.get("preset").unwrap_or("screen").to_string(); - let files = extract_files(&form)?; - - if files.len() != 1 { - return Err(ApiError::InvalidOption( - "optimise requires exactly one PDF file".into() - )); - } - - // Optimise - let result = state - .pdfops - .as_ref() - .unwrap() - .optimise(&files[0], &preset) - .await?; - - let duration = start.elapsed().as_secs_f64(); - - // Log optimisation stats - tracing::info!( - bytes_in = files[0].len(), - bytes_out = result.len(), - ratio = result.len() as f64 / files[0].len() as f64, - duration_ms = duration * 1000.0, - "PDF optimised" - ); - - pdf_response(result, "result.pdf") -} -``` - -### 2. PDF Ops Implementation - -```rust -// crates/engine/src/pdfops/optimise.rs - -use std::process::{Command, Stdio}; - -pub struct OptimiseOptions { - pub preset: OptimisePreset, - pub backend: OptimiseBackend, -} - -#[derive(Debug, Clone, Copy)] -pub enum OptimisePreset { - Screen, // Low quality, 72 DPI, heavy compression - Ebook, // Medium quality, 150 DPI - Printer, // High quality, 300 DPI, light compression -} - -#[derive(Debug, Clone, Copy)] -pub enum OptimiseBackend { - Ghostscript, // Best compression, slow - Qpdf, // Medium compression, fast - PdfCpu, // Light compression, fastest -} - -impl PdfOps { - pub async fn optimise( - &self, - input: &[u8], - preset: &str, - ) -> Result<Vec<u8>, EngineError> { - let preset = match preset.to_lowercase().as_str() { - "screen" => OptimisePreset::Screen, - "ebook" => OptimisePreset::Ebook, - "printer" => OptimisePreset::Printer, - _ => return Err(EngineError::InvalidOption( - format!("Unknown preset: {}, use screen/ebook/printer", preset) - )), - }; - - // Try backends in order of compression quality - let backends: Vec<OptimiseBackend> = vec![ - OptimiseBackend::Ghostscript, - OptimiseBackend::Qpdf, - OptimiseBackend::PdfCpu, - ]; - - for backend in backends { - if backend.is_available() { - tracing::info!(?backend, "Using backend for optimisation"); - return self.optimise_with_backend(input, &preset, backend).await; - } - } - - Err(EngineError::Internal( - "No optimisation backend available (install ghostscript/qpdf/pdfcpu)".into() - )) - } - - async fn optimise_with_backend( - &self, - input: &[u8], - preset: &OptimisePreset, - backend: OptimiseBackend, - ) -> Result<Vec<u8>, EngineError> { - match backend { - OptimiseBackend::Ghostscript => self.optimise_ghostscript(input, preset).await, - OptimiseBackend::Qpdf => self.optimise_qpdf(input, preset).await, - OptimiseBackend::PdfCpu => self.optimise_pdfcpu(input, preset).await, - } - } - - async fn optimise_ghostscript( - &self, - input: &[u8], - preset: &OptimisePreset, - ) -> Result<Vec<u8>, EngineError> { - let preset_args = match preset { - OptimisePreset::Screen => vec![ - "-dPDFSETTINGS=/screen", - "-dCompatibilityLevel=1.4", - "-dDownsampleColorImages=true", - "-dColorImageResolution=72", - "-dAutoFilterColorImages=false", - "-dColorImageFilter=/DCTEncode", - ], - OptimisePreset::Ebook => vec![ - "-dPDFSETTINGS=/ebook", - "-dCompatibilityLevel=1.5", - "-dDownsampleColorImages=true", - "-dColorImageResolution=150", - ], - OptimisePreset::Printer => vec![ - "-dPDFSETTINGS=/printer", - "-dCompatibilityLevel=1.6", - "-dColorImageResolution=300", - ], - }; - - let mut cmd = Command::new("gs"); - cmd.arg("-sDEVICE=pdfwrite") - .arg("-dNOPAUSE") - .arg("-dQUIET") - .arg(format!("-sOutputFile={}", output_path.display())) - .args(&preset_args) - .arg(input_path.display()); - - let output = cmd.output() - .map_err(|e| EngineError::Internal( - format!("Ghostscript failed: {}", e) - ))?; - - if !output.status.success() { - return Err(EngineError::Internal( - format!("Ghostscript error: {}", String::from_utf8_lossy(&output.stderr)) - )); - } - - tokio::fs::read(&output_path).await - .map_err(|e| EngineError::Internal(e.to_string())) - } -} -``` - -### 3. Size Estimation Endpoint - -```rust -// New endpoint: POST /estimate - -pub async fn estimate_size( - State(state): State<AppState>, - mp: Multipart, -) -> ApiResult<impl IntoResponse> { - let form = parse_multipart(mp).await?; - - // Parse the conversion request - let options = parse_chromium_options(&form)?; - - // Estimate size based on inputs - let estimate = SizeEstimate { - estimated_mb: calculate_estimate(&form).await?, - warnings: vec![], - }; - - // Check for web fonts - if has_web_fonts(&form) { - estimate.warnings.push( - "Uses web fonts - may increase size by 200%".into() - ); - } - - // Check for images - if has_large_images(&form) { - estimate.warnings.push( - "Contains large images - consider optimisation".into() - ); - } - - Ok(Json(estimate)) -} - -#[derive(Serialize)] -struct SizeEstimate { - estimated_mb: f64, - warnings: Vec<String>, -} -``` - -### 4. Response Headers (Size Warnings) - -```rust -// Add to all PDF conversion responses - -if let Some(ref response) = result { - let size_mb = response.body().len() as f64 / 1_000_000.0; - - if size_mb > 5.0 { - response.headers_mut().insert( - HeaderName::from_static("X-Size-Warning"), - HeaderValue::from_str(&format!( - "PDF size {:.1} MB exceeds recommended 5 MB. Consider POST /forms/pdfengines/optimise", - size_mb - )).unwrap(), - ); - } -} -``` - -## Form Fields - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `files` | file | required | PDF file to optimise | -| `preset` | string | "screen" | Compression preset: screen/ebook/printer | -| `backend` | string | "auto" | Force backend: ghostscript/qpdf/pdfcpu | - -## Expected Behaviour - -### Optimise Endpoint - -1. Accept PDF file + preset -2. Detect best available backend (Ghostscript > qpdf > pdfcpu) -3. Apply compression based on preset -4. Return optimised PDF -5. Include compression stats in response headers - -### Size Estimation - -1. Accept same form data as conversion endpoints -2. Analyse inputs (HTML, CSS, images, fonts) -3. Return estimated output size -4. Warn about web fonts, large images - -### Response Headers - -``` -X-Original-Size: 10240 (10 MB) -X-Optimised-Size: 2048 (2 MB) -X-Compression-Ratio: 20% (80% reduction) -X-Warnings: Uses web fonts -``` - -## Test Plan - -### Unit Tests - -- `optimise_ghostscript_screen_preset` -- `optimise_qpdf_fallback_when_ghostscript_missing` -- `estimate_size_with_web_fonts` -- `parse_preset_from_form` - -### Integration Tests - -- `optimise_10mb_pdf_to_2mb` - Real compression -- `optimise_presets_produce_different_sizes` -- `estimate_warns_about_web_fonts` -- `response_header_includes_size_warning` - -### Performance Tests - -- `optimise_100mb_pdf_completes_in_30s` - -## Acceptance - -- [ ] `POST /forms/pdfengines/optimise` endpoint -- [ ] Three presets: screen/ebook/printer -- [ ] Auto backend selection (Ghostscript first) -- [ ] `POST /estimate` endpoint for size estimation -- [ ] Response headers with size warnings -- [ ] Unit tests for all functions -- [ ] Integration tests with real PDFs -- [ ] `cargo clippy -p engine -- -D warnings` clean - -## References - -- Gotenberg issue #521: https://github.com/gotenberg/gotenberg/issues/521 -- Gotenberg issue #1056: https://github.com/gotenberg/gotenberg/issues/1056 -- Ghostscript documentation: https://www.ghostscript.com/doc/9.56.1/Use.htm -- qpdf documentation: https://qpdf.readthedocs.io/ diff --git a/docs/specs/43-font-doctor.md b/docs/specs/43-font-doctor.md deleted file mode 100644 index 72b2796..0000000 --- a/docs/specs/43-font-doctor.md +++ /dev/null @@ -1,391 +0,0 @@ -# Spec 43 — Font Doctor - -> Diagnose and fix font-related rendering issues, the #2 -> complaint across PDF generation tools. Provides endpoints to -> detect missing fonts, suggest fixes, and validate font loading. - -## Goal - -Create a comprehensive font diagnostics system that detects, -diagnoses, and helps fix font-related issues in PDF -generation. Addresses Gotenberg issues #921, #1371, #861 -where users struggle with deformed numbers, missing fonts, and -intermittent rendering failures. - -## Problem Analysis - -### Real User Quotes (Gotenberg Issues) - -> "Numbers 6 and 8 get a bigger font size than other -> numbers after conversion... The problem isn't with the HTML, -> everything renders just fine. After conversion the resulted -> PDF file shows this problem." -> — Issue #921 - -> "Every so often a PDF generated with Gotenberg 8 will -> lack all fonts loaded with CSS @font-face... It seems -> standard fonts work, the header and footer are both using -> font-family: 'Helvetica Neue', Helvetica, Roboto, Arial, -> sans-serif; I suppose a workaround could be to rebuild -> the Docker container" -> — Discussion #861 - -> "Custom fonts not working on versions >8.21.1... -> After upgrading to 8.30.0: The font stack was -> simplified from 30+ packages to 8. Documents relying on -> Microsoft Core Fonts now use metric-compatible replacements." -> — Issue #1371 - -### Root Causes - -| Problem | Impact | Detection Method | -|----------|--------|-------------------| -| Font not installed in container | Deformed text, wrong fonts | Check system fonts | -| Web fonts not loaded in time | Missing text | `waitForSelector` + font check | -| Chromium font cache issues | Intermittent failures | Clear cache, retry | -| Fallback fonts used | Layout shifts | Compare requested vs actual | -| Large web fonts | 10x PDF size | Check font file sizes | - -## Scope - -**In:** - -- `GET /debug/fonts` - List all system fonts -- `POST /debug/validate-fonts` - Check if fonts will render -- `POST /debug/diagnose-html` - Full font diagnostics for HTML -- Font loading wait mechanism (extend spec-36) -- Auto-suggestion for missing fonts -- Dockerfile generator for custom fonts - -**Out:** - -- Font installation via API (security risk) -- Automatic font downloading (copyright concerns) -- Font substitution algorithm (too complex) - -## Implementation - -### 1. Font Detection (`GET /debug/fonts`) - -```rust -// crates/server/src/routes/debug.rs - -use font_kit::source::SystemSource; - -/// List all system fonts with metadata. -pub async fn list_fonts() -> ApiResult<impl IntoResponse> { - let source = SystemSource::new(); - let fonts = source.all_fonts().map_err(|e| { - ApiError::Internal(format!("Failed to list fonts: {}", e)) - })?; - - let font_list: Vec<FontInfo> = fonts - .iter() - .map(|(path, font)| FontInfo { - name: font.name().to_string(), - family: font.family_name().to_string(), - style: format!("{:?}", font.style()), - path: path.to_string_lossy().to_string(), - size_bytes: std::fs::metadata(path) - .map(|m| m.len()) - .unwrap_or(0), - }) - .collect(); - - Ok(Json(FontList { fonts: font_list })) -} - -#[derive(Serialize)] -struct FontInfo { - name: String, - family: String, - style: String, - path: String, - size_bytes: u64, -} - -#[derive(Serialize)] -struct FontList { - fonts: Vec<FontInfo>, -} -``` - -### 2. Font Validation (`POST /debug/validate-fonts`) - -```rust -// Validate that fonts in CSS will render correctly - -pub async fn validate_fonts( - mp: Multipart, -) -> ApiResult<impl IntoResponse> { - let form = parse_multipart(mp).await?; - - let mut html = form.get("html").cloned(); - let mut css = form.get("css").cloned(); - let url = form.get("url").cloned(); - - // Extract font families from CSS/HTML - let font_families = extract_font_families(html, css, url).await?; - - // Check each font - let mut results = Vec::new(); - for family in font_families { - let status = check_font_availability(&family).await; - results.push(FontValidation { - family: family.clone(), - available: status.available, - installed_font: status.installed_font, - suggestion: status.suggestion, - }); - } - - Ok(Json(FontValidationResponse { fonts: results })) -} - -struct FontAvailability { - available: bool, - installed_font: Option<String>, - suggestion: Option<String>, -} - -async fn check_font_availability(family: &str) -> FontAvailability { - let source = SystemSource::new(); - - // Check if font is installed - if let Ok(fonts) = source.select_family_by_name(family) { - if !fonts.is_empty() { - return FontAvailability { - available: true, - installed_font: Some(fonts[0].name().to_string()), - suggestion: None, - }; - } - } - - // Not installed - suggest similar or default - let suggestion = find_similar_font(family); - - FontAvailability { - available: false, - installed_font: None, - suggestion: Some(format!( - "Font '{}' not installed. {}", - family, - suggestion.unwrap_or_else(|| "Install via: apt-get install ttf-mscorefonts-installer".into()) - )), - } -} -``` - -### 3. HTML Diagnostics (`POST /debug/diagnose-html`) - -```rust -// Full diagnostics for an HTML file - -pub async fn diagnose_html( - State(state): State<AppState>, - mp: Multipart, -) -> ApiResult<impl IntoResponse> { - let form = parse_multipart(mp).await?; - - let html = form.get("html").ok_or_else(|| { - ApiError::InvalidOption("html field required".into()) - })?; - - let mut diagnostics = HtmlDiagnostics { - fonts: Vec::new(), - warnings: Vec::new(), - suggestions: Vec::new(), - }; - - // 1. Extract all font families - let font_families = extract_font_families_from_html(&html); - for family in font_families { - let available = check_font_availability(&family).await; - if !available.available { - diagnostics.warnings.push(format!( - "Font '{}' not installed", - family - )); - if let Some(suggestion) = available.suggestion { - diagnostics.suggestions.push(suggestion); - } - } - diagnostics.fonts.push(FontDetail { - family: family, - installed: available.available, - path: available.installed_font, - }); - } - - // 2. Check for web fonts (will bloat PDF) - if has_web_fonts(&html) { - diagnostics.warnings.push( - "HTML uses web fonts - PDF size may increase by 200%".into() - ); - diagnostics.suggestions.push( - "Install fonts locally in Docker: apt-get install ttf-mscorefonts-installer".into() - ); - } - - // 3. Validate CSS @font-face declarations - let font_face_issues = validate_font_face(&html).await?; - diagnostics.warnings.extend(font_face_issues); - - Ok(Json(diagnostics)) -} - -#[derive(Serialize)] -struct HtmlDiagnostics { - fonts: Vec<FontDetail>, - warnings: Vec<String>, - suggestions: Vec<String>, -} -``` - -### 4. Font Wait Mechanism (Chromium) - -```rust -// Extend spec-36: wait for fonts to load - -// In chromium/mod.rs render function -if let Some(ref font_wait) = opts.wait_for_fonts { - // Wait for fonts to be loaded - let js = format!( - r#" - const fontsLoaded = await document.fonts.ready; - return fontsLoaded; - "# - ); - - page.evaluate(&js).await.map_err(|e| { - EngineError::Navigation { - url: "font-wait".into(), - reason: format!("Font loading timeout: {}", e), - } - })?; -} -``` - -### 5. Dockerfile Generator - -```bash -# Generated Dockerfile for custom fonts - -# Usage: POST /debug/generate-dockerfile -# Body: { "fonts": ["Comic Sans", "Helvetica Neue"] } - -pub async fn generate_dockerfile( - Json(request): Json<DockerfileRequest>, -) -> ApiResult<impl IntoResponse> { - let mut dockerfile = vec![ - "FROM gotenberg/gotenberg:latest".to_string(), - ]; - - for font in &request.fonts { - match font.as_str() { - "Comic Sans" => { - dockerfile.push("RUN apt-get update && apt-get install -y fonts-comic-sans".into()); - } - "Helvetica Neue" => { - dockerfile.push( - "COPY helvetica-neue.ttf /usr/share/fonts/truetype/".into() - ); - } - _ => { - dockerfile.push(format!( - "# TODO: Add installation command for {}", - font - )); - } - } - } - - Ok(TextResponse(dockerfile.join("\n"))) -} -``` - -## Expected Behaviour - -### `GET /debug/fonts` - -```json -{ - "fonts": [ - { - "name": "Arial", - "family": "Arial", - "style": "Normal", - "path": "/usr/share/fonts/truetype/arial.ttf", - "size_bytes": 786432 - } - ] -} -``` - -### `POST /debug/validate-fonts` - -```json -{ - "fonts": [ - { - "family": "Comic Sans", - "available": false, - "installed_font": null, - "suggestion": "Font 'Comic Sans' not installed. Install via: apt-get install fonts-comic-sans" - } - ] -} -``` - -### `POST /debug/diagnose-html` - -```json -{ - "fonts": [ - {"family": "Arial", "installed": true, "path": "/usr/share/fonts/arial.ttf"} - ], - "warnings": [ - "Font 'Helvetica Neue' not installed", - "HTML uses web fonts - PDF size may increase by 200%" - ], - "suggestions": [ - "Install fonts locally in Docker: apt-get install ttf-mscorefonts-installer" - ] -} -``` - -## Test Plan - -### Unit Tests - -- `list_fonts_returns_system_fonts` -- `check_font_availability_detects_missing` -- `extract_font_families_from_css` -- `validate_font_face_returns_errors` - -### Integration Tests - -- `diagnose_html_finds_missing_fonts` -- `validate_fonts_returns_suggestions` -- `dockerfile_generator_creates_valid_dockerfile` - -## Acceptance - -- [ ] `GET /debug/fonts` endpoint -- [ ] `POST /debug/validate-fonts` endpoint -- [ ] `POST /debug/diagnose-html` endpoint -- [ ] Font availability checking with suggestions -- [ ] Web font detection and warnings -- [ ] Dockerfile generator for custom fonts -- [ ] Unit tests for all font functions -- [ ] Integration tests with real HTML/CSS -- [ ] `cargo clippy -p server -- -D warnings` clean - -## References - -- Gotenberg issue #921: https://github.com/gotenberg/gotenberg/issues/921 -- Gotenberg issue #1371: https://github.com/gotenberg/gotenberg/issues/1371 -- Gotenberg discussion #861: https://github.com/gotenberg/gotenberg/discussions/861 -- font-kit crate: https://docs.rs/font-kit/ -- CSS @font-face spec: https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face diff --git a/docs/specs/44-crystal-clear-errors.md b/docs/specs/44-crystal-clear-errors.md deleted file mode 100644 index 97d0841..0000000 --- a/docs/specs/44-crystal-clear-errors.md +++ /dev/null @@ -1,389 +0,0 @@ -# Spec 44 — Crystal-Clear Error Messages - -> Replace generic "500 Internal Server Error" with actionable, -> structured error responses. Addresses Gotenberg issues #1356, -> #921, #1926 where users get opaque errors with no guidance. - -## Goal - -Transform error handling from generic HTTP status codes to -rich, actionable error responses that tell users exactly -what went wrong and how to fix it. This is the #3 complaint -across all PDF generation tools. - -## Problem Analysis - -### Real User Quotes - -> "Including web fonts in header or footer will cause 500 -> Error / Printing failed (-32000)... I feel Gotenberg should -> ignore it without performance impact or we should update the -> docs to reflect that." -> — Issue #1356 - -> "I've noticed some problems with converting html to pdf: for -> some reason the numbers 6 and 8 get a bigger font size -> than other numbers... I suppose a workaround could be to -> rebuild the Docker container" -> — Issue #921 - -> "Testing HTML / CSS fails to render correctly... it fails -> to render correctly. I am not sure where to start because -> it generated no error messages." -> — WeasyPrint issue #1926 - -### Current State (Bad) - -```json -{ - "error": "Printing failed (-32000)", - "code": "INTERNAL" -} -``` - -### Desired State (Good) - -```json -{ - "error": "PDF generation failed: image not loaded", - "code": "RESOURCE_TIMEOUT", - "details": { - "url": "https://cdn.example.com/image.png", - "timeout_ms": 30000, - "suggestion": "Add --form 'waitDelay=5s' or check URL accessibility" - }, - "documentation": "https://folio.dev/docs/troubleshooting#image-not-loaded" -} -``` - -## Scope - -**In:** - -- Structured error responses with suggestions -- Error code taxonomy (not just INTERNAL) -- Suggestions field with fix instructions -- Documentation links for each error type -- Field-level validation errors -- Resource-level error details (which URL failed) -- Stack trace in debug mode only - -**Out:** - -- Exposing internal paths (security risk) -- Full Chromium logs in production -- Arbitrary error message from engine (sanitisation needed) - -## Error Code Taxonomy - -### Conversion Errors - -| Code | HTTP Status | Description | Suggestion | -|------|-------------|-------------|------------| -| `NAVIGATION` | 502 | Failed to navigate to URL | Check URL accessibility | -| `TIMEOUT` | 504 | Conversion timed out | Increase `--request-timeout` | -| `INVALID_OPTION` | 400 | Bad form field value | Check field format | -| `INVALID_PAGE_RANGE` | 400 | Bad page range syntax | Use format "1-5,7" | -| `RESOURCE_TIMEOUT` | 502 | Sub-resource failed to load | Check CDN/network | -| `RESOURCE_404` | 502 | Sub-resource not found | Fix missing images/CSS | -| `CHROMIUM_CRASH` | 503 | Chromium process died | Restart or check memory | -| `LIBREOFFICE_CRASH` | 503 | LibreOffice failed | Check document format | -| `FONT_MISSING` | 200 + warning | Font not installed | Install font in Docker | -| `WEB_FONT_BLOAT` | 200 + warning | Web font increases size | Use local fonts | - -### Validation Errors - -| Code | Description | Suggestion | -|------|-------------|------------| -| `MISSING_FIELD` | Required field not provided | Add `files` or `url` field | -| `INVALID_PAPER_SIZE` | Bad paper dimensions | Use format "8.5,11" or "A4" | -| `INVALID_MARGIN` | Bad margin value | Use float like "1.0" | -| `INVALID_BOOL` | Not true/false | Use "true" or "false" | -| `INVALID_JSON` | Bad JSON in field | Check JSON syntax | - -## Implementation - -### 1. Enhanced Error Type - -```rust -// crates/engine/src/error.rs - -#[derive(Debug, Clone, Serialize)] -pub struct ApiErrorResponse { - pub error: String, - pub code: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub details: Option<ErrorDetails>, - #[serde(skip_serialising_if = "Option::is_none")] - pub suggestion: Option<String>, - #[serde(skip_serialising_if = "Option::is_none")] - pub documentation: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ErrorDetails { - pub url: Option<String>, - pub timeout_ms: Option<u64>, - pub field: Option<String>, - pub value: Option<String>, - pub resource_errors: Option<Vec<ResourceError>>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ResourceError { - pub url: String, - pub status_code: Option<u16>, - pub error: String, -} - -impl ApiError { - pub fn to_response(&self) -> (StatusCode, Json<ApiErrorResponse>) { - match self { - ApiError::Navigation { url, reason } => ( - StatusCode::BAD_GATEWAY, - Json(ApiErrorResponse { - error: format!("Navigation failed: {}", reason), - code: "NAVIGATION".into(), - details: Some(ErrorDetails { - url: Some(url.clone()), - ..Default::default() - }), - suggestion: Some(format!( - "Check that {} is accessible. Try with waitDelay=5s", - url - )), - documentation: Some( - "https://folio.dev/docs/troubleshooting#navigation-failed".into() - ), - }) - ), - - ApiError::Timeout(duration) => ( - StatusCode::GATEWAY_TIMEOUT, - Json(ApiErrorResponse { - error: "Conversion timed out".into(), - code: "TIMEOUT".into(), - details: Some(ErrorDetails { - timeout_ms: Some(duration.as_millis() as u64), - ..Default::default() - }), - suggestion: Some(format!( - "Increase timeout: --request-timeout {}s", - duration.as_secs() * 2 - )), - documentation: Some( - "https://folio.dev/docs/troubleshooting#timeout".into() - ), - }) - ), - - ApiError::InvalidOption(msg) => ( - StatusCode::BAD_REQUEST, - Json(ApiErrorResponse { - error: msg.clone(), - code: "INVALID_OPTION".into(), - suggestion: Some( - "Check field format in documentation".into() - ), - documentation: Some( - "https://folio.dev/docs/api#form-fields".into() - ), - ..Default::default() - }) - ), - - // ... handle all error variants - } - } -} -``` - -### 2. Resource Error Collection - -```rust -// In chromium/mod.rs - collect resource errors - -struct ResourceErrorCollector { - errors: Vec<ResourceError>, -} - -impl ResourceErrorCollector { - fn new() -> Self { - Self { errors: Vec::new() } - } - - async fn monitor_page(&mut self, page: &Page) { - // Listen for failed requests - page.event_listener::<RequestFailed>() - .await - .for_each(|event| { - if let Some(status) = event.response_status { - if status >= 400 { - self.errors.push(ResourceError { - url: event.request_url.unwrap_or_default(), - status_code: Some(status), - error: format!("HTTP {}", status), - }); - } - } - }); - } - - fn into_api_error(self) -> Option<ApiError> { - if self.errors.is_empty() { - None - } else { - Some(ApiError::ResourceErrors(self.errors)) - } - } -} -``` - -### 3. Field-Level Validation - -```rust -// Improved form parsing with field-level errors - -pub fn parse_paper_size(form: &HashMap<String, String>) -> Result<(f64, f64), ApiError> { - let value = form.get("paperSize").ok_or_else(|| { - ApiError::InvalidOption( - "paperSize field is required".into() - ) - })?; - - // Try named sizes - let dimensions = match value.as_str() { - "A4" => (210.0, 297.0), - "Letter" => (215.9, 279.4), - "Legal" => (215.9, 355.6), - _ => { - // Try "W,H" format - let parts: Vec<&str> = value.split(',').collect(); - if parts.len() != 2 { - return Err(ApiError::InvalidOption( - format!( - "Invalid paperSize: '{}'. Use 'A4', 'Letter', or 'W,H' format (e.g., '8.5,11')", - value - ) - )); - } - - let w = parts[0].parse::<f64>().map_err(|_| { - ApiError::InvalidOption(format!( - "Invalid paperSize width: '{}'. Must be a number", - parts[0] - )) - })?; - - let h = parts[1].parse::<f64>().map_err(|_| { - ApiError::InvalidOption(format!( - "Invalid paperSize height: '{}'. Must be a number", - parts[1] - )) - })?; - - (w, h) - } - }; - - Ok(dimensions) -} -``` - -### 4. Documentation Links - -```rust -// Auto-generate documentation links - -fn documentation_link(error_code: &str) -> String { - match error_code { - "NAVIGATION" => "https://folio.dev/docs/troubleshooting#navigation-failed", - "TIMEOUT" => "https://folio.dev/docs/troubleshooting#timeout", - "INVALID_OPTION" => "https://folio.dev/docs/api#form-fields", - "RESOURCE_TIMEOUT" => "https://folio.dev/docs/troubleshooting#resource-failed", - "CHROMIUM_CRASH" => "https://folio.dev/docs/troubleshooting#chromium-crash", - _ => "https://folio.dev/docs/troubleshooting", - }.into() -} -``` - -## Expected Behaviour - -### Good Error (Resource Failed) - -```json -{ - "error": "Image not loaded", - "code": "RESOURCE_TIMEOUT", - "details": { - "url": "https://cdn.example.com/image.png", - "timeout_ms": 30000 - }, - "suggestion": "Add --form 'waitDelay=5s' or check URL accessibility. CDN may be blocking requests.", - "documentation": "https://folio.dev/docs/troubleshooting#resource-timeout" -} -``` - -### Good Error (Invalid Option) - -```json -{ - "error": "Invalid paperSize: 'A5'. Use 'A4', 'Letter', or 'W,H' format (e.g., '8.5,11')", - "code": "INVALID_OPTION", - "details": { - "field": "paperSize", - "value": "A5" - }, - "suggestion": "Valid values: A4, Letter, Legal, or 'W,H' (e.g., '8.5,11')", - "documentation": "https://folio.dev/docs/api#form-fields" -} -``` - -### Warning (Not Error) - -```json -{ - "result": "ok", - "warnings": [ - { - "code": "FONT_MISSING", - "message": "Font 'Comic Sans' not installed", - "suggestion": "Install in Docker: apt-get install fonts-comic-sans" - } - ] -} -``` - -## Test Plan - -### Unit Tests - -- `error_response_has_suggestion_field` -- `resource_error_collection_captures_failed_requests` -- `field_validation_returns_helpful_message` -- `documentation_link_matches_error_code` - -### Integration Tests - -- `navigation_error_returns_url_in_details` -- `timeout_error_suggests_increasing_timeout` -- `invalid_option_error_shows_valid_values` -- `resource_errors_list_all_failed_urls` - -## Acceptance - -- [ ] `ApiErrorResponse` struct with all fields -- [ ] All error variants return structured responses -- [ ] Resource error collection in Chromium -- [ ] Field-level validation with suggestions -- [ ] Documentation links for each error type -- [ ] Unit tests for error formatting -- [ ] Integration tests for all error scenarios -- [ ] `cargo clippy -p server -- -D warnings` clean - -## References - -- Gotenberg issue #1356: https://github.com/gotenberg/gotenberg/issues/1356 -- Gotenberg issue #921: https://github.com/gotenberg/gotenberg/issues/921 -- WeasyPrint issue #1926: https://github.com/Kozea/WeasyPrint/issues/1926 -- RFC 7807: Problem Details for HTTP APIs: https://tools.ietf.org/html/rfc7807 diff --git a/docs/specs/45-live-preview-mode.md b/docs/specs/45-live-preview-mode.md deleted file mode 100644 index 94b6609..0000000 --- a/docs/specs/45-live-preview-mode.md +++ /dev/null @@ -1,292 +0,0 @@ -# Spec 45 — Live Preview Mode - -> Provide lightweight preview of HTML/URL before full PDF -> generation. Helps debug rendering issues - a unique Folio -> feature that Gotenberg cannot easily replicate. - -## Goal - -Create a live preview system that renders HTML/URL to -lightweight images for quick debugging. Solves the "why does -my PDF look bad?" problem (Gotenberg issues #921, #861). - -## Problem Analysis# - -### User Complaints (Gotenberg Discussions) - -> "Every so often a PDF generated with Gotenberg 8 will -> lack all fonts loaded with CSS @font-face... Tryed -> implementing waitForExpression as 'document.readyState === -> \"complete\"'... No idea what's going on" -> — Discussion #861 - -> "Numbers 6 and 8 get a bigger font size than other -> numbers after conversion... I suppose a workaround could -> be to rebuild the Docker container" -> — Issue #921 - -### Root Cause - -Users have no way to see what the browser is rendering BEFORE -generating the full PDF. They're flying blind. - -## Scope# - -**In:** - -- `GET /preview/html?url=...` - Preview URL as image -- `POST /preview/html` - Preview HTML as image -- `GET /preview/markdown?url=...` - Preview Markdown -- Multiple preview formats: png, jpeg, webp -- Preview dimensions: viewport size, clip region -- Auto-refresh for iterative debugging -- Compare mode: before/after changes - -**Out:** - -- Full PDF preview (too heavy) -- Interactive browser session (complex) -- Screenshot comparison (separate tool) - -## Implementation# - -### 1. Preview Endpoints# - -```rust -// crates/server/src/routes/preview.rs - -use axum::extract::Query; - -#[derive(Deserialize)] -struct PreviewQuery { - url: String, - format: Option<String>, // png, jpeg, webp - width: Option<u32>, // viewport width - height: Option<u32>, // viewport height - clip_x: Option<f64>, - clip_y: Option<f64>, - clip_width: Option<f64>, - clip_height: Option<f64>, -} - -/// Preview URL as image. -pub async fn preview_url( - State(state): State<AppState>, - Query(query): Query<PreviewQuery>, -) -> ApiResult<impl IntoResponse> { - let start = Instant::now(); - - // Validate format - let format = query.format.as_deref().unwrap_or("png"); - if !["png", "jpeg", "webp"].contains(&format) { - return Err(ApiError::InvalidOption( - format!("Invalid format: '{}'. Use png/jpeg/webp", format) - )); - } - - // Build screenshot options - let mut opts = ScreenshotOptions::default(); - if let Some(w) = query.width { - opts.viewport_width = w; - } - if let Some(h) = query.height { - opts.viewport_height = h; - } - - // Capture screenshot - let result = state - .chromium - .as_ref() - .unwrap() - .screenshot_url(&query.url, &opts) - .await - .map_err(|e| ApiError::from(e))?; - - let duration = start.elapsed().as_secs_f64(); - tracing::info!( - url = %query.url, - format = %format, - duration_ms = duration * 1000.0, - "Preview generated" - ); - - // Return image - let content_type = match format { - "jpeg" => "image/jpeg", - "webp" => "image/webp", - _ => "image/png", - }; - - Ok(( - [(header::CONTENT_TYPE, HeaderValue::from_static(content_type))], - result, - )) -} -``` - -### 2. HTML Preview with Form# - -```rust -/// Preview HTML file as image. -pub async fn preview_html( - State(state): State<AppState>, - mp: Multipart, -) -> ApiResult<impl IntoResponse> { - let form = parse_multipart(mp).await?; - - let html = form.get("files") - .ok_or_else(|| ApiError::InvalidOption("HTML file required".into()))?; - - let mut opts = ScreenshotOptions::default(); - if let Some(format) = form.get("format") { - opts.format = format.clone(); - } - - let result = state - .chromium - .as_ref() - .unwrap() - .screenshot_html(html, None, &opts) - .await - .map_err(|e| ApiError::from(e))?; - - image_response(result, &opts.format) -} -``` - -### 3. Preview Options# - -```rust -// crates/engine/src/chromium/screenshot.rs - -pub struct ScreenshotOptions { - pub format: String, // png, jpeg, webp - pub quality: u8, // 1-100 for jpeg/webp - pub viewport_width: u32, // Default 1920 - pub viewport_height: u32, // Default 1080 - pub clip: Option<ClipRect>, - pub full_page: bool, // Screenshot full scrollable page -} - -pub struct ClipRect { - pub x: f64, - pub y: f64, - pub width: f64, - pub height: f64, -} -``` - -### 4. Compare Mode (Advanced)# - -```rust -/// Compare two versions side by side. -pub async fn preview_compare( - State(state): State<AppState>, - mp: Multipart, -) -> ApiResult<impl IntoResponse> { - let form = parse_multipart(mp).await?; - - let before = form.get("before") - .ok_or_else(|| ApiError::InvalidOption("'before' required".into()))?; - let after = form.get("after") - .ok_or_else(|| ApiError::InvalidOption("'after' required".into()))?; - - // Screenshot both - let img1 = state.chromium.as_ref().unwrap() - .screenshot_html(before, None, &Default::default()) - .await?; - let img2 = state.chromium.as_ref().unwrap() - .screenshot_html(after, None, &Default::default()) - .await?; - - // Create side-by-side comparison image - let comparison = create_comparison_image(&img1, &img2)?; - - image_response(comparison, "png") -} -``` - -## Form Fields# - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `url` | string | required | URL to preview | -| `files` | file | required | HTML file to preview | -| `format` | string | "png" | Output format: png/jpeg/webp | -| `quality` | int | 90 | JPEG/WebP quality (1-100) | -| `width` | int | 1920 | Viewport width | -| `height` | int | 1080 | Viewport height | -| `fullPage` | bool | false | Capture full scrollable page | -| `clip.x` | float | 0 | Clip rectangle X | -| `clip.y` | float | 0 | Clip rectangle Y | -| `clip.width` | float | viewport | Clip width | -| `clip.height` | float | viewport | Clip height | - -## Expected Behaviour# - -### Preview URL - -```bash -# Quick preview -curl "http://localhost:3000/preview/url?url=https://example.com" -o preview.png - -# High-quality JPEG -curl "http://localhost:3000/preview/url?url=https://example.com&format=jpeg&quality=95" -o preview.jpg - -# Custom viewport -curl "http://localhost:3000/preview/url?url=https://example.com&width=375&height=667" -o mobile.png -``` - -### Preview HTML - -```bash -curl -X POST http://localhost:3000/preview/html \ - --form files=@index.html \ - --form format=png \ - -o preview.png -``` - -### Compare Mode - -```bash -curl -X POST http://localhost:3000/preview/compare \ - --form before=@old.html \ - --form after=@new.html \ - -o comparison.png -``` - -## Test Plan# - -### Unit Tests - -- `preview_url_returns_png_by_default` -- `preview_html_with_jpeg_format` -- `invalid_format_returns_400` -- `viewport_dimensions_applied` - -### Integration Tests# - -- `preview_url_returns_valid_image` -- `preview_html_screenshot_matches_viewport` -- `compare_mode_creates_side_by_side` -- `full_page_captures_scrollable_content` - -## Acceptance# - -- [ ] `GET /preview/url` endpoint -- [ ] `POST /preview/html` endpoint -- [ ] `GET /preview/markdown` endpoint -- [ ] Format selection: png/jpeg/webp -- [ ] Viewport dimensions applied -- [ ] Clip rectangle support -- [ ] Compare mode for debugging -- [ ] Unit tests for all endpoints -- [ ] Integration tests with real browser -- [ ] `cargo clippy -p server -- -D warnings` clean - -## References# - -- Gotenberg discussion #861: https://github.com/gotenberg/gotenberg/discussions/861 -- Gotenberg issue #921: https://github.com/gotenberg/gotenberg/issues/921 -- Chromium screenshot API: https://chromedevtools.github.io/devtools-protocol/1-3/Page/#method-captureScreenshot -- axum response handling: https://docs.rs/axum/latest/axum/response/ diff --git a/docs/specs/46-pdf-size-estimator.md b/docs/specs/46-pdf-size-estimator.md deleted file mode 100644 index 84a2ccf..0000000 --- a/docs/specs/46-pdf-size-estimator.md +++ /dev/null @@ -1,301 +0,0 @@ -# Spec 46 — PDF Size Estimator - -> Proactively warn users about PDF size before conversion. -> Solves the #1 complaint: "PDFs 8x larger than -> wkhtmltopdf" (Gotenberg issues #521, #1056, #1067). - -## Goal - -Create a pre-flight estimation system that analyses -HTML/CSS/fonts/images and predicts output PDF size. -Gives users actionable warnings BEFORE they waste -time converting a document that will be too large. - -## Problem Analysis# - -### User Quotes (Gotenberg Issues) - -> "Gotenberg generates larger PDFs than Chromium, AthenaPDF -> and Firefox... noticed a significant increase of file -> size... This unfortunately broke our integration with other -> tools, which enforce a file size limit" -> — Issue #521 - -> "HTML to PDF file size 8X larger than wkhtmltopdf... -> We recently switched from wkhtmltopdf to Gotenberg..." -> — Issue #1056 - -> "Generated PDF sizes with v8.x are ~2-3x larger -> than same generated PDF on v7.x... 286kb vs 795kb" -> — Issue #1067 - -### Root Causes Identified# - -| Factor | Size Impact | Detection Method | -|--------|------------|-------------------| -| Web fonts (Google Fonts) | +200% | Scan CSS for @font-face | -| White background paths (Chromium bug) | +50% | Check printBackground=false | -| Images not optimised | +300% | Check image dimensions | -| Font not installed locally | +100% | Compare with system fonts | -| No compression applied | +400% | Check if Ghostscript needed | - -## Scope# - -**In:** - -- `POST /estimate` - Analyse HTML/URL and return size prediction -- `POST /estimate/batch` - Estimate multiple URLs -- Size breakdown: fonts, images, markup, overhead -- Warning thresholds: 5MB (warn), 10MB (error) -- Suggestions: install fonts, optimise images, use Ghostscript -- Factor analysis: what contributes most to size -- Comparison: vs Gotenberg, vs wkhtmltopdf - -**Out:** - -- Actual conversion (that's other endpoints) -- File size limits (policy, not estimation) -- Automatic optimisation (see spec-42) - -## Implementation# - -### 1. Estimation Endpoint# - -```rust -// crates/server/src/routes/estimate.rs - -#[derive(Deserialize)] -struct EstimateRequest { - url: Option<String>, - html: Option<String>, - files: Option<Vec<String>>, -} - -#[derive(Serialize)] -struct EstimateResponse { - estimated_size_mb: f64, - confidence: String, // "high", "medium", "low" - breakdown: SizeBreakdown, - warnings: Vec<String>, - suggestions: Vec<String>, - comparison: Option<Comparison>, -} - -#[derive(Serialize)] -struct SizeBreakdown { - fonts_mb: f64, - images_mb: f64, - markup_mb: f64, - overhead_mb: f64, -} - -pub async fn estimate( - State(state): State<AppState>, - Json(req): Json<EstimateRequest>, -) -> ApiResult<impl IntoResponse> { - let mut breakdown = SizeBreakdown { - fonts_mb: 0.0, - images_mb: 0.0, - markup_mb: 0.0, - overhead_mb: 0.5, // Base PDF overhead - }; - - let mut warnings = Vec::new(); - let mut suggestions = Vec::new(); - - // Analyse HTML/CSS - if let Some(ref html) = req.html { - let analysis = analyse_html(html).await?; - breakdown.markup_mb += analysis.markup_size_mb; - breakdown.fonts_mb += analysis.font_size_mb; - breakdown.images_mb += analysis.image_size_mb; - - if analysis.has_web_fonts { - warnings.push( - "Uses web fonts - may increase size by 200%".into() - ); - suggestions.push( - "Install fonts locally: apt-get install ttf-mscorefonts-installer".into() - ); - } - - if analysis.large_images { - warnings.push( - "Contains large images - consider optimisation".into() - ); - } - } - - // Estimate total - let estimated_mb = breakdown.fonts_mb - + breakdown.images_mb - + breakdown.markup_mb - + breakdown.overhead_mb; - - // Add warnings based on thresholds - if estimated_mb > 10.0 { - warnings.push(format!( - "Estimated size {:.1} MB exceeds 10 MB limit", - estimated_mb - )); - suggestions.push( - "Consider POST /forms/pdfengines/optimise after conversion".into() - ); - } else if estimated_mb > 5.0 { - warnings.push(format!( - "Estimated size {:.1} MB is quite large", - estimated_mb - )); - } - - Ok(Json(EstimateResponse { - estimated_size_mb: estimated_mb, - confidence: "medium".into(), - breakdown, - warnings, - suggestions, - comparison: None, // TODO: compare with Gotenberg - })) -} -``` - -### 2. HTML Analysis# - -```rust -// crates/server/src/analysis/html.rs - -struct HtmlAnalysis { - markup_size_mb: f64, - font_size_mb: f64, - image_size_mb: f64, - has_web_fonts: bool, - large_images: bool, -} - -async fn analyse_html(html: &str) -> Result<HtmlAnalysis, EngineError> { - let mut result = HtmlAnalysis { - markup_size_mb: (html.len() as f64) / 1_000_000.0, - font_size_mb: 0.0, - image_size_mb: 0.0, - has_web_fonts: false, - large_images: false, - }; - - // Check for web fonts - if html.contains("@font-face") { - result.has_web_fonts = true; - // Estimate: each web font ~500KB - let font_count = html.matches("@font-face").count(); - result.font_size_mb += font_count as f64 * 0.5; - } - - // Check for images - let img_pattern = regex::Regex::new(r#"img[^>]+src="([^"]+)""#).unwrap(); - for cap in img_pattern.captures_iter(html) { - let src = &cap[1]; - if src.starts_with("http") || src.starts_with("data:") { - result.large_images = true; - result.image_size_mb += 1.0; // Estimate - } - } - - Ok(result) -} -``` - -### 3. Batch Estimation# - -```rust -/// Estimate multiple URLs at once. -pub async fn estimate_batch( - State(state): State<AppState>, - Json(req): Json<Vec<String>>, -) -> ApiResult<impl IntoResponse> { - let mut results = Vec::new(); - - for url in req { - let estimate = estimate_single_url(&state, &url).await; - results.push((url, estimate)); - } - - Ok(Json(BatchEstimateResponse { results })) -} -``` - -## Expected Behaviour# - -### Estimation Request# - -```json -POST /estimate -{ - "html": "<html><head><style>@font-face { font-family: 'Comic Sans'; src: url(font.woff2); }</style></head><body><p>Hello</p><img src=\"large.jpg\"></body></html>" -} -``` - -### Estimation Response# - -```json -{ - "estimated_size_mb": 3.5, - "confidence": "medium", - "breakdown": { - "fonts_mb": 2.0, - "images_mb": 1.0, - "markup_mb": 0.002, - "overhead_mb": 0.5 - }, - "warnings": [ - "Uses web fonts - may increase size by 200%", - "Contains large images - consider optimisation" - ], - "suggestions": [ - "Install fonts locally: apt-get install ttf-mscorefonts-installer", - "Consider POST /forms/pdfengines/optimise after conversion" - ] -} -``` - -### Size Thresholds# - -| Estimated Size | Action | -|---------------|--------| -| <5 MB | ✅ Proceed (no warning) | -| 5-10 MB | ⚠️ Warning in response | -| >10 MB | 🔥 Error suggestion + optimisation tip | - -## Test Plan# - -### Unit Tests# - -- `estimate_html_with_web_fonts` -- `estimate_html_with_large_images` -- `breakdown_calculates_correctly` -- `threshold_warnings_triggered` - -### Integration Tests# - -- `estimate_url_returns_valid_prediction` -- `batch_estimate_handles_10_urls` -- `web_fonts_warning_included` -- `optimisation_suggestion_provided` - -## Acceptance# - -- [ ] `POST /estimate` endpoint -- [ ] `POST /estimate/batch` endpoint -- [ ] Size breakdown: fonts/images/markup/overhead -- [ ] Warning thresholds: 5MB/10MB -- [ ] Web font detection -- [ ] Large image detection -- [ ] Suggestions for optimisation -- [ ] Unit tests for analysis functions -- [ ] Integration tests with real HTML -- [ ] `cargo clippy -p server -- -D warnings` clean - -## References# - -- Gotenberg issue #521: https://github.com/gotenberg/gotenberg/issues/521 -- Gotenberg issue #1056: https://github.com/gotenberg/gotenberg/issues/1056 -- Gotenberg issue #1067: https://github.com/gotenberg/gotenberg/issues/1067 -- Web font size impact: https://github.com/puppeteer/puppeteer/issues/3939 diff --git a/docs/specs/47-one-command-install.md b/docs/specs/47-one-command-install.md deleted file mode 100644 index d7d8eaf..0000000 --- a/docs/specs/47-one-command-install.md +++ /dev/null @@ -1,430 +0,0 @@ -# Spec 47 — One-Command Install - -> Make Folio the easiest PDF generation tool to install. -> Gotenberg requires Docker + Chrome + LibreOffice setup. -> Folio should be: `curl -sSL https://folio.dev/install.sh | bash` - -## Goal - -Create a frictionless installation experience that gets -users from "nothing" to "first PDF in 30 seconds". -This is critical for adoption (see wkhtmltopdf archived 2023 -due to installation complexity). - -## Problem Analysis# - -### Current State (Painful)# - -#### Gotenberg (Requires Docker)# - -```bash -# Gotenberg installation (complex) -docker pull gotenberg/gotenberg:8 -docker run -p 3000:3000 gotenberg/gotenberg:8 - -# Need Chrome + LibreOffice in container -# Custom fonts? Edit Dockerfile -# Upgrade? Re-pull image -``` - -#### Folio (Current State)# - -```bash -# Install Rust (if not installed) -curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh - -# Clone repo -git clone https://github.com/yourusername/folio.git -cd folio - -# Build (long!) -cargo build --release -p server - -# Install Chrome + LibreOffice -apt-get install chromium libreoffice # Linux -brew install chromium libreoffice # macOS -``` - -### Desired State (One Command)# - -```bash -# The dream -curl -sSL https://folio.dev/install.sh | bash - -# Or via package managers -brew install folio -npm install -g folio -pip install folio -``` - -## Scope# - -**In:** - -- **Install scripts** for Linux (apt/yum), macOS (brew), Windows (chocolatey) -- **Pre-built binaries** for all platforms (GitHub Releases) -- **Package manager support**: Homebrew, npm, pip, cargo -- **Docker images** (slim + full variants) -- **Auto-detection** of Chrome/LibreOffice paths -- **Font installation** helper in install script -- **Post-install test**: verify conversion works - -**Out:** - -- Auto-update mechanism (security risk) -- In-app installation of Chrome/LibreOffice (complex) -- Cloud deployment (separate: spec-40) - -## Implementation# - -### 1. Install Script (Unix)# - -```bash -#!/bin/bash -# install.sh - One-command Folio installer -# Usage: curl -sSL https://folio.dev/install.sh | bash - -set -e - -FOLIO_VERSION="latest" -INSTALL_DIR="/usr/local/bin" -REPO="yourusername/folio" - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -info() { - echo -e "${GREEN}[INFO]${NC} $1" -} - -warn() { - echo -e "${YELLOW}[WARN]${NC} $1" -} - -error() { - echo -e "${RED}[ERROR]${NC} $1" - exit 1 -} - -# Detect OS -detect_os() { - if [[ "$OSTYPE" == "linux-gnu"* ]]; then - echo "linux" - elif [[ "$OSTYPE" == "darwin"* ]]; then - echo "macos" - elif [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]]; then - echo "windows" - else - error "Unsupported OS: $OSTYPE" - fi -} - -OS=$(detect_os) -info "Detected OS: $OS" - -# Check for required tools -check_dependencies() { - if ! command -v curl &> /dev/null; then - error "curl is required but not installed" - fi - - if ! command -v tar &> /dev/null; then - error "tar is required but not installed" - fi -} - -# Download and install binary -install_folio() { - info "Downloading Folio $FOLIO_VERSION..." - - ARCH=$(uname -m) - case "$ARCH" in - x86_64) - ARCH="amd64" - ;; - aarch64|arm64) - ARCH="arm64" - ;; - *) - error "Unsupported architecture: $ARCH" - ;; - esac - - BINARY="folio-server-${OS}-${ARCH}.tar.gz" - DOWNLOAD_URL="https://github.com/${REPO}/releases/${FOLIO_VERSION}/download/${BINARY}" - - info "Downloading from $DOWNLOAD_URL" - curl -sSL -o /tmp/folio.tar.gz "$DOWNLOAD_URL" || error "Download failed" - - info "Installing to $INSTALL_DIR" - tar -xzf /tmp/folio.tar.gz -C "$INSTALL_DIR" - chmod +x "$INSTALL_DIR/folio-server" - - rm /tmp/folio.tar.gz -} - -# Check for Chrome/Chromium -check_chromium() { - if command -v chromium-browser &> /dev/null; then - info "Found Chromium: $(which chromium-browser)" - elif command -v chromium &> /dev/null; then - info "Found Chromium: $(which chromium)" - elif command -v google-chrome &> /dev/null; then - info "Found Chrome: $(which google-chrome)" - else - warn "Chromium/Chrome not found. Installing..." - if [[ "$OS" == "linux" ]]; then - if command -v apt-get &> /dev/null; then - sudo apt-get update && sudo apt-get install -y chromium-browser - elif command -v yum &> /dev/null; then - sudo yum install -y chromium - fi - elif [[ "$OS" == "macos" ]]; then - brew install chromium - fi - fi -} - -# Check for LibreOffice -check_libreoffice() { - if command -v soffice &> /dev/null; then - info "Found LibreOffice: $(which soffice)" - else - warn "LibreOffice not found. Installing..." - if [[ "$OS" == "linux" ]]; then - if command -v apt-get &> /dev/null; then - sudo apt-get update && sudo apt-get install -y libreoffice - elif command -v yum &> /dev/null; then - sudo yum install -y libreoffice - fi - elif [[ "$OS" == "macos" ]]; then - brew install libreoffice - fi - fi -} - -# Install common fonts -install_fonts() { - info "Installing common fonts..." - if [[ "$OS" == "linux" ]]; then - if command -v apt-get &> /dev/null; then - sudo apt-get install -y ttf-mscorefonts-installer || warn "Failed to install MS fonts" - fi - fi -} - -# Post-install test -test_installation() { - info "Testing installation..." - - # Start Folio in background - folio-server --port 13000 & - PID=$! - - sleep 3 - - # Test health endpoint - if curl -s http://localhost:13000/health | grep -q "up"; then - info "✅ Folio is working!" - else - warn "Health check failed" - fi - - # Test conversion - echo "<h1>Test</h1>" > /tmp/test.html - if curl -s -X POST http://localhost:13000/forms/chromium/convert/html \ - --form files=@/tmp/test.html -o /tmp/test.pdf; then - info "✅ PDF conversion works!" - else - warn "PDF conversion failed" - fi - - # Cleanup - kill $PID 2>/dev/null || true - rm /tmp/test.html /tmp/test.pdf 2>/dev/null || true -} - -# Main -main() { - info "Installing Folio..." - - check_dependencies - install_folio - check_chromium - check_libreoffice - install_fonts - test_installation - - info "✅ Folio installation complete!" - info "Start Folio: folio-server --port 3000" - info "Convert HTML: curl -X POST http://localhost:3000/forms/chromium/convert/html --form files=@file.html" -} - -main -``` - -### 2. Package Manager Configs# - -#### Homebrew (macOS)# - -```ruby -# Formula/folio.rb -class Folio < Formula - desc "Modern, Rust-native PDF generation engine" - homepage "https://folio.dev" - url "https://github.com/yourusername/folio/releases/download/v0.1.0/folio-server-darwin-amd64.tar.gz" - sha256 "..." - - depends_on "chromium" - depends_on "libreoffice" - - def install - bin.install "folio-server" - (bin/"folio-server").chmod 0755 - end - - test do - system "#{bin}/folio-server", "--version" - end -end -``` - -#### npm (Node.js)# - -```json -{ - "name": "folio", - "version": "0.1.0", - "description": "Folio PDF generation - Gotenberg-compatible API", - "bin": { - "folio-server": "./bin/folio-server.js" - }, - "scripts": { - "postinstall": "node install.js" - }, - "dependencies": {} -} -``` - -#### PyPI (Python)# - -```python -# setup.py -from setuptools import setup - -setup( - name="folio", - version="0.1.0", - description="Folio PDF generation - Gotenberg-compatible API", - scripts=["bin/folio-server"], - install_requires=[], -) -``` - -### 3. GitHub Actions (Auto-release)# - -```yaml -# .github/workflows/release.yml -name: Release - -on: - push: - tags: - - 'v*' - -jobs: - release: - runs-on: ubuntu-latest - strategy: - matrix: - os: [linux, macos, windows] - arch: [amd64, arm64] - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-rust@v1 - - name: Build - run: cargo build --release -p server - - name: Package - run: | - tar -czf folio-server-${{ matrix.os }}-${{ matrix.arch }}.tar.gz \ - -C target/release folio-server - - name: Release - uses: softprops/action-gh-release@v1 - with: - files: folio-server-*.tar.gz -``` - -## Expected Behaviour# - -### One-Command Install# - -```bash -# Linux/macOS -curl -sSL https://folio.dev/install.sh | bash - -# Homebrew -brew install folio - -# npm -npm install -g folio - -# Python -pip install folio - -# Cargo -cargo install folio-server -``` - -### Post-Install Test# - -```bash -$ curl -sSL https://folio.dev/install.sh | bash -[INFO] Detected OS: linux -[INFO] Downloading Folio latest... -[INFO] Installing to /usr/local/bin -[INFO] Found Chromium: /usr/bin/chromium-browser -[INFO] Found LibreOffice: /usr/bin/soffice -[INFO] Installing common fonts... -[INFO] Testing installation... -[INFO] ✅ Folio is working! -[INFO] ✅ PDF conversion works! -[INFO] ✅ Folio installation complete! -[INFO] Start Folio: folio-server --port 3000 -``` - -## Test Plan# - -### Unit Tests# - -- `install_script_detects_linux` -- `install_script_detects_macos` -- `post_install_test_passes` - -### Integration Tests# - -- `one_command_install_linux` -- `one_command_install_macos` -- `homebrew_install_works` -- `npm_install_works` - -## Acceptance# - -- [ ] `install.sh` script for Unix-like systems -- [ ] Homebrew formula (macOS) -- [ ] npm package (Node.js) -- [ ] PyPI package (Python) -- [ ] GitHub Actions for auto-release -- [ ] Pre-built binaries for all platforms -- [ ] Auto-detection of Chrome/LibreOffice -- [ ] Post-install test suite -- [ ] `cargo clippy -p server -- -D warnings` clean - -## References# - -- Gotenberg Docker install: https://gotenberg.dev/docs/getting-started/installation -- Homebrew formula guide: https://docs.brew.sh/Formula-Cookbook/ -- npm package creation: https://docs.npmjs.com/creating-and-publishing-unscoped-public-packages -- PyPI packaging: https://packaging.python.org/tutorials/packaging-projects/ diff --git a/docs/specs/48-interactive-docs.md b/docs/specs/48-interactive-docs.md deleted file mode 100644 index 71ff274..0000000 --- a/docs/specs/48-interactive-docs.md +++ /dev/null @@ -1,312 +0,0 @@ -# Spec 48 — Interactive Documentation# - -> Built-in API explorer and interactive docs. Gotenberg -> has static docs only. Folio should have "Try it now" -> buttons, live testing, and interactive API exploration. - -## Goal# - -Create an interactive documentation system that lets users -test Folio endpoints directly from the browser. -No external tools needed - just visit `/docs` and start -converting. This dramatically lowers the barrier to entry. - -## Problem Analysis# - -### Current State (Bad)# - -#### Gotenberg# -- Static docs at `gotenberg.dev/docs` -- Users need `curl`/`postman` to test -- No way to "try before install" -- **User complaint**: *"I wish I could test if my HTML works before installing"* - -#### Folio (Current)# -- Static docs in `/docs/` -- Same problems as Gotenberg - -### Desired State (Good)# - -- Visit `http://localhost:3000/docs` -- See all endpoints with examples -- Click "Try it" → auto-fills the form -- Submit → see live response -- Share example URLs with team - -## Scope# - -**In:** - -- `GET /docs` - Interactive API explorer (HTML UI) -- `GET /docs/api/openapi.json` - OpenAPI/Swagger spec -- Live "Try it now" buttons on every endpoint -- Code samples in curl, Python, Node.js -- Response preview (PDF, JSON, image) -- Shareable example URLs -- Dark mode support - -**Out:** - -- Full Swagger UI (too heavy, build custom) -- API key management (separate feature) -- Rate limiting display (not needed for docs) - -## Implementation# - -### 1. OpenAPI Spec Generation# - -```rust -// crates/server/src/docs/openapi.rs# - -use serde::Serialize; - -#[derive(Serialize)] -struct OpenApiSpec { - openapi: String, - info: Info, - servers: Vec<Server>, - paths: HashMap<String, PathItem>, -} - -#[derive(Serialize)] -struct Info { - title: String, - version: String, - description: String, -} - -/// Generate OpenAPI 3.0 spec. -pub fn generate_openapi() -> OpenApiSpec { - let mut paths = HashMap::new(); - - // Chromium endpoints - paths.insert( - "/forms/chromium/convert/url".into(), - PathItem { - post: Some(Operation { - summary: "Convert URL to PDF".into(), - operation_id: Some("convertUrl".into()), - request_body: Some(RequestBody { - content: hashmap! { - "multipart/form-data" => MediaType { - schema: Some(schema_for_chromium_convert()) - } - }, - }), - responses: responses_for_pdf(), - .. - }), - } - ); - - // ... add all endpoints - - OpenApiSpec { - openapi: "3.0.0".into(), - info: Info { - title: "Folio API".into(), - version: env!("CARGO_PKG_VERSION").into(), - description: "Gotenberg-compatible PDF generation API".into(), - }, - servers: vec![ - Server { - url: "http://localhost:3000".into(), - description: Some("Local development".into()), - } - ], - paths, - } -} -``` - -### 2. Interactive HTML UI# - -```html -<!-- crates/server/assets/docs/index.html --> - -<!DOCTYPE html> -<html> -<head> - <title>Folio API Docs - - - -

📄 Folio API Documentation

- -
-

POST /forms/chromium/convert/url

-

Convert any URL to PDF

- - - -
- - -
-
-
- - - - -``` - -### 3. Endpoint Handler# - -```rust -// crates/server/src/routes/docs.rs# - -use axum::response::Html; - -/// Serve interactive API documentation. -pub async fn docs_handler() -> Html<&'static str> { - let html = include_str!("../../assets/docs/index.html"); - Html(html) -} - -/// Serve OpenAPI spec as JSON. -pub async fn openapi_handler() -> Json { - Json(generate_openapi()) -} -``` - -### 4. Router Integration# - -```rust -// crates/server/src/app.rs# - -Router::new() - .route("/docs", get(docs_handler)) - .route("/docs/api/openapi.json", get(openapi_handler)) - // ... other routes -``` - -### 5. "Try it Now" Code Samples# - -```javascript -// Code sample generator -function generateCurl(endpoint, fields) { - let cmd = `curl -X POST http://localhost:3000${endpoint} \\\n`; - for (let [key, value] of Object.entries(fields)) { - cmd += ` --form ${key}="${value}" \\\n`; - } - return cmd + ' -o output.pdf'; -} - -function generatePython(endpoint, fields) { - return `import requests - -response = requests.post( - "http://localhost:3000${endpoint}", - files={${Object.entries(fields).map(([k,v]) => `"${k}": open("${v}")`).join(', ')} -) -open("output.pdf", "wb").write(response.content)`; -} - -function generateNode(endpoint, fields) { - return `const axios = require('axios'); -const fs = require('fs'); - -const form = new FormData(); -${Object.entries(fields).map(([k,v]) => `form.append('${k}', '${v}');`).join('\n')} - -axios.post('http://localhost:3000${endpoint}', form) - .then(response => fs.writeFileSync('output.pdf', response.data));`; -} -``` - -## Expected Behaviour# - -### Visit `/docs`# - -``` -📄 Folio API Documentation - -[Endpoint List] -- POST /forms/chromium/convert/url [Try it now] -- POST /forms/chromium/convert/html [Try it now] -- ... - -[Interactive Tester] -URL: [https://example.com ] -[Convert] [View cURL] [View Python] [View Node] -``` - -### Response Preview# - -- PDF: Auto-downloads and opens in new tab -- JSON: Pretty-printed with syntax highlighting -- Image: Rendered inline - -### Shareable URLs# - -``` -http://localhost:3000/docs#endpoint=chromium-url&url=https://example.com -``` - -## Test Plan# - -### Unit Tests# - -- `openapi_spec_generates_valid_json` -- `code_sample_generator_curl` -- `code_sample_generator_python` - -### Integration Tests# - -- `docs_page_loads` -- `try_it_now_returns_pdf` -- `openapi_json_valid` - -## Acceptance# - -- [ ] `GET /docs` serves interactive UI -- [ ] `GET /docs/api/openapi.json` returns spec -- [ ] "Try it now" buttons on all endpoints -- [ ] Code samples in 3 languages -- [ ] PDF/JSON/image preview -- [ ] Dark mode support -- [ ] Shareable URLs -- [ ] Unit tests for OpenAPI generation -- [ ] Integration tests for docs page -- [ ] `cargo clippy -p server -- -D warnings` clean - -## References# - -- Swagger UI: https://swagger.io/tools/swagger-ui/ -- OpenAPI 3.0: https://spec.openapis.org/oas/v3.0.3 -- Gotenberg docs (static): https://gotenberg.dev/docs/ diff --git a/docs/specs/49-template-library.md b/docs/specs/49-template-library.md deleted file mode 100644 index 159de80..0000000 --- a/docs/specs/49-template-library.md +++ /dev/null @@ -1,363 +0,0 @@ -# Spec 49 — Template Library# - -> Pre-built document templates for common use cases. -> Users don't need to write HTML from scratch - just -> pick a template, fill in data, and get a perfect PDF. -> Unique to Folio (Gotenberg doesn't have this). - -## Goal# - -Create a library of professional document templates -that users can customize with their data. Solves the -"I don't know how to write HTML invoices" problem. - -## Problem Analysis# - -### Current State (Painful)# - -**User workflows:** -1. User needs an invoice PDF -2. Searches web for "HTML invoice template" -3. Downloads sketchy HTML from questionable sites -4. Struggles to customize it -5. Converts to PDF → "Why does it look bad?" - -**Quote from Gotenberg Discussion:** -> "I wish there was an invoice template. I spent 3 hours -> tweaking HTML/CSS before getting a decent PDF." -> — Gotenberg Discussion #850 - -### Desired State (Easy)# - -1. User picks "Invoice Standard" template -2. Fills in JSON data: `{"company": "Acme", "amount": 1000}` -3. Gets perfect PDF in 2 seconds - -## Scope# - -**In:** - -- Template library at `GET /templates` -- Pre-built templates: - - Invoice (3 variants) - - Report (2 variants) - - Receipt (compact, thermal-printer friendly) - - Letter (business, personal) - - Certificate (award, completion) -- Template preview images at `GET /templates/{id}/preview` -- Data injection via JSON: `POST /forms/templates/{id}/render` -- Custom templates support (user-provided HTML) -- Template variables validation - -**Out:** - -- Template editor (too complex, use external tools) -- Drag-and-drop builder (separate product) -- Template marketplace (legal concerns) - -## Implementation# - -### 1. Template Definition# - -```rust -// crates/server/src/templates/mod.rs# - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Template { - pub id: String, - pub name: String, - pub description: String, - pub category: TemplateCategory, - pub thumbnail: String, // URL to preview image - pub fields: Vec, - pub html_template: String, // Mustache/Handlebars template -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum TemplateCategory { - Invoice, - Report, - Receipt, - Letter, - Certificate, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TemplateField { - pub name: String, - pub label: String, - pub field_type: FieldType, - pub required: bool, - pub default: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum FieldType { - String, - Number, - Date, - Boolean, - Image, // Base64 or URL -} -``` - -### 2. Built-in Templates# - -```rust -// crates/server/src/templates/builtin.rs# - -pub fn get_templates() -> Vec