{"id":51496902,"url":"https://github.com/petehottelet/pdf-fax-optimizer","last_synced_at":"2026-07-07T16:01:40.749Z","repository":{"id":365123936,"uuid":"1270646515","full_name":"petehottelet/PDF-fax-optimizer","owner":"petehottelet","description":"Agent skill (SKILL.md) that maximizes document quality and readability over fax — converts a PDF to a 1-bit CCITT-G4 PDF/TIFF that arrives LEGIBLE after Group-3 transmission, with 5 halftone methods and an 'eye tokens' comparison preview. For Claude and OpenAI Codex.","archived":false,"fork":false,"pushed_at":"2026-06-16T00:13:45.000Z","size":2256,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-16T02:12:34.251Z","etag":null,"topics":["agent-skill","bilevel","blue-noise","ccitt-g4","claude-skill","codex","dithering","document-imaging","error-diffusion","fax","halftoning","image-processing","pdf","pymupdf","python","skill"],"latest_commit_sha":null,"homepage":null,"language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/petehottelet.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-15T23:19:33.000Z","updated_at":"2026-06-16T00:13:48.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/petehottelet/PDF-fax-optimizer","commit_stats":null,"previous_names":["petehottelet/pdf-fax-optimizer"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/petehottelet/PDF-fax-optimizer","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/petehottelet%2FPDF-fax-optimizer","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/petehottelet%2FPDF-fax-optimizer/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/petehottelet%2FPDF-fax-optimizer/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/petehottelet%2FPDF-fax-optimizer/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/petehottelet","download_url":"https://codeload.github.com/petehottelet/PDF-fax-optimizer/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/petehottelet%2FPDF-fax-optimizer/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35234127,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-07T02:00:07.222Z","response_time":90,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["agent-skill","bilevel","blue-noise","ccitt-g4","claude-skill","codex","dithering","document-imaging","error-diffusion","fax","halftoning","image-processing","pdf","pymupdf","python","skill"],"created_at":"2026-07-07T16:01:39.905Z","updated_at":"2026-07-07T16:01:40.741Z","avatar_url":"https://github.com/petehottelet.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n  \u003cimg src=\"docs/banner.png\" alt=\"PDF FAX — maximize document quality over any fax line\" width=\"100%\"\u003e\n\u003c/p\u003e\n\n# PDF FAX — an Agent Skill\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://github.com/petehottelet/pdf-fax-optimizer/releases/latest\"\u003e\u003cimg alt=\"Latest release\" src=\"https://img.shields.io/github/v/release/petehottelet/pdf-fax-optimizer?display_name=tag\u0026sort=semver\u0026color=2da44e\u0026label=release\"\u003e\u003c/a\u003e\n  \u003ca href=\"LICENSE\"\u003e\u003cimg alt=\"License: MIT\" src=\"https://img.shields.io/badge/license-MIT-green.svg\"\u003e\u003c/a\u003e\n  \u003cimg alt=\"Python 3.10+\" src=\"https://img.shields.io/badge/python-3.10%2B-blue.svg\"\u003e\n  \u003cimg alt=\"Claude + Codex\" src=\"https://img.shields.io/badge/Claude%20%2B%20Codex-agent%20ready-555555.svg\"\u003e\n  \u003cimg alt=\"Agent Skill (SKILL.md)\" src=\"https://img.shields.io/badge/Agent%20Skill-SKILL.md-orange.svg\"\u003e\n  \u003ca href=\"https://skills.sh/petehottelet/pdf-fax-optimizer\"\u003e\u003cimg alt=\"skills.sh\" src=\"https://skills.sh/b/petehottelet/pdf-fax-optimizer\"\u003e\u003c/a\u003e\n  \u003cimg alt=\"Formats: PDF, DOCX, PPTX, XLSX, image\" src=\"https://img.shields.io/badge/formats-.pdf%20%7C%20.docx%20%7C%20.pptx%20%7C%20.xlsx%20%7C%20image-777777.svg\"\u003e\n  \u003cimg alt=\"Output: fax PDF, TIFF, JSON, PNG preview\" src=\"https://img.shields.io/badge/output-.fax.pdf%20%7C%20.tiff%20%7C%20.json%20%7C%20.png-8A2BE2.svg\"\u003e\n\u003c/p\u003e\n\nA portable [Agent Skill](https://www.anthropic.com/news/skills) that teaches an\nAI coding agent to **maximize document quality and readability when sending a\nPDF over a fax network.** It converts a PDF into a fax-native **1-bit bilevel\nCCITT-G4** PDF (or Class-F multipage TIFF) that survives the lossy Group-3\ntransmission and **arrives legible on the receiving machine.**\n\n## Try it in 30 seconds\n\nInstall it as an agent skill with the [`skills`](https://skills.sh) CLI:\n\n```bash\nnpx skills add petehottelet/pdf-fax-optimizer\n```\n\nThen ask your agent:\n\n\u003e Make `contract.pdf` fax-ready. Prioritize legibility, generate a preview sheet, and warn me before sending if anything may arrive unreadable.\n\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"docs/readme/text_rescue.png\" alt=\"Before/after: colored highlight chips and image-baked captions recolored to solid black or white so small text and signatures stay legible after a 1-bit fax threshold\" width=\"100%\"\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\u003cem\u003eOriginal vs. fax-safe output — the skill protects small text, signatures, forms, and scans before transmission.\u003c/em\u003e\u003c/p\u003e\n\n\u003e **A fax's whole job is to be READ.** That is the single most important thing\n\u003e about this skill. Fax transmission is low-resolution, 1-bit, and lossy by\n\u003e design over a noisy phone line, so this skill optimizes for **legibility on\n\u003e the other end first** — crisp text, intact small fonts and signatures,\n\u003e recognizable photos. Smaller files and faster transmission are welcome side\n\u003e effects, never the goal: a tiny fax that arrives unreadable is a failure.\n\n\u003e **Just need to shrink a PDF for email or the web?** That's a different job with\n\u003e the opposite trade-offs — use the companion skill:\n\u003e **[pdf-email-optimizer](https://github.com/petehottelet/pdf-email-optimizer)**.\n\nTo make a fax legible, the skill models the Group-3 constraint (1-bit,\nrun-length compression along each scanline) and runs a layered Mixed Raster\nContent pipeline: it renders at the **source's native resolution with square\npixels** (no aspect distortion, no down-scaling), segments image areas, routes\ntext through a contrast-maximising binariser and photos through a tunable\nhalftone screen, and rescues dark text on saturated-colour fills with the\ndefault-on `preserve_text` pass. With **`--recover-text on`** it additionally\nruns OCR over the page and recolours **every recognised word's glyphs to SOLID\nBLACK or SOLID WHITE by a single bright-line rule — median field luminance vs\n`#808080`** — riding the recoloured glyphs on a **text layer composited above**\nthe halftoned image layer so the screen can never disturb them. It defends\nfine detail (background flatten, despeckle, deskew, optional stroke\nthickening), warns about content that won't survive bilevel, and lets you\n**preview exactly what will be transmitted** with a 4-panel sample sheet so\nyou can confirm it's readable before sending.\n\nThe `SKILL.md` format is an open standard. This skill is built and tested for\n**Claude** (Claude Code / claude.ai) and **OpenAI Codex**.\n\n## What it does\n\n- Accepts **PDF, Word, PowerPoint, Excel, OpenDocument, text, and image** input,\n  normalizing non-PDF formats to PDF first (see *Input formats* below).\n- Rasterizes each page at the **source's native resolution with square pixels**\n  — raster sources (PNG, JPG, scans) come through pixel-for-pixel with their\n  original aspect ratio; vector sources rasterize square at the\n  `--fax-resolution` preset (default **`superfine`**), hard-capped at 300 PPI.\n  On a **mixed page** (live vector text plus an embedded low-DPI image) the\n  preset is honoured as a *floor* for the live text, so a single 72-DPI picture\n  can't drag the crisp body text down with it — a higher-DPI image can still\n  pull the page up toward the cap. Each page's chosen DPI and the reason for it\n  are recorded in the JSON report (`chosen_dpi` / `chosen_dpi_reason`).\n- MRC-lite segmentation using the PDF's embedded-image rectangles: photos go to\n  halftone, document text goes to an adaptive binarizer, with a guard for\n  full-page rasters so the document's own text never gets dithered.\n- **OCR-driven text recolor** (opt-in via `--recover-text on`):\n  `rapidocr-onnxruntime` locates every word in two scopes — outside images\n  (the `--ocr-text` scope: header/footer/form text) and inside images (the\n  `--recover-text` scope: signage, captions) — and the pipeline marks each\n  word BLACK or WHITE by the **#808080 rule** (median field luma \u003c 128 →\n  WHITE; ≥ 128 → BLACK). The recoloured glyphs ride a layer composited\n  **above** the halftone, so the screen can never disturb them. OCR is off by\n  default because it's the slow step (≈20+ min on a 6-page 391-DPI deck); the\n  default pipeline relies on the contrast binariser + `preserve_text` and is\n  already legible for the vast majority of documents.\n- Pre-cleans: background flatten, despeckle, deskew; optional stroke thickening\n  to save hairlines and small fonts.\n- Emits lossless CCITT-G4 (no re-encode) via img2pdf — a `CCITTFaxDecode` PDF or\n  a Class-F multipage TIFF, with the (square) effective DPI embedded so the\n  output PDF is correctly proportioned.\n- `--transmission-safe` clamps the final scanline to 1728 px when you need\n  strict Group-3 transmissibility (default keeps native resolution for\n  legibility).\n- Produces a JSON report with **estimated transmission time per page**, the\n  chosen halftone screen and the feature stats that drove the auto-pick,\n  legibility/inversion warnings, and — when `--recover-text on` is used —\n  every OCR-recoloured word with its polarity and field luminance. Plus\n  `--sample N` for a labelled contact sheet that goes from 1 panel (preview)\n  up to 20 panels (every entry in the `SCREENS` registry alongside the colour\n  / grayscale / default-fax references), all with a settings header that\n  documents which options produced the sheet.\n\n## When to use this skill\n\nUse this skill when you need to:\n\n- fax a PDF, Word document, PowerPoint, Excel file, scan, or image\n- make a document **faxable** before sending\n- fix a **muddy, faint, low-contrast, or unreadable fax**\n- preserve **signatures, small text, form fields, screenshots, tables, or photo captions** through a 1-bit channel\n- generate a **fax preview / contact sheet** before transmission\n- produce fax-native **1-bit CCITT-G4 PDF or TIFF** output\n- send through **mFax, Phaxio,** or another cloud fax provider\n\n## When not to use this skill\n\nThis skill is for documents that must **survive fax transmission**. Reach for a different tool when faxing isn't part of the request — it is not primarily for:\n\n- shrinking a PDF for **email** (use [pdf-email-optimizer](https://github.com/petehottelet/pdf-email-optimizer))\n- **merging, splitting, or editing** PDFs\n- **extracting tables or text**, or OCR-only workflows\n- converting documents for **web or print** publishing\n\n## Example workflow\n\n**User request:**\n\n\u003e Make `intake-form.pdf` fax-ready. Preserve small text and signatures, generate a preview sheet, and tell me if anything may arrive unreadable.\n\n**What the skill does:**\n\n1. Inspects the document and picks a fax-safe resolution per page.\n2. Improves contrast and converts each page to 1-bit bilevel output.\n3. Writes fax-native PDF/TIFF (`CCITTFaxDecode` PDF or Class-F multipage TIFF).\n4. Generates a preview / contact sheet so you can confirm legibility before sending.\n5. Emits a JSON report with per-page decisions, warnings, and output paths.\n6. Asks for confirmation before sending through a cloud fax provider.\n\n## Optimizing for the channel, not \"fax-ifying\" the document\n\nThe goal is to **optimize the document for transmission**, not to make it look\nlike a generic fax. The skill treats a page as Mixed Raster Content and applies a\n*different, selectable schema* to each kind of content:\n\n- **Text / line art** → a contrast-maximizing binarizer (`--text-binarize`,\n  default `contrast`; also `sauvola`, `niblack`, `wolf`, `bradley`, `otsu`).\n  Text is **never halftoned** — it is thresholded for legibility, pulling gray /\n  light-gray text on white to **solid black**, and holding glyphs crisp over dark\n  header bars, reverse type, and uneven illumination where a single global cut\n  drops light text or fills shadows.\n- **Photos / continuous tone** → a **halftone schema** (`--dither`), with\n  **dot-gain pre-correction** (`--tone-curve auto`, so midtones don't plug to a\n  black silhouette) and optional **edge sharpening** (`--sharpen`).\n- **Text baked into an image** (captions, signs, screenshots, or a whole page\n  scanned as a single image) → detected *inside* the photo region and routed back\n  to the legibility path, so it stays readable instead of dissolving into a\n  halftone screen (`--no-text-in-image` to disable). See the next section.\n\n### Text inside images — found, recolored by the #808080 rule, kept legible\n\nPlenty of real fax jobs are full of text that *isn't* live text: a whole page\nexported or scanned as a single image, a screenshot, a caption burned into a\nphoto, a sign in a snapshot. If that page were treated as one big picture and\nhalftoned, the words would dissolve into dot-screen mud. With `--recover-text\non`, the pipeline therefore runs **OCR over the page** and recolors text by a\nsingle bright-line rule — the **#808080 rule** — and never lets the halftone\ntouch a glyph:\n\n1. **Locate every word.** With `--recover-text on`, OCR\n   (`rapidocr-onnxruntime`) finds words in two scopes: OUTSIDE the image\n   regions (the `--ocr-text` scope — the page's header/footer/form text;\n   toggleable with `--ocr-text off`) and INSIDE the image regions (signage,\n   captions). OCR is the locator only; if `--recover-text` is left off (the\n   default) or the engine isn't installed, the skill falls back to the\n   binarizer's default black-on-white.\n2. **Segment the original glyph pixels.** Each word's OCR box is used to crop;\n   glyphs are split from their field using the box's border ring (definitely\n   field) — robust where a blind 2-means split would invert. The real\n   letterforms are preserved; no synthetic font is ever pasted.\n3. **Pick polarity per the #808080 rule.** Median field luminance \u003c 128\n   ⇒ glyphs become **WHITE**; ≥ 128 ⇒ glyphs become **BLACK**. Polarity is\n   decided **per sign** (words grouped by proximity, one field tone for the\n   whole group) so co-located words on the same plate get one consistent\n   treatment.\n4. **Composite text ABOVE the halftone.** The recoloured glyphs ride a text\n   layer painted on top of the halftoned image layer, so the halftone screen\n   can never disturb them. No field-lift, no field-darken, no stroke backing —\n   the layered composite makes those obsolete.\n\nThe payoff: **even when an image is aggressively halftoned for transmission,\nevery word inside it stays legible** — and the report lists each recoloured\nword with its OCR confidence, polarity, and field luminance so you can verify.\nThe default pipeline (with `--recover-text` off) leaves text baked into images\nto the halftone screen — fine for photographic captions, lossy for billboards\nand signs; flip `--recover-text on` when legibility inside images matters\nenough to spend the OCR pass. A `--no-text-in-image` fallback is also\navailable for the rare case where you want pure halftone everywhere.\n\n### Halftone schemas + the \"eye tokens\" comparison preview\n\nA continuous-tone photo can't exist in 1-bit fax — it has to be simulated with\ndot patterns, and that choice is the biggest lever on how a photo reads after a\nlossy transmission. The skill ships these schemas, spanning the design space:\n\n\"Detail\" is how much fine information the screen carries before the channel\nchews on it. \"G4 size\" is how richly the chosen screen encodes onto the page —\na richer screen leaves more bytes for the modem to ship but renders more of\nwhat the source actually had. \"Channel character\" is the *kind of trace* the\nscreen leaves on the line: long-run AM strokes survive line noise cleanly,\nfine-grain FM stipple holds photographic detail, error-diffusion families\ndiffuse tone with their own signature. None of these is \"best\" in isolation —\nthe right pick depends on what's on the page.\n\n| `--dither` | Family | Detail | G4 size | Channel character |\n|---|---|---|---|---|\n| `clustered` | AM screening — round clustered-dot | low–med | minimal | long-run, line-tolerant |\n| `screen --dot-shape square` | AM screening — square dot | low–med | minimal | long-run, line-tolerant |\n| `screen --dot-shape diamond` | AM screening — diamond dot (newspaper-photo classic) | low–med | minimal | long-run, line-tolerant |\n| `screen --dot-shape ellipse` | AM screening — ellipse dot (smooth midtone joins) | low–med | minimal | long-run, line-tolerant |\n| `mezzotint` | AM grain — random stippling (expressive; not eligible for `auto`) | med | rich | stippled grain, decorative |\n| `ordered` | Bayer ordered (matrix dither) | med | med | regular matrix, predictable |\n| `blue-noise` | FM screening (void-and-cluster) | **high** | medium | isotropic fine-grain stipple |\n| `green-noise` | hybrid AM–FM (clustered FM) | med–high | low–med | hybrid stipple/cluster — balanced |\n| `floyd` | error diffusion (4-tap Floyd-Steinberg) | **highest** | rich | fine-grain, detail-first |\n| `atkinson` | error diffusion (6/8 Atkinson, clean whites) | high | med | sparse stipple, clean whites |\n| `jarvis` | error diffusion (12-tap Jarvis-Judice-Ninke, very smooth) | high | rich | diffuse, smooth-tone |\n| `stucki` | error diffusion (12-tap Stucki, sharp + smooth) | high | rich | diffuse, sharp + smooth |\n| `sierra` | error diffusion (12-tap Sierra, lighter Jarvis) | high | rich | diffuse, soft-tone |\n| `edd` | edge-enhancing error diffusion (high-pass + diffusion) | high | med | edge-enhancing, type-friendly |\n| `line` (`woodcut`) | horizontal line screen (engraving) | med | minimal | scanline-aligned stripes |\n| `crosshatch` | layered angled line screens (pen-and-ink etching) | med | low–med | angled strokes, etching |\n| `none` | hard threshold (no halftone) | — | minimal | hard-edge, no halftone |\n\n`green-noise` is the standout for a balanced fax line — blue-noise detail with\nclustered-dot run-length, tunable via `--green-noise-coarseness` (~2 detail … 8\nrobust) — and it's the auto-picker's fallback when no other signal dominates.\nBut the picker isn't single-track: it now reads cheap content stats (mean/std\nluma, edge density, bimodality, texture) off the photo regions and chooses from\n`{clustered, atkinson, edd, floyd, jarvis, green-noise}` based on what's\nactually on the page — `edd` for text overlaid on a photo, `atkinson` for dark\nhigh-contrast images, `floyd` for fine-detail texture, `jarvis` for smooth\ngradients, `clustered` for true 2-tone posters. The picked dither plus the\nfeature stats that drove it are surfaced in the `--report` JSON so you can see\nWHY a page chose what it chose. `line`/`woodcut` renders tone as horizontal\nstripes that thicken with darkness — because the strokes run *along the\nscanline* it's the most G4-friendly way to carry a photo and reads as a clean\nengraving, never mud. Because the pipeline runs at square pixels, the screens\nare isotropic by construction — dots stay round on paper without any\nanisotropic correction. `mezzotint` is an expressive screen — velvety midtones\nbut no spatial coherence, so it compresses richly and isn't eligible for the\nauto-picker; it has to be requested explicitly.\n\nEvery screen in the registry, applied to the same letter cover sheet —\n**`floyd`, `jarvis`, and `edd` are highlighted as the OPTIMAL picks** for a\nforms-and-photo page like this one (preserve fine document text, hold\nphotographic detail in the masthead, keep edge sharpness on the billboard):\n\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"docs/readme/halftone_grid.png\" alt=\"Halftone style contact sheet — every screen in the registry applied to the same Prestige Estates cover sheet, with floyd / jarvis / edd marked OPTIMAL for a forms-and-photo page\" width=\"100%\"\u003e\n\u003c/p\u003e\n\nThat grid above is the whole catalogue. For picking by eye on your own document\nyou don't need all 17 — compression can be ranked by a machine, but **readability\ncan't**, and you can't read 17 thumbnails in parallel. So `--sample N --panels K`\nrenders one page of your file through K side-by-side panels into a single\nlabelled **contact sheet**, each panel annotated with its real G4 size and\ntransmission estimate and the recommended pick highlighted. The skill\n**suggests the optimal** method from the page's content, and you **choose the\noptimal** by spending your *eye tokens* on the contact sheet — then re-run\nwith the chosen `--dither` for the final file.\n\n```bash\n# Default 4-panel: original / grayscale / default fax (Otsu) / auto-pick\npdf-fax-optimizer input.pdf -o output.fax.pdf \\\n    --sample 1\n\n# Minimal 2-panel: original + Claude's auto-picked optimal\npdf-fax-optimizer input.pdf -o output.fax.pdf \\\n    --sample 1 --panels 2\n\n# Curated 6-up: the four reference panels + `green-noise` + `floyd`\npdf-fax-optimizer input.pdf -o output.fax.pdf \\\n    --sample 1 --panels 6\n\n# Full catalogue on your own page: every screen in the SCREENS registry,\n# laid out exactly like halftone_grid.png above but for YOUR document\npdf-fax-optimizer input.pdf -o output.fax.pdf \\\n    --sample 1 --panels max\n\n# Power-user custom recipe\npdf-fax-optimizer input.pdf -o output.fax.pdf \\\n    --sample 1 --sample-include orig,gray,clustered,floyd,line\n```\n\n`--panels K` supports 1, 2, 4 *(default)*, 6, 8, 12, 20 *(`max`)*. Counts\n4–12 anchor the four reference panels (`orig`, `gray`, `default_fax`,\n`recommended`) and fill the remaining slots with a curated set of halftones\nchosen to span the design space; `--panels max` (20) drops the explicit\n`recommended` slot and instead lays out the colour / grayscale /\ndefault-Otsu references next to every entry in the `SCREENS` registry, with\nthe auto-picked screen badged in-place — exactly the catalogue in\n`halftone_grid.png` above, but for *your* document. Use `--sample-include`\nto pin an exact panel set. Every contact sheet carries a 3-line **settings\nheader** at the top documenting the exact options that produced it\n(preserve_text, ocr_text, recover_text, text_binarize, dither,\ntransmission_safe, the auto-pick recommendation, and the page's photo\nfraction) — so saved sheets stay self-documenting weeks later. Add\n`--no-sample-header` to omit it.\n\n### Text That Survives the Fax\n\nTwo layered passes protect text against the 1-bit channel: **`preserve_text`**\nruns by default (no OCR needed, fast) and **`recover_text`** is opt-in\n(requires the OCR engine and adds ~20+ minutes of inference on a typical 6-page\ndeck — that's why it isn't on by default). The report lists everything they\ntouched so you can verify.\n\n**`preserve_text` — dark text on any colored fill (no OCR needed).** Slide\nlabels on highlight chips, dashboard status badges, colored table cells,\ntinted callout boxes, color-filled banners — anywhere dark text sits on a\nsaturated-colour background. In grayscale those fills collapse to mid-tone\nluma and the contrast binarizer flips polarity, painting the fill solid\nblack and knocking the label out as a mangled crosshatch. The `preserve_text`\npass runs ahead of the binarizer: small high-chroma regions that carry dark\ntext strokes get **lifted to white in the gray image** so the dark text reads\nas crisp black-on-white. Disable with `--no-preserve-text`.\n\n**`recover_text` — OCR-driven #808080 polarity for text inside photos.** OCR\nlocates every word inside the photo region; each word's ORIGINAL glyph pixels\nare recoloured BLACK on a light/mid field or WHITE on a dark field by the\n#808080 rule, then composited **above** the halftone so the screen never\ndisturbs them. Opt in with `--recover-text on`.\n\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"docs/readme/text_rescue.png\" alt=\"Text rescue features — top row: preserve_text whitens colored highlight chips so dark labels read cleanly through the 1-bit threshold; bottom row: recover_text OCRs text baked into the halftoned billboard and recolors it BLACK or WHITE by the #808080 rule\" width=\"100%\"\u003e\n\u003c/p\u003e\n\n### Verify exactly what was changed (`--report`)\n\nEvery run can emit a JSON report (`--report out.report.json`) listing what the\npipeline decided per page — the halftone screen it chose, the estimated\ntransmission seconds, every legibility warning, and **every word the OCR-driven\npasses recoloured** with its polarity, the field-luma the polarity was decided\nagainst, and the WCAG contrast it landed at. That makes the layered text passes\nauditable rather than magic: open the report, search for any word in the\ndocument, see precisely which polarity it carried and why.\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cstrong\u003eExample excerpt\u003c/strong\u003e — one page, abbreviated (produced with \u003ccode\u003e--recover-text on\u003c/code\u003e, which is what populates the \u003ccode\u003eocr_text\u003c/code\u003e / \u003ccode\u003erecover_text\u003c/code\u003e word lists)\u003c/summary\u003e\n\n```json\n{\n  \"mode\": \"fax\",\n  \"input\": \"Prestige_Estates_v3.pdf\",\n  \"output\": \"Prestige_Estates_v3.fax.pdf\",\n  \"input_bytes\": 603870,\n  \"output_bytes\": 492627,\n  \"pages\": [\n    {\n      \"index\": 1,\n      \"encoded_bytes\": 492050,\n      \"est_transmission_s\": 274.9,\n      \"photo_regions\": 1,\n      \"photo_fraction\": 0.2948,\n      \"dither\": \"green-noise\",\n      \"text_binarize\": \"contrast\",\n      \"already_bilevel\": false,\n      \"ocr_text\": {\n        \"scope\": \"doc\",\n        \"engine\": \"rapidocr-onnxruntime\",\n        \"words_recognized\": 184,\n        \"words_recolored_black\": 174,\n        \"words_recolored_white\": 10,\n        \"words\": [\n          { \"text\": \"Prestige\",   \"conf\": 0.987, \"polarity\": \"white\",\n            \"rendered\": true,  \"field_gray\":  42.0, \"wcag_contrast\": 12.1,\n            \"bbox\": [120, 88, 412, 156] },\n          { \"text\": \"Investor\",   \"conf\": 0.974, \"polarity\": \"black\",\n            \"rendered\": true,  \"field_gray\": 248.0, \"wcag_contrast\": 16.4,\n            \"bbox\": [120, 252, 388, 296] }\n          /* …182 more words… */\n        ]\n      },\n      \"recover_text\": {\n        \"scope\": \"image\",\n        \"engine\": \"rapidocr-onnxruntime\",\n        \"words_recognized\": 7,\n        \"words_recolored_black\": 0,\n        \"words_recolored_white\": 7,\n        \"words\": [\n          { \"text\": \"VILLA\",      \"conf\": 0.992, \"polarity\": \"white\",\n            \"rendered\": true,  \"field_gray\":  88.4, \"wcag_contrast\":  9.8,\n            \"bbox\": [1042, 612, 1304, 692] },\n          { \"text\": \"DEL\",        \"conf\": 0.988, \"polarity\": \"white\",\n            \"rendered\": true,  \"field_gray\":  91.2, \"wcag_contrast\":  9.4,\n            \"bbox\": [1310, 612, 1416, 692] },\n          { \"text\": \"MAR\",        \"conf\": 0.985, \"polarity\": \"white\",\n            \"rendered\": true,  \"field_gray\":  90.6, \"wcag_contrast\":  9.5,\n            \"bbox\": [1424, 612, 1572, 692] }\n          /* …4 more words… */\n        ]\n      },\n      \"warnings\": [\"text_preserved:395kpx\"]\n    }\n  ],\n  \"total_est_transmission_s\": 274.9,\n  \"warnings\": [\"text_preserved:395kpx\"]\n}\n```\n\nRead this as: *page 1 was halftoned with `green-noise`, will take ~275 seconds\nto transmit at G3 superfine, and the OCR-driven recolor pass touched **191\nwords**. Of those, **184 outside the photo** were recoloured by the\n`--ocr-text` polarity rule (174 BLACK on light fields, 10 WHITE on dark header\nbars), and **7 inside the photo** were recoloured WHITE by `--recover-text`\nbecause they sit on the dark billboard with `field_gray ≈ 90` (\u003c 128, so the\n#808080 rule paints them WHITE).*\n\nThe `warnings` array uses short greppable keys: `text_preserved:Nkpx` (preserve-text\npass lifted N thousand pixels), `inverted_or_heavy_black` (\u003e45 % of the output is\nblack ink — likely an inverted page), `wash_out_color:light` (light-yellow / pale\nhues that won't survive bilevel), and `expressive_screen:mezzotint` (you asked\nfor a screen that compresses poorly — flagged honestly).\n\n\u003c/details\u003e\n\n## Input formats — fax a PDF, or a Word, PowerPoint, Excel, or image file\n\nYou don't have to start from a PDF. Point the optimizer at common office and\nimage formats and it normalizes them to PDF first, then runs the exact same\nfax pipeline:\n\n- **Word / OpenDocument / text** — `.doc`, `.docx`, `.rtf`, `.odt`, `.txt`\n- **PowerPoint** — `.ppt`, `.pptx`, `.odp`\n- **Excel / CSV** — `.xls`, `.xlsx`, `.ods`, `.csv`\n- **Images** — `.png`, `.jpg`, `.tif`, `.bmp`, `.gif`, `.webp`\n- **PDF** — used as-is\n\n```bash\n# Fax a Word doc directly (defaults: superfine + preserve_text;\n# OCR-driven #808080 polarity is opt-in via --recover-text on)\npdf-fax-optimizer proposal.docx -o proposal.fax.pdf\n\n# Fax a scanned image with a 4-panel sample sheet\npdf-fax-optimizer scan.jpg -o scan.fax.pdf --sample 1\n```\n\nImages are wrapped to PDF with `img2pdf` (no extra tools). Office/OpenDocument\nfiles are rendered by **LibreOffice headless** (`soffice`), which reproduces the\nlayout faithfully — install [LibreOffice](https://www.libreoffice.org/download/)\nonce (it needs no GUI) or export to PDF yourself. Add `--keep-converted-pdf` to\nretain the intermediate PDF next to the output.\n\n## Repository layout\n\n```\n.\n├── README.md                  # this file (for humans)\n├── LICENSE                    # MIT\n├── pyproject.toml             # packaging: deps, extras, console scripts\n├── requirements.txt           # Python deps (mirrors pyproject for `pip install -r`)\n├── tests/                     # pytest suite (synthetic fixtures, no large binaries)\n├── .github/workflows/         # CI (lint + test matrix) and release-to-PyPI\n└── pdf-fax-optimizer/             # the skill (this folder IS the skill / release zip)\n    ├── SKILL.md               # entry point: metadata + instructions\n    ├── agents/openai.yaml      # optional Codex UI sidecar\n    ├── references/            # fax-optimization.md, config-schema.md, sending.md\n    ├── scripts/               # thin back-compat shims -\u003e pdf_fax_optimizer.*\n    └── pdf_fax_optimizer/     # the importable / pip-installable Python package\n        ├── __init__.py        # version + public API (FaxOptions, convert_pdf, …)\n        ├── cli.py             # `pdf-fax-optimizer` console-script entry point\n        ├── optimize_pdf.py    # CLI argument parsing + orchestration\n        ├── fax_pipeline.py    # the fax conversion pipeline\n        ├── to_pdf.py          # normalize Office/image input to PDF\n        ├── send_fax.py        # transmit via a cloud fax API (mFax/Phaxio/generic)\n        ├── ocr_text.py        # optional OCR backend (rapidocr-onnxruntime)\n        ├── check_deps.py      # detect dependencies; report what's missing\n        └── assets/            # bluenoise_64.npy, greennoise_64_s4.0.npy, Oswald.ttf\n```\n\nThe same tree serves both audiences: `pip install pdf-fax-optimizer` installs the\n`pdf_fax_optimizer` package, while the `pdf-fax-optimizer/` folder is the agent\nskill (and the release zip) — one source of truth, no duplication.\n\n## Requirements\n\n- **Python 3.10+** with: PyMuPDF, Pillow, numpy, opencv-python-headless, img2pdf\n  (`pip install -r requirements.txt`). `requests` is also installed, needed only\n  to **send** faxes.\n- **`rapidocr-onnxruntime`** (optional but recommended) — drives the OCR-based\n  #808080 polarity passes (`--ocr-text` and `--recover-text`). Self-contained\n  (bundled ONNX models, no system OCR binary). Without it the skill still\n  works: document text falls back to the binarizer's default black-on-white\n  and the within-image recover pass is silently skipped.\n- **No CLI tools required** for PDF/image input. (qpdf / Ghostscript are optional\n  and only useful for unrelated PDF work.)\n- **LibreOffice** (optional) — only needed to fax **Office/OpenDocument** input\n  (Word/PowerPoint/Excel); it runs headless, no GUI.\n\nCheck what's present (detect-only; prints the right `pip install` command for\nanything missing):\n\n```bash\npython -m pdf_fax_optimizer.check_deps\n```\n\n## Install (CLI)\n\nThe fastest way to get the `pdf-fax-optimizer` command is pip:\n\n```bash\npip install pdf-fax-optimizer                 # core\npip install \"pdf-fax-optimizer[ocr,send]\"     # + OCR text rescue + cloud-fax sending\n```\n\nThen run it directly:\n\n```bash\npdf-fax-optimizer input.pdf -o output.fax.pdf --sample 1 --report output.report.json\n```\n\nOptional extras: `ocr` adds `rapidocr-onnxruntime` (the OCR-driven `--recover-text`\n/ `--ocr-text` passes), `send` adds `requests` (the `pdf-fax-send` transmit path),\nand `all` pulls in both.\n\n### From source\n\n```bash\ngit clone https://github.com/petehottelet/pdf-fax-optimizer.git\ncd pdf-fax-optimizer\npip install -e \".[all]\"          # editable install with every optional feature\n```\n\nWithout installing, you can also run the package straight from a checkout\n(the skill folder is on the path):\n\n```bash\npython -m pdf_fax_optimizer.optimize_pdf input.pdf -o output.fax.pdf --sample 1\n```\n\n### Development\n\n```bash\npip install -e \".[dev]\"          # pytest, ruff, build, twine, …\npytest                            # run the test suite (synthetic fixtures, no large binaries)\nruff check .                      # lint\n```\n\n## Installing the skill\n\n**Easiest** — install it with the [`skills`](https://skills.sh) CLI, which\ncopies the skill into the right place for whichever agent you're using:\n\n```bash\nnpx skills add petehottelet/pdf-fax-optimizer\n```\n\n`SKILL.md` is the open standard; to place it by hand, the only difference between\nagents is **where** the skill folder lives. Copy the `pdf-fax-optimizer/` folder\ninto the appropriate location:\n\n| Agent | Location (user-level) | Location (project-level) |\n|---|---|---|\n| **Claude Code** | `~/.claude/skills/pdf-fax-optimizer/` | `.claude/skills/pdf-fax-optimizer/` |\n| **OpenAI Codex** | `~/.codex/skills/pdf-fax-optimizer/` | `.agents/skills/pdf-fax-optimizer/` |\n\n**Or grab the release zip** — download the packaged skill from the\n**[latest release](https://github.com/petehottelet/pdf-fax-optimizer/releases/latest)**\n(`pdf-fax-optimizer.zip`) and unzip it directly into one of the locations above:\n\n```bash\n# Claude Code (user-level)\ncurl -L -o pdf-fax-optimizer.zip \\\n  https://github.com/petehottelet/pdf-fax-optimizer/releases/latest/download/pdf-fax-optimizer.zip\nunzip pdf-fax-optimizer.zip -d ~/.claude/skills/\n```\n\nOr clone the repo and copy the inner skill folder into place:\n\n```bash\ngit clone https://github.com/petehottelet/pdf-fax-optimizer.git\n# Claude Code\ncp -r pdf-fax-optimizer/pdf-fax-optimizer ~/.claude/skills/pdf-fax-optimizer\n# OpenAI Codex\ncp -r pdf-fax-optimizer/pdf-fax-optimizer ~/.codex/skills/pdf-fax-optimizer\n```\n\n**Claude Code** discovers skills automatically (no restart) and you can invoke\nwith `/pdf-fax-optimizer`. For **claude.ai** (web/desktop), zip the `pdf-fax-optimizer/`\nfolder so the folder is the archive root, then upload it under\nSettings → Capabilities → Skills:\n\n```bash\ncd pdf-fax-optimizer \u0026\u0026 zip -r pdf-fax-optimizer.zip pdf-fax-optimizer\n```\n\n**OpenAI Codex** keeps skills behind an experimental flag — enable it once, then\nrestart Codex:\n\n```toml\n# ~/.codex/config.toml\nskills = true\n```\n\nCodex activates the skill implicitly when your request matches the description,\nor explicitly via `$pdf-fax-optimizer`. (Codex caps the frontmatter `description` at\n500 characters — this skill's description is within that limit.)\n\n## Using it directly (without an agent)\n\nAfter `pip install pdf-fax-optimizer` it's a normal CLI (or run\n`python -m pdf_fax_optimizer.optimize_pdf …` straight from a source checkout):\n\n```bash\n# Make a PDF faxable (defaults: superfine quality, native res, preserve_text on;\n# recover_text OCR / #808080 recolor is opt-in via --recover-text on)\npdf-fax-optimizer input.pdf -o output.fax.pdf \\\n    --report output.report.json --sample 1\n\n# Compare halftone methods side-by-side and pick by eye (6-up, 12-up, max…)\npdf-fax-optimizer input.pdf -o output.fax.pdf \\\n    --sample 1 --panels 6\n\n# Strict Group-3 transmissibility (1728-px scanline) for a real fax machine\npdf-fax-optimizer input.pdf -o output.fax.pdf \\\n    --transmission-safe\n\n# Multipage Class-F G4 TIFF instead of a PDF\npdf-fax-optimizer input.pdf -o output.tiff \\\n    --format tiff\n```\n\nSee `pdf-fax-optimizer/references/config-schema.md` for the full flag/config\nreference, and `pdf-fax-optimizer/references/fax-optimization.md` for the reasoning\nbehind the fax defaults.\n\n## Sending the fax via a cloud API\n\nThe skill can also **transmit** the optimized file — no machine, modem, or phone\nline, just an API key and the recipient number in **E.164**. Built-in providers:\n`mfax` (mFax/Documo), `phaxio` (Phaxio/Sinch), and `generic` (any upload API such\nas Telnyx or SRFax). Always pass keys via environment variables, and use\n`--dry-run` to preview the exact request first.\n\n```bash\nexport MFAX_API_KEY=sk_live_xxx\n\n# optimize and send in one step (transmission-safe for real fax lines)\npdf-fax-optimizer input.pdf -o output.fax.pdf \\\n    --transmission-safe \\\n    --send mfax --to +14155551234 --dry-run     # drop --dry-run to transmit\n\n# or send an already-optimized file\npdf-fax-send output.fax.pdf \\\n    --provider phaxio --to +14155551234\n```\n\nSee `pdf-fax-optimizer/references/sending.md` for per-provider endpoints, auth, env\nvars, and configuring `generic` for other APIs.\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpetehottelet%2Fpdf-fax-optimizer","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpetehottelet%2Fpdf-fax-optimizer","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpetehottelet%2Fpdf-fax-optimizer/lists"}