From 56afce6649ce2966f969a0ebc6691e3e14ae2798 Mon Sep 17 00:00:00 2001 From: geb Date: Fri, 28 Aug 2026 23:22:59 +0800 Subject: [PATCH 01/16] docs: multi-format printing plan (roadmap + spike protocol T5-T7) Approved investigation and decision record for growing the PDF-only service into a multi-format print service: - SumatraPDF kept as the print engine; LibreOffice headless joins later as office-to-PDF converter only; PDF stays the one internal print format - format strategy table, print-quality caveats, old-PC load profile - phased roadmap p10-p15 plus a v2 print-options phase, MVP/v2/future split - hardware spike protocol T5-T7 (images, office, text) for the real PC SOURCE_OF_TRUTH Section 9 now points to this plan. --- docs/MULTI_FORMAT_PLAN.md | 306 ++++++++++++++++++++++++++++++++++++++ docs/SOURCE_OF_TRUTH.md | 8 + 2 files changed, 314 insertions(+) create mode 100644 docs/MULTI_FORMAT_PLAN.md diff --git a/docs/MULTI_FORMAT_PLAN.md b/docs/MULTI_FORMAT_PLAN.md new file mode 100644 index 0000000..dfb0e62 --- /dev/null +++ b/docs/MULTI_FORMAT_PLAN.md @@ -0,0 +1,306 @@ +# Multi-Format Printing β€” Investigation, Decision Record & Roadmap + +Status: **approved plan** (branch `multiple-types-compatibility`). +Goal: grow the PDF-only MVP into a general multi-format network printing +service while keeping it simple enough for a beginner home lab on an +old/low-spec Windows PC. + +Claims are tagged like SOURCE_OF_TRUTH: +🟒 CONFIRMED FACT Β· πŸ”΅ RECOMMENDED (decided here) Β· 🟑 ALTERNATIVE Β· πŸ”΄ NEEDS TESTING (spike) Β· βšͺ FUTURE + +--- + +## 1. Executive summary β€” the 10 answers + +| # | Question | Decision | +|---|----------|----------| +| 1 | Keep SumatraPDF? | **Yes** β€” unchanged CLI, same install, same `submit_pdf()` | +| 2 | Replace it? | **No** | +| 3 | What joins it? | LibreOffice Headless (officeβ†’PDF converter only), Pillow (images), reportlab (TXT/CSV) β€” all free (MPL-2.0 / MIT-style / BSD) | +| 4 | Convert to PDF before printing? | **Yes**, for every non-PDF input | +| 5 | Internal print format? | **PDF**, stored beside the original upload | +| 6 | Python's role? | Orchestrator: Pillow/reportlab/pywin32 as libraries; LibreOffice/SumatraPDF as external executables via arg-list subprocess | +| 7 | First new formats? | Images (JPG/PNG/WebP) β€” no heavy dependencies | +| 8 | Architecture? | Format processors β†’ common PDF β†’ existing `submit_pdf()` print engine, inserted at one point of the current pipeline | +| 9 | Avoid? | Per-format direct printing, MS Office COM, RAW/GDI printing, anything beyond SQLite, internet exposure (SOURCE_OF_TRUTH Β§16) | +| 10 | Implement first? | Hardware spike T5–T7 on the real PC, then the Phase 1 refactor (p10) | + +--- + +## 2. What the service is today 🟒 (verified by reading the code) + +- FastAPI console app (`uvicorn app.main:app`), optional PIN auth, LAN-only + (private-profile firewall rule), Epson L3210 on USB via the Windows queue + `EPSON L3210 Series`. +- `POST /print` β†’ `validate_pdf()` (`.pdf` extension + `%PDF-` magic + + `MAX_UPLOAD_MB`) β†’ saved as `uploads/.pdf` β†’ in-memory job store + (dict behind a lock, SOURCE_OF_TRUTH Β§12) β†’ one daemon thread per job β†’ + `submit_pdf()` β†’ `SumatraPDF.exe -print-to "" -silent ` + (180 s timeout) β†’ done β†’ file deleted. +- The Windows "print" verb fallback is broken on this machine (spike T3, + WinError 1155) and stays a documented safety net only. +- Spike T4 physically printed paper through the whole chain. +- 81 tests mock `win32print`; CI runs ruff + pytest + β‰₯90 % coverage. + +**Key insight:** the service is already ~80 % format-agnostic. +`submit_pdf(pdf_path)` *is* the print-engine contract (PDF in β†’ paper out). +The only PDF-specific code: `uploads.py` (validate/save/sweep), `config.py` +(`PDF_MAGIC`), `api/print.py` (415 message), `api/web.py` (`accept=".pdf"`). + +## 3. Keep / modify / new / replace / isolate πŸ”΅ + +- **Keep unchanged:** FastAPI app + routes + PIN auth; job store and lock; + **`submit_pdf()` untouched**; logging; startup sweep pattern; tests/CI + conventions; SumatraPDF itself. +- **Modify (surgical):** `uploads.py` (generic validation, real extension, + sweep everything), `config.py` (format-related settings), `pipeline.py` + (detect β†’ processor β†’ submit; real `converting`/`printing` states; + conversion lock), `models/printing.py` (+`converting`, +`format` field), + `api/print.py` + `api/web.py` (generic messages, wider accept list). +- **New:** `app/detection.py` (magic-byte-first detection); + `app/processors/` package (`base.py` Processor protocol, `pdf.py` + pass-through, later `images.py`, `office.py`, `text.py`). +- **Replaced:** nothing β€” no working component is rewritten. +- **Isolated behind interfaces:** (1) the Processor protocol + (`process(src, out_dir) -> Path` returning a print-ready PDF); (2) + `submit_pdf()`'s signature (a future alternative engine implements the + same one call); (3) the job-store function set (dict β†’ SQLite swap). + +## 4. SumatraPDF evaluation β€” KEEP 🟒/πŸ”΅ + +🟒 Confirmed from the official CLI docs: `-print-to` / `-print-settings` +accept `paper=A4|letter|legal|...`, `fit`/`shrink`/`noscale`, `center`, +`color`/`monochrome`, `duplex`, copies (`3x`, `collate`), page ranges +(`2-6`, `odd`, `even`); documented exit codes 0/2/3/4/5/6 (2 = file won't +open, 4 = printer not found, 5 = driver/device failure) β†’ mappable to human +messages. Reads PDF, EPUB, MOBI, CBZ, CBR, FB2, CHM, XPS, DjVu β€” **not +images, not office docs** β€” which is irrelevant here because processors +normalize everything to PDF first. (A)GPLv3 is unproblematic: invoked as a +separate process, no linking. ~15 MB, fast startup, actively maintained, +works from a console/Task-Scheduler session (the current deployment model). + +### Alternatives rejected πŸ”΅ + +| Alternative | Why rejected | +|---|---| +| LibreOffice `--pt` direct print | Still loads the whole office suite; bypasses the PDF quality checkpoint; weaker per-file error reporting | +| win32print RAW | The L3210 is a host-based GDI inkjet β€” RAW PDF bytes print as garbage | +| pypdfium2 β†’ bitmap β†’ GDI | Most control, most code, worse raster quality; documented future fallback | +| Ghostscript (mswinpr2) | AGPL, no advantage over Sumatra | +| Adobe/Foxit CLIs | Licensing / deprecation | +| MS Office COM, docx2pdf, docto | Need Office installed + licensed; unsupported headless; fragile | +| Windows print verb | Proven broken here (spike T3, WinError 1155) | + +## 5. PDF as the intermediate format β€” YES πŸ”΅ + +**Advantages:** one print engine; a debuggable artifact when paper looks +wrong (open `uploads/.pdf`); all paper/fit/orientation handling in one +place (`-print-settings`); source-document layout fidelity preserved by +LibreOffice's own layout engine; JPEGs embed losslessly; testable without a +printer; one set of print options works for every format (Phase 7). +**Disadvantages:** extra disk I/O; 10–30 s conversion per office doc on an +old PC; exotic office features may not survive export; LibreOffice must be +installed. Alternatives (XPS, raster, "each format prints itself") are +worse: weaker tooling, no multipage, N engines = N failure modes. + +## 6. Format strategy table πŸ”΅ + +| Format | Processor | Convert to PDF? | Dependencies | Difficulty | Reliability | Notes | +|---|---|---|---|---|---|---| +| PDF | pass-through | β€” | none | trivial | high | today's behavior | +| JPG/JPEG | images (Pillow) | yes | Pillow | easy | high | EXIF rotation honored | +| PNG | images (Pillow) | yes | Pillow | easy | high | alpha β†’ white | +| WebP | images (Pillow) | yes | Pillow | easy | high | wheels bundle libwebp | +| BMP/GIF/TIFF | images (bonus) | yes | Pillow | free | high | multipage TIFF | +| DOC/DOCX | office (LibreOffice headless) | yes | LibreOffice | medium | med-high | server-side fonts matter | +| XLS/XLSX | office (LibreOffice headless) | yes | LibreOffice | medium | medium | honors stored print areas/scaling | +| PPT/PPTX | office (LibreOffice headless) | yes | LibreOffice | medium | med-high | 1 slide = 1 page | +| ODT/ODS/ODP | office (bonus) | yes | LibreOffice | free | high | comes free with LibreOffice | +| TXT | text (reportlab) | generated | reportlab | easy | high | monospace, word-wrap, page breaks | +| CSV | text (csv + reportlab) | generated | reportlab | easy-med | high | bordered grid, capped rows/cols | + +Policy rejections: `.docm/.xlsm/.pptm/.dotm` (macro-enabled), `.heic` +(needs pillow-heif β€” βšͺ v2), everything else not listed. + +**Old-PC load profile (during a print job only β€” the service idles at +~100 MB):** PDF/Sumatra ~100 MB for 1–2 s; image conversion 50–200 MB for +<1 s; text conversion ~50 MB for <1 s; LibreOffice 300–500 MB for 5–30 s, +then the process exits and frees everything. Only LibreOffice is a real +cost, and there is no lighter alternative β€” any DOCX printer must load a +layout engine. + +**Office kill switch:** office formats are printable only when +`ENABLE_OFFICE=1` (default) *and* LibreOffice is actually found. +Otherwise office uploads get a friendly 415 ("convert to PDF first"); +PDF/images/text keep working. No uninstall needed to disable office. + +## 7. Print-quality reality check (honest caveats) πŸ”΅ + +- **DOCX β€” fonts are risk #1.** LibreOffice renders with fonts installed on + the *server*; missing fonts get substituted and line breaks shift. + Mitigation: install a reasonable font pack on the server; document it. +- **XLSX is the least predictable format** regardless of tool: sheets saved + without a print area paginate all columns; wide sheets split arbitrarily. + LibreOffice honors stored print areas/scaling/orientation. Document "set + a print area in Excel for best results"; fit-to-width is a βšͺ v2 knob. +- **PPTX** is reliable: slide size becomes page size; 16:9 decks print + landscape. Speaker notes are not printed. +- **Images:** honor EXIF orientation, alpha β†’ white, fit-to-page centered + with configurable margin, auto-landscape for wide images, downscale to + ≀300 effective DPI. +- **PDF pass-through** is untouched; `paper=A4,fit` handles odd sizes. + +## 8. Print job lifecycle πŸ”΅ + +States: `received β†’ queued β†’ converting β†’ printing β†’ done | failed | +cancelled` (`printing` finally gets set; `converting` explains slow office +jobs to the phone). + +1. `POST /print` β†’ validate (extension allowlist β†’ magic bytes β†’ size cap β†’ + category availability) β†’ save as `uploads/.` β†’ create job + (format recorded) β†’ **201 immediately**. +2. Worker thread: detect category β†’ pick processor β†’ status `converting` β†’ + produce `uploads/.pdf` (conversions serialized by a lock β€” + the old-PC guard). +3. Status `printing` β†’ `submit_pdf(pdf_path)`. +4. done β†’ delete original + intermediate; failed β†’ keep both + human + error; startup sweep clears leftovers. + +Edge cases: printer offline β†’ Sumatra exit 4/5 β†’ FAILED with mapped +message (spooler-side stalls after acceptance can't be seen via exit code β€” +Phase 5 adds a pywin32 pre-check + retry). Conversion crash β†’ timeout + +`taskkill /T`, FAILED with stderr tail. Corrupted file β†’ passes magic, +fails converter β†’ FAILED with converter error. Two users at once β†’ +thread-per-job stays; one conversion lock serializes conversions; the +spooler serializes printing. Huge file β†’ size cap + conversion timeout; +phone already got its 201. Restart mid-job β†’ in-memory history lost +(accepted trade-off); sweep clears partials; Phase 5 adds SQLite + +startup recovery. + +## 9. Security (practical, layered) πŸ”΅ + +Keep: PIN + LAN-only + private-profile firewall; magic-byte validation +(generalized); **server-generated filenames** (client filename stored for +display only β€” kills path traversal); arg-list subprocess (never +`shell=True`); startup sweep. +Add: hard-reject macro formats; LibreOffice invoked as +`--headless --norestore --nolockcheck -env:UserInstallation=` (isolated profile, no network) with timeout + process-tree kill; +pinned dependencies. +Explicitly NOT in MVP: dedicated low-priv conversion account, sandboxing, +AV scanning β€” βšͺ v2+ options. + +## 10. Phased roadmap + +- **Phase 0 β€” hardware spike (real PC, real paper):** T5/T6/T7 below. +- **Phase 1 (p10) β€” refactor PDF pipeline, no behavior change:** + detection + Processor layer + generalized uploads + conversion lock + + `converting`/`printing` states; all tests stay green. +- **Phase 2 (p11) β€” images:** Pillow processor; web page accept/copy; + `PAPER_SIZE` wiring; cancel cleanup must delete `.` too + (`uploads.delete_job_files`). +- **Phase 3 (p12) β€” office:** install LibreOffice (run T6 first); + `office.py` adapter (timeout, taskkill, profile isolation, + `ENABLE_OFFICE` kill switch); friendly error mapping; font-pack docs; + verify a table-heavy DOCX and a print-area XLSX on real paper. +- **Phase 4 (p13) β€” text/CSV:** reportlab renderer β€” TXT = monospace text + with wrap; CSV = bordered grid with row/col caps + "truncated" notice. +- **Phase 5 (p14) β€” queue management:** cancel while queued/converting; + spooler purge via `win32print.SetJob` once printed; retry failed jobs; + SQLite persistence (SOURCE_OF_TRUTH Β§12 upgrade path) + startup recovery. +- **Phase 6 (p15) β€” reliability:** pre-dispatch printer-status check; error + catalog (exit code β†’ message); log rotation; startup recovery. +- **Phase 7 (v2) β€” print options & dialog UI:** optional per-request + options on `POST /print`: `copies` (1–99 β†’ `3x` + `collate`), `pages` + (range `2-6`, `odd`/`even` β€” strict allowlist regex before it touches a + command line), `paper` (A4 / short bond Letter / long bond 8.5Γ—13 via + `paper=215.9mm x 330.2mm` or driver paper name β€” one spike line), + `color_mode` (`color`/`monochrome`). One `print-settings` builder in the + pipeline; stored on the job; web page gains dropdown/inputs in its + vanilla-JS style. Works for **all** formats automatically because + everything is a PDF by print time. Duplex/quality/tray hidden (L3210 has + no duplex hardware, one tray). A print preview (serve the intermediate + PDF to the phone before printing) comes nearly free later. + +Each phase: ruff + pytest + β‰₯90 % coverage gate; README + +SOURCE_OF_TRUTH updated; one commit per phase (p10, p11, …). + +## 11. MVP / v2 / future + +- **MVP (Phases 0–4):** PDF, JPG/PNG/WebP, DOCX/XLSX/PPTX (+ legacy + ODF), + TXT/CSV. +- **v2 (Phases 5–7):** queue management, reliability hardening, print + options & dialog UI; plus HEIC via pillow-heif, pypdf pre-validation, + multi-image jobs, printer-offline retry parking, per-format size caps, + print preview. +- **Future (do NOT build):** everything in SOURCE_OF_TRUTH Β§16 (internet + exposure, cloud, Docker, multi-user, dashboards), an IPP server, direct + USB/Epson protocol, distributed anything. + +## 12. Printer note + +Stay on the Windows printing stack: SumatraPDF β†’ spooler β†’ Epson driver β†’ +USB, exactly as today. Direct USB communication (ESC/P-R via libusb) only +pays off for real-time status/ink management and is a large, brittle, +printer-firmware-coupled build β€” rejected. + +## 13. Assumptions that need the spike πŸ”΄ + +1. LibreOffice headless behaves under the Task-Scheduler startup session on + the target PC (T6). +2. The target OS runs current LibreOffice β€” ≀4 GB RAM is fine; if it turns + out to be Win 7/8.1, pin LibreOffice 7.6.x (last branch supporting it). +3. The Epson driver honors `paper=A4` from Sumatra's `-print-settings` + (today nothing pins paper size). +4. LibreOffice fidelity on *your* real documents (table-heavy DOCX, + print-area XLSX). +5. ~1.2 GB free disk for LibreOffice; serialized conversions keep RAM safe + on the ≀4 GB PC. +6. Long-bond paper (8.5Γ—13) prints correctly via Sumatra custom paper size + or the Epson driver's named paper (Phase 7 spike line). + +## 14. Spike protocol (Phase 0) + +Extend the T1–T4 convention in `spike_print_test.py` (T4 = SumatraPDF chain, +PASS with real paper). Same output style: print `T PASS/FAIL` plus +timings, and record results in SOURCE_OF_TRUTH Β§5. + +### T5 β€” Images β†’ PDF β†’ paper +1. `pip install pillow` (dev only for the spike). +2. Python: create three test images with Pillow (a photo-like gradient JPEG, + a PNG with transparency, a WebP), then fit each onto an A4/Letter page + (white background, centered) and save as PDF. +3. Print each via the T4 command (`SumatraPDF.exe -print-to "EPSON L3210 + Series" -silent `). +**PASS =** three pages on paper, correct orientation, transparency rendered +white (not black), no clipping. + +### T6 β€” Office β†’ PDF β†’ paper (needs LibreOffice installed) +1. Install LibreOffice (default install; note the disk usage). +2. `soffice --headless --norestore --convert-to pdf --outdir %TEMP% test.docx` + with (a) a table-heavy DOCX, (b) an XLSX with a defined print area, (c) a + 16:9 PPTX. Open each PDF and compare against the source app. +3. Print each PDF via the T4 command. +4. Measure: `powershell Measure-Command { soffice ... }` per file; watch RAM + in Task Manager during conversion. +5. Repeat one conversion while the service runs under Task Scheduler + ("at startup", before logon) to verify session-0 behavior. +**PASS =** PDFs open correctly, layouts acceptable, paper output matches, +conversion ≀ 30 s and RAM ≀ ~500 MB on the target PC. + +### T7 β€” TXT/CSV β†’ PDF β†’ paper +1. `pip install reportlab` (dev only for the spike). +2. Python: render a wrapped TXT (long lines, unicode) and a CSV (20Γ—6 grid) + to PDF with reportlab; print via T4. +**PASS =** readable monospace text with wrapping; CSV grid aligned, nothing +cut off at the right margin. + +## 15. Open items + +- [ ] Run T5/T6/T7 on the real print-server PC; record results in + SOURCE_OF_TRUTH Β§5. +- [ ] Confirm the target PC's Windows version before Phase 3 (pin LO 7.6.x + if Win 7/8.1). +- [ ] Phase 2: verify cancel cleanup covers non-PDF extensions. +- [ ] Phase 7: verify long-bond paper on the Epson driver (custom mm size + vs driver paper name). diff --git a/docs/SOURCE_OF_TRUTH.md b/docs/SOURCE_OF_TRUTH.md index 77cfa77..e21c150 100644 --- a/docs/SOURCE_OF_TRUTH.md +++ b/docs/SOURCE_OF_TRUTH.md @@ -309,6 +309,14 @@ This is a home-lab project, so the goal is **sensible defaults**, not enterprise - Basic queue management if multiple jobs arrive close together. - Logging, and automatic cleanup of temp files. +### Multi-Format Printing (current stage β€” p10 onward) +The service grows from PDF-only to a general multi-format print service +(images, office documents, text/CSV) with PDF as the one internal print +format and SumatraPDF kept as the print engine. The full investigation, +decision record, phased roadmap and spike protocol (T5–T7) live in +[MULTI_FORMAT_PLAN.md](MULTI_FORMAT_PLAN.md); its hardware spikes extend +Section 5's T1–T4 convention before any new format prints real paper. + --- ## 10. Project Folder Structure πŸ”΅ From d078d41d5e5662c88571a64bf021fae0052c5560 Mon Sep 17 00:00:00 2001 From: geb Date: Fri, 28 Aug 2026 23:23:00 +0800 Subject: [PATCH 02/16] p10: multi-format groundwork - detection + processor registry - app/detection.py: magic-byte detection (PDF/JPEG/PNG/WebP/OLE, ZIP containers sniffed for office parts), extension allowlist, macro-format policy list - app/processors/: Processor protocol + category registry; the PDF pass-through is the only registered processor, so upload behavior stays PDF-only - uploads: validate_upload replaces validate_pdf (macro rejection, content vs extension cross-check, availability gate, size last), files stored with their real extension, sweep covers all files, delete_job_files centralizes per-job cleanup (used by cancel too) - pipeline: conversion stage behind a lock (one conversion at a time for the <=4GB PC), converting/printing states now actually set - models/jobs: converting state + format field on PrintJob - api: /print validates generically and records the format - config/.env.example: PAPER_SIZE, ENABLE_OFFICE, LO_PATH, CONVERT_TIMEOUT_S placeholders for phases 2-3 - tests: 132 pass, coverage 96.8% (gate 90%) --- .env.example | 20 +++++ app/api/jobs.py | 12 ++- app/api/print.py | 66 +++++++++------- app/config.py | 25 +++++++ app/detection.py | 117 +++++++++++++++++++++++++++++ app/models/printing.py | 7 +- app/processors/__init__.py | 33 ++++++++ app/processors/base.py | 42 +++++++++++ app/processors/pdf.py | 21 ++++++ app/services/jobs.py | 15 +++- app/services/pipeline.py | 66 +++++++++++----- app/services/uploads.py | 137 ++++++++++++++++++++++++++++------ tests/api/test_print_api.py | 2 +- tests/unit/test_detection.py | 97 ++++++++++++++++++++++++ tests/unit/test_pipeline.py | 79 ++++++++++++++++++-- tests/unit/test_processors.py | 39 ++++++++++ tests/unit/test_uploads.py | 118 +++++++++++++++++++++++------ 17 files changed, 787 insertions(+), 109 deletions(-) create mode 100644 app/detection.py create mode 100644 app/processors/__init__.py create mode 100644 app/processors/base.py create mode 100644 app/processors/pdf.py create mode 100644 tests/unit/test_detection.py create mode 100644 tests/unit/test_processors.py diff --git a/.env.example b/.env.example index 58de782..ea15c95 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,23 @@ 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 (used from the image phase on). +# Examples: A4, letter, legal. +PAPER_SIZE=A4 + +# Office documents (DOC/XLS/PPT and friends) need LibreOffice Headless in a +# later phase. ENABLE_OFFICE=0 turns office formats off without uninstalling +# anything; they are also refused while LibreOffice is not installed. +ENABLE_OFFICE=1 + +# Explicit path to soffice.exe. Empty = use the standard install location. +LO_PATH= + +# Seconds a file conversion may run before the service kills it. +CONVERT_TIMEOUT_S=120 diff --git a/app/api/jobs.py b/app/api/jobs.py index 2d7affe..6e2a759 100644 --- a/app/api/jobs.py +++ b/app/api/jobs.py @@ -10,9 +10,8 @@ from fastapi import APIRouter, Depends, HTTPException from app.models.printing import PrintJob -from app.services import jobs +from app.services import jobs, uploads from app.services.auth import require_pin -from app.services.uploads import upload_path router = APIRouter() @@ -43,10 +42,9 @@ def cancel(job_id: str, _: None = Depends(require_pin)): 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 + # Remove the stored file(s) so cancelled uploads don't fill the disk. + # delete_job_files covers the source upload and, once non-PDF formats + # land, its converted PDF alongside it. + uploads.delete_job_files(job_id) return jobs.get_job(job_id) diff --git a/app/api/print.py b/app/api/print.py index 1bddd9d..adb56aa 100644 --- a/app/api/print.py +++ b/app/api/print.py @@ -1,67 +1,79 @@ """ -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. Phase 1 + registers only the PDF processor, so everything else is refused with + "support arrives in a later phase". + 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 app.detection import DEFAULT_EXTENSIONS from app.models.printing import PrintAccepted 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(...), _: None = Depends(require_pin), # PIN required only when API_PIN is set ): - """Accept a PDF 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). - """ + """Accept a file exactly like a web form uploads a photo: a + multipart/form-data POST whose file field is named "file".""" 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)) + # 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) + 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) + pipeline.start_job(job_id, path, category) 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/config.py b/app/config.py index ac82e65..9ee3171 100644 --- a/app/config.py +++ b/app/config.py @@ -52,4 +52,29 @@ def _get(name: str, default: str) -> str: # Every real PDF starts with these 5 bytes β€” the "magic bytes" check that # catches renamed/fake files that a mere ".pdf" extension check would miss. +# Consumed by app/detection.py, which generalizes the idea to every format. PDF_MAGIC = b"%PDF-" + +# ------------------------------------------------------------------ +# Multi-format printing settings (docs/MULTI_FORMAT_PLAN.md). +# Phase 1 only wires the config; each value becomes load-bearing in the +# phase that needs it (images p11, office p12, print options v2). +# ------------------------------------------------------------------ + +# Paper size passed to SumatraPDF's -print-settings once non-PDF formats +# reach the printer (Phase 2). Examples: A4, letter, legal. +PAPER_SIZE = _get("PAPER_SIZE", "A4") + +# Office conversion (Phase 3): LibreOffice Headless. ENABLE_OFFICE is the +# kill switch for the old PC β€” 0 turns office formats off without +# uninstalling anything; they are additionally refused while LibreOffice +# is not installed. +ENABLE_OFFICE = _get("ENABLE_OFFICE", "1").strip().lower() not in ("0", "false", "no") + +# Explicit path to soffice.exe. Empty = use the standard install location. +LO_PATH = _get("LO_PATH", "") + +# Seconds a file conversion may run before the service kills it (enforced +# by the office processor's subprocess handling; images/text finish in well +# under a second). +CONVERT_TIMEOUT_S = int(_get("CONVERT_TIMEOUT_S", "120")) diff --git a/app/detection.py b/app/detection.py new file mode 100644 index 0000000..43e4d98 --- /dev/null +++ b/app/detection.py @@ -0,0 +1,117 @@ +"""Format detection β€” "what kind of file is this, really?" (multi-format +plan, docs/MULTI_FORMAT_PLAN.md Section 3). + +Detection is deliberately separate from "what can we print": the printable +gate lives in app/processors (a category becomes printable only once a +processor is registered for it). Phase 1 registers only "pdf"; image, +office and text arrive in Phases 2–4 without touching this module again. + +Rules, cheapest first (SOURCE_OF_TRUTH Section 8): + +1. The extension is only a HINT β€” extensions lie, so nothing is accepted + on the extension alone. +2. Every supported binary format has a fixed magic signature; the + signature wins whenever it disagrees with the extension. +3. ZIP and OLE containers hold several formats (DOCX/XLSX/PPTX/ODF all + start with PK), so the container is opened and its entry names are + sniffed to confirm it really is an office document. +4. Plain text (.txt/.csv) has no magic bytes: it is classified by + extension, and its decodability is verified later by its processor + (Phase 4). + +This module knows nothing about HTTP β€” uploads.py maps classification +failures to 415 responses. +""" + +import zipfile +from io import BytesIO +from pathlib import Path + +from app.config import PDF_MAGIC + +JPEG_MAGIC = b"\xff\xd8\xff" +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" +WEBP_MAGIC = b"RIFF" # a RIFF container; b"WEBP" must follow at offset 8 +OLE_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" # legacy DOC/XLS/PPT +ZIP_MAGIC = b"PK\x03\x04" # OOXML (DOCX/XLSX/PPTX) and ODF containers + +# Categories whose files MUST carry their magic signature. Text is the +# exception: bytes that are "just text" are indistinguishable from any +# other content, so .txt/.csv are trusted at upload time and verified +# (decodable, sane) by their processor. +MAGIC_REQUIRED = frozenset({"pdf", "image", "office"}) + +# Macro-enabled Office formats are rejected outright, before any other +# check runs (plan Section 9). LibreOffice headless would not execute +# their macros, but rejecting is cheaper and safer than relying on that. +MACRO_EXTENSIONS = frozenset( + {".docm", ".dotm", ".xlsm", ".xltm", ".pptm", ".potm"} +) + +EXTENSION_CATEGORIES: dict[str, str] = { + ".pdf": "pdf", + ".jpg": "image", + ".jpeg": "image", + ".png": "image", + ".webp": "image", + ".bmp": "image", + ".gif": "image", + ".tif": "image", + ".tiff": "image", + ".doc": "office", + ".docx": "office", + ".xls": "office", + ".xlsx": "office", + ".ppt": "office", + ".pptx": "office", + ".odt": "office", + ".ods": "office", + ".odp": "office", + ".txt": "text", + ".csv": "text", +} + +# Extension to store a file under when the client sent no usable filename +# (its category was proven by magic bytes instead). +DEFAULT_EXTENSIONS = { + "pdf": ".pdf", + "image": ".jpg", + "office": ".docx", + "text": ".txt", +} + + +def category_for(filename: str) -> str | None: + """The category an extension claims, or None for unknown/absent names.""" + ext = Path(filename).suffix.lower() if filename else "" + return EXTENSION_CATEGORIES.get(ext) + + +def magic_category(data: bytes) -> str | None: + """The category the CONTENT claims, or None if no signature matches.""" + if data.startswith(PDF_MAGIC): + return "pdf" + if data.startswith(JPEG_MAGIC) or data.startswith(PNG_MAGIC): + return "image" + if data.startswith(WEBP_MAGIC) and data[8:12] == b"WEBP": + return "image" + if data.startswith(OLE_MAGIC): + return "office" + if data.startswith(ZIP_MAGIC) and _is_office_zip(data): + return "office" + return None + + +def _is_office_zip(data: bytes) -> bool: + """Tell printable office containers from ordinary zip files. + + OOXML parts live under word/ (DOCX), xl/ (XLSX) or ppt/ (PPTX); ODF + files carry a "mimetype" entry. Anything else is a zip we don't print. + """ + try: + names = zipfile.ZipFile(BytesIO(data)).namelist() + except (zipfile.BadZipFile, OSError): + return False + return any(name.startswith(("word/", "xl/", "ppt/")) for name in names) or ( + "mimetype" in names + ) diff --git a/app/models/printing.py b/app/models/printing.py index 2d05b89..00fe352 100644 --- a/app/models/printing.py +++ b/app/models/printing.py @@ -37,18 +37,23 @@ class PrintJob(BaseModel): updated_at: datetime printer: str | None = None # set in Phase 5 when actually submitted error: str | None = None + format: str | None = None # detected category ("pdf" today; image/office/text as phases land) class JobStatus: """The lifecycle of a job. String constants keep the JSON simple. - received β†’ queued β†’ printing β†’ done + received β†’ queued β†’ converting β†’ printing β†’ done β†˜ failed received (or queued, once P5 submits to Windows) β†’ cancelled + + `converting` (p10) covers format conversion β€” a no-op for PDFs, but the + visible step that explains why an office document takes tens of seconds. """ RECEIVED = "received" QUEUED = "queued" + CONVERTING = "converting" PRINTING = "printing" DONE = "done" FAILED = "failed" diff --git a/app/processors/__init__.py b/app/processors/__init__.py new file mode 100644 index 0000000..80c967c --- /dev/null +++ b/app/processors/__init__.py @@ -0,0 +1,33 @@ +"""The processor registry: category β†’ the processor that converts it to PDF. + +Registering a processor here is the ONLY code change needed to enable a +format category (its magic signatures already live in app/detection.py). +A category without a registration is detected but refused at upload time β€” +the Phase 1 state for image/office/text (docs/MULTI_FORMAT_PLAN.md Β§10). +""" + +from app.processors.base import ConversionError, Processor +from app.processors.pdf import PDF_PROCESSOR + +__all__ = [ + "ConversionError", + "Processor", + "for_category", + "supported_categories", +] + +_REGISTRY: dict[str, Processor] = { + "pdf": PDF_PROCESSOR, +} + + +def for_category(category: str) -> Processor | None: + """The processor for a category, or None while that format is + unregistered ("not enabled yet" β€” the upload gate and the pipeline + both treat None as "cannot convert").""" + return _REGISTRY.get(category) + + +def supported_categories() -> tuple[str, ...]: + """Categories that can currently be printed (sorted for stable display).""" + return tuple(sorted(_REGISTRY)) diff --git a/app/processors/base.py b/app/processors/base.py new file mode 100644 index 0000000..756b85f --- /dev/null +++ b/app/processors/base.py @@ -0,0 +1,42 @@ +"""The Processor contract (multi-format plan, docs/MULTI_FORMAT_PLAN.md Β§3). + +A processor turns ONE category of uploaded file into the service's single +internal print format: a PDF. Everything downstream of the processors β€” +the pipeline, `submit_pdf()`, SumatraPDF, the Windows queue β€” only ever +sees a PDF, which is exactly what spike T4 proved prints reliably on the +L3210. + +This is the seam that keeps the print engine format-agnostic forever: +adding a format means writing one processor and registering it in +app/processors/__init__.py β€” the pipeline never changes. +""" + +from pathlib import Path +from typing import Protocol + + +class ConversionError(Exception): + """Raised with a human-readable reason when a file cannot be converted. + + The pipeline records the message as the job's error β€” write for the + person holding the phone ("LibreOffice could not open the file: ..."), + not for a log analyst. + """ + + +class Processor(Protocol): + """One format category's conversion step. + + Implementations must be safe to run on the pipeline's background + thread, must not modify `src`, and must return a path to a valid PDF. + Long-running converters (LibreOffice, Phase 3) are subprocess calls + with their own timeout + process-tree kill. + """ + + def process(self, src: Path, out_dir: Path) -> Path: + """Convert `src` into a print-ready PDF and return that PDF's path. + + `out_dir` is the directory to write the converted file into + (uploads/, next to the original) so cleanup stays one glob away. + """ + ... diff --git a/app/processors/pdf.py b/app/processors/pdf.py new file mode 100644 index 0000000..4983740 --- /dev/null +++ b/app/processors/pdf.py @@ -0,0 +1,21 @@ +"""PDF pass-through processor (Phase 1). + +PDF already IS the service's internal print format, so "conversion" is a +no-op that returns the source unchanged. It exists so every category flows +through the same pipeline code path β€” detect β†’ processor β†’ print engine β€” +with no PDF special case anywhere. +""" + +from pathlib import Path + + +class PdfProcessor: + def process(self, src: Path, out_dir: Path) -> Path: + # Source bytes were validated at upload time (magic + size); the + # real "can Sumatra open it" check happens when Sumatra runs, and + # its exit code 2 becomes a job error with a human message. + return src + + +# Stateless β†’ one shared instance for every job. +PDF_PROCESSOR = PdfProcessor() diff --git a/app/services/jobs.py b/app/services/jobs.py index fa49f8a..4570ec9 100644 --- a/app/services/jobs.py +++ b/app/services/jobs.py @@ -25,8 +25,18 @@ def _now() -> datetime: return datetime.now(timezone.utc) -def create_job(job_id: str, filename: str, size_bytes: int, path: Path) -> PrintJob: - """Register a freshly uploaded file as a tracked job.""" +def create_job( + job_id: str, + filename: str, + size_bytes: int, + path: Path, + format: str | None = None, +) -> PrintJob: + """Register a freshly uploaded file as a tracked job. + + `format` is the detected category from upload validation ("pdf" in + Phase 1) β€” recorded so the pipeline can pick the right processor. + """ job = PrintJob( job_id=job_id, filename=filename, @@ -34,6 +44,7 @@ def create_job(job_id: str, filename: str, size_bytes: int, path: Path) -> Print status=JobStatus.RECEIVED, created_at=_now(), updated_at=_now(), + format=format, ) with _lock: _jobs[job_id] = job diff --git a/app/services/pipeline.py b/app/services/pipeline.py index dc45b4a..907db86 100644 --- a/app/services/pipeline.py +++ b/app/services/pipeline.py @@ -1,15 +1,33 @@ -""" -Job submission pipeline (Phase 5) β€” turns a stored upload into paper. +"""Job submission pipeline (Phase 5; multi-format in p10) β€” turns a stored +upload into paper. Why a background thread: printing a PDF can take seconds. Doing it inside the HTTP request would make the phone wait with no feedback; instead the upload response returns immediately with status "queued" (matching the -Section 11 API design), and the job's status moves forward in the store: +Section 11 API design), and the job's status moves forward in the store. + +The multi-format shape (docs/MULTI_FORMAT_PLAN.md Β§3/Β§8): + + detect (upload time) β†’ processor β†’ PDF β†’ submit_pdf β†’ Windows queue + +PDF is the service's ONE internal print format: every category is turned +into a PDF before submit_pdf() ever sees it, so the print engine stays +byte-for-byte what Phase 5 proved with real paper (spike T4). - received β†’ queued β†’ done (or failed, with a human-readable error) +The job's states now move: -Windows' own print queue serializes actual printing between concurrent -jobs (SOURCE_OF_TRUTH Section 2), so we don't need our own queue for v1. + received β†’ queued β†’ converting β†’ printing β†’ done + β†˜ failed + +`printing` used to be defined but never set; it now wraps the actual +submission, and `converting` covers the (future) slow office conversions +so the phone can tell "working on your DOCX" from "talking to the printer". + +The conversion lock: at most ONE conversion runs at a time. On the +print-server PC (≀4 GB RAM) that keeps future LibreOffice conversions from +stacking up; the PDF pass-through holds it for microseconds, and Windows' +own print queue keeps serializing actual printing between concurrent jobs +(SOURCE_OF_TRUTH Section 2). """ import logging @@ -18,36 +36,48 @@ from app.models.printing import JobStatus from app.printer import windows -from app.services import jobs +from app.processors import for_category +from app.services import jobs, uploads logger = logging.getLogger(__name__) +# The old-PC guard: one conversion at a time, job or no job. +_conversion_lock = threading.Lock() -def start_job(job_id: str, pdf_path: Path) -> None: + +def start_job(job_id: str, src: Path, category: str = "pdf") -> None: """Hand a freshly uploaded job to a background submission thread.""" jobs.update_status(job_id, JobStatus.QUEUED) threading.Thread( target=_process, - args=(job_id, pdf_path), + args=(job_id, src, category), name=f"print-{job_id}", daemon=True, # never block service shutdown on a stuck print job ).start() -def _process(job_id: str, pdf_path: Path) -> None: +def _process(job_id: str, src: Path, category: str) -> None: try: + pdf_path = src + processor = for_category(category) + if processor is not None: + jobs.update_status(job_id, JobStatus.CONVERTING) + with _conversion_lock: + pdf_path = processor.process(src, src.parent) + + jobs.update_status(job_id, JobStatus.PRINTING) method, printer = windows.submit_pdf(pdf_path) jobs.update_status(job_id, JobStatus.DONE, printer=printer) - logger.info("job %s submitted via %s to %r", job_id, method, printer) + logger.info( + "job %s (%s) submitted via %s to %r", job_id, category, method, printer + ) # Temp-file lifecycle (Section 8): printed β†’ no longer needed. - try: - pdf_path.unlink(missing_ok=True) - except OSError: - logger.warning("could not delete %s after printing", pdf_path) + # Covers the source upload AND the converted PDF in one sweep. + uploads.delete_job_files(job_id) except Exception as exc: - logger.exception("job %s failed to print", job_id) - # Keep the stored file on failure β€” useful for diagnosing, and the - # startup sweep (Phase 4) eventually clears it. + logger.exception("job %s failed", job_id) + # Keep the stored file(s) on failure β€” useful for diagnosing, and the + # startup sweep (Phase 4) eventually clears them. jobs.update_status(job_id, JobStatus.FAILED, error=str(exc)) diff --git a/app/services/uploads.py b/app/services/uploads.py index 17d318a..5ef77eb 100644 --- a/app/services/uploads.py +++ b/app/services/uploads.py @@ -1,19 +1,42 @@ -""" -Upload handling (Phase 4): validation + temporary storage. +"""Upload handling (Phase 4; generalized for multi-format in p10). Lifecycle of an uploaded file: - phone β†’ POST /print β†’ validated here β†’ uploads/.pdf - β†’ (Phase 5) handed to the Windows print queue β†’ deleted + phone β†’ POST /print β†’ validated here β†’ uploads/ + β†’ pipeline converts to uploads/.pdf β†’ printed β†’ deleted + +The PDF-only days enforced one rule ("is this a PDF?"); the multi-format +service enforces a policy instead (docs/MULTI_FORMAT_PLAN.md Β§6/Β§9): + + 0. Macro-enabled Office formats are refused outright β€” policy, not + technology (rejecting is cheaper than trusting a converter not to + run their macros). + 1. The extension is a hint; app/detection.py decides what the file + really is, using magic bytes. + 2. A binary format must actually show its signature β€” a renamed file + fails here even though its extension looks right. + 3. A detected format is only accepted when a processor is registered + for it (app/processors) β€” image/office/text stay refused until + their phases land. + 4. The size limit still protects memory and disk from hostile uploads. Anything still in uploads/ when the service starts is stale (the previous run died before cleanup), so the app sweeps it on startup β€” the cheap -insurance SOURCE_OF_TRUTH Section 8 asks for. +insurance SOURCE_OF_TRUTH Section 8 asks for. uploads/ is service-managed +(every name in it is server-generated), so the sweep now removes ANY file, +not just PDFs. """ import uuid from pathlib import Path -from app.config import MAX_UPLOAD_MB, PDF_MAGIC, UPLOAD_DIR +from app.config import MAX_UPLOAD_MB, UPLOAD_DIR +from app.detection import ( + EXTENSION_CATEGORIES, + MACRO_EXTENSIONS, + category_for, + magic_category, +) +from app.processors import for_category class UploadError(Exception): @@ -28,45 +51,113 @@ def ensure_upload_dir() -> None: UPLOAD_DIR.mkdir(parents=True, exist_ok=True) -def upload_path(job_id: str) -> Path: +def upload_path(job_id: str, ext: str = ".pdf") -> Path: """Where an upload with this job id lives on disk. Every module that needs to find a stored upload goes through here, so the location is defined once β€” and tests can redirect it in one place. """ - return UPLOAD_DIR / f"{job_id}.pdf" + return UPLOAD_DIR / f"{job_id}{ext}" -def validate_pdf(filename: str, data: bytes) -> None: - """Three checks, cheapest first (SOURCE_OF_TRUTH Section 8).""" +def job_files(job_id: str) -> list[Path]: + """Every file belonging to a job: the source upload and β€” once non-PDF + formats exist β€” its converted PDF alongside it. Defined once so the + pipeline's cleanup and the cancel endpoint agree on what a job leaves + behind.""" + ensure_upload_dir() + return sorted(path for path in UPLOAD_DIR.glob(f"{job_id}.*") if path.is_file()) - # 1. Extension hint β€” a cheap first look, but extensions can lie. - if filename and not filename.lower().endswith(".pdf"): - raise UploadError("Only .pdf files are accepted.", status_code=415) - # 2. Magic bytes β€” what a file CLAIMS to be matters less than what it IS. - # A real PDF always begins with the bytes b"%PDF-"; a renamed .txt - # fails here even though it ends in ".pdf". - if not data.startswith(PDF_MAGIC): +def delete_job_files(job_id: str) -> int: + """Delete every file of a job. Returns how many were removed.""" + removed = 0 + for path in job_files(job_id): + try: + path.unlink() + removed += 1 + except OSError: + pass # never let cleanup crash the service + return removed + + +def validate_upload(filename: str, data: bytes) -> str: + """Security gate for every upload (SOURCE_OF_TRUTH Section 8). + + Checks run cheapest-first; returns the detected category ("pdf" in + Phase 1) that the API records on the job and the pipeline dispatches + on. + """ + # 0. Macro policy β€” before any content parsing even looks at the file. + ext = Path(filename).suffix.lower() if filename else "" + if ext in MACRO_EXTENSIONS: + raise UploadError( + f"Macro-enabled Office files ({ext}) are not accepted for security " + "reasons. Re-save the document without macros as a plain " + ".docx/.xlsx/.pptx, or export a PDF.", + status_code=415, + ) + + # 1+2. Extension hint vs magic evidence (app/detection.py): the allowlist + # stays explicit β€” an unknown extension is refused even when the + # content itself is recognizable (a lying "virus.exe" must not sneak + # in just because it happens to contain a PDF). When both sides are + # known they must agree; a binary format must show its signature. + if ext and ext not in EXTENSION_CATEGORIES: + raise UploadError( + f"Unsupported file type '{ext}'. Currently supported: .pdf β€” " + "more formats arrive in later phases.", + status_code=415, + ) + claimed = category_for(filename) + content = magic_category(data) + category = claimed or content + if category is None: + raise UploadError( + "Unsupported file type '(no extension)'. Currently supported: " + ".pdf β€” more formats arrive in later phases.", + status_code=415, + ) + if content is not None and content != category: + raise UploadError( + f"File content does not match the '{ext or '(none)'}' extension " + f"(content looks like {content} data).", + status_code=415, + ) + if category in {"pdf", "image", "office"} and content is None: + # Binary formats must show their signature; text is the only + # extension-trusted category (see detection.py). + reason = { + "pdf": "File content is not a PDF (missing %PDF- header).", + "image": "File content does not look like an image.", + "office": "File content does not look like an Office document.", + }[category] + raise UploadError(reason, status_code=415) + + # 3. Availability β€” a category prints only once its processor is + # registered (app/processors). Phase 1: PDF only. + if for_category(category) is None: raise UploadError( - "File content is not a PDF (missing %PDF- header).", + f"'{ext or 'this format'}' files cannot be printed yet β€” support " + "arrives in a later phase. Currently supported: .pdf.", status_code=415, ) - # 3. Size limit β€” protects memory and disk from huge or hostile uploads. + # 4. Size limit β€” protects memory and disk from huge or hostile uploads. max_bytes = MAX_UPLOAD_MB * 1024 * 1024 if len(data) > max_bytes: raise UploadError( f"File is {len(data) / 1_000_000:.1f} MB; limit is {MAX_UPLOAD_MB} MB.", status_code=413, ) + return category -def save_upload(data: bytes) -> tuple[str, Path]: +def save_upload(data: bytes, ext: str = ".pdf") -> tuple[str, Path]: """Store the bytes under a unique name. Returns (job_id, saved_path).""" ensure_upload_dir() job_id = uuid.uuid4().hex[:12] # short, unique, no secrets in it - path = upload_path(job_id) + path = upload_path(job_id, ext) path.write_bytes(data) return job_id, path @@ -75,7 +166,9 @@ def sweep_stale_uploads() -> int: """Delete leftovers from a previous run. Returns how many were removed.""" ensure_upload_dir() removed = 0 - for stale in UPLOAD_DIR.glob("*.pdf"): + for stale in UPLOAD_DIR.iterdir(): + if not stale.is_file(): + continue # directories (or oddities) are skipped, not deleted try: stale.unlink() removed += 1 diff --git a/tests/api/test_print_api.py b/tests/api/test_print_api.py index 2973187..746a06d 100644 --- a/tests/api/test_print_api.py +++ b/tests/api/test_print_api.py @@ -69,7 +69,7 @@ def test_rejected_files_never_reach_the_store(self, client, mock_print, tmp_uplo class TestPrintErrors: def test_disk_failure_returns_500(self, client, pdf_bytes, monkeypatch, mock_print): - def broken_save(data): + def broken_save(data, ext=".pdf"): raise OSError("disk full") monkeypatch.setattr("app.api.print.save_upload", broken_save) diff --git a/tests/unit/test_detection.py b/tests/unit/test_detection.py new file mode 100644 index 0000000..efdcd3f --- /dev/null +++ b/tests/unit/test_detection.py @@ -0,0 +1,97 @@ +"""Unit tests for format detection (app/detection.py). + +Detection is pure logic β€” no HTTP, no disk, no status codes β€” so these +tests pin its rules: + - extension is only a hint (category_for); + - magic bytes are the evidence (magic_category); + - ZIP/OLE containers are sniffed to confirm they really are office docs; + - text has no signature, so it is classified by extension only. +""" + +import io +import zipfile + +import pytest + +from app.detection import category_for, magic_category + +JPEG = b"\xff\xd8\xff\xe0" + b"\x00" * 8 +PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 8 +WEBP = b"RIFF\x24\x00\x00\x00WEBP" + b"\x00" * 4 +OLE = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\x00" * 8 +ZIP_PLAIN = b"PK\x03\x04" + b"\x00" * 8 # a zip that is not an office document +RUBBISH = b"definitely not any known format" + + +def build_ooxml(part_prefix: str) -> bytes: + """A real (small) zip whose entry names look like an OOXML document.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr(f"{part_prefix}/document.xml", b"") + return buffer.getvalue() + + +class TestMagicCategory: + def test_pdf_magic(self): + assert magic_category(b"%PDF-1.7 trailing bytes") == "pdf" + + def test_jpeg_magic(self): + assert magic_category(JPEG) == "image" + + def test_png_magic(self): + assert magic_category(PNG) == "image" + + def test_webp_magic_needs_the_riff_subtype(self): + assert magic_category(WEBP) == "image" + # "RIFF" alone (e.g. a WAV file) is not an image β€” offset 8 must say WEBP. + assert magic_category(b"RIFF\x24\x00\x00\x00WAVE") is None + assert magic_category(b"RIFF") is None # truncated: no decision either way + + def test_ole_magic_is_office(self): + assert magic_category(OLE) == "office" + + def test_ooxml_containers_are_office(self): + for prefix in ("word", "xl", "ppt"): + assert magic_category(build_ooxml(prefix)) == "office" + + def test_odf_mimetype_entry_is_office(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("mimetype", "application/vnd.oasis.opendocument.text") + assert magic_category(buffer.getvalue()) == "office" + + def test_plain_zip_is_not_office(self): + assert magic_category(ZIP_PLAIN) is None + + def test_corrupt_zip_is_not_office(self): + assert magic_category(b"PK\x03\x04this is not really a zip") is None + + def test_rubbish_is_unknown(self): + assert magic_category(RUBBISH) is None + + def test_empty_bytes_are_unknown(self): + assert magic_category(b"") is None + + +class TestCategoryFor: + @pytest.mark.parametrize( + ("filename", "expected"), + [ + ("report.pdf", "pdf"), + ("REPORT.PDF", "pdf"), + ("photo.jpg", "image"), + ("photo.JPEG", "image"), + ("shot.webp", "image"), + ("invoice.docx", "office"), + ("sheet.xls", "office"), + ("deck.pptx", "office"), + ("notes.txt", "text"), + ("data.csv", "text"), + ], + ) + def test_known_extensions(self, filename, expected): + assert category_for(filename) == expected + + @pytest.mark.parametrize("filename", ["virus.exe", "archive.zip", "", "noext"]) + def test_unknown_or_absent_extensions_return_none(self, filename): + assert category_for(filename) is None diff --git a/tests/unit/test_pipeline.py b/tests/unit/test_pipeline.py index 339e00c..24c3b16 100644 --- a/tests/unit/test_pipeline.py +++ b/tests/unit/test_pipeline.py @@ -4,8 +4,14 @@ threading.Event so each test waits deterministically for that thread instead of sleeping. This is where the documented lifecycle lives: - received β†’ queued β†’ done (file deleted) - β†˜ failed (file kept for diagnosis) + received β†’ queued β†’ converting β†’ printing β†’ done (files deleted) + β†˜ failed (files kept) + +Since p10 the pipeline has a conversion stage between upload and print: +the processor turns the source file into the service's one print format +(a PDF). Phase 1 registers only the PDF pass-through, so the PDF path is +a no-op conversion β€” the office/image/text stages arrive in later phases +(docs/MULTI_FORMAT_PLAN.md Β§10). """ import threading @@ -13,6 +19,7 @@ import pytest from app.models.printing import JobStatus +from app.processors.base import ConversionError from app.services import jobs, pipeline TEST_PRINTER = "EPSON L3210 Series" @@ -29,19 +36,19 @@ def job_with_pdf(tmp_upload_dir): class TestStartJob: - def test_job_is_queued_while_the_print_thread_is_still_running( + def test_job_is_in_printing_state_while_the_print_thread_is_running( self, job_with_pdf, mock_print, wait_for_status ): - # The fake is so fast the thread could finish before this test even - # reads the store β€” so freeze it with a gate to observe "queued", - # the state the phone's 201 response reflects. + # The fake is frozen mid-print so the test can observe the transient + # "printing" state. The phone's 201 still says "queued" β€” this is + # what polling sees on the way to done. gate = threading.Event() mock_print.gate = gate pipeline.start_job("job-1", job_with_pdf) assert mock_print.called.wait(timeout=5) - assert jobs.get_job("job-1").status == JobStatus.QUEUED + assert jobs.get_job("job-1").status == JobStatus.PRINTING gate.set() wait_for_status("job-1", JobStatus.DONE) # completes once released @@ -79,6 +86,64 @@ def test_hands_the_right_file_to_the_printer( wait_for_status("job-1", JobStatus.DONE) + # The PDF processor is a pass-through: Sumatra receives the upload + # exactly as stored β€” same contract spike T4 proved on real paper. assert mock_print.pdf_path == job_with_pdf # printer_name=None means "let windows.py resolve PRINTER_NAME/default". assert mock_print.printer_name is None + + +class TestConversionStage: + """The conversion stage between upload and print (p10 groundwork).""" + + def test_conversion_failure_marks_failed_and_never_prints( + self, job_with_pdf, mock_print, monkeypatch, wait_for_status + ): + class FailingProcessor: + def process(self, src, out_dir): + raise ConversionError("LibreOffice crashed mid-conversion") + + monkeypatch.setattr(pipeline, "for_category", lambda category: FailingProcessor()) + + pipeline.start_job("job-1", job_with_pdf, category="office") + + job = wait_for_status("job-1", JobStatus.FAILED) + assert "crashed" in job.error + assert job_with_pdf.exists() # kept for diagnosis + assert mock_print.called.is_set() is False # nothing reached the printer + + def test_conversions_run_one_at_a_time( + self, tmp_upload_dir, mock_print, monkeypatch, wait_for_status, wait_until + ): + # The old-PC guard: with a ≀4 GB machine and a future heavyweight + # converter (LibreOffice), two jobs must never convert at once. + class SlowProcessor: + def __init__(self): + self.in_process = 0 + self.max_in_process = 0 + self.release = threading.Event() + + def process(self, src, out_dir): + self.in_process += 1 + self.max_in_process = max(self.max_in_process, self.in_process) + self.release.wait(timeout=5) + self.in_process -= 1 + return src + + slow = SlowProcessor() + monkeypatch.setattr(pipeline, "for_category", lambda category: slow) + + tmp_upload_dir.mkdir(parents=True, exist_ok=True) + for number in ("1", "2"): + path = tmp_upload_dir / f"job-{number}.pdf" + path.write_bytes(b"%PDF-1.4 test") + jobs.create_job(f"job-{number}", "file.pdf", 13, path, format="office") + pipeline.start_job(f"job-{number}", path, category="office") + + wait_until(lambda: slow.in_process >= 1, message="first conversion never started") + + slow.release.set() + wait_for_status("job-1", JobStatus.DONE) + wait_for_status("job-2", JobStatus.DONE) + + assert slow.max_in_process == 1 # the second never overlapped the first diff --git a/tests/unit/test_processors.py b/tests/unit/test_processors.py new file mode 100644 index 0000000..f80ad23 --- /dev/null +++ b/tests/unit/test_processors.py @@ -0,0 +1,39 @@ +"""Unit tests for the processor registry (app/processors). + +Phase 1 registers exactly one processor: the PDF pass-through. These tests +pin the registry contract the later phases plug into: + - for_category() returns a processor whose process() yields a PDF path; + - unregistered categories return None β€” the "not enabled yet" signal the + upload gate and the pipeline both rely on. +""" + +from app.processors import for_category, supported_categories + + +class TestRegistry: + def test_pdf_category_has_a_processor(self): + assert for_category("pdf") is not None + + def test_future_categories_are_not_registered_yet(self): + # Phase order from docs/MULTI_FORMAT_PLAN.md Β§10: images (p11), + # office (p12), text (p13). Each phase extends this expectation. + assert for_category("image") is None + assert for_category("office") is None + assert for_category("text") is None + + def test_unknown_category_returns_none(self): + assert for_category("holodeck") is None + + def test_supported_categories_are_pinned_for_phase_1(self): + assert supported_categories() == ("pdf",) + + +class TestPdfProcessor: + def test_process_returns_the_source_unchanged(self, tmp_path): + src = tmp_path / "doc.pdf" + src.write_bytes(b"%PDF-1.4 test") + + result = for_category("pdf").process(src, tmp_path) + + assert result == src # pass-through: same path, nothing new written + assert src.read_bytes() == b"%PDF-1.4 test" # source untouched diff --git a/tests/unit/test_uploads.py b/tests/unit/test_uploads.py index 18b6ff9..5828c7f 100644 --- a/tests/unit/test_uploads.py +++ b/tests/unit/test_uploads.py @@ -1,8 +1,11 @@ """Unit tests for upload handling (app/services/uploads.py). -validate_pdf() is the single most security-relevant pure-logic function in -the service (SOURCE_OF_TRUTH Section 8), so it gets the boundary treatment: -what's accepted, what's rejected, and exactly WHERE the line sits. +validate_upload() is the single most security-relevant pure-logic function +in the service (SOURCE_OF_TRUTH Section 8), so it gets the boundary +treatment: what's accepted, what's rejected, and exactly WHERE the line +sits. Phase 1 registers only the PDF processor, so the image/office/text +cases here prove files are DETECTED correctly and then refused until their +phase lands (docs/MULTI_FORMAT_PLAN.md Β§10). """ import pytest @@ -10,49 +13,89 @@ from app.services import uploads from app.services.uploads import ( UploadError, + delete_job_files, save_upload, sweep_stale_uploads, upload_path, - validate_pdf, + validate_upload, ) -class TestValidatePdf: +class TestValidateUpload: def test_accepts_a_real_pdf(self): - validate_pdf("report.pdf", b"%PDF-1.4 rest of the document") # no raise + assert validate_upload("report.pdf", b"%PDF-1.4 rest of the document") == "pdf" def test_empty_filename_skips_extension_check_but_still_checks_magic(self): # curl/some clients send no filename; the content check must still fire. - validate_pdf("", b"%PDF-1.4") # no raise + assert validate_upload("", b"%PDF-1.4") == "pdf" - def test_wrong_extension_rejected_with_415(self): + @pytest.mark.parametrize("name", ["REPORT.PDF", "Report.Pdf", "x.pDf"]) + def test_extension_check_is_case_insensitive(self, name): + assert validate_upload(name, b"%PDF-1.4") == "pdf" + + def test_unsupported_extension_rejected_with_415(self): with pytest.raises(UploadError) as exc_info: - validate_pdf("notes.txt", b"%PDF-1.4") + validate_upload("virus.exe", b"MZ\x90\x00") assert exc_info.value.status_code == 415 + assert "pdf" in str(exc_info.value).lower() # the message names what IS allowed - @pytest.mark.parametrize("name", ["REPORT.PDF", "Report.Pdf", "x.pDf"]) - def test_extension_check_is_case_insensitive(self, name): - validate_pdf(name, b"%PDF-1.4") # no raise + def test_recognizable_content_with_a_lying_extension_is_still_refused(self): + # Even PDF bytes don't smuggle a file in under an unknown extension: + # the allowlist stays explicit (plan Section 9) β€” the client must + # name the file correctly too. + with pytest.raises(UploadError, match="Unsupported file type"): + validate_upload("report.exe", b"%PDF-1.4 real pdf") def test_renamed_text_file_rejected_by_magic_bytes_with_415(self): # A .txt renamed to .pdf passes the extension check β€” the %PDF- magic - # bytes are what catch it (config.py's PDF_MAGIC). + # bytes are what catch it (detection.py, fed by config.PDF_MAGIC). with pytest.raises(UploadError, match="not a PDF") as exc_info: - validate_pdf("fake.pdf", b"just some text, definitely not a pdf") + validate_upload("fake.pdf", b"just some text, definitely not a pdf") + assert exc_info.value.status_code == 415 + + def test_empty_pdf_rejected_by_magic_bytes(self): + # An empty file carries no signature β€” same fate as a renamed one. + with pytest.raises(UploadError, match="not a PDF"): + validate_upload("empty.pdf", b"") + + def test_text_extension_with_binary_content_is_a_mismatch(self): + # The extension says "text", the bytes say "PDF" β€” extensions lie, + # and detection must say so instead of guessing. + with pytest.raises(UploadError, match="does not match"): + validate_upload("notes.txt", b"%PDF-1.4") + + def test_detected_but_unregistered_format_refused_until_its_phase(self): + # Images are detected correctly (detection knows JPEG), but no + # processor is registered yet β€” refused with the honest message. + with pytest.raises(UploadError, match="later phase") as exc_info: + validate_upload("photo.jpg", b"\xff\xd8\xff\xe0" + b"x" * 32) + assert exc_info.value.status_code == 415 + + def test_no_filename_and_unknown_content_is_unsupported(self): + # No extension to hint from AND no magic to prove anything with β€” + # the honest answer is "unsupported", not a guess. + with pytest.raises(UploadError, match="no extension"): + validate_upload("", b"random junk") + + def test_macro_office_formats_rejected_by_policy(self): + # Macro-enabled formats are refused before anything else looks at + # the content (plan Section 9) β€” policy, not a content check. + with pytest.raises(UploadError, match="[Mm]acro") as exc_info: + validate_upload("invoice.docm", b"PK\x03\x04 whatever") assert exc_info.value.status_code == 415 def test_too_large_rejected_with_413(self, monkeypatch): monkeypatch.setattr(uploads, "MAX_UPLOAD_MB", 1) five_bytes_over = b"%PDF-" + b"x" * (1024 * 1024) with pytest.raises(UploadError, match="limit is 1 MB") as exc_info: - validate_pdf("big.pdf", five_bytes_over) + validate_upload("big.pdf", five_bytes_over) assert exc_info.value.status_code == 413 def test_exactly_at_limit_passes(self, monkeypatch): # The check is strictly '>' β€” a file AT the limit is legitimate. monkeypatch.setattr(uploads, "MAX_UPLOAD_MB", 1) exactly_one_mb = b"%PDF-" + b"x" * (1024 * 1024 - 5) - validate_pdf("big.pdf", exactly_one_mb) # no raise + assert validate_upload("big.pdf", exactly_one_mb) == "pdf" class TestSaveUpload: @@ -61,6 +104,12 @@ def test_stores_bytes_under_job_id_name(self, tmp_upload_dir): assert path == tmp_upload_dir / f"{job_id}.pdf" assert path.read_bytes() == b"%PDF-hello" + def test_extension_is_stored_with_the_file(self, tmp_upload_dir): + # Non-PDF phases will store the real extension; the mechanism + # already works (and keeps a client's ".JPG" lowercase). + job_id, path = save_upload(b"\xff\xd8\xffjpg-bytes", ext=".jpg") + assert path == tmp_upload_dir / f"{job_id}.jpg" + def test_job_ids_are_unique(self, tmp_upload_dir): first_id, _ = save_upload(b"%PDF-a") second_id, _ = save_upload(b"%PDF-b") @@ -76,24 +125,45 @@ class TestUploadPath: def test_job_id_maps_to_pdf_file_in_upload_dir(self, tmp_upload_dir): assert upload_path("abc123") == tmp_upload_dir / "abc123.pdf" + def test_extension_parameter_maps_the_same_way(self, tmp_upload_dir): + assert upload_path("abc123", ".jpg") == tmp_upload_dir / "abc123.jpg" + + +class TestDeleteJobFiles: + def test_deletes_every_file_of_a_job_and_reports_count(self, tmp_upload_dir): + # A job can own several files (source upload + converted PDF once + # non-PDF formats land) β€” cleanup must take them all, only them. + tmp_upload_dir.mkdir(parents=True) + (tmp_upload_dir / "job-1.pdf").write_bytes(b"x") + (tmp_upload_dir / "job-1.jpg").write_bytes(b"x") + (tmp_upload_dir / "other.pdf").write_bytes(b"x") + + assert delete_job_files("job-1") == 2 + assert (tmp_upload_dir / "other.pdf").exists() + + def test_missing_job_is_a_clean_no_op(self, tmp_upload_dir): + tmp_upload_dir.mkdir(parents=True) + assert delete_job_files("ghost") == 0 + class TestSweepStaleUploads: - def test_removes_only_pdfs_and_reports_count(self, tmp_upload_dir): + def test_removes_every_stale_file_and_reports_count(self, tmp_upload_dir): + # uploads/ is service-managed (every name in it is server-generated), + # so ANY file left by a previous run is stale β€” including files of + # formats that didn't exist when the sweep was PDF-only. tmp_upload_dir.mkdir(parents=True) - for name in ("a.pdf", "b.pdf", "c.pdf"): + for name in ("a.pdf", "b.pdf", "c.jpg"): (tmp_upload_dir / name).write_bytes(b"%PDF-x") - (tmp_upload_dir / "keep.txt").write_bytes(b"not a pdf") removed = sweep_stale_uploads() assert removed == 3 - assert list(tmp_upload_dir.glob("*.pdf")) == [] - assert (tmp_upload_dir / "keep.txt").exists() + assert list(tmp_upload_dir.iterdir()) == [] def test_directory_named_like_a_pdf_never_crashes_the_sweep(self, tmp_upload_dir): - # A directory called "weird.pdf" matches the glob but can't be - # unlink()ed β€” the OSError guard must swallow it (Section 8: cleanup - # must never crash the service). + # A directory called "weird.pdf" must be skipped (is_file check), + # not crash the sweep (Section 8: cleanup must never crash the + # service) and not be deleted either. tmp_upload_dir.mkdir(parents=True) (tmp_upload_dir / "weird.pdf").mkdir() (tmp_upload_dir / "good.pdf").write_bytes(b"%PDF-x") From b928e11d62943368a110c25b1124af75bb0c3956 Mon Sep 17 00:00:00 2001 From: geb Date: Sat, 29 Aug 2026 00:46:35 +0800 Subject: [PATCH 03/16] p11: image printing - JPG/PNG/WebP via Pillow processor - app/processors/images.py: images become print-ready PDF pages on a white A4 canvas - EXIF orientation honored, transparency flattened to white, fitted+centered with 0.5in margins, wide photos get a landscape page, effective DPI capped at 300, multi-frame files capped at 10 pages - registry: image category registered (office/text still pending) - windows.py: optional -print-settings "paper=,fit" when PAPER_SIZE is set; default stays empty = the spike-T4-proven driver behavior - web page + API: accept list widened to .pdf/.jpg/.jpeg/.png/.webp - spike_t5_images.py: T5 hardware check uses the real processor (convert timings + print + paper checklist); optional --paper verifies the driver honors paper size before PAPER_SIZE is enabled - tests: 150 pass, coverage 96.4% (gate 90%) --- .env.example | 8 +- README.md | 14 +- app/api/print.py | 7 +- app/api/web.py | 19 ++- app/config.py | 9 +- app/printer/windows.py | 12 +- app/processors/__init__.py | 4 +- app/processors/images.py | 157 +++++++++++++++++++++ docs/MULTI_FORMAT_PLAN.md | 6 +- requirements.txt | 4 + spike_t5_images.py | 213 +++++++++++++++++++++++++++++ tests/api/test_print_api.py | 43 ++++++ tests/unit/test_images.py | 123 +++++++++++++++++ tests/unit/test_printer_windows.py | 31 +++++ tests/unit/test_processors.py | 26 ++-- tests/unit/test_uploads.py | 18 ++- 16 files changed, 659 insertions(+), 35 deletions(-) create mode 100644 app/processors/images.py create mode 100644 spike_t5_images.py create mode 100644 tests/unit/test_images.py diff --git a/.env.example b/.env.example index ea15c95..3ce6a32 100644 --- a/.env.example +++ b/.env.example @@ -23,9 +23,11 @@ SUMATRA_PATH= # each becomes active in the phase that needs it. # ------------------------------------------------------------------ -# Paper size for SumatraPDF's print settings (used from the image phase on). -# Examples: A4, letter, legal. -PAPER_SIZE=A4 +# 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/XLS/PPT and friends) need LibreOffice Headless in a # later phase. ENABLE_OFFICE=0 turns office formats off without uninstalling diff --git a/README.md b/README.md index e2f7aa6..31297f4 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,10 @@ | `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 | +| `spike_t5_images.py` | Image-printing spike (T5) β€” run once at the printer to verify photo output | | `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.txt` | Python packages: fastapi, uvicorn, pywin32, python-multipart, pillow | | `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) | @@ -84,9 +85,10 @@ 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: +3. Pick a PDF or an image (JPG/PNG/WebP) β†’ tap **Print** β†’ watch the status: `πŸ“¨ Queued… β†’ ⏳ status: queued… β†’ πŸ–¨οΈ Printed to EPSON L3210 Series!` -4. Paper comes out. Done. +4. Paper comes out. Done. Images are placed on a white A4 page, fitted and + centered; phone-photo rotation (EXIF) is handled automatically. Other endpoints (also browsable interactively at `http://:8000/docs`): @@ -94,7 +96,7 @@ Other endpoints (also browsable interactively at `http://:8000/docs`): |---|---| | `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 | +| `POST /print` | Upload a file (PDF, or JPG/PNG/WebP image) 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 | @@ -111,6 +113,7 @@ Copy `.env.example` β†’ `.env` and edit. All values are optional; defaults work. | `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) | +| `PAPER_SIZE` | *(empty)* | Paper size sent to the driver (`paper=,fit` via SumatraPDF, e.g. `A4`). Empty = the driver chooses β€” the spike-proven default. Images are laid out on A4 when empty | | `HOST`, `PORT` | `8000` | Informational β€” actually pass them on the uvicorn command line (Β§3) | --- @@ -125,6 +128,8 @@ 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. +For the multi-format work, `spike_t5_images.py` runs the same kind of hardware check for image printing (converts test images with the service's real processor, prints them, and gives you a paper checklist). Its results are the Phase 2 acceptance gate β€” see `docs/MULTI_FORMAT_PLAN.md` Β§14. + --- ## 7. Deploying to the Print-Server PC (final step) @@ -176,6 +181,7 @@ pytest # the suite + coverage report (fails below ## Where to Go Next +- **Multi-format roadmap & decisions** β†’ [docs/MULTI_FORMAT_PLAN.md](docs/MULTI_FORMAT_PLAN.md) (phases, format table, spike protocol) - **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 diff --git a/app/api/print.py b/app/api/print.py index adb56aa..17d85ce 100644 --- a/app/api/print.py +++ b/app/api/print.py @@ -10,9 +10,10 @@ 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. Phase 1 - registers only the PDF processor, so everything else is refused with - "support arrives in a later phase". + availability, size) and returns the detected category. A category + prints once its processor is registered (app/processors) β€” PDF and + images today; office/text arrive in later phases and are refused + with a "support arrives in a later phase" message until then. 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 diff --git a/app/api/web.py b/app/api/web.py index 527d72d..a05d378 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): @@ -53,9 +55,9 @@

