sphinx-typst-render 0.2.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NB-TUDelft and K.Zabłocki (Zamkorus)
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,202 @@
1
+ Metadata-Version: 2.4
2
+ Name: sphinx-typst-render
3
+ Version: 0.2.0
4
+ Summary: Render Typst sources into downloadable PDFs and inline previews in Sphinx and Jupyter Book.
5
+ Keywords: jupyter-book,sphinx,typst,pdf,forms,documentation
6
+ Author: NB-TUDelft, K.Zabłocki (Zamkorus)
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: Sphinx
11
+ Classifier: Framework :: Sphinx :: Extension
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Education
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Documentation :: Sphinx
21
+ Classifier: Topic :: Software Development :: Documentation
22
+ Requires-Dist: sphinx>=7
23
+ Requires-Dist: typst>=0.15
24
+ Requires-Dist: typst-fillable>=0.0.1
25
+ Requires-Python: >=3.10
26
+ Project-URL: Homepage, https://github.com/NB-TUDelft/sphinx-typst-render
27
+ Project-URL: Repository, https://github.com/NB-TUDelft/sphinx-typst-render
28
+ Project-URL: Issues, https://github.com/NB-TUDelft/sphinx-typst-render/issues
29
+ Project-URL: Changelog, https://github.com/NB-TUDelft/sphinx-typst-render/releases
30
+ Description-Content-Type: text/markdown
31
+
32
+ # sphinx-typst-render
33
+
34
+ Compile [Typst](https://typst.app) sources during a Sphinx or Jupyter Book
35
+ build. Each source becomes a PDF students can download, plus an optional inline
36
+ preview image that follows the page theme.
37
+
38
+ Typst ships inside the [`typst`](https://pypi.org/project/typst/) wheel as a
39
+ statically linked extension module, so there is no `typst` CLI to install and no
40
+ Rust toolchain on the build machine.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install sphinx-typst-render
46
+ ```
47
+
48
+ ## Enable
49
+
50
+ In a Jupyter Book `_config.yml`:
51
+
52
+ ```yaml
53
+ sphinx:
54
+ extra_extensions:
55
+ - sphinx_typst_render
56
+ ```
57
+
58
+ In a plain Sphinx `conf.py`:
59
+
60
+ ```python
61
+ extensions = ["sphinx_typst_render"]
62
+ ```
63
+
64
+ ## Use
65
+
66
+ ````markdown
67
+ ```{typst} worksheets/week3.typ
68
+ :label: Week 3 worksheet
69
+ ```
70
+ ````
71
+
72
+ The block renders **nothing into the page**. It compiles `worksheets/week3.typ`
73
+ and adds the resulting PDF to the theme's download menu, beside the page's own
74
+ `.ipynb` and `.pdf` entries, under a heading you configure.
75
+
76
+ Pass `:inline:` to also show a preview image and a download link in the body.
77
+
78
+ ### Options
79
+
80
+ | Option | Default | Meaning |
81
+ | --- | --- | --- |
82
+ | `:preview:` | `none`, or `svg` with `:inline:` | `svg`, `png`, or `none`. |
83
+ | `:page:` | `1` | Which page to preview in a multi page document. |
84
+ | `:height:` | unset | Height of the preview image, e.g. `420px`. |
85
+ | `:alt:` | generated | Alt text for the preview image. |
86
+ | `:label:` | generated | Text of the download link. |
87
+ | `:ppi:` | `144` | Resolution of a `png` preview. |
88
+ | `:class:` | none | Extra CSS classes on the wrapper. |
89
+ | `:inline:` | off | Also show a preview and link in the page body. |
90
+ | `:fillable:` | off | Add interactive form fields. See below. |
91
+
92
+ ### Configuration
93
+
94
+ | Value | Default | Meaning |
95
+ | --- | --- | --- |
96
+ | `typst_render_preview` | `"svg"` | Default for `:preview:`. |
97
+ | `typst_render_ppi` | `144.0` | Default for `:ppi:`. |
98
+ | `typst_render_stage_field_library` | `True` | Stage `capture_field.typ` into the source root. |
99
+ | `typst_render_source_label` | `"Source"` | Heading above the page's own downloads. |
100
+ | `typst_render_downloads_label` | `"Worksheets"` | Heading above the rendered PDFs. |
101
+
102
+ ## The download menu
103
+
104
+ Rendered PDFs are added to the download dropdown that sphinx-book-theme puts in
105
+ the article header, so students find them where they already look for the page
106
+ source. Two headings separate the groups, and both are configurable because
107
+ what the page *is* differs per project:
108
+
109
+ ```yaml
110
+ sphinx:
111
+ config:
112
+ typst_render_source_label: Manual # or Chapter, Page, Book
113
+ typst_render_downloads_label: Worksheets
114
+ ```
115
+
116
+ That renders as:
117
+
118
+ ```
119
+ Manual
120
+ .ipynb
121
+ .pdf
122
+ Worksheets
123
+ Organizer 1.1 (fillable)
124
+ ```
125
+
126
+ Set either to an empty string to leave that group unlabelled.
127
+
128
+ The headings are inserted by a small stylesheet and script shipped with this
129
+ package. They are not built server side because the theme's button macro
130
+ dispatches on a fixed set of item types and forces its own CSS class, so a
131
+ non-link entry cannot be expressed through the `header_buttons` context.
132
+
133
+ Without a theme that provides such a menu, the PDFs are still written and
134
+ copied, and a warning names the page whose downloads are unreachable. Use
135
+ `:inline:` in that case.
136
+
137
+ ## Why SVG previews
138
+
139
+ With `:inline:`, an SVG preview is line art, so a theme that inverts diagrams for dark mode
140
+ handles it correctly, and it stays sharp at any zoom. Use `:preview: png` for a
141
+ worksheet that contains photographs, and pair it with whatever class your theme
142
+ uses to opt out of inversion:
143
+
144
+ ````markdown
145
+ ```{typst} worksheets/week5.typ
146
+ :preview: png
147
+ :class: no-invert
148
+ ```
149
+ ````
150
+
151
+ ## Fillable PDFs
152
+
153
+ Typst cannot emit interactive form fields on its own. Support is requested in
154
+ [typst/typst#1765](https://github.com/typst/typst/issues/1765) and is still
155
+ open. This package therefore delegates to
156
+ [typst-fillable](https://github.com/carpe-diem/typst-fillable), which reads
157
+ field geometry back out of the compiled document with `typst.query()`, draws a
158
+ transparent AcroForm overlay with ReportLab, and merges it with pypdf.
159
+
160
+ Mark fields in the Typst source with the helper, then add `:fillable:`:
161
+
162
+ ```typ
163
+ #import "/_typst_lib/capture_field.typ": text_field, checkbox_field, textarea_field
164
+
165
+ Measured $U_(i n)$: #text_field("u_in", width: 80pt)
166
+
167
+ Did the output clip? #checkbox_field("clipped")
168
+
169
+ Explain the discrepancy:
170
+ #textarea_field("discussion", height: 60pt)
171
+ ```
172
+
173
+ `capture_field.typ` is staged into `_typst_lib/` at the root of your Sphinx
174
+ source directory on every build, so the import path above is stable wherever the
175
+ worksheet lives. Set `typst_render_stage_field_library = False` to manage it
176
+ yourself.
177
+
178
+ Available helpers are `text_field`, `textarea_field`, `checkbox_field`, and
179
+ `radio_field`, plus the lower level `capture_field`.
180
+
181
+ ## Caching
182
+
183
+ Output is written beside the source and skipped when nothing relevant changed.
184
+ The cache key covers the source, every other `.typ` file in the same folder, the
185
+ staged helper, and the render options.
186
+
187
+ Imports that reach outside the source folder are not tracked. Keep shared
188
+ partials next to the worksheets that use them, or touch the importing file to
189
+ force a rebuild.
190
+
191
+ Add the generated artefacts to `.gitignore`:
192
+
193
+ ```gitignore
194
+ *.typst-stamp
195
+ ```
196
+
197
+ Commit the PDFs if you want the book to build without recompiling, or ignore
198
+ them too and let CI regenerate them.
199
+
200
+ ## Licence
201
+
202
+ MIT
@@ -0,0 +1,171 @@
1
+ # sphinx-typst-render
2
+
3
+ Compile [Typst](https://typst.app) sources during a Sphinx or Jupyter Book
4
+ build. Each source becomes a PDF students can download, plus an optional inline
5
+ preview image that follows the page theme.
6
+
7
+ Typst ships inside the [`typst`](https://pypi.org/project/typst/) wheel as a
8
+ statically linked extension module, so there is no `typst` CLI to install and no
9
+ Rust toolchain on the build machine.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install sphinx-typst-render
15
+ ```
16
+
17
+ ## Enable
18
+
19
+ In a Jupyter Book `_config.yml`:
20
+
21
+ ```yaml
22
+ sphinx:
23
+ extra_extensions:
24
+ - sphinx_typst_render
25
+ ```
26
+
27
+ In a plain Sphinx `conf.py`:
28
+
29
+ ```python
30
+ extensions = ["sphinx_typst_render"]
31
+ ```
32
+
33
+ ## Use
34
+
35
+ ````markdown
36
+ ```{typst} worksheets/week3.typ
37
+ :label: Week 3 worksheet
38
+ ```
39
+ ````
40
+
41
+ The block renders **nothing into the page**. It compiles `worksheets/week3.typ`
42
+ and adds the resulting PDF to the theme's download menu, beside the page's own
43
+ `.ipynb` and `.pdf` entries, under a heading you configure.
44
+
45
+ Pass `:inline:` to also show a preview image and a download link in the body.
46
+
47
+ ### Options
48
+
49
+ | Option | Default | Meaning |
50
+ | --- | --- | --- |
51
+ | `:preview:` | `none`, or `svg` with `:inline:` | `svg`, `png`, or `none`. |
52
+ | `:page:` | `1` | Which page to preview in a multi page document. |
53
+ | `:height:` | unset | Height of the preview image, e.g. `420px`. |
54
+ | `:alt:` | generated | Alt text for the preview image. |
55
+ | `:label:` | generated | Text of the download link. |
56
+ | `:ppi:` | `144` | Resolution of a `png` preview. |
57
+ | `:class:` | none | Extra CSS classes on the wrapper. |
58
+ | `:inline:` | off | Also show a preview and link in the page body. |
59
+ | `:fillable:` | off | Add interactive form fields. See below. |
60
+
61
+ ### Configuration
62
+
63
+ | Value | Default | Meaning |
64
+ | --- | --- | --- |
65
+ | `typst_render_preview` | `"svg"` | Default for `:preview:`. |
66
+ | `typst_render_ppi` | `144.0` | Default for `:ppi:`. |
67
+ | `typst_render_stage_field_library` | `True` | Stage `capture_field.typ` into the source root. |
68
+ | `typst_render_source_label` | `"Source"` | Heading above the page's own downloads. |
69
+ | `typst_render_downloads_label` | `"Worksheets"` | Heading above the rendered PDFs. |
70
+
71
+ ## The download menu
72
+
73
+ Rendered PDFs are added to the download dropdown that sphinx-book-theme puts in
74
+ the article header, so students find them where they already look for the page
75
+ source. Two headings separate the groups, and both are configurable because
76
+ what the page *is* differs per project:
77
+
78
+ ```yaml
79
+ sphinx:
80
+ config:
81
+ typst_render_source_label: Manual # or Chapter, Page, Book
82
+ typst_render_downloads_label: Worksheets
83
+ ```
84
+
85
+ That renders as:
86
+
87
+ ```
88
+ Manual
89
+ .ipynb
90
+ .pdf
91
+ Worksheets
92
+ Organizer 1.1 (fillable)
93
+ ```
94
+
95
+ Set either to an empty string to leave that group unlabelled.
96
+
97
+ The headings are inserted by a small stylesheet and script shipped with this
98
+ package. They are not built server side because the theme's button macro
99
+ dispatches on a fixed set of item types and forces its own CSS class, so a
100
+ non-link entry cannot be expressed through the `header_buttons` context.
101
+
102
+ Without a theme that provides such a menu, the PDFs are still written and
103
+ copied, and a warning names the page whose downloads are unreachable. Use
104
+ `:inline:` in that case.
105
+
106
+ ## Why SVG previews
107
+
108
+ With `:inline:`, an SVG preview is line art, so a theme that inverts diagrams for dark mode
109
+ handles it correctly, and it stays sharp at any zoom. Use `:preview: png` for a
110
+ worksheet that contains photographs, and pair it with whatever class your theme
111
+ uses to opt out of inversion:
112
+
113
+ ````markdown
114
+ ```{typst} worksheets/week5.typ
115
+ :preview: png
116
+ :class: no-invert
117
+ ```
118
+ ````
119
+
120
+ ## Fillable PDFs
121
+
122
+ Typst cannot emit interactive form fields on its own. Support is requested in
123
+ [typst/typst#1765](https://github.com/typst/typst/issues/1765) and is still
124
+ open. This package therefore delegates to
125
+ [typst-fillable](https://github.com/carpe-diem/typst-fillable), which reads
126
+ field geometry back out of the compiled document with `typst.query()`, draws a
127
+ transparent AcroForm overlay with ReportLab, and merges it with pypdf.
128
+
129
+ Mark fields in the Typst source with the helper, then add `:fillable:`:
130
+
131
+ ```typ
132
+ #import "/_typst_lib/capture_field.typ": text_field, checkbox_field, textarea_field
133
+
134
+ Measured $U_(i n)$: #text_field("u_in", width: 80pt)
135
+
136
+ Did the output clip? #checkbox_field("clipped")
137
+
138
+ Explain the discrepancy:
139
+ #textarea_field("discussion", height: 60pt)
140
+ ```
141
+
142
+ `capture_field.typ` is staged into `_typst_lib/` at the root of your Sphinx
143
+ source directory on every build, so the import path above is stable wherever the
144
+ worksheet lives. Set `typst_render_stage_field_library = False` to manage it
145
+ yourself.
146
+
147
+ Available helpers are `text_field`, `textarea_field`, `checkbox_field`, and
148
+ `radio_field`, plus the lower level `capture_field`.
149
+
150
+ ## Caching
151
+
152
+ Output is written beside the source and skipped when nothing relevant changed.
153
+ The cache key covers the source, every other `.typ` file in the same folder, the
154
+ staged helper, and the render options.
155
+
156
+ Imports that reach outside the source folder are not tracked. Keep shared
157
+ partials next to the worksheets that use them, or touch the importing file to
158
+ force a rebuild.
159
+
160
+ Add the generated artefacts to `.gitignore`:
161
+
162
+ ```gitignore
163
+ *.typst-stamp
164
+ ```
165
+
166
+ Commit the PDFs if you want the book to build without recompiling, or ignore
167
+ them too and let CI regenerate them.
168
+
169
+ ## Licence
170
+
171
+ MIT
@@ -0,0 +1,88 @@
1
+ [project]
2
+ name = "sphinx-typst-render"
3
+ version = "0.2.0"
4
+ description = "Render Typst sources into downloadable PDFs and inline previews in Sphinx and Jupyter Book."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ keywords = [
10
+ "jupyter-book",
11
+ "sphinx",
12
+ "typst",
13
+ "pdf",
14
+ "forms",
15
+ "documentation",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Framework :: Sphinx",
20
+ "Framework :: Sphinx :: Extension",
21
+ "Intended Audience :: Developers",
22
+ "Intended Audience :: Education",
23
+ "Operating System :: OS Independent",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Topic :: Documentation :: Sphinx",
30
+ "Topic :: Software Development :: Documentation",
31
+ ]
32
+ dependencies = [
33
+ "sphinx>=7",
34
+ "typst>=0.15",
35
+ "typst-fillable>=0.0.1",
36
+ ]
37
+
38
+ [[project.authors]]
39
+ name = "NB-TUDelft"
40
+
41
+ [[project.authors]]
42
+ name = "K.Zabłocki (Zamkorus)"
43
+
44
+ [project.urls]
45
+ Homepage = "https://github.com/NB-TUDelft/sphinx-typst-render"
46
+ Repository = "https://github.com/NB-TUDelft/sphinx-typst-render"
47
+ Issues = "https://github.com/NB-TUDelft/sphinx-typst-render/issues"
48
+ Changelog = "https://github.com/NB-TUDelft/sphinx-typst-render/releases"
49
+
50
+ [project.scripts]
51
+ sphinx-typst-render = "sphinx_typst_render:main"
52
+
53
+ [dependency-groups]
54
+ dev = [
55
+ "pytest>=8",
56
+ "twine>=6.1",
57
+ ]
58
+
59
+ [tool.setuptools.packages.find]
60
+ where = ["src"]
61
+
62
+ [tool.setuptools.package-data]
63
+ sphinx_typst_render = [
64
+ "static/*.js",
65
+ "static/*.css",
66
+ ]
67
+
68
+ [tool.pytest.ini_options]
69
+ testpaths = ["tests"]
70
+ addopts = "-ra"
71
+
72
+ [tool.conda-recipe]
73
+ noarch = true
74
+ build-channels = ["conda-forge"]
75
+ maintainers = [
76
+ "NB-TUDelft",
77
+ "Zamkorus",
78
+ ]
79
+ test-imports = ["sphinx_typst_render"]
80
+ extra-run = []
81
+
82
+ [tool.conda-recipe.name-map]
83
+
84
+ [tool.conda-recipe.requirement-overrides]
85
+
86
+ [build-system]
87
+ requires = ["uv_build>=0.12.14,<0.13.0"]
88
+ build-backend = "uv_build"
@@ -0,0 +1,89 @@
1
+ [project]
2
+ name = "sphinx-typst-render"
3
+ version = "0.2.0"
4
+ description = "Render Typst sources into downloadable PDFs and inline previews in Sphinx and Jupyter Book."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [
10
+ { name = "NB-TUDelft" },
11
+ { name = "K.Zabłocki (Zamkorus)" },
12
+ ]
13
+ keywords = ["jupyter-book", "sphinx", "typst", "pdf", "forms", "documentation"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Framework :: Sphinx",
17
+ "Framework :: Sphinx :: Extension",
18
+ "Intended Audience :: Developers",
19
+ "Intended Audience :: Education",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Documentation :: Sphinx",
27
+ "Topic :: Software Development :: Documentation",
28
+ ]
29
+ dependencies = [
30
+ "sphinx>=7",
31
+ "typst>=0.15",
32
+ "typst-fillable>=0.0.1",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/NB-TUDelft/sphinx-typst-render"
37
+ Repository = "https://github.com/NB-TUDelft/sphinx-typst-render"
38
+ Issues = "https://github.com/NB-TUDelft/sphinx-typst-render/issues"
39
+ Changelog = "https://github.com/NB-TUDelft/sphinx-typst-render/releases"
40
+
41
+ [dependency-groups]
42
+ dev = [
43
+ "pytest>=8",
44
+ "twine>=6.1",
45
+ ]
46
+
47
+ [tool.setuptools.packages.find]
48
+ where = ["src"]
49
+
50
+ [tool.setuptools.package-data]
51
+ sphinx_typst_render = ["static/*.js", "static/*.css"]
52
+
53
+ [tool.pytest.ini_options]
54
+ testpaths = ["tests"]
55
+ addopts = "-ra"
56
+
57
+
58
+ [project.scripts]
59
+ sphinx-typst-render = "sphinx_typst_render:main"
60
+
61
+ [build-system]
62
+ requires = ["uv_build>=0.12.14,<0.13.0"]
63
+ build-backend = "uv_build"
64
+
65
+ # Consumed by NB-TUDelft/actions/python-publish, which renders the conda
66
+ # recipe from the metadata above so the conda package can never drift from the
67
+ # PyPI one. Only what conda needs and pyproject cannot express lives here.
68
+ #
69
+ # Conda publishing is currently disabled in .github/workflows/publish.yml.
70
+ # Two dependencies block it: typst (the Python binding) and typst-fillable are
71
+ # both PyPI only. conda-forge ships a `typst` package, but that is the Rust CLI
72
+ # and does not provide the importable module, so mapping to it would be wrong.
73
+ [tool.conda-recipe]
74
+ # The package itself is pure Python, so a single noarch build serves every
75
+ # architecture and every Python >=3.10.
76
+ noarch = true
77
+ build-channels = ["conda-forge"]
78
+ maintainers = ["NB-TUDelft", "Zamkorus"]
79
+ test-imports = [
80
+ "sphinx_typst_render",
81
+ ]
82
+ # PyPI project name -> conda-forge package name, where the two differ.
83
+ name-map = {}
84
+ # Escape hatch keyed by normalized PyPI name: replaces the auto-translated
85
+ # spec outright. Use when a requirement carries an environment marker or
86
+ # extras, which conda's dependency syntax cannot express.
87
+ requirement-overrides = {}
88
+ # Run-time requirements that apply only to the conda package.
89
+ extra-run = []
@@ -0,0 +1,92 @@
1
+ """Render Typst sources into Sphinx and Jupyter Book output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from ._compile import RenderRequest, RenderResult, render, stage_field_library
9
+ from ._directive import TypstDirective
10
+ from ._downloads import (
11
+ add_download_buttons,
12
+ copy_downloads,
13
+ merge_info,
14
+ purge_doc,
15
+ )
16
+
17
+ __all__ = [
18
+ "RenderRequest",
19
+ "RenderResult",
20
+ "TypstDirective",
21
+ "render",
22
+ "setup",
23
+ "stage_field_library",
24
+ ]
25
+ __version__ = "0.2.0"
26
+
27
+ STATIC_DIR = Path(__file__).parent / "static"
28
+
29
+ #: Written into the output at build time, so the labels reach the browser as a
30
+ #: file rather than an inline script. An inline script added through
31
+ #: add_js_file(None, body=...) is emitted twice by sphinx-book-theme, which
32
+ #: re-processes the asset list in its own html-page-context handler.
33
+ LABELS_FILENAME = "typst-render-labels.js"
34
+
35
+
36
+ def _install_assets(app) -> None:
37
+ """Register the dropdown header assets.
38
+
39
+ The labels file is generated here rather than at build-finished because
40
+ sphinx-book-theme fingerprints each asset by reading it while pages are
41
+ written. A file that does not exist yet at that point is dropped from the
42
+ page instead of being linked.
43
+ """
44
+ if getattr(app.builder, "format", None) != "html":
45
+ return
46
+
47
+ generated = Path(app.doctreedir).parent / "typst-render-static"
48
+ generated.mkdir(parents=True, exist_ok=True)
49
+ labels = {
50
+ "source": app.config.typst_render_source_label,
51
+ "downloads": app.config.typst_render_downloads_label,
52
+ }
53
+ (generated / LABELS_FILENAME).write_text(
54
+ f"window.typstRenderLabels = {json.dumps(labels)};\n"
55
+ )
56
+
57
+ app.config.html_static_path.append(str(STATIC_DIR))
58
+ app.config.html_static_path.append(str(generated))
59
+ # Labels first: typst-render.js reads the global this one defines.
60
+ app.add_js_file(LABELS_FILENAME)
61
+ app.add_js_file("typst-render.js")
62
+ app.add_css_file("typst-render.css")
63
+
64
+
65
+ def setup(app):
66
+ app.add_config_value("typst_render_preview", "svg", "env")
67
+ app.add_config_value("typst_render_ppi", 144.0, "env")
68
+ app.add_config_value("typst_render_stage_field_library", True, "env")
69
+ # Headings inserted into the theme's download menu. The first names what
70
+ # the page itself is, which differs per project: a manual, a chapter, a
71
+ # page. An empty string leaves that group unlabelled.
72
+ # Offer the .typ source next to each rendered PDF.
73
+ app.add_config_value("typst_render_link_source", False, "env")
74
+ app.add_config_value("typst_render_source_label", "Source", "html")
75
+ app.add_config_value("typst_render_downloads_label", "Worksheets", "html")
76
+
77
+ app.add_directive("typst", TypstDirective)
78
+
79
+ app.connect("builder-inited", _install_assets)
80
+ app.connect("env-purge-doc", purge_doc)
81
+ app.connect("env-merge-info", merge_info)
82
+ # After sphinx-book-theme builds the download group at priority 501.
83
+ app.connect("html-page-context", add_download_buttons, priority=600)
84
+ app.connect("build-finished", copy_downloads)
85
+
86
+ return {
87
+ "version": __version__,
88
+ # Rendering writes files during the read phase, so parallel readers
89
+ # could race on the same output. Correctness over throughput here.
90
+ "parallel_read_safe": False,
91
+ "parallel_write_safe": True,
92
+ }
@@ -0,0 +1,206 @@
1
+ """Compile Typst sources to PDF and to inline preview images."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import typst
11
+
12
+ #: Directory, relative to the Typst root, where the field helper is staged.
13
+ LIB_DIRNAME = "_typst_lib"
14
+
15
+ #: Directory, beside each source, holding generated output.
16
+ OUT_DIRNAME = "_typst"
17
+ LIB_FILENAME = "capture_field.typ"
18
+
19
+ #: Bumped whenever the output layout changes, to invalidate stale caches.
20
+ _CACHE_VERSION = 1
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class RenderRequest:
25
+ """Everything that determines the bytes of a render."""
26
+
27
+ source: Path
28
+ root: Path
29
+ preview: str = "svg"
30
+ fillable: bool = False
31
+ ppi: float | None = None
32
+ preview_page: int = 1
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class RenderResult:
37
+ """Paths written by :func:`render`."""
38
+
39
+ pdf: Path
40
+ preview: Path | None
41
+
42
+
43
+ def stage_field_library(root: Path) -> Path | None:
44
+ """Copy typst-fillable's ``capture_field.typ`` into the Typst root.
45
+
46
+ Authors then import it by a stable root absolute path, which does not
47
+ change when a worksheet moves between week folders::
48
+
49
+ #import "/_typst_lib/capture_field.typ": text_field, checkbox_field
50
+
51
+ Returns the staged path, or ``None`` when typst-fillable is not installed.
52
+ """
53
+ try:
54
+ from importlib.resources import files
55
+
56
+ data = (files("typst_fillable") / "typst" / LIB_FILENAME).read_bytes()
57
+ except (ImportError, FileNotFoundError, ModuleNotFoundError):
58
+ return None
59
+
60
+ target = root / LIB_DIRNAME / LIB_FILENAME
61
+ target.parent.mkdir(parents=True, exist_ok=True)
62
+ # Only write when the content differs, so the mtime stays stable and the
63
+ # fingerprint below does not churn on every build.
64
+ if not target.is_file() or target.read_bytes() != data:
65
+ target.write_bytes(data)
66
+ return target
67
+
68
+
69
+ def _options(request: RenderRequest) -> dict[str, object]:
70
+ try:
71
+ source_key = request.source.resolve().relative_to(request.root.resolve()).as_posix()
72
+ except ValueError:
73
+ source_key = request.source.name
74
+ return {
75
+ "version": _CACHE_VERSION,
76
+ # Output lives outside the source tree, so the key must identify the
77
+ # source. Two worksheets may share a stem in different week folders.
78
+ "source": source_key,
79
+ "preview": request.preview,
80
+ "fillable": request.fillable,
81
+ "ppi": request.ppi,
82
+ "page": request.preview_page,
83
+ }
84
+
85
+
86
+ def _variant(request: RenderRequest) -> str:
87
+ """Short digest of the render options.
88
+
89
+ Two directives may point at one source with different options. Keying the
90
+ output directory on the options keeps them from overwriting each other,
91
+ while the file inside keeps the source stem so the download is named
92
+ ``week3.pdf`` rather than something hashed.
93
+ """
94
+ payload = json.dumps(_options(request), sort_keys=True).encode()
95
+ return hashlib.sha256(payload).hexdigest()[:8]
96
+
97
+
98
+ def _fingerprint(request: RenderRequest) -> str:
99
+ """Hash everything that can change the output.
100
+
101
+ Covers the source, every other ``.typ`` file beside it (worksheets that
102
+ import shared partials keep them in the same folder), the staged helper,
103
+ and the render options. Imports reaching outside the source folder are not
104
+ tracked; touch the importing file to force a rebuild.
105
+ """
106
+ digest = hashlib.sha256()
107
+ digest.update(json.dumps(_options(request), sort_keys=True).encode())
108
+ for path in sorted(request.source.parent.glob("*.typ")):
109
+ digest.update(path.name.encode())
110
+ digest.update(path.read_bytes())
111
+
112
+ library = request.root / LIB_DIRNAME / LIB_FILENAME
113
+ if library.is_file():
114
+ digest.update(library.read_bytes())
115
+ return digest.hexdigest()
116
+
117
+
118
+ def _add_form_fields(base: bytes, request: RenderRequest) -> bytes:
119
+ """Overlay interactive AcroForm fields onto an already compiled PDF.
120
+
121
+ Only the field extraction and the ReportLab overlay come from
122
+ typst-fillable. Its ``make_fillable()`` copies the whole Typst root into a
123
+ temp dir and then expects the template at the top of it, which breaks for
124
+ worksheets in subfolders, and its ``merge_with_overlay()`` merges page
125
+ content without carrying the widget annotations, so the result has an
126
+ /AcroForm whose /Fields point at nothing and no reader shows a field.
127
+
128
+ Cloning the overlay instead keeps the form dictionary and its widgets
129
+ intact in one document, and the Typst content is stamped underneath.
130
+ """
131
+ from io import BytesIO
132
+
133
+ from pypdf import PdfReader, PdfWriter
134
+ from typst_fillable import create_form_overlay, extract_field_metadata
135
+
136
+ fields = extract_field_metadata(request.source, root=request.root)
137
+ if not fields:
138
+ return base
139
+
140
+ reader = PdfReader(BytesIO(base))
141
+ first = reader.pages[0]
142
+ overlay = create_form_overlay(
143
+ fields=fields,
144
+ page_count=len(reader.pages),
145
+ page_size=(float(first.mediabox.width), float(first.mediabox.height)),
146
+ )
147
+
148
+ writer = PdfWriter(clone_from=overlay)
149
+ for index, page in enumerate(reader.pages):
150
+ if index < len(writer.pages):
151
+ writer.pages[index].merge_page(page, over=False)
152
+ # Ask the reader to build field appearances, so a blank field is visible.
153
+ writer.set_need_appearances_writer(True)
154
+
155
+ output = BytesIO()
156
+ writer.write(output)
157
+ return output.getvalue()
158
+
159
+
160
+ def _compile_pdf(request: RenderRequest) -> bytes:
161
+ base = typst.compile(str(request.source), root=str(request.root), format="pdf")
162
+ if not request.fillable:
163
+ return base
164
+ return _add_form_fields(base, request)
165
+
166
+
167
+ def _compile_preview(request: RenderRequest) -> bytes:
168
+ options: dict[str, object] = {"root": str(request.root), "format": request.preview}
169
+ if request.preview == "png":
170
+ options["ppi"] = request.ppi or 144.0
171
+
172
+ rendered = typst.compile(str(request.source), **options)
173
+ # A multi page document comes back as one bytes object per page.
174
+ if isinstance(rendered, list):
175
+ index = max(1, request.preview_page) - 1
176
+ if index >= len(rendered):
177
+ raise IndexError(
178
+ f"{request.source.name} has {len(rendered)} pages, "
179
+ f"cannot preview page {request.preview_page}"
180
+ )
181
+ return rendered[index]
182
+ return rendered
183
+
184
+
185
+ def render(request: RenderRequest, basedir: Path) -> RenderResult:
186
+ """Compile ``request`` under ``basedir``, reusing cached output when valid.
187
+
188
+ Output lands in ``<basedir>/_typst/<options digest>/``.
189
+ """
190
+ outdir = basedir / OUT_DIRNAME / _variant(request)
191
+ outdir.mkdir(parents=True, exist_ok=True)
192
+ stem = request.source.stem
193
+ pdf = outdir / f"{stem}.pdf"
194
+ preview = None if request.preview == "none" else outdir / f"{stem}.{request.preview}"
195
+ stamp = outdir / f"{stem}.typst-stamp"
196
+
197
+ expected = _fingerprint(request)
198
+ if stamp.is_file() and stamp.read_text().strip() == expected:
199
+ if pdf.is_file() and (preview is None or preview.is_file()):
200
+ return RenderResult(pdf, preview)
201
+
202
+ pdf.write_bytes(_compile_pdf(request))
203
+ if preview is not None:
204
+ preview.write_bytes(_compile_preview(request))
205
+ stamp.write_text(expected)
206
+ return RenderResult(pdf, preview)
@@ -0,0 +1,150 @@
1
+ """The ``typst`` directive."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from docutils import nodes
8
+ from docutils.parsers.rst import directives
9
+ from sphinx import addnodes
10
+ from sphinx.util.docutils import SphinxDirective
11
+
12
+ from ._compile import RenderRequest, render, stage_field_library
13
+ from ._downloads import register
14
+
15
+
16
+ def _preview(argument: str) -> str:
17
+ return directives.choice(argument, ("svg", "png", "none"))
18
+
19
+
20
+ def _yes_no(argument: str) -> str:
21
+ return directives.choice(argument, ("yes", "no"))
22
+
23
+
24
+ class TypstDirective(SphinxDirective):
25
+ """Compile a Typst source and offer it as a download.
26
+
27
+ The block renders nothing into the page. The PDF is added to the theme's
28
+ download menu, next to the page's own source files. Pass ``:inline:`` to
29
+ also show a preview and a link in the body.
30
+
31
+ ::
32
+
33
+ ```{typst} worksheets/week3.typ
34
+ :label: Week 3 worksheet
35
+ :fillable:
36
+ ```
37
+ """
38
+
39
+ required_arguments = 1
40
+ optional_arguments = 0
41
+ final_argument_whitespace = True
42
+ has_content = False
43
+ option_spec = {
44
+ "preview": _preview,
45
+ "fillable": directives.flag,
46
+ "inline": directives.flag,
47
+ "source": _yes_no,
48
+ "label": directives.unchanged,
49
+ "alt": directives.unchanged,
50
+ "height": directives.length_or_unitless,
51
+ "ppi": directives.positive_int,
52
+ "page": directives.positive_int,
53
+ "class": directives.class_option,
54
+ }
55
+
56
+ def run(self) -> list[nodes.Node]:
57
+ config = self.env.config
58
+ relative, absolute = self.env.relfn2path(self.arguments[0].strip())
59
+ source = Path(absolute)
60
+ if not source.is_file():
61
+ raise self.error(f"Typst source not found: {relative}")
62
+
63
+ # Rebuild the page whenever the source changes.
64
+ self.env.note_dependency(relative)
65
+
66
+ root = Path(self.env.srcdir)
67
+ if config.typst_render_stage_field_library:
68
+ stage_field_library(root)
69
+
70
+ inline = "inline" in self.options
71
+ # Without an inline block there is nothing to show a preview in, so do
72
+ # not spend a second Typst compile on one.
73
+ default_preview = config.typst_render_preview if inline else "none"
74
+
75
+ request = RenderRequest(
76
+ source=source,
77
+ root=root,
78
+ preview=self.options.get("preview", default_preview),
79
+ fillable="fillable" in self.options,
80
+ ppi=self.options.get("ppi", config.typst_render_ppi),
81
+ preview_page=self.options.get("page", 1),
82
+ )
83
+
84
+ # An inline preview has to be an image Sphinx can see, which means it
85
+ # must live under the source directory. With no inline block there is
86
+ # nothing to keep there, so the output goes to a build local cache and
87
+ # the author's folders stay clean.
88
+ basedir = (
89
+ source.parent
90
+ if inline
91
+ else Path(self.env.doctreedir).parent / "typst-build"
92
+ )
93
+
94
+ try:
95
+ result = render(request, basedir)
96
+ except Exception as error:
97
+ # A clean build is a hard requirement, so surface this as an error
98
+ # rather than a warning that scrolls past unnoticed.
99
+ raise self.error(f"Typst render failed for {relative}: {error}") from error
100
+
101
+ label = self.options.get("label") or result.pdf.stem
102
+ digest = result.pdf.parent.name
103
+ register(
104
+ self.env,
105
+ self.env.docname,
106
+ digest=digest,
107
+ path=result.pdf,
108
+ label=label,
109
+ )
110
+
111
+ want_source = self.options.get(
112
+ "source", "yes" if config.typst_render_link_source else "no"
113
+ )
114
+ if want_source == "yes":
115
+ register(
116
+ self.env,
117
+ self.env.docname,
118
+ digest=digest,
119
+ path=source,
120
+ label=f"{label} (source)",
121
+ icon="fas fa-file-code",
122
+ )
123
+
124
+ if not inline:
125
+ return []
126
+
127
+ container = nodes.container(
128
+ classes=["typst-render", *self.options.get("class", [])]
129
+ )
130
+
131
+ if result.preview is not None:
132
+ uri = "/" + result.preview.relative_to(root).as_posix()
133
+ image = nodes.image(uri=uri)
134
+ image["candidates"] = {"*": uri}
135
+ image["alt"] = self.options.get("alt", f"Preview of {source.name}")
136
+ if "height" in self.options:
137
+ image["height"] = self.options["height"]
138
+ container += image
139
+
140
+ reference = addnodes.download_reference(
141
+ "",
142
+ "",
143
+ reftarget="/" + result.pdf.relative_to(root).as_posix(),
144
+ refdoc=self.env.docname,
145
+ refexplicit=True,
146
+ refwarn=False,
147
+ )
148
+ reference += nodes.literal(label, label, classes=["xref", "download"])
149
+ container += nodes.paragraph("", "", reference, classes=["typst-download"])
150
+ return [container]
@@ -0,0 +1,110 @@
1
+ """Put rendered PDFs into the theme's download menu instead of the page body."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ from sphinx.util import logging
9
+
10
+ LOGGER = logging.getLogger(__name__)
11
+
12
+ #: Attribute on the build environment holding {docname: [entry, ...]}.
13
+ ENV_KEY = "typst_render_downloads"
14
+
15
+ #: Output subdirectory, below the HTML output root.
16
+ URI_PREFIX = "_downloads/typst"
17
+
18
+ #: Button label, which the theme turns into a "btn-<label>" class. The
19
+ #: companion JavaScript uses that class to find our entries in the menu.
20
+ BUTTON_LABEL = "typst-download"
21
+
22
+ #: Label of the download group that sphinx-book-theme builds.
23
+ THEME_GROUP_LABEL = "download-buttons"
24
+
25
+
26
+ def _store(env) -> dict[str, list[dict]]:
27
+ if not hasattr(env, ENV_KEY):
28
+ setattr(env, ENV_KEY, {})
29
+ return getattr(env, ENV_KEY)
30
+
31
+
32
+ def register(
33
+ env, docname: str, *, digest: str, path: Path, label: str, icon: str = "fas fa-file-pdf"
34
+ ) -> str:
35
+ """Record one download for ``docname`` and return its output relative URI."""
36
+ uri = f"{URI_PREFIX}/{digest}/{path.name}"
37
+ entries = _store(env).setdefault(docname, [])
38
+ # A page may hold several worksheets, but re-reading it must not duplicate.
39
+ for entry in entries:
40
+ if entry["uri"] == uri:
41
+ entry["label"] = label
42
+ return uri
43
+ entries.append({"uri": uri, "path": str(path), "label": label, "icon": icon})
44
+ return uri
45
+
46
+
47
+ def purge_doc(app, env, docname: str) -> None:
48
+ _store(env).pop(docname, None)
49
+
50
+
51
+ def merge_info(app, env, docnames, other) -> None:
52
+ _store(env).update(_store(other))
53
+
54
+
55
+ def add_download_buttons(app, pagename, templatename, context, doctree) -> None:
56
+ """Append this page's worksheets to the theme's download dropdown.
57
+
58
+ Runs after sphinx-book-theme has built ``header_buttons`` (priority 501),
59
+ so the group already exists and we only extend it.
60
+ """
61
+ entries = _store(app.env).get(pagename)
62
+ if not entries:
63
+ return
64
+
65
+ group = next(
66
+ (
67
+ item
68
+ for item in context.get("header_buttons", [])
69
+ if item.get("type") == "group" and item.get("label") == THEME_GROUP_LABEL
70
+ ),
71
+ None,
72
+ )
73
+ if group is None:
74
+ LOGGER.warning(
75
+ "sphinx-typst-render: no download menu on %s, so %d worksheet(s) are "
76
+ "not reachable. The theme must provide one, as sphinx-book-theme does "
77
+ "with use_download_button enabled.",
78
+ pagename,
79
+ len(entries),
80
+ type="typst_render",
81
+ )
82
+ return
83
+
84
+ pathto = context["pathto"]
85
+ for entry in entries:
86
+ group["buttons"].append(
87
+ {
88
+ "type": "link",
89
+ "url": pathto(entry["uri"], 1),
90
+ "text": entry["label"],
91
+ "icon": entry.get("icon", "fas fa-file-pdf"),
92
+ "tooltip": entry["label"],
93
+ "label": BUTTON_LABEL,
94
+ }
95
+ )
96
+
97
+
98
+ def copy_downloads(app, exception) -> None:
99
+ """Copy the rendered PDFs into the output tree."""
100
+ if exception is not None or getattr(app.builder, "format", None) != "html":
101
+ return
102
+ outdir = Path(app.builder.outdir)
103
+ for entries in _store(app.env).values():
104
+ for entry in entries:
105
+ source = Path(entry["path"])
106
+ if not source.is_file():
107
+ continue
108
+ destination = outdir / entry["uri"]
109
+ destination.parent.mkdir(parents=True, exist_ok=True)
110
+ shutil.copyfile(source, destination)
@@ -0,0 +1,22 @@
1
+ /* Section headings inside the theme's download dropdown.
2
+ The bundled Bootstrap subset does not style .dropdown-header, so do it here.
3
+ Colours come from pydata-sphinx-theme variables, which follow the light and
4
+ dark theme toggle, with plain fallbacks for other themes. */
5
+ .dropdown-download-buttons .dropdown-menu .dropdown-header {
6
+ display: block;
7
+ margin: 0;
8
+ padding: 0.35rem 1rem 0.2rem;
9
+ font-size: 0.72rem;
10
+ font-weight: 600;
11
+ letter-spacing: 0.04em;
12
+ text-transform: uppercase;
13
+ white-space: nowrap;
14
+ color: var(--pst-color-text-muted, #6c757d);
15
+ }
16
+
17
+ /* Separate the second and later groups from the one above. */
18
+ .dropdown-download-buttons .dropdown-menu .dropdown-header:not(:first-child) {
19
+ margin-top: 0.3rem;
20
+ padding-top: 0.45rem;
21
+ border-top: 1px solid var(--pst-color-border, #d1d5da);
22
+ }
@@ -0,0 +1,49 @@
1
+ // Insert section headers into the theme's download dropdown, so the page's own
2
+ // source files and the worksheets rendered by sphinx-typst-render read as two
3
+ // labelled groups rather than one undifferentiated list.
4
+ //
5
+ // The headers are added here rather than server side because the theme macro
6
+ // dispatches on a fixed set of button types and forces its own item class, so
7
+ // a non-link entry cannot be expressed through the header_buttons context.
8
+ (function () {
9
+ "use strict";
10
+
11
+ var OURS = ".btn-typst-download";
12
+ var MARK = "data-typst-headed";
13
+
14
+ function addHeader(menu, before, text) {
15
+ if (!text || !before) return;
16
+ var item = document.createElement("li");
17
+ var heading = document.createElement("h6");
18
+ heading.className = "dropdown-header";
19
+ heading.textContent = text;
20
+ item.appendChild(heading);
21
+ menu.insertBefore(item, before);
22
+ }
23
+
24
+ function decorate() {
25
+ var labels = window.typstRenderLabels || {};
26
+ var menus = document.querySelectorAll(".dropdown-download-buttons ul.dropdown-menu");
27
+
28
+ Array.prototype.forEach.call(menus, function (menu) {
29
+ if (menu.hasAttribute(MARK)) return;
30
+
31
+ var items = Array.prototype.slice.call(menu.children);
32
+ var ours = items.filter(function (li) { return li.querySelector(OURS); });
33
+ // Nothing of ours on this page means nothing to separate.
34
+ if (!ours.length) return;
35
+
36
+ var theirs = items.filter(function (li) { return !li.querySelector(OURS); });
37
+ menu.setAttribute(MARK, "");
38
+ // Insert the lower header first so the reference node stays valid.
39
+ addHeader(menu, ours[0], labels.downloads);
40
+ addHeader(menu, theirs[0], labels.source);
41
+ });
42
+ }
43
+
44
+ if (document.readyState === "loading") {
45
+ document.addEventListener("DOMContentLoaded", decorate);
46
+ } else {
47
+ decorate();
48
+ }
49
+ })();