fuzzyworld

auto-print / commit

Initial version of auto-print as a package

a6d0aed2e4ddce44aed75088608e3716c4b30e43 · Pavel Safronov · 2026-10-24 10:58:32 -0700

Claude Code usage: this commit ~541.3K out / ~60.4M cached (first commit of its session: everything since it began) · cost unknown (no per-model trailer) · session 1 tally 700 in / 541.3K out / 60.4M cached · usage by commit and session

Initial version of auto-print as a package

Turns markdown manuscripts into epub, docx, pdf and Kindle kpf books with pandoc.
Moved out of the blog folder, where it was a single build script.

Usage: 0.7K in / 541.3K out / 60.4M cached (session tally)
rtk:   1.9M saved, 39.3% over 3896 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 .gitignore                          |   3 +
 .gitlab-ci.yml                      |  16 ++
 CHANGELOG.md                        |  18 ++
 LICENSE                             |  21 ++
 README.md                           | 257 ++++++++++++++++++++++
 assets/epub.css                     |  13 ++
 assets/print.css                    | 113 ++++++++++
 assets/reference.docx               | Bin 0 -> 11014 bytes
 decisions.md                        |  50 +++++
 package-lock.json                   | 415 ++++++++++++++++++++++++++++++++++++
 package.json                        |  47 ++++
 scripts/make-reference-docx.sh      |  31 +++
 src/build.ts                        | 267 +++++++++++++++++++++++
 src/cli.ts                          |  75 +++++++
 src/config.ts                       | 132 ++++++++++++
 src/manuscript.ts                   | 243 +++++++++++++++++++++
 src/util.ts                         |  32 +++
 test/config.test.ts                 |  62 ++++++
 test/fixtures/collection/index.md   |  32 +++
 test/fixtures/collection/story-1.md |  24 +++
 test/fixtures/collection/story-2.md |  15 ++
 test/manuscript.test.ts             | 167 +++++++++++++++
 test/pandoc.test.ts                 | 100 +++++++++
 tsconfig.build.json                 |  12 ++
 tsconfig.json                       |  22 ++
 25 files changed, 2167 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3bdd52e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+dist/
+.DS_Store
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000..536fe15
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,16 @@
+image: node:22
+
+stages: [test]
+
+test:
+  stage: test
+  before_script:
+    # The same pandoc version as the tests were written against. Debian's own package is too old (2.x).
+    - curl -fsSL -o /tmp/pandoc.deb https://github.com/jgm/pandoc/releases/download/3.6.1/pandoc-3.6.1-1-amd64.deb
+    - dpkg -i /tmp/pandoc.deb
+    - pandoc --version | head -1
+  script:
+    - npm ci
+    - npm run typecheck
+    - npm test
+    - npm run build
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..81761e4
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,18 @@
+# Changelog
+
+All notable changes to auto-print are written down here. The format follows
+[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+
+## [0.1.0] - 2026-10-24
+
+The first version as a package. Before this, auto-print was a single script inside the author's blog folder.
+
+### Added
+
+- Builds an epub, a docx, a pdf and a Kindle kpf for each book in a config file.
+- Publishes only the story section of a manuscript (`## Writing` by default), and leaves notes out.
+- Collections of stories: a book's story section can be a list of links to story files.
+- Extra pages from named sections, before or after the story (`front` and `back`), each on its own page.
+- A dedication page with the typical look: centered, in italics, lower on the page, with no visible heading.
+- `chapterBreaks` setting for the epub and kpf.
+- `--config` option. All paths in the config file are relative to its folder. `outDir` sets the output folder.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..88e06c2
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Pavel Safronov
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6a78f98
--- /dev/null
+++ b/README.md
@@ -0,0 +1,257 @@
+# auto-print
+
+Turns markdown manuscripts into books: an epub, a docx, a pdf and a Kindle kpf for each one.
+
+It is made for writers who keep their drafts in markdown, next to their notes.
+A manuscript file can hold a spelling list, character notes and cut scenes. auto-print publishes only the story,
+plus the extra pages you name, such as a dedication or a character guide.
+
+- The source files are never changed.
+- One config file lists the books. One command builds all of them.
+- Under the hood, [pandoc](https://pandoc.org) does the conversion. A Chromium-based browser prints the pdf, and Amazon's Kindle Previewer makes the kpf.
+
+## Requirements
+
+- **Node 22.18 or newer.**
+- **pandoc 3.** It converts the markdown to epub, docx and html. Tested with pandoc 3.6.1. macOS: `brew install pandoc`.
+- **Google Chrome**, or Chromium, Edge or Brave, for the pdf and for generated covers.
+  Set the `CHROME_PATH` environment variable to use a browser in a non-standard location.
+- **Kindle Previewer 4**, only for the kpf. macOS: `brew install --cask kindle-previewer`.
+  Set the `KINDLE_PREVIEWER_PATH` environment variable to use a copy in a non-standard location.
+
+If a tool is missing, only the formats that need it fail. The other files are still written.
+
+## Install
+
+Install the `auto-print` command from GitLab:
+
+```sh
+npm install --global git+https://gitlab.com/PavelSafronov/auto-print.git
+```
+
+Or run it from a clone, with nothing installed. Node runs the TypeScript source directly:
+
+```sh
+git clone https://gitlab.com/PavelSafronov/auto-print.git
+node auto-print/src/cli.ts --config path/to/books.json
+```
+
+## Usage
+
+```sh
+auto-print                          # build every book in ./books.json
+auto-print my-novel                 # build only the named book(s)
+auto-print --config books/books.json
+auto-print --list                   # list the books, build nothing
+```
+
+The epub, docx and pdf take a few seconds in total. The kpf takes about 20 seconds per book.
+Each run overwrites the previous output files.
+
+## The config file
+
+```json
+{
+  "outDir": "out",
+  "defaults": {
+    "author": "Your Name",
+    "formats": ["epub", "docx", "pdf", "kpf"]
+  },
+  "books": [
+    {
+      "slug": "my-novel",
+      "title": "My Novel",
+      "source": "drafts/my-novel.md",
+      "cover": "covers/my-novel.jpg"
+    }
+  ]
+}
+```
+
+Every path in the config file is relative to the folder of the config file.
+The files of each book are written to `<outDir>/<slug>/`, for example `out/my-novel/my-novel.epub`.
+
+| Top-level field | Required | Meaning                                                              |
+| --------------- | -------- | -------------------------------------------------------------------- |
+| `books`         | yes      | The list of books.                                                   |
+| `defaults`      | no       | Settings for every book. A book's own setting wins over the default. |
+| `outDir`        | no       | Output folder. Default `"auto-print-out"`.                           |
+
+| Book field      | Required | Meaning                                                                                   |
+| --------------- | -------- | ----------------------------------------------------------------------------------------- |
+| `slug`          | yes      | Name of the output folder and files. Use lowercase words joined by dashes.                |
+| `title`         | yes      | Book title, used on the cover, the title page and in the file metadata.                   |
+| `source`        | yes      | The markdown file. Its story section can link to other files, see "Collections of stories". |
+| `author`        | yes      | In the book or in `defaults`.                                                             |
+| `cover`         | no       | Cover image for the epub and kpf. Without it, a text cover is generated: title, "by", author. |
+| `section`       | no       | Heading that holds the story. Default `"Writing"`. Use `null` to take the whole file.     |
+| `language`      | no       | Default `"en-US"`.                                                                        |
+| `formats`       | no       | Default `["epub", "docx", "pdf", "kpf"]`. List fewer to skip some.                        |
+| `chapterBreaks` | no       | epub and kpf only. Default `false`. See "Chapter breaks in the epub".                     |
+| `front`         | no       | Headings whose sections become pages before the story. See "Extra pages".                 |
+| `back`          | no       | Headings whose sections become pages after the story. See "Extra pages".                  |
+
+## What happens to a manuscript
+
+1. auto-print finds the heading named `Writing` (or the `section` you set). Any heading level, any letter case.
+   If the name is used at several levels, the shallowest heading wins.
+2. It takes everything under that heading, up to the next heading of the same or a higher level.
+   Everything else in the file, such as notes or removed scenes, is left out.
+3. It removes HTML comments, such as a blog's `<!--more-->` marker.
+4. It promotes the headings, so the story's top headings become chapters. If the story's chapters are `###` headings, they become level 1.
+5. Text before the first chapter heading, such as a content warning, is kept as an opening section.
+6. Images are found relative to the file they are written in, so `![map](./map.jpg)` works.
+
+A manuscript might look like this:
+
+```markdown
+# My Novel
+
+## Notes
+
+Things only the author should see.
+
+## Writing
+
+[Content warning: peril.]
+
+### Chapter One
+
+#### The morning
+
+The hero woke up.
+
+## Removed writing
+
+A scene that was cut.
+```
+
+## Collections of stories
+
+A book can be a collection of stories that each live in their own file.
+The book's `source` is then an index file, and its `Writing` section holds only links to the story files, one per line:
+
+```markdown
+## Writing
+
+[The first story](./story-1.md)
+
+[The second story](./story-2.md)
+
+<!-- [The third story](./story-3.md) -->
+```
+
+- auto-print reads the `Writing` section of each linked file, in the order of the links.
+- A link inside an HTML comment (`<!-- -->`) is left out. Use this for a story that is not ready.
+- A `Writing` section that mixes links with other text stops the build with an error.
+
+Each story file is handled with the rules in "What happens to a manuscript", with one difference:
+the text before a story's first heading, such as its content warning, is moved to just below that heading.
+Without this move, a story's content warning would appear at the end of the previous story.
+
+## Extra pages
+
+Pages such as a dedication, a content warnings page, a character guide or a map are sections in the book's source file.
+The `front` and `back` lists name the headings of those sections, in the order they appear in the book.
+
+```json
+{
+  "slug": "my-collection",
+  "title": "My Collection",
+  "source": "my-collection.md",
+  "front": ["Dedication", "Content Warnings"],
+  "back": ["Characters", "Map"]
+}
+```
+
+```markdown
+## Dedication
+
+To my city.
+
+## Characters
+
+### The hero
+
+The one the stories are about.
+
+## Map
+
+![Map of the city](./map.jpg)
+```
+
+Only the listed sections are published. Other sections, such as notes, stay private.
+
+- Headings match ignoring letter case. If a name is used at several heading levels, the shallowest heading wins.
+  So a `## Characters` page is published, and `### Characters` notes inside a `## Notes` section are not.
+- Two headings with the same name at the same level stop the build with an error. So does a listed name that is not in the file.
+- The section heading becomes the page title. Headings inside the section become sub-headings of the page.
+- Every extra page starts on a new page, in every format.
+- A section named `Dedication` has the typical look of a dedication: centered, in italics, about a third of the way down the page.
+  Its heading is not shown, and it is left out of the table of contents.
+- A section named `Copyright` is left out of the table of contents.
+- In the epub and the kpf, front pages are marked as front matter, and back pages as back matter (appendices).
+  E-readers use these marks to know where the story starts.
+
+## Chapter breaks in the epub
+
+The `chapterBreaks` setting controls how the chapters appear in the epub and the kpf.
+
+- `false` (the default): the story flows continuously. A chapter is only a heading in the text.
+- `true`: every chapter starts on a new page.
+
+In both cases the chapters are listed in the table of contents.
+The docx and the pdf always flow continuously.
+
+## Sentences on separate lines
+
+A manuscript can put each sentence on its own line, or write each paragraph as one long line.
+Both styles produce the same book.
+
+- A single line break joins the two lines with a space, inside the same paragraph.
+- A new paragraph starts only at an empty line. A line that holds only spaces also counts as empty.
+
+This works because auto-print reads the files as GitHub-flavored markdown (`gfm`), where a single line break is not a real line break.
+
+A line can be read as markdown formatting by mistake. Avoid these at the start or end of a line:
+
+| In the manuscript                                          | What happens                                    |
+| ---------------------------------------------------------- | ----------------------------------------------- |
+| A line that starts with `- `, `* `, `+ ` or `1. `          | It starts a list and ends the paragraph. Other numbers, such as `2. `, are safe. |
+| A line of only `---` or `===`, directly after a text line  | The line above it becomes a heading.            |
+| A line that starts with `#` and a space                    | It becomes a heading.                           |
+| A line that starts with `>`                                | It becomes a block quote.                       |
+| A line that starts with `:::`                              | It starts or ends a styled block.               |
+| Two spaces, or a `\`, at the end of a line                 | A real line break is kept inside the paragraph. |
+| `{.word}`, `{#word}` or `{key=value}` at the end of a heading, or right after an image or link | It is read as a setting and not shown. Other braces, like `{quietly}`, are shown as written. |
+
+A dash such as `—` at the start of a line is safe.
+
+## How each format is made
+
+- **epub**: pandoc, with a table of contents built from the headings.
+  The stylesheet is pandoc's default one, plus [assets/epub.css](assets/epub.css).
+- **docx**: pandoc, with [assets/reference.docx](assets/reference.docx) as the Word template.
+  It is pandoc's default template plus a `Dedication` paragraph style. Chapters use the `Heading 1` style.
+- **pdf**: pandoc makes a standalone html page with [assets/print.css](assets/print.css), then a headless browser prints it.
+  The browser does not always exit by itself after printing, so auto-print waits for the pdf and then stops the browser.
+- **kpf**: Kindle Previewer's command line tool converts an epub into a kpf.
+  auto-print builds a fresh epub for this, so the kpf works even if `epub` is not in `formats`.
+
+## Development
+
+```sh
+npm install          # TypeScript and Node's type definitions, for the checks below
+npm test             # unit tests, plus pandoc tests that are skipped when pandoc is missing
+npm run typecheck
+npm run build        # compiles src/ to dist/, which the installed command runs
+```
+
+The code has no runtime dependencies. [decisions.md](decisions.md) records why it works the way it does.
+
+`assets/reference.docx` is made by [scripts/make-reference-docx.sh](scripts/make-reference-docx.sh).
+Run it again to change the Word styles.
+
+## License
+
+[MIT](LICENSE)
diff --git a/assets/epub.css b/assets/epub.css
new file mode 100644
index 0000000..af26927
--- /dev/null
+++ b/assets/epub.css
@@ -0,0 +1,13 @@
+/* auto-print's additions to pandoc's default epub stylesheet. */
+
+/* Pages whose heading is only there to start a new page, such as the dedication. */
+section.hidden-title > h1 {
+  display: none;
+}
+
+/* The dedication: centered, in italics, about a third of the way down the page. */
+div.dedication {
+  text-align: center;
+  font-style: italic;
+  margin-top: 33%;
+}
diff --git a/assets/print.css b/assets/print.css
new file mode 100644
index 0000000..569d615
--- /dev/null
+++ b/assets/print.css
@@ -0,0 +1,113 @@
+/* Styles for the PDF output. Chrome prints the pandoc HTML with these rules. */
+
+@page {
+  size: Letter;
+  margin: 1in;
+  @bottom-center {
+    content: counter(page);
+    font-family: Georgia, "Times New Roman", serif;
+    font-size: 10pt;
+    color: #555;
+  }
+}
+
+/* No page number on the title page. */
+@page :first {
+  @bottom-center {
+    content: none;
+  }
+}
+
+html,
+body {
+  max-width: none;
+  margin: 0;
+  padding: 0;
+  background: white;
+  color: black;
+}
+
+body {
+  font-family: Georgia, "Times New Roman", serif;
+  font-size: 12pt;
+  line-height: 1.5;
+  hyphens: manual;
+}
+
+/* Title page: title and author, alone on the first page. */
+#title-block-header {
+  text-align: center;
+  padding-top: 3in;
+  break-after: page;
+}
+
+#title-block-header h1.title {
+  font-size: 32pt;
+  margin: 0 0 0.5in 0;
+}
+
+#title-block-header p.author {
+  font-size: 16pt;
+}
+
+#title-block-header p.date {
+  display: none;
+}
+
+/* Pages before and after the story, such as a dedication, start on a new page. */
+.page-break {
+  break-before: page;
+}
+
+/* The dedication: centered, in italics, about a third of the way down the page. */
+.dedication {
+  text-align: center;
+  font-style: italic;
+  padding-top: 2.5in;
+}
+
+/* Chapter headings. Chapters flow continuously, they do not start new pages. */
+h1,
+h2,
+h3 {
+  font-family: Helvetica, Arial, sans-serif;
+  line-height: 1.25;
+  break-after: avoid;
+}
+
+h1 {
+  font-size: 15pt;
+  margin: 2.2em 0 0.8em 0;
+}
+
+h2 {
+  font-size: 13pt;
+  margin: 1.8em 0 0.6em 0;
+}
+
+p {
+  margin: 0 0 0.8em 0;
+  orphans: 2;
+  widows: 2;
+}
+
+img {
+  max-width: 100%;
+  height: auto;
+}
+
+hr {
+  border: none;
+  text-align: center;
+  margin: 1.5em 0;
+}
+
+hr::after {
+  content: "* * *";
+}
+
+/* Inline code, used in the stories for machine or ship speech. */
+code {
+  font-family: Menlo, Consolas, monospace;
+  font-size: 0.85em;
+}
diff --git a/assets/reference.docx b/assets/reference.docx
new file mode 100644
index 0000000..17ebdcd
Binary files /dev/null and b/assets/reference.docx differ
diff --git a/decisions.md b/decisions.md
new file mode 100644
index 0000000..7652325
--- /dev/null
+++ b/decisions.md
@@ -0,0 +1,50 @@
+# Decisions
+
+Each design or technical decision, why it was made, and when. Newest last.
+
+## From the blog script (October 2026)
+
+auto-print started as one script in the author's Dropbox-synced blog folder. These decisions were made there.
+
+1. **Read manuscripts as `gfm`.** It matches how GitHub, GitLab and the VS Code preview show the files.
+   A single line break is a soft break, so a manuscript with one sentence per line gives the same book as one with one line
+   per paragraph. This was checked on three full books with pandoc 3.6.1: the epub, html and docx text was identical.
+2. **Publish only one section of a manuscript** (`## Writing` by default). Manuscripts keep notes, spelling lists and cut
+   scenes next to the story, and these must never reach a reader.
+3. **The source files are never changed.** The build reads them and writes only to the output folder.
+4. **Collections are an index file with links**, not a list of files in the config. The index file is where the author
+   already keeps the story order, so the order lives in one place. A link in an HTML comment is skipped, which is how a story
+   that is not ready is kept out.
+5. **A story's opening text moves below its title in a collection.** Otherwise each story's content warning would land at the
+   end of the previous story.
+6. **Extra pages are sections of the source file, named in the config.** The author did not want a separate file per page.
+   Naming them in the config, instead of recognising fixed names, means a section is never published by accident.
+7. **The shallowest heading wins when a name is used at several levels.** A real case: an `## Info` section held
+   `### Characters` notes with spoilers, next to a reader-facing `## Characters` page. Two headings with the same name at the
+   same level stop the build, because there is no safe way to choose.
+8. **The epub's flowing layout is built in the markdown, not with pandoc's `--shift-heading-level-by`.** Extra pages need
+   their own epub files (level 1 headings) while the story stays in one file. Doing the shift in the markdown, under a level 1
+   heading with the book title, gave byte-identical epub files to pandoc's own shift for books without extra pages.
+9. **Only pandoc's known epub roles are used.** pandoc puts `dedication`, `copyright-page` and `preface` in the front matter
+   and `appendix` in the back matter. An unknown role is treated as story text, so front pages default to `preface` and back
+   pages to `appendix`.
+10. **pandoc extensions `attributes`, `raw_attribute` and `fenced_divs` are on.** The assembled markdown uses them to mark
+    the extra pages, the page breaks and the dedication. They were turned on only after checking that none of the existing
+    manuscripts contained `{` or a line starting with `:::`, and that the books came out identical.
+11. **Page breaks are written per format:** a raw Word page break in the docx, a `page-break` div in the html for the pdf,
+    and nothing in the epub, where a new file is a new page.
+
+## As a package (2026-10-24)
+
+12. **No runtime dependencies.** Only Node's built-in modules. The external tools (pandoc, a browser, Kindle Previewer) are
+    run as commands. Fewer dependencies means less upkeep.
+13. **TypeScript that Node can run directly** (`erasableSyntaxOnly`, `.ts` imports). The author's blog runs the source from a
+    clone with `node src/cli.ts`, so there is no build step to forget. Installed packages live in `node_modules`, where Node
+    does not run `.ts` files, so the published package runs `dist/`, compiled by `tsc` (`rewriteRelativeImportExtensions`).
+14. **Every path in the config file is relative to the config file's folder.** One rule for `source`, `cover` and `outDir`.
+15. **`author` has no built-in default.** The blog script defaulted to its own author, which would be wrong for anyone else.
+16. **The docx dedication style comes from a reference doc** (`assets/reference.docx`). A Word paragraph style is the only way
+    pandoc can center a paragraph in a docx. The file is pandoc's default template plus one style, made by
+    `scripts/make-reference-docx.sh`. For a book without a dedication, the only change in the docx is one unused style.
+17. **Tests use Node's built-in test runner**, again to avoid dependencies. The pandoc tests check pandoc's syntax tree of the
+    assembled markdown, and skip themselves when pandoc is not installed.
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..f6534fc
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,415 @@
+{
+  "name": "auto-print",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "auto-print",
+      "version": "0.1.0",
+      "license": "MIT",
+      "bin": {
+        "auto-print": "dist/cli.js"
+      },
+      "devDependencies": {
+        "@types/node": "22.20.5",
+        "typescript": "7.0.2"
+      },
+      "engines": {
+        "node": ">=22.18"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "22.20.5",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz",
+      "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@typescript/typescript-aix-ppc64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+      "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-darwin-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+      "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-darwin-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+      "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-freebsd-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+      "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-freebsd-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+      "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-arm": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+      "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+      "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-loong64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+      "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-mips64el": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+      "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-ppc64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+      "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-riscv64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+      "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-s390x": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+      "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-linux-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+      "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-netbsd-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+      "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-netbsd-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+      "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-openbsd-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+      "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-openbsd-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+      "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-sunos-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+      "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-win32-arm64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+      "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/@typescript/typescript-win32-x64": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+      "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=16.20.0"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+      "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc"
+      },
+      "engines": {
+        "node": ">=16.20.0"
+      },
+      "optionalDependencies": {
+        "@typescript/typescript-aix-ppc64": "7.0.2",
+        "@typescript/typescript-darwin-arm64": "7.0.2",
+        "@typescript/typescript-darwin-x64": "7.0.2",
+        "@typescript/typescript-freebsd-arm64": "7.0.2",
+        "@typescript/typescript-freebsd-x64": "7.0.2",
+        "@typescript/typescript-linux-arm": "7.0.2",
+        "@typescript/typescript-linux-arm64": "7.0.2",
+        "@typescript/typescript-linux-loong64": "7.0.2",
+        "@typescript/typescript-linux-mips64el": "7.0.2",
+        "@typescript/typescript-linux-ppc64": "7.0.2",
+        "@typescript/typescript-linux-riscv64": "7.0.2",
+        "@typescript/typescript-linux-s390x": "7.0.2",
+        "@typescript/typescript-linux-x64": "7.0.2",
+        "@typescript/typescript-netbsd-arm64": "7.0.2",
+        "@typescript/typescript-netbsd-x64": "7.0.2",
+        "@typescript/typescript-openbsd-arm64": "7.0.2",
+        "@typescript/typescript-openbsd-x64": "7.0.2",
+        "@typescript/typescript-sunos-x64": "7.0.2",
+        "@typescript/typescript-win32-arm64": "7.0.2",
+        "@typescript/typescript-win32-x64": "7.0.2"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..bd9823e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,47 @@
+{
+  "name": "auto-print",
+  "version": "0.1.0",
+  "description": "Turns markdown manuscripts into epub, docx, pdf and Kindle kpf books, with pandoc.",
+  "type": "module",
+  "bin": {
+    "auto-print": "dist/cli.js"
+  },
+  "files": [
+    "dist",
+    "assets"
+  ],
+  "engines": {
+    "node": ">=22.18"
+  },
+  "scripts": {
+    "build": "tsc -p tsconfig.build.json",
+    "typecheck": "tsc",
+    "test": "node --test \"test/**/*.test.ts\"",
+    "prepare": "npm run build"
+  },
+  "keywords": [
+    "markdown",
+    "epub",
+    "kindle",
+    "kpf",
+    "docx",
+    "pdf",
+    "pandoc",
+    "book",
+    "writing"
+  ],
+  "author": "Pavel Safronov",
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "https://gitlab.com/PavelSafronov/auto-print.git"
+  },
+  "homepage": "https://gitlab.com/PavelSafronov/auto-print",
+  "bugs": {
+    "url": "https://gitlab.com/PavelSafronov/auto-print/-/issues"
+  },
+  "devDependencies": {
+    "@types/node": "22.20.5",
+    "typescript": "7.0.2"
+  }
+}
diff --git a/scripts/make-reference-docx.sh b/scripts/make-reference-docx.sh
new file mode 100755
index 0000000..91cce69
--- /dev/null
+++ b/scripts/make-reference-docx.sh
@@ -0,0 +1,31 @@
+#!/bin/sh
+# Makes assets/reference.docx: pandoc's default Word template, plus a "Dedication" paragraph style.
+# Needs pandoc, unzip, zip and python3. Run from anywhere: scripts/make-reference-docx.sh
+set -eu
+
+root=$(cd "$(dirname "$0")/.." && pwd)
+work=$(mktemp -d)
+trap 'rm -rf "$work"' EXIT
+
+pandoc --print-default-data-file reference.docx > "$work/default.docx"
+mkdir "$work/files"
+(cd "$work/files" && unzip -q ../default.docx)
+
+python3 - "$work/files/word/styles.xml" <<'EOF'
+import sys
+path = sys.argv[1]
+styles = open(path, encoding="utf-8").read()
+# Centered, in italics, with 2 inches (2880 twentieths of a point) of space above.
+dedication = (
+    '<w:style w:type="paragraph" w:customStyle="1" w:styleId="Dedication"><w:name w:val="Dedication" />'
+    '<w:basedOn w:val="BodyText" /><w:qFormat />'
+    '<w:pPr><w:spacing w:before="2880" /><w:jc w:val="center" /></w:pPr><w:rPr><w:i /><w:iCs /></w:rPr></w:style>'
+)
+assert styles.count("</w:styles>") == 1 and 'w:styleId="Dedication"' not in styles
+open(path, "w", encoding="utf-8").write(styles.replace("</w:styles>", dedication + "</w:styles>"))
+EOF
+
+# [Content_Types].xml goes first, as Word expects. -D leaves out folder entries.
+rm -f "$root/assets/reference.docx"
+(cd "$work/files" && zip -X -D -q "$root/assets/reference.docx" '[Content_Types].xml' && zip -X -D -q -r "$root/assets/reference.docx" . -x '[Content_Types].xml')
+echo "Wrote $root/assets/reference.docx"
diff --git a/src/build.ts b/src/build.ts
new file mode 100644
index 0000000..6104ace
--- /dev/null
+++ b/src/build.ts
@@ -0,0 +1,267 @@
+// Builds the output files of one book, by running pandoc, a browser and Kindle Previewer.
+
+import { spawn } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import type { Book } from "./config.ts";
+import { assemble, pandocFormat, readExtraPages, readStory, type Target } from "./manuscript.ts";
+import { displayPath, fail, run, sleep } from "./util.ts";
+
+/** Stylesheets and the docx reference doc. The folder sits next to src/ and dist/. */
+export const assetsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "assets");
+
+const browserCandidates = [
+  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+  "/Applications/Chromium.app/Contents/MacOS/Chromium",
+  "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
+  "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
+  "/usr/bin/google-chrome",
+  "/usr/bin/chromium",
+  "/usr/bin/chromium-browser",
+];
+
+const kindlePreviewerCandidates = [
+  "/Applications/Kindle Previewer 4.app/Contents/MacOS/KindlePreviewer4CLI",
+];
+
+function findBrowser(): string {
+  const fromEnv = process.env.CHROME_PATH;
+  if (fromEnv) return fromEnv;
+  const found = browserCandidates.find((candidate) => fs.existsSync(candidate));
+  if (!found) {
+    fail("No Chromium-based browser found (looked for Chrome, Chromium, Edge, Brave). Install one, or set CHROME_PATH.");
+  }
+  return found;
+}
+
+/**
+ * Runs the browser headless against a local HTML file, with a throwaway profile.
+ *
+ * Headless Chrome often keeps running after it has written its output file.
+ * So this does not wait for the browser to exit. It waits until the output
+ * file exists and has stopped growing, and then stops the browser itself.
+ */
+async function runBrowser(tempDir: string, htmlPath: string, browserArgs: string[], expectedOutput: string): Promise<void> {
+  const browser = findBrowser();
+  const child = spawn(browser, [
+    "--headless",
+    "--disable-gpu",
+    "--no-first-run",
+    "--no-default-browser-check",
+    "--disable-breakpad",
+    "--disable-background-networking",
+    "--disable-component-update",
+    `--user-data-dir=${path.join(tempDir, "browser-profile")}`,
+    ...browserArgs,
+    pathToFileURL(htmlPath).href,
+  ], { stdio: "ignore", detached: true });
+
+  let exited = false;
+  let spawnError: Error | null = null;
+  child.on("exit", () => { exited = true; });
+  child.on("error", (error) => { spawnError = error; exited = true; });
+
+  const deadline = Date.now() + 180_000;
+  let lastSize = -1;
+  let stableChecks = 0;
+  while (!exited && Date.now() < deadline) {
+    await sleep(250);
+    const size = fs.existsSync(expectedOutput) ? fs.statSync(expectedOutput).size : 0;
+    stableChecks = size > 0 && size === lastSize ? stableChecks + 1 : 0;
+    lastSize = size;
+    if (stableChecks >= 4) break;
+  }
+  if (!exited) {
+    // The browser runs in its own process group, so this also stops its helper processes.
+    try {
+      process.kill(-child.pid!, "SIGKILL");
+    } catch {
+      child.kill("SIGKILL");
+    }
+    while (!exited) await sleep(50);
+    // Give helper processes a moment to disappear, so the temp folder can be removed cleanly.
+    await sleep(300);
+  }
+
+  if (spawnError) fail(`${path.basename(browser)} could not be started: ${(spawnError as Error).message}`);
+  if (!fs.existsSync(expectedOutput) || fs.statSync(expectedOutput).size === 0) {
+    fail(`${path.basename(browser)} did not produce ${path.basename(expectedOutput)}.`);
+  }
+}
+
+function escapeHtml(text: string): string {
+  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
+}
+
+/** Makes a plain text cover: "Title / by / Author", in large bold letters on white. */
+async function generateCover(tempDir: string, title: string, author: string): Promise<string> {
+  const width = 1200;
+  const height = 1700;
+  const htmlPath = path.join(tempDir, "cover.html");
+  const pngPath = path.join(tempDir, "cover.png");
+  const html = `<!doctype html>
+<html><head><meta charset="utf-8"><style>
+  html, body { margin: 0; width: ${width}px; height: ${height}px; background: white; overflow: hidden; }
+  body { display: flex; align-items: center; justify-content: center; }
+  #text { font-family: Helvetica, Arial, sans-serif; font-weight: bold; color: #1a1a1a;
+          text-align: center; line-height: 1.15; font-size: 240px; }
+</style></head><body>
+<div id="text">${escapeHtml(title)}<br>by<br>${escapeHtml(author)}</div>
+<script>
+  // Shrink the text until it fits inside the cover with a margin.
+  const text = document.getElementById("text");
+  let size = 240;
+  while (size > 40 && (text.scrollWidth > ${width - 160} || text.scrollHeight > ${height - 200})) {
+    size -= 4;
+    text.style.fontSize = size + "px";
+  }
+</script>
+</body></html>`;
+  fs.writeFileSync(htmlPath, html);
+  await runBrowser(tempDir, htmlPath, [`--screenshot=${pngPath}`, `--window-size=${width},${height}`, "--hide-scrollbars"], pngPath);
+  return pngPath;
+}
+
+function findKindlePreviewer(): string {
+  const fromEnv = process.env.KINDLE_PREVIEWER_PATH;
+  if (fromEnv) return fromEnv;
+  const found = kindlePreviewerCandidates.find((candidate) => fs.existsSync(candidate));
+  if (!found) {
+    fail("Kindle Previewer 4 is not installed. On macOS, install it with: brew install --cask kindle-previewer");
+  }
+  return found;
+}
+
+/**
+ * Converts an epub into a kpf, the file format of Amazon's Kindle tools.
+ *
+ * Kindle Previewer names the output after the input file and puts it in a
+ * "KPF" subfolder, next to its log files. It also exits with code 0 when the
+ * conversion fails. So this runs it in the temp folder, looks for the kpf
+ * file, and copies only that file to its final place.
+ */
+function convertEpubToKpf(tempDir: string, epubPath: string, kpfPath: string): void {
+  const previewer = findKindlePreviewer();
+  const workDir = path.join(tempDir, "kindle");
+  fs.mkdirSync(workDir, { recursive: true });
+  const output = run(previewer, [epubPath, "--convert", "--output", workDir], undefined, 600_000);
+  const kpfDir = path.join(workDir, "KPF");
+  const produced = fs.existsSync(kpfDir) ? fs.readdirSync(kpfDir).filter((name) => name.endsWith(".kpf")) : [];
+  if (produced.length === 0) {
+    const lastLines = output.trim().split("\n").slice(-4).join("\n");
+    fail(`Kindle Previewer did not produce a kpf file. Its last messages were:\n${lastLines}`);
+  }
+  fs.copyFileSync(path.join(kpfDir, produced[0]!), kpfPath);
+}
+
+/** pandoc's own epub stylesheet, followed by auto-print's additions from assets/epub.css. */
+function writeEpubStylesheet(tempDir: string): string {
+  const cssPath = path.join(tempDir, "epub.css");
+  const defaults = run("pandoc", ["--print-default-data-file", "epub.css"]);
+  fs.writeFileSync(cssPath, `${defaults}\n${fs.readFileSync(path.join(assetsDir, "epub.css"), "utf8")}`);
+  return cssPath;
+}
+
+/**
+ * Builds every format of one book into `outDir/<slug>/`.
+ * A format that fails is reported, and the other formats are still built.
+ * Returns the number of formats that failed.
+ */
+export async function buildBook(book: Book, outDir: string): Promise<number> {
+  if (!fs.existsSync(book.source)) fail(`Source file not found: ${book.source}`);
+  const { story, files } = readStory(book.source, book.section);
+  const front = readExtraPages(book.source, book.front, "front");
+  const back = readExtraPages(book.source, book.back, "back");
+  const markdownFor = (target: Target) => assemble(story, front, back, target, book.chapterBreaks, book.title);
+
+  const bookDir = path.join(outDir, book.slug);
+  fs.mkdirSync(bookDir, { recursive: true });
+  const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `auto-print-${book.slug}-`));
+  let failures = 0;
+
+  // gfm matches how GitHub, GitLab and the VS Code preview show markdown files.
+  // "smart" turns straight quotes and dashes into typographic ones.
+  // The other extensions let assemble() mark the extra pages and their page breaks.
+  const commonArgs = [
+    `--from=${pandocFormat}`,
+    `--metadata=title:${book.title}`,
+    `--metadata=author:${book.author}`,
+    `--metadata=lang:${book.language}`,
+    // Lets images in the story be found relative to the files they are written in.
+    `--resource-path=${[...new Set(files.map((file) => path.dirname(file)))].join(path.delimiter)}`,
+  ];
+
+  const buildEpub = async (epubPath: string): Promise<void> => {
+    let coverPath: string;
+    if (book.cover) {
+      coverPath = book.cover;
+      if (!fs.existsSync(coverPath)) fail(`Cover image not found: ${coverPath}`);
+    } else {
+      coverPath = await generateCover(tempDir, book.title, book.author);
+    }
+    run("pandoc", [
+      ...commonArgs,
+      "--to=epub3",
+      // pandoc starts a new file, and so a new page, at every level 1 heading.
+      // assemble() decides which headings are at level 1. See chapterBreaks.
+      "--split-level=1",
+      `--css=${writeEpubStylesheet(tempDir)}`,
+      `--metadata=identifier:${book.slug}`,
+      `--metadata=date:${new Date().toISOString().slice(0, 10)}`,
+      `--epub-cover-image=${coverPath}`,
+      `--output=${epubPath}`,
+    ], markdownFor("epub"));
+  };
+
+  // The kpf is made from an epub. This epub lives in the temp folder, so the
+  // kpf can be built even when "epub" itself is not in the list of formats.
+  let epubForKindle: string | null = null;
+
+  try {
+    for (const format of book.formats) {
+      const outPath = path.join(bookDir, `${book.slug}.${format}`);
+      try {
+        if (format === "epub") {
+          await buildEpub(outPath);
+        } else if (format === "docx") {
+          run("pandoc", [
+            ...commonArgs,
+            "--to=docx",
+            // pandoc's default Word styles, plus a style for the dedication.
+            `--reference-doc=${path.join(assetsDir, "reference.docx")}`,
+            `--output=${outPath}`,
+          ], markdownFor("docx"));
+        } else if (format === "pdf") {
+          const htmlPath = path.join(tempDir, `${book.slug}.html`);
+          run("pandoc", [
+            ...commonArgs,
+            "--to=html5",
+            "--standalone",
+            "--embed-resources",
+            `--css=${path.join(assetsDir, "print.css")}`,
+            `--output=${htmlPath}`,
+          ], markdownFor("html"));
+          fs.rmSync(outPath, { force: true });
+          await runBrowser(tempDir, htmlPath, ["--no-pdf-header-footer", `--print-to-pdf=${outPath}`], outPath);
+        } else {
+          findKindlePreviewer(); // Fail early, before spending time on the epub.
+          if (epubForKindle === null) {
+            epubForKindle = path.join(tempDir, `${book.slug}.epub`);
+            await buildEpub(epubForKindle);
+          }
+          convertEpubToKpf(tempDir, epubForKindle, outPath);
+        }
+        const kilobytes = Math.round(fs.statSync(outPath).size / 1024);
+        console.log(`  wrote ${displayPath(outPath)} (${kilobytes} KB)`);
+      } catch (error) {
+        failures += 1;
+        console.error(`  FAILED ${format}: ${(error as Error).message}`);
+      }
+    }
+  } finally {
+    fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
+  }
+  return failures;
+}
diff --git a/src/cli.ts b/src/cli.ts
new file mode 100644
index 0000000..db2409a
--- /dev/null
+++ b/src/cli.ts
@@ -0,0 +1,75 @@
+#!/usr/bin/env node
+// auto-print: turns markdown manuscripts into epub, docx, pdf and Kindle kpf files.
+//
+//   auto-print                       build every book in ./books.json
+//   auto-print wick-canyon           build only the named book(s)
+//   auto-print --config path/books.json
+//   auto-print --list                list the configured books
+
+import { buildBook } from "./build.ts";
+import { loadConfig } from "./config.ts";
+import { displayPath, fail } from "./util.ts";
+
+const usage = `Usage: auto-print [--config <file>] [--list] [slug ...]
+
+  --config <file>  The config file. Default: books.json in the current folder.
+  --list           List the books in the config file, and build nothing.
+  slug ...         Build only these books. Without a slug, every book is built.`;
+
+export async function main(args: string[]): Promise<number> {
+  let configPath = "books.json";
+  let list = false;
+  const slugs: string[] = [];
+  for (let index = 0; index < args.length; index++) {
+    const arg = args[index]!;
+    if (arg === "--help" || arg === "-h") {
+      console.log(usage);
+      return 0;
+    } else if (arg === "--list") {
+      list = true;
+    } else if (arg === "--config") {
+      configPath = args[++index] ?? fail("--config needs a file name.");
+    } else if (arg.startsWith("-")) {
+      fail(`Unknown option: ${arg}\n\n${usage}`);
+    } else {
+      slugs.push(arg);
+    }
+  }
+
+  const config = loadConfig(configPath);
+  if (list) {
+    for (const book of config.books) console.log(`${book.slug}\t${book.title}\t${displayPath(book.source)}`);
+    return 0;
+  }
+
+  const unknown = slugs.filter((slug) => !config.books.some((book) => book.slug === slug));
+  if (unknown.length > 0) {
+    fail(`Unknown book: ${unknown.join(", ")}. Known books: ${config.books.map((b) => b.slug).join(", ")}.`);
+  }
+  const selected = slugs.length > 0 ? config.books.filter((book) => slugs.includes(book.slug)) : config.books;
+
+  let failures = 0;
+  for (const book of selected) {
+    console.log(`\n${book.title} (${displayPath(book.source)})`);
+    try {
+      if ((await buildBook(book, config.outDir)) > 0) failures += 1;
+    } catch (error) {
+      failures += 1;
+      console.error(`  FAILED: ${(error as Error).message}`);
+    }
+  }
+  if (failures > 0) {
+    console.error(`\n${failures} of ${selected.length} book(s) had failures.`);
+    return 1;
+  }
+  console.log(`\nDone. ${selected.length} book(s) built into ${config.outDir}`);
+  return 0;
+}
+
+main(process.argv.slice(2)).then(
+  (code) => { process.exitCode = code; },
+  (error) => {
+    console.error(`Error: ${(error as Error).message}`);
+    process.exitCode = 1;
+  },
+);
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..0f33b83
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,132 @@
+// The config file (books.json): the list of books, and the settings they share.
+
+import fs from "node:fs";
+import path from "node:path";
+import { fail } from "./util.ts";
+
+export type Format = "epub" | "docx" | "pdf" | "kpf";
+
+export const allFormats: Format[] = ["epub", "docx", "pdf", "kpf"];
+
+/** Settings that a book can have, and that the "defaults" block can set for every book. */
+export interface Settings {
+  author: string;
+  language: string;
+  /** Heading whose content is the story. null means "use the whole file". */
+  section: string | null;
+  formats: Format[];
+  /**
+   * epub only. true: every chapter starts on a new page, as its own file.
+   * false: the chapters flow continuously, with only a heading between them.
+   */
+  chapterBreaks: boolean;
+}
+
+export interface BookEntry extends Partial<Settings> {
+  /** Used for the output folder and file names. */
+  slug: string;
+  title: string;
+  /** Markdown file, relative to the config file. */
+  source: string;
+  /** Cover image, relative to the config file. If missing, a text cover is generated. */
+  cover?: string;
+  /** Headings in the source file whose sections become pages before the story, in this order. */
+  front?: string[];
+  /** Headings in the source file whose sections become pages after the story, in this order. */
+  back?: string[];
+}
+
+/** A book with every setting filled in, and every path made absolute. */
+export interface Book extends Settings {
+  slug: string;
+  title: string;
+  source: string;
+  cover: string | null;
+  front: string[];
+  back: string[];
+}
+
+export interface Config {
+  /** Absolute path of the config file. */
+  path: string;
+  /** Absolute path of the folder that gets one subfolder of output files per book. */
+  outDir: string;
+  books: Book[];
+}
+
+const builtInDefaults: Omit<Settings, "author"> = {
+  language: "en-US",
+  section: "Writing",
+  formats: allFormats,
+  chapterBreaks: false,
+};
+
+const defaultOutDir = "auto-print-out";
+
+function isStringList(value: unknown): value is string[] {
+  return Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim().length > 0);
+}
+
+/** Reads and checks a config file. Every problem is reported with the file name and the book it is in. */
+export function loadConfig(configPath: string): Config {
+  const absolutePath = path.resolve(configPath);
+  const name = path.basename(absolutePath);
+  if (!fs.existsSync(absolutePath)) fail(`Config file not found: ${absolutePath}`);
+
+  let raw: { outDir?: unknown; defaults?: Partial<Settings>; books?: unknown };
+  try {
+    raw = JSON.parse(fs.readFileSync(absolutePath, "utf8"));
+  } catch (error) {
+    fail(`${name} is not valid JSON: ${(error as Error).message}`);
+  }
+  if (!Array.isArray(raw.books)) fail(`${name}: needs a "books" list.`);
+  if (raw.outDir !== undefined && (typeof raw.outDir !== "string" || !raw.outDir)) {
+    fail(`${name}: "outDir" must be a folder name.`);
+  }
+
+  const baseDir = path.dirname(absolutePath);
+  const defaults = { ...builtInDefaults, ...raw.defaults };
+  const seen = new Set<string>();
+  const books = (raw.books as BookEntry[]).map((entry): Book => {
+    const label = `${name}: book "${entry.slug ?? entry.title ?? JSON.stringify(entry)}"`;
+    if (!entry.slug || !entry.title || typeof entry.source !== "string" || !entry.source) {
+      fail(`${name}: every book needs "slug", "title" and "source" (one file). Problem entry: ${JSON.stringify(entry)}`);
+    }
+    if (seen.has(entry.slug)) fail(`${name}: the slug "${entry.slug}" is used twice.`);
+    seen.add(entry.slug);
+
+    const author = entry.author ?? defaults.author;
+    if (typeof author !== "string" || !author.trim()) {
+      fail(`${label} needs an "author", in the book or in the "defaults" block.`);
+    }
+    for (const position of ["front", "back"] as const) {
+      if (entry[position] !== undefined && !isStringList(entry[position])) {
+        fail(`${label}: "${position}" must be a list of heading names, like ["Dedication"].`);
+      }
+    }
+    const formats = entry.formats ?? defaults.formats;
+    for (const format of formats) {
+      if (!allFormats.includes(format)) fail(`${label}: unknown format "${format}". Allowed: ${allFormats.join(", ")}.`);
+    }
+
+    return {
+      slug: entry.slug,
+      title: entry.title,
+      source: path.resolve(baseDir, entry.source),
+      cover: entry.cover ? path.resolve(baseDir, entry.cover) : null,
+      front: entry.front ?? [],
+      back: entry.back ?? [],
+      author,
+      language: entry.language ?? defaults.language,
+      section: entry.section !== undefined ? entry.section : defaults.section,
+      formats,
+      chapterBreaks: entry.chapterBreaks ?? defaults.chapterBreaks,
+    };
+  });
+
+  return {
+    path: absolutePath,
+    outDir: path.resolve(baseDir, (raw.outDir as string | undefined) ?? defaultOutDir),
+    books,
+  };
+}
diff --git a/src/manuscript.ts b/src/manuscript.ts
new file mode 100644
index 0000000..e9544ca
--- /dev/null
+++ b/src/manuscript.ts
@@ -0,0 +1,243 @@
+// Turns manuscript files into the markdown that pandoc converts.
+//
+// Manuscripts hold notes, spelling lists and removed writing next to the story.
+// These functions pick out the story and the extra pages, and put them together.
+// They only read files. Nothing here runs pandoc.
+
+import fs from "node:fs";
+import path from "node:path";
+import { displayPath as labelOf, fail } from "./util.ts";
+
+export interface Heading {
+  line: number;
+  level: number;
+  text: string;
+}
+
+/** Finds ATX headings ("## Like this"), ignoring anything inside fenced code blocks. */
+export function findHeadings(lines: string[]): Heading[] {
+  const headings: Heading[] = [];
+  let fence: string | null = null;
+  lines.forEach((line, index) => {
+    const fenceMatch = /^\s{0,3}(```+|~~~+)/.exec(line);
+    if (fenceMatch) {
+      const marker = fenceMatch[1]![0]!;
+      if (fence === null) fence = marker;
+      else if (fence === marker) fence = null;
+      return;
+    }
+    if (fence !== null) return;
+    const match = /^(#{1,6})\s+(.*?)\s*$/.exec(line);
+    if (match) headings.push({ line: index, level: match[1]!.length, text: match[2]! });
+  });
+  return headings;
+}
+
+export function readLines(filePath: string): string[] {
+  return fs.readFileSync(filePath, "utf8").replace(/\r\n/g, "\n").split("\n");
+}
+
+/**
+ * Finds the section under the heading with the given text, ignoring letter case.
+ * Returns the heading text as written in the file, and the lines under it,
+ * up to the next heading of the same or a higher level.
+ *
+ * If the name is used at several heading levels, the shallowest one wins.
+ * So a "## Characters" section is chosen over "### Characters" notes inside "## Info".
+ */
+export function findSection(lines: string[], name: string, sourceLabel: string): { title: string; lines: string[] } | null {
+  const headings = findHeadings(lines);
+  const wanted = name.trim().toLowerCase();
+  const matches = headings.filter((h) => h.text.toLowerCase() === wanted);
+  if (matches.length === 0) return null;
+  const level = Math.min(...matches.map((h) => h.level));
+  const shallowest = matches.filter((h) => h.level === level);
+  if (shallowest.length > 1) {
+    fail(`${sourceLabel}: the heading "${name}" appears ${shallowest.length} times at the same level. Rename one of them.`);
+  }
+  const start = shallowest[0]!;
+  const end = headings.find((h) => h.line > start.line && h.level <= start.level);
+  return { title: start.text, lines: lines.slice(start.line + 1, end ? end.line : lines.length) };
+}
+
+/** Moves every heading by `by` levels: 1 turns "#" into "##", -1 turns "##" into "#". */
+export function shiftHeadings(lines: string[], by: number): string[] {
+  const shifted = [...lines];
+  for (const heading of findHeadings(lines)) {
+    shifted[heading.line] = by >= 0 ? "#".repeat(by) + shifted[heading.line] : shifted[heading.line]!.slice(-by);
+  }
+  return shifted;
+}
+
+/** Removes HTML comments and moves the headings so that the shallowest one is at `topLevel`. */
+export function tidySection(lines: string[], topLevel: number): string {
+  // Remove HTML comments, such as a blog's <!--more--> excerpt marker.
+  lines = lines.join("\n").replace(/<!--[\s\S]*?-->/g, "").split("\n");
+  const headings = findHeadings(lines);
+  if (headings.length > 0) lines = shiftHeadings(lines, topLevel - Math.min(...headings.map((h) => h.level)));
+  return lines.join("\n").trim();
+}
+
+/**
+ * Pulls the story out of a manuscript.
+ *
+ * The story is the content under one heading (normally "## Writing"). That
+ * content is returned with its headings promoted so the top ones are level 1,
+ * which is what pandoc treats as chapters.
+ */
+export function extractStory(lines: string[], section: string | null, sourceLabel: string): string {
+  if (section === null) {
+    // Whole file. Drop a leading "# Title" line, because the title comes from the config.
+    const first = findHeadings(lines)[0];
+    if (first && first.level === 1) lines = lines.slice(first.line + 1);
+  } else {
+    const found = findSection(lines, section, sourceLabel);
+    if (!found) {
+      const available = findHeadings(lines).filter((h) => h.level <= 2).map((h) => `"${h.text}"`).join(", ");
+      fail(`${sourceLabel}: no heading named "${section}". Top-level headings found: ${available || "none"}.`);
+    }
+    lines = found.lines;
+  }
+
+  const story = tidySection(lines, 1);
+  if (story.length === 0) fail(`${sourceLabel}: the story section is empty.`);
+  return story + "\n";
+}
+
+/**
+ * Moves the text before a story's first heading, such as a content warning,
+ * to just below that heading.
+ *
+ * Used when several stories are joined into one book. Otherwise the opening text
+ * of a story would end up at the end of the previous story's last chapter.
+ */
+export function moveOpeningBelowTitle(story: string): string {
+  const lines = story.split("\n");
+  const first = findHeadings(lines)[0];
+  if (!first || first.line === 0) return story;
+  const opening = lines.slice(0, first.line).join("\n").trim();
+  const rest = lines.slice(first.line + 1).join("\n").trim();
+  return `${lines[first.line]}\n\n${opening}\n\n${rest}\n`;
+}
+
+/** A line that holds only a link to a markdown file, like "[Story one](./story-1.md)". */
+const storyLink = /^\[[^\]]*\]\(<?([^()<>]+\.md)>?\)$/;
+
+/**
+ * Reads the story of a book.
+ *
+ * If the story section holds only links to other markdown files, one per line,
+ * each linked file's own story section is read instead, in the order of the links.
+ * A link inside an HTML comment is ignored, because comments are removed first.
+ * Returns the story and every file that was read, so images can be found next to them.
+ */
+export function readStory(sourcePath: string, section: string | null): { story: string; files: string[] } {
+  const label = labelOf(sourcePath);
+  const story = extractStory(readLines(sourcePath), section, label);
+  const lines = story.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
+  const links = lines.map((line) => storyLink.exec(line));
+  if (!links.some((link) => link !== null)) return { story, files: [sourcePath] };
+  if (!links.every((link) => link !== null)) {
+    fail(`${label}: the story section mixes links to story files with other text. Use only links, one per line, or only story text.`);
+  }
+
+  const files = links.map((link) => path.resolve(path.dirname(sourcePath), decodeURIComponent(link![1]!)));
+  const stories = files.map((file) => {
+    if (!fs.existsSync(file)) fail(`${label}: the linked story file was not found: ${file}`);
+    return moveOpeningBelowTitle(extractStory(readLines(file), section, labelOf(file)));
+  });
+  return { story: stories.join("\n"), files: [sourcePath, ...files] };
+}
+
+/** How a page before or after the story is marked and shown. */
+export interface PageRole {
+  /** The page's role in the epub. pandoc only puts some roles in the front or the back, see pageRoles. */
+  epubType: string;
+  /** false: the page is left out of the table of contents. */
+  listed: boolean;
+  /** false: the heading is not shown. The epub still needs it to start a new page. */
+  showTitle: boolean;
+  /** A class for the page's text, styled in print.css, the epub stylesheet and the docx reference doc. */
+  textClass?: string;
+}
+
+/** A page before or after the story, such as a dedication or a character guide. */
+export interface ExtraPage extends PageRole {
+  title: string;
+  body: string;
+}
+
+/**
+ * Front pages that are treated differently from the others, by heading name.
+ * Other front pages are marked as a preface, and every back page as an appendix.
+ * These are roles that pandoc puts in the front or the back of an epub.
+ */
+export const pageRoles: Record<string, PageRole> = {
+  dedication: { epubType: "dedication", listed: false, showTitle: false, textClass: "dedication" },
+  copyright: { epubType: "copyright-page", listed: false, showTitle: true },
+};
+
+export function readExtraPages(sourcePath: string, names: string[], position: "front" | "back"): ExtraPage[] {
+  const label = labelOf(sourcePath);
+  const lines = readLines(sourcePath);
+  return names.map((name) => {
+    const found = findSection(lines, name, label);
+    if (!found) fail(`${label}: no heading named "${name}", which is listed in "${position}" in the config.`);
+    const body = tidySection(found.lines, 2);
+    if (body.length === 0) fail(`${label}: the "${found.title}" section is empty.`);
+    const role: PageRole = position === "front"
+      ? pageRoles[found.title.toLowerCase()] ?? { epubType: "preface", listed: true, showTitle: true }
+      : { epubType: "appendix", listed: true, showTitle: true };
+    return { title: found.title, body, ...role };
+  });
+}
+
+export type Target = "epub" | "docx" | "html";
+
+/**
+ * A page break, written in the markup that each target passes through untouched.
+ * The epub needs none, because pandoc starts a new file at every level 1 heading.
+ */
+const pageBreaks: Record<Target, string> = {
+  epub: "",
+  docx: '```{=openxml}\n<w:p><w:r><w:br w:type="page"/></w:r></w:p>\n```',
+  html: '<div class="page-break"></div>',
+};
+
+/** The input format for pandoc. assemble() writes markup that needs these extensions. */
+export const pandocFormat = "gfm+smart+attributes+raw_attribute+fenced_divs";
+
+/**
+ * Builds the markdown that pandoc converts, for one target.
+ *
+ * Every extra page starts on a new page. In the epub, an extra page is a level 1
+ * heading, which pandoc puts in a file of its own. When the epub flows continuously
+ * (chapterBreaks false), the story's headings are moved down one level, under one
+ * level 1 heading with the book title, so the whole story stays in one file.
+ */
+export function assemble(story: string, front: ExtraPage[], back: ExtraPage[], target: Target, chapterBreaks: boolean, title: string): string {
+  const parts: string[] = [];
+  const pageBreak = pageBreaks[target];
+  const addPage = (page: ExtraPage) => {
+    if (pageBreak) parts.push(pageBreak);
+    const classes = [!page.listed && ".unlisted", !page.showTitle && ".hidden-title"].filter(Boolean).join(" ");
+    if (target === "epub") parts.push(`# ${page.title} {${classes ? classes + " " : ""}epub:type=${page.epubType}}`);
+    else if (page.showTitle) parts.push(`# ${page.title}${page.listed ? "" : " {.unlisted}"}`);
+    // custom-style names the paragraph style in the docx. The other targets use the class.
+    parts.push(page.textClass ? `::: {.${page.textClass} custom-style="${capitalize(page.textClass)}"}\n${page.body}\n:::` : page.body);
+  };
+
+  front.forEach(addPage);
+  if (front.length > 0 && pageBreak) parts.push(pageBreak);
+  if (target === "epub" && !chapterBreaks) {
+    parts.push(`# ${title} {.unnumbered}`, shiftHeadings(story.trim().split("\n"), 1).join("\n"));
+  } else {
+    parts.push(story.trim());
+  }
+  back.forEach(addPage);
+  return parts.join("\n\n") + "\n";
+}
+
+function capitalize(text: string): string {
+  return text.charAt(0).toUpperCase() + text.slice(1);
+}
diff --git a/src/util.ts b/src/util.ts
new file mode 100644
index 0000000..9b648c0
--- /dev/null
+++ b/src/util.ts
@@ -0,0 +1,32 @@
+// Small helpers shared by the other modules.
+
+import { spawnSync } from "node:child_process";
+import path from "node:path";
+
+export function fail(message: string): never {
+  throw new Error(message);
+}
+
+/** Runs a command and returns its output. Fails with the command's error output if it does not succeed. */
+export function run(command: string, args: string[], input?: string, timeoutMs = 180_000): string {
+  const result = spawnSync(command, args, { input, encoding: "utf8", timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024 });
+  if (result.error) {
+    const code = (result.error as NodeJS.ErrnoException).code;
+    if (code === "ENOENT") fail(`"${command}" is not installed or not on the PATH.`);
+    fail(`${path.basename(command)} failed: ${result.error.message}`);
+  }
+  if (result.status !== 0) {
+    fail(`${path.basename(command)} exited with code ${result.status}:\n${result.stderr.trim()}`);
+  }
+  return result.stdout;
+}
+
+/** How a file is named in messages: relative to the current folder when it is inside it, otherwise in full. */
+export function displayPath(filePath: string): string {
+  const relative = path.relative(process.cwd(), filePath);
+  return relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : filePath;
+}
+
+export function sleep(milliseconds: number): Promise<void> {
+  return new Promise((resolve) => setTimeout(resolve, milliseconds));
+}
diff --git a/test/config.test.ts b/test/config.test.ts
new file mode 100644
index 0000000..979c376
--- /dev/null
+++ b/test/config.test.ts
@@ -0,0 +1,62 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { describe, it } from "node:test";
+import { loadConfig } from "../src/config.ts";
+
+function writeConfig(content: unknown): string {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "auto-print-test-"));
+  const file = path.join(dir, "books.json");
+  fs.writeFileSync(file, JSON.stringify(content));
+  return file;
+}
+
+describe("loadConfig", () => {
+  it("resolves paths against the config file's folder and fills in the defaults", () => {
+    const file = writeConfig({
+      outDir: "../out",
+      defaults: { author: "A. Writer" },
+      books: [{ slug: "one", title: "One", source: "../one.md", cover: "covers/one.jpg", front: ["Dedication"] }],
+    });
+    const dir = path.dirname(file);
+    const config = loadConfig(file);
+    assert.equal(config.outDir, path.resolve(dir, "../out"));
+    assert.deepEqual(config.books[0], {
+      slug: "one",
+      title: "One",
+      source: path.resolve(dir, "../one.md"),
+      cover: path.join(dir, "covers/one.jpg"),
+      front: ["Dedication"],
+      back: [],
+      author: "A. Writer",
+      language: "en-US",
+      section: "Writing",
+      formats: ["epub", "docx", "pdf", "kpf"],
+      chapterBreaks: false,
+    });
+  });
+
+  it("puts the output next to the config file by default, and lets a book override the defaults", () => {
+    const file = writeConfig({
+      defaults: { author: "A. Writer", formats: ["epub"] },
+      books: [{ slug: "one", title: "One", source: "one.md", author: "B. Writer", section: null, chapterBreaks: true }],
+    });
+    const config = loadConfig(file);
+    assert.equal(config.outDir, path.join(path.dirname(file), "auto-print-out"));
+    assert.equal(config.books[0]?.author, "B. Writer");
+    assert.equal(config.books[0]?.section, null);
+    assert.equal(config.books[0]?.chapterBreaks, true);
+    assert.deepEqual(config.books[0]?.formats, ["epub"]);
+  });
+
+  it("explains what is wrong", () => {
+    const book = { slug: "one", title: "One", source: "one.md", author: "A" };
+    assert.throws(() => loadConfig("/no/such/books.json"), /Config file not found/);
+    assert.throws(() => loadConfig(writeConfig({ books: [{ ...book, author: undefined }] })), /needs an "author"/);
+    assert.throws(() => loadConfig(writeConfig({ books: [book, book] })), /slug "one" is used twice/);
+    assert.throws(() => loadConfig(writeConfig({ books: [{ ...book, source: ["a.md"] }] })), /needs "slug", "title" and "source"/);
+    assert.throws(() => loadConfig(writeConfig({ books: [{ ...book, formats: ["mobi"] }] })), /unknown format "mobi"/);
+    assert.throws(() => loadConfig(writeConfig({ books: [{ ...book, front: "Dedication" }] })), /"front" must be a list/);
+  });
+});
diff --git a/test/fixtures/collection/index.md b/test/fixtures/collection/index.md
new file mode 100644
index 0000000..b09046b
--- /dev/null
+++ b/test/fixtures/collection/index.md
@@ -0,0 +1,32 @@
+# A Collection
+
+## Info
+
+### Characters
+
+1. Hero
+   1. Secret: dies in story two
+
+## Dedication
+
+To *everyone*.
+
+## Content Warnings
+
+This book contains:
+
+- peril
+
+## Characters
+
+### Hero
+
+The one the stories are about.
+
+## Writing
+
+[Story one](./story-1.md)
+
+[Story two](./story-2.md)
+
+<!-- [Story three](./story-3.md) -->
diff --git a/test/fixtures/collection/story-1.md b/test/fixtures/collection/story-1.md
new file mode 100644
index 0000000..40f3b38
--- /dev/null
+++ b/test/fixtures/collection/story-1.md
@@ -0,0 +1,24 @@
+# A Collection: Story one
+
+## Spelling
+
+cspell:words Hero
+
+## Writing
+
+<!--more-->
+
+[Content Warning: peril.]
+
+### Story one
+
+#### The start
+
+The hero woke up.
+The sun was up too.
+
+The end of the beginning.
+
+### ~Intermission One~
+
+A pause.
diff --git a/test/fixtures/collection/story-2.md b/test/fixtures/collection/story-2.md
new file mode 100644
index 0000000..74780a5
--- /dev/null
+++ b/test/fixtures/collection/story-2.md
@@ -0,0 +1,15 @@
+# A Collection: Story two
+
+## Writing
+
+[Content Warning: more peril.]
+
+### Story two
+
+#### Another start
+
+The hero slept.
+
+## Removed writing
+
+This was cut.
diff --git a/test/manuscript.test.ts b/test/manuscript.test.ts
new file mode 100644
index 0000000..fa58055
--- /dev/null
+++ b/test/manuscript.test.ts
@@ -0,0 +1,167 @@
+import assert from "node:assert/strict";
+import path from "node:path";
+import { describe, it } from "node:test";
+import { fileURLToPath } from "node:url";
+import {
+  assemble,
+  extractStory,
+  findHeadings,
+  findSection,
+  moveOpeningBelowTitle,
+  readExtraPages,
+  readStory,
+  shiftHeadings,
+} from "../src/manuscript.ts";
+
+const fixtures = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "collection");
+const index = path.join(fixtures, "index.md");
+
+describe("findHeadings", () => {
+  it("finds headings and skips fenced code blocks", () => {
+    const lines = ["# One", "text", "```", "# not a heading", "```", "### Three  "];
+    assert.deepEqual(findHeadings(lines), [
+      { line: 0, level: 1, text: "One" },
+      { line: 5, level: 3, text: "Three" },
+    ]);
+  });
+});
+
+describe("findSection", () => {
+  const lines = ["## Info", "### Characters", "secret", "## Characters", "public", "## Writing", "story"];
+
+  it("picks the shallowest heading when a name is used at several levels", () => {
+    assert.deepEqual(findSection(lines, "characters", "file.md"), { title: "Characters", lines: ["public"] });
+  });
+
+  it("returns null for a missing heading", () => {
+    assert.equal(findSection(lines, "Dedication", "file.md"), null);
+  });
+
+  it("fails when a name is used twice at the same level", () => {
+    assert.throws(() => findSection([...lines, "## Info"], "Info", "file.md"), /appears 2 times at the same level/);
+  });
+});
+
+describe("shiftHeadings", () => {
+  it("moves headings down and up", () => {
+    assert.deepEqual(shiftHeadings(["# A", "text", "## B"], 1), ["## A", "text", "### B"]);
+    assert.deepEqual(shiftHeadings(["### A", "#### B"], -2), ["# A", "## B"]);
+  });
+});
+
+describe("extractStory", () => {
+  it("takes the Writing section, removes comments and promotes the headings", () => {
+    const lines = ["# Title", "## Notes", "note", "## Writing", "<!--more-->", "### Chapter", "Text.", "## Removed", "cut"];
+    assert.equal(extractStory(lines, "Writing", "file.md"), "# Chapter\nText.\n");
+  });
+
+  it("takes the whole file without its title when the section is null", () => {
+    assert.equal(extractStory(["# Title", "", "## Chapter", "Text."], null, "file.md"), "# Chapter\nText.\n");
+  });
+
+  it("names the headings it found when the section is missing", () => {
+    assert.throws(() => extractStory(["# Title", "## Notes"], "Writing", "file.md"), /no heading named "Writing".*"Title", "Notes"/);
+  });
+
+  it("fails on an empty story", () => {
+    assert.throws(() => extractStory(["## Writing", "<!-- nothing -->", "## Notes"], "Writing", "file.md"), /empty/);
+  });
+});
+
+describe("moveOpeningBelowTitle", () => {
+  it("moves a content warning below the story title", () => {
+    assert.equal(moveOpeningBelowTitle("[Warning.]\n\n# Story\n\n## Scene\n\nText.\n"), "# Story\n\n[Warning.]\n\n## Scene\n\nText.\n");
+  });
+
+  it("leaves a story that starts with its title alone", () => {
+    assert.equal(moveOpeningBelowTitle("# Story\n\nText.\n"), "# Story\n\nText.\n");
+  });
+});
+
+describe("readStory", () => {
+  it("follows the links in the Writing section, in order, and skips commented-out links", () => {
+    const { story, files } = readStory(index, "Writing");
+    assert.deepEqual(files.map((file) => path.basename(file)), ["index.md", "story-1.md", "story-2.md"]);
+    const headings = findHeadings(story.split("\n")).map((h) => `${h.level} ${h.text}`);
+    assert.deepEqual(headings, ["1 Story one", "2 The start", "1 ~Intermission One~", "1 Story two", "2 Another start"]);
+    // Each story's content warning sits right under its title.
+    assert.match(story, /^# Story one\n\n\[Content Warning: peril\.\]/);
+    assert.match(story, /# Story two\n\n\[Content Warning: more peril\.\]/);
+    // Notes outside the Writing sections stay out.
+    assert.doesNotMatch(story, /This was cut|cspell|Secret/);
+  });
+
+  it("returns a story without links as it is", () => {
+    const { story, files } = readStory(path.join(fixtures, "story-2.md"), "Writing");
+    assert.equal(files.length, 1);
+    assert.match(story, /^\[Content Warning: more peril\.\]\n\n# Story two/);
+  });
+});
+
+describe("readExtraPages", () => {
+  it("reads front pages with their roles", () => {
+    const [dedication, warnings] = readExtraPages(index, ["Dedication", "content warnings"], "front");
+    assert.deepEqual(dedication, {
+      title: "Dedication", body: "To *everyone*.", epubType: "dedication", listed: false, showTitle: false, textClass: "dedication",
+    });
+    assert.equal(warnings?.title, "Content Warnings");
+    assert.equal(warnings?.epubType, "preface");
+  });
+
+  it("reads back pages as appendices, with the public section and not the notes", () => {
+    const [characters] = readExtraPages(index, ["Characters"], "back");
+    assert.equal(characters?.epubType, "appendix");
+    assert.equal(characters?.body, "## Hero\n\nThe one the stories are about.");
+  });
+
+  it("fails on a name that is not in the file", () => {
+    assert.throws(() => readExtraPages(index, ["Map"], "back"), /no heading named "Map", which is listed in "back"/);
+  });
+});
+
+describe("assemble", () => {
+  const story = "# Story\n\n## Scene\n\nText.\n";
+  const dedication = { title: "Dedication", body: "To you.", epubType: "dedication", listed: false, showTitle: false, textClass: "dedication" };
+  const guide = { title: "Guide", body: "People.", epubType: "appendix", listed: true, showTitle: true };
+
+  it("returns the story unchanged when there are no extra pages", () => {
+    assert.equal(assemble(story, [], [], "docx", false, "Book"), "# Story\n\n## Scene\n\nText.\n");
+    assert.equal(assemble(story, [], [], "html", false, "Book"), "# Story\n\n## Scene\n\nText.\n");
+  });
+
+  it("puts a flowing epub under one heading with the book title", () => {
+    assert.equal(assemble(story, [], [], "epub", false, "Book"), "# Book {.unnumbered}\n\n## Story\n\n### Scene\n\nText.\n");
+    assert.equal(assemble(story, [], [], "epub", true, "Book"), "# Story\n\n## Scene\n\nText.\n");
+  });
+
+  it("gives each extra page its own level 1 heading in the epub", () => {
+    const markdown = assemble(story, [dedication], [guide], "epub", false, "Book");
+    assert.equal(markdown, [
+      "# Dedication {.unlisted .hidden-title epub:type=dedication}",
+      '::: {.dedication custom-style="Dedication"}\nTo you.\n:::',
+      "# Book {.unnumbered}",
+      "## Story\n\n### Scene\n\nText.",
+      "# Guide {epub:type=appendix}",
+      "People.",
+    ].join("\n\n") + "\n");
+  });
+
+  it("puts page breaks before the extra pages and the story in the docx, and leaves out a hidden heading", () => {
+    const pageBreak = '```{=openxml}\n<w:p><w:r><w:br w:type="page"/></w:r></w:p>\n```';
+    const markdown = assemble(story, [dedication], [guide], "docx", false, "Book");
+    assert.equal(markdown, [
+      pageBreak,
+      '::: {.dedication custom-style="Dedication"}\nTo you.\n:::',
+      pageBreak,
+      "# Story\n\n## Scene\n\nText.",
+      pageBreak,
+      "# Guide",
+      "People.",
+    ].join("\n\n") + "\n");
+  });
+
+  it("uses a div as the page break in html", () => {
+    const markdown = assemble(story, [], [guide], "html", false, "Book");
+    assert.equal(markdown, '# Story\n\n## Scene\n\nText.\n\n<div class="page-break"></div>\n\n# Guide\n\nPeople.\n');
+  });
+});
diff --git a/test/pandoc.test.ts b/test/pandoc.test.ts
new file mode 100644
index 0000000..1065d76
--- /dev/null
+++ b/test/pandoc.test.ts
@@ -0,0 +1,100 @@
+// Checks what pandoc makes of the markdown that assemble() writes.
+// These tests are skipped when pandoc is not installed.
+
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import path from "node:path";
+import { describe, it } from "node:test";
+import { fileURLToPath } from "node:url";
+import { assemble, pandocFormat, readExtraPages, readStory, type Target } from "../src/manuscript.ts";
+
+const hasPandoc = spawnSync("pandoc", ["--version"]).status === 0;
+const index = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "collection", "index.md");
+
+interface Node {
+  t: string;
+  c?: unknown;
+}
+
+function parse(markdown: string): Node[] {
+  const result = spawnSync("pandoc", [`--from=${pandocFormat}`, "--to=json"], { input: markdown, encoding: "utf8" });
+  assert.equal(result.status, 0, result.stderr);
+  return JSON.parse(result.stdout).blocks;
+}
+
+/** The plain words of a block list, with soft line breaks counted as spaces. */
+function words(blocks: unknown): string {
+  const out: string[] = [];
+  const walk = (value: unknown): void => {
+    if (Array.isArray(value)) value.forEach(walk);
+    else if (value && typeof value === "object") {
+      const node = value as Node;
+      if (node.t === "Str") out.push(node.c as string);
+      else if (node.t === "Space" || node.t === "SoftBreak") out.push(" ");
+      else walk(node.c);
+    }
+  };
+  walk(blocks);
+  return out.join("").replace(/ +/g, " ").trim();
+}
+
+/** [level, attributes, text] of each heading. */
+function headings(blocks: Node[]): [number, string[], Record<string, string>, string][] {
+  return blocks.filter((b) => b.t === "Header").map((b) => {
+    const [level, [, classes, pairs], inlines] = b.c as [number, [string, string[], [string, string][]], unknown];
+    return [level, classes, Object.fromEntries(pairs), words(inlines)];
+  });
+}
+
+function book(target: Target, chapterBreaks = false): Node[] {
+  const { story } = readStory(index, "Writing");
+  const front = readExtraPages(index, ["Dedication", "Content Warnings"], "front");
+  const back = readExtraPages(index, ["Characters"], "back");
+  return parse(assemble(story, front, back, target, chapterBreaks, "A Collection"));
+}
+
+describe("pandoc", { skip: hasPandoc ? false : "pandoc is not installed" }, () => {
+  it("joins sentences on separate lines into one paragraph, like a paragraph on one line", () => {
+    const lines = parse("One sentence.\nTwo sentence.\n\nNew paragraph.\n");
+    const joined = parse("One sentence. Two sentence.\n\nNew paragraph.\n");
+    assert.equal(lines.length, 2);
+    assert.deepEqual(lines.map(words), joined.map(words));
+    assert.equal(JSON.stringify(lines).includes("LineBreak"), false);
+  });
+
+  it("reads braces in prose as text", () => {
+    assert.equal(words(parse("She said {quietly} that it was over.\n")), "She said {quietly} that it was over.");
+  });
+
+  it("marks the extra pages of the epub as level 1 headings with their roles", () => {
+    assert.deepEqual(headings(book("epub")).filter(([level]) => level === 1), [
+      [1, ["unlisted", "hidden-title"], { "epub:type": "dedication" }, "Dedication"],
+      [1, [], { "epub:type": "preface" }, "Content Warnings"],
+      [1, ["unnumbered"], {}, "A Collection"],
+      [1, [], { "epub:type": "appendix" }, "Characters"],
+    ]);
+  });
+
+  it("keeps the stories as level 2 headings in a flowing epub, and level 1 with chapter breaks", () => {
+    const titles = (blocks: Node[], level: number) => headings(blocks).filter(([l]) => l === level).map(([, , , text]) => text);
+    assert.deepEqual(titles(book("epub"), 2), ["Story one", "~Intermission One~", "Story two", "Hero"]);
+    assert.deepEqual(titles(book("epub", true), 1), ["Dedication", "Content Warnings", "Story one", "~Intermission One~", "Story two", "Characters"]);
+  });
+
+  it("wraps the dedication in a div with the Dedication style", () => {
+    const div = book("docx").find((b) => b.t === "Div");
+    const [[, classes, pairs], content] = div!.c as [[string, string[], [string, string][]], unknown];
+    assert.deepEqual(classes, ["dedication"]);
+    assert.deepEqual(pairs, [["custom-style", "Dedication"]]);
+    assert.equal(words(content), "To everyone.");
+  });
+
+  it("puts a page break before each extra page and before the story in the docx", () => {
+    const blocks = book("docx");
+    const breaks = blocks.map((b, i) => (b.t === "RawBlock" ? i : -1)).filter((i) => i >= 0);
+    assert.equal(breaks.length, 4);
+    for (const i of breaks) assert.deepEqual((blocks[i]!.c as [string, string])[0], "openxml");
+    // No heading is shown for the dedication.
+    assert.equal(headings(blocks).some(([, , , text]) => text === "Dedication"), false);
+  });
+});
diff --git a/tsconfig.build.json b/tsconfig.build.json
new file mode 100644
index 0000000..e9ce7ef
--- /dev/null
+++ b/tsconfig.build.json
@@ -0,0 +1,12 @@
+{
+  // Compiles src/ to dist/ for the published package (npm run build).
+  // Installed packages live in node_modules, where Node does not run .ts files.
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "noEmit": false,
+    "allowImportingTsExtensions": false,
+    "rootDir": "src",
+    "outDir": "dist"
+  },
+  "include": ["src"]
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..f90a36e
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,22 @@
+{
+  // Type checking only (npm run typecheck). tsconfig.build.json writes dist/.
+  // Node runs the .ts files directly, so the code only uses syntax that Node can strip.
+  "compilerOptions": {
+    "target": "ES2023",
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext",
+    "lib": ["ES2023"],
+    "types": ["node"],
+    "strict": true,
+    "noUncheckedIndexedAccess": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "erasableSyntaxOnly": true,
+    "verbatimModuleSyntax": true,
+    "allowImportingTsExtensions": true,
+    "rewriteRelativeImportExtensions": true,
+    "skipLibCheck": true,
+    "noEmit": true
+  },
+  "include": ["src", "test"]
+}