lectern-slides 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. lectern_slides-0.1.0/.github/workflows/ci.yml +48 -0
  2. lectern_slides-0.1.0/.github/workflows/release.yml +46 -0
  3. lectern_slides-0.1.0/.gitignore +25 -0
  4. lectern_slides-0.1.0/CHANGELOG.md +70 -0
  5. lectern_slides-0.1.0/CLAUDE.md +98 -0
  6. lectern_slides-0.1.0/LICENSE +21 -0
  7. lectern_slides-0.1.0/PDF-EXPORT.md +179 -0
  8. lectern_slides-0.1.0/PKG-INFO +457 -0
  9. lectern_slides-0.1.0/README.md +427 -0
  10. lectern_slides-0.1.0/ROADMAP.md +85 -0
  11. lectern_slides-0.1.0/SPECIFICATION.md +326 -0
  12. lectern_slides-0.1.0/deck.toml +42 -0
  13. lectern_slides-0.1.0/examples/sample-deck/README.md +109 -0
  14. lectern_slides-0.1.0/examples/sample-deck/_partials/principles.md +60 -0
  15. lectern_slides-0.1.0/examples/sample-deck/_partials/qualifications.md +7 -0
  16. lectern_slides-0.1.0/examples/sample-deck/assets/bg-grid.svg +24 -0
  17. lectern_slides-0.1.0/examples/sample-deck/assets/d3-bars.html +68 -0
  18. lectern_slides-0.1.0/examples/sample-deck/assets/pups.jpg +0 -0
  19. lectern_slides-0.1.0/examples/sample-deck/assets/request-flow.svg +27 -0
  20. lectern_slides-0.1.0/examples/sample-deck/assets/stars.jpg +0 -0
  21. lectern_slides-0.1.0/examples/sample-deck/assets/webgl-triangle.html +123 -0
  22. lectern_slides-0.1.0/examples/sample-deck/deck.toml +71 -0
  23. lectern_slides-0.1.0/examples/sample-deck/slides/00-title.md +17 -0
  24. lectern_slides-0.1.0/examples/sample-deck/slides/10-agenda.md +16 -0
  25. lectern_slides-0.1.0/examples/sample-deck/slides/20-qualifications.md +6 -0
  26. lectern_slides-0.1.0/examples/sample-deck/slides/22-ranges.md +13 -0
  27. lectern_slides-0.1.0/examples/sample-deck/slides/25-code.md +27 -0
  28. lectern_slides-0.1.0/examples/sample-deck/slides/26-diagram.md +15 -0
  29. lectern_slides-0.1.0/examples/sample-deck/slides/27-icons.md +18 -0
  30. lectern_slides-0.1.0/examples/sample-deck/slides/28-image-sizing.md +28 -0
  31. lectern_slides-0.1.0/examples/sample-deck/slides/30-background.md +7 -0
  32. lectern_slides-0.1.0/examples/sample-deck/slides/32-image.md +6 -0
  33. lectern_slides-0.1.0/examples/sample-deck/slides/40-incremental.md +22 -0
  34. lectern_slides-0.1.0/examples/sample-deck/slides/45-layout.md +43 -0
  35. lectern_slides-0.1.0/examples/sample-deck/slides/50-animation.md +10 -0
  36. lectern_slides-0.1.0/examples/sample-deck/slides/55-webgl.md +8 -0
  37. lectern_slides-0.1.0/examples/sample-deck/slides/60-table.md +10 -0
  38. lectern_slides-0.1.0/examples/sample-deck/slides/70-math.md +16 -0
  39. lectern_slides-0.1.0/examples/sample-deck/slides/80-closing.md +18 -0
  40. lectern_slides-0.1.0/examples/sample-deck/themes/midnight.css +94 -0
  41. lectern_slides-0.1.0/examples/sample-deck/themes/paper.css +97 -0
  42. lectern_slides-0.1.0/pyproject.toml +64 -0
  43. lectern_slides-0.1.0/src/lectern/__init__.py +9 -0
  44. lectern_slides-0.1.0/src/lectern/a11y.py +290 -0
  45. lectern_slides-0.1.0/src/lectern/assets.py +161 -0
  46. lectern_slides-0.1.0/src/lectern/cli.py +824 -0
  47. lectern_slides-0.1.0/src/lectern/config.py +310 -0
  48. lectern_slides-0.1.0/src/lectern/errors.py +52 -0
  49. lectern_slides-0.1.0/src/lectern/fontawesome.py +66 -0
  50. lectern_slides-0.1.0/src/lectern/outline.py +236 -0
  51. lectern_slides-0.1.0/src/lectern/pdf/__init__.py +13 -0
  52. lectern_slides-0.1.0/src/lectern/pdf/colors.py +87 -0
  53. lectern_slides-0.1.0/src/lectern/pdf/geometry.py +185 -0
  54. lectern_slides-0.1.0/src/lectern/pdf/grayscale.py +47 -0
  55. lectern_slides-0.1.0/src/lectern/pdf/impose.py +191 -0
  56. lectern_slides-0.1.0/src/lectern/pdf/master.py +98 -0
  57. lectern_slides-0.1.0/src/lectern/pdf/options.py +105 -0
  58. lectern_slides-0.1.0/src/lectern/pdf/pipeline.py +145 -0
  59. lectern_slides-0.1.0/src/lectern/pdf/posters.py +86 -0
  60. lectern_slides-0.1.0/src/lectern/pdf/printcss.py +58 -0
  61. lectern_slides-0.1.0/src/lectern/preprocess.py +333 -0
  62. lectern_slides-0.1.0/src/lectern/ranges.py +75 -0
  63. lectern_slides-0.1.0/src/lectern/remark_compat.py +167 -0
  64. lectern_slides-0.1.0/src/lectern/render/__init__.py +33 -0
  65. lectern_slides-0.1.0/src/lectern/render/_external.py +41 -0
  66. lectern_slides-0.1.0/src/lectern/render/base.py +87 -0
  67. lectern_slides-0.1.0/src/lectern/render/lowering.py +303 -0
  68. lectern_slides-0.1.0/src/lectern/render/marp.py +160 -0
  69. lectern_slides-0.1.0/src/lectern/render/quarto.py +179 -0
  70. lectern_slides-0.1.0/src/lectern/render/remark.py +157 -0
  71. lectern_slides-0.1.0/src/lectern/render/reveal.py +209 -0
  72. lectern_slides-0.1.0/src/lectern/serve.py +363 -0
  73. lectern_slides-0.1.0/src/lectern/slides.py +97 -0
  74. lectern_slides-0.1.0/src/lectern/source.py +48 -0
  75. lectern_slides-0.1.0/src/lectern/sourcemap.py +50 -0
  76. lectern_slides-0.1.0/src/lectern/templates/remark.html.j2 +94 -0
  77. lectern_slides-0.1.0/src/lectern/templates/reveal.html.j2 +498 -0
  78. lectern_slides-0.1.0/src/lectern/themes/base.css +136 -0
  79. lectern_slides-0.1.0/src/lectern/themes/blue-professional.css +148 -0
  80. lectern_slides-0.1.0/src/lectern/themes/broadside.css +150 -0
  81. lectern_slides-0.1.0/src/lectern/themes/cartesian.css +137 -0
  82. lectern_slides-0.1.0/src/lectern/themes/grove.css +149 -0
  83. lectern_slides-0.1.0/src/lectern/themes/long-table.css +163 -0
  84. lectern_slides-0.1.0/src/lectern/themes/mat.css +153 -0
  85. lectern_slides-0.1.0/src/lectern/themes/monochrome.css +145 -0
  86. lectern_slides-0.1.0/src/lectern/themes/sakura-chroma.css +162 -0
  87. lectern_slides-0.1.0/src/lectern/themes/signal.css +153 -0
  88. lectern_slides-0.1.0/src/lectern/themes/soft-editorial.css +146 -0
  89. lectern_slides-0.1.0/src/lectern/themes/vellum.css +134 -0
  90. lectern_slides-0.1.0/src/lectern/theming.py +168 -0
  91. lectern_slides-0.1.0/tests/conftest.py +30 -0
  92. lectern_slides-0.1.0/tests/fixtures/deck/_partials/lib.md +21 -0
  93. lectern_slides-0.1.0/tests/fixtures/deck/_partials/note.md +1 -0
  94. lectern_slides-0.1.0/tests/fixtures/deck/deck.toml +11 -0
  95. lectern_slides-0.1.0/tests/fixtures/deck/slides/00-intro.md +9 -0
  96. lectern_slides-0.1.0/tests/fixtures/deck/slides/20-fence.md +12 -0
  97. lectern_slides-0.1.0/tests/fixtures/deck/slides/30-canvas.md +9 -0
  98. lectern_slides-0.1.0/tests/fixtures/deck.assembled.golden.md +55 -0
  99. lectern_slides-0.1.0/tests/fixtures/legacy-remark/builds.md +10 -0
  100. lectern_slides-0.1.0/tests/fixtures/legacy-remark/deck.toml +10 -0
  101. lectern_slides-0.1.0/tests/fixtures/legacy-remark/title.md +6 -0
  102. lectern_slides-0.1.0/tests/fixtures/render-deck/assets/grid.svg +1 -0
  103. lectern_slides-0.1.0/tests/fixtures/render-deck/assets/logo.png +1 -0
  104. lectern_slides-0.1.0/tests/fixtures/render-deck/deck.toml +17 -0
  105. lectern_slides-0.1.0/tests/fixtures/render-deck/slides/anchor.md +9 -0
  106. lectern_slides-0.1.0/tests/fixtures/render-deck/slides/bg.md +5 -0
  107. lectern_slides-0.1.0/tests/fixtures/render-deck/slides/incr.md +16 -0
  108. lectern_slides-0.1.0/tests/test_a11y.py +213 -0
  109. lectern_slides-0.1.0/tests/test_assets.py +95 -0
  110. lectern_slides-0.1.0/tests/test_cli.py +345 -0
  111. lectern_slides-0.1.0/tests/test_config.py +83 -0
  112. lectern_slides-0.1.0/tests/test_config_layering.py +79 -0
  113. lectern_slides-0.1.0/tests/test_external_deck.py +90 -0
  114. lectern_slides-0.1.0/tests/test_fontawesome.py +60 -0
  115. lectern_slides-0.1.0/tests/test_golden.py +16 -0
  116. lectern_slides-0.1.0/tests/test_lowering_notes.py +56 -0
  117. lectern_slides-0.1.0/tests/test_outline.py +108 -0
  118. lectern_slides-0.1.0/tests/test_pdf.py +384 -0
  119. lectern_slides-0.1.0/tests/test_preprocess.py +203 -0
  120. lectern_slides-0.1.0/tests/test_ranges.py +54 -0
  121. lectern_slides-0.1.0/tests/test_remark_compat.py +112 -0
  122. lectern_slides-0.1.0/tests/test_render_marp.py +126 -0
  123. lectern_slides-0.1.0/tests/test_render_quarto.py +104 -0
  124. lectern_slides-0.1.0/tests/test_render_remark.py +85 -0
  125. lectern_slides-0.1.0/tests/test_render_reveal.py +350 -0
  126. lectern_slides-0.1.0/tests/test_serve.py +212 -0
  127. lectern_slides-0.1.0/tests/test_slides.py +64 -0
  128. lectern_slides-0.1.0/tests/test_theming.py +134 -0
  129. lectern_slides-0.1.0/themes/base.css +136 -0
  130. lectern_slides-0.1.0/uv.lock +1022 -0
