diff --git a/.env.example b/.env.example
index 58de782..3cf78e5 100644
--- a/.env.example
+++ b/.env.example
@@ -17,3 +17,31 @@ PRINTER_NAME=
# Full path to SumatraPDF.exe (Phase 5's PDF printing method). Empty = search
# standard install locations. If set here, it is used as-is (no fallback).
SUMATRA_PATH=
+
+# ------------------------------------------------------------------
+# Multi-format printing (docs/MULTI_FORMAT_PLAN.md). Phase 1 stores these;
+# each becomes active in the phase that needs it.
+# ------------------------------------------------------------------
+
+# Paper size for SumatraPDF's print settings. Empty (default) = the driver
+# chooses the paper β the spike-T4-proven behavior. Set e.g. A4 only after
+# spike T5 confirmed this printer honors the setting. Images are laid out
+# on A4 when this is empty.
+PAPER_SIZE=
+
+# Office documents (DOC/DOCX/XLS/XLSX/PPT/PPTX/ODF) are converted to PDF by
+# LibreOffice Headless (Phase 3). ENABLE_OFFICE=0 turns office formats off
+# without uninstalling anything; they are also refused while LibreOffice is
+# not installed (its "convert" needs ~400 MB RAM while running).
+ENABLE_OFFICE=1
+
+# Explicit path to soffice.exe. Empty = search the standard install
+# locations (C:\Program Files\LibreOffice\program\soffice.exe).
+LO_PATH=
+
+# Seconds an office conversion may run before the service kills LibreOffice.
+CONVERT_TIMEOUT_S=120
+
+# Job history database (SQLite, Phase 5). Default: logs/jobs.sqlite3 inside
+# the project folder. Delete the file to reset job history.
+# JOB_DB_PATH=
diff --git a/.gitignore b/.gitignore
index 21284cc..55223fb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,6 @@ logs/*
.coverage
.coverage.*
htmlcov/
+
+# Z-Code
+.zcode/
diff --git a/README.md b/README.md
index e2f7aa6..fd18616 100644
--- a/README.md
+++ b/README.md
@@ -1,182 +1,401 @@
-# Printer Service β Print from your Android phone, over Wi-Fi
+
+
+
+
+
+
+
+
+
+ Print from your Android phone or any device over Wi-Fi to your USB printer
+
+
+
+
+
+
+
+
+A lightweight, self-hosted network printing service built with **Python** and **FastAPI**. It turns any standard USB-connected printer (such as the **Epson L3210**) attached to a Windows PC into a wireless network printer accessible directly from your Android phone or any browser on your local Wi-Fi networkβno manufacturer cloud services, vendor apps, or specialized mobile drivers required.
-[](https://github.com/samananias/printerService/actions/workflows/ci.yml)
+---
+
+## π About The Project
+
+### The Problem
+Most entry-level desktop inkjets (like the Epson L3210) connect only via USB and lack built-in Wi-Fi or network capabilities. Mobile printing from Android typically requires proprietary manufacturer cloud apps, AirPrint/IPP hardware support, or complex network setups.
+
+### The Solution
+**Printer Service** acts as a lightweight software bridge running on a host Windows PC (even low-spec / old PCs). It exposes a mobile-friendly web application and REST API on your local network. When you select a document or image on your phone, the service accepts the upload, standardizes it into a print-ready PDF via dedicated format processors, and silently submits it to the Windows print queue using **SumatraPDF**. Windows and the official Epson driver handle the low-level USB communication and physical printing.
+
+```text
+βββββββββββββββββββββββββββ
+β Android Phone / Client β
+β (Browser / Web UI) β
+ββββββββββββββ¬βββββββββββββ
+ β Wi-Fi (HTTP POST /port 8000)
+ βΌ
+βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+β Windows Host PC (Printer Service) β
+β β
+β 1. FastAPI Web Server & REST API (`/`, `/print`, `/jobs`) β
+β 2. Validation & Security (Magic bytes, Size, PIN Auth) β
+β 3. Format Processors (Normalize all inputs to PDF): β
+β β’ Images (Pillow) βββββΊ PDF β
+β β’ Office (LibreOffice) βΊ PDF β
+β β’ TXT / CSV (ReportLab)βΊ PDF β
+β β’ PDF βββββββββββββββββΊ Pass-through β
+β 4. Print Pipeline & Job Engine (SQLite state tracking) β
+β 5. SumatraPDF CLI (`-print-to` / `-print-settings`) β
+ββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββββββββ
+ β Windows Spooler API
+ βΌ
+βββββββββββββββββββββββββββ
+β Windows Print Queue β
+β Epson L3210 Driver β
+ββββββββββββββ¬βββββββββββββ
+ β USB Cable
+ βΌ
+βββββββββββββββββββββββββββ
+β Epson L3210 Printer β
+β (Paper Output) β
+βββββββββββββββββββββββββββ
+```
+
+### β¨ Key Features
+
+- **π± Zero-Install Mobile Printing**: Open `http://:8000` in Chrome/Firefox on your phone to upload and print instantly.
+- **π Multi-Format Conversion**: Seamlessly print PDFs, Photos/Images, Microsoft Office documents, OpenDocument files, and plain text/CSV tables.
+- **βοΈ Configurable Print Options**: Set number of copies (1β99), specific page ranges (e.g., `2-6`, `odd`/`even`), paper sizes (A4, Letter, Legal, Long Bond, A3, A5), and Color vs. Monochrome modes.
+- **π‘οΈ Pre-Flight Hardware Checks**: Queries Windows spooler status flags (`offline`, `out of paper`, `door open`, `jam`) before dispatching jobs, preventing silent print failures.
+- **π Robust Job Lifecycle & Recovery**: Durable job tracking backed by SQLite (`logs/jobs.sqlite3`), with one-click **π Retry** for failed jobs and support for cancellation.
+- **π Local Network Security**: Restricted to private LAN profiles, optional PIN authentication (`API_PIN`), strict file magic-byte validation, and process-tree cleanup.
+- **π§ͺ Comprehensive Testing**: 190+ automated tests (~97% coverage) with faked OS/spooler boundaries running in CI, accompanied by standalone hardware diagnostic spikes (T1βT7).
+
+### π Supported Formats
-**Project:** Android phone β Wi-Fi β Python service (this PC) β Windows print queue β USB β Epson L3210
-**Status:** β
**MVP working end-to-end** β a phone upload prints real paper. β
Logic verified automatically: pytest suite + ruff lint run in CI on every push.
-**Full design document:** [docs/SOURCE_OF_TRUTH.md](docs/SOURCE_OF_TRUTH.md) β architecture, concepts, roadmap, testing plan. If it disagrees with this file, it wins.
+| Category | Extensions | Processing Method | Details |
+|---|---|---|---|
+| **PDF** | `.pdf` | Direct pass-through | Native vector submission via SumatraPDF |
+| **Images** | `.jpg`, `.jpeg`, `.png`, `.webp`, `.bmp`, `.gif`, `.tiff` | Pillow (PIL) | Automatic EXIF orientation, alpha-to-white transparency, centered on page, 300 DPI cap |
+| **Office Documents** | `.docx`, `.xlsx`, `.pptx`, `.doc`, `.xls`, `.ppt`, `.odt`, `.ods`, `.odp` | LibreOffice Headless | High-fidelity headless conversion, isolated throwaway profile, honors stored Excel print areas |
+| **Text & Data** | `.txt`, `.csv` | ReportLab | TXT formatted with monospace wrapping and pagination; CSV formatted with styled grid borders and headers |
---
-## Repo Map
+## πΊοΈ Repo Map
| Path | What it is |
|---|---|
-| `app/` | The service: FastAPI app, upload + print pipeline, job tracking, Windows printing |
-| `app/api/web.py` | The mobile web page (file picker + Print button) served at `/` |
-| `tests/` | pytest suite β unit tests (OS boundaries faked) + API tests via TestClient |
-| `tests/conftest.py` | Shared fixtures: fresh job store, temp `uploads/`, fake `win32print`, print mock |
-| `spike_print_test.py` | Standalone printer diagnostic β run it when printing misbehaves |
-| `allow_firewall_8000.bat` | One-click firewall rule (run as administrator, once) |
-| `.env.example` | Configuration template β copy to `.env` (never committed) |
-| `requirements.txt` | Python packages: fastapi, uvicorn, pywin32, python-multipart |
-| `requirements-dev.txt` | Dev tools: pytest, pytest-cov, httpx, ruff |
-| `pyproject.toml` | Tool config: pytest options, coverage gate (90%), ruff lint rules |
-| `.github/workflows/ci.yml` | GitHub Actions: lint + tests on every push/PR (Ubuntu) |
-| `uploads/`, `logs/` | Runtime temp files (auto-cleaned) and `logs/service.log` |
+| `app/` | Core application: FastAPI routes, format processors, upload validation, print pipeline, and job engine |
+| `app/api/web.py` | Mobile web application (file upload form, options dialog, live job status) served at `/` |
+| `tests/` | Pytest test suite: unit tests (OS boundaries faked) and API tests via FastAPI `TestClient` |
+| `tests/conftest.py` | Shared test fixtures: isolated job stores, temporary `uploads/`, mock `win32print`, print mocks |
+| `spike_print_test.py` | Core hardware diagnostic spike (T1βT4) β validates printer visibility, spooler, and SumatraPDF |
+| `spike_t5_images.py` | Image printing spike (T5) β tests image conversion, EXIF rotation, and paper size on real hardware |
+| `spike_t6_office.py` | Office document spike (T6) β tests LibreOffice conversion of DOCX, XLSX, and PPTX |
+| `spike_t7_text.py` | Text & CSV spike (T7) β tests plain-text wrapping and grid table rendering |
+| `allow_firewall_8000.bat` | One-click Windows Firewall script to allow inbound TCP traffic on port 8000 |
+| `.env.example` | Configuration template β copy to `.env` for local customizations |
+| `requirements.txt` | Production dependencies: FastAPI, Uvicorn, pywin32, Pillow, ReportLab, python-multipart |
+| `requirements-dev.txt` | Development & testing tools: pytest, pytest-cov, httpx, ruff |
+| `pyproject.toml` | Tool configurations: pytest options, coverage thresholds (90%), ruff linting rules |
+| `.github/workflows/ci.yml` | Continuous integration workflow: runs ruff and pytest on Ubuntu runners |
+| `uploads/`, `logs/` | Runtime directories for temporary upload processing and rotating logs (`logs/service.log`) |
+| `docs/logo/` | Project branding assets: SVG icons and wordmark logos (light & dark backgrounds) |
---
-## 1. What to Install
+## π¦ 1. Installation & Prerequisites
-### The machine that runs the service (the one with printer access)
+### Server Machine (The Windows PC connected to the printer)
-| Requirement | Why | How |
+| Requirement | Purpose | Installation Instructions |
|---|---|---|
-| **Python 3.12+** | Runs the service | `winget install -e --id Python.Python.3.12` or [python.org](https://www.python.org/downloads/). Verify: `python --version`. If typing `python` opens the Microsoft Store: *Settings β Apps β Advanced app settings β App execution aliases* β turn OFF `python.exe` / `python3.exe` |
-| **SumatraPDF** | The PDF printing engine β the service hands PDFs to it silently | `winget install SumatraPDF.SumatraPDF` or [sumatrapdfreader.org](https://www.sumatrapdfreader.org). No configuration needed β standard install locations are searched automatically |
-| **Epson L3210 driver** | Windows must print normally on its own first | Test: *Settings β Printers β Epson L3210 β Print test page*. If that fails, fix it before anything else |
-| **Firewall rule, TCP 8000** | The #1 reason phones "can't connect" | Right-click `allow_firewall_8000.bat` β **Run as administrator** (one time), or accept Windows' pop-up on first run (tick *Private networks*) |
+| **Python 3.12+** | Runs the Python service | Install via winget: `winget install -e --id Python.Python.3.12` or download from [python.org](https://www.python.org/downloads/). Verify: `python --version` *(Note: If typing `python` opens the Microsoft Store: go to **Windows Settings β Apps β Advanced app settings β App execution aliases** and turn **OFF** `python.exe` and `python3.exe`)* |
+| **SumatraPDF** | Silent PDF printing engine | Install via winget: `winget install SumatraPDF.SumatraPDF` or download from [sumatrapdfreader.org](https://www.sumatrapdfreader.org). Standard install paths are detected automatically. |
+| **Epson L3210 Driver** | Windows printer driver | Ensure Windows can print normally: *Windows Settings β Printers β Epson L3210 β Print test page*. |
+| **LibreOffice** *(Optional)* | Office-to-PDF conversion | Required for `.docx`, `.xlsx`, `.pptx`, `.odt`, etc. Without it, office uploads are refused with a friendly message while PDF/image/text printing continues working. Install via the CLI recipe below or from [libreoffice.org](https://www.libreoffice.org). |
+| **Windows Firewall Rule** | Allows LAN traffic on port 8000 | Right-click `allow_firewall_8000.bat` β **Run as administrator** (one-time setup), or allow it on the Windows Defender prompt on first run (select *Private networks*). |
-### The phone
+#### Installing LibreOffice via CLI (Fast & Scriptable)
+Download and run the official MSI installer using PowerShell:
-Nothing to install β any browser. Same Wi-Fi network as the service PC (guest networks usually isolate devices β a classic silent failure).
+```powershell
+curl.exe -L -o "$env:TEMP\LibreOffice.msi" "https://download.documentfoundation.org/libreoffice/stable/26.8.0/win/x86_64/LibreOffice_26.8.0_Win_x86-64.msi"
+msiexec /i "$env:TEMP\LibreOffice.msi"
+```
+
+A default "Typical" installation installs to `C:\Program Files\LibreOffice\program\soffice.exe`, which the service detects automatically (set `LO_PATH` in `.env` only if installed in a custom path).
+
+### Phone / Client Device
+- **Zero installation needed**: Any modern mobile browser (Chrome, Safari, Firefox).
+- **Network requirement**: Must be connected to the **same Wi-Fi network** as the server PC (note: guest Wi-Fi networks usually isolate devices and prevent connection).
---
-## 2. One-Time Setup
+## π 2. One-Time Setup
+
+Clone or copy the repository to your host PC, open a terminal in the project root folder, and follow the instructions for your preferred shell:
-From the project folder:
+### Option A: PowerShell / Command Prompt (CMD)
```powershell
-python -m venv .venv # create an isolated Python environment
-.venv\Scripts\activate # activate it β prompt gains (.venv)
-pip install -r requirements.txt # install fastapi, uvicorn, pywin32, python-multipart
-copy .env.example .env # local config (see Β§5; defaults are fine)
+# 1. Create an isolated virtual environment
+python -m venv .venv
+
+# 2. Activate the virtual environment
+.venv\Scripts\activate
+
+# 3. Install required Python packages
+pip install -r requirements.txt
+
+# 4. Create your local configuration file
+copy .env.example .env
+```
+
+> [!TIP]
+> **PowerShell execution policy error with `Activate.ps1`?**
+> If script execution is restricted, run this command once and retry activating:
+> ```powershell
+> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
+> ```
+
+### Option B: Git Bash
+
+```bash
+# 1. Create an isolated virtual environment
+python -m venv .venv
+
+# 2. Activate the virtual environment (use 'source' and forward slashes)
+source .venv/Scripts/activate
+
+# 3. Install required Python packages
+pip install -r requirements.txt
+
+# 4. Create your local configuration file
+cp .env.example .env
```
-> PowerShell blocked `Activate.ps1`? Run once, then retry:
-> `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser`
-> Git Bash activation: `source .venv/Scripts/activate`
+> [!NOTE]
+> **Git Bash Tip:** Always use forward slashes `/` and `source`. In Git Bash, backslashes `\` act as escape characters (so `.venv\Scripts\activate` will fail with "command not found").
---
-## 3. Run the Service
+## βΆοΈ 3. Running the Service
+
+From the project root folder:
+
+### PowerShell / Command Prompt
```powershell
.venv\Scripts\activate
uvicorn app.main:app --host 0.0.0.0 --port 8000
```
-- Keep the window open β the service exists only while it runs. `Ctrl+C` stops it.
-- `--host 0.0.0.0` is **required** β it means "listen on all network interfaces". Omit it and the phone can never connect.
-- No-activation one-liner (works from any terminal, any folder):
- ```powershell
- .venv\Scripts\python.exe -m uvicorn app.main:app --host 0.0.0.0 --port 8000
- ```
-- Working looks like: `INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)`
-- Quick self-check from the same PC: open `http://localhost:8000/health` β `{"status":"ok"}`
-- Changed the code? Run **Run the checks** below β same commands CI runs.
+*Or via the direct one-liner (without manual activation):*
+```powershell
+.venv\Scripts\python.exe -m uvicorn app.main:app --host 0.0.0.0 --port 8000
+```
----
+### Git Bash
-## 4. Print From the Phone
+```bash
+source .venv/Scripts/activate
+uvicorn app.main:app --host 0.0.0.0 --port 8000
+```
-1. Find the service PC's IP: run `ipconfig`, note the **IPv4 Address** (e.g. `192.168.1.5`). Tip: set a **DHCP reservation** for it in the router so it never changes.
-2. On the phone (same Wi-Fi): open `http://:8000`
-3. Pick a PDF β tap **Print** β watch the status:
- `π¨ Queuedβ¦ β β³ status: queuedβ¦ β π¨οΈ Printed to EPSON L3210 Series!`
-4. Paper comes out. Done.
+*Or via the direct one-liner (without manual activation):*
+```bash
+.venv\Scripts\python.exe -m uvicorn app.main:app --host 0.0.0.0 --port 8000
+```
-Other endpoints (also browsable interactively at `http://:8000/docs`):
+### π‘ Runtime Notes
+- Keep the terminal window openβthe service runs as long as the process is active. Press `Ctrl+C` to stop it.
+- `--host 0.0.0.0` is **mandatory** because it instructs the server to listen on all local network interfaces so your phone can reach it.
+- A successful startup output looks like: `INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)`
+- **Self-Check**: On the host PC, open `http://localhost:8000/health` in your browser to verify it returns `{"status":"ok"}`.
-| Endpoint | What it does |
-|---|---|
-| `GET /health` | Is the service up? First thing to check when anything seems broken |
-| `GET /printers` | Which printers Windows sees (the L3210 should be listed) |
-| `POST /print` | Upload a PDF and print it |
-| `GET /jobs` | Recent jobs and their statuses |
-| `GET /jobs/{id}` | One job's status (what the page polls) |
-| `DELETE /jobs/{id}` | Cancel a job that hasn't printed yet |
+---
+
+## π± 4. Printing From Your Phone
+
+1. **Find the PC's Local IP Address**:
+ - Open Command Prompt/PowerShell on the server PC and run `ipconfig`.
+ - Note the **IPv4 Address** (e.g., `192.168.1.5`).
+ - *(Recommended)* Set a **DHCP reservation** in your home router settings so this IP address never changes.
+2. **Open the Web UI on your Phone**:
+ - Make sure your phone is connected to the same Wi-Fi.
+ - Open your mobile browser and navigate to `http://:8000` (e.g., `http://192.168.1.5:8000`).
+3. **Upload and Print**:
+ - Select a document or image (PDF, JPG/PNG/WebP, DOCX/XLSX/PPTX, or TXT/CSV).
+ - Tap **Print**.
+ - Watch the live progress status:
+ `π¨ Queuedβ¦ β β³ status: convertingβ¦ β π¨οΈ status: printingβ¦ β β
Printed to EPSON L3210 Series!`
+4. **Formatting Behaviors**:
+ - **Images**: Automatically fitted and centered on an A4 page with white margins; camera EXIF orientation is respected.
+ - **Office Files**: Converted via LibreOffice headless (typically 10β30s); UI indicates `converting` state during processing.
+ - **TXT & CSV**: TXT prints formatted monospace text with automatic line-wrapping; CSV renders a structured, bordered grid table.
+5. **Job Retries & Cancellation**:
+ - If a job fails (e.g., printer out of paper), the uploaded source file is preserved. Tap the **π Retry** button on the web page or call `POST /jobs/{id}/retry`.
+ - Active jobs can be cancelled while queued, converting, or printing (best-effort once handed to the Windows spooler).
+6. **Print Options Dialog** (Expandable on the web page):
+ - **Copies**: 1 to 99 copies.
+ - **Page Range**: Custom ranges (`2-6`, `1,3,5`), `odd`, or `even`.
+ - **Paper Size**: A4, Short Bond (Letter), Long Bond (8.5Γ13 / Folio), Legal, A3, A5.
+ - **Color Mode**: Full Color or Monochrome (Black & White).
+ - *Options apply across all document formats and are retained when retrying a job.*
+
+### π API Reference
+
+The service provides a full REST API, also browsable interactively via Swagger UI at `http://:8000/docs`:
+
+| Endpoint | Method | Description |
+|---|---|---|
+| `/health` | `GET` | Server health check (`{"status": "ok"}`). Ideal for reachability verification. |
+| `/printers` | `GET` | Lists all printers detected by Windows via `pywin32`. |
+| `/print` | `POST` | Upload and queue a file for printing (accepts optional print settings). |
+| `/jobs` | `GET` | Lists recent print jobs and their current execution statuses. |
+| `/jobs/{id}` | `GET` | Fetches detailed status and metadata for a specific print job. |
+| `/jobs/{id}` | `DELETE` | Cancels an active or queued print job. |
+| `/jobs/{id}/retry` | `POST` | Re-dispatches a failed job using its stored upload without re-uploading. |
---
-## 5. Configuration (`.env`)
+## βοΈ 5. Configuration (`.env`)
-Copy `.env.example` β `.env` and edit. All values are optional; defaults work.
+Copy `.env.example` to `.env` to configure optional server settings. All keys are optionalβthe default configuration is ready for immediate use:
-| Key | Default | Meaning |
+| Key | Default | Description |
|---|---|---|
-| `MAX_UPLOAD_MB` | `25` | Upload size limit (bigger files are rejected with HTTP 413) |
-| `API_PIN` | *(empty)* | If set, printing/cancelling requires the PIN (sent as `X-API-PIN`; the web page has a PIN field). Empty = no auth |
-| `PRINTER_NAME` | *(empty)* | Target printer. Empty = Windows' default printer |
-| `SUMATRA_PATH` | *(empty)* | Explicit path to `SumatraPDF.exe`. Empty = search standard locations. If set, used as-is (misconfiguration fails loudly) |
-| `HOST`, `PORT` | `8000` | Informational β actually pass them on the uvicorn command line (Β§3) |
+| `MAX_UPLOAD_MB` | `25` | Maximum allowed upload size in megabytes (larger files rejected with HTTP 413). |
+| `API_PIN` | *(empty)* | Optional security PIN. When set, requests require the `X-API-PIN` header (the web interface will display a PIN input field). |
+| `PRINTER_NAME` | *(empty)* | Specific Windows printer name to target. If empty, the system default printer is used. |
+| `SUMATRA_PATH` | *(empty)* | Custom path to `SumatraPDF.exe`. Leave empty to use automatic standard path detection. |
+| `PAPER_SIZE` | *(empty)* | Default paper size token passed to driver (e.g. `A4`). Leave empty to let the Windows driver choose. |
+| `ENABLE_OFFICE` | `1` | Enables office file processing (`.docx`, `.xlsx`, `.pptx`, `.odt`). Set to `0` to disable office conversion. |
+| `LO_PATH` | *(empty)* | Custom path to LibreOffice `soffice.exe`. Leave empty to use automatic discovery. |
+| `CONVERT_TIMEOUT_S` | `120` | Maximum timeout in seconds for LibreOffice conversion before terminating the process tree. |
+| `JOB_DB_PATH` | `logs/jobs.sqlite3` | Path to the SQLite job history database. Delete the file at any time to reset job history. |
+| `HOST`, `PORT` | `8000` | Reference settings (actual binding host and port are passed via the `uvicorn` CLI command). |
+
+> [!NOTE]
+> - **Pre-Flight Checks**: The service verifies spooler status (`offline`, `out of paper`, `door open`, `error`) before dispatching jobs.
+> - **SumatraPDF Exit Codes**: Exit codes (2 = corrupt file, 4 = printer not found, 5 = driver error) are translated into clear, human-readable explanations.
+> - **Log Management**: Log files in `logs/service.log` automatically rotate at ~1 MB with 2 backup archives kept.
---
-## 6. Diagnostics: the Printer Spike
+## π©Ί 6. Diagnostics & Hardware Spikes
+
+If printing ever fails or you want to verify your setup before launching the service, use the standalone diagnostic spike scripts:
-If printing ever misbehaves, `spike_print_test.py` checks each link of the chain separately (needs `pip install pywin32` on the machine it runs on):
+### Core Printer Diagnostic (T1βT4)
+Run from the project root (requires `pywin32`):
```powershell
python spike_print_test.py
```
-It reports: printer visibility (T1), spooler acceptance (T2), Windows print-verb (T3), SumatraPDF (T4) β with a summary and "what to do with this result" guidance. **T4 passing + paper = the whole chain works.** See SOURCE_OF_TRUTH Section 5 for the recorded results that decided the current design.
+This diagnostic systematically checks each layer:
+- **T1**: Verifies printer visibility through `win32print`.
+- **T2**: Tests RAW text submission to the Windows spooler.
+- **T3**: Checks the Windows "print" shell verb.
+- **T4**: Tests silent PDF printing through SumatraPDF to the physical printer.
+- **Result:** T4 PASS with a printed test page confirms the entire Python β SumatraPDF β Spooler β USB chain is functional.
----
+### Multi-Format Acceptance Spikes
+- **`spike_t5_images.py`**: Tests ImageProcessor conversion (JPEG, PNG with transparency, WebP, EXIF orientation) and prints to paper.
+- **`spike_t6_office.py`**: Tests OfficeProcessor (DOCX tables, landscape XLSX with print areas, 16:9 PPTX) using LibreOffice.
+- **`spike_t7_text.py`**: Tests TextProcessor rendering of wrapped text and CSV tables.
-## 7. Deploying to the Print-Server PC (final step)
+---
-1. Install **Python 3.12+** and **SumatraPDF** on that PC (Β§1)
-2. Get the code there: `git clone` / `git pull`, or copy the folder (delete `.venv` first β recreate it there with Β§2)
-3. `pip install -r requirements.txt`, `copy .env.example .env`
-4. Run `allow_firewall_8000.bat` **as administrator** on that PC
-5. Run `python spike_print_test.py` there once β confirm T1/T2/T4 pass with paper
-6. Start the service (Β§3) and test from the phone using **that PC's IP** (`ipconfig`)
-7. Set the router's **DHCP reservation** for that PC so its IP is stable
-8. Auto-start on boot (optional): Task Scheduler β *Create Task* β trigger "At startup" β action: `C:\...\printerService\.venv\Scripts\python.exe` with arguments `-m uvicorn app.main:app --host 0.0.0.0 --port 8000`, start-in the project folder
+## π₯οΈ 7. Deploying to the Dedicated Print-Server PC
+
+Follow these steps when setting up the service permanently on your print-server machine:
+
+1. **Install Prerequisites**: Install **Python 3.12+**, **SumatraPDF**, printer drivers, and optionally **LibreOffice** (Β§1).
+2. **Obtain the Code**: Clone the repository with `git clone` or copy the project folder (ensure you delete any existing `.venv` folder before copying).
+3. **Set Up Virtual Environment**:
+ ```powershell
+ python -m venv .venv
+ .venv\Scripts\activate
+ pip install -r requirements.txt
+ copy .env.example .env
+ ```
+4. **Configure Firewall**: Run `allow_firewall_8000.bat` **as Administrator** to open TCP port 8000.
+5. **Run Diagnostics**: Run `python spike_print_test.py` to confirm physical paper prints successfully.
+6. **Start the Server**: Launch the service using the instructions in Β§3 and verify connectivity from your phone.
+7. **Reserve Static IP**: In your home router configuration, create a **DHCP reservation** for the PC's MAC address to keep its IP permanent.
+8. **Configure Auto-Start on Boot (Optional via Windows Task Scheduler)**:
+ - Open **Task Scheduler** β *Create Task*.
+ - **General**: Name the task (e.g., `PrinterService`), check *Run whether user is logged on or not*.
+ - **Triggers**: Set trigger to **At startup**.
+ - **Actions**: Set action to **Start a program**:
+ - *Program/script*: `C:\path\to\printerService\.venv\Scripts\python.exe`
+ - *Add arguments*: `-m uvicorn app.main:app --host 0.0.0.0 --port 8000`
+ - *Start in*: `C:\path\to\printerService\`
---
-## 8. When Something Breaks
-
-| Symptom | Likely cause / fix |
-|---|---|
-| Phone can't connect at all (page hangs) | Firewall rule missing (Β§1), or phone on a different Wi-Fi/guest network |
-| `error 10048` on startup | Port 8000 taken by another program β close it or use `--port 8001` (and that port on the phone) |
-| Phone reaches `/health` but print fails | Read the error on the page or in `logs/service.log`; run the spike (Β§6) |
-| Job `failed`: SumatraPDF not found | Install SumatraPDF (Β§1) or set `SUMATRA_PATH` in `.env` |
-| Job `failed`: printer not default / offline | Check the printer in Windows, print a Windows test page |
-| Service IP changed after reboot | Set the router's DHCP reservation (Β§7 step 7) |
+## π οΈ 8. Troubleshooting & Common Issues
-Full troubleshooting table: [docs/SOURCE_OF_TRUTH.md](docs/SOURCE_OF_TRUTH.md) Section 14. Debug in this order β connectivity (IP/port) β firewall β service β printing logic (Section 15 explains why).
+| Symptom | Likely Cause | Recommended Fix |
+|---|---|---|
+| Phone browser hangs / cannot connect | Missing Windows Firewall rule or wrong network | Run `allow_firewall_8000.bat` as Admin (Β§1). Ensure phone is on the same private Wi-Fi network (not a guest network). |
+| `error 10048` on server startup | Port 8000 is already in use | Terminate the conflicting application or start Uvicorn with `--port 8001` (and use port 8001 on the phone). |
+| Phone reaches `/health` but print job fails | Printer offline or driver error | Check printer power/cable, verify test print in Windows Settings, and check error messages in `logs/service.log`. |
+| Job fails: `SumatraPDF not found` | SumatraPDF is not installed in a standard location | Install SumatraPDF via winget (Β§1) or set the exact binary path in `SUMATRA_PATH` in `.env`. |
+| Job fails: `Printer offline / out of paper` | Pre-flight readiness check failed | Ensure printer is turned on, paper is loaded in tray, and clear any paper jams, then click **π Retry**. |
+| Job fails: `Printer not default / not found` | Printer name mismatch | Set the exact printer name under `PRINTER_NAME` in `.env` matching Windows printer settings. |
+| Office upload rejected: `LibreOffice not installed` | Missing LibreOffice or `ENABLE_OFFICE=0` | Install LibreOffice (Β§1) or verify `ENABLE_OFFICE=1` in `.env`, then restart the service. |
+| Office conversion fails: `Timeout after 120 s` | Large or complex document | Increase `CONVERT_TIMEOUT_S` in `.env` or export to PDF directly on your client device. |
+| Office print layout looks altered (fonts/margins) | Missing server-side fonts or missing print area | Install standard font packs on Windows server; for spreadsheets, define an explicit Print Area in Excel. |
+| SumatraPDF fails with exit codes `2`, `4`, or `5` | File corrupt (`2`), printer missing (`4`), or driver error (`5`) | Review detailed logs in `logs/service.log` and verify the PDF renders in SumatraPDF directly. |
+| Server IP address changes after PC reboot | Dynamic DHCP re-assignment | Set a static IP or configure a DHCP reservation on your home router (Β§7). |
+
+> [!TIP]
+> **Recommended Debugging Hierarchy**: Always diagnose issues from the bottom up:
+> 1. Physical Hardware & Drivers (USB cable, paper, Windows test page)
+> 2. Network Connectivity (IP address, ping, same Wi-Fi subnet)
+> 3. Firewall & Ports (Inbound rules on port 8000)
+> 4. Application Logic & Service Logs (`logs/service.log`, `http://localhost:8000/health`)
---
-## Run the Checks (what CI runs)
+## π§ͺ 9. Running Tests & Developer Checks
-Changed the service code? Two commands verify the logic β without printing anything:
+Verify code quality and ensure test coverage before committing changes (mirrors CI workflow):
+### PowerShell / Command Prompt
```powershell
.venv\Scripts\activate
-pip install -r requirements-dev.txt # once per machine
-ruff check . # lint: unused imports, undefined names, style drift
-pytest # the suite + coverage report (fails below the 90% gate)
+pip install -r requirements-dev.txt # Install testing dependencies (once)
+ruff check . # Linting: unused imports, style rules, bugs
+pytest # Executes full test suite with coverage report (β₯90% gate)
```
-- **Unit tests** (`tests/unit/`) exercise validation, job tracking, PIN auth, and the printing decisions with every OS boundary faked β no printer, no SumatraPDF, no network needed.
-- **API tests** (`tests/api/`) drive the whole FastAPI app through a test client, the same requests the phone makes.
-- Tests never touch machine state (real `uploads/` is redirected to a temp dir), so they run identically on your PC and on CI's Ubuntu runner.
-- Real paper **can't** be tested by CI β no printer is attached to it. The spike (Β§6) stays the hardware test.
-- GitHub Actions runs both commands on every push and pull request (`.github/workflows/ci.yml`); the badge at the top shows the latest result.
+### Git Bash
+```bash
+source .venv/Scripts/activate
+pip install -r requirements-dev.txt # Install testing dependencies (once)
+ruff check . # Linting: unused imports, style rules, bugs
+pytest # Executes full test suite with coverage report (β₯90% gate)
+```
+
+### Test Architecture Highlights
+- **Unit Tests (`tests/unit/`)**: Verify file validation, magic-byte detection, format processors, and pipeline state transitions with all OS interactions (subprocess, file system, `win32print`) completely faked.
+- **API Tests (`tests/api/`)**: Simulate real client and phone requests against FastAPI endpoints using `httpx` and `TestClient` without network overhead.
+- **Hardware Isolation**: Automated tests never touch the real printer or filesystem state, enabling the exact same test suite to run identically on Windows development machines and Ubuntu CI runners.
+- **CI Automation**: GitHub Actions runs `ruff check` and `pytest` with a 90% coverage threshold on every push and pull request (`.github/workflows/ci.yml`).
---
-## Where to Go Next
+## π 10. Documentation & References
+
+For deep dives into architectural decisions, roadmap phases, and design rationales, refer to the project documentation:
-- **Roadmap & current phase** β [docs/SOURCE_OF_TRUTH.md](docs/SOURCE_OF_TRUTH.md) Section 9
-- **Why it's built this way** β Sections 3β8 there (protocol choice, tech stack, security, scope)
-- **API design** β Section 11 Β· **Testing plan + how the automated suite fits in** β Section 13
-- **How the test fixtures work** β the commented `tests/conftest.py` Β· **What CI runs** β `.github/workflows/ci.yml`
+- **[docs/SOURCE_OF_TRUTH.md](docs/SOURCE_OF_TRUTH.md)**: The authoritative architecture document, technology stack rationale, network security rules, and detailed failure diagnostic guides.
+- **[docs/MULTI_FORMAT_PLAN.md](docs/MULTI_FORMAT_PLAN.md)**: Multi-format design record, decision logs (Pillow, LibreOffice, ReportLab), and the T5βT7 spike protocols.
+- **[tests/conftest.py](tests/conftest.py)**: Reference for test fixture implementations and Windows printing mocks.
diff --git a/app/api/jobs.py b/app/api/jobs.py
index 2d7affe..bded906 100644
--- a/app/api/jobs.py
+++ b/app/api/jobs.py
@@ -1,20 +1,25 @@
"""
-Jobs API (Phase 7) β see what happened, poll status, cancel mistakes.
+Jobs API (Phase 7; queue management in p14) β see what happened, poll
+status, cancel mistakes, retry failures.
-Endpoints (SOURCE_OF_TRUTH Section 11):
- GET /jobs β recent jobs and their statuses
- GET /jobs/{id} β one job (what the phone UI polls: "done yet?")
- DELETE /jobs/{id} β cancel a job that hasn't reached the print queue
+Endpoints (SOURCE_OF_TRUTH Section 11, extended by p14):
+ GET /jobs β recent jobs and their statuses
+ GET /jobs/{id} β one job (what the phone UI polls: "done yet?")
+ DELETE /jobs/{id} β cancel a job (queued/converting/printing)
+ POST /jobs/{id}/retry β re-print a failed job from its stored upload
"""
+import logging
+
from fastapi import APIRouter, Depends, HTTPException
-from app.models.printing import PrintJob
-from app.services import jobs
+from app.models.printing import JobStatus, PrintJob
+from app.printer import windows
+from app.services import jobs, pipeline, uploads
from app.services.auth import require_pin
-from app.services.uploads import upload_path
router = APIRouter()
+logger = logging.getLogger(__name__)
@router.get("/jobs", response_model=list[PrintJob])
@@ -34,19 +39,70 @@ def one_job(job_id: str):
@router.delete("/jobs/{job_id}", response_model=PrintJob)
def cancel(job_id: str, _: None = Depends(require_pin)):
- """Cancel a queued job (Section 11: "you will queue the wrong file")."""
+ """Cancel a job (Section 11: "you will queue the wrong file").
+
+ p14: cancellation works while queued, converting AND printing. The
+ printing case is best-effort β our queued spooler jobs are purged via
+ win32print, but paper that already fed into the printer cannot be
+ recalled; the pipeline never marks a cancelled job done.
+ """
job = jobs.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"No job with id '{job_id}'.")
+ was_printing = job.status == JobStatus.PRINTING
ok, message = jobs.cancel_job(job_id)
if not ok:
raise HTTPException(status_code=409, detail=message)
- # Remove the stored file so cancelled uploads don't fill the disk.
- try:
- upload_path(job_id).unlink(missing_ok=True)
- except OSError:
- pass # cleanup failure must not fail the cancel
+ # Best-effort spooler purge for anything already handed to Windows.
+ # Never fails the cancel β a purge hiccup must not 500 the request.
+ if was_printing:
+ try:
+ removed = windows.cancel_spooler_jobs(
+ windows.resolve_printer_name(), job_id
+ )
+ if removed:
+ logger.info(
+ "purged %d spooler job(s) for cancelled job %s", removed, job_id
+ )
+ except Exception:
+ logger.warning(
+ "spooler purge failed for cancelled job %s", job_id, exc_info=True
+ )
+
+ # Remove the stored file(s) so cancelled uploads don't fill the disk.
+ # delete_job_files covers the source upload and its converted PDF.
+ uploads.delete_job_files(job_id)
+
+ return jobs.get_job(job_id)
+
+
+@router.post("/jobs/{job_id}/retry", response_model=PrintJob)
+def retry(job_id: str, _: None = Depends(require_pin)):
+ """Re-print a failed job (p14).
+
+ Failed jobs keep their uploaded file precisely for this. The pipeline
+ re-runs from conversion β a transient failure (printer offline, a
+ LibreOffice hiccup) becomes a second chance without re-uploading.
+ """
+ job = jobs.get_job(job_id)
+ if job is None:
+ raise HTTPException(status_code=404, detail=f"No job with id '{job_id}'.")
+ if job.status != JobStatus.FAILED:
+ raise HTTPException(
+ status_code=409,
+ detail=f"Job is '{job.status}' β only failed jobs can be retried.",
+ )
+
+ source = jobs.get_source(job_id)
+ if source is None or not source[0].is_file():
+ raise HTTPException(
+ status_code=409,
+ detail="The uploaded file for this job is gone β upload it again.",
+ )
+ jobs.reset_for_retry(job_id)
+ pipeline.start_job(job_id, source[0], source[1], options=job.options)
+ logger.info("job %s queued for retry", job_id)
return jobs.get_job(job_id)
diff --git a/app/api/print.py b/app/api/print.py
index 1bddd9d..c295256 100644
--- a/app/api/print.py
+++ b/app/api/print.py
@@ -1,67 +1,97 @@
"""
-POST /print β accept a PDF upload (Phase 4).
+POST /print β accept a printable file (Phase 4; multi-format in p10).
-Printing itself arrives in Phase 5; this endpoint proves the *transfer* half
-of the pipeline: phone β HTTP β validated bytes on disk, intact.
+Printing itself arrives in Phase 5's background thread; this endpoint
+proves the *transfer* half of the pipeline: phone β HTTP β validated bytes
+on disk, intact.
+
+Phase 1 (multi-format refactor) keeps the behavior PDF-only, but the flow
+is now format-agnostic (docs/MULTI_FORMAT_PLAN.md Β§8):
+
+ 1. FastAPI/python-multipart parse the request and hand us the bytes.
+ 2. validate_upload() applies the Section 8 checks (type, content,
+ availability, size) and returns the detected category. A category
+ prints once its processor is registered AND available on this
+ machine (PDF, images and text always; office additionally needs
+ LibreOffice installed / ENABLE_OFFICE=1). Refusals explain which
+ gate fired.
+ 3. save_upload() stores the bytes under a unique job id, keeping the
+ real extension.
+ 4. The job is registered (category recorded) and handed to the
+ background pipeline: convert β print β cleanup.
+
+Returns 201 with the job id. Errors: 401 (bad PIN), 415 (unsupported file
+or lying extension), 413 (too large), 500 (disk trouble).
"""
import logging
+from pathlib import Path
-from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
+from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
-from app.models.printing import PrintAccepted
+from app.detection import DEFAULT_EXTENSIONS
+from app.models.printing import PrintAccepted, validate_print_options
from app.services import jobs, pipeline
from app.services.auth import require_pin
-from app.services.uploads import UploadError, save_upload, validate_pdf
+from app.services.uploads import UploadError, save_upload, validate_upload
router = APIRouter()
logger = logging.getLogger(__name__)
@router.post("/print", response_model=PrintAccepted, status_code=201)
-async def print_pdf(
+async def print_file(
file: UploadFile = File(...),
+ copies: int = Form(1),
+ pages: str = Form(""),
+ paper: str = Form(""),
+ color_mode: str = Form("color"),
_: None = Depends(require_pin), # PIN required only when API_PIN is set
):
- """Accept a PDF exactly like a web form uploads a photo: a
+ """Accept a file exactly like a web form uploads a photo: a
multipart/form-data POST whose file field is named "file".
- Flow (SOURCE_OF_TRUTH Section 5, stages 2-7):
- 1. FastAPI/python-multipart parse the request and hand us the bytes.
- 2. validate_pdf() applies the Section 8 checks (type, size).
- 3. save_upload() stores them under a unique job_id in uploads/.
- 4. The job is registered in the in-memory tracker (Phase 7).
- 5. pipeline.start_job() submits it to the Windows print queue in a
- background thread; status moves queued β done/failed there.
-
- Returns 201 with the job id. Errors: 401 (bad PIN), 415 (not a PDF),
- 413 (too large), 500 (disk trouble).
- """
+ The print options (copies, pages, paper, color_mode) are all optional
+ with safe defaults β Phase 7; see PrintOptions for the allowlists."""
data = await file.read()
+ filename = file.filename or ""
try:
- validate_pdf(file.filename or "", data)
+ category = validate_upload(filename, data)
except UploadError as exc:
- logger.warning("rejected upload %r: %s", file.filename, exc)
+ logger.warning("rejected upload %r: %s", filename, exc)
raise HTTPException(status_code=exc.status_code, detail=str(exc))
try:
- job_id, path = save_upload(data)
+ options = validate_print_options(copies, pages, paper, color_mode).model_dump()
+ except ValueError as exc:
+ logger.warning("rejected upload %r: bad print options: %s", filename, exc)
+ raise HTTPException(status_code=422, detail=str(exc))
+
+ # Store under the real extension; a client that sent no usable filename
+ # gets the canonical one for its (magic-proven) category.
+ ext = Path(filename).suffix.lower() or DEFAULT_EXTENSIONS[category]
+
+ try:
+ job_id, path = save_upload(data, ext=ext)
except OSError as exc:
# Disk full, permissions, antivirus blocking writes... a clean 500
# beats an unhandled exception crashing the request (Section 14).
- logger.exception("could not store upload %r", file.filename)
+ logger.exception("could not store upload %r", filename)
raise HTTPException(status_code=500, detail=f"Could not store upload: {exc}")
- jobs.create_job(job_id, file.filename or "unknown.pdf", len(data), path)
- pipeline.start_job(job_id, path)
+ jobs.create_job(
+ job_id, filename or f"unknown{ext}", len(data), path, format=category,
+ options=options,
+ )
+ pipeline.start_job(job_id, path, category, options=options)
logger.info(
- "job %s received: %s (%d bytes)", job_id, file.filename, len(data)
+ "job %s received: %s (%d bytes, %s)", job_id, filename, len(data), category
)
return PrintAccepted(
job_id=job_id,
status="queued",
- filename=file.filename or "unknown.pdf",
+ filename=filename or f"unknown{ext}",
size_bytes=len(data),
)
diff --git a/app/api/web.py b/app/api/web.py
index 527d72d..20356b4 100644
--- a/app/api/web.py
+++ b/app/api/web.py
@@ -3,7 +3,9 @@
The page is a single self-contained HTML string (inline CSS + vanilla JS β
no build tools, no frameworks) served directly by FastAPI. The phone's
-browser IS the app: open http://:8000, pick a PDF, tap Print.
+browser IS the app: open http://:8000, pick a PDF or image, tap
+Print. The accept list mirrors the categories registered in
+app/processors β new formats update both.
How the upload works (worth reading slowly β this is HTTP from the browser's
point of view):
@@ -18,15 +20,44 @@
"""
from fastapi import APIRouter
-from fastapi.responses import HTMLResponse
+from fastapi.responses import HTMLResponse, Response
router = APIRouter()
+FAVICON_SVG = """
+ PrinterService
+ A smartphone feeding into a printer printing a Wi-Fi page.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+
PAGE = """
+
+
Printer Service