diff --git a/CHANGELOG.md b/CHANGELOG.md index b90566e..7bc55fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to this project are documented here. Format: Keep a Changelo ### Features +- **Several outputs per project.** `outputs:` in `scriptorium.yaml` names + several renders of one source, each PDF with its own page size (`page:`) and + vars. `scriptorium render` builds them all; `--only NAME` picks. A project + without `outputs:` still renders `book.pdf`. - **`render_html()` and `scriptorium render --html` return the document HTML.** Everything a PDF goes through (footnotes, citations, glossary, timeline, the parser, `emit()`) without the printing, so a screen consumer gets every diff --git a/README.md b/README.md index f0c1971..c661d88 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,32 @@ scriptorium render scriptorium.yaml # concatenates the files -> book.pdf scriptorium tangle scriptorium.yaml # extract export= code into src/ ``` +### Several outputs + +`outputs:` names several renders of the same project. Without it a project +renders one PDF, `book.pdf`, as before. + +```yaml +outputs: + print: # the name is what --only takes + format: pdf # file defaults to .pdf + file: book.pdf + pocket: + format: pdf + file: book-a5.pdf + page: {size: A5, margin: 14mm} # overrides the theme's page block +``` + +`page.size` takes `A4`, `A5`, `letter`, `16:9`, `4:3` or `"mm mm"`; an +output's `vars` override the project's. Paths are relative to the project file. + +```bash +scriptorium render scriptorium.yaml # every output +scriptorium render scriptorium.yaml --only pocket # one (repeatable) +``` + +`-o` works only when a single output is being built. + ## Themes Themes are project templates. Point at one and customize with `vars:` — no theme diff --git a/docs/superpowers/plans/2026-10-20-html-book.html b/docs/superpowers/plans/2026-10-20-html-book.html new file mode 100644 index 0000000..9383727 --- /dev/null +++ b/docs/superpowers/plans/2026-10-20-html-book.html @@ -0,0 +1,463 @@ + + + + + + +scriptorium plan · the HTML book output + + + +
+ scriptorium + plan · the HTML book output + execution: native2026-10-20 +
+
+

The HTML book output: implementation plan

+
docs/superpowers/plans/2026-10-20-html-book.html·implements specs/2026-10-20-html-book-design.html
+
0 of 9 tasks done
+ +

Goal. A project declares several outputs; an html output writes an installable, offline, self-updating ebook directory paginated by CSS columns.

+

Architecture. project.py parses outputs:. The PDF path stays prepare() + WeasyPrint. A new scriptorium/book.py takes prepare()'s units, filters the theme CSS for browsers, resolves cross-references, collects images and fonts, and writes the reader from themes/base/reader/ plus a versioned sw.js.

+

Stack. Python 3.12, markdown-it-py, Pillow (WebP, icons), plain JS and CSS for the reader, pytest, Playwright with a system Chromium for browser tests.

+

Execution. Native: one agent implements every task in order, then one review of each PR's branch. The tasks share interfaces tightly (Output, prepare(), book.py), and the reader was already proven in a spike at ~/Workspace/.playground/scriptorium-html-book/ (reader.js, reader.css, sw.tpl.js, check.py, check_update.py), which tasks 7 and 8 port.

+ +

Base: three open pull requests

+

This work sits on three PRs that have waited since 2026-10-05. They were merged first, each updated with main by a merge commit:

+ + + + + + + +
PRWhat this plan uses from itState on 2026-10-20
#10 ciThe test suite on every PR, so "merge only on green CI" means something.merged 2026-10-20
#11 langlang: in frontmatter and project, read by galley._lang(); the reader writes it into <html lang> for hyphenation.merged 2026-10-20
#3 render-htmlrender.prepare(src, *, theme_name, cwd, ...) -> Prepared(units, theme, meta, warnings, env); emit.py with emit(), _emit_css(), _page_size(); WeasyPrint as the pdf extra.merged 2026-10-20, after porting the formal theme's furniture CSS and _lang() into emit.py
+

#3 also adds render --html, which writes the plain document HTML. It stays as it is. The book is the html output format and, for a single file, --format html.

+ +

Global constraints

+
    +
  • uv run pytest passes before every commit; browser tests are marked browser and skip when no Chromium is found.
  • +
  • The engine stays theme-agnostic: reference formats live in each theme's theme.yml under html-refs:, not in Python.
  • +
  • Every URL the site writes is relative. No file names a host or a path.
  • +
  • The build is deterministic: same sources, byte-identical directory.
  • +
  • Cache name book:<scope path>:<version>; version is the first 12 hex digits of sha256 over every file except sw.js, sorted by path, hashing path then bytes.
  • +
  • Defaults from the spec: image-max-width 1600; two pages when width ≥ 900 and width/height ≥ 1.15; swipe turns past 14% of the width or above 0.45 px/ms; tap zones 30%; wheel threshold 40 px, pause 380 ms; font sizes 15, 16, 17, 18, 19, 20, 22, 24, 26, 28 px, default 18 on a phone and 19 otherwise.
  • +
  • Public repo: no personal names, machines or servers in code, docs, commits or PR bodies.
  • +
  • Conventional commits, one logical change each; stage named paths only.
  • +
+ +

Review focus

+
    +
  • A book with no images and no cover: builds, gets letter icons, and opens on its title page. Test in task 8.
  • +
  • Heading ids with accents (capítulo-1-…): TOC jumps, #id URLs and in-book links resolve after percent-decoding. Browser test in task 7.
  • +
  • A word longer than a column, a wide table or a long code line does not spill into the next page: code wraps (pre-wrap), tables scroll inside their own box. Browser test in task 7 asserts no block's right edge passes its column.
  • +
  • The same image referenced twice, or with spaces in its path, becomes one file and both tags point to it. Unit test in task 6.
  • +
  • Two books on one origin: updating one leaves the other's cache intact. Browser test in task 8 serves /a/ and /b/.
  • +
+ +
PR 1 · feat: several outputs per project · branch outputs
+ +
+1Outputs in the project filescriptorium/project.py · tests/test_outputs.py +
produces
@dataclass Output(name: str, format: str, file: str | None = None, dir: str | None = None, page: dict | None = None, vars: dict = {}, cover: str | None = None, image_max_width: int = 1600, js: list[str] = [], css: list[str] = []); Project.outputs: list[Output]; class ProjectError(ValueError).
+
    +
  1. Write the failing tests: + +_project(tmp_path, yaml_text) writes the YAML and an a.md, then calls load().
  2. +
  3. Run uv run pytest tests/test_outputs.py -q: 4 failures (no outputs attribute).
  4. +
  5. Add Output, ProjectError and the parsing in load(). Allowed keys per format: pdf takes format file page vars; html takes format dir vars cover image-max-width js css (YAML kebab-case maps to the snake_case field).
  6. +
  7. Run the file again: 4 pass. Run the whole suite: green.
  8. +
  9. Commit feat(project): outputs: names several renders of one project.
  10. +