πŸ–¨οΈ Printer Service

-

Pick a PDF and send it to the printer.

+

Pick a PDF or an image and send it to the printer.

- +
@@ -65,6 +67,10 @@ const btn = document.getElementById("printBtn"); const resultDiv = document.getElementById("result"); +// Client-side convenience only β€” the server re-checks everything +// (extension allowlist + magic bytes) and never trusts the browser. +const OK_TYPES = [".pdf", ".jpg", ".jpeg", ".png", ".webp"]; + function show(text, cls) { resultDiv.textContent = text; resultDiv.className = cls || ""; @@ -74,9 +80,10 @@ const fileInput = document.getElementById("file"); const file = fileInput.files[0]; - if (!file) { show("Pick a PDF first.", "err"); return; } - if (!file.name.toLowerCase().endsWith(".pdf")) { - show("That doesn't look like a PDF file.", "err"); + if (!file) { show("Pick a file first.", "err"); return; } + if (!OK_TYPES.some(ext => file.name.toLowerCase().endsWith(ext))) { + show("That file type isn't supported yet β€” PDF or JPG/PNG/WebP images.", + "err"); return; } diff --git a/app/config.py b/app/config.py index 9ee3171..987576a 100644 --- a/app/config.py +++ b/app/config.py @@ -61,9 +61,12 @@ def _get(name: str, default: str) -> str: # phase that needs it (images p11, office p12, print options v2). # ------------------------------------------------------------------ -# Paper size passed to SumatraPDF's -print-settings once non-PDF formats -# reach the printer (Phase 2). Examples: A4, letter, legal. -PAPER_SIZE = _get("PAPER_SIZE", "A4") +# Paper size sent to the driver via SumatraPDF's -print-settings +# ("paper=,fit"). Empty (default) = no print-settings flag at all β€” the +# driver chooses the paper, which is the exact behavior spike T4 proved on +# real paper. Opt in (e.g. A4) only after spike T5 confirmed this driver +# honors the flag. Images are also laid out on this size (A4 when empty). +PAPER_SIZE = _get("PAPER_SIZE", "") # Office conversion (Phase 3): LibreOffice Headless. ENABLE_OFFICE is the # kill switch for the old PC β€” 0 turns office formats off without diff --git a/app/printer/windows.py b/app/printer/windows.py index fb420ed..6eb21fa 100644 --- a/app/printer/windows.py +++ b/app/printer/windows.py @@ -29,7 +29,7 @@ import subprocess from pathlib import Path -from app.config import PRINTER_NAME, SUMATRA_PATH +from app.config import PAPER_SIZE, PRINTER_NAME, SUMATRA_PATH from app.models.printing import PrinterInfo logger = logging.getLogger(__name__) @@ -89,8 +89,16 @@ def submit_pdf(pdf_path: Path, printer_name: str | None = None) -> tuple[str, st sumatra = find_sumatra() if sumatra: logger.info("printing %s via SumatraPDF to %r", pdf_path.name, printer_name) + cmd = [sumatra, "-print-to", printer_name] + if PAPER_SIZE: + # Opt-in paper pinning (PAPER_SIZE in .env). Empty = no print + # settings at all β€” the driver chooses, which is the exact + # behavior spike T4 proved on real paper. "fit" scales content + # instead of clipping it when page and paper disagree. + cmd += ["-print-settings", f"paper={PAPER_SIZE},fit"] + cmd += ["-silent", str(pdf_path)] result = subprocess.run( - [sumatra, "-print-to", printer_name, "-silent", str(pdf_path)], + cmd, capture_output=True, timeout=180, ) diff --git a/app/processors/__init__.py b/app/processors/__init__.py index 80c967c..e18ae2e 100644 --- a/app/processors/__init__.py +++ b/app/processors/__init__.py @@ -3,10 +3,11 @@ Registering a processor here is the ONLY code change needed to enable a format category (its magic signatures already live in app/detection.py). A category without a registration is detected but refused at upload time β€” -the Phase 1 state for image/office/text (docs/MULTI_FORMAT_PLAN.md Β§10). +the Phase 2 state for office/text (docs/MULTI_FORMAT_PLAN.md Β§10). """ from app.processors.base import ConversionError, Processor +from app.processors.images import IMAGE_PROCESSOR from app.processors.pdf import PDF_PROCESSOR __all__ = [ @@ -17,6 +18,7 @@ ] _REGISTRY: dict[str, Processor] = { + "image": IMAGE_PROCESSOR, "pdf": PDF_PROCESSOR, } diff --git a/app/processors/images.py b/app/processors/images.py new file mode 100644 index 0000000..283e2a0 --- /dev/null +++ b/app/processors/images.py @@ -0,0 +1,157 @@ +"""Image processor (Phase 2) β€” turns images into print-ready PDF pages. + +Strategy (docs/MULTI_FORMAT_PLAN.md Β§6): Pillow composites the picture onto +a white page canvas and saves the canvas as the PDF β€” one code path for +every input quirk: + + load β†’ EXIF-rotate β†’ flatten transparency onto white β†’ + fit + center on the page (wide photos rotate the PAGE, not the pixels) + β†’ cap the effective DPI β†’ save (multi-frame files become multi-page PDFs) + +Why a canvas instead of Pillow's bare img.save(..., "PDF"): the canvas +pins the PAGE size (A4 by default) no matter the photo's pixel size, +centers the picture with real margins, and caps the effective DPI β€” a +12 MP phone photo must not become a 100 MB PDF, and a 200 px thumbnail +must not be blown up into a full page of blur. + +Quality decisions recorded in MULTI_FORMAT_PLAN.md Β§6 (images row + Β§7): +EXIF orientation is honored, transparency prints white (unflattened alpha +prints black on paper), a wide photo gets a landscape page instead of +rotated pixels (rotation would fight EXIF and resample the image twice). +""" + +import logging +from pathlib import Path + +from PIL import Image, ImageOps, ImageSequence + +from app.config import PAPER_SIZE +from app.processors.base import ConversionError + +logger = logging.getLogger(__name__) + +# Page sizes in points (1 pt = 1/72"). Unknown/empty PAPER_SIZE falls back +# to A4. Add entries (e.g. long bond 8.5x13) as later phases need them. +PAGE_SIZES_PT = { + "a3": (842, 1191), + "a4": (595, 842), + "a5": (420, 595), + "letter": (612, 792), + "legal": (612, 1008), +} +DEFAULT_PAGE = "a4" + +MARGIN_PT = 36 # 0.5" β€” this printer cannot print borderless anyway +MAX_DPI = 300 # above ~300 effective DPI, extra pixels are invisible on paper +SOURCE_DPI = 96 # small images are assumed ~96 DPI (the web/phone norm)... +MAX_UPSCALE = MAX_DPI / SOURCE_DPI # ...so upscaling stops at 300 effective DPI +MAX_FRAMES = 10 # an animated WebP/scan-batch TIFF is not a 50-page print job + + +def page_size_pt() -> tuple[int, int]: + """The page images are laid out on: PAPER_SIZE when it names a known + size, A4 otherwise (images always need a concrete page to sit on).""" + return PAGE_SIZES_PT.get(PAPER_SIZE.strip().lower(), PAGE_SIZES_PT[DEFAULT_PAGE]) + + +def layout( + img_w: int, img_h: int, page_pt: tuple[int, int] +) -> tuple[tuple[int, int], tuple[int, int, int, int]]: + """Fit an image on a page; return ((canvas_w, canvas_h), (x, y, w, h)). + + Pure geometry β€” all values in CANVAS pixels, and the canvas renders at + MAX_DPI (so a page of `page_pt` points becomes page_pt * MAX_DPI/72 + pixels). Rules: + + - a wide image on a portrait page rotates the PAGE, not the pixels; + - the image never leaves the printable area (page minus margins); + - upscaling is capped at MAX_UPSCALE: a small image prints near its + natural size, centered, instead of stretched full-page blurry. + """ + page_w, page_h = page_pt + if img_w > img_h and page_w < page_h: + page_w, page_h = page_h, page_w + + points_to_px = MAX_DPI / 72 + canvas_w, canvas_h = round(page_w * points_to_px), round(page_h * points_to_px) + margin_px = round(MARGIN_PT * points_to_px) + avail_w, avail_h = canvas_w - 2 * margin_px, canvas_h - 2 * margin_px + + scale = min(avail_w / img_w, avail_h / img_h, MAX_UPSCALE) + box_w, box_h = max(1, round(img_w * scale)), max(1, round(img_h * scale)) + x, y = (canvas_w - box_w) // 2, (canvas_h - box_h) // 2 + return (canvas_w, canvas_h), (x, y, box_w, box_h) + + +def _printable(img: Image.Image) -> Image.Image: + """EXIF-rotate, then flatten anything with transparency onto white. + + Returns a plain RGB image β€” the only mode worth encoding into a PDF + page for this printer. + """ + img = ImageOps.exif_transpose(img) + if img.mode != "RGB": + if "A" in img.getbands() or img.mode == "P": + rgba = img.convert("RGBA") + flat = Image.new("RGB", rgba.size, "white") + flat.paste(rgba, mask=rgba.getchannel("A")) + return flat + return img.convert("RGB") + return img + + +def _page_canvas(img: Image.Image) -> Image.Image: + """One print-ready page: the image fitted and centered on white.""" + (canvas_w, canvas_h), (x, y, box_w, box_h) = layout( + img.width, img.height, page_size_pt() + ) + canvas = Image.new("RGB", (canvas_w, canvas_h), "white") + canvas.paste(img.resize((box_w, box_h), Image.Resampling.LANCZOS), (x, y)) + return canvas + + +class ImageProcessor: + def process(self, src: Path, out_dir: Path) -> Path: + """Convert an image file into the print-ready PDF the engine gets.""" + pdf_path = out_dir / f"{src.stem}.pdf" + try: + frames: list[Image.Image] = [] + with Image.open(src) as img: + for frame in ImageSequence.Iterator(img): + frames.append(_page_canvas(_printable(frame))) + if len(frames) > MAX_FRAMES: + raise ConversionError( + f"The image has more than {MAX_FRAMES} frames; the " + f"service prints at most {MAX_FRAMES} pages from one " + "image file. Split it or export single pages." + ) + if not frames: + raise ConversionError("The image contains no frames to print.") + except ConversionError: + raise + except Exception as exc: + raise ConversionError( + f"Could not read the image β€” it looks corrupt or is an " + f"unsupported image format ({exc})" + ) from exc + + try: + first, *rest = frames + first.save( + pdf_path, + "PDF", + resolution=MAX_DPI, + save_all=bool(rest), + append_images=rest, + ) + except OSError as exc: + raise ConversionError(f"Could not write the print file: {exc}") from exc + + logger.info( + "converted %s -> %s (%d page(s))", src.name, pdf_path.name, len(frames) + ) + return pdf_path + + +# Stateless β†’ one shared instance for every job. +IMAGE_PROCESSOR = ImageProcessor() diff --git a/docs/MULTI_FORMAT_PLAN.md b/docs/MULTI_FORMAT_PLAN.md index dfb0e62..6242250 100644 --- a/docs/MULTI_FORMAT_PLAN.md +++ b/docs/MULTI_FORMAT_PLAN.md @@ -197,8 +197,10 @@ AV scanning β€” βšͺ v2+ options. detection + Processor layer + generalized uploads + conversion lock + `converting`/`printing` states; all tests stay green. - **Phase 2 (p11) β€” images:** Pillow processor; web page accept/copy; - `PAPER_SIZE` wiring; cancel cleanup must delete `.` too - (`uploads.delete_job_files`). + `PAPER_SIZE` wiring (default empty = driver chooses, per the de-risking + decision); cancel cleanup must delete `.` too + (`uploads.delete_job_files`). Code landed in p11 β€” physical check pending + T5 (`spike_t5_images.py`), which is this phase's acceptance gate. - **Phase 3 (p12) β€” office:** install LibreOffice (run T6 first); `office.py` adapter (timeout, taskkill, profile isolation, `ENABLE_OFFICE` kill switch); friendly error mapping; font-pack docs; diff --git a/requirements.txt b/requirements.txt index d6d4685..0c6205f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,7 @@ python-multipart>=0.0.9 # Windows printing API (README Section 4) β€” Windows only pywin32>=306; sys_platform == "win32" + +# Image processing (Phase 2, multi-format plan): converts JPG/PNG/WebP into +# print-ready PDF pages. 10.3+ has the image-parsing security fixes. +pillow>=10.3 diff --git a/spike_t5_images.py b/spike_t5_images.py new file mode 100644 index 0000000..0afb040 --- /dev/null +++ b/spike_t5_images.py @@ -0,0 +1,213 @@ +""" +spike_t5_images.py β€” Image Printing Spike (docs/MULTI_FORMAT_PLAN.md Β§14, T5) + +Run this ON the print-server PC, from the project root: + + .venv\\Scripts\\pip install pillow + .venv\\Scripts\\python spike_t5_images.py [--paper A4] + +Uses the service's REAL image processor (app/processors/images.py) to +convert generated test images into PDFs, then prints them via SumatraPDF +exactly like the service does, timing each step: + + 1. JPEG β€” photo-like gradient (portrait page) + 2. PNG β€” with transparency (corners must print WHITE, not black) + 3. WebP β€” the modern web format + 4. JPEG β€” with EXIF rotation (must come out upright, matching #1) + +With --paper A4|letter|legal|a5|a3, one extra copy of image 1 is printed +with -print-settings "paper=,fit" to check the Epson driver honors the +paper size (plan assumption #3) BEFORE setting PAPER_SIZE in .env. + +PASS criteria β€” judge the PAPER (the script cannot see it): + [ ] every page upright, nothing clipped, even white margins + [ ] image 2's corners white, not black + [ ] image 4 upright (EXIF respected β€” same orientation as image 1) + [ ] --paper copy actually matches the requested paper size +Record the summary in SOURCE_OF_TRUTH Section 5, like the T4 entry. +""" + +import argparse +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +LINE = "=" * 64 + + +def banner(text: str) -> None: + print("\n" + LINE) + print(text) + print(LINE) + + +def find_printer() -> str: + """Prefer the L3210 by name, fall back to the Windows default.""" + import win32print + + flags = win32print.PRINTER_ENUM_LOCAL | win32print.PRINTER_ENUM_CONNECTIONS + names = sorted(p[2] for p in win32print.EnumPrinters(flags)) + if not names: + raise RuntimeError("No printers found β€” is the Epson installed on this PC?") + for name in names: + if "L3210" in name: + return name + return names[0] + + +def make_test_images(folder: Path) -> list[tuple[str, Path]]: + """Generate the four spike images (no binary fixtures in the repo).""" + from PIL import Image, ImageDraw + + # A smooth color gradient, built small and resized (fast, looks like a + # photo's tonal range on paper). + small = Image.new("RGB", (12, 16)) + for y in range(16): + for x in range(12): + small.putpixel((x, y), (x * 255 // 11, y * 255 // 15, 128)) + gradient = small.resize((1200, 1600), Image.Resampling.LANCZOS) + + out: list[tuple[str, Path]] = [] + + path = folder / "t5_1_gradient.jpg" + gradient.save(path, quality=90) + out.append(("1 JPEG gradient (portrait)", path)) + + # Transparent background + opaque circle: the corners must print WHITE. + rgba = Image.new("RGBA", (800, 800), (255, 0, 0, 0)) + draw = ImageDraw.Draw(rgba) + draw.ellipse((100, 100, 700, 700), fill=(0, 0, 255, 255)) + path = folder / "t5_2_alpha.png" + rgba.save(path) + out.append(("2 PNG with transparency", path)) + + path = folder / "t5_3_modern.webp" + gradient.save(path, quality=90) + out.append(("3 WebP", path)) + + # Same gradient WITH an EXIF orientation tag: must print like image 1, + # not rotated 90Β°. + exif = Image.Exif() + exif[274] = 6 # orientation: rotate 90Β° to display upright + path = folder / "t5_4_exif.jpg" + gradient.save(path, quality=90, exif=exif) + out.append(("4 JPEG with EXIF rotation", path)) + + return out + + +def print_pdf( + sumatra: str, pdf_path: Path, printer_name: str, settings: str | None = None +) -> None: + """The service's exact print invocation (see app/printer/windows.py).""" + cmd = [sumatra, "-print-to", printer_name] + if settings: + cmd += ["-print-settings", settings] + cmd += ["-silent", str(pdf_path)] + result = subprocess.run(cmd, capture_output=True, timeout=180) + if result.returncode != 0: + raise RuntimeError( + f"SumatraPDF exited with code {result.returncode}: " + f"{result.stderr.decode(errors='replace').strip()}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--paper", + choices=["a4", "letter", "legal", "a5", "a3"], + help="also print one copy with -print-settings paper=,fit", + ) + args = parser.parse_args() + + banner("T5 IMAGE SPIKE β€” run this ON the PC the printer is plugged into") + + try: + from app.printer.windows import find_sumatra + from app.processors.images import ImageProcessor + except ImportError as exc: + print(f"Cannot import the app ({exc}). Run from the project root:") + print(" .venv\\Scripts\\python spike_t5_images.py") + return 1 + + try: + import win32print # noqa: F401 (pywin32 presence check, like T1) + except ImportError: + print("pywin32 is not installed here: pip install pywin32") + return 1 + + printer_name = find_printer() + sumatra = find_sumatra() + if not sumatra: + print("SumatraPDF not found β€” install it or set SUMATRA_PATH in .env") + return 1 + + print(f"\nPrinter: {printer_name}") + print(f"Sumatra: {sumatra}") + print("\n>>> Keep paper loaded and watch the physical printer.") + input("Press Enter when ready...") + + processor = ImageProcessor() + temp_dir = Path(tempfile.mkdtemp(prefix="spike_t5_")) + results: list[tuple[str, str, str]] = [] + try: + for name, image_path in make_test_images(temp_dir): + try: + started = time.perf_counter() + pdf_path = processor.process(image_path, temp_dir) + seconds = time.perf_counter() - started + print_pdf(sumatra, pdf_path, printer_name) + results.append( + ( + f"T5 {name}", + "PASS", + f"converted in {seconds:.2f}s, print accepted β€” CHECK PAPER", + ) + ) + except Exception as exc: + results.append((f"T5 {name}", "FAIL", str(exc))) + + if args.paper: + try: + pdf_path = processor.process(temp_dir / "t5_1_gradient.jpg", temp_dir) + print_pdf(sumatra, pdf_path, printer_name, f"paper={args.paper},fit") + results.append( + ( + f"T5 paper={args.paper} via -print-settings", + "PASS", + "print accepted β€” verify the paper size matches on paper", + ) + ) + except Exception as exc: + results.append( + (f"T5 paper={args.paper} via -print-settings", "FAIL", str(exc)) + ) + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + banner("SUMMARY") + for name, status, detail in results: + print(f"[{status:4}] {name}: {detail}") + + print( + "\nNow judge the paper:\n" + " [ ] all pages upright, nothing clipped, even white margins\n" + " [ ] image 2's corners WHITE (black = alpha flattening bug)\n" + " [ ] image 4 upright, same orientation as image 1 (EXIF works)\n" + + ( + f" [ ] --paper {args.paper} copy really is {args.paper} size\n" + if args.paper + else "" + ) + + "\nRecord the results in SOURCE_OF_TRUTH Section 5 (like the T4 entry) β€”\n" + "they are the Phase 2 acceptance gate (docs/MULTI_FORMAT_PLAN.md Β§14)." + ) + return 0 if all(r[1] == "PASS" for r in results) else 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/api/test_print_api.py b/tests/api/test_print_api.py index 746a06d..0cec5f5 100644 --- a/tests/api/test_print_api.py +++ b/tests/api/test_print_api.py @@ -7,6 +7,9 @@ Event it signals. """ +from io import BytesIO + +from PIL import Image ONE_MB = 1024 * 1024 @@ -16,6 +19,13 @@ def post_pdf(client, data, name="report.pdf", **kwargs): return client.post("/print", files=files, **kwargs) +def make_png_bytes(): + """A tiny real PNG, generated by Pillow (no binary fixtures in the repo).""" + buffer = BytesIO() + Image.new("RGB", (8, 8), (200, 30, 30)).save(buffer, "PNG") + return buffer.getvalue() + + class TestPrintHappyPath: def test_upload_returns_201_with_queued_job(self, client, pdf_bytes, mock_print): response = post_pdf(client, pdf_bytes) @@ -41,6 +51,30 @@ def test_job_reaches_done_and_temp_file_is_deleted( message="printed job's temp file should be deleted", ) + def test_png_upload_prints_via_the_image_processor( + self, client, mock_print, tmp_upload_dir, wait_for_status, wait_until + ): + response = client.post( + "/print", files={"file": ("shot.png", make_png_bytes(), "image/png")} + ) + + assert response.status_code == 201 + job_id = response.json()["job_id"] + + job = wait_for_status(job_id, "done") + assert job.format == "image" + + # The engine only ever sees a PDF (the converted page), never raw + # image bytes β€” and it is named .pdf so cleanup finds it. + assert mock_print.pdf_path.suffix == ".pdf" + assert mock_print.pdf_path.stem == job_id + + # Both the source upload and the converted page are cleaned up. + wait_until( + lambda: not list(tmp_upload_dir.glob(f"{job_id}.*")), + message="image job's temp files should be deleted", + ) + class TestPrintRejections: """Section 13 test #8 β€” bad files must be refused before printing.""" @@ -66,6 +100,15 @@ def test_rejected_files_never_reach_the_store(self, client, mock_print, tmp_uplo assert mock_print.called.is_set() is False # no print attempted assert list(tmp_upload_dir.glob("*.pdf")) == [] # nothing stored + def test_unsupported_image_type_rejected_with_415(self, client, mock_print): + # HEIC (iPhone photos) is a Phase-2 rejection: detected as unknown + # extension, refused with the "what IS supported" message. + response = client.post( + "/print", files={"file": ("photo.heic", b"whatever", "image/heic")} + ) + assert response.status_code == 415 + assert "pdf" in response.json()["detail"].lower() + class TestPrintErrors: def test_disk_failure_returns_500(self, client, pdf_bytes, monkeypatch, mock_print): diff --git a/tests/unit/test_images.py b/tests/unit/test_images.py new file mode 100644 index 0000000..2a64f71 --- /dev/null +++ b/tests/unit/test_images.py @@ -0,0 +1,123 @@ +"""Unit tests for the image processor (app/processors/images.py). + +The processor composites each frame onto a white page canvas and saves the +canvas as the PDF page. The geometry is pure math (layout()), so the +fit/center/orientation rules are asserted exactly; the PDF bytes themselves +are only checked structurally (%PDF- magic, source untouched) β€” reading +pages back would need another PDF library, and the plan says avoid +unnecessary dependencies. Paper-level quality (transparency white, EXIF +upright, nothing clipped) is spike T5's job on real hardware. +""" + +import pytest +from PIL import Image + +from app.processors import images +from app.processors.base import ConversionError +from app.processors.images import MAX_UPSCALE, ImageProcessor, layout, page_size_pt + +A4_PT = (595, 842) + + +class TestLayout: + def test_portrait_photo_fits_the_printable_area_centered(self): + canvas = (round(595 * 300 / 72), round(842 * 300 / 72)) # A4 at 300 DPI + (canvas_w, canvas_h), (x, y, box_w, box_h) = layout(3000, 4000, A4_PT) + + assert (canvas_w, canvas_h) == canvas + margin = round(36 * 300 / 72) # 150 px + assert box_w <= canvas_w - 2 * margin # inside the margins + assert box_h <= canvas_h - 2 * margin + assert abs((canvas_w - box_w) - 2 * x) <= 1 # centered (Β±1 px rounding) + assert abs((canvas_h - box_h) - 2 * y) <= 1 + + def test_wide_photo_rotates_the_page_not_the_pixels(self): + # A landscape photo gets a landscape canvas β€” rotating the pixels + # would fight EXIF orientation and resample the image a second time. + (canvas_w, canvas_h), _ = layout(4000, 3000, A4_PT) + assert canvas_w > canvas_h + + def test_small_images_upscale_is_capped(self): + # A 200 px thumbnail must not be blown up to full-page blur: the + # upscale stops at MAX_UPSCALE (= 300 effective DPI from 96). + _, (_, _, box_w, _) = layout(200, 100, A4_PT) + assert box_w == round(200 * MAX_UPSCALE) + + +class TestPageSize: + def test_empty_and_unknown_config_fall_back_to_a4(self, monkeypatch): + for value in ("", "bogus"): + monkeypatch.setattr(images, "PAPER_SIZE", value) + assert page_size_pt() == (595, 842) + + def test_known_names_are_case_insensitive(self, monkeypatch): + monkeypatch.setattr(images, "PAPER_SIZE", "Letter") + assert page_size_pt() == (612, 792) + + +class TestPrintable: + def test_alpha_is_flattened_onto_white_not_black(self): + # Fully transparent red pixels: the printer has no "transparent + # paper", so unflattened alpha would print as black. + rgba = Image.new("RGBA", (4, 4), (255, 0, 0, 0)) + + flat = images._printable(rgba) + + assert flat.mode == "RGB" + assert flat.getpixel((0, 0)) == (255, 255, 255) + + def test_exif_orientation_is_applied(self, tmp_path): + wide = Image.new("RGB", (20, 10), "red") + exif = Image.Exif() + exif[274] = 6 # orientation tag: display rotated 90Β° + path = tmp_path / "phone_photo.jpg" + wide.save(path, exif=exif) + + with Image.open(path) as img: + assert images._printable(img).size == (10, 20) # dimensions swapped + + def test_plain_rgb_needs_no_flattening(self): + assert images._printable(Image.new("RGB", (3, 3), "blue")).mode == "RGB" + + +class TestProcess: + def test_jpeg_becomes_a_pdf_next_to_the_source(self, tmp_path): + src = tmp_path / "job-1.jpg" + Image.new("RGB", (40, 30), "green").save(src) + + pdf = ImageProcessor().process(src, tmp_path) + + assert pdf == tmp_path / "job-1.pdf" # .pdf, per the pipeline + assert pdf.read_bytes().startswith(b"%PDF-") + assert src.read_bytes().startswith(b"\xff\xd8") # source untouched + + def test_corrupt_image_raises_a_human_error(self, tmp_path): + src = tmp_path / "job-2.png" + src.write_bytes(b"definitely not image data") + + with pytest.raises(ConversionError, match="corrupt"): + ImageProcessor().process(src, tmp_path) + + def test_multipage_tiff_becomes_a_multipage_pdf(self, tmp_path): + frames = [Image.new("RGB", (30, 20), color) for color in ("red", "green", "blue")] + src = tmp_path / "job-3.tif" + frames[0].save(src, save_all=True, append_images=frames[1:]) + + pdf = ImageProcessor().process(src, tmp_path) + + assert pdf.read_bytes().startswith(b"%PDF-") + + def test_too_many_frames_are_refused_before_memory_blows_up(self, tmp_path): + # An animated GIF with more frames than MAX_FRAMES must fail fast β€” + # the guard fires DURING frame collection, not after rendering all + # of them into canvases on a 4 GB PC. Frames differ per color + # because GIF writers optimize identical frames away. + frames = [ + Image.new("RGB", (2, 2), (number, 0, 0)) + for number in range(images.MAX_FRAMES + 1) + ] + src = tmp_path / "job-4.gif" + frames[0].save(src, save_all=True, append_images=frames[1:]) + + with pytest.raises(ConversionError, match="frames"): + ImageProcessor().process(src, tmp_path) diff --git a/tests/unit/test_printer_windows.py b/tests/unit/test_printer_windows.py index 5aa7655..27aadbb 100644 --- a/tests/unit/test_printer_windows.py +++ b/tests/unit/test_printer_windows.py @@ -91,6 +91,7 @@ def test_success_builds_the_documented_command_line( ): pdf = tmp_path / "doc.pdf" pdf.write_bytes(b"%PDF-") + monkeypatch.setattr(windows, "PAPER_SIZE", "") # hermetic: no print settings monkeypatch.setattr(windows, "find_sumatra", lambda: "C:/SumatraPDF.exe") method, printer = windows.submit_pdf(pdf) @@ -139,6 +140,36 @@ def failing_run(cmd, **kwargs): windows.submit_pdf(tmp_path / "doc.pdf") +class TestPrintSettings: + """PAPER_SIZE is opt-in (docs/MULTI_FORMAT_PLAN.md Β§13 assumption #3): + empty = no print-settings flag at all, the driver chooses the paper β€” + the exact command spike T4 proved on real paper.""" + + def test_set_paper_size_adds_the_print_settings_flag( + self, fake_win32print, tmp_path, monkeypatch, recorded_run + ): + pdf = tmp_path / "doc.pdf" + pdf.write_bytes(b"%PDF-") + monkeypatch.setattr(windows, "PAPER_SIZE", "A4") + monkeypatch.setattr(windows, "find_sumatra", lambda: "C:/SumatraPDF.exe") + + windows.submit_pdf(pdf) + + cmd = recorded_run["cmd"] + assert cmd[3:5] == ["-print-settings", "paper=A4,fit"] + assert cmd[5:] == ["-silent", str(pdf)] # file still last, -silent still present + + def test_empty_paper_size_sends_no_print_settings( + self, fake_win32print, tmp_path, monkeypatch, recorded_run + ): + monkeypatch.setattr(windows, "PAPER_SIZE", "") + monkeypatch.setattr(windows, "find_sumatra", lambda: "C:/SumatraPDF.exe") + + windows.submit_pdf(tmp_path / "doc.pdf") + + assert "-print-settings" not in recorded_run["cmd"] + + # --------------------------------------------------------------------------- # submit_pdf β€” the print-verb fallback (only when SumatraPDF is absent) # --------------------------------------------------------------------------- diff --git a/tests/unit/test_processors.py b/tests/unit/test_processors.py index f80ad23..346b033 100644 --- a/tests/unit/test_processors.py +++ b/tests/unit/test_processors.py @@ -1,31 +1,34 @@ """Unit tests for the processor registry (app/processors). -Phase 1 registers exactly one processor: the PDF pass-through. These tests -pin the registry contract the later phases plug into: +These tests pin the registry contract the phases plug into: - for_category() returns a processor whose process() yields a PDF path; - unregistered categories return None β€” the "not enabled yet" signal the upload gate and the pipeline both rely on. +Phase 1 registered pdf, Phase 2 added image; office/text are still pending +(docs/MULTI_FORMAT_PLAN.md Β§10) and each later phase extends these +expectations. """ + from app.processors import for_category, supported_categories class TestRegistry: - def test_pdf_category_has_a_processor(self): + def test_registered_categories_have_processors(self): assert for_category("pdf") is not None + assert for_category("image") is not None def test_future_categories_are_not_registered_yet(self): - # Phase order from docs/MULTI_FORMAT_PLAN.md Β§10: images (p11), - # office (p12), text (p13). Each phase extends this expectation. - assert for_category("image") is None + # Phase order from docs/MULTI_FORMAT_PLAN.md Β§10: office (p12), + # text (p13). assert for_category("office") is None assert for_category("text") is None def test_unknown_category_returns_none(self): assert for_category("holodeck") is None - def test_supported_categories_are_pinned_for_phase_1(self): - assert supported_categories() == ("pdf",) + def test_supported_categories_are_pinned_after_phase_2(self): + assert supported_categories() == ("image", "pdf") class TestPdfProcessor: @@ -37,3 +40,10 @@ def test_process_returns_the_source_unchanged(self, tmp_path): assert result == src # pass-through: same path, nothing new written assert src.read_bytes() == b"%PDF-1.4 test" # source untouched + + +class TestImageProcessorRegistration: + def test_image_processor_is_the_registered_instance(self): + from app.processors.images import IMAGE_PROCESSOR + + assert for_category("image") is IMAGE_PROCESSOR diff --git a/tests/unit/test_uploads.py b/tests/unit/test_uploads.py index 5828c7f..f6b9546 100644 --- a/tests/unit/test_uploads.py +++ b/tests/unit/test_uploads.py @@ -64,11 +64,23 @@ def test_text_extension_with_binary_content_is_a_mismatch(self): with pytest.raises(UploadError, match="does not match"): validate_upload("notes.txt", b"%PDF-1.4") + def test_images_are_now_printable(self): + # Phase 2 registered the image processor: a real JPEG is accepted + # and its category flows to the job/pipeline. + assert validate_upload("photo.jpg", b"\xff\xd8\xff\xe0" + b"x" * 32) == "image" + def test_detected_but_unregistered_format_refused_until_its_phase(self): - # Images are detected correctly (detection knows JPEG), but no - # processor is registered yet β€” refused with the honest message. + # Office files are DETECTED correctly (detection sniffs the ZIP for + # word/ parts) but no processor is registered yet β€” refused with the + # honest message. + import io + import zipfile + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("word/document.xml", b"") with pytest.raises(UploadError, match="later phase") as exc_info: - validate_upload("photo.jpg", b"\xff\xd8\xff\xe0" + b"x" * 32) + validate_upload("invoice.docx", buffer.getvalue()) assert exc_info.value.status_code == 415 def test_no_filename_and_unknown_content_is_unsupported(self): From 19a7223c8eeaf03c9ae6fa444c48ad0f079c593e Mon Sep 17 00:00:00 2001 From: geb Date: Sat, 29 Aug 2026 01:21:07 +0800 Subject: [PATCH 04/16] p12: office printing - DOCX/XLSX/PPTX/ODF via LibreOffice headless - app/processors/office.py: soffice --headless --convert-to pdf with a FRESH throwaway profile per conversion (a crashed run can never poison the next; GUI clashes impossible), CONVERT_TIMEOUT_S bounds the run and the process TREE is killed on timeout, failures map to human messages - Processor protocol gains available(): upload gate now distinguishes "arrives in a later phase" (unregistered) from "unavailable on this server" (office kill switch / LibreOffice missing) with actionable text - registry: office category registered; web page + API accept DOC/XLS/PPT/ODF families; text stays pending (Phase 4) - spike_t6_office.py: generates a table-heavy DOCX, a print-area XLSX and a 16:9 PPTX (python-docx/openpyxl/python-pptx, spike-only), converts with the real processor, prints, paper checklist = acceptance gate - tests: 169 pass, coverage 96.3% (gate 90%) --- .env.example | 12 +- README.md | 19 ++- app/api/print.py | 6 +- app/api/web.py | 15 +- app/processors/__init__.py | 8 +- app/processors/base.py | 11 ++ app/processors/images.py | 4 + app/processors/office.py | 160 ++++++++++++++++++++ app/processors/pdf.py | 5 + app/services/uploads.py | 33 ++++- docs/MULTI_FORMAT_PLAN.md | 4 + spike_t6_office.py | 210 +++++++++++++++++++++++++++ tests/api/test_print_api.py | 45 ++++++ tests/unit/test_office.py | 266 ++++++++++++++++++++++++++++++++++ tests/unit/test_processors.py | 20 ++- tests/unit/test_uploads.py | 45 ++++-- 16 files changed, 821 insertions(+), 42 deletions(-) create mode 100644 app/processors/office.py create mode 100644 spike_t6_office.py create mode 100644 tests/unit/test_office.py diff --git a/.env.example b/.env.example index 3ce6a32..9f25a76 100644 --- a/.env.example +++ b/.env.example @@ -29,13 +29,15 @@ SUMATRA_PATH= # on A4 when this is empty. PAPER_SIZE= -# Office documents (DOC/XLS/PPT and friends) need LibreOffice Headless in a -# later phase. ENABLE_OFFICE=0 turns office formats off without uninstalling -# anything; they are also refused while LibreOffice is not installed. +# 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 = use the standard install location. +# Explicit path to soffice.exe. Empty = search the standard install +# locations (C:\Program Files\LibreOffice\program\soffice.exe). LO_PATH= -# Seconds a file conversion may run before the service kills it. +# Seconds an office conversion may run before the service kills LibreOffice. CONVERT_TIMEOUT_S=120 diff --git a/README.md b/README.md index 31297f4..e41fbf8 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ | `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 | | `spike_t5_images.py` | Image-printing spike (T5) β€” run once at the printer to verify photo output | +| `spike_t6_office.py` | Office-printing spike (T6) β€” run once after installing LibreOffice | | `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, pillow | @@ -37,6 +38,7 @@ | **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 | +| **LibreOffice** *(optional)* | Office documents (DOCX/XLSX/PPTX/ODF) are converted to PDF through it. Without it, office uploads are refused with a clear message β€” everything else keeps working | [libreoffice.org](https://www.libreoffice.org) or `winget install TheDocumentFoundation.LibreOffice`. Verify: `soffice --version` in a terminal (or just restart the service after installing) | | **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*) | ### The phone @@ -85,10 +87,13 @@ 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 or an image (JPG/PNG/WebP) β†’ tap **Print** β†’ watch the status: +3. Pick a PDF, image (JPG/PNG/WebP), or Office document (DOCX/XLSX/PPTX/ODF) + β†’ tap **Print** β†’ watch the status: `πŸ“¨ Queued… β†’ ⏳ status: queued… β†’ πŸ–¨οΈ Printed to EPSON L3210 Series!` 4. Paper comes out. Done. Images are placed on a white A4 page, fitted and - centered; phone-photo rotation (EXIF) is handled automatically. + centered; phone-photo rotation (EXIF) is handled automatically. Office + documents need LibreOffice on the server (Β§1); DOCX/XLSX/PPTX convert in + roughly 10–30 s β€” the page shows `converting` while that runs. Other endpoints (also browsable interactively at `http://:8000/docs`): @@ -96,7 +101,7 @@ Other endpoints (also browsable interactively at `http://:8000/docs`): |---|---| | `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 file (PDF, or JPG/PNG/WebP image) and print it | +| `POST /print` | Upload a file (PDF, image, or Office document) 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 | @@ -114,6 +119,9 @@ Copy `.env.example` β†’ `.env` and edit. All values are optional; defaults work. | `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) | | `PAPER_SIZE` | *(empty)* | Paper size sent to the driver (`paper=,fit` via SumatraPDF, e.g. `A4`). Empty = the driver chooses β€” the spike-proven default. Images are laid out on A4 when empty | +| `ENABLE_OFFICE` | `1` | Office-document printing (DOCX/XLSX/PPTX/ODF β†’ PDF via LibreOffice). `0` = office uploads refused with a clear message, everything else unaffected | +| `LO_PATH` | *(empty)* | Explicit path to `soffice.exe`. Empty = search standard install locations | +| `CONVERT_TIMEOUT_S` | `120` | Seconds an office conversion may run before LibreOffice is killed | | `HOST`, `PORT` | `8000` | Informational β€” actually pass them on the uvicorn command line (Β§3) | --- @@ -128,7 +136,7 @@ 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. -For the multi-format work, `spike_t5_images.py` runs the same kind of hardware check for image printing (converts test images with the service's real processor, prints them, and gives you a paper checklist). Its results are the Phase 2 acceptance gate β€” see `docs/MULTI_FORMAT_PLAN.md` Β§14. +For the multi-format work, `spike_t5_images.py` (images) and `spike_t6_office.py` (DOCX/XLSX/PPTX after installing LibreOffice) run the same kind of hardware check for the newer formats β€” each converts test files with the service's real processors, prints them, and gives you a paper checklist. Their results are the phases' acceptance gates β€” see `docs/MULTI_FORMAT_PLAN.md` Β§14. --- @@ -154,6 +162,9 @@ For the multi-format work, `spike_t5_images.py` runs the same kind of hardware c | 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 | +| Office upload refused: "LibreOffice is not installed / ENABLE_OFFICE=0" | Install LibreOffice (Β§1) or set `ENABLE_OFFICE=1` in `.env`, then restart the service | +| Office job `failed`: "did not finish within 120 s" | Big/complex document β€” raise `CONVERT_TIMEOUT_S` in `.env`, or export a PDF from the source app | +| Office output looks wrong (fonts/pagination) | Install common fonts on the server; for XLSX, set a print area in Excel before saving (Β§ docs/MULTI_FORMAT_PLAN.md Β§7) | | Service IP changed after reboot | Set the router's DHCP reservation (Β§7 step 7) | 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). diff --git a/app/api/print.py b/app/api/print.py index 17d85ce..352836c 100644 --- a/app/api/print.py +++ b/app/api/print.py @@ -11,9 +11,9 @@ 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 (app/processors) β€” PDF and - images today; office/text arrive in later phases and are refused - with a "support arrives in a later phase" message until then. + prints once its processor is registered AND available on this + machine (PDF and images 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 diff --git a/app/api/web.py b/app/api/web.py index a05d378..0162b5c 100644 --- a/app/api/web.py +++ b/app/api/web.py @@ -55,9 +55,10 @@

