From e48c500ca3cb476fca94585ce9db090465142b30 Mon Sep 17 00:00:00 2001 From: __deesh__ Date: Fri, 1 May 2026 18:10:23 +0530 Subject: [PATCH 01/16] 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