@@ -0,0 +1,48 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ # Cancel superseded runs on the same ref.
10
+ concurrency:
11
+ group: ci-${{ github.ref }}
12
+ cancel-in-progress: true
13
+
14
+ jobs:
15
+ lint:
16
+ name: Lint & format
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v6
20
+ - name: Install uv
21
+ uses: astral-sh/setup-uv@v7
22
+ with:
23
+ enable-cache: true
24
+ - name: Sync (runtime + dev group)
25
+ run: uv sync
26
+ - name: ruff check
27
+ run: uv run ruff check .
28
+ - name: ruff format --check
29
+ run: uv run ruff format --check .
30
+
31
+ test:
32
+ name: Test (py${{ matrix.python-version }})
33
+ runs-on: ubuntu-latest
34
+ strategy:
35
+ fail-fast: false
36
+ matrix:
37
+ python-version: ["3.11", "3.12", "3.13"]
38
+ steps:
39
+ - uses: actions/checkout@v6
40
+ - name: Install uv
41
+ uses: astral-sh/setup-uv@v7
42
+ with:
43
+ enable-cache: true
44
+ python-version: ${{ matrix.python-version }}
45
+ - name: Sync (runtime + dev group + pdf extra)
46
+ run: uv sync --extra pdf
47
+ - name: pytest
48
+ run: uv run pytest
@@ -0,0 +1,46 @@
1
+ name: Release
2
+
3
+ # Publishes lectern-slides to PyPI when a GitHub Release is published.
4
+ # Uses PyPI Trusted Publishing (OIDC) — no API token secret required. Configure
5
+ # the trusted publisher once at https://pypi.org/manage/account/publishing/ with:
6
+ # owner=bsletten repo=lectern-slides workflow=release.yml environment=pypi
7
+ on:
8
+ release:
9
+ types: [published]
10
+ workflow_dispatch:
11
+
12
+ jobs:
13
+ build:
14
+ name: Build sdist + wheel
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v6
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@v7
20
+ with:
21
+ enable-cache: true
22
+ - name: Build distributions
23
+ run: uv build
24
+ - name: Check metadata
25
+ run: uvx twine check dist/*
26
+ - uses: actions/upload-artifact@v7
27
+ with:
28
+ name: dist
29
+ path: dist/
30
+
31
+ publish:
32
+ name: Publish to PyPI
33
+ needs: build
34
+ runs-on: ubuntu-latest
35
+ environment: pypi
36
+ permissions:
37
+ id-token: write # required for trusted publishing
38
+ steps:
39
+ - uses: actions/download-artifact@v8
40
+ with:
41
+ name: dist
42
+ path: dist/
43
+ - name: Install uv
44
+ uses: astral-sh/setup-uv@v7
45
+ - name: Publish
46
+ run: uv publish
@@ -0,0 +1,25 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+
9
+ # Virtualenvs / tooling
10
+ .venv/
11
+ .ruff_cache/
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+
15
+ # Lectern build artifacts (deck out_dir / build_dir defaults)
16
+ /dist/
17
+ /build/
18
+
19
+ # PDF master cache (written under the deck's build_dir on -f pdf)
20
+ .lectern-cache/
21
+
22
+ # Editor backups (Emacs undo-tree, backups, autosaves)
23
+ *.~undo-tree~
24
+ *~
25
+ \#*\#
@@ -0,0 +1,70 @@
1
+ # Changelog
2
+
3
+ All notable changes to **lectern-slides** are documented here. The format
4
+ follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
5
+ aims to adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] — 2026-06-17
10
+
11
+ First public release. A dependency-light Python CLI (`lectern`) that assembles
12
+ Markdown slide sources into one deck and renders it through a pluggable framework
13
+ adapter, with a live-reload preview server and vector PDF export.
14
+
15
+ ### Assembly
16
+
17
+ - Transclusion and slide-range includes with the `#1-3,14` range grammar.
18
+ - Fence-aware slide splitter; relative + `partials` search-path resolution with
19
+ cycle and depth guards.
20
+ - Frontmatter handling and a source-map so every diagnostic cites the
21
+ originating `file:line`/directive, never an internal offset.
22
+ - `lectern assemble` and `lectern check` (validation + warnings, no render).
23
+
24
+ ### Rendering
25
+
26
+ - Pluggable adapter registry: **reveal** (default) and **remark** native
27
+ adapters (Python + Jinja2 only), plus **marp** and **quarto** subprocess
28
+ adapters that `available()`-guard their external binary.
29
+ - Remark input-compat normalizer so existing Remark decks assemble and render
30
+ unchanged.
31
+ - Neutral directive syntax in HTML comments / Pandoc fenced divs: per-slide
32
+ classes/id/data-attributes, inline and block classes, content anchors, placed
33
+ boxes, and incremental builds.
34
+ - Theme resolution with a token contract; ships a `base.css` plus a set of
35
+ swappable themes. A deck's own theme folder is discovered automatically.
36
+ - Asset resolver (directory + URL `asset_base`): copies, content-hashes, and
37
+ rewrites references; garbage-collects orphaned hashed assets.
38
+ - Mermaid diagrams, Font Awesome icons, image sizing, and raw HTML/`<canvas>`
39
+ passthrough.
40
+
41
+ ### Speaker notes
42
+
43
+ - `<!-- notes -->` comment blocks and `::: {.notes}` fenced divs route to the
44
+ presenter view.
45
+ - `notes:presenter` category for notes that show in the presenter view but are
46
+ **excluded from the printed PDF handout**; a mistyped category is flagged.
47
+
48
+ ### Watch + serve
49
+
50
+ - `lectern watch` — Starlette/Uvicorn serving `dist/`, `watchfiles` rebuild, SSE
51
+ live-reload, and a build-error overlay.
52
+ - `[serve].coi` COOP/COEP headers (the on-ramp for WASM-threads / WebGPU embeds).
53
+
54
+ ### PDF export
55
+
56
+ - Vector master printed once via headless Chromium, then imposed and cached for
57
+ reuse across layout/color changes.
58
+ - Layouts `1up`, `2up`, `2up-notes`, `4up`/`2x2`, `6up`, `3up-notes`; paper
59
+ presets and `WxH`; B&W (token and ghostscript engines), `--no-backgrounds`,
60
+ `--light-inverse`, and `--ink-saver`.
61
+ - `-f pdf|pptx` output across capable adapters with graceful degradation.
62
+
63
+ ### Accessibility
64
+
65
+ - `lectern check` runs a source-cited a11y audit by default: accessible-name and
66
+ alt-text checks, heading-order and contrast lint, tagged PDF output, a document
67
+ outline, and forced-colors support.
68
+
69
+ [Unreleased]: https://github.com/bsletten/lectern-slides/compare/v0.1.0...HEAD
70
+ [0.1.0]: https://github.com/bsletten/lectern-slides/releases/tag/v0.1.0
@@ -0,0 +1,98 @@
1
+ # CLAUDE.md — Lectern
2
+
3
+ Guidance for building Lectern with Claude Code. Read `SPECIFICATION.md` for the
4
+ full design and `ROADMAP.md` for sequencing. This file is the operating manual.
5
+
6
+ ## What you are building
7
+
8
+ A Python CLI (`lectern`) that assembles Markdown slide sources (transclusion +
9
+ slide-range includes) into one deck and renders it via a pluggable framework
10
+ adapter, with a live `--watch` preview server. It is the Markdown front-end to a
11
+ larger resource-oriented slide system; keep the `Source` seam clean so a future
12
+ CMS/graph backend can replace the filesystem source without changing anything
13
+ downstream.
14
+
15
+ ## Prime directives
16
+
17
+ - **Build the smallest thing that works, then grow.** Follow the milestones below
18
+ in order. Do not scaffold future phases speculatively.
19
+ - **Keep the core dependency-light.** `reveal` and `remark` adapters use only
20
+ Python + templates. `marp`/`quarto` adapters shell out and must `available()`-
21
+ guard the external binary. Never make Pandoc a required dependency.
22
+ - **Directives live in HTML comments** so raw `.md` stays valid CommonMark. The
23
+ assemble stage expands them.
24
+ - **Pure where it matters.** `ranges.py`, the fence-aware splitter, and the
25
+ include resolver are pure functions with no I/O — they get the heaviest tests.
26
+ - **Errors map to source.** Carry a source-map; every user-facing error cites the
27
+ originating `file:line`/directive, never an internal offset.
28
+ - **Static-mostly.** Do not model animation timelines. Raw HTML/script passthrough
29
+ plus the asset pipeline is the entire animation story for now.
30
+
31
+ ## Tech choices (don't re-litigate)
32
+
33
+ Python ≥3.11 · `typer` CLI · `pydantic` config · `jinja2` templates ·
34
+ `python-frontmatter` · `watchfiles` · `starlette`+`uvicorn` server · `httpx` for
35
+ URL assets · `tomllib` (stdlib). Package with hatchling: distribution name
36
+ **`lectern-slides`** (PyPI/GitHub), import package `lectern`, `console_scripts` →
37
+ `lectern`, PDF extra `lectern-slides[pdf]`, license **MIT** (`LICENSE` file +
38
+ `license = "MIT"` in pyproject). (Import name `lectern` technically
39
+ shares PyPI's Minecraft `lectern` namespace; harmless here — namespace to
40
+ `lectern_slides` only if that ever matters.) Format with `ruff format`; lint with
41
+ `ruff`; type-check with `pyright`/`mypy`.
42
+
43
+ ## Milestones (each ends in a working, committed, tested state)
44
+
45
+ **M1 — assemble.** `ranges.py` (the `#1-3,14` grammar), fence-aware slide
46
+ splitter, include resolver (relative + `partials` search paths + cycle/depth
47
+ guards), frontmatter handling, source-map. `lectern assemble SOURCE -o out.md`
48
+ and `lectern check SOURCE`. Unit + golden-file tests. No rendering yet.
49
+
50
+ **M2 — render (reveal).** Config model, theme resolution + token injection,
51
+ `reveal` adapter via Jinja2 template (assembled md → reveal.js HTML), asset
52
+ resolver (dir + URL, copy + rewrite). `lectern build SOURCE`. Ship `themes/base.css`.
53
+
54
+ **M3 — watch + serve.** Starlette/Uvicorn serving `dist/`, `watchfiles` rebuild,
55
+ SSE live-reload, build-error overlay, `[serve].coi` COOP/COEP headers.
56
+ `lectern watch SOURCE`.
57
+
58
+ **M4 — remark adapter + migration.** Native `remark` adapter and the Remark
59
+ input-compat normalizer (`.cls[]`, `class:`/`name:`/`layout:` property lines,
60
+ `--` flatten) so existing decks assemble and render unchanged.
61
+
62
+ **M5 — marp + quarto adapters.** Subprocess adapters with neutral→flavor lowering
63
+ and `available()` guards; capability-based graceful degradation. `-f pdf|pptx`.
64
+
65
+ **M6 — PDF finishing.** Per `PDF-EXPORT.md`: the 1-up vector master via Playwright
66
+ ships earlier (M2); this milestone adds N-up imposition / `3up-notes` (pypdf +
67
+ reportlab overlay),
68
+ the B&W engines, `light_inverse`, auto poster capture, and handout chrome.
69
+
70
+ Stop here for v1. Phases beyond (components/WASM/WebGPU, CMS source backend) are
71
+ in `ROADMAP.md` and are *not* M-scope.
72
+
73
+ ## Working habits
74
+
75
+ - Run `pytest` before declaring any milestone done; commit per milestone with a
76
+ short message.
77
+ - When you touch `ranges.py`, the splitter, or the include resolver, add/adjust
78
+ tests in the same change — these are the load-bearing pure functions.
79
+ - Add a `fixtures/` deck early (M1) that exercises includes, ranges, partials,
80
+ assets, classes, notes, and a raw-HTML/`<canvas>` slide; grow it as you go.
81
+ - Prefer one clear way to do a thing over configurable cleverness. This is one
82
+ author's tool.
83
+
84
+ ## Seams to respect (so later phases are cheap, without building them now)
85
+
86
+ - `source.py` `Source` protocol — filesystem now, CMS/graph later. Nothing
87
+ downstream may import filesystem paths directly; go through `Source`.
88
+ - `render/base.py` registry — adapters are discovered, not hard-wired.
89
+ - Theme **token contract** — components and adapters read tokens, never hardcode
90
+ colors/sizes.
91
+ - `[serve].coi` headers — the on-ramp for WASM-threads/WebGPU embeds.
92
+
93
+ ## Definition of done (v1)
94
+
95
+ `lectern watch ./talks/ai-sec` serves a live, reloading reveal deck assembled
96
+ from a manifest with at least one ranged partial include and a URL `asset_base`;
97
+ `lectern build -f pdf` produces a vector PDF; existing Remark decks render via the
98
+ `remark` adapter unchanged; `pytest` is green.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brian Sletten
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,179 @@
1
+ # PDF Export — Strategy
2
+
3
+ How Lectern turns a deck into PDF. Read alongside `SPECIFICATION.md` (§7 reveal
4
+ adapter) and `examples/sample-deck/`.
5
+
6
+ ## Principle
7
+
8
+ One **vector master**, many cheap derivations.
9
+
10
+ ```
11
+ render-time post-process
12
+ assembled deck ──▶ [ Chromium print: reveal ──▶ 1-up master.pdf
13
+ ?print-pdf, vector ] │
14
+ • backgrounds on/off ├─▶ impose ──▶ 2-up / N-up / notes
15
+ • light-inverse │ (pypdf places vector pages)
16
+ • poster frames for embeds └─▶ grayscale ──▶ B&W
17
+ • fragments flatten/steps (tokens or Ghostscript)
18
+ ```
19
+
20
+ The master is always 1 slide / page, vector, full color. Everything the user
21
+ asks for — 2-up, handouts with notes, B&W, no-backgrounds — is either a flag that
22
+ changes the *master render* or a transform *applied to the master*. We never fall
23
+ back to rasterizing the deck (that was DeckTape's failure mode).
24
+
25
+ ## Engines
26
+
27
+ - **Render + capture:** Playwright for Python driving headless Chromium. One
28
+ dependency, no Node. Gives `page.pdf(...)` (vector, honors `@page`,
29
+ `print_background`) and `page.screenshot(...)` for poster capture, plus
30
+ `emulate_media(media="print", reduced_motion="reduce")`.
31
+ - **Imposition (N-up / handouts):** `pypdf` (BSD-3-Clause, pure Python) places the
32
+ source *vector* pages scaled and translated into the grid via
33
+ `PageObject.add_transformation(Transformation().scale(s).translate(x, y))` +
34
+ `merge_page`. The handout chrome that pypdf can't draw — borders, slide numbers,
35
+ header/footer, and the **notes text** — is rendered as a thin overlay with
36
+ `reportlab` (BSD-3-Clause) and merged in. Both pure-Python and permissive, so the
37
+ whole tool stays MIT-clean. No quality loss (slide pages remain vector).
38
+ - **Grayscale (optional, for full-document B&W incl. raster images):** Ghostscript
39
+ (`-sColorConversionStrategy=Gray`). External binary; only needed for the
40
+ `ghostscript` B&W engine (see Color/B&W).
41
+
42
+ These live behind a `lectern-slides[pdf]` install extra so the core stays light.
43
+
44
+ ## Render-time options (change the master)
45
+
46
+ | Option | Effect |
47
+ | --- | --- |
48
+ | `backgrounds = true\|false` | `print_background` on/off **and** an injected print stylesheet that hides `data-background-*` and `.inverse` fills when off. Default `true`. |
49
+ | `light_inverse = true\|false` | Flip `.inverse` (dark) slides to light for ink economy, using the theme's own tokens (`--inverse-bg`→`--bg`, `--inverse-fg`→`--fg`). Default `false`. |
50
+ | `fragments = "flatten"\|"steps"` | Maps to reveal `pdfSeparateFragments`. `flatten` = one page per slide with all builds shown (handout default); `steps` = one page per build. |
51
+ | `paper = "deck"\|"letter"\|"a4"\|WxH` | `deck` (default) uses the slide aspect via `prefer_css_page_size` for the 1-up master; for a multi-up handout `deck` falls back to `letter` (a deck-shaped sheet leaves tiled slides tiny). Named sizes letterbox the slide for standard paper. |
52
+ | `posters = "auto"\|"explicit"\|"off"` | How live embeds become stills (next section). |
53
+
54
+ Because these change printed pixels, they are set when the master is rendered.
55
+ The pipeline injects a small print stylesheet built on the **theme token
56
+ contract**, so `light_inverse` / `backgrounds=off` work for any theme without the
57
+ theme needing its own print block (a theme MAY still add `@media print`
58
+ refinements; it's optional).
59
+
60
+ ## Animations → a default frame
61
+
62
+ Live embeds (the D3 iframe today; WASM/WebGPU components later) can't animate in a
63
+ PDF, so each resolves to one deterministic still. Resolution order:
64
+
65
+ 1. **Explicit poster.** `<iframe data-poster="assets/d3.png">` → that image is
66
+ used. Most predictable; recommended for anything you care about exactly.
67
+ 2. **Auto-capture** (`posters = "auto"`, the default). The pipeline loads the
68
+ embed in headless Chromium with reduced-motion emulated and `?static=1`
69
+ appended, waits `data-poster-at` ms (default 1200) — or for a
70
+ `window.lecternReady === true` signal if the embed sets one — screenshots the
71
+ embed's box to PNG, and swaps the `<iframe>` for an `<img>` in the print DOM.
72
+ 3. **Static-mode passthrough** (`posters = "off"` or capture unavailable). The
73
+ print render loads the embed with reduced-motion + `?static=1`; a well-behaved
74
+ embed paints a single frame, which prints inline. (This already works for the
75
+ sample's `d3-bars.html`, which honors `prefers-reduced-motion`.)
76
+
77
+ **Embed authoring contract** (so an embed produces a clean default frame):
78
+ - Honor `prefers-reduced-motion: reduce` **and** a `?static=1` query by drawing
79
+ one deterministic frame and not looping.
80
+ - Optionally set `window.lecternReady = true` once that frame is painted, so
81
+ auto-capture knows when to shoot instead of guessing with `data-poster-at`.
82
+
83
+ WebGPU/WASM note: headless Chromium needs ANGLE/SwiftShader flags
84
+ (`--enable-unsafe-swiftshader`) for GPU contexts, and late-painting components
85
+ should rely on explicit posters or `lecternReady` rather than a fixed delay.
86
+
87
+ ## Layout: 1-up, 2-up, N-up, handouts (imposition)
88
+
89
+ The master is imposed onto sheets with pypdf (+ a reportlab overlay for chrome and
90
+ notes). Presets:
91
+
92
+ | `layout` | Sheet |
93
+ | --- | --- |
94
+ | `2up-notes` (**default**) | 2 slides stacked on a portrait page, each with its speaker notes beside it |
95
+ | `1up` | the master, unchanged — clean projection slides |
96
+ | `2up` | 2 slides stacked, no notes |
97
+ | `4up` / `2x2` | 4 slides, 2×2 |
98
+ | `6up` | 6 slides, 2×3 |
99
+ | `3up-notes` | 3 slides down the left, **speaker notes beside each** |
100
+
101
+ `2up-notes` is the default delivered layout: a portrait page holds two rows, each
102
+ row a slide thumbnail (~58% width, left) with its notes column (right). Use
103
+ `--layout 1up` when you want bare slides for projection. The notes are the real
104
+ ones Lectern already parses from `<!-- notes -->` / `::: notes`, so handouts carry
105
+ your script, not blank lines.
106
+
107
+ Imposition controls: `paper`, `orientation` (`auto` by default — the sheet is
108
+ turned to match the deck aspect, so wide 16:9 slides tile without big margins),
109
+ `margins`, `gutter`,
110
+ `frame = true|false` (hairline border per thumbnail), `slide_numbers`,
111
+ `header` / `footer` (e.g. title, date, page x/y). All vector; text stays
112
+ selectable.
113
+
114
+ ## Color / B&W
115
+
116
+ Two engines, pick per taste:
117
+
118
+ - **`tokens` (default, no dependency).** The pipeline reads the active theme's
119
+ color tokens, maps each to its perceptual-luminance gray, and injects the gray
120
+ token set for the render. Vector, text stays crisp, and contrast is *designed*
121
+ (the indigo and the vermilion seal become distinct grays rather than mud). Does
122
+ **not** recolor embedded raster images / captured posters — those stay as-is.
123
+ - **`ghostscript` (full document).** Post-processes the finished PDF to DeviceGray,
124
+ converting everything including raster posters and images. Needs the `gs`
125
+ binary; use when you want guaranteed end-to-end grayscale.
126
+
127
+ `color = "color" | "bw"`; `bw_engine = "tokens" | "ghostscript"`.
128
+
129
+ Convenience preset: **`ink_saver = true`** = `bw` + `backgrounds = false` +
130
+ `light_inverse = true`. The handout-friendly default for printing.
131
+
132
+ ## CLI & config
133
+
134
+ ```
135
+ lectern build SOURCE -f pdf [--layout 1up] [--bw] [--no-backgrounds]
136
+ [--light-inverse] [--ink-saver] [--paper a4]
137
+ ```
138
+
139
+ ```toml
140
+ [pdf]
141
+ layout = "2up-notes" # 2up-notes (default) | 1up | 2up | 4up | 6up | 3up-notes
142
+ color = "color" # color | bw
143
+ bw_engine = "tokens" # tokens | ghostscript
144
+ backgrounds = true
145
+ light_inverse = false
146
+ fragments = "flatten" # flatten | steps
147
+ paper = "deck" # deck | letter | a4 | 1280x720
148
+ posters = "auto" # auto | explicit | off
149
+ poster_at = 1200 # ms, default capture moment for auto posters
150
+ tagged = true # emit a tagged (structured) PDF for screen readers;
151
+ # preserved by the 1up master, flattened by N-up imposition
152
+
153
+ # handout chrome (used by 2up/4up/6up/3up-notes)
154
+ frame = true
155
+ slide_numbers = true
156
+ gutter = "10mm"
157
+ header = ""
158
+ footer = "{title} · {date} · {page}/{pages}"
159
+ ```
160
+
161
+ CLI flags override `[pdf]`, which overrides defaults. `--ink-saver` expands to the
162
+ three options above.
163
+
164
+ ## Caching & determinism
165
+
166
+ The 1-up master is content-hashed and cached under the deck's `build_dir`
167
+ (`build/.lectern-cache/`). Re-exporting `2up` then `4up` then `--bw` re-runs only
168
+ imposition / conversion, not the (slow) Chromium render. Poster captures are
169
+ cached per embed + `poster_at`. A given deck + options always produces a
170
+ byte-stable master so diffs are meaningful. Because the cache lives in
171
+ `build_dir`, `lectern clean` (which clears `out_dir`) keeps it; `lectern clean
172
+ --all` drops it.
173
+
174
+ ## Milestone mapping
175
+
176
+ - **M2** ships the 1-up vector master (`-f pdf`) — backgrounds toggle, fragments
177
+ flatten, poster *passthrough* (reduced-motion static frames).
178
+ - A later milestone (**M6, PDF finishing**) adds imposition (N-up / `3up-notes`),
179
+ the B&W engines, `light_inverse`, auto poster capture, and handout chrome.