docs(humans): ship the MD explainer as a PDF with the images baked in

The HTML references screenshots relatively, so it only renders inside a
checkout -- mailing the file on its own gives broken images. The PDF is one
self-contained file, which is what actually gets sent to Ozlem.

- tools/humans_to_pdf.py: renders .humans/*.html via headless Chrome (or
  Edge), so the output is what the browser shows. HTML stays the source.
- @media print block on the page: the wide tables and the pipeline diagram
  scroll on screen but would be cut off at the paper edge; this reflows
  them and keeps figures, callouts and table rows off page breaks.
  Verified at A4 content width -- 6 pages, both screenshots embedded.
- *.pdf -> LFS. Each regeneration is a whole new ~2 MB blob, and unlike
  png/jpg there are no PDFs tracked raw yet, so there is no history to
  rewrite by adding it now.

Also corrects working-files.md: it claimed .gitattributes does not LFS-track
.blend, which was true when written and is no longer -- .blend/.zprj/.obj/
.npy/.npz have since been added. The note now covers what is actually still
excluded (png/jpg, deliberately) and why that makes QA renders scratch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 09:31:37 -07:00
parent 3ba86b2ea8
commit 4bbd1a1732
6 changed files with 157 additions and 9 deletions
+6 -4
View File
@@ -66,7 +66,9 @@ pinned.
## Committing ## Committing
Scratch patterns are in `.gitignore` and must never be committed. Note that Scratch patterns are in `.gitignore` and must never be committed. `.blend`,
`.gitattributes` LFS-tracks `.glb/.fbx/.mp4/.task` but **not `.blend`** — an `.zprj`, `.obj`, `.npy`/`.npz` and the rest of the heavy formats are LFS-tracked
80 MB master committed today goes into git proper, not LFS. Add `.blend` to in `.gitattributes`, so a pinned master can be committed safely — but note that
`.gitattributes` first if masters are ever to be tracked. `.png`/`.jpg` deliberately are **not** (≈250 are already tracked raw; adding them
would rewrite every one without shrinking history). A lane's QA renders are
scratch for that reason too, not just a disk one.
+5
View File
@@ -18,6 +18,11 @@
*.npy filter=lfs diff=lfs merge=lfs -text *.npy filter=lfs diff=lfs merge=lfs -text
*.npz filter=lfs diff=lfs merge=lfs -text *.npz filter=lfs diff=lfs merge=lfs -text
# Generated .humans/ PDFs (tools/humans_to_pdf.py). Each regeneration is a whole
# new ~2 MB blob, so LFS from the start — unlike png/jpg above, none are tracked
# raw yet, so there is no history to rewrite.
*.pdf filter=lfs diff=lfs merge=lfs -text
# Reallusion CC / iClone asset containers # Reallusion CC / iClone asset containers
*.iAvatar filter=lfs diff=lfs merge=lfs -text *.iAvatar filter=lfs diff=lfs merge=lfs -text
*.iavatar filter=lfs diff=lfs merge=lfs -text *.iavatar filter=lfs diff=lfs merge=lfs -text
+19 -5
View File
@@ -1,7 +1,7 @@
# .humans/ — pages written for people, not agents # .humans/ — pages written for people, not agents
Self-contained HTML pages explaining how parts of this repo work, for reading in Explainers about how parts of this repo work. Same convention as
a browser. Same convention as `ariki-game/.humans/`. `ariki-game/.humans/`: an HTML page per topic, read in a browser.
The operating detail lives in `.claude/skills/` and `.agents/` and is written for The operating detail lives in `.claude/skills/` and `.agents/` and is written for
whoever is driving the tools. These pages are the **explanation** — what we built, whoever is driving the tools. These pages are the **explanation** — what we built,
@@ -10,7 +10,21 @@ skill/wiki is the source of truth.
| page | about | | page | about |
|---|---| |---|---|
| `marvelous-designer.html` | How we author Ariki's garments as real sewn cloth in Marvelous Designer — the process, what's been made, the traps, what's still unsolved. Written for Özlem and Jeremy. | | `marvelous-designer` | How we author Ariki's garments as real sewn cloth in Marvelous Designer — the process, what's been made, the traps, what's still unsolved. Written for Özlem and Jeremy. |
Images are referenced relatively (`../tools/tailor/screenshots/…`), so open these ## HTML is the source; the PDF is what you send
from inside the repo rather than copying the file out on its own.
The HTML references screenshots relatively (`../tools/tailor/screenshots/…`), so it
only renders from inside a checkout — mail someone the `.html` on its own and they
get broken images. The **PDF has the images baked in** and travels as one file.
Edit the HTML, then regenerate:
```
python tools/humans_to_pdf.py --all # or a single page
```
That drives headless Chrome, so what you get is exactly what the browser shows.
Each page carries an `@media print` block controlling the paper layout — without it
the wide tables and the pipeline diagram, which merely scroll on screen, get cut
off at the page edge. Commit the HTML and the PDF together so they don't drift.
+30
View File
@@ -114,6 +114,36 @@
.callout p:last-child { margin-bottom: 0; } .callout p:last-child { margin-bottom: 0; }
.meta { color: var(--muted); font-size: .85rem; border-top: 1px solid var(--line); margin-top: 3.5rem; padding-top: 1.2rem; } .meta { color: var(--muted); font-size: .85rem; border-top: 1px solid var(--line); margin-top: 3.5rem; padding-top: 1.2rem; }
hr { border: 0; border-top: 1px solid var(--line); margin: 3rem 0 0; } hr { border: 0; border-top: 1px solid var(--line); margin: 3rem 0 0; }
/* PDF export — `python tools/humans_to_pdf.py` drives headless Chrome.
Screen rules scroll wide content; on paper it would simply be cut off. */
@media print {
@page { size: A4; margin: 12mm; }
html, body {
background: #0b1113 !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
body { padding: 0; font-size: 10pt; line-height: 1.5; }
h1 { font-size: 26pt; margin-bottom: .6rem; }
h2 { font-size: 13pt; margin: 20pt 0 6pt; }
h3 { font-size: 10.5pt; margin: 12pt 0 4pt; }
p, li { max-width: none; }
a { text-decoration: none; }
/* Wide content is scrollable on screen; on paper it must reflow. */
.wrap { overflow: visible; }
table { min-width: 0; font-size: 8.5pt; }
th, td { padding: .5rem .7rem; }
.flow { padding: .8rem 1rem; overflow: visible; }
.flow pre { font-size: 7pt; line-height: 1.5; }
/* Never split a figure, table row, callout or diagram across a page. */
figure, .callout, .flow, .wrap, tr { break-inside: avoid; page-break-inside: avoid; }
h1, h2, h3 { break-after: avoid; page-break-after: avoid; }
figure img { max-height: 16cm; width: auto; margin: 0 auto; }
figcaption { font-size: 8.5pt; }
.shots { display: block; }
.meta { margin-top: 20pt; font-size: 8pt; }
}
</style> </style>
</head> </head>
<body> <body>
Before
After
Binary file not shown.
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Render a .humans/ HTML page to a self-contained PDF via headless Chrome.
The pages in `.humans/` reference screenshots relatively
(`../tools/tailor/screenshots/...`), so the HTML only works from inside a repo
checkout. The PDF embeds those images, which makes it the thing you actually
send to someone.
The HTML stays the source of truth: edit the page, re-run this, commit both.
python tools/humans_to_pdf.py .humans/marvelous-designer.html
python tools/humans_to_pdf.py --all
"""
from __future__ import annotations
import argparse
import subprocess
import sys
import tempfile
from pathlib import Path
CHROME_CANDIDATES = [
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/usr/bin/google-chrome",
"/usr/bin/chromium",
]
def find_chrome() -> Path:
for c in CHROME_CANDIDATES:
p = Path(c)
if p.exists():
return p
raise SystemExit("error: no Chrome or Edge found; tried:\n "
+ "\n ".join(CHROME_CANDIDATES))
def to_pdf(html: Path, chrome: Path) -> Path:
out = html.with_suffix(".pdf")
# Chrome refuses to reuse a running profile, so give it a throwaway one.
with tempfile.TemporaryDirectory() as profile:
cmd = [
str(chrome),
"--headless=new",
"--disable-gpu",
f"--user-data-dir={profile}",
"--no-pdf-header-footer", # drop the URL/date furniture
"--virtual-time-budget=15000", # let local images decode first
f"--print-to-pdf={out}",
html.resolve().as_uri(),
]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
if not out.exists():
sys.stderr.write(r.stderr or "")
raise SystemExit(f"error: Chrome produced no PDF for {html}")
return out
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("page", type=Path, nargs="?", help="HTML page under .humans/")
ap.add_argument("--all", action="store_true", help="render every .humans/*.html")
args = ap.parse_args()
repo = Path(__file__).resolve().parent.parent
if args.all:
pages = sorted((repo / ".humans").glob("*.html"))
elif args.page:
pages = [args.page]
else:
ap.error("give a page, or --all")
if not pages:
print("nothing to render")
return 0
chrome = find_chrome()
print(f"using {chrome.name}")
for html in pages:
if not html.is_file():
print(f" skip (missing): {html}")
continue
out = to_pdf(html, chrome)
print(f" {out.stat().st_size / 1048576:.2f} MB {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())