πŸ–¨οΈ Printer Service

-

Pick a PDF or an image and send it to the printer.

+

Pick a PDF, image, or Office document and send it to the printer.

- +
@@ -69,7 +70,11 @@ // Client-side convenience only β€” the server re-checks everything // (extension allowlist + magic bytes) and never trusts the browser. -const OK_TYPES = [".pdf", ".jpg", ".jpeg", ".png", ".webp"]; +const OK_TYPES = [ + ".pdf", ".jpg", ".jpeg", ".png", ".webp", + ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", + ".odt", ".ods", ".odp", +]; function show(text, cls) { resultDiv.textContent = text; @@ -82,8 +87,8 @@ if (!file) { show("Pick a file first.", "err"); return; } if (!OK_TYPES.some(ext => file.name.toLowerCase().endsWith(ext))) { - show("That file type isn't supported yet β€” PDF or JPG/PNG/WebP images.", - "err"); + show("That file type isn't supported yet β€” PDF, images, or Office " + + "documents (DOCX/XLSX/PPTX).", "err"); return; } diff --git a/app/processors/__init__.py b/app/processors/__init__.py index e18ae2e..82dc73d 100644 --- a/app/processors/__init__.py +++ b/app/processors/__init__.py @@ -2,12 +2,15 @@ Registering a processor here is the ONLY code change needed to enable a format category (its magic signatures already live in app/detection.py). -A category without a registration is detected but refused at upload time β€” -the Phase 2 state for office/text (docs/MULTI_FORMAT_PLAN.md Β§10). +A category without a registration is detected but refused at upload time; +a REGISTERED processor can still be per-machine unavailable (the office +kill switch, LibreOffice missing) β€” that's what Processor.available() is +for (docs/MULTI_FORMAT_PLAN.md Β§10). """ from app.processors.base import ConversionError, Processor from app.processors.images import IMAGE_PROCESSOR +from app.processors.office import OFFICE_PROCESSOR from app.processors.pdf import PDF_PROCESSOR __all__ = [ @@ -19,6 +22,7 @@ _REGISTRY: dict[str, Processor] = { "image": IMAGE_PROCESSOR, + "office": OFFICE_PROCESSOR, "pdf": PDF_PROCESSOR, } diff --git a/app/processors/base.py b/app/processors/base.py index 756b85f..8749697 100644 --- a/app/processors/base.py +++ b/app/processors/base.py @@ -33,6 +33,17 @@ class Processor(Protocol): with their own timeout + process-tree kill. """ + def available(self) -> bool: + """Whether this processor can run on THIS machine right now. + + A processor can be registered but still disabled (the office kill + switch) or missing its external tool (LibreOffice not installed). + The upload gate checks this so users get an actionable message β€” + "install LibreOffice / flip ENABLE_OFFICE" β€” instead of a job that + dies later with a subprocess error. + """ + ... + def process(self, src: Path, out_dir: Path) -> Path: """Convert `src` into a print-ready PDF and return that PDF's path. diff --git a/app/processors/images.py b/app/processors/images.py index 283e2a0..fef7696 100644 --- a/app/processors/images.py +++ b/app/processors/images.py @@ -111,6 +111,10 @@ def _page_canvas(img: Image.Image) -> Image.Image: class ImageProcessor: + def available(self) -> bool: + # Pillow is a hard dependency (requirements.txt) β€” always available. + return True + def process(self, src: Path, out_dir: Path) -> Path: """Convert an image file into the print-ready PDF the engine gets.""" pdf_path = out_dir / f"{src.stem}.pdf" diff --git a/app/processors/office.py b/app/processors/office.py new file mode 100644 index 0000000..7812e18 --- /dev/null +++ b/app/processors/office.py @@ -0,0 +1,160 @@ +"""Office processor (Phase 3) β€” DOCX/XLSX/PPTX (and legacy/ODF) via +LibreOffice Headless. + +LibreOffice converts each document to the service's one print format (a +PDF) in a subprocess; the conversion runs inside the pipeline's conversion +lock, so at most ONE LibreOffice instance exists at a time β€” the old-PC +guard (≀4 GB RAM, MULTI_FORMAT_PLAN.md Β§6 load profile). + +Invocation (security notes in MULTI_FORMAT_PLAN.md Β§9): + + soffice --headless --norestore --nolockcheck + -env:UserInstallation= + --convert-to pdf --outdir + +- headless: no GUI, no desktop needed; it does not execute document macros. +- a FRESH throwaway user profile per conversion: a crashed earlier run can + never poison the next one (stale locks), and someone running LibreOffice's + GUI on the default profile can never clash with us. Costs ~1s of profile + warmup per document β€” worth the robustness on a home server. +- CONVERT_TIMEOUT_S bounds the whole conversion; on timeout the whole + process TREE is killed (soffice spawns soffice.bin children). + +Quality expectations (MULTI_FORMAT_PLAN.md Β§7): layout fidelity is +LibreOffice's, so fonts installed on THIS server matter (missing fonts get +substituted and line breaks shift); an XLSX saved without a print area +paginates all columns. spike_t6_office.py verifies on real paper β€” it is +this phase's acceptance gate. +""" + +import logging +import os +import shutil +import subprocess +import tempfile +import time +from pathlib import Path + +from app.config import CONVERT_TIMEOUT_S, ENABLE_OFFICE, LO_PATH +from app.processors.base import ConversionError + +logger = logging.getLogger(__name__) + +SOFFICE_CANDIDATES = [ + r"C:\Program Files\LibreOffice\program\soffice.exe", + r"C:\Program Files (x86)\LibreOffice\program\soffice.exe", +] + + +def find_soffice() -> str | None: + """Locate soffice.exe. An explicitly configured LO_PATH is authoritative: + if it's set but missing, we report it missing rather than silently + falling back (misconfigurations should be loud) β€” the same rule as + SUMATRA_PATH.""" + if LO_PATH: + return LO_PATH if Path(LO_PATH).is_file() else None + found = shutil.which("soffice") or shutil.which("soffice.exe") + if found: + return found + for candidate in SOFFICE_CANDIDATES: + if Path(candidate).is_file(): + return candidate + return None + + +class OfficeProcessor: + """Converts office documents to PDF. Depends on an external LibreOffice + install β€” the service's only heavyweight dependency, gated behind + ENABLE_OFFICE so it can be switched off without uninstalling.""" + + def available(self) -> bool: + return ENABLE_OFFICE and find_soffice() is not None + + def process(self, src: Path, out_dir: Path) -> Path: + soffice = find_soffice() + if soffice is None: + # Only reachable if the machine changed between upload and + # conversion (e.g. ENABLE_OFFICE flipped mid-queue) β€” fail with + # the message the user needs, not a subprocess traceback. + raise ConversionError( + "LibreOffice is not available on this server, so this " + "document cannot be converted. Convert it to PDF first." + ) + + pdf_path = out_dir / f"{src.stem}.pdf" + try: + profile = tempfile.TemporaryDirectory( + prefix="lo-profile-", ignore_cleanup_errors=True + ) + except OSError as exc: + raise ConversionError(f"Could not create a temp profile dir: {exc}") from exc + + with profile: + cmd = [ + soffice, + "--headless", + "--norestore", + "--nolockcheck", + f"-env:UserInstallation={Path(profile.name).as_uri()}", + "--convert-to", + "pdf", + "--outdir", + str(out_dir), + str(src), + ] + started = time.perf_counter() + try: + process = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + except OSError as exc: + raise ConversionError(f"Could not start LibreOffice: {exc}") from exc + + try: + _stdout, stderr = process.communicate(timeout=CONVERT_TIMEOUT_S) + except subprocess.TimeoutExpired: + self._kill_tree(process) + raise ConversionError( + f"LibreOffice did not finish within {CONVERT_TIMEOUT_S}s β€” " + "the document may be too complex. Try exporting a PDF " + "from the app it was made in." + ) from None + + if process.returncode != 0: + raise ConversionError( + f"LibreOffice failed (exit {process.returncode}): " + f"{stderr.decode(errors='replace').strip()[:500]}" + ) + if not pdf_path.is_file(): + # soffice can exit 0 without producing output (e.g. an + # unreadable file it chose not to complain about). + raise ConversionError( + "LibreOffice reported success but produced no PDF β€” the " + "document may be corrupt or use an unsupported feature." + ) + + logger.info( + "converted %s -> %s in %.1fs (LibreOffice)", + src.name, + pdf_path.name, + time.perf_counter() - started, + ) + return pdf_path + + @staticmethod + def _kill_tree(process: subprocess.Popen) -> None: + """Kill soffice AND its children β€” it runs the real work in a + soffice.bin child, so killing the direct process is not enough.""" + if os.name == "nt": + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(process.pid)], + capture_output=True, + ) + else: + process.kill() + process.communicate() # reap so nothing is left hanging + + +# Stateless β†’ one shared instance for every job (conversions are serialized +# by the pipeline's conversion lock). +OFFICE_PROCESSOR = OfficeProcessor() diff --git a/app/processors/pdf.py b/app/processors/pdf.py index 4983740..ec9faea 100644 --- a/app/processors/pdf.py +++ b/app/processors/pdf.py @@ -10,6 +10,11 @@ class PdfProcessor: + def available(self) -> bool: + # PDFs need nothing but SumatraPDF, which the print engine already + # requires β€” always available. + return True + def process(self, src: Path, out_dir: Path) -> Path: # Source bytes were validated at upload time (magic + size); the # real "can Sumatra open it" check happens when Sumatra runs, and diff --git a/app/services/uploads.py b/app/services/uploads.py index 5ef77eb..3d8afed 100644 --- a/app/services/uploads.py +++ b/app/services/uploads.py @@ -36,7 +36,18 @@ category_for, magic_category, ) -from app.processors import for_category +from app.processors import for_category, supported_categories + +# For a REGISTERED-but-unavailable processor: the message must tell the +# phone user what to do (office is the case today β€” LibreOffice missing or +# the ENABLE_OFFICE kill switch). +UNAVAILABLE_MESSAGES = { + "office": ( + "Office printing is unavailable on this server β€” LibreOffice is not " + "installed, or ENABLE_OFFICE=0 in .env. Convert the document to PDF " + "first, or install LibreOffice to enable office formats." + ), +} class UploadError(Exception): @@ -134,12 +145,24 @@ def validate_upload(filename: str, data: bytes) -> str: }[category] raise UploadError(reason, status_code=415) - # 3. Availability β€” a category prints only once its processor is - # registered (app/processors). Phase 1: PDF only. - if for_category(category) is None: + # 3. Availability β€” two distinct gates, two distinct messages: + # (a) no processor registered yet β†’ "arrives in a later phase"; + # (b) registered but not runnable on THIS machine (office kill + # switch / LibreOffice missing) β†’ an actionable message. + processor = for_category(category) + if processor is None: + printable = ", ".join(supported_categories()) raise UploadError( f"'{ext or 'this format'}' files cannot be printed yet β€” support " - "arrives in a later phase. Currently supported: .pdf.", + f"arrives in a later phase. Currently printable: {printable}.", + status_code=415, + ) + if not processor.available(): + raise UploadError( + UNAVAILABLE_MESSAGES.get( + category, + f"'{ext or category}' printing is unavailable on this server.", + ), status_code=415, ) diff --git a/docs/MULTI_FORMAT_PLAN.md b/docs/MULTI_FORMAT_PLAN.md index 6242250..2e8cb06 100644 --- a/docs/MULTI_FORMAT_PLAN.md +++ b/docs/MULTI_FORMAT_PLAN.md @@ -205,6 +205,10 @@ AV scanning β€” βšͺ v2+ options. `office.py` adapter (timeout, taskkill, profile isolation, `ENABLE_OFFICE` kill switch); friendly error mapping; font-pack docs; verify a table-heavy DOCX and a print-area XLSX on real paper. + Code landed in p12 (fresh throwaway profile per conversion instead of a + shared one β€” crash-proof, ~1 s warmup cost). Physical check pending T6 + (`spike_t6_office.py`, needs `pip install python-docx openpyxl + python-pptx` spike-only) β€” this phase's acceptance gate. - **Phase 4 (p13) β€” text/CSV:** reportlab renderer β€” TXT = monospace text with wrap; CSV = bordered grid with row/col caps + "truncated" notice. - **Phase 5 (p14) β€” queue management:** cancel while queued/converting; diff --git a/spike_t6_office.py b/spike_t6_office.py new file mode 100644 index 0000000..76f075d --- /dev/null +++ b/spike_t6_office.py @@ -0,0 +1,210 @@ +""" +spike_t6_office.py β€” Office Printing Spike (docs/MULTI_FORMAT_PLAN.md Β§14, T6) + +Run this ON the print-server PC, from the project root, AFTER installing +LibreOffice (https://www.libreoffice.org, or: winget install TheDocumentFoundation.LibreOffice): + + .venv\\Scripts\\pip install python-docx openpyxl python-pptx + .venv\\Scripts\\python spike_t6_office.py + +Generates three REAL office documents, converts each with the service's +real OfficeProcessor (LibreOffice headless β€” the exact production path, +including its timeout and fresh-profile handling), prints the PDFs via +SumatraPDF, and times every conversion: + + 1. DOCX β€” a table-heavy document (a 12x4 bordered table + headings) + 2. XLSX β€” a spreadsheet with a defined PRINT AREA + landscape page setup + 3. PPTX β€” a 16:9 deck (2 slides, landscape) + +PASS criteria β€” judge the PAPER (the script cannot see it): + [ ] DOCX: table fits the page width, borders visible, no cut columns + [ ] XLSX: ONLY the print area prints, in landscape, on one page + [ ] PPTX: slides fill the page in landscape (16:9) +Also note each conversion time β€” if the machine takes > 30 s per document, +consider raising CONVERT_TIMEOUT_S in .env. +Record the summary in SOURCE_OF_TRUTH Section 5, like the T4/T5 entries. +These results are the Phase 3 acceptance gate. +""" + +import argparse +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +LINE = "=" * 64 + + +def banner(text: str) -> None: + print("\n" + LINE) + print(text) + print(LINE) + + +def find_printer() -> str: + """Prefer the L3210 by name, fall back to the Windows default.""" + import win32print + + flags = win32print.PRINTER_ENUM_LOCAL | win32print.PRINTER_ENUM_CONNECTIONS + names = sorted(p[2] for p in win32print.EnumPrinters(flags)) + if not names: + raise RuntimeError("No printers found β€” is the Epson installed on this PC?") + for name in names: + if "L3210" in name: + return name + return names[0] + + +def make_test_documents(folder: Path) -> list[tuple[str, Path]]: + """Generate the three spike documents with the python-* helper libs.""" + out: list[tuple[str, Path]] = [] + + # 1. DOCX β€” table-heavy (the documented DOCX quality check). + import docx + + doc = docx.Document() + doc.add_heading("T6 office spike β€” table layout check", 1) + doc.add_paragraph( + "If this table fits the page width with visible borders and no cut " + "columns, DOCX table conversion works on this machine." + ) + table = doc.add_table(rows=12, cols=4) + table.style = "Table Grid" + for row in range(12): + for col in range(4): + table.cell(row, col).text = f"r{row + 1}c{col + 1} β€” some cell content" + path = folder / "t6_1_table.docx" + doc.save(path) + out.append(("1 DOCX table-heavy", path)) + + # 2. XLSX β€” print area + landscape (the documented XLSX quality check: + # ONLY the print area may come out of the printer, in landscape). + import openpyxl + + workbook = openpyxl.Workbook() + sheet = workbook.active + sheet.title = "Spike" + for row in range(1, 21): + for col in range(1, 5): + sheet.cell(row=row, column=col, value=f"cell r{row}c{col}") + sheet.cell(row=1, column=6, value="OUTSIDE the print area β€” must NOT print") + sheet.print_area = "A1:D20" + sheet.page_setup.orientation = "landscape" + path = folder / "t6_2_printarea.xlsx" + workbook.save(path) + out.append(("2 XLSX with print area", path)) + + # 3. PPTX β€” 16:9 deck (slide size should drive a landscape PDF page). + from pptx import Presentation + from pptx.util import Inches + + deck = Presentation() + deck.slide_width = Inches(13.333) + deck.slide_height = Inches(7.5) + for number in (1, 2): + slide = deck.slides.add_slide(deck.slide_layouts[6]) # blank layout + box = slide.shapes.add_textbox(Inches(1), Inches(3), Inches(11), Inches(1.5)) + box.text_frame.text = f"T6 office spike β€” slide {number} of 2 (16:9)" + path = folder / "t6_3_deck.pptx" + deck.save(path) + out.append(("3 PPTX 16:9 deck", path)) + + return out + + +def print_pdf(sumatra: str, pdf_path: Path, printer_name: str) -> None: + """The service's exact print invocation (see app/printer/windows.py).""" + result = subprocess.run( + [sumatra, "-print-to", printer_name, "-silent", str(pdf_path)], + capture_output=True, + timeout=180, + ) + if result.returncode != 0: + raise RuntimeError( + f"SumatraPDF exited with code {result.returncode}: " + f"{result.stderr.decode(errors='replace').strip()}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.parse_args() + + banner("T6 OFFICE SPIKE β€” run this ON the PC the printer is plugged into") + + try: + from app.printer.windows import find_sumatra + from app.processors.office import OfficeProcessor, find_soffice + except ImportError as exc: + print(f"Cannot import the app ({exc}). Run from the project root:") + print(" .venv\\Scripts\\python spike_t6_office.py") + return 1 + + try: + import win32print # noqa: F401 (pywin32 presence check, like T1) + except ImportError: + print("pywin32 is not installed here: pip install pywin32") + return 1 + + processor = OfficeProcessor() + if not processor.available(): + print( + "Office conversion is NOT available on this machine:\n" + " - install LibreOffice (winget install TheDocumentFoundation.LibreOffice)\n" + " - and/or set ENABLE_OFFICE=1 / LO_PATH in .env\n" + f" find_soffice() -> {find_soffice()!r}, ENABLE_OFFICE from .env" + ) + return 1 + + printer_name = find_printer() + sumatra = find_sumatra() + if not sumatra: + print("SumatraPDF not found β€” install it or set SUMATRA_PATH in .env") + return 1 + + print(f"\nPrinter: {printer_name}") + print(f"Sumatra: {sumatra}") + print(f"LibreOffice: {find_soffice()}") + print("\n>>> Keep paper loaded and watch the physical printer.") + input("Press Enter when ready...") + + temp_dir = Path(tempfile.mkdtemp(prefix="spike_t6_")) + results: list[tuple[str, str, str]] = [] + try: + for name, doc_path in make_test_documents(temp_dir): + try: + started = time.perf_counter() + pdf_path = processor.process(doc_path, temp_dir) + seconds = time.perf_counter() - started + print_pdf(sumatra, pdf_path, printer_name) + results.append( + ( + f"T6 {name}", + "PASS", + f"converted in {seconds:.1f}s, print accepted β€” CHECK PAPER", + ) + ) + except Exception as exc: + results.append((f"T6 {name}", "FAIL", str(exc))) + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + banner("SUMMARY") + for name, status, detail in results: + print(f"[{status:4}] {name}: {detail}") + + print( + "\nNow judge the paper:\n" + " [ ] DOCX table fits the width, borders visible, no cut columns\n" + " [ ] XLSX: ONLY A1:D20 printed (no 'OUTSIDE' cell!), landscape\n" + " [ ] PPTX slides fill the page, landscape (16:9)\n" + "\nRecord the results in SOURCE_OF_TRUTH Section 5 (like the T4/T5\n" + "entries) β€” they are the Phase 3 acceptance gate." + ) + return 0 if all(r[1] == "PASS" for r in results) else 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/api/test_print_api.py b/tests/api/test_print_api.py index 0cec5f5..9468eff 100644 --- a/tests/api/test_print_api.py +++ b/tests/api/test_print_api.py @@ -7,6 +7,7 @@ Event it signals. """ +import zipfile from io import BytesIO from PIL import Image @@ -26,6 +27,14 @@ def make_png_bytes(): return buffer.getvalue() +def make_docx_bytes(): + """The smallest ZIP detection treats as a DOCX (a word/ part inside).""" + buffer = BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("word/document.xml", b"") + return buffer.getvalue() + + class TestPrintHappyPath: def test_upload_returns_201_with_queued_job(self, client, pdf_bytes, mock_print): response = post_pdf(client, pdf_bytes) @@ -75,6 +84,42 @@ def test_png_upload_prints_via_the_image_processor( message="image job's temp files should be deleted", ) + def test_docx_upload_prints_via_the_office_processor( + self, client, mock_print, monkeypatch, tmp_upload_dir, wait_for_status, wait_until + ): + # LibreOffice itself is never run in tests (external executable): + # available() is patched on and process() fakes the conversion, + # exactly the seam spike T6 covers on real hardware. + monkeypatch.setattr( + "app.processors.office.OfficeProcessor.available", lambda self: True + ) + + def fake_convert(self, src, out_dir): + pdf = out_dir / f"{src.stem}.pdf" + pdf.write_bytes(b"%PDF- converted by (mocked) LibreOffice") + return pdf + + monkeypatch.setattr( + "app.processors.office.OfficeProcessor.process", fake_convert + ) + + response = client.post( + "/print", files={"file": ("invoice.docx", make_docx_bytes(), "application/zip")} + ) + + assert response.status_code == 201 + job_id = response.json()["job_id"] + + job = wait_for_status(job_id, "done") + assert job.format == "office" + assert mock_print.pdf_path.suffix == ".pdf" # engine only ever sees PDF + assert mock_print.pdf_path.stem == job_id + + wait_until( + lambda: not list(tmp_upload_dir.glob(f"{job_id}.*")), + message="office job's temp files should be deleted", + ) + class TestPrintRejections: """Section 13 test #8 β€” bad files must be refused before printing.""" diff --git a/tests/unit/test_office.py b/tests/unit/test_office.py new file mode 100644 index 0000000..bf18eea --- /dev/null +++ b/tests/unit/test_office.py @@ -0,0 +1,266 @@ +"""Unit tests for the office processor (app/processors/office.py). + +LibreOffice is an EXTERNAL executable β€” the tests replace subprocess.Popen +with fakes that write the output PDF soffice would have written, so the +suite pins down everything EXCEPT LibreOffice itself: + + - the exact invocation shape (headless, private profile, convert-to pdf); + - the failure mapping: nonzero exit / timeout / no output file / cannot + start β†’ ConversionError with a message a phone user can act on; + - the kill switch: available() = ENABLE_OFFICE AND LibreOffice found. + +Whether LibreOffice actually renders a DOCX correctly is T6's job on real +hardware (spike_t6_office.py) β€” that stays outside the automated suite, +like every paper test. +""" + +import subprocess +import zipfile +from io import BytesIO +from pathlib import Path + +import pytest + +from app.processors import office +from app.processors.base import ConversionError +from app.processors.office import OfficeProcessor, find_soffice + + +def make_docx_bytes() -> bytes: + """The smallest ZIP that detection/office code treats as a DOCX.""" + buffer = BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("word/document.xml", b"") + return buffer.getvalue() + + +class FakeProcess: + """Stands in for subprocess.Popen's return value.""" + + def __init__(self, returncode=0, stderr=b"", raises_timeout=False): + self.pid = 4242 + self.returncode = returncode + self._stderr = stderr + self._raises_timeout = raises_timeout + self.kill_calls = 0 + self.communicate_calls = 0 + + def communicate(self, timeout=None): + self.communicate_calls += 1 + # Only the FIRST wait can time out β€” after the kill, the reap must + # return normally (that's the real subprocess contract the code + # relies on). + if self._raises_timeout and self.communicate_calls == 1: + raise subprocess.TimeoutExpired(cmd="soffice", timeout=timeout) + return b"", self._stderr + + def kill(self): + self.kill_calls += 1 + + +# --------------------------------------------------------------------------- +# find_soffice β€” search order and the "explicit path is authoritative" rule +# --------------------------------------------------------------------------- + + +class TestFindSoffice: + def test_explicit_path_wins_when_it_exists(self, tmp_path, monkeypatch): + exe = tmp_path / "soffice.exe" + exe.write_bytes(b"MZ") + monkeypatch.setattr(office, "LO_PATH", str(exe)) + assert find_soffice() == str(exe) + + def test_explicit_missing_path_is_authoritative_loudly(self, tmp_path, monkeypatch): + monkeypatch.setattr(office, "LO_PATH", str(tmp_path / "missing.exe")) + monkeypatch.setattr( + office.shutil, "which", lambda name: "C:/elsewhere/soffice.exe" + ) + assert find_soffice() is None + + def test_found_on_path_when_no_explicit_config(self, monkeypatch): + monkeypatch.setattr(office, "LO_PATH", "") + monkeypatch.setattr( + office.shutil, + "which", + lambda name: "C:/LibreOffice/soffice.exe" if "soffice" in name else None, + ) + assert find_soffice() == "C:/LibreOffice/soffice.exe" + + def test_found_among_standard_install_candidates(self, tmp_path, monkeypatch): + exe = tmp_path / "soffice.exe" + exe.write_bytes(b"MZ") + monkeypatch.setattr(office, "LO_PATH", "") + monkeypatch.setattr(office.shutil, "which", lambda name: None) + monkeypatch.setattr(office, "SOFFICE_CANDIDATES", [str(exe)]) + assert find_soffice() == str(exe) + + def test_returns_none_when_nowhere_to_be_found(self, tmp_path, monkeypatch): + monkeypatch.setattr(office, "LO_PATH", "") + monkeypatch.setattr(office.shutil, "which", lambda name: None) + monkeypatch.setattr(office, "SOFFICE_CANDIDATES", [str(tmp_path / "nope.exe")]) + assert find_soffice() is None + + +# --------------------------------------------------------------------------- +# available() β€” the office kill switch +# --------------------------------------------------------------------------- + + +class TestAvailable: + def test_disabled_by_config_even_when_installed(self, monkeypatch): + monkeypatch.setattr(office, "ENABLE_OFFICE", False) + monkeypatch.setattr(office, "find_soffice", lambda: "C:/soffice.exe") + assert OfficeProcessor().available() is False + + def test_unavailable_when_libreoffice_missing(self, monkeypatch): + monkeypatch.setattr(office, "ENABLE_OFFICE", True) + monkeypatch.setattr(office, "find_soffice", lambda: None) + assert OfficeProcessor().available() is False + + def test_available_when_enabled_and_installed(self, monkeypatch): + monkeypatch.setattr(office, "ENABLE_OFFICE", True) + monkeypatch.setattr(office, "find_soffice", lambda: "C:/soffice.exe") + assert OfficeProcessor().available() is True + + +# --------------------------------------------------------------------------- +# process() β€” the subprocess contract, with LibreOffice faked +# --------------------------------------------------------------------------- + + +@pytest.fixture +def soffice_found(monkeypatch): + monkeypatch.setattr(office, "find_soffice", lambda: "C:/LibreOffice/soffice.exe") + + +class TestProcess: + def test_converts_with_the_documented_headless_invocation( + self, tmp_path, monkeypatch, soffice_found + ): + src = tmp_path / "job-1.docx" + src.write_bytes(make_docx_bytes()) + calls = {} + + def fake_popen(cmd, **kwargs): + calls["cmd"] = cmd + outdir = Path(cmd[cmd.index("--outdir") + 1]) + (outdir / "job-1.pdf").write_bytes(b"%PDF- converted") + return FakeProcess() + + monkeypatch.setattr(office.subprocess, "Popen", fake_popen) + + pdf = OfficeProcessor().process(src, tmp_path) + + assert pdf == tmp_path / "job-1.pdf" # .pdf, per the pipeline + cmd = calls["cmd"] + assert cmd[0] == "C:/LibreOffice/soffice.exe" + assert "--headless" in cmd and "--norestore" in cmd and "--nolockcheck" in cmd + assert cmd[cmd.index("--convert-to") + 1] == "pdf" + assert cmd[cmd.index("--outdir") + 1] == str(tmp_path) + assert cmd[-1] == str(src) + # A fresh throwaway profile per run, passed as a file URI. + profile_flag = next(flag for flag in cmd if flag.startswith("-env:UserInstallation=")) + assert profile_flag.startswith("-env:UserInstallation=file:///") + + def test_each_conversion_gets_a_fresh_profile( + self, tmp_path, monkeypatch, soffice_found + ): + src = tmp_path / "job-2.docx" + src.write_bytes(make_docx_bytes()) + profiles = [] + + def fake_popen(cmd, **kwargs): + profiles.append( + next(f for f in cmd if f.startswith("-env:UserInstallation=")) + ) + outdir = Path(cmd[cmd.index("--outdir") + 1]) + src = Path(cmd[-1]) + (outdir / f"{src.stem}.pdf").write_bytes(b"%PDF-") + return FakeProcess() + + monkeypatch.setattr(office.subprocess, "Popen", fake_popen) + for number in range(2): + (tmp_path / f"job-2-{number}.docx").write_bytes(make_docx_bytes()) + OfficeProcessor().process(tmp_path / f"job-2-{number}.docx", tmp_path) + + assert profiles[0] != profiles[1] # a crashed run can't poison the next + + def test_nonzero_exit_becomes_a_conversion_error_with_stderr( + self, tmp_path, monkeypatch, soffice_found + ): + (tmp_path / "job-3.docx").write_bytes(make_docx_bytes()) + + def fake_popen(cmd, **kwargs): + return FakeProcess(returncode=3, stderr=b"Error: source could not be loaded\n") + + monkeypatch.setattr(office.subprocess, "Popen", fake_popen) + + with pytest.raises(ConversionError, match="exit 3.*could not be loaded"): + OfficeProcessor().process(tmp_path / "job-3.docx", tmp_path) + + def test_timeout_kills_the_process_tree_and_explains_itself( + self, tmp_path, monkeypatch, soffice_found + ): + (tmp_path / "job-4.docx").write_bytes(make_docx_bytes()) + processes = [] + + def fake_popen(cmd, **kwargs): + process = FakeProcess(raises_timeout=True) + processes.append(process) + return process + + monkeypatch.setattr(office.subprocess, "Popen", fake_popen) + # Task Manager for robots: record whatever kill mechanism runs. + kills = [] + monkeypatch.setattr( + office.subprocess, "run", lambda cmd, **kw: kills.append(cmd) + ) + + with pytest.raises(ConversionError, match="did not finish within"): + OfficeProcessor().process(tmp_path / "job-4.docx", tmp_path) + + process = processes[0] + # Reaped after the kill attempt, on every platform (taskkill on + # Windows, kill() elsewhere). + assert process.communicate_calls == 2 + if process.kill_calls: + assert kills == [] # kill() path means no taskkill was needed + else: + assert kills and kills[0][0] == "taskkill" + + def test_missing_output_pdf_is_an_error_even_at_exit_zero( + self, tmp_path, monkeypatch, soffice_found + ): + (tmp_path / "job-5.docx").write_bytes(make_docx_bytes()) + + def fake_popen(cmd, **kwargs): + return FakeProcess() # exit 0, but writes nothing + + monkeypatch.setattr(office.subprocess, "Popen", fake_popen) + + with pytest.raises(ConversionError, match="produced no PDF"): + OfficeProcessor().process(tmp_path / "job-5.docx", tmp_path) + + def test_failure_to_start_libreoffice_is_a_conversion_error( + self, tmp_path, monkeypatch, soffice_found + ): + (tmp_path / "job-6.docx").write_bytes(make_docx_bytes()) + + def fake_popen(cmd, **kwargs): + raise OSError("soffice vanished") + + monkeypatch.setattr(office.subprocess, "Popen", fake_popen) + + with pytest.raises(ConversionError, match="Could not start LibreOffice"): + OfficeProcessor().process(tmp_path / "job-6.docx", tmp_path) + + def test_missing_libreoffice_fails_with_an_actionable_message( + self, tmp_path, monkeypatch + ): + # The pipeline-side safety net for a machine that changed after the + # upload was accepted (config flip, uninstall). + monkeypatch.setattr(office, "find_soffice", lambda: None) + + (tmp_path / "job-7.docx").write_bytes(make_docx_bytes()) + with pytest.raises(ConversionError, match="Convert it to PDF first"): + OfficeProcessor().process(tmp_path / "job-7.docx", tmp_path) diff --git a/tests/unit/test_processors.py b/tests/unit/test_processors.py index 346b033..1636bfe 100644 --- a/tests/unit/test_processors.py +++ b/tests/unit/test_processors.py @@ -4,12 +4,11 @@ - for_category() returns a processor whose process() yields a PDF path; - unregistered categories return None β€” the "not enabled yet" signal the upload gate and the pipeline both rely on. -Phase 1 registered pdf, Phase 2 added image; office/text are still pending -(docs/MULTI_FORMAT_PLAN.md Β§10) and each later phase extends these +Phase 1 registered pdf, Phase 2 added image, Phase 3 added office; text is +still pending (docs/MULTI_FORMAT_PLAN.md Β§10) and Phase 4 extends these expectations. """ - from app.processors import for_category, supported_categories @@ -17,18 +16,23 @@ class TestRegistry: def test_registered_categories_have_processors(self): assert for_category("pdf") is not None assert for_category("image") is not None + assert for_category("office") is not None def test_future_categories_are_not_registered_yet(self): - # Phase order from docs/MULTI_FORMAT_PLAN.md Β§10: office (p12), - # text (p13). - assert for_category("office") is None + # Phase order from docs/MULTI_FORMAT_PLAN.md Β§10: text (p13). assert for_category("text") is None def test_unknown_category_returns_none(self): assert for_category("holodeck") is None - def test_supported_categories_are_pinned_after_phase_2(self): - assert supported_categories() == ("image", "pdf") + def test_supported_categories_are_pinned_after_phase_3(self): + assert supported_categories() == ("image", "office", "pdf") + + def test_office_availability_depends_on_the_machine(self): + # Registered everywhere, but its available() gate is what refuses + # uploads when LibreOffice is missing or ENABLE_OFFICE=0. The + # gate's own logic lives in tests/unit/test_office.py. + assert for_category("office").available() in (True, False) class TestPdfProcessor: diff --git a/tests/unit/test_uploads.py b/tests/unit/test_uploads.py index f6b9546..e57a1ee 100644 --- a/tests/unit/test_uploads.py +++ b/tests/unit/test_uploads.py @@ -8,6 +8,9 @@ phase lands (docs/MULTI_FORMAT_PLAN.md Β§10). """ +import zipfile +from io import BytesIO + import pytest from app.services import uploads @@ -21,6 +24,14 @@ ) +def make_docx_bytes() -> bytes: + """The smallest ZIP that detection treats as a DOCX (word/ part).""" + buffer = BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("word/document.xml", b"") + return buffer.getvalue() + + class TestValidateUpload: def test_accepts_a_real_pdf(self): assert validate_upload("report.pdf", b"%PDF-1.4 rest of the document") == "pdf" @@ -69,18 +80,32 @@ def test_images_are_now_printable(self): # and its category flows to the job/pipeline. assert validate_upload("photo.jpg", b"\xff\xd8\xff\xe0" + b"x" * 32) == "image" + def test_office_file_accepted_when_libreoffice_is_available(self, monkeypatch): + # Phase 3 registered the office processor; acceptance depends on + # the machine having LibreOffice (patched here β€” the dev/CI boxes + # don't have it, which is exactly the state the next test pins). + monkeypatch.setattr( + "app.processors.office.OfficeProcessor.available", lambda self: True + ) + assert validate_upload("invoice.docx", make_docx_bytes()) == "office" + + def test_office_file_refused_with_an_actionable_message_when_unavailable( + self, monkeypatch + ): + # The kill switch / missing LibreOffice: registered, but not + # runnable β€” the message must say what to DO, not just "no". + monkeypatch.setattr( + "app.processors.office.OfficeProcessor.available", lambda self: False + ) + with pytest.raises(UploadError, match="LibreOffice") as exc_info: + validate_upload("invoice.docx", make_docx_bytes()) + assert exc_info.value.status_code == 415 + def test_detected_but_unregistered_format_refused_until_its_phase(self): - # Office files are DETECTED correctly (detection sniffs the ZIP for - # word/ parts) but no processor is registered yet β€” refused with the - # honest message. - import io - import zipfile - - buffer = io.BytesIO() - with zipfile.ZipFile(buffer, "w") as archive: - archive.writestr("word/document.xml", b"") + # Text has no processor yet (Phase 4): detected via extension, + # refused until then. with pytest.raises(UploadError, match="later phase") as exc_info: - validate_upload("invoice.docx", buffer.getvalue()) + validate_upload("notes.txt", b"just some plain text content here") assert exc_info.value.status_code == 415 def test_no_filename_and_unknown_content_is_unsupported(self): From 0be3eabf39f6ef54d53f063f773350926699a065 Mon Sep 17 00:00:00 2001 From: geb Date: Sat, 29 Aug 2026 01:48:42 +0800 Subject: [PATCH 05/16] p13: text/CSV printing - reportlab renderer (MVP format set complete) - app/processors/text.py: TXT renders as monospace, word-wrapped and paginated; CSV renders as a bordered grid with the header repeated on every page, delimiter sniffing (, ; tab |), and row/column caps that print an explicit truncation notice - encoding is verified, not assumed: UTF-16-with-BOM -> UTF-8 -> Windows-1252, and a file that decodes as none of them fails with a clear error instead of printing mojibake - page geometry (PAGE_SIZES_PT/page_size_pt) moves to processors/base.py so images and text share one source of truth - registry: text registered - all four MVP categories (pdf, image, office, text) are now printable, gated only by LibreOffice for office - web page + API accept .txt/.csv; spike_t7_text.py = T7 paper check - tests: 193 pass, coverage 96.9% (gate 90%) --- README.md | 14 +- app/api/print.py | 5 +- app/api/web.py | 9 +- app/processors/__init__.py | 5 +- app/processors/base.py | 21 +++ app/processors/images.py | 20 +-- app/processors/text.py | 270 ++++++++++++++++++++++++++++++++++ docs/MULTI_FORMAT_PLAN.md | 3 + requirements.txt | 4 + spike_t7_text.py | 171 +++++++++++++++++++++ tests/api/test_print_api.py | 27 ++++ tests/unit/test_images.py | 9 +- tests/unit/test_processors.py | 18 +-- tests/unit/test_text.py | 194 ++++++++++++++++++++++++ tests/unit/test_uploads.py | 18 ++- 15 files changed, 737 insertions(+), 51 deletions(-) create mode 100644 app/processors/text.py create mode 100644 spike_t7_text.py create mode 100644 tests/unit/test_text.py diff --git a/README.md b/README.md index e41fbf8..cfce961 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,10 @@ | `spike_print_test.py` | Standalone printer diagnostic β€” run it when printing misbehaves | | `spike_t5_images.py` | Image-printing spike (T5) β€” run once at the printer to verify photo output | | `spike_t6_office.py` | Office-printing spike (T6) β€” run once after installing LibreOffice | +| `spike_t7_text.py` | Text/CSV spike (T7) β€” run once to verify plain-text output | | `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, pillow | +| `requirements.txt` | Python packages: fastapi, uvicorn, pywin32, python-multipart, pillow, reportlab | | `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) | @@ -87,13 +88,14 @@ 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, image (JPG/PNG/WebP), or Office document (DOCX/XLSX/PPTX/ODF) - β†’ tap **Print** β†’ watch the status: +3. Pick a PDF, image (JPG/PNG/WebP), Office document (DOCX/XLSX/PPTX/ODF), + or TXT/CSV β†’ tap **Print** β†’ watch the status: `πŸ“¨ Queued… β†’ ⏳ status: queued… β†’ πŸ–¨οΈ Printed to EPSON L3210 Series!` 4. Paper comes out. Done. Images are placed on a white A4 page, fitted and centered; phone-photo rotation (EXIF) is handled automatically. Office documents need LibreOffice on the server (Β§1); DOCX/XLSX/PPTX convert in - roughly 10–30 s β€” the page shows `converting` while that runs. + roughly 10–30 s β€” the page shows `converting` while that runs. TXT + prints as monospace text, CSV as a bordered grid table. Other endpoints (also browsable interactively at `http://:8000/docs`): @@ -101,7 +103,7 @@ Other endpoints (also browsable interactively at `http://:8000/docs`): |---|---| | `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 file (PDF, image, or Office document) and print it | +| `POST /print` | Upload a file (PDF, image, Office document, or TXT/CSV) 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 | @@ -136,7 +138,7 @@ 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. -For the multi-format work, `spike_t5_images.py` (images) and `spike_t6_office.py` (DOCX/XLSX/PPTX after installing LibreOffice) run the same kind of hardware check for the newer formats β€” each converts test files with the service's real processors, prints them, and gives you a paper checklist. Their results are the phases' acceptance gates β€” see `docs/MULTI_FORMAT_PLAN.md` Β§14. +For the multi-format work, `spike_t5_images.py` (images), `spike_t6_office.py` (DOCX/XLSX/PPTX after installing LibreOffice) and `spike_t7_text.py` (TXT/CSV) run the same kind of hardware check for the newer formats β€” each converts test files with the service's real processors, prints them, and gives you a paper checklist. Their results are the phases' acceptance gates β€” see `docs/MULTI_FORMAT_PLAN.md` Β§14. --- diff --git a/app/api/print.py b/app/api/print.py index 352836c..8f4d013 100644 --- a/app/api/print.py +++ b/app/api/print.py @@ -12,8 +12,9 @@ 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 and images always; office additionally needs LibreOffice - installed / ENABLE_OFFICE=1). Refusals explain which gate fired. + 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 diff --git a/app/api/web.py b/app/api/web.py index 0162b5c..2125d2e 100644 --- a/app/api/web.py +++ b/app/api/web.py @@ -55,10 +55,10 @@

πŸ–¨οΈ Printer Service

-

Pick a PDF, image, or Office document and send it to the printer.

+

Pick a PDF, image, Office, or text file and send it to the printer.

+ accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.odt,.ods,.odp,.txt,.csv">
@@ -74,6 +74,7 @@ ".pdf", ".jpg", ".jpeg", ".png", ".webp", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp", + ".txt", ".csv", ]; function show(text, cls) { @@ -87,8 +88,8 @@ if (!file) { show("Pick a file first.", "err"); return; } if (!OK_TYPES.some(ext => file.name.toLowerCase().endsWith(ext))) { - show("That file type isn't supported yet β€” PDF, images, or Office " - + "documents (DOCX/XLSX/PPTX).", "err"); + show("That file type isn't supported yet β€” PDF, images, Office " + + "documents, or TXT/CSV.", "err"); return; } diff --git a/app/processors/__init__.py b/app/processors/__init__.py index 82dc73d..724911f 100644 --- a/app/processors/__init__.py +++ b/app/processors/__init__.py @@ -5,13 +5,15 @@ A category without a registration is detected but refused at upload time; a REGISTERED processor can still be per-machine unavailable (the office kill switch, LibreOffice missing) β€” that's what Processor.available() is -for (docs/MULTI_FORMAT_PLAN.md Β§10). +for (docs/MULTI_FORMAT_PLAN.md Β§10). All four MVP categories are +registered as of Phase 4. """ from app.processors.base import ConversionError, Processor from app.processors.images import IMAGE_PROCESSOR from app.processors.office import OFFICE_PROCESSOR from app.processors.pdf import PDF_PROCESSOR +from app.processors.text import TEXT_PROCESSOR __all__ = [ "ConversionError", @@ -24,6 +26,7 @@ "image": IMAGE_PROCESSOR, "office": OFFICE_PROCESSOR, "pdf": PDF_PROCESSOR, + "text": TEXT_PROCESSOR, } diff --git a/app/processors/base.py b/app/processors/base.py index 8749697..a2d86f4 100644 --- a/app/processors/base.py +++ b/app/processors/base.py @@ -14,6 +14,27 @@ from pathlib import Path from typing import Protocol +from app.config import PAPER_SIZE + +# Shared page geometry (points; 1 pt = 1/72"). Processors that AUTHOR pages +# (images, text) lay their content out on this size; unknown/empty +# PAPER_SIZE falls back to A4. Add entries (e.g. long bond 8.5x13) as later +# phases need them. +PAGE_SIZES_PT = { + "a3": (842, 1191), + "a4": (595, 842), + "a5": (420, 595), + "letter": (612, 792), + "legal": (612, 1008), +} +DEFAULT_PAGE = "a4" + + +def page_size_pt() -> tuple[int, int]: + """The page authored content sits on: PAPER_SIZE when it names a known + size, A4 otherwise (a page must be concrete before anything fits on it).""" + return PAGE_SIZES_PT.get(PAPER_SIZE.strip().lower(), PAGE_SIZES_PT[DEFAULT_PAGE]) + class ConversionError(Exception): """Raised with a human-readable reason when a file cannot be converted. diff --git a/app/processors/images.py b/app/processors/images.py index fef7696..e988f45 100644 --- a/app/processors/images.py +++ b/app/processors/images.py @@ -25,22 +25,10 @@ from PIL import Image, ImageOps, ImageSequence -from app.config import PAPER_SIZE -from app.processors.base import ConversionError +from app.processors.base import ConversionError, page_size_pt logger = logging.getLogger(__name__) -# Page sizes in points (1 pt = 1/72"). Unknown/empty PAPER_SIZE falls back -# to A4. Add entries (e.g. long bond 8.5x13) as later phases need them. -PAGE_SIZES_PT = { - "a3": (842, 1191), - "a4": (595, 842), - "a5": (420, 595), - "letter": (612, 792), - "legal": (612, 1008), -} -DEFAULT_PAGE = "a4" - MARGIN_PT = 36 # 0.5" β€” this printer cannot print borderless anyway MAX_DPI = 300 # above ~300 effective DPI, extra pixels are invisible on paper SOURCE_DPI = 96 # small images are assumed ~96 DPI (the web/phone norm)... @@ -48,12 +36,6 @@ MAX_FRAMES = 10 # an animated WebP/scan-batch TIFF is not a 50-page print job -def page_size_pt() -> tuple[int, int]: - """The page images are laid out on: PAPER_SIZE when it names a known - size, A4 otherwise (images always need a concrete page to sit on).""" - return PAGE_SIZES_PT.get(PAPER_SIZE.strip().lower(), PAGE_SIZES_PT[DEFAULT_PAGE]) - - def layout( img_w: int, img_h: int, page_pt: tuple[int, int] ) -> tuple[tuple[int, int], tuple[int, int, int, int]]: diff --git a/app/processors/text.py b/app/processors/text.py new file mode 100644 index 0000000..74ac095 --- /dev/null +++ b/app/processors/text.py @@ -0,0 +1,270 @@ +"""Text/CSV processor (Phase 4) β€” authors print-ready PDFs for text files. + +A text file has no pages of its own, so this processor AUTHORS the layout +(multi-format plan Β§0, answer 4): + + TXT β†’ monospace text, word-wrapped, paginated + CSV β†’ a bordered grid with the header row repeated on every page + +reportlab draws both onto the shared page (base.page_size_pt). reportlab +is a Python library β€” unlike LibreOffice it runs INSIDE the test suite, so +these conversions are exercised for real, not mocked. + +Encoding: text has no magic bytes (detection trusts the extension), so +decoding is verified here: UTF-16 (only with a BOM, which carries the byte +order) β†’ UTF-8 β†’ Windows-1252. A file that decodes as none of those is a +binary file wearing a text-file name and fails with a clear error instead +of printing mojibake. + +Limits (paper + memory guards on the ≀4 GB PC): TXT caps at MAX_TXT_PAGES; +CSV caps rows and columns β€” both print an explicit "truncated" notice. + +Known limitation: the built-in Courier font covers Latin scripts; CJK or +emoji print as boxes (registering a Unicode TTF is a v2 idea). +""" + +import csv +import io +import logging +import math +from pathlib import Path + +from reportlab.pdfbase.pdfmetrics import stringWidth +from reportlab.pdfgen import canvas as pdfcanvas + +from app.processors.base import ConversionError, page_size_pt + +logger = logging.getLogger(__name__) + +FONT = "Courier" +FONT_BOLD = "Courier-Bold" +FONT_SIZE = 9.5 +LINE_HEIGHT = 12 +CELL_PADDING = 3 +MARGIN_PT = 54 # 0.75" β€” text pages want breathing room on both sides +MAX_TXT_PAGES = 100 +CSV_MAX_ROWS = 1000 # rendered rows INCLUDING the header row +CSV_MAX_COLS = 30 + +# Monospace: every character occupies the width of an "M". +CHAR_W = stringWidth("M", FONT, FONT_SIZE) + + +def decode_text(data: bytes) -> str: + """Bytes β†’ text, with the no-mojibake guarantee described above.""" + if data.startswith((b"\xff\xfe", b"\xfe\xff")): + return data.decode("utf-16") # BOM carries the byte order + for encoding in ("utf-8", "cp1252"): + try: + return data.decode(encoding) + except UnicodeDecodeError: + continue + raise ConversionError( + "This text file does not decode as text (tried UTF-8, UTF-16 and " + "Windows-1252) β€” it may be a binary file with a text-file name." + ) + + +def wrap_line(line: str, max_chars: int) -> list[str]: + """Word-wrap one logical line into chunks of at most max_chars. + + Breaks at spaces when it can; a single word longer than a whole line + is hard-split rather than allowed to overflow the page. + """ + if len(line) <= max_chars: + return [line] + chunks: list[str] = [] + rest = line + while len(rest) > max_chars: + cut = rest.rfind(" ", 0, max_chars + 1) + if cut <= 0: + cut = max_chars # no space to break at: hard-split the word + chunks.append(rest[:cut].rstrip(" ")) + rest = rest[cut:].lstrip(" ") + chunks.append(rest) + return chunks + + +def sniff_dialect(sample: str): + """Detect , ; tab | delimiters; fall back to plain comma-separated.""" + try: + return csv.Sniffer().sniff(sample, delimiters=",;\t|") + except csv.Error: + return csv.excel + + +def grid_columns(rows: list[list[str]], avail_w: float) -> list[float]: + """Column widths in points for a grid that must fit `avail_w`. + + Each width covers its widest cell (a monster cell is capped so it + can't hog the page) PLUS the cell padding on both sides β€” the same + accounting _clip_cell uses β€” then the whole grid is scaled down + proportionally when it exceeds the printable width. + """ + column_count = max((len(row) for row in rows), default=1) + char_counts = [1] * column_count + for row in rows: + for index, cell in enumerate(row): + char_counts[index] = max(char_counts[index], min(len(cell), 60)) + widths = [count * CHAR_W + 2 * CELL_PADDING for count in char_counts] + if sum(widths) > avail_w: + factor = avail_w / sum(widths) + widths = [width * factor for width in widths] + return widths + + +def _clip_cell(cell: str, column_w: float) -> str: + # The 1e-9 absorbs binary-float noise in the width math (e.g. a cell + # that is exactly 2.0 characters wide must not truncate to 1). + max_chars = int((column_w - 2 * CELL_PADDING) / CHAR_W + 1e-9) + if len(cell) <= max_chars: + return cell + return cell[: max(0, max_chars - 3)] + "..." + + +def _render_txt(text: str, canvas: pdfcanvas.Canvas, page_w: float, page_h: float) -> int: + """Monospace, wrapped, paginated. Returns the page count.""" + max_chars = int((page_w - 2 * MARGIN_PT) / CHAR_W) + lines_per_page = int((page_h - 2 * MARGIN_PT) // LINE_HEIGHT) + + logical = ( + text.replace("\r\n", "\n").replace("\r", "\n").replace("\t", " ") + .replace("\f", "\n") + .split("\n") + ) + wrapped: list[str] = [] + for line in logical: + wrapped.extend(wrap_line(line, max_chars)) + + total_pages = math.ceil(len(wrapped) / lines_per_page) + truncated = total_pages > MAX_TXT_PAGES + pages = min(total_pages, MAX_TXT_PAGES) + + for page in range(pages): + # setFont belongs to the page being drawn β€” anything after the + # final showPage would touch a new page and reportlab would emit + # it as a blank trailing page. + canvas.setFont(FONT, FONT_SIZE) + chunk = wrapped[page * lines_per_page : (page + 1) * lines_per_page] + if truncated and page == pages - 1: + chunk[-1] = "[truncated β€” the file was longer than the print limit]" + y = page_h - MARGIN_PT - FONT_SIZE + for line in chunk: + canvas.drawString(MARGIN_PT, y, line) + y -= LINE_HEIGHT + canvas.showPage() + return pages + + +def _render_csv( + rows: list[list[str]], canvas: pdfcanvas.Canvas, page_w: float, page_h: float +) -> int: + """Bordered grid, header repeated per page, caps with a notice.""" + original_rows = len(rows) + original_cols = max(len(row) for row in rows) + + truncated_cols = original_cols > CSV_MAX_COLS + if truncated_cols: + rows = [row[:CSV_MAX_COLS] for row in rows] + column_count = max(len(row) for row in rows) + rows = [row + [""] * (column_count - len(row)) for row in rows] # pad ragged rows + + truncated_rows = original_rows > CSV_MAX_ROWS + if truncated_rows: + rows = rows[:CSV_MAX_ROWS] + + notes = [] + if truncated_rows: + notes.append(f"[truncated β€” showing the first {CSV_MAX_ROWS} of " + f"{original_rows} rows]") + if truncated_cols: + notes.append(f"[truncated β€” showing the first {CSV_MAX_COLS} of " + f"{original_cols} columns]") + note_space = LINE_HEIGHT * (len(notes) + 1) # +1: never touch the bottom margin + + header, data = rows[0], rows[1:] + column_widths = grid_columns(rows, page_w - 2 * MARGIN_PT) + grid_w = sum(column_widths) + row_h = LINE_HEIGHT + 2 * CELL_PADDING + rows_per_page = max(1, int((page_h - 2 * MARGIN_PT - note_space) // row_h)) + data_per_page = rows_per_page - 1 # the header occupies a slot on every page + pages = max(1, math.ceil(len(data) / data_per_page)) + + for page in range(pages): + y_top = page_h - MARGIN_PT + page_rows = [header] + data[page * data_per_page : (page + 1) * data_per_page] + grid_h = row_h * len(page_rows) + + canvas.setLineWidth(0.4) + for index in range(len(page_rows) + 1): # horizontal rules + y = y_top - index * row_h + canvas.line(MARGIN_PT, y, MARGIN_PT + grid_w, y) + x = MARGIN_PT + for width in [*column_widths]: # vertical rules + x += width + canvas.line(x, y_top - grid_h, x, y_top) + + for row_index, row in enumerate(page_rows): + canvas.setFont(FONT_BOLD if row_index == 0 else FONT, FONT_SIZE) + y = y_top - (row_index + 1) * row_h + CELL_PADDING + x = MARGIN_PT + for col_index, cell in enumerate(row): + canvas.drawString( + x + CELL_PADDING, y, _clip_cell(cell, column_widths[col_index]) + ) + x += column_widths[col_index] + + if page == pages - 1: + canvas.setFont(FONT, FONT_SIZE) + y = y_top - grid_h - LINE_HEIGHT + for note in notes: + canvas.drawString(MARGIN_PT, y, note) + y -= LINE_HEIGHT + + canvas.showPage() + return pages + + +class TextProcessor: + def available(self) -> bool: + # reportlab is a hard dependency (requirements.txt) β€” always on. + return True + + def process(self, src: Path, out_dir: Path) -> Path: + """Render a TXT/CSV file into the print-ready PDF the engine gets.""" + pdf_path = out_dir / f"{src.stem}.pdf" + data = src.read_bytes() + if not data.strip(): + raise ConversionError("The text file is empty β€” nothing to print.") + + text = decode_text(data) + page_w, page_h = page_size_pt() + # pageCompression off: the drawn text stays visible in the bytes, + # which is how the tests (and a plain text editor) can verify output. + document = pdfcanvas.Canvas(str(pdf_path), pagesize=(page_w, page_h), pageCompression=0) + try: + if src.suffix.lower() == ".csv": + rows = [ + row + for row in csv.reader(io.StringIO(text), sniff_dialect(text[:4096])) + if any(cell.strip() for cell in row) # blank lines are noise + ] + if not rows: + raise ConversionError("The CSV file contains no rows to print.") + pages = _render_csv(rows, document, page_w, page_h) + else: + pages = _render_txt(text, document, page_w, page_h) + except ConversionError: + raise + except Exception as exc: + raise ConversionError(f"Could not render the text as PDF: {exc}") from exc + document.save() + + logger.info( + "rendered %s -> %s (%d page(s))", src.name, pdf_path.name, pages + ) + return pdf_path + + +# Stateless β†’ one shared instance for every job. +TEXT_PROCESSOR = TextProcessor() diff --git a/docs/MULTI_FORMAT_PLAN.md b/docs/MULTI_FORMAT_PLAN.md index 2e8cb06..a5e5bfc 100644 --- a/docs/MULTI_FORMAT_PLAN.md +++ b/docs/MULTI_FORMAT_PLAN.md @@ -211,6 +211,9 @@ AV scanning β€” βšͺ v2+ options. python-pptx` spike-only) β€” this phase's acceptance gate. - **Phase 4 (p13) β€” text/CSV:** reportlab renderer β€” TXT = monospace text with wrap; CSV = bordered grid with row/col caps + "truncated" notice. + Code landed in p13 (all four MVP categories now registered). Physical + check pending T7 (`spike_t7_text.py`) β€” with T5/T6 this completes the + MVP's paper verification. - **Phase 5 (p14) β€” queue management:** cancel while queued/converting; spooler purge via `win32print.SetJob` once printed; retry failed jobs; SQLite persistence (SOURCE_OF_TRUTH Β§12 upgrade path) + startup recovery. diff --git a/requirements.txt b/requirements.txt index 0c6205f..17dcc66 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,7 @@ pywin32>=306; sys_platform == "win32" # Image processing (Phase 2, multi-format plan): converts JPG/PNG/WebP into # print-ready PDF pages. 10.3+ has the image-parsing security fixes. pillow>=10.3 + +# Text/CSV rendering (Phase 4, multi-format plan): authors PDF pages for +# TXT (monospace, wrapped) and CSV (bordered grid). +reportlab>=4.0 diff --git a/spike_t7_text.py b/spike_t7_text.py new file mode 100644 index 0000000..144c1c9 --- /dev/null +++ b/spike_t7_text.py @@ -0,0 +1,171 @@ +""" +spike_t7_text.py β€” Text/CSV Printing Spike (docs/MULTI_FORMAT_PLAN.md Β§14, T7) + +Run this ON the print-server PC, from the project root: + + .venv\\Scripts\\python spike_t7_text.py + +(No extra installs β€” reportlab ships in requirements.txt.) + +Generates a TXT and a CSV, converts each with the service's REAL +TextProcessor (reportlab β€” the exact production path), prints the PDFs via +SumatraPDF, and reports page counts: + + 1. TXT β€” paragraphs + lines much longer than the page width + (word-wrap must keep everything inside the margins) + 2. CSV β€” 40 rows x 6 columns with quoted cells containing commas + (grid must stay aligned, header repeated on page 2) + +PASS criteria β€” judge the PAPER (the script cannot see it): + [ ] TXT: nothing clipped at either margin, no orphan single words + making a mess, readable monospace + [ ] CSV: all 6 columns visible with borders, nothing cut to "...", + header row repeated on every page + [ ] conversion was instant (text should print with no wait at all) +Record the summary in SOURCE_OF_TRUTH Section 5, like the T4/T5/T6 +entries. These results are the Phase 4 acceptance gate β€” the last one +before the whole MVP format set is verified on real paper. +""" + +import argparse +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +LINE = "=" * 64 + + +def banner(text: str) -> None: + print("\n" + LINE) + print(text) + print(LINE) + + +def find_printer() -> str: + """Prefer the L3210 by name, fall back to the Windows default.""" + import win32print + + flags = win32print.PRINTER_ENUM_LOCAL | win32print.PRINTER_ENUM_CONNECTIONS + names = sorted(p[2] for p in win32print.EnumPrinters(flags)) + if not names: + raise RuntimeError("No printers found β€” is the Epson installed on this PC?") + for name in names: + if "L3210" in name: + return name + return names[0] + + +def make_test_files(folder: Path) -> list[tuple[str, Path]]: + out: list[tuple[str, Path]] = [] + + # 1. TXT β€” short lines, plus lines far wider than the page (the wrap + # check), plus an empty-line rhythm. + prose = [ + "T7 text spike β€” TXT rendering check", + "", + "The next line is a single unbroken stream far wider than the page:", + "word " * 60, + "Then normal paragraphs resume. " * 3, + "Final line of page one, hopefully.", + "", + ] * 15 + path = folder / "t7_1_notes.txt" + path.write_text("\n".join(prose), encoding="utf-8") + out.append(("1 TXT (wrap + pagination)", path)) + + # 2. CSV β€” quoted cells containing commas, 40 rows x 6 columns (multi- + # page grid with a repeated header). + rows = ["item,qty,unit price,location,checked by,remark"] + for number in range(1, 41): + rows.append( + f'"widget {number}, type A",{number},9.99,shelf {number % 7},' + f'"crew, night shift",ok' + ) + path = folder / "t7_2_inventory.csv" + path.write_text("\n".join(rows), encoding="utf-8") + out.append(("2 CSV (40x6 grid, quoted cells)", path)) + + return out + + +def print_pdf(sumatra: str, pdf_path: Path, printer_name: str) -> None: + """The service's exact print invocation (see app/printer/windows.py).""" + result = subprocess.run( + [sumatra, "-print-to", printer_name, "-silent", str(pdf_path)], + capture_output=True, + timeout=180, + ) + if result.returncode != 0: + raise RuntimeError( + f"SumatraPDF exited with code {result.returncode}: " + f"{result.stderr.decode(errors='replace').strip()}" + ) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.parse_args() + + banner("T7 TEXT SPIKE β€” run this ON the PC the printer is plugged into") + + try: + from app.printer.windows import find_sumatra + from app.processors.text import TextProcessor + except ImportError as exc: + print(f"Cannot import the app ({exc}). Run from the project root:") + print(" .venv\\Scripts\\python spike_t7_text.py") + return 1 + + try: + import win32print # noqa: F401 (pywin32 presence check, like T1) + except ImportError: + print("pywin32 is not installed here: pip install pywin32") + return 1 + + printer_name = find_printer() + sumatra = find_sumatra() + if not sumatra: + print("SumatraPDF not found β€” install it or set SUMATRA_PATH in .env") + return 1 + + print(f"\nPrinter: {printer_name}") + print(f"Sumatra: {sumatra}") + print("\n>>> Keep paper loaded and watch the physical printer.") + input("Press Enter when ready...") + + processor = TextProcessor() + temp_dir = Path(tempfile.mkdtemp(prefix="spike_t7_")) + results: list[tuple[str, str, str]] = [] + try: + for name, file_path in make_test_files(temp_dir): + try: + pdf_path = processor.process(file_path, temp_dir) + print_pdf(sumatra, pdf_path, printer_name) + results.append( + (f"T7 {name}", "PASS", "print accepted β€” CHECK PAPER") + ) + except Exception as exc: + results.append((f"T7 {name}", "FAIL", str(exc))) + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + banner("SUMMARY") + for name, status, detail in results: + print(f"[{status:4}] {name}: {detail}") + + print( + "\nNow judge the paper:\n" + " [ ] TXT: nothing clipped at either margin; wrapping is clean\n" + " [ ] CSV: all 6 columns visible, borders drawn, no '...' cuts\n" + " [ ] CSV: header row repeated on page 2\n" + "\nRecord the results in SOURCE_OF_TRUTH Section 5 (like the T4/T5/T6\n" + "entries) β€” they are the Phase 4 acceptance gate. With this pass, the\n" + "whole MVP format set is verified on real paper." + ) + return 0 if all(r[1] == "PASS" for r in results) else 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/api/test_print_api.py b/tests/api/test_print_api.py index 9468eff..72a949e 100644 --- a/tests/api/test_print_api.py +++ b/tests/api/test_print_api.py @@ -120,6 +120,33 @@ def fake_convert(self, src, out_dir): message="office job's temp files should be deleted", ) + def test_txt_upload_prints_via_the_real_text_renderer( + self, client, mock_print, tmp_upload_dir, wait_for_status, wait_until + ): + # No mocking needed: reportlab is a Python library, so this runs + # the REAL conversion inside the API flow. + content = "hello from the phone\nline two with more text\n" * 10 + response = client.post( + "/print", files={"file": ("notes.txt", content.encode(), "text/plain")} + ) + + assert response.status_code == 201 + job_id = response.json()["job_id"] + + job = wait_for_status(job_id, "done") + assert job.format == "text" + + # The engine only ever sees a PDF named .pdf. Its CONTENT + # can't be read here β€” cleanup deletes it right after "done" β€” the + # unit tests assert the rendered bytes. + assert mock_print.pdf_path.suffix == ".pdf" + assert mock_print.pdf_path.name == f"{job_id}.pdf" + + wait_until( + lambda: not list(tmp_upload_dir.glob(f"{job_id}.*")), + message="text job's temp files should be deleted", + ) + class TestPrintRejections: """Section 13 test #8 β€” bad files must be refused before printing.""" diff --git a/tests/unit/test_images.py b/tests/unit/test_images.py index 2a64f71..8425691 100644 --- a/tests/unit/test_images.py +++ b/tests/unit/test_images.py @@ -12,9 +12,10 @@ import pytest from PIL import Image +from app.processors import base as processor_base from app.processors import images -from app.processors.base import ConversionError -from app.processors.images import MAX_UPSCALE, ImageProcessor, layout, page_size_pt +from app.processors.base import ConversionError, page_size_pt +from app.processors.images import MAX_UPSCALE, ImageProcessor, layout A4_PT = (595, 842) @@ -47,11 +48,11 @@ def test_small_images_upscale_is_capped(self): class TestPageSize: def test_empty_and_unknown_config_fall_back_to_a4(self, monkeypatch): for value in ("", "bogus"): - monkeypatch.setattr(images, "PAPER_SIZE", value) + monkeypatch.setattr(processor_base, "PAPER_SIZE", value) assert page_size_pt() == (595, 842) def test_known_names_are_case_insensitive(self, monkeypatch): - monkeypatch.setattr(images, "PAPER_SIZE", "Letter") + monkeypatch.setattr(processor_base, "PAPER_SIZE", "Letter") assert page_size_pt() == (612, 792) diff --git a/tests/unit/test_processors.py b/tests/unit/test_processors.py index 1636bfe..889dbe4 100644 --- a/tests/unit/test_processors.py +++ b/tests/unit/test_processors.py @@ -1,32 +1,28 @@ """Unit tests for the processor registry (app/processors). -These tests pin the registry contract the phases plug into: +These tests pin the registry contract: - for_category() returns a processor whose process() yields a PDF path; - unregistered categories return None β€” the "not enabled yet" signal the upload gate and the pipeline both rely on. -Phase 1 registered pdf, Phase 2 added image, Phase 3 added office; text is -still pending (docs/MULTI_FORMAT_PLAN.md Β§10) and Phase 4 extends these -expectations. +All four MVP categories are registered as of Phase 4 (docs/ +MULTI_FORMAT_PLAN.md Β§10); a future format phase extends these expectations. """ from app.processors import for_category, supported_categories class TestRegistry: - def test_registered_categories_have_processors(self): + def test_all_mvp_categories_have_processors(self): assert for_category("pdf") is not None assert for_category("image") is not None assert for_category("office") is not None - - def test_future_categories_are_not_registered_yet(self): - # Phase order from docs/MULTI_FORMAT_PLAN.md Β§10: text (p13). - assert for_category("text") is None + assert for_category("text") is not None def test_unknown_category_returns_none(self): assert for_category("holodeck") is None - def test_supported_categories_are_pinned_after_phase_3(self): - assert supported_categories() == ("image", "office", "pdf") + def test_supported_categories_are_pinned_after_phase_4(self): + assert supported_categories() == ("image", "office", "pdf", "text") def test_office_availability_depends_on_the_machine(self): # Registered everywhere, but its available() gate is what refuses diff --git a/tests/unit/test_text.py b/tests/unit/test_text.py new file mode 100644 index 0000000..19d3e63 --- /dev/null +++ b/tests/unit/test_text.py @@ -0,0 +1,194 @@ +"""Unit tests for the text/CSV processor (app/processors/text.py). + +reportlab is a Python library, so these tests run the REAL renderer and +assert on the produced PDF bytes: the %PDF- magic, page counts (counting +/Type /Page objects), and the truncation notices (pageCompression is off +in the processor precisely so drawn text is visible in the bytes). +""" + +import re + +import pytest + +from app.processors.base import ConversionError +from app.processors.text import ( + CSV_MAX_COLS, + CSV_MAX_ROWS, + MAX_TXT_PAGES, + TextProcessor, + decode_text, + grid_columns, + sniff_dialect, + wrap_line, +) + +A4_AVAIL_W = 595 - 2 * 54 # A4 minus the text margins +LINE_H = 12 + + +def count_pages(pdf_bytes: bytes) -> int: + """Count page objects ("Pages" is the tree node β€” excluded).""" + return len(re.findall(rb"/Type /Page(?![a-zA-Z])", pdf_bytes)) + + +class TestWrapLine: + def test_short_line_passes_through(self): + assert wrap_line("hello", 10) == ["hello"] + + def test_breaks_at_spaces_when_possible(self): + assert wrap_line("hello world foo", 5) == ["hello", "world", "foo"] + + def test_long_words_are_hard_split(self): + chunks = wrap_line("a" * 25, 10) + assert chunks == ["a" * 10, "a" * 10, "a" * 5] + + def test_empty_line_yields_one_empty_chunk(self): + assert wrap_line("", 10) == [""] + + +class TestDecodeText: + def test_utf8(self): + assert decode_text("hΓ©llo wΓΆrld".encode("utf-8")) == "hΓ©llo wΓΆrld" + + def test_utf16_with_bom(self): + assert decode_text("hΓ©llo wΓΆrld".encode("utf-16")) == "hΓ©llo wΓΆrld" + + def test_windows_1252(self): + # Smart quotes + Γ©: invalid as UTF-8, valid as cp1252. + raw = "cafΓ© β€œquoted”".encode("cp1252") + assert decode_text(raw) == "cafΓ© β€œquoted”" + + def test_undecodable_binary_fails_instead_of_printing_mojibake(self): + # 0x81/0x8D/0x8F/0x90/0x9D are undefined in cp1252, 0x80+ breaks + # UTF-8 β€” a binary file pretending to be .txt lands here. + with pytest.raises(ConversionError, match="does not decode"): + decode_text(b"\x81\x8d\x8f\x90\x9d\xff\xfe\x00\x01") + + +class TestSniffDialect: + def test_semicolons_detected(self): + assert sniff_dialect("a;b;c\n1;2;3").delimiter == ";" + + def test_comma_fallback(self): + assert sniff_dialect("plain words only").delimiter == "," + + +class TestGridColumns: + def test_grid_never_exceeds_the_printable_width(self): + rows = [["x" * 200] * 5] # five monster cells + widths = grid_columns(rows, A4_AVAIL_W) + assert sum(widths) <= A4_AVAIL_W + 1e-6 # float rounding guard + + def test_narrow_content_is_not_stretched(self): + rows = [["a", "b", "c"]] + widths = grid_columns(rows, A4_AVAIL_W) + assert sum(widths) < A4_AVAIL_W / 2 # left-aligned, natural size + + def test_short_cells_keep_their_content_after_padding(self): + # The regression this pins: padding is part of the width accounting, + # so a 2-character cell is never clipped down to "...". + from app.processors.text import _clip_cell + + widths = grid_columns([["a1"]], A4_AVAIL_W) + assert _clip_cell("a1", widths[0]) == "a1" + + +class TestProcessTxt: + def test_renders_a_real_pdf_next_to_the_source(self, tmp_path): + src = tmp_path / "job-1.txt" + src.write_text("hello from a text file\n" * 10, encoding="utf-8") + + pdf = TextProcessor().process(src, tmp_path) + + assert pdf == tmp_path / "job-1.pdf" + data = pdf.read_bytes() + assert data.startswith(b"%PDF-") + assert count_pages(data) == 1 + assert b"hello from a text file" in data # compression is off on purpose + assert src.read_bytes().startswith(b"hello") # source untouched + + def test_long_lines_wrap_and_fill_pages(self, tmp_path): + # 100 wrapped lines of prose β€” several pages' worth (61 lines/page). + src = tmp_path / "job-2.txt" + src.write_text( + "\n".join("word " * 30 for _ in range(100)), encoding="utf-8" + ) + + pdf = TextProcessor().process(src, tmp_path) + + assert count_pages(pdf.read_bytes()) > 1 + + def test_very_long_files_are_capped_with_a_notice(self, tmp_path): + src = tmp_path / "job-3.txt" + lines_per_page = int((842 - 2 * 54) // LINE_H) + src.write_text( + "line of text\n" * (MAX_TXT_PAGES * lines_per_page + 50), + encoding="utf-8", + ) + + pdf = TextProcessor().process(src, tmp_path) + + data = pdf.read_bytes() + assert count_pages(data) == MAX_TXT_PAGES + assert b"truncated" in data + + def test_empty_file_is_an_error(self, tmp_path): + src = tmp_path / "job-4.txt" + src.write_bytes(b" \n ") + with pytest.raises(ConversionError, match="empty"): + TextProcessor().process(src, tmp_path) + + def test_binary_in_disguise_is_an_error(self, tmp_path): + src = tmp_path / "job-5.txt" + src.write_bytes(b"\x81\x8d\x8f\x90\x9d" * 4) + with pytest.raises(ConversionError, match="does not decode"): + TextProcessor().process(src, tmp_path) + + +class TestProcessCsv: + def test_renders_a_grid_with_a_bold_header(self, tmp_path): + src = tmp_path / "job-6.csv" + src.write_text("name,qty,note\nbolt,12,galvanized\nnut,7,", encoding="utf-8") + + pdf = TextProcessor().process(src, tmp_path) + + data = pdf.read_bytes() + assert data.startswith(b"%PDF-") + assert count_pages(data) == 1 + assert b"galvanized" in data # cell text visible in the bytes + assert b"truncated" not in data + + def test_semicolon_files_are_detected_and_split(self, tmp_path): + src = tmp_path / "job-7.csv" + src.write_text("a1;a2;a3\nb1;b2;b3", encoding="utf-8") + + pdf = TextProcessor().process(src, tmp_path) + + data = pdf.read_bytes() + assert b"a1" in data and b"b3" in data # drawn as separate cells + + def test_row_overflow_is_capped_with_a_notice(self, tmp_path): + src = tmp_path / "job-8.csv" + rows = ["index,value"] + [f"{i},row {i}" for i in range(CSV_MAX_ROWS + 100)] + src.write_text("\n".join(rows), encoding="utf-8") + + pdf = TextProcessor().process(src, tmp_path) + + data = pdf.read_bytes() + assert count_pages(data) > 1 + assert b"truncated" in data + + def test_column_overflow_is_capped_with_a_notice(self, tmp_path): + src = tmp_path / "job-9.csv" + header = ",".join(f"col{i}" for i in range(CSV_MAX_COLS + 5)) + src.write_text(header, encoding="utf-8") + + pdf = TextProcessor().process(src, tmp_path) + + assert b"truncated" in pdf.read_bytes() + + def test_empty_csv_is_an_error(self, tmp_path): + src = tmp_path / "job-10.csv" + src.write_text(",,\n,,", encoding="utf-8") # nothing but blank cells + with pytest.raises(ConversionError, match="no rows"): + TextProcessor().process(src, tmp_path) diff --git a/tests/unit/test_uploads.py b/tests/unit/test_uploads.py index e57a1ee..1d260f6 100644 --- a/tests/unit/test_uploads.py +++ b/tests/unit/test_uploads.py @@ -101,11 +101,21 @@ def test_office_file_refused_with_an_actionable_message_when_unavailable( validate_upload("invoice.docx", make_docx_bytes()) assert exc_info.value.status_code == 415 - def test_detected_but_unregistered_format_refused_until_its_phase(self): - # Text has no processor yet (Phase 4): detected via extension, - # refused until then. + def test_text_files_are_now_printable(self): + # Phase 4 registered the text processor; text has no magic bytes, + # so the extension is the trusted signal here (detection.py). + assert validate_upload("notes.txt", b"just some plain text content") == "text" + assert validate_upload("data.csv", b"a,b,c\n1,2,3") == "text" + + def test_unregistered_category_refused_until_its_phase(self, monkeypatch): + # All current categories are registered, so the "later phase" gate + # has no natural case left β€” simulate a future category whose + # processor isn't registered yet to keep the branch honest. + monkeypatch.setattr( + "app.services.uploads.for_category", lambda category: None + ) with pytest.raises(UploadError, match="later phase") as exc_info: - validate_upload("notes.txt", b"just some plain text content here") + validate_upload("notes.txt", b"just some plain text content") assert exc_info.value.status_code == 415 def test_no_filename_and_unknown_content_is_unsupported(self): From 5527c7c19cd4fd119ee20707929c2a3af1e1af96 Mon Sep 17 00:00:00 2001 From: geb Date: Sat, 29 Aug 2026 02:00:00 +0800 Subject: [PATCH 06/16] docs: record multi-format stopping point - code complete p10-p13, paper gates pending - SOURCE_OF_TRUTH: status header now says multi-format code-complete (193 tests, ~97% coverage) with T5/T6/T7 explicitly pending; Section 5 gains the pending-spike entries (T5 images + paper-size check, T6 office after LibreOffice install, T7 text/CSV) and the normalization decision; Section 9 gains the 'where this stage stopped' block (done commits, open steps, phases 5-7 not started); Section 13's suite table covers the new processors and gates; Open Items updated - MULTI_FORMAT_PLAN: new 'Where this stage stopped' section (Section 0) with the resume checklist; open-items checklist refreshed - README: status line reflects the same state --- README.md | 3 +- docs/MULTI_FORMAT_PLAN.md | 51 ++++++++++++++++++++++++---- docs/SOURCE_OF_TRUTH.md | 70 +++++++++++++++++++++++++++++++++------ 3 files changed, 105 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index cfce961..456267e 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,9 @@ [![CI](https://github.com/samananias/printerService/actions/workflows/ci.yml/badge.svg)](https://github.com/samananias/printerService/actions/workflows/ci.yml) **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. +**Status:** βœ… **PDF MVP working end-to-end** (phone β†’ service β†’ paper, spike T4). βœ… **Multi-format code-complete** (p10–p13: JPG/PNG/WebP images, DOCX/XLSX/PPTX/ODF office, TXT/CSV β€” office needs LibreOffice installed, otherwise it's refused with a clear message). βœ… 193 automated tests (~97 % coverage) + ruff + CI verify the logic on every push. πŸ”΄ The new formats' paper checks (spikes T5/T6/T7) are still pending β€” run them on the print-server PC before trusting the output; scripts and instructions in `docs/MULTI_FORMAT_PLAN.md` Β§14. **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. +**Multi-format roadmap & exact stopping point:** [docs/MULTI_FORMAT_PLAN.md](docs/MULTI_FORMAT_PLAN.md) ("Where this stage stopped") and [docs/SOURCE_OF_TRUTH.md](docs/SOURCE_OF_TRUTH.md) Β§9. --- diff --git a/docs/MULTI_FORMAT_PLAN.md b/docs/MULTI_FORMAT_PLAN.md index a5e5bfc..7c2025e 100644 --- a/docs/MULTI_FORMAT_PLAN.md +++ b/docs/MULTI_FORMAT_PLAN.md @@ -10,6 +10,37 @@ Claims are tagged like SOURCE_OF_TRUTH: --- +## 0. Where this stage stopped (2026-08-29) + +**Code: complete through Phase 4. Paper verification: not started.** + +- 🟒 **Done and committed** on `multiple-types-compatibility` (local only, + not pushed): `56afce6` (this roadmap) β†’ `d078d41` **p10** groundwork + (detection + processor registry + generalized uploads + conversion + lock) β†’ `b928e11` **p11** images β†’ `19a7223` **p12** office β†’ + `0be3eab` **p13** text/CSV. All four MVP categories are registered; + 193 automated tests pass at β‰ˆ97 % coverage (gate 90 %); ruff clean. + Office uploads currently return the kill-switch 415 because LibreOffice + is not installed β€” that is the designed behavior, not a bug. +- πŸ”΄ **The only thing between here and "multi-format MVP verified" is + Phase 0's hardware spikes**, run on the print-server PC in one sitting: + 1. `git pull` && `.venv\Scripts\pip install -r requirements.txt` && + restart the service && one real PDF print from the phone (regression). + 2. `python spike_t5_images.py --paper A4` (also verifies the driver + honors `-print-settings`). + 3. Install LibreOffice, then `python spike_t6_office.py`. + 4. `python spike_t7_text.py`. + Record results in SOURCE_OF_TRUTH Β§5 (T4 style). +- 🟑 **Config note:** `PAPER_SIZE` ships empty (the driver chooses paper β€” + the T4-proven behavior). Set e.g. `PAPER_SIZE=A4` only after T5's + `--paper A4` copy looks right. +- βšͺ **Phases 5–7 are NOT started** β€” Β§10 below describes them as designed + (queue management, reliability, print options UI), not as built. +- Housekeeping: the branch has not been pushed; CI has therefore not run + on p10–p13 yet. + +--- + ## 1. Executive summary β€” the 10 answers | # | Question | Decision | @@ -306,10 +337,16 @@ cut off at the right margin. ## 15. Open items -- [ ] Run T5/T6/T7 on the real print-server PC; record results in - SOURCE_OF_TRUTH Β§5. -- [ ] Confirm the target PC's Windows version before Phase 3 (pin LO 7.6.x - if Win 7/8.1). -- [ ] Phase 2: verify cancel cleanup covers non-PDF extensions. -- [ ] Phase 7: verify long-bond paper on the Epson driver (custom mm size - vs driver paper name). +- [x] Phase 1 refactor (p10), images (p11), office (p12), text/CSV (p13) β€” + code committed on `multiple-types-compatibility` (local only). +- [ ] Push/merge the branch so CI runs on p10–p13. +- [ ] Run T5/T6/T7 on the real print-server PC (T6 after installing + LibreOffice); record results in SOURCE_OF_TRUTH Β§5 β€” the only + remaining gate for the multi-format MVP. +- [ ] After T5 passes: decide whether to set `PAPER_SIZE=A4` in `.env`. +- [ ] Confirm the target PC's Windows version before enabling office + (pin LibreOffice 7.6.x if it's Win 7/8.1). +- [x] Cancel cleanup covers non-PDF extensions (centralized in + `uploads.delete_job_files` in p10, exercised by the API tests). +- [ ] Phase 7: verify long-bond paper (8.5Γ—13) on the Epson driver + (custom mm size vs driver paper name). diff --git a/docs/SOURCE_OF_TRUTH.md b/docs/SOURCE_OF_TRUTH.md index e21c150..58f9884 100644 --- a/docs/SOURCE_OF_TRUTH.md +++ b/docs/SOURCE_OF_TRUTH.md @@ -1,7 +1,7 @@ # Printer Service β€” Source of Truth **Project:** Android β†’ Network β†’ Python Service β†’ USB β†’ Epson L3210 -**Status:** βœ… MVP working end-to-end (spike T4 PASS: real page printed via SumatraPDF; phone β†’ service β†’ paper verified). βœ… Automated test suite (90 tests, 95%+ coverage gate) + ruff lint + GitHub Actions CI β€” service *logic* is verified on every push; hardware is verified by the spike on the real PC. Living document. Update this file whenever a decision changes. +**Status:** 🟒 **Multi-format MVP code-complete** (p10–p13 on branch `multiple-types-compatibility`: PDF + images + office + TXT/CSV all printable in code; 193 automated tests, β‰ˆ97 % coverage, ruff clean). πŸ”΄ **Paper verification for the NEW formats is still open** β€” hardware spikes T5/T6/T7 (scripts ready) have not been run, and LibreOffice is not yet installed, so office uploads currently get the kill-switch refusal (by design). T1–T4 remain 🟒 (real page printed via SumatraPDF). Commits are local to the branch β€” not yet pushed/merged. Living document. Update this file whenever a decision changes. **Audience:** Beginner learning networking, servers, and Python. **Quickstart & pre-setup checklist:** see the root [README.md](../README.md). @@ -196,6 +196,14 @@ None of these should be assumed to work out of the box on your specific old PC w πŸ”΅ **Decision:** **SumatraPDF** (`SumatraPDF.exe -print-to "" -silent `) is the primary PDF printing method; the print verb remains a code fallback (default printer only); PDFβ†’image conversion is the unimplemented last resort. Implemented in `app/printer/windows.py`; submission runs in a background thread (`app/services/pipeline.py`) so `POST /print` returns `"queued"` immediately and job status moves `queued β†’ done/failed`. **Status: confirmed working end-to-end via the spike (real page printed).** Re-confirm on the old PC at deploy time. +🟒 **Multi-format extension of the same decision (p10–p13):** every format is normalized to PDF *before* the print engine β€” format processors (images via Pillow, office via LibreOffice Headless, text/CSV via reportlab) feed the unchanged `submit_pdf()`, so SumatraPDF remains the only component that ever talks to the printer. The office converter is gated by `ENABLE_OFFICE` plus LibreOffice presence (the kill switch). Full decision record: [MULTI_FORMAT_PLAN.md](MULTI_FORMAT_PLAN.md). + +πŸ”΄ **PENDING SPIKES (code landed, hardware NOT yet verified β€” recorded 2026-08-29):** +- **T5 β€” images β†’ PDF β†’ paper** (`spike_t5_images.py --paper A4`): EXIF orientation, transparency β†’ white, fit/center on a white page, 300-DPI cap. The `--paper A4` copy verifies the driver honors `-print-settings` BEFORE `PAPER_SIZE` is ever set in `.env` (it ships empty on purpose). +- **T6 β€” office β†’ PDF β†’ paper** (`spike_t6_office.py`): requires installing LibreOffice FIRST (not installed as of this date). Table-heavy DOCX, print-area XLSX (landscape), 16:9 PPTX; also record conversion time and RAM on the ≀4 GB PC. +- **T7 β€” TXT/CSV β†’ PDF β†’ paper** (`spike_t7_text.py`): word-wrap/pagination and grid alignment with a repeated header row. +A PASS on all three closes the multi-format MVP's hardware verification β€” record the results here, T4-style. + --- ## 6. Android Side @@ -317,6 +325,45 @@ decision record, phased roadmap and spike protocol (T5–T7) live in [MULTI_FORMAT_PLAN.md](MULTI_FORMAT_PLAN.md); its hardware spikes extend Section 5's T1–T4 convention before any new format prints real paper. +**Status β€” where this stage stopped (2026-08-29):** 🟒 code complete +through Phase 4; πŸ”΄ the paper gates have not been run yet. + +- 🟒 **Done** (commits on `multiple-types-compatibility`, local only β€” + not pushed): + - `56afce6 docs:` decision record + roadmap (MULTI_FORMAT_PLAN.md). + - `d078d41 p10:` detection (magic bytes per format, extension + allowlist, macro rejection) + processor registry + generalized + uploads + conversion lock; `converting`/`printing` states now set. + - `b928e11 p11:` images via Pillow (EXIF, alphaβ†’white, fit/center, + 300-DPI cap, 10-page frame cap); `-print-settings` wired but + `PAPER_SIZE` defaults to empty = the T4-proven driver behavior. + - `19a7223 p12:` office via LibreOffice headless (fresh profile per + conversion, timeout + process-tree kill, `ENABLE_OFFICE` kill + switch; `available()` gate separates "later phase" from + "unavailable on this server"). + - `0be3eab p13:` text/CSV via reportlab (TXT wrap/paginate, CSV grid + with repeated header + truncation notices, verified decoding). + All four MVP categories registered. + - Suite: 193 tests, β‰ˆ97 % coverage (gate 90 %), ruff clean. Web page + and API accept every MVP extension. Office uploads currently return + the kill-switch 415 because LibreOffice is not installed β€” designed + behavior, not a bug. +- πŸ”΄ **Still open before this stage is "done":** + 1. Push/PR the branch so CI runs on the new commits (nothing pushed). + 2. On the print-server PC: `git pull`, `pip install -r + requirements.txt`, restart the service, one real PDF print from the + phone (regression check through the new pipeline). + 3. Run spikes T5, T6 (install LibreOffice first), T7; record results + in Section 5. + 4. Only after T5's `--paper A4` check passes: optionally set + `PAPER_SIZE=A4` in `.env`. +- βšͺ **Not started:** Phase 5 (queue management: cancel-while-converting, + spooler purge via `win32print.SetJob`, retry, SQLite persistence), + Phase 6 (reliability: printer pre-check, error catalog, log rotation), + Phase 7/v2 (print options UI: copies, page range, paper size, color + mode). These are described as designed in MULTI_FORMAT_PLAN.md Β§10, + not as built. + --- ## 10. Project Folder Structure πŸ”΅ @@ -398,17 +445,18 @@ The table above is the *hardware/network* test plan. On top of it sits an automa | What the suite verifies | How | |---|---| -| Upload validation: extension, `%PDF-` magic bytes, size limit β€” including the exact boundary (`>` vs `>=`) | Unit tests with parametrized inputs (table row #8, automated) | -| Job lifecycle `received β†’ queued β†’ done/failed/cancelled`, error/printer recording, cancellation rules | Unit tests against the in-memory store (row #6's locking, via a concurrency smoke test) | -| Print submission *decisions*: SumatraPDF command line, printer-name override, print-verb fallback and its "default printer only" refusal, loud failure without pywin32 | Unit tests with a **fake `win32print` module** injected into `sys.modules` and mocked `subprocess`/`os.startfile` | -| Whole-API behavior: `/health`, `/`, `/print` (201/401/413/415/500), `/jobs` CRUD, `/printers` (200/503/500) | API tests through FastAPI's `TestClient` β€” no network needed (rows #2/#3's logic) | -| Pipeline threading: `queued` visible while the thread runs, temp file deleted after `done`, kept after `failed` | Unit tests waiting on fakes with bounded polling (deterministic, no `sleep`) | +| Format detection & the upload gate: magic bytes per format (PDF, JPEG/PNG/WebP, ZIP containers sniffed for OOXML/ODF parts, OLE), extension allowlist (a lying `.exe` is refused even when it contains a PDF), macro-format policy rejection, the two availability gates (unregistered = "later phase", office kill switch = actionable message), size limit | Unit tests with parametrized inputs (table row #8, generalized to every format) | +| Format processors: images (EXIF orientation, alphaβ†’white, fit/center geometry, 300-DPI cap, frame cap β€” real Pillow), office (headless invocation shape, fresh profile per conversion, timeout β†’ process-tree kill, nonzero-exit / no-output mapping, kill switch β€” LibreOffice faked at `subprocess.Popen`), text/CSV (word-wrap, decode chain, grid widths, truncation notices β€” real reportlab) | Unit tests; produced PDFs are asserted at byte level (`%PDF-` magic, page-object counts, drawn text is visible because page compression is off) | +| Job lifecycle `received β†’ queued β†’ converting β†’ printing β†’ done/failed/cancelled`, error/printer recording, cancellation rules | Unit tests against the in-memory store (row #6's locking, via a concurrency smoke test) | +| Print submission *decisions*: SumatraPDF command line (incl. optional `-print-settings` when `PAPER_SIZE` is set), printer-name override, print-verb fallback and its "default printer only" refusal, loud failure without pywin32 | Unit tests with a **fake `win32print` module** injected into `sys.modules` and mocked `subprocess`/`os.startfile` | +| Whole-API behavior: `/health`, `/`, `/print` (201/401/413/415/500) for every category, `/jobs` CRUD, `/printers` (200/503/500) | API tests through FastAPI's `TestClient` β€” no network needed (rows #2/#3's logic) | +| Pipeline threading: `converting`/`printing` visible while the thread runs, temp files deleted after `done`, kept after `failed`, conversions serialized one-at-a-time by the conversion lock (the ≀4 GB guard) | Unit tests waiting on fakes with bounded polling (deterministic, no `sleep`) | -**Key design rule:** tests never touch machine state β€” no real printer, no real SumatraPDF, no real `.env`, real `uploads/` redirected to a temp dir. That's what lets the same suite pass on a Windows dev box and the Ubuntu CI runner. It's possible at all because `win32print` is imported lazily *inside* `app/printer/windows.py`'s functions β€” a v1 design choice (Section 4) that turned out to make CI possible for free. +**Key design rule:** tests never touch machine state β€” no real printer, no real SumatraPDF, no real LibreOffice, no real `.env`, real `uploads/` redirected to a temp dir. That's what lets the same suite pass on a Windows dev box and the Ubuntu CI runner. It's possible at all because `win32print` is imported lazily *inside* `app/printer/windows.py`'s functions β€” a v1 design choice (Section 4) that turned out to make CI possible for free. Pillow and reportlab, being Python libraries, run *inside* the tests; LibreOffice, being an external executable, is only ever faked. -**What stays manual:** anything physical or environmental β€” rows #1, #5, #7, #9, #10, #11, and the "did paper come out" half of #4/#5. `spike_print_test.py` on the actual print-server PC remains the hardware truth (Section 5). +**What stays manual:** anything physical or environmental β€” rows #1, #5, #7, #9, #10, #11, the "did paper come out" half of #4/#5, and the multi-format spikes T5/T6/T7 (Section 5). `spike_print_test.py` plus the three new spike scripts on the actual print-server PC remain the hardware truth. -**Quality gates in CI (`.github/workflows/ci.yml`, Ubuntu + Python 3.12):** `ruff check .` (lint only β€” no formatter enforcement, so working code never gets reformatted wholesale) then `pytest` with `--cov-fail-under=90` (configured in `pyproject.toml`; measured coverage β‰ˆ95%, the gap is mostly `logging_setup.py`, which tests deliberately don't execute to keep global logging state pristine). The same two commands run locally from the project root after `pip install -r requirements-dev.txt`. +**Quality gates in CI (`.github/workflows/ci.yml`, Ubuntu + Python 3.12):** `ruff check .` (lint only β€” no formatter enforcement, so working code never gets reformatted wholesale) then `pytest` with `--cov-fail-under=90` (configured in `pyproject.toml`; measured coverage β‰ˆ97 % across 193 tests β€” the gap is mostly `logging_setup.py`, which tests deliberately don't execute to keep global logging state pristine). The same two commands run locally from the project root after `pip install -r requirements-dev.txt`. --- @@ -516,8 +564,8 @@ This MVP deliberately has **no database, no authentication beyond "same Wi-Fi ne ## Open Items Requiring Testing (Summary) -- 🟒 **Resolved (spike run):** the spooler path works and the printer is detected; the Windows "print" verb has no PDF handler on the tested machine, so **SumatraPDF is the chosen PDF method** (Section 5 now records the decision). Still to confirm: T4 PASS after installing SumatraPDF, then a real end-to-end print β€” on this machine and again on the old PC at deploy time. -- πŸ”΄ Confirm `pywin32` correctly detects and can submit jobs to the specific Epson L3210 driver installed on your PC (Section 4, Section 9 Phase 4). *(Spike T1/T2 confirm detection and spooler acceptance.)* +- 🟒 **Resolved (spikes run):** the spooler path works, the printer is detected, and the Windows "print" verb has no PDF handler on the tested machine β€” so **SumatraPDF is the chosen print engine** (Section 5 records the decision and the T4 PASS with real paper). Still worth re-confirming the whole chain on the old PC at deploy time. +- πŸ”΄ **Multi-format hardware gates (p10–p13 code landed, NOT yet verified on paper β€” 2026-08-29):** on the print-server PC run `spike_t5_images.py --paper A4`, then install LibreOffice and run `spike_t6_office.py`, then `spike_t7_text.py`; record results in Section 5. Until T5 passes, `PAPER_SIZE` must stay empty; until T6, office uploads are refused by the kill switch (expected behavior). Also push/merge branch `multiple-types-compatibility` so CI runs on the new commits. - πŸ”΄ If you later pursue Option C (Android's native `PrintService` framework, Section 6), treat IPP support and the `PrintService` implementation itself as a separate research phase β€” do not assume it's a small extension of the MVP. --- From 1f1a1f42cbc5a3e5fe2bc92265a649559ec4c723 Mon Sep 17 00:00:00 2001 From: geb Date: Sat, 29 Aug 2026 14:28:55 +0800 Subject: [PATCH 07/16] fix: spike_t5 --paper accepts uppercase (A4 -> a4) via str.lower --- spike_t5_images.py | 1 + 1 file changed, 1 insertion(+) diff --git a/spike_t5_images.py b/spike_t5_images.py index 0afb040..478f798 100644 --- a/spike_t5_images.py +++ b/spike_t5_images.py @@ -120,6 +120,7 @@ def main() -> int: parser.add_argument( "--paper", choices=["a4", "letter", "legal", "a5", "a3"], + type=str.lower, help="also print one copy with -print-settings paper=,fit", ) args = parser.parse_args() From 601e721833f95d2c6d85c38d02c56a786a308abb Mon Sep 17 00:00:00 2001 From: geb Date: Sat, 29 Aug 2026 14:35:53 +0800 Subject: [PATCH 08/16] docs: record T5 and T7 PASS on real paper - T6 (office) is the last open gate Spikes run on the print-server PC (2026-08-29): 4 image conversions 0.19-0.35s each, transparency corners white, EXIF upright, paper=A4 via -print-settings honored by the Epson driver (PAPER_SIZE now safe to set); TXT wrap and 40x6 CSV grid judged good on paper. Branch pushed, CI runs on PR #1. Only T6 (office, needs LibreOffice) remains open. --- README.md | 2 +- docs/MULTI_FORMAT_PLAN.md | 64 ++++++++++++++++++--------------------- docs/SOURCE_OF_TRUTH.md | 40 ++++++++++++------------ 3 files changed, 52 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 456267e..260e37b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://github.com/samananias/printerService/actions/workflows/ci.yml/badge.svg)](https://github.com/samananias/printerService/actions/workflows/ci.yml) **Project:** Android phone β†’ Wi-Fi β†’ Python service (this PC) β†’ Windows print queue β†’ USB β†’ Epson L3210 -**Status:** βœ… **PDF MVP working end-to-end** (phone β†’ service β†’ paper, spike T4). βœ… **Multi-format code-complete** (p10–p13: JPG/PNG/WebP images, DOCX/XLSX/PPTX/ODF office, TXT/CSV β€” office needs LibreOffice installed, otherwise it's refused with a clear message). βœ… 193 automated tests (~97 % coverage) + ruff + CI verify the logic on every push. πŸ”΄ The new formats' paper checks (spikes T5/T6/T7) are still pending β€” run them on the print-server PC before trusting the output; scripts and instructions in `docs/MULTI_FORMAT_PLAN.md` Β§14. +**Status:** βœ… **PDF MVP working end-to-end** (phone β†’ service β†’ paper, spike T4). βœ… **Multi-format code-complete** (p10–p13: JPG/PNG/WebP images, DOCX/XLSX/PPTX/ODF office, TXT/CSV) and **T5 (images) + T7 (TXT/CSV) verified on real paper** (2026-08-29). βœ… 193 automated tests (~97 % coverage) + ruff + CI. πŸ”΄ Only **T6 (office)** still pending β€” install LibreOffice, run `spike_t6_office.py` (office uploads are refused with a clear message until then). **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. **Multi-format roadmap & exact stopping point:** [docs/MULTI_FORMAT_PLAN.md](docs/MULTI_FORMAT_PLAN.md) ("Where this stage stopped") and [docs/SOURCE_OF_TRUTH.md](docs/SOURCE_OF_TRUTH.md) Β§9. diff --git a/docs/MULTI_FORMAT_PLAN.md b/docs/MULTI_FORMAT_PLAN.md index 7c2025e..8768fc7 100644 --- a/docs/MULTI_FORMAT_PLAN.md +++ b/docs/MULTI_FORMAT_PLAN.md @@ -10,34 +10,29 @@ Claims are tagged like SOURCE_OF_TRUTH: --- -## 0. Where this stage stopped (2026-08-29) - -**Code: complete through Phase 4. Paper verification: not started.** - -- 🟒 **Done and committed** on `multiple-types-compatibility` (local only, - not pushed): `56afce6` (this roadmap) β†’ `d078d41` **p10** groundwork - (detection + processor registry + generalized uploads + conversion - lock) β†’ `b928e11` **p11** images β†’ `19a7223` **p12** office β†’ - `0be3eab` **p13** text/CSV. All four MVP categories are registered; - 193 automated tests pass at β‰ˆ97 % coverage (gate 90 %); ruff clean. - Office uploads currently return the kill-switch 415 because LibreOffice - is not installed β€” that is the designed behavior, not a bug. -- πŸ”΄ **The only thing between here and "multi-format MVP verified" is - Phase 0's hardware spikes**, run on the print-server PC in one sitting: - 1. `git pull` && `.venv\Scripts\pip install -r requirements.txt` && - restart the service && one real PDF print from the phone (regression). - 2. `python spike_t5_images.py --paper A4` (also verifies the driver - honors `-print-settings`). - 3. Install LibreOffice, then `python spike_t6_office.py`. - 4. `python spike_t7_text.py`. - Record results in SOURCE_OF_TRUTH Β§5 (T4 style). -- 🟑 **Config note:** `PAPER_SIZE` ships empty (the driver chooses paper β€” - the T4-proven behavior). Set e.g. `PAPER_SIZE=A4` only after T5's - `--paper A4` copy looks right. +## 0. Where this stage stopped (updated 2026-08-29) + +**Code: complete through Phase 4. Paper verification: images βœ“, text/CSV βœ“, office pending (needs LibreOffice).** + +- 🟒 **Done and committed** on `multiple-types-compatibility` (pushed; + PR #1 open, CI running): `56afce6` (this roadmap) β†’ `d078d41` **p10** + groundwork (detection + processor registry + generalized uploads + + conversion lock) β†’ `b928e11` **p11** images β†’ `19a7223` **p12** office β†’ + `0be3eab` **p13** text/CSV β†’ `5527c7c` docs β†’ `1f1a1f4` spike fix. All + four MVP categories are registered; 193 automated tests pass at β‰ˆ97 % + coverage (gate 90 %); ruff clean. +- βœ… **Spikes T5 (images) and T7 (TXT/CSV) PASSED on real paper** + (2026-08-29; details in SOURCE_OF_TRUTH Β§5). The T5 `--paper A4` copy + confirmed the Epson driver honors `-print-settings`, so `PAPER_SIZE` + may now be set in `.env` (optional; empty = driver chooses). +- πŸ”΄ **The last open MVP gate is T6 (office):** install LibreOffice on the + print-server PC, run `spike_t6_office.py`, judge the paper (table fits, + only the print area prints in landscape, 16:9 slides landscape), record + results in SOURCE_OF_TRUTH Β§5. Until then office uploads keep getting + the kill-switch 415 β€” designed behavior. - βšͺ **Phases 5–7 are NOT started** β€” Β§10 below describes them as designed (queue management, reliability, print options UI), not as built. -- Housekeeping: the branch has not been pushed; CI has therefore not run - on p10–p13 yet. +- Housekeeping: CI runs on PR #1; merge when green. --- @@ -338,15 +333,16 @@ cut off at the right margin. ## 15. Open items - [x] Phase 1 refactor (p10), images (p11), office (p12), text/CSV (p13) β€” - code committed on `multiple-types-compatibility` (local only). -- [ ] Push/merge the branch so CI runs on p10–p13. -- [ ] Run T5/T6/T7 on the real print-server PC (T6 after installing - LibreOffice); record results in SOURCE_OF_TRUTH Β§5 β€” the only - remaining gate for the multi-format MVP. -- [ ] After T5 passes: decide whether to set `PAPER_SIZE=A4` in `.env`. + code committed and pushed. +- [x] Branch pushed; CI runs on PR #1 β€” merge when green. +- [x] T5 images spike PASSED on real paper (2026-08-29), including the + `paper=A4` driver check. +- [x] T7 text/CSV spike PASSED on real paper (2026-08-29). +- [ ] **T6 office spike** β€” install LibreOffice first; the last open gate + of the multi-format MVP. +- [ ] Set `PAPER_SIZE=A4` in `.env` whenever desired (verified by T5; + optional β€” empty = driver chooses). - [ ] Confirm the target PC's Windows version before enabling office (pin LibreOffice 7.6.x if it's Win 7/8.1). -- [x] Cancel cleanup covers non-PDF extensions (centralized in - `uploads.delete_job_files` in p10, exercised by the API tests). - [ ] Phase 7: verify long-bond paper (8.5Γ—13) on the Epson driver (custom mm size vs driver paper name). diff --git a/docs/SOURCE_OF_TRUTH.md b/docs/SOURCE_OF_TRUTH.md index 58f9884..5293aef 100644 --- a/docs/SOURCE_OF_TRUTH.md +++ b/docs/SOURCE_OF_TRUTH.md @@ -1,7 +1,7 @@ # Printer Service β€” Source of Truth **Project:** Android β†’ Network β†’ Python Service β†’ USB β†’ Epson L3210 -**Status:** 🟒 **Multi-format MVP code-complete** (p10–p13 on branch `multiple-types-compatibility`: PDF + images + office + TXT/CSV all printable in code; 193 automated tests, β‰ˆ97 % coverage, ruff clean). πŸ”΄ **Paper verification for the NEW formats is still open** β€” hardware spikes T5/T6/T7 (scripts ready) have not been run, and LibreOffice is not yet installed, so office uploads currently get the kill-switch refusal (by design). T1–T4 remain 🟒 (real page printed via SumatraPDF). Commits are local to the branch β€” not yet pushed/merged. Living document. Update this file whenever a decision changes. +**Status:** 🟒 **Multi-format MVP code-complete** (p10–p13 on branch `multiple-types-compatibility`: PDF + images + office + TXT/CSV all printable in code; 193 automated tests, β‰ˆ97 % coverage, ruff clean). βœ… **T5 (images) and T7 (TXT/CSV) PASSED on real paper** (2026-08-29). πŸ”΄ **Only T6 (office) remains** β€” LibreOffice is not installed yet, so office uploads currently get the kill-switch refusal (by design). Branch pushed; PR #1 open; CI running. Living document. Update this file whenever a decision changes. **Audience:** Beginner learning networking, servers, and Python. **Quickstart & pre-setup checklist:** see the root [README.md](../README.md). @@ -198,11 +198,10 @@ None of these should be assumed to work out of the box on your specific old PC w 🟒 **Multi-format extension of the same decision (p10–p13):** every format is normalized to PDF *before* the print engine β€” format processors (images via Pillow, office via LibreOffice Headless, text/CSV via reportlab) feed the unchanged `submit_pdf()`, so SumatraPDF remains the only component that ever talks to the printer. The office converter is gated by `ENABLE_OFFICE` plus LibreOffice presence (the kill switch). Full decision record: [MULTI_FORMAT_PLAN.md](MULTI_FORMAT_PLAN.md). -πŸ”΄ **PENDING SPIKES (code landed, hardware NOT yet verified β€” recorded 2026-08-29):** -- **T5 β€” images β†’ PDF β†’ paper** (`spike_t5_images.py --paper A4`): EXIF orientation, transparency β†’ white, fit/center on a white page, 300-DPI cap. The `--paper A4` copy verifies the driver honors `-print-settings` BEFORE `PAPER_SIZE` is ever set in `.env` (it ships empty on purpose). -- **T6 β€” office β†’ PDF β†’ paper** (`spike_t6_office.py`): requires installing LibreOffice FIRST (not installed as of this date). Table-heavy DOCX, print-area XLSX (landscape), 16:9 PPTX; also record conversion time and RAM on the ≀4 GB PC. -- **T7 β€” TXT/CSV β†’ PDF β†’ paper** (`spike_t7_text.py`): word-wrap/pagination and grid alignment with a repeated header row. -A PASS on all three closes the multi-format MVP's hardware verification β€” record the results here, T4-style. +🟒 **SPIKE RESULTS for the new formats (run on the print-server PC, 2026-08-29):** +- **T5 PASS** (`spike_t5_images.py --paper A4`) β€” 4 test images (JPEG gradient, PNG with transparency, WebP, EXIF-rotated JPEG) converted by the real ImageProcessor in 0.19–0.35 s each and printed via SumatraPDF to `EPSON L3210 Series`. Paper judged good: all pages upright, nothing clipped, transparency corners white (not black), EXIF image upright. The extra `-print-settings "paper=A4,fit"` page also came out A4 β†’ **the driver honors the flag**, so `PAPER_SIZE` may now be set in `.env` if wanted (still optional; empty = driver chooses). +- **T7 PASS** (`spike_t7_text.py`) β€” TXT (wrap + pagination) and a 40Γ—6 CSV with quoted cells rendered by the real TextProcessor and printed. Paper judged good: margins clean, nothing clipped, grid/borders drawn. +- πŸ”΄ **T6 β€” STILL PENDING:** LibreOffice is not installed on the print-server PC yet, so office uploads currently get the kill-switch 415 (by design). Install LibreOffice β†’ run `spike_t6_office.py` (table-heavy DOCX, print-area XLSX, 16:9 PPTX; note conversion time/RAM) β†’ record results here. **T6 is the last open gate of the multi-format MVP.** --- @@ -325,11 +324,12 @@ decision record, phased roadmap and spike protocol (T5–T7) live in [MULTI_FORMAT_PLAN.md](MULTI_FORMAT_PLAN.md); its hardware spikes extend Section 5's T1–T4 convention before any new format prints real paper. -**Status β€” where this stage stopped (2026-08-29):** 🟒 code complete -through Phase 4; πŸ”΄ the paper gates have not been run yet. +**Status β€” where this stage stopped (updated 2026-08-29, same day):** 🟒 code +complete through Phase 4; paper gates: **T5 PASS, T7 PASS, T6 pending** +(needs LibreOffice). -- 🟒 **Done** (commits on `multiple-types-compatibility`, local only β€” - not pushed): +- 🟒 **Done** (commits on `multiple-types-compatibility`, pushed; PR #1 + open, CI running): - `56afce6 docs:` decision record + roadmap (MULTI_FORMAT_PLAN.md). - `d078d41 p10:` detection (magic bytes per format, extension allowlist, macro rejection) + processor registry + generalized @@ -348,15 +348,17 @@ through Phase 4; πŸ”΄ the paper gates have not been run yet. and API accept every MVP extension. Office uploads currently return the kill-switch 415 because LibreOffice is not installed β€” designed behavior, not a bug. +- βœ… **Since the morning of 2026-08-29:** branch pushed (PR #1 open, CI + running); spikes **T5 PASS** and **T7 PASS** on real paper (recorded in + Section 5); spike `--paper` argument fix pushed. - πŸ”΄ **Still open before this stage is "done":** - 1. Push/PR the branch so CI runs on the new commits (nothing pushed). - 2. On the print-server PC: `git pull`, `pip install -r - requirements.txt`, restart the service, one real PDF print from the - phone (regression check through the new pipeline). - 3. Run spikes T5, T6 (install LibreOffice first), T7; record results - in Section 5. - 4. Only after T5's `--paper A4` check passes: optionally set - `PAPER_SIZE=A4` in `.env`. + 1. **T6 only:** install LibreOffice on the print-server PC, run + `spike_t6_office.py`, record results in Section 5. + 2. `PAPER_SIZE` may now be set in `.env` (e.g. `A4`) β€” T5 verified the + driver honors `-print-settings`; still optional (empty = driver + chooses). + 3. One real print of each format from the phone once the service runs + with LibreOffice installed (the phone-side regression check). - βšͺ **Not started:** Phase 5 (queue management: cancel-while-converting, spooler purge via `win32print.SetJob`, retry, SQLite persistence), Phase 6 (reliability: printer pre-check, error catalog, log rotation), @@ -565,7 +567,7 @@ This MVP deliberately has **no database, no authentication beyond "same Wi-Fi ne ## Open Items Requiring Testing (Summary) - 🟒 **Resolved (spikes run):** the spooler path works, the printer is detected, and the Windows "print" verb has no PDF handler on the tested machine β€” so **SumatraPDF is the chosen print engine** (Section 5 records the decision and the T4 PASS with real paper). Still worth re-confirming the whole chain on the old PC at deploy time. -- πŸ”΄ **Multi-format hardware gates (p10–p13 code landed, NOT yet verified on paper β€” 2026-08-29):** on the print-server PC run `spike_t5_images.py --paper A4`, then install LibreOffice and run `spike_t6_office.py`, then `spike_t7_text.py`; record results in Section 5. Until T5 passes, `PAPER_SIZE` must stay empty; until T6, office uploads are refused by the kill switch (expected behavior). Also push/merge branch `multiple-types-compatibility` so CI runs on the new commits. +- 🟑 **Multi-format hardware gates (updated 2026-08-29):** **T5 (images) and T7 (TXT/CSV) PASS** on real paper β€” see Section 5. πŸ”΄ Only **T6 (office)** remains: install LibreOffice on the print-server PC, run `spike_t6_office.py`, record results here. `PAPER_SIZE` may now be set in `.env` (the driver honors the flag, verified by T5). Branch pushed; CI runs on PR #1. - πŸ”΄ If you later pursue Option C (Android's native `PrintService` framework, Section 6), treat IPP support and the `PrintService` implementation itself as a separate research phase β€” do not assume it's a small extension of the MVP. --- From 00e0b58d04681c2784e09ea84e3c30088dd432e9 Mon Sep 17 00:00:00 2001 From: geb Date: Sat, 29 Aug 2026 15:13:22 +0800 Subject: [PATCH 09/16] docs: CLI install recipe for LibreOffice (curl direct MSI, faster than winget) --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 260e37b..62c0854 100644 --- a/README.md +++ b/README.md @@ -40,13 +40,22 @@ | **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 | -| **LibreOffice** *(optional)* | Office documents (DOCX/XLSX/PPTX/ODF) are converted to PDF through it. Without it, office uploads are refused with a clear message β€” everything else keeps working | [libreoffice.org](https://www.libreoffice.org) or `winget install TheDocumentFoundation.LibreOffice`. Verify: `soffice --version` in a terminal (or just restart the service after installing) | +| **LibreOffice** *(optional)* | Office documents (DOCX/XLSX/PPTX/ODF) are converted to PDF through it. Without it, office uploads are refused with a clear message β€” everything else keeps working | Install via CLI (below) or [libreoffice.org](https://www.libreoffice.org). Verify: `soffice --version` in a terminal (or just restart the service after installing) | | **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*) | ### The phone Nothing to install β€” any browser. Same Wi-Fi network as the service PC (guest networks usually isolate devices β€” a classic silent failure). +**LibreOffice via CLI** (faster than winget, which can crawl on its CDN): download the MSI with the built-in `curl`, then run the installer. Swap in the current stable version from [libreoffice.org/download](https://www.libreoffice.org/download/download-libreoffice/) β€” the pattern is `LibreOffice__Win_x86-64.msi` under `stable//win/x86_64/`: + +```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" +``` + +Default "Typical" install is fine β€” it lands in `C:\Program Files\LibreOffice\program\`, which the service searches automatically (set `LO_PATH` in `.env` only for non-standard locations). + --- ## 2. One-Time Setup From e27845079e32c2b89d9d474e6fad2bf7c80e625c Mon Sep 17 00:00:00 2001 From: geb Date: Sat, 29 Aug 2026 15:36:22 +0800 Subject: [PATCH 10/16] docs: T6 PASS - multi-format MVP fully verified on real hardware LibreOffice 26.8.0 installed via the new CLI recipe; spike_t6_office converted a table-heavy DOCX (20.8s), a print-area XLSX (10.5s, only A1:D20 printed, landscape) and a 16:9 PPTX (10.9s) - paper judged good. With T4/T5/T6/T7 all PASS, every supported format is verified end-to-end on the Epson L3210. Windows 11 confirmed (no LO 7.6.x pin needed). Next: Phase 5 (queue management). --- README.md | 50 +++++++++++++++++++++++++++------ docs/MULTI_FORMAT_PLAN.md | 58 +++++++++++++++++++-------------------- docs/SOURCE_OF_TRUTH.md | 35 +++++++++++------------ 3 files changed, 85 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 62c0854..8e829e1 100644 --- a/README.md +++ b/README.md @@ -60,34 +60,57 @@ Default "Typical" install is fine β€” it lands in `C:\Program Files\LibreOffice\ ## 2. One-Time Setup -From the project folder: +From the project root folder, choose your terminal: +### 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 +pip install -r requirements.txt # install dependencies copy .env.example .env # local config (see Β§5; defaults are fine) ``` -> PowerShell blocked `Activate.ps1`? Run once, then retry: +> **PowerShell blocked `Activate.ps1`?** Run once, then retry: > `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser` -> Git Bash activation: `source .venv/Scripts/activate` + +### Option B: Git Bash +```bash +python -m venv .venv # create an isolated Python environment +source .venv/Scripts/activate # activate it β€” use 'source' and forward slashes '/' +pip install -r requirements.txt # install dependencies +cp .env.example .env # local config (see Β§5; defaults are fine) +``` + +> ⚠️ **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 +From the project root folder: + +### PowerShell / Command Prompt ```powershell .venv\Scripts\activate uvicorn app.main:app --host 0.0.0.0 --port 8000 ``` +*Or no-activation one-liner:* +```powershell +.venv\Scripts\python.exe -m uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +### Git Bash +```bash +source .venv/Scripts/activate +uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` +*Or no-activation one-liner:* +```bash +.venv/Scripts/python.exe -m 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. @@ -185,8 +208,9 @@ Full troubleshooting table: [docs/SOURCE_OF_TRUTH.md](docs/SOURCE_OF_TRUTH.md) S ## Run the Checks (what CI runs) -Changed the service code? Two commands verify the logic β€” without printing anything: +Changed the service code? Verify the logic β€” without printing anything: +### PowerShell / Command Prompt ```powershell .venv\Scripts\activate pip install -r requirements-dev.txt # once per machine @@ -194,6 +218,14 @@ ruff check . # lint: unused imports, undefined names, s pytest # the suite + coverage report (fails below the 90% gate) ``` +### Git Bash +```bash +source .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) +``` + - **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. diff --git a/docs/MULTI_FORMAT_PLAN.md b/docs/MULTI_FORMAT_PLAN.md index 8768fc7..c5d471d 100644 --- a/docs/MULTI_FORMAT_PLAN.md +++ b/docs/MULTI_FORMAT_PLAN.md @@ -10,29 +10,26 @@ Claims are tagged like SOURCE_OF_TRUTH: --- -## 0. Where this stage stopped (updated 2026-08-29) - -**Code: complete through Phase 4. Paper verification: images βœ“, text/CSV βœ“, office pending (needs LibreOffice).** - -- 🟒 **Done and committed** on `multiple-types-compatibility` (pushed; - PR #1 open, CI running): `56afce6` (this roadmap) β†’ `d078d41` **p10** - groundwork (detection + processor registry + generalized uploads + - conversion lock) β†’ `b928e11` **p11** images β†’ `19a7223` **p12** office β†’ - `0be3eab` **p13** text/CSV β†’ `5527c7c` docs β†’ `1f1a1f4` spike fix. All - four MVP categories are registered; 193 automated tests pass at β‰ˆ97 % - coverage (gate 90 %); ruff clean. -- βœ… **Spikes T5 (images) and T7 (TXT/CSV) PASSED on real paper** - (2026-08-29; details in SOURCE_OF_TRUTH Β§5). The T5 `--paper A4` copy - confirmed the Epson driver honors `-print-settings`, so `PAPER_SIZE` - may now be set in `.env` (optional; empty = driver chooses). -- πŸ”΄ **The last open MVP gate is T6 (office):** install LibreOffice on the - print-server PC, run `spike_t6_office.py`, judge the paper (table fits, - only the print area prints in landscape, 16:9 slides landscape), record - results in SOURCE_OF_TRUTH Β§5. Until then office uploads keep getting - the kill-switch 415 β€” designed behavior. -- βšͺ **Phases 5–7 are NOT started** β€” Β§10 below describes them as designed - (queue management, reliability, print options UI), not as built. -- Housekeeping: CI runs on PR #1; merge when green. +## 0. Where this stage stopped (closed 2026-08-29) + +**MVP VERIFIED: code complete through Phase 4 AND all paper gates passed.** + +- 🟒 **Done and pushed** on `multiple-types-compatibility` (PR #1 open): + `56afce6` (this roadmap) β†’ `d078d41` **p10** groundwork β†’ `b928e11` + **p11** images β†’ `19a7223` **p12** office β†’ `0be3eab` **p13** text/CSV β†’ + docs/status commits β†’ `1f1a1f4` spike fix. 193 automated tests at β‰ˆ97 % + coverage; ruff clean. +- βœ… **Hardware gates all PASS on the Epson L3210** (2026-08-29): + **T4** (PDF, earlier) Β· **T5** (images + `paper=A4` driver check) Β· + **T6** (office via LibreOffice 26.8.0: DOCX 20.8 s, XLSX 10.5 s, PPTX + 10.9 s β€” print area honored) Β· **T7** (TXT/CSV). Details in + SOURCE_OF_TRUTH Β§5. +- 🟑 **Follow-ups:** merge PR #1 when green; optionally set + `PAPER_SIZE=A4` in `.env` (verified safe by T5); a phone smile-check of + each format; LibreOffice install recipe now lives in README Β§1. +- βšͺ **Next stage: Phase 5** (queue management: cancel-while-converting, + spooler purge, retry, SQLite persistence + startup recovery) β€” then + Phase 6 (reliability) and Phase 7/v2 (print options UI). --- @@ -335,14 +332,15 @@ cut off at the right margin. - [x] Phase 1 refactor (p10), images (p11), office (p12), text/CSV (p13) β€” code committed and pushed. - [x] Branch pushed; CI runs on PR #1 β€” merge when green. -- [x] T5 images spike PASSED on real paper (2026-08-29), including the - `paper=A4` driver check. -- [x] T7 text/CSV spike PASSED on real paper (2026-08-29). -- [ ] **T6 office spike** β€” install LibreOffice first; the last open gate - of the multi-format MVP. +- [x] **T6 office spike PASSED** (2026-08-29, LibreOffice 26.8.0): print + area honored in landscape, table fits, slides landscape β€” the last + MVP gate is closed. +- [x] T5 images spike PASSED (2026-08-29), including the `paper=A4` + driver check. Β· [x] T7 text/CSV spike PASSED (2026-08-29). +- [ ] Merge PR #1 into `main` when CI is green. - [ ] Set `PAPER_SIZE=A4` in `.env` whenever desired (verified by T5; optional β€” empty = driver chooses). -- [ ] Confirm the target PC's Windows version before enabling office - (pin LibreOffice 7.6.x if it's Win 7/8.1). +- [x] Windows version confirmed: Windows 11 (current LibreOffice fine, + no 7.6.x pin needed). - [ ] Phase 7: verify long-bond paper (8.5Γ—13) on the Epson driver (custom mm size vs driver paper name). diff --git a/docs/SOURCE_OF_TRUTH.md b/docs/SOURCE_OF_TRUTH.md index 5293aef..4603bc7 100644 --- a/docs/SOURCE_OF_TRUTH.md +++ b/docs/SOURCE_OF_TRUTH.md @@ -1,7 +1,7 @@ # Printer Service β€” Source of Truth **Project:** Android β†’ Network β†’ Python Service β†’ USB β†’ Epson L3210 -**Status:** 🟒 **Multi-format MVP code-complete** (p10–p13 on branch `multiple-types-compatibility`: PDF + images + office + TXT/CSV all printable in code; 193 automated tests, β‰ˆ97 % coverage, ruff clean). βœ… **T5 (images) and T7 (TXT/CSV) PASSED on real paper** (2026-08-29). πŸ”΄ **Only T6 (office) remains** β€” LibreOffice is not installed yet, so office uploads currently get the kill-switch refusal (by design). Branch pushed; PR #1 open; CI running. Living document. Update this file whenever a decision changes. +**Status:** 🟒 **Multi-format MVP VERIFIED on real hardware** (2026-08-29): p10–p13 code + spikes **T4/T5/T6/T7 all PASS** on the Epson L3210 β€” PDF, images, office, TXT/CSV all print end-to-end. 193 automated tests, β‰ˆ97 % coverage, ruff clean. Branch pushed; PR #1 open; CI running. Living document. Update this file whenever a decision changes. **Audience:** Beginner learning networking, servers, and Python. **Quickstart & pre-setup checklist:** see the root [README.md](../README.md). @@ -201,7 +201,9 @@ None of these should be assumed to work out of the box on your specific old PC w 🟒 **SPIKE RESULTS for the new formats (run on the print-server PC, 2026-08-29):** - **T5 PASS** (`spike_t5_images.py --paper A4`) β€” 4 test images (JPEG gradient, PNG with transparency, WebP, EXIF-rotated JPEG) converted by the real ImageProcessor in 0.19–0.35 s each and printed via SumatraPDF to `EPSON L3210 Series`. Paper judged good: all pages upright, nothing clipped, transparency corners white (not black), EXIF image upright. The extra `-print-settings "paper=A4,fit"` page also came out A4 β†’ **the driver honors the flag**, so `PAPER_SIZE` may now be set in `.env` if wanted (still optional; empty = driver chooses). - **T7 PASS** (`spike_t7_text.py`) β€” TXT (wrap + pagination) and a 40Γ—6 CSV with quoted cells rendered by the real TextProcessor and printed. Paper judged good: margins clean, nothing clipped, grid/borders drawn. -- πŸ”΄ **T6 β€” STILL PENDING:** LibreOffice is not installed on the print-server PC yet, so office uploads currently get the kill-switch 415 (by design). Install LibreOffice β†’ run `spike_t6_office.py` (table-heavy DOCX, print-area XLSX, 16:9 PPTX; note conversion time/RAM) β†’ record results here. **T6 is the last open gate of the multi-format MVP.** +- **T6 PASS** (`spike_t6_office.py`, 2026-08-29) β€” LibreOffice 26.8.0 installed on the print-server PC (Windows 11 β€” no 7.6.x pin needed; installed via the CLI curl recipe now in README Β§1 after winget stalled). Table-heavy DOCX (20.8 s), print-area XLSX in landscape (10.5 s) and 16:9 PPTX (10.9 s) converted by the real OfficeProcessor and printed β€” well under the 120 s `CONVERT_TIMEOUT_S`. Paper judged good: table fits with borders, **only the print area printed** (the "OUTSIDE" cell stayed out), slides landscape. + +🟒 **With T4/T5/T6/T7 all PASS, the multi-format MVP is fully verified on real hardware β€” every supported format prints end-to-end.** --- @@ -324,9 +326,7 @@ decision record, phased roadmap and spike protocol (T5–T7) live in [MULTI_FORMAT_PLAN.md](MULTI_FORMAT_PLAN.md); its hardware spikes extend Section 5's T1–T4 convention before any new format prints real paper. -**Status β€” where this stage stopped (updated 2026-08-29, same day):** 🟒 code -complete through Phase 4; paper gates: **T5 PASS, T7 PASS, T6 pending** -(needs LibreOffice). +**Status β€” stage CLOSED (2026-08-29): 🟒 code complete through Phase 4 AND all paper gates passed (T5/T6/T7). The multi-format MVP is verified.** - 🟒 **Done** (commits on `multiple-types-compatibility`, pushed; PR #1 open, CI running): @@ -349,22 +349,19 @@ complete through Phase 4; paper gates: **T5 PASS, T7 PASS, T6 pending** the kill-switch 415 because LibreOffice is not installed β€” designed behavior, not a bug. - βœ… **Since the morning of 2026-08-29:** branch pushed (PR #1 open, CI - running); spikes **T5 PASS** and **T7 PASS** on real paper (recorded in - Section 5); spike `--paper` argument fix pushed. -- πŸ”΄ **Still open before this stage is "done":** - 1. **T6 only:** install LibreOffice on the print-server PC, run - `spike_t6_office.py`, record results in Section 5. + running); **T5 PASS, T7 PASS, then LibreOffice 26.8.0 installed (CLI + curl recipe) and T6 PASS** β€” all recorded in Section 5. +- βœ… **Remaining follow-ups (non-blocking):** + 1. Merge PR #1 into `main` when CI is green. 2. `PAPER_SIZE` may now be set in `.env` (e.g. `A4`) β€” T5 verified the driver honors `-print-settings`; still optional (empty = driver chooses). - 3. One real print of each format from the phone once the service runs - with LibreOffice installed (the phone-side regression check). -- βšͺ **Not started:** Phase 5 (queue management: cancel-while-converting, - spooler purge via `win32print.SetJob`, retry, SQLite persistence), - Phase 6 (reliability: printer pre-check, error catalog, log rotation), - Phase 7/v2 (print options UI: copies, page range, paper size, color - mode). These are described as designed in MULTI_FORMAT_PLAN.md Β§10, - not as built. + 3. One real print of each format from the phone as a final smile-check + now that LibreOffice is installed. +- βšͺ **Next stage:** Phase 5 (queue management: cancel-while-converting, + spooler purge via `win32print.SetJob`, retry, SQLite persistence + + startup recovery), then Phase 6 (reliability), Phase 7/v2 (print + options UI). Designed in MULTI_FORMAT_PLAN.md Β§10, not yet built. --- @@ -567,7 +564,7 @@ This MVP deliberately has **no database, no authentication beyond "same Wi-Fi ne ## Open Items Requiring Testing (Summary) - 🟒 **Resolved (spikes run):** the spooler path works, the printer is detected, and the Windows "print" verb has no PDF handler on the tested machine β€” so **SumatraPDF is the chosen print engine** (Section 5 records the decision and the T4 PASS with real paper). Still worth re-confirming the whole chain on the old PC at deploy time. -- 🟑 **Multi-format hardware gates (updated 2026-08-29):** **T5 (images) and T7 (TXT/CSV) PASS** on real paper β€” see Section 5. πŸ”΄ Only **T6 (office)** remains: install LibreOffice on the print-server PC, run `spike_t6_office.py`, record results here. `PAPER_SIZE` may now be set in `.env` (the driver honors the flag, verified by T5). Branch pushed; CI runs on PR #1. +- 🟒 **Multi-format hardware gates β€” ALL PASS (2026-08-29):** T5 (images), T6 (office, LibreOffice 26.8.0), T7 (TXT/CSV) β€” details in Section 5. The multi-format MVP is verified end-to-end. Follow-ups: merge PR #1, optionally set `PAPER_SIZE=A4` in `.env`, phone smile-check per format. - πŸ”΄ If you later pursue Option C (Android's native `PrintService` framework, Section 6), treat IPP support and the `PrintService` implementation itself as a separate research phase β€” do not assume it's a small extension of the MVP. --- From b4d16329fb33453416dc5a2510dc33f09d2be7bf Mon Sep 17 00:00:00 2001 From: geb Date: Sat, 29 Aug 2026 15:41:19 +0800 Subject: [PATCH 11/16] docs: README status - all formats verified on paper --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8e829e1..5f591fc 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://github.com/samananias/printerService/actions/workflows/ci.yml/badge.svg)](https://github.com/samananias/printerService/actions/workflows/ci.yml) **Project:** Android phone β†’ Wi-Fi β†’ Python service (this PC) β†’ Windows print queue β†’ USB β†’ Epson L3210 -**Status:** βœ… **PDF MVP working end-to-end** (phone β†’ service β†’ paper, spike T4). βœ… **Multi-format code-complete** (p10–p13: JPG/PNG/WebP images, DOCX/XLSX/PPTX/ODF office, TXT/CSV) and **T5 (images) + T7 (TXT/CSV) verified on real paper** (2026-08-29). βœ… 193 automated tests (~97 % coverage) + ruff + CI. πŸ”΄ Only **T6 (office)** still pending β€” install LibreOffice, run `spike_t6_office.py` (office uploads are refused with a clear message until then). +**Status:** βœ… **Multi-format MVP VERIFIED on real hardware** (2026-08-29): PDF, JPG/PNG/WebP, DOCX/XLSX/PPTX/ODF, and TXT/CSV all print end-to-end β€” spikes T4–T7 PASS (office via LibreOffice, CLI install in Β§1). βœ… 193 automated tests (~97 % coverage) + ruff + CI. πŸ”΄ Next: merge PR #1, optionally `PAPER_SIZE` in `.env`, then Phase 5 (queue management). **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. **Multi-format roadmap & exact stopping point:** [docs/MULTI_FORMAT_PLAN.md](docs/MULTI_FORMAT_PLAN.md) ("Where this stage stopped") and [docs/SOURCE_OF_TRUTH.md](docs/SOURCE_OF_TRUTH.md) Β§9. From 0572f016f084a1450da460d10d377f0e5b384326 Mon Sep 17 00:00:00 2001 From: geb Date: Sat, 29 Aug 2026 15:55:35 +0800 Subject: [PATCH 12/16] p14: queue management - cancel overhaul, retry, SQLite persistence - jobs.py: in-memory dict -> SQLite (JOB_DB_PATH, default logs/jobs.sqlite3) behind the same function surface; source file + category stored per job; SOURCE_OF_TRUTH Section 12's upgrade path taken - cancellation works in every pre-done state now (was received-only, a near-useless window): the pipeline checks between stages and never marks a cancelled job done; cancelling while printing purges our queued spooler jobs by document name (win32print.SetJob, best-effort) - POST /jobs/{id}/retry + a Retry button on the web page: failed jobs re-print from their stored upload, no re-upload needed - startup recovery: jobs left active by a crashed run become failed (their uploads are swept, so retrying is impossible) - conftest: fresh per-test SQLite store; fake win32print gains the spooler surface (OpenPrinter/EnumJobs/SetJob/JOB_CONTROL_DELETE) - tests: 216 pass, coverage 96.1% (gate 90%) --- .env.example | 4 + README.md | 7 +- app/api/jobs.py | 78 ++++++++-- app/api/web.py | 41 ++++- app/config.py | 5 + app/main.py | 13 +- app/printer/windows.py | 44 +++++- app/services/jobs.py | 242 ++++++++++++++++++++++------- app/services/pipeline.py | 45 +++++- docs/MULTI_FORMAT_PLAN.md | 13 +- docs/SOURCE_OF_TRUTH.md | 6 +- tests/api/test_jobs_api.py | 68 ++++++++ tests/conftest.py | 28 +++- tests/unit/test_jobs.py | 87 +++++++++++ tests/unit/test_pipeline.py | 77 +++++++++ tests/unit/test_printer_windows.py | 38 +++++ 16 files changed, 715 insertions(+), 81 deletions(-) diff --git a/.env.example b/.env.example index 9f25a76..3cf78e5 100644 --- a/.env.example +++ b/.env.example @@ -41,3 +41,7 @@ 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/README.md b/README.md index 5f591fc..78364c7 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,9 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000 documents need LibreOffice on the server (Β§1); DOCX/XLSX/PPTX convert in roughly 10–30 s β€” the page shows `converting` while that runs. TXT prints as monospace text, CSV as a bordered grid table. +5. Failed jobs keep their uploaded file: the page shows a **πŸ” Retry** + button, or `POST /jobs/{id}/retry`. Cancel works while queued, + converting, or printing (best-effort once handed to Windows). Other endpoints (also browsable interactively at `http://:8000/docs`): @@ -139,7 +142,8 @@ Other endpoints (also browsable interactively at `http://:8000/docs`): | `POST /print` | Upload a file (PDF, image, Office document, or TXT/CSV) 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 | +| `DELETE /jobs/{id}` | Cancel a job while queued, converting, or printing (best-effort once handed to Windows) | +| `POST /jobs/{id}/retry` | Re-print a failed job from its stored upload β€” no re-upload needed | --- @@ -157,6 +161,7 @@ Copy `.env.example` β†’ `.env` and edit. All values are optional; defaults work. | `ENABLE_OFFICE` | `1` | Office-document printing (DOCX/XLSX/PPTX/ODF β†’ PDF via LibreOffice). `0` = office uploads refused with a clear message, everything else unaffected | | `LO_PATH` | *(empty)* | Explicit path to `soffice.exe`. Empty = search standard install locations | | `CONVERT_TIMEOUT_S` | `120` | Seconds an office conversion may run before LibreOffice is killed | +| `JOB_DB_PATH` | `logs/jobs.sqlite3` | SQLite job history (survives restarts; started jobs from a crashed run are marked failed at startup). Delete the file to reset history | | `HOST`, `PORT` | `8000` | Informational β€” actually pass them on the uvicorn command line (Β§3) | --- diff --git a/app/api/jobs.py b/app/api/jobs.py index 6e2a759..7ec7c6d 100644 --- a/app/api/jobs.py +++ b/app/api/jobs.py @@ -1,19 +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, uploads +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 router = APIRouter() +logger = logging.getLogger(__name__) @router.get("/jobs", response_model=list[PrintJob]) @@ -33,18 +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) + # 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, once non-PDF formats - # land, its converted PDF alongside it. + # 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]) + logger.info("job %s queued for retry", job_id) + return jobs.get_job(job_id) diff --git a/app/api/web.py b/app/api/web.py index 2125d2e..abb06df 100644 --- a/app/api/web.py +++ b/app/api/web.py @@ -62,11 +62,16 @@
+