Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ All notable changes to this project are documented here. Format: Keep a Changelo

### Features

- **Several outputs per project.** `outputs:` in `scriptorium.yaml` names
several renders of one source, each PDF with its own page size (`page:`) and
vars. `scriptorium render` builds them all; `--only NAME` picks. A project
without `outputs:` still renders `book.pdf`.
- **`render_html()` and `scriptorium render --html` return the document HTML.**
Everything a PDF goes through (footnotes, citations, glossary, timeline, the
parser, `emit()`) without the printing, so a screen consumer gets every
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,32 @@ scriptorium render scriptorium.yaml # concatenates the files -> book.pdf
scriptorium tangle scriptorium.yaml # extract export= code into src/
```

### Several outputs

`outputs:` names several renders of the same project. Without it a project
renders one PDF, `book.pdf`, as before.

```yaml
outputs:
print: # the name is what --only takes
format: pdf # file defaults to <name>.pdf
file: book.pdf
pocket:
format: pdf
file: book-a5.pdf
page: {size: A5, margin: 14mm} # overrides the theme's page block
```

`page.size` takes `A4`, `A5`, `letter`, `16:9`, `4:3` or `"<w>mm <h>mm"`; an
output's `vars` override the project's. Paths are relative to the project file.

```bash
scriptorium render scriptorium.yaml # every output
scriptorium render scriptorium.yaml --only pocket # one (repeatable)
```

`-o` works only when a single output is being built.

## Themes

Themes are project templates. Point at one and customize with `vars:` — no theme
Expand Down
463 changes: 463 additions & 0 deletions docs/superpowers/plans/2026-10-20-html-book.html

Large diffs are not rendered by default.

497 changes: 497 additions & 0 deletions docs/superpowers/specs/2026-10-20-html-book-design.html

Large diffs are not rendered by default.

72 changes: 55 additions & 17 deletions scriptorium/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,53 @@
from .tangle import write as tangle_write


def _warn(report) -> None:
for w in [*report.oversized, *report.warnings, *report.underfull]:
print(f" ⚠ {w}", file=sys.stderr)


def _render_project(args) -> int:
"""Build every output of a scriptorium.yaml, or the ones --only names."""
from .galley import render_pdf
from .project import ProjectError
from .project import load as load_project

try:
proj = load_project(args.input)
except ProjectError as e:
print(f"scriptorium: {e}", file=sys.stderr)
return 2
outputs = proj.outputs
if args.only:
names = [o.name for o in outputs]
missing = [n for n in args.only if n not in names]
if missing:
print(f"scriptorium: no output named {', '.join(missing)}; "
f"this project has {', '.join(names)}", file=sys.stderr)
return 2
outputs = [o for o in outputs if o.name in args.only]
if args.output and len(outputs) > 1:
print(f"scriptorium: -o is ambiguous with {len(outputs)} outputs; "
"pick one with --only", file=sys.stderr)
return 2

base = args.input.resolve().parent
cwd = str(base)
for o in outputs:
if o.format == "html":
print(f"{o.name}: html output is not available yet", file=sys.stderr)
return 2
out = args.output or (base / o.file)
report = render_pdf(proj.src, str(out), base_url=cwd + "/",
theme_name=proj.theme, cwd=cwd,
execute=not args.no_execute, vars={**proj.vars, **o.vars},
code_root=proj.code_root, project_meta=proj.meta,
page=o.page)
print(f"{o.name}: {out} — {report.n_pages} page(s)")
_warn(report)
return 0


def main(argv=None) -> int:
p = argparse.ArgumentParser(prog="scriptorium")
sub = p.add_subparsers(dest="cmd", required=True)
Expand All @@ -20,6 +67,10 @@ def main(argv=None) -> int:
help="override the document's frontmatter `theme:`")
r.add_argument("--html", action="store_true",
help="write the HTML document instead of a PDF (needs no WeasyPrint)")
r.add_argument("--only", action="append", metavar="NAME", default=None,
help="build only this output of a project (repeatable)")
r.add_argument("--format", choices=("pdf", "html"), default="pdf",
help="for a single file: a PDF (default) or an HTML book")

t = sub.add_parser("tangle", help="extract export= code blocks to source files")
t.add_argument("input", type=Path)
Expand Down Expand Up @@ -57,23 +108,10 @@ def main(argv=None) -> int:
from .galley import render_pdf

if args.input.suffix in (".yaml", ".yml"): # a project
from .project import load as load_project

proj = load_project(args.input)
out = args.output or (args.input.parent / "book.pdf")
cwd = str(args.input.resolve().parent)
report = render_pdf(proj.src, str(out), base_url=cwd + "/",
theme_name=proj.theme, cwd=cwd,
execute=not args.no_execute, vars=proj.vars,
code_root=proj.code_root, project_meta=proj.meta)
print(f"rendered {out} — {report.n_pages} page(s)")
for w in report.oversized:
print(f" ⚠ {w}", file=sys.stderr)
for w in report.warnings:
print(f" ⚠ {w}", file=sys.stderr)
for w in report.underfull:
print(f" ⚠ {w}", file=sys.stderr)
return 0
return _render_project(args)
if args.format == "html":
print("html output is not available yet", file=sys.stderr)
return 2
out = args.output or args.input.with_suffix(".pdf")
cwd = str(args.input.resolve().parent)
report = render_pdf(src, str(out), base_url=cwd + "/", theme_name=args.theme,
Expand Down
2 changes: 1 addition & 1 deletion scriptorium/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

# named page sizes (mm) — landscape slides for decks
_SIZES = {
"a4": (210.0, 297.0), "letter": (215.9, 279.4),
"a4": (210.0, 297.0), "a5": (148.0, 210.0), "letter": (215.9, 279.4),
"16:9": (254.0, 142.875), "4:3": (254.0, 190.5),
}

Expand Down
4 changes: 2 additions & 2 deletions scriptorium/galley.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,12 +470,12 @@ def render_pdf(src: str, out_path: str, base_url: str | None = None,
theme_name: str | None = None, cwd: str | None = None,
execute: bool = True, vars: dict | None = None,
code_root: str | None = None,
project_meta: dict | None = None) -> Report:
project_meta: dict | None = None, page: dict | None = None) -> Report:
from .render import prepare

HTML = _weasy()
p = prepare(src, theme_name=theme_name, cwd=cwd, execute=execute, vars=vars,
code_root=code_root, project_meta=project_meta)
code_root=code_root, project_meta=project_meta, page=page)
theme, meta, units, warnings = p.theme, p.meta, p.units, p.warnings
_, _, content_h = _geom(theme)

Expand Down
56 changes: 55 additions & 1 deletion scriptorium/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,59 @@
_VAR = re.compile(r"\{\{\s*([\w.-]+)\s*\}\}")


class ProjectError(ValueError):
"""A scriptorium.yaml that cannot be built as written."""


@dataclass
class Output:
"""One render of a project: a PDF (`file`) or an HTML book (`dir`)."""
name: str
format: str
file: str | None = None
dir: str | None = None
page: dict | None = None
vars: dict = field(default_factory=dict)
cover: str | None = None
image_max_width: int = 1600
js: list[str] = field(default_factory=list)
css: list[str] = field(default_factory=list)


# the keys each format accepts; anything else is a typo or a key for the
# other format, and silently ignoring it would render the wrong book
_OUTPUT_KEYS = {
"pdf": {"format", "file", "page", "vars"},
"html": {"format", "dir", "vars", "cover", "image-max-width", "js", "css"},
}


def _outputs(spec) -> list[Output]:
if spec is None:
return [Output(name="pdf", format="pdf", file="book.pdf")]
if not isinstance(spec, dict):
raise ProjectError("outputs: expected a mapping of output names to settings")
outs = []
for name, cfg in spec.items():
cfg = cfg or {}
fmt = cfg.get("format")
if fmt not in _OUTPUT_KEYS:
raise ProjectError(f"outputs.{name}: format {fmt!r} is not one of pdf, html")
unknown = sorted(set(cfg) - _OUTPUT_KEYS[fmt])
if unknown:
raise ProjectError(f"outputs.{name}: unknown key(s) for a {fmt} output: "
+ ", ".join(unknown))
outs.append(Output(
name=name, format=fmt,
file=cfg.get("file", f"{name}.pdf") if fmt == "pdf" else None,
dir=cfg.get("dir", name) if fmt == "html" else None,
page=cfg.get("page"), vars=cfg.get("vars") or {}, cover=cfg.get("cover"),
image_max_width=int(cfg.get("image-max-width", 1600)),
js=list(cfg.get("js") or []), css=list(cfg.get("css") or []),
))
return outs


@dataclass
class Project:
theme: str
Expand All @@ -23,6 +76,7 @@ class Project:
base_dir: Path
code_root: str | None = None
meta: dict = field(default_factory=dict) # content keys: bibliography, nocite
outputs: list[Output] = field(default_factory=list)


def _strip_frontmatter(text: str) -> str:
Expand Down Expand Up @@ -63,4 +117,4 @@ def load(path: str | Path) -> Project:

code_root = (spec.get("code") or {}).get("root")
return Project(theme=theme, vars=vars, src=src, base_dir=base,
code_root=code_root, meta=meta)
code_root=code_root, meta=meta, outputs=_outputs(spec.get("outputs")))
6 changes: 5 additions & 1 deletion scriptorium/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,16 @@ class Prepared:
def prepare(src: str, *, theme_name: str | None = None, cwd: str | None = None,
execute: bool = True, vars: dict | None = None,
code_root: str | None = None,
project_meta: dict | None = None, frozen: bool = False) -> Prepared:
project_meta: dict | None = None, frozen: bool = False,
page: dict | None = None) -> Prepared:
"""Run the preprocessors and the parser: the units, theme and metadata a
document renders from."""
from .parse import frontmatter, parse

theme = load_theme(resolve_theme_name(src, theme_name))
if page:
# one output's page size and margin; a copy, so no other render sees it
theme.meta["page"] = {**theme.meta.get("page", {}), **page}
# theme var defaults, overridden by project vars, then by per-doc frontmatter
merged = {**theme.vars, **(vars or {})}
# a project's chapters have had their frontmatter stripped, so project_meta
Expand Down
132 changes: 132 additions & 0 deletions tests/test_outputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""outputs: several renders of one project (PDF sizes, the HTML book)."""

import pytest

from scriptorium.project import ProjectError, load


def _project(tmp_path, yaml_text):
(tmp_path / "a.md").write_text("# A\n\nHello.\n", encoding="utf-8")
path = tmp_path / "scriptorium.yaml"
path.write_text(yaml_text, encoding="utf-8")
return load(path)


def test_no_outputs_defaults_to_one_pdf(tmp_path):
p = _project(tmp_path, "theme: book\nfiles: [a.md]\n")
assert [(o.name, o.format, o.file) for o in p.outputs] == [("pdf", "pdf", "book.pdf")]


def test_outputs_take_names_and_defaults(tmp_path):
p = _project(tmp_path, """files: [a.md]
outputs:
print: {format: pdf}
pocket: {format: pdf, file: small.pdf, page: {size: A5, margin: 14mm}}
web: {format: html, cover: c.jpg, js: [x.js]}
""")
by = {o.name: o for o in p.outputs}
assert [o.name for o in p.outputs] == ["print", "pocket", "web"]
assert by["print"].file == "print.pdf"
assert by["pocket"].page == {"size": "A5", "margin": "14mm"}
assert by["web"].dir == "web" and by["web"].image_max_width == 1600
assert by["web"].js == ["x.js"] and by["web"].cover == "c.jpg"


def test_kebab_keys_map_to_fields(tmp_path):
p = _project(tmp_path, "files: [a.md]\noutputs:\n web: {format: html, image-max-width: 900}\n")
assert p.outputs[0].image_max_width == 900


def test_unknown_output_key_is_named(tmp_path):
with pytest.raises(ProjectError, match="web.*colour"):
_project(tmp_path, "files: [a.md]\noutputs:\n web: {format: html, colour: red}\n")


def test_a_pdf_key_on_an_html_output_is_refused(tmp_path):
with pytest.raises(ProjectError, match="web.*page"):
_project(tmp_path, "files: [a.md]\noutputs:\n web: {format: html, page: {size: A5}}\n")


def test_unknown_format_is_refused(tmp_path):
with pytest.raises(ProjectError, match="epub"):
_project(tmp_path, "files: [a.md]\noutputs:\n e: {format: epub}\n")


def test_outputs_must_be_a_mapping(tmp_path):
with pytest.raises(ProjectError, match="outputs"):
_project(tmp_path, "files: [a.md]\noutputs: [pdf, html]\n")


def _mm(path):
from pypdf import PdfReader
box = PdfReader(path).pages[0].mediabox
return float(box.width) * 25.4 / 72, float(box.height) * 25.4 / 72


def test_page_override_sets_a5_and_leaves_the_theme_alone(tmp_path):
from scriptorium.galley import render_pdf
md = "---\ntheme: book\n---\n\nHello.\n"
render_pdf(md, str(tmp_path / "a5.pdf"), page={"size": "A5"})
render_pdf(md, str(tmp_path / "a4.pdf"))
w5, h5 = _mm(tmp_path / "a5.pdf")
w4, h4 = _mm(tmp_path / "a4.pdf")
assert abs(w5 - 148) < 0.5 and abs(h5 - 210) < 0.5
assert abs(w4 - 210) < 0.5 and abs(h4 - 297) < 0.5


def _two_pdf_project(tmp_path):
(tmp_path / "a.md").write_text("# A\n\nHello.\n", encoding="utf-8")
path = tmp_path / "scriptorium.yaml"
path.write_text("""theme: book
files: [a.md]
outputs:
print: {format: pdf}
pocket: {format: pdf, file: small.pdf, page: {size: A5}}
""", encoding="utf-8")
return path


def test_render_builds_every_output(tmp_path, capsys):
from scriptorium.cli import main
proj = _two_pdf_project(tmp_path)
assert main(["render", str(proj)]) == 0
assert (tmp_path / "print.pdf").exists() and (tmp_path / "small.pdf").exists()
assert abs(_mm(tmp_path / "small.pdf")[0] - 148) < 0.5
out = capsys.readouterr().out
assert "print:" in out and "pocket:" in out


def test_only_builds_one(tmp_path):
from scriptorium.cli import main
proj = _two_pdf_project(tmp_path)
assert main(["render", str(proj), "--only", "pocket"]) == 0
assert not (tmp_path / "print.pdf").exists() and (tmp_path / "small.pdf").exists()


def test_dash_o_with_several_outputs_is_refused(tmp_path, capsys):
from scriptorium.cli import main
proj = _two_pdf_project(tmp_path)
assert main(["render", str(proj), "-o", str(tmp_path / "x.pdf")]) == 2
assert "--only" in capsys.readouterr().err


def test_dash_o_with_one_selected_output_writes_there(tmp_path):
from scriptorium.cli import main
proj = _two_pdf_project(tmp_path)
assert main(["render", str(proj), "--only", "print", "-o", str(tmp_path / "x.pdf")]) == 0
assert (tmp_path / "x.pdf").exists()


def test_only_with_an_unknown_name_lists_the_names(tmp_path, capsys):
from scriptorium.cli import main
proj = _two_pdf_project(tmp_path)
assert main(["render", str(proj), "--only", "web"]) == 2
assert "print, pocket" in capsys.readouterr().err


def test_project_without_outputs_still_writes_book_pdf(tmp_path):
from scriptorium.cli import main
(tmp_path / "a.md").write_text("# A\n\nHello.\n", encoding="utf-8")
(tmp_path / "scriptorium.yaml").write_text("theme: book\nfiles: [a.md]\n", encoding="utf-8")
assert main(["render", str(tmp_path / "scriptorium.yaml")]) == 0
assert (tmp_path / "book.pdf").exists()
Loading