+
verify
uv run pytest -q ends with "passed" and no failures.
+
+ +
+2Per-output page size and vars for PDFsscriptorium/render.py · scriptorium/emit.py · scriptorium/galley.py · tests/test_outputs.py +
produces
prepare(..., page: dict | None = None) merges into theme.meta["page"] before CSS is emitted; render_pdf(..., page: dict | None = None) passes it through. _SIZES["a5"] = (148.0, 210.0).
+
    +
  1. Failing test: render a two-paragraph document twice, once with page={"size": "A5"}; read the first page's mediabox with pypdf. + +_mm(path) converts PdfReader(path).pages[0].mediabox from points (×25.4/72).
  2. +
  3. Run it: fails with an unexpected keyword page.
  4. +
  5. Implement: copy theme.meta["page"] before updating it, so the cached theme of another render is never mutated. Per-output vars already have a route (vars=); the CLI merges project vars with output vars, output winning.
  6. +
  7. Run: passes; full suite green. Commit feat(galley): a render can override the theme's page size.
  8. +
+
verify
uv run pytest tests/test_outputs.py -q all pass.
+
+ +
+3CLI: render every output, --only, --formatscriptorium/cli.py · tests/test_outputs.py · README.md · CHANGELOG.md +
consumes Project.outputs, render_pdf(..., page=). produces scriptorium render X.yaml [--only NAME]... [-o PATH]; scriptorium render X.md --format {pdf,html}.
+
    +
  1. Failing tests through cli.main([...]) on a tmp project with outputs print and pocket: +
  2. +
  3. Run: fail.
  4. +
  5. Implement. Output paths resolve against the project directory. Each output prints one line: pocket: small.pdf, 3 page(s). In this PR an html output, and --format html, exit 2 with "html output is not available yet".
  6. +
  7. Run: pass; suite green.
  8. +
  9. README: a short "Several outputs" section with the YAML from the spec's section 04. CHANGELOG entry.
  10. +
  11. Commit feat(cli): render builds every output; --only picks. Push, open PR 1, stop for review.
  12. +
+
verify
uv run scriptorium render examples/…/scriptorium.yaml on a two-output project writes both PDFs; pdfinfo small.pdf | grep "Page size" shows 419.53 x 595.28 pts.
+
+ +
PR 2 · feat: the HTML book · branch html-book (stacked on outputs)
+ +
+4Theme CSS for a browserscriptorium/book.py · tests/test_book_css.py +
produces browser_css(css: str) -> str
+
    +
  1. Failing tests on string fixtures: +
  2. +
  3. Run: fail (no module).
  4. +
  5. Implement with a brace-depth scanner over the CSS text (regexes cannot drop a nested @page{…{…}}). Remove a declaration whose value contains target-counter(, target-text(, counter(page, leader( or whose property is string-set. Drop rules whose selector list is exactly .page, .page:first-child, .slide, .slide:last-child.
  6. +
  7. Run: pass. Commit feat(book): filter theme CSS down to what a browser runs.
  8. +
+
verify
uv run python -c "from scriptorium.book import browser_css; from scriptorium.theme import load_theme; c=browser_css(load_theme('book').css); assert '@page' not in c and 'target-' not in c; print(len(c))" prints a length.
+
+ +
+5Cross-references as textscriptorium/book.py · themes/base/theme.yml · themes/book/theme.yml · tests/test_book_refs.py +
produces resolve_refs(units: list[Unit], formats: dict[str, str], vars: dict) -> list[str] (warnings); fills each empty <a class="ref-K" href="#ID"></a> in place. Formats come from theme.meta.get("html-refs", {}).
+
    +
  1. Add to themes/base/theme.yml: html-refs: {fig: "{figure-ref-label} {figure}"}. To themes/book/theme.yml: html-refs: {chap: "Chapter {chapter}", sec: "§{chapter}.{section}", fig: "“{text}”"} (merged over base by the existing meta merge; check that a dict key merges and does not replace, and extend the merge for html-refs if it replaces).
  2. +
  3. Failing tests: +
  4. +
  5. Run: fail.
  6. +
  7. Implement: one pass in document order. chapter counts <h1> without class unnumbered or part and resets section; section counts <h2>; figure counts <figure id="fig-…">. Record for each id its counters and its text (heading text, or figcaption with tags stripped). Then fill each ref with formats[kind].format_map(...) over counters, text and vars (figure-ref-label falls back to figure-label). A kind with no format renders the target's text. A missing target stays empty and adds "unresolved reference @fig-none".
  8. +
  9. Run: pass; suite green. Commit feat(book): resolve cross-references to text for the browser.
  10. +
+
+ +
+6Images and fonts into the sitescriptorium/book.py · pyproject.toml · tests/test_book_assets.py +
produces collect_images(html: str, base_dir: Path, out_dir: Path, max_width: int) -> tuple[str, list[str]] and collect_css_assets(css: str, out_dir: Path) -> str.
+
    +
  1. Add pillow>=10 to dependencies (WeasyPrint pulls it today, but the book needs it without the pdf extra).
  2. +
  3. Failing tests: +
  4. +
  5. Run: fail.
  6. +
  7. Implement. Local means not http:, https: or data:; percent-decode before resolving. Raster (png jpg jpeg gif webp): convert to RGB or RGBA, shrink to max_width with LANCZOS, save WebP quality 80, method 6, no metadata; name img/<sha256(source bytes + str(max_width))[:12]>.webp. SVG: copied as img/<hash>.svg. Write width/height, replacing existing ones. CSS: each url('file://…') goes to fonts/ for woff2, woff, ttf, otf and to assets/ otherwise, keeping the basename (hash-prefixed on a clash).
  8. +
  9. Run: pass. Commit feat(book): collect images as WebP and theme fonts into the site.
  10. +
+
+ +
+7emit_book, render_book and the readerscriptorium/book.py · themes/base/reader/{index.html,reader.css,reader.js} · scriptorium/cli.py · tests/test_book_html.py · tests/test_reader_browser.py · tests/conftest.py · examples/html-book/ +
consumes tasks 1, 4, 5, 6 and prepare(). produces render_book(src: str, out_dir: Path, *, output: Output, base_dir: Path, theme_name: str | None, vars: dict, project_meta: dict, execute: bool, code_root: str | None) -> BookReport(files: int, bytes: int, version: str, warnings: list[str]); emit_book(units, theme, meta, output) -> str. JS: window.scriptoriumReader = {go(page), goTo(el), page, pages, relayout()}; events reader:ready, reader:page {page, pages, chapter}, reader:layout on document.
+
    +
  1. prepare() needs two switches for this path: footnote_mode="document" forced and toc=False (no fill_toc()). Add them as keyword arguments with today's behaviour as default.
  2. +
  3. Example book examples/html-book/: scriptorium.yaml with outputs print, pocket (A5) and web; three short chapters with lang: es, an accented heading, a cover image, one figure with {#fig-…} and a reference to it, one footnote, a 300-character word, a wide table and a long code line. Images generated by a tiny script are fine; keep the folder under 300 KB.
  4. +
  5. Failing structure tests on the example's built web/: + +site is a session fixture that runs main(["render", "examples/html-book/scriptorium.yaml", "--only", "web"]) into a tmp copy.
  6. +
  7. Run: fail.
  8. +
  9. Implement book.py. Chapters split at h1 when any unit is an h1, else at h2; a scene figure immediately before a chapter heading belongs to that chapter. Units are wrapped as emit() wraps them. Full-page units become <section class="fullpage">. Cover and title page from output.cover and vars. Drop cap only when the first paragraph starts with a letter (re.match(r"<p>\w", …)). CSS order: browser_css(_emit_css(...)), then reader.css, then the theme's reader.css if present, then project css: and output css:. Output js: files are copied and loaded with defer after reader.js. The directory is deleted and rewritten.
  10. +
  11. Port the reader from the spike's reader.js and reader.css, with the spec's fixes: panel state through ids, dragstart cancelled and images not draggable, in-book # links turn to their target (decodeURIComponent), rectos (after each layout, in a spread, walk .recto elements in order and insert one .blank page before any that lands on a left column), pre{white-space:pre-wrap}, tables in a scroll box, reader API and events. Colours from theme vars: --ink, --muted, --rule, --accent, --brand, --body-font, --heading-font.
  12. +
  13. Wire the CLI: an html output calls render_book and prints web: web/, 14 files, 412 KB, version 1a2b3c4d5e6f; --format html on a single file writes <stem>/.
  14. +
  15. Run structure tests: pass.
  16. +
  17. Browser tests (@pytest.mark.browser), from the spike's check.py: a fixture serves the site with http.server on a free port; launch Chromium from Playwright's cache or /usr/bin/chromium-browser, else skip. At 390×844 with touch and at 1440×900: +
  18. +
  19. Measure: build repos/books-tsoc's project with an added web output into /tmp, open it at 390×844 with CDP CPU throttling ×4, time layout() via performance.now(). Write the number in the PR body. Over 300 ms: stop and bring it to the maintainer before merging (spec, open questions).
  20. +
  21. Commit in logical pieces (feat(book): emit the book directory, feat(reader): …, test(reader): …). Push, open PR 2 stacked on PR 1, stop.
  22. +
+
verify
uv run pytest -q and uv run pytest -m browser -q both green; open the example on localhost and look at both sizes.
+
+ +
PR 3 · feat: installable and offline · branch html-book-pwa (stacked on html-book)
+ +
+8Manifest, icons, service worker, updatesscriptorium/book.py · themes/base/reader/sw.js · themes/base/reader/reader.js · tests/test_book_pwa.py · tests/test_reader_browser.py +
produces make_icons(cover: Path | None, title: str, brand: str, out_dir: Path) -> None (192, 512 and maskable 512 PNGs in icons/); site_version(out_dir: Path) -> str; sw.js template with __VERSION__ and __ASSETS__.
+
    +
  1. Failing unit tests: +
  2. +
  3. Run: fail.
  4. +
  5. Implement from the spike's sw.tpl.js and build.py. Icons: centre-crop the cover to a square; without a cover, the title's first letter in the theme's heading font on --brand. Maskable icon: the same art at 80% on a brand background.
  6. +
  7. Reader: register with updateViaCache: 'none', registration.update() on visibilitychange, track controlled across controllerchange, notice "A new edition of this book is available." with an Update button that saves the place and reloads; "✓ offline" when a cache with this book's prefix exists; Install button on beforeinstallprompt; iOS hint. The notice text takes vars.lang: English and Spanish strings built in, other languages fall back to English.
  8. +
  9. Browser tests, from the spike's check.py and check_update.py: offline reload shows the book with every image decoded; rebuilding with one word changed shows the notice and Update shows the word; two copies served at /a/ and /b/, updating /a/ leaves /b/'s cache key unchanged.
  10. +
  11. Run all: green. Commit feat(book): install, offline reading and updates.
  12. +
+
+ +
+9Docs, example, changelogREADME.md · know-how/the-html-book.md · AGENTS.md · CHANGELOG.md · docs/superpowers/specs/2026-08-14-html-ebook-design.md · docs/superpowers/plans/2026-08-14-html-ebook.md +
    +
  1. README: "The HTML book" (the output keys, what the reader does) and "Publishing an HTML book" (HTTPS, sw.js not cached long, manifest MIME type, and a Caddy example with mutually exclusive matchers).
  2. +
  3. know-how/the-html-book.md with a when: line: the column geometry, why JS never measures text, rectos, the cache naming, and the six spike defects as rules. Add it to the AGENTS.md know-how index and add book.py to the pipeline list.
  4. +
  5. One line at the top of the 2026-08-14 spec and plan: superseded by this spec.
  6. +
  7. CHANGELOG entry; bump the minor version.
  8. +
  9. Commit docs: the HTML book. Push, open PR 3 stacked on PR 2, stop.
  10. +
+
verify
grep -m1 -H '^when:' know-how/*.md lists the new doc; the README's YAML example builds as written.
+
+ +

Where this plan departs from the spec

+
    +
  • The shared front is not lifted in this work: prepare() already exists in PR #3 (scriptorium/render.py), so delivery step 1 becomes "merge #3" plus two keyword arguments (task 7, step 1).
  • +
  • Reference formats for HTML live in theme.yml under html-refs:. The spec said "counts as the theme's counters do"; the counting rules are fixed in book.py (chapter, section, figure) because they are the only counters any theme uses today, and the strings stay with the theme.
  • +
  • The spec named the module book.py with emit_book(); it also gets render_book() as the entry point next to render_pdf().
  • +
+
+ + + + diff --git a/docs/superpowers/specs/2026-10-20-html-book-design.html b/docs/superpowers/specs/2026-10-20-html-book-design.html new file mode 100644 index 0000000..c265a99 --- /dev/null +++ b/docs/superpowers/specs/2026-10-20-html-book-design.html @@ -0,0 +1,497 @@ + + + + + +scriptorium spec · the HTML book output + + + + +
+ scriptorium + spec · the HTML book output + draft2026-10-20supersedes 2026-08-14-html-ebook +
+ +
+ + +
+

One source, many outputs: the installable HTML book

+
docs/superpowers/specs/2026-10-20-html-book-design.html·not started
+

A scriptorium project declares several outputs: one or more PDFs at different page sizes, and an HTML book. The HTML book is a static directory that reads like an ebook: swipe to turn on a phone, two facing pages on a desktop. It installs as an app, reads offline with every image and font cached, and picks up a new edition from the network by itself.

+ +

01What this delivers

+
+
+

Several outputs, one source

+

A new outputs: list in scriptorium.yaml. scriptorium render scriptorium.yaml builds all of them; --only <name> builds one. A PDF output can override the theme's page size and margin, so the same book renders at A4 and A5.

+
+
+

An HTML book

+

A directory with index.html, the reader's CSS and JS, WebP images and the theme's fonts. Pages come from CSS multi-column layout; JavaScript only moves the strip, handles gestures and remembers the place. All URLs are relative, so the directory works under any path on any static host.

+
+
+

Installable, offline, self-updating

+

A web manifest, icons from the cover, and a service worker that precaches every file of the book under a cache named by the book's path and the hash of its content. A changed book changes sw.js; an open reader is told and can reload into the new edition at the same place.

+
+
+

A throwaway spike proved the approach on a 4,600-word illustrated children's novel, using scriptorium's parse() as a library and its own emitter. Measured in headless Chromium:

+
+
18
files in the site, 1.97 MB (8 WebP images, 4 woff2)
+
49
pages on a 390×844 phone
+
21
two-page spreads at 1440×900
+
4/4
checks pass at both sizes on the published copy: TOC jump, turn, resume on reload, offline reload with all images
+
1
update check, on a local server: notice shown, new text after Update, old cache deleted
+
+ +

02Decisions

+ + + + + + + + + + + + + + + + + + +
DecisionWhyWhose
One source renders to many outputs, declared in the project fileThe same book must ship as PDF and HTML, and as PDFs of more than one size, without a second source tree.maintainer
The HTML output is a static directory served from any host and path, like a GitHub Pages siteBooks are published side by side under one domain, one directory per book.maintainer
Install, offline reading and automatic updates are in scopeThis reverses the 2026-08-14 spec, which left offline to a downstream app. That app is what this output replaces.maintainer
CSS paginates; JavaScript never measures text or splits paragraphsMulti-column layout breaks text into pages and honours the same break-* rules the PDF uses. JS measurement was the source of bugs in the earlier app reader.maintainer
Each book is its own project; pages that list several books are not scriptorium's jobA collection index is a hand-made page next to the book directories.maintainer
A book can add its own JS with a js: key, as it adds CSS with css:; no plugin API beyond a few DOM eventsBook-specific extras (activities, badges) stay possible without engine overhead now.maintainer
No Node, no bundler: Python writes every file, including sw.js and the manifestscriptorium is a Python tool; the reader is about 300 lines of plain JS and needs no build.agent
The single-file embedded mode of the 2026-08-14 spec is droppedA page opened from a file:// URL cannot register a service worker, and a 2 MB base64 page cannot be cached per asset.agent
Swipe is handled in JS with pointer events, not CSS scroll snapSnapping to a column needs the ::column pseudo-element, which MDN lists as limited availability, not Baseline.agent
Raster images become WebP at build time, capped at a configurable width, with intrinsic width/height written into the tagSize: the spike's 8 images went from JPEG to 1.6 MB of WebP at 1200 px. Dimensions: an image without them reflows every column when it loads.agent
Cache name is book:<path>:<content hash>; the worker deletes only caches with its own path prefixMany books share one origin; one book's update must never evict another.agent
An update shows a notice with an Update button; it does not reload by itselfThe new edition is already cached and opens next time anyway; a reload mid-sentence is worse than a notice.agent
Cross-references are resolved to text in Python for HTML; page numbers in references are droppedThemes number references with target-counter() and target-text(), which browsers do not implement; without this, every reference renders empty.agent
The reader's chrome and its light, sepia and dark modes are built on the theme's variablesA book keeps its theme's fonts and colours in the browser.agent
+ +

03How it fits

+

Everything up to parse() is shared. The PDF path is untouched. The HTML path forces endnotes, skips fill_toc(), and hands the units to a new emitter in scriptorium/book.py that writes a directory.

+
+ + + + + scriptorium.yaml + + Markdown files + + shared front + footnotes · citations + glossary · timeline + parse → units + + emit() + → WeasyPrint + + emit_book() + new: book.py + + book.pdf + book-a5.pdf … + + site/ + reader + sw.js + + + + + + HTML path: footnotes forced to "document", no fill_toc() + + +
New code is in amber. galley.py, parse.py, execute.py and the preprocessors keep their behaviour.
+
+

The shared front is the first half of render_pdf() today. Delivery step 1 lifts it into a function both paths call, so the HTML path never copies it.

+ +

04Outputs in a project

+

A project without outputs: behaves exactly as today: one PDF, book.pdf unless -o says otherwise. With outputs:, each entry names a format and may override a few keys for that output only.

+
# scriptorium.yaml
+theme: book
+vars: {title: "Treasure Island", subtitle: "Retold for children", lang: es}
+files: [ch01.md, ch02.md, ch03.md]
+
+outputs:
+  print:                       # the name is what --only takes
+    format: pdf
+    file: book.pdf
+  pocket:
+    format: pdf
+    file: book-a5.pdf
+    page: {size: A5, margin: 14mm}    # overrides the theme's page block
+  web:
+    format: html
+    dir: site                  # written fresh on every build
+    cover: img/cover.jpg       # cover page, app icons, manifest
+    image-max-width: 1600      # default 1600
+    js: [extras.js]            # optional, see section 10
+    css: [web.css]             # optional, after the project css
+
    +
  • scriptorium render scriptorium.yaml builds every output and prints one line per output (pages for a PDF; files, size and version hash for HTML).
  • +
  • --only web builds one; repeatable. -o is refused when there is more than one output, because it is ambiguous.
  • +
  • A single Markdown file takes --format html and writes <stem>/, so trying the reader needs no project file.
  • +
  • page.size accepts the names in _SIZES plus A5, and "<w>mm <h>mm", the forms _page_size() already parses. Per-output vars may also override appearance vars.
  • +
  • Unknown keys in an output are an error naming the key, not a silent ignore.
  • Paths in an output (file, dir, cover, js, css) are relative to the project file. file defaults to <name>.pdf and dir to <name>/. --format applies only to a single Markdown file; on a project, --only selects. agent
  • +
+ +

05The site directory

+
site/
+  index.html             # the whole book: cover, title page, one <section> per chapter
+  reader.css  reader.js  # copied from themes/base/reader/, theme CSS filtered in (section 07)
+  manifest.webmanifest
+  sw.js                  # VERSION = first 12 hex of sha256 over every other file
+  icons/icon-192.png  icons/icon-512.png  icons/icon-maskable-512.png
+  img/…webp  img/…svg    # every local image the book references, renamed by content hash
+  fonts/…woff2           # every font the theme's @font-face rules reference
+  extras.js              # the project's js:, if any
+
    +
  • Every URL in every file is relative. No file names the host or the path it is served from; the worker and the reader derive their scope from location.
  • +
  • The build is deterministic: the same sources produce byte-identical files, so an unchanged book keeps its version and readers see no update notice. A test pins this.
  • +
  • The output directory is removed and rewritten on each build, so a deleted image does not linger in the cache list.
  • +
  • emit_book() wraps units exactly as emit() does (.unit, .keep, .break-before), so the theme's break rules apply unchanged. Math stays inline as the SVG data URIs mathrender.py already emits. Besides fonts, any local file a theme's url() references (a logo, a texture) is copied and precached. agent
  • +
  • Chapters split at level-1 headings when the book has any, otherwise at level 2. A \newpage or ::: newpage becomes a column break.
  • +
  • The manifest takes name from vars.title, description from vars.subtitle, lang from vars.lang (default en), and colours from the theme's --brand and paper. start_url and scope are ./. Without a cover:, icons are drawn from the title's first letter on the brand colour.
  • +
+ +

06Pages from columns

+

The book is one element, #book, with a fixed height and column-width set to a page's text width. Its overflowing columns extend to the right; the stage clips them, and turning a page translates #book by one screen width. The geometry is chosen so a screen is always a whole number of columns:

+
    +
  • Stage width W (even, whole pixels), side margin M, column gap 2M, padding M on each side of #book.
  • +
  • One page per screen: column width W − 2M, so a column plus a gap is exactly W.
  • +
  • Two pages per screen: column width W/2 − 2M, so two columns plus two gaps is exactly W, and the inner gutter is 2M.
  • +
  • Two pages when the window is at least 900 px wide and wider than 1.15 times its height. The stage keeps a book's proportions on large screens (spread: width up to 1.42 times the height; single page on a tablet: 0.74), centred on a dark desk.
  • +
  • An element's page is floor((left(el) − left(#book) − M) / (column width + 2M)), read from getBoundingClientRect(). This is the only measurement the reader makes, and it reads a layout CSS already did.
  • +
+ +

try itThe geometry for a window

+
+
+ + + +
+
+
+
+ +

Full pages and rectos

+
    +
  • A full-page unit (a master such as a cover or a part opener, or a figure marked {.fullpage}) is a block as tall as the page's text area with break-after: column; its image is fitted with max-width/max-height: 100%. The cover is the first full page.
  • +
  • Multi-column layout cannot start a block on a left or right column. The reader fixes this after layout: in a spread, every element marked .recto (chapter openers by default) that lands on a left column gets an empty page inserted before it, walking in document order. The spread also starts with a blank left page so the cover is a right-hand page.
  • +
  • Folios count from the first page after the title page; covers, blanks and full pages show none.
  • +
+ +

07Theme CSS in a browser

+

_emit_css(theme, meta) is reused and filtered, as the 2026-08-14 spec laid out; the reader's own CSS is appended after it.

+ + + + + + + + + + +
Rule in the themeIn the HTML book
@page …, margin boxesRemoved. The page is the column.
.page, .slide blocksRemoved; full pages use the reader's .fullpage.
string-set, counter(page)Removed. The reader shows the chapter in its footer.
break-before: pageRewritten to break-before: column.
target-counter() / target-text() in ::afterRemoved from the CSS. Before emitting, a Python pass walks the units in order, counts chapters, sections and figures exactly as the theme's counters do, and writes the reference text into each a.ref-*. The "(p. N)" part is dropped. Back-links in citations, glossary and timeline show an arrow instead of a page number. TOC page numbers are not emitted at all.
Fonts, colours, components, callouts, code, tablesKept as they are.
+

The reader needs these variables from the theme, which every theme extending base sets: --ink, --muted, --rule, --accent, --brand, --body-font, --heading-font. It defines --paper itself and switches --paper, --ink and --muted for sepia and dark. A theme may ship reader.css next to styles.css; it is appended last.

+ +

08The reader

+
+
+

Turning

+

Swipe: the strip follows the finger; release past 14% of the width, or a flick faster than 0.45 px/ms, turns. Tap the outer 30% on either side to turn, the middle to show the bars. Arrow keys, Page Up/Down, Space, Home and End. A wheel or trackpad turns once per 40 px of travel, then pauses 380 ms.

+
+
+

Bars

+

Hidden while reading. The top bar holds the contents button, the title, Install (when the browser offers it) and the settings button. The bottom bar holds the current chapter, a page slider, "page / pages" and "✓ offline" once the book is cached.

+
+
+

Place

+

Saved as a block, not a page: the index of the last block starting on or before the current screen, plus how many screens into it. A resize or a font change re-lays the book and returns to that block. Stored in localStorage under book:<path>, with font size and mode.

+
+
+

Links

+

A click on any a[href^="#"] inside the book (references, notes, back-links) turns to the target's page instead of scrolling. A URL with #id opens at that element. The contents panel jumps to a chapter's first page, which may be its full-page illustration.

+
+
+

Settings

+

Ten font sizes from 15 to 28 px (default 18 on a phone, 19 otherwise) and three modes: light, sepia and dark. theme-color follows the mode. On iOS, where there is no install prompt, the panel says how to add the book to the home screen.

+
+
+

Typography

+

lang on <html> from vars.lang, so hyphens: auto uses the right dictionary. Justified text, indented paragraphs, no indent after headings and figures. A chapter's drop cap is set at build time, and only when its first paragraph starts with a letter.

+
+
+ +

09Offline and updates

+
+

First visit

The page registers sw.js with updateViaCache: 'none'. The worker's install fetches every file in its list with cache: 'reload' into book:<path>:<version> and calls skipWaiting(). Activate deletes this book's other versions, claims clients and posts "cached"; the footer shows "✓ offline".

+

Reading

Every same-origin GET under the book's path is answered from the cache, ignoring query strings. A navigation request is answered with the cached ./. Anything not cached goes to the network.

+

A new edition is published

Its sw.js differs in its version line. The browser finds it on the next navigation; the reader also calls registration.update() whenever the tab becomes visible. The new worker precaches, activates and takes control.

+

The open reader

On controllerchange after the first, it shows "A new edition of this book is available. Update". Update saves the place and reloads; the place is restored in the new layout. A reader who ignores the notice gets the new edition on the next open.

+
+

What the host must do

+
    +
  • Serve over HTTPS (or localhost), as service workers require.
  • +
  • Not cache sw.js for long. GitHub Pages' ten-minute max-age is fine because the registration bypasses the HTTP cache; a host that sets long cache lifetimes needs Cache-Control: no-cache on sw.js.
  • +
  • Serve .webmanifest as application/manifest+json (GitHub Pages does; some servers need a rule).
  • +
+

The README gets a short "Publishing an HTML book" section with these three points and a two-line Caddy example.

+ +

10Custom JS

+

Files in an HTML output's js: are copied into the site, precached, and loaded with <script defer> after reader.js. The reader exposes a small surface and nothing else:

+
    +
  • window.scriptoriumReader with go(page), goTo(element), page, pages and relayout() (for scripts that change the book's content).
  • +
  • Events on document: reader:ready after the first layout, reader:page with {page, pages, chapter} after each turn, reader:layout after each re-layout.
  • +
+ +

11What the spike taught

+

Each of these was a real defect in the spike; the implementation and its tests must cover them.

+ + + + + + + + + + +
DefectRule
A chapter illustration did not fit under the chapter title, jumped to the next page, and left the title alone on a pageOpening illustrations are full pages before the title, not inline figures after it.
A swipe that started on an image did nothingThe browser began a native image drag and sent pointercancel. Images in the book get -webkit-user-drag: none and pointer-events: none, and dragstart is cancelled.
::first-letter on "—¡Mira…" enlarged the dash and the exclamation markThe drop cap is a class set at build time, only on a paragraph that starts with a letter.
The contents panel never opened#toc outranked .panel.open. Panel state rules use ids or an attribute, and a test opens each panel.
The update notice never showed on a reader's first visitWhether the page was controlled is tracked across controllerchange events, not read once at load.
Two header Cache-Control rules in Caddy: the general one won over the sw.js oneThe README's host example uses mutually exclusive matchers.
+ +

12Delivery

+

Three pull requests, each green on its own.

+
+

Outputs

outputs: in project.py, per-output page and vars, --only and --format in the CLI, the shared front lifted out of render_pdf(). PDF only; HTML is accepted by the parser and refused by the CLI with "not yet". Useful alone: A4 and A5 from one project.

+

The HTML book

book.py with render_book() and emit_book(), the CSS filter, cross-reference resolution, image and font collection, themes/base/reader/{reader.css,reader.js,index.html}, full pages, rectos, the js: hook. Readable from a local server; no worker yet.

+

Installable and offline

Manifest, icons, sw.js with the version hash, the update notice, the offline mark, the install button and iOS hint. An example book in examples/html-book/, the README section, a know-how doc on the reader, and a CHANGELOG entry. The 2026-08-14 spec and plan get a "superseded by" line at the top.

+
+ +

13Testing

+

Unit tests with pytest, as today:

+
    +
  • A project without outputs: renders the same PDF as before (page count and text of the existing project fixtures).
  • +
  • page: {size: A5} on an output yields 148×210 mm pages; the theme's own size is unchanged for the other outputs.
  • +
  • An unknown output key, and -o with several outputs, fail with a message naming the problem.
  • +
  • The site has no @page, target-counter or string-set in its CSS; a figure reference in the example renders its number as text.
  • +
  • sw.js's list equals the set of files in the directory plus ./; every URL in index.html, reader.css and the manifest is relative and exists.
  • +
  • Two builds of the same source are byte-identical; changing one word changes the version.
  • +
  • Every <img> in index.html has width and height.
  • +
+

Browser tests with Playwright, marked browser and skipped when no Chromium is found, over the example book on a local server, at 390×844 with touch and at 1440×900:

+
    +
  • Page count is above 1; a TOC jump lands on the chapter's first page; a swipe that starts on an image turns the page.
  • +
  • In a spread, every .recto element is on a right-hand column.
  • +
  • Reload returns to the same page; a font-size change keeps the same first block on screen.
  • +
  • Offline reload shows the book and every image is decoded.
  • +
  • Rebuilding with a changed word while the page is open shows the update notice; Update shows the new word.
  • +
  • No page errors in the console.
  • +
+

One measurement before the second PR merges: layout time of the largest book at hand on a phone viewport with 4× CPU throttling. Over 300 ms raises the per-chapter question below.

+ +

14Non-goals and open questions

+

Not in this work

+
    +
  • Pages that list several books, and any server or deploy tooling.
  • +
  • EPUB, full-text search, highlights and bookmarks beyond the saved place.
  • +
  • Footnotes as pop-ups; notes are endnotes per chapter, reached by link.
  • +
  • Per-page footnote floats and running heads, which have no column equivalent.
  • +
+

Open

+
    +
  • One strip or one per chapter. One multi-column element for the whole book is simplest, and fast for the spike's 4,600 words. A book of several hundred pages may take long to lay out on a phone at every resize and font change. If the measurement above is over budget, each chapter becomes its own strip laid out on demand, with page numbers summed across chapters. Decided by the number, not now.
  • +
  • Hyphenation on phones. The test Chromium lacks a Spanish hyphenation dictionary, so justified Spanish text showed wide gaps. Android Chrome and Safari ship dictionaries; to be confirmed on real devices. If gaps remain, narrow pages switch to ragged right.
  • +
+
+
+ + + + diff --git a/scriptorium/cli.py b/scriptorium/cli.py index 0e354cd..6d4a607 100644 --- a/scriptorium/cli.py +++ b/scriptorium/cli.py @@ -8,6 +8,53 @@ from .tangle import write as tangle_write +def _warn(report) -> None: + for w in [*report.oversized, *report.warnings, *report.underfull]: + print(f" ⚠ {w}", file=sys.stderr) + + +def _render_project(args) -> int: + """Build every output of a scriptorium.yaml, or the ones --only names.""" + from .galley import render_pdf + from .project import ProjectError + from .project import load as load_project + + try: + proj = load_project(args.input) + except ProjectError as e: + print(f"scriptorium: {e}", file=sys.stderr) + return 2 + outputs = proj.outputs + if args.only: + names = [o.name for o in outputs] + missing = [n for n in args.only if n not in names] + if missing: + print(f"scriptorium: no output named {', '.join(missing)}; " + f"this project has {', '.join(names)}", file=sys.stderr) + return 2 + outputs = [o for o in outputs if o.name in args.only] + if args.output and len(outputs) > 1: + print(f"scriptorium: -o is ambiguous with {len(outputs)} outputs; " + "pick one with --only", file=sys.stderr) + return 2 + + base = args.input.resolve().parent + cwd = str(base) + for o in outputs: + if o.format == "html": + print(f"{o.name}: html output is not available yet", file=sys.stderr) + return 2 + out = args.output or (base / o.file) + report = render_pdf(proj.src, str(out), base_url=cwd + "/", + theme_name=proj.theme, cwd=cwd, + execute=not args.no_execute, vars={**proj.vars, **o.vars}, + code_root=proj.code_root, project_meta=proj.meta, + page=o.page) + print(f"{o.name}: {out} — {report.n_pages} page(s)") + _warn(report) + return 0 + + def main(argv=None) -> int: p = argparse.ArgumentParser(prog="scriptorium") sub = p.add_subparsers(dest="cmd", required=True) @@ -20,6 +67,10 @@ def main(argv=None) -> int: help="override the document's frontmatter `theme:`") r.add_argument("--html", action="store_true", help="write the HTML document instead of a PDF (needs no WeasyPrint)") + r.add_argument("--only", action="append", metavar="NAME", default=None, + help="build only this output of a project (repeatable)") + r.add_argument("--format", choices=("pdf", "html"), default="pdf", + help="for a single file: a PDF (default) or an HTML book") t = sub.add_parser("tangle", help="extract export= code blocks to source files") t.add_argument("input", type=Path) @@ -57,23 +108,10 @@ def main(argv=None) -> int: from .galley import render_pdf if args.input.suffix in (".yaml", ".yml"): # a project - from .project import load as load_project - - proj = load_project(args.input) - out = args.output or (args.input.parent / "book.pdf") - cwd = str(args.input.resolve().parent) - report = render_pdf(proj.src, str(out), base_url=cwd + "/", - theme_name=proj.theme, cwd=cwd, - execute=not args.no_execute, vars=proj.vars, - code_root=proj.code_root, project_meta=proj.meta) - print(f"rendered {out} — {report.n_pages} page(s)") - for w in report.oversized: - print(f" ⚠ {w}", file=sys.stderr) - for w in report.warnings: - print(f" ⚠ {w}", file=sys.stderr) - for w in report.underfull: - print(f" ⚠ {w}", file=sys.stderr) - return 0 + return _render_project(args) + if args.format == "html": + print("html output is not available yet", file=sys.stderr) + return 2 out = args.output or args.input.with_suffix(".pdf") cwd = str(args.input.resolve().parent) report = render_pdf(src, str(out), base_url=cwd + "/", theme_name=args.theme, diff --git a/scriptorium/emit.py b/scriptorium/emit.py index 10fabed..ef1174f 100644 --- a/scriptorium/emit.py +++ b/scriptorium/emit.py @@ -22,7 +22,7 @@ # named page sizes (mm) — landscape slides for decks _SIZES = { - "a4": (210.0, 297.0), "letter": (215.9, 279.4), + "a4": (210.0, 297.0), "a5": (148.0, 210.0), "letter": (215.9, 279.4), "16:9": (254.0, 142.875), "4:3": (254.0, 190.5), } diff --git a/scriptorium/galley.py b/scriptorium/galley.py index c0c25ac..8103a8d 100644 --- a/scriptorium/galley.py +++ b/scriptorium/galley.py @@ -470,12 +470,12 @@ def render_pdf(src: str, out_path: str, base_url: str | None = None, theme_name: str | None = None, cwd: str | None = None, execute: bool = True, vars: dict | None = None, code_root: str | None = None, - project_meta: dict | None = None) -> Report: + project_meta: dict | None = None, page: dict | None = None) -> Report: from .render import prepare HTML = _weasy() p = prepare(src, theme_name=theme_name, cwd=cwd, execute=execute, vars=vars, - code_root=code_root, project_meta=project_meta) + code_root=code_root, project_meta=project_meta, page=page) theme, meta, units, warnings = p.theme, p.meta, p.units, p.warnings _, _, content_h = _geom(theme) diff --git a/scriptorium/project.py b/scriptorium/project.py index 8777e7e..4aab58a 100644 --- a/scriptorium/project.py +++ b/scriptorium/project.py @@ -15,6 +15,59 @@ _VAR = re.compile(r"\{\{\s*([\w.-]+)\s*\}\}") +class ProjectError(ValueError): + """A scriptorium.yaml that cannot be built as written.""" + + +@dataclass +class Output: + """One render of a project: a PDF (`file`) or an HTML book (`dir`).""" + name: str + format: str + file: str | None = None + dir: str | None = None + page: dict | None = None + vars: dict = field(default_factory=dict) + cover: str | None = None + image_max_width: int = 1600 + js: list[str] = field(default_factory=list) + css: list[str] = field(default_factory=list) + + +# the keys each format accepts; anything else is a typo or a key for the +# other format, and silently ignoring it would render the wrong book +_OUTPUT_KEYS = { + "pdf": {"format", "file", "page", "vars"}, + "html": {"format", "dir", "vars", "cover", "image-max-width", "js", "css"}, +} + + +def _outputs(spec) -> list[Output]: + if spec is None: + return [Output(name="pdf", format="pdf", file="book.pdf")] + if not isinstance(spec, dict): + raise ProjectError("outputs: expected a mapping of output names to settings") + outs = [] + for name, cfg in spec.items(): + cfg = cfg or {} + fmt = cfg.get("format") + if fmt not in _OUTPUT_KEYS: + raise ProjectError(f"outputs.{name}: format {fmt!r} is not one of pdf, html") + unknown = sorted(set(cfg) - _OUTPUT_KEYS[fmt]) + if unknown: + raise ProjectError(f"outputs.{name}: unknown key(s) for a {fmt} output: " + + ", ".join(unknown)) + outs.append(Output( + name=name, format=fmt, + file=cfg.get("file", f"{name}.pdf") if fmt == "pdf" else None, + dir=cfg.get("dir", name) if fmt == "html" else None, + page=cfg.get("page"), vars=cfg.get("vars") or {}, cover=cfg.get("cover"), + image_max_width=int(cfg.get("image-max-width", 1600)), + js=list(cfg.get("js") or []), css=list(cfg.get("css") or []), + )) + return outs + + @dataclass class Project: theme: str @@ -23,6 +76,7 @@ class Project: base_dir: Path code_root: str | None = None meta: dict = field(default_factory=dict) # content keys: bibliography, nocite + outputs: list[Output] = field(default_factory=list) def _strip_frontmatter(text: str) -> str: @@ -63,4 +117,4 @@ def load(path: str | Path) -> Project: code_root = (spec.get("code") or {}).get("root") return Project(theme=theme, vars=vars, src=src, base_dir=base, - code_root=code_root, meta=meta) + code_root=code_root, meta=meta, outputs=_outputs(spec.get("outputs"))) diff --git a/scriptorium/render.py b/scriptorium/render.py index ea45d19..f5fc467 100644 --- a/scriptorium/render.py +++ b/scriptorium/render.py @@ -28,12 +28,16 @@ class Prepared: def prepare(src: str, *, theme_name: str | None = None, cwd: str | None = None, execute: bool = True, vars: dict | None = None, code_root: str | None = None, - project_meta: dict | None = None, frozen: bool = False) -> Prepared: + project_meta: dict | None = None, frozen: bool = False, + page: dict | None = None) -> Prepared: """Run the preprocessors and the parser: the units, theme and metadata a document renders from.""" from .parse import frontmatter, parse theme = load_theme(resolve_theme_name(src, theme_name)) + if page: + # one output's page size and margin; a copy, so no other render sees it + theme.meta["page"] = {**theme.meta.get("page", {}), **page} # theme var defaults, overridden by project vars, then by per-doc frontmatter merged = {**theme.vars, **(vars or {})} # a project's chapters have had their frontmatter stripped, so project_meta diff --git a/tests/test_outputs.py b/tests/test_outputs.py new file mode 100644 index 0000000..dfb3ae9 --- /dev/null +++ b/tests/test_outputs.py @@ -0,0 +1,132 @@ +"""outputs: several renders of one project (PDF sizes, the HTML book).""" + +import pytest + +from scriptorium.project import ProjectError, load + + +def _project(tmp_path, yaml_text): + (tmp_path / "a.md").write_text("# A\n\nHello.\n", encoding="utf-8") + path = tmp_path / "scriptorium.yaml" + path.write_text(yaml_text, encoding="utf-8") + return load(path) + + +def test_no_outputs_defaults_to_one_pdf(tmp_path): + p = _project(tmp_path, "theme: book\nfiles: [a.md]\n") + assert [(o.name, o.format, o.file) for o in p.outputs] == [("pdf", "pdf", "book.pdf")] + + +def test_outputs_take_names_and_defaults(tmp_path): + p = _project(tmp_path, """files: [a.md] +outputs: + print: {format: pdf} + pocket: {format: pdf, file: small.pdf, page: {size: A5, margin: 14mm}} + web: {format: html, cover: c.jpg, js: [x.js]} +""") + by = {o.name: o for o in p.outputs} + assert [o.name for o in p.outputs] == ["print", "pocket", "web"] + assert by["print"].file == "print.pdf" + assert by["pocket"].page == {"size": "A5", "margin": "14mm"} + assert by["web"].dir == "web" and by["web"].image_max_width == 1600 + assert by["web"].js == ["x.js"] and by["web"].cover == "c.jpg" + + +def test_kebab_keys_map_to_fields(tmp_path): + p = _project(tmp_path, "files: [a.md]\noutputs:\n web: {format: html, image-max-width: 900}\n") + assert p.outputs[0].image_max_width == 900 + + +def test_unknown_output_key_is_named(tmp_path): + with pytest.raises(ProjectError, match="web.*colour"): + _project(tmp_path, "files: [a.md]\noutputs:\n web: {format: html, colour: red}\n") + + +def test_a_pdf_key_on_an_html_output_is_refused(tmp_path): + with pytest.raises(ProjectError, match="web.*page"): + _project(tmp_path, "files: [a.md]\noutputs:\n web: {format: html, page: {size: A5}}\n") + + +def test_unknown_format_is_refused(tmp_path): + with pytest.raises(ProjectError, match="epub"): + _project(tmp_path, "files: [a.md]\noutputs:\n e: {format: epub}\n") + + +def test_outputs_must_be_a_mapping(tmp_path): + with pytest.raises(ProjectError, match="outputs"): + _project(tmp_path, "files: [a.md]\noutputs: [pdf, html]\n") + + +def _mm(path): + from pypdf import PdfReader + box = PdfReader(path).pages[0].mediabox + return float(box.width) * 25.4 / 72, float(box.height) * 25.4 / 72 + + +def test_page_override_sets_a5_and_leaves_the_theme_alone(tmp_path): + from scriptorium.galley import render_pdf + md = "---\ntheme: book\n---\n\nHello.\n" + render_pdf(md, str(tmp_path / "a5.pdf"), page={"size": "A5"}) + render_pdf(md, str(tmp_path / "a4.pdf")) + w5, h5 = _mm(tmp_path / "a5.pdf") + w4, h4 = _mm(tmp_path / "a4.pdf") + assert abs(w5 - 148) < 0.5 and abs(h5 - 210) < 0.5 + assert abs(w4 - 210) < 0.5 and abs(h4 - 297) < 0.5 + + +def _two_pdf_project(tmp_path): + (tmp_path / "a.md").write_text("# A\n\nHello.\n", encoding="utf-8") + path = tmp_path / "scriptorium.yaml" + path.write_text("""theme: book +files: [a.md] +outputs: + print: {format: pdf} + pocket: {format: pdf, file: small.pdf, page: {size: A5}} +""", encoding="utf-8") + return path + + +def test_render_builds_every_output(tmp_path, capsys): + from scriptorium.cli import main + proj = _two_pdf_project(tmp_path) + assert main(["render", str(proj)]) == 0 + assert (tmp_path / "print.pdf").exists() and (tmp_path / "small.pdf").exists() + assert abs(_mm(tmp_path / "small.pdf")[0] - 148) < 0.5 + out = capsys.readouterr().out + assert "print:" in out and "pocket:" in out + + +def test_only_builds_one(tmp_path): + from scriptorium.cli import main + proj = _two_pdf_project(tmp_path) + assert main(["render", str(proj), "--only", "pocket"]) == 0 + assert not (tmp_path / "print.pdf").exists() and (tmp_path / "small.pdf").exists() + + +def test_dash_o_with_several_outputs_is_refused(tmp_path, capsys): + from scriptorium.cli import main + proj = _two_pdf_project(tmp_path) + assert main(["render", str(proj), "-o", str(tmp_path / "x.pdf")]) == 2 + assert "--only" in capsys.readouterr().err + + +def test_dash_o_with_one_selected_output_writes_there(tmp_path): + from scriptorium.cli import main + proj = _two_pdf_project(tmp_path) + assert main(["render", str(proj), "--only", "print", "-o", str(tmp_path / "x.pdf")]) == 0 + assert (tmp_path / "x.pdf").exists() + + +def test_only_with_an_unknown_name_lists_the_names(tmp_path, capsys): + from scriptorium.cli import main + proj = _two_pdf_project(tmp_path) + assert main(["render", str(proj), "--only", "web"]) == 2 + assert "print, pocket" in capsys.readouterr().err + + +def test_project_without_outputs_still_writes_book_pdf(tmp_path): + from scriptorium.cli import main + (tmp_path / "a.md").write_text("# A\n\nHello.\n", encoding="utf-8") + (tmp_path / "scriptorium.yaml").write_text("theme: book\nfiles: [a.md]\n", encoding="utf-8") + assert main(["render", str(tmp_path / "scriptorium.yaml")]) == 0 + assert (tmp_path / "book.pdf").exists()