sphinx-typst-render 0.2.0__py3-none-any.whl
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.
- sphinx_typst_render/__init__.py +92 -0
- sphinx_typst_render/_compile.py +206 -0
- sphinx_typst_render/_directive.py +150 -0
- sphinx_typst_render/_downloads.py +110 -0
- sphinx_typst_render/static/typst-render.css +22 -0
- sphinx_typst_render/static/typst-render.js +49 -0
- sphinx_typst_render-0.2.0.dist-info/METADATA +202 -0
- sphinx_typst_render-0.2.0.dist-info/RECORD +11 -0
- sphinx_typst_render-0.2.0.dist-info/WHEEL +4 -0
- sphinx_typst_render-0.2.0.dist-info/entry_points.txt +3 -0
- sphinx_typst_render-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
})();
|
|
@@ -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,11 @@
|
|
|
1
|
+
sphinx_typst_render/__init__.py,sha256=9SGPeLut3NMcEha6Wi3OcmkXiW21DECxf1WD3olSjvQ,3355
|
|
2
|
+
sphinx_typst_render/_compile.py,sha256=n7ZgVxOzvqvNgoYp-lGgD5CMTqEgCWP7bresX2-GccQ,7291
|
|
3
|
+
sphinx_typst_render/_directive.py,sha256=LRGhH3EVirB5gMQZWGrm4l1yVAcplBoeDgE3x8f04cA,4983
|
|
4
|
+
sphinx_typst_render/_downloads.py,sha256=gOr9hUh0qTCXp42IbA9mc_bLdYJBrJc9ziDA0IJcsgY,3542
|
|
5
|
+
sphinx_typst_render/static/typst-render.css,sha256=3o9cWKk_pEGLJajO72V-lxlYl3iPTxzVIEfTkcTgPPM,823
|
|
6
|
+
sphinx_typst_render/static/typst-render.js,sha256=7geSCr8l8hlHUnmkp_PjmCFCon3Ji4jfk6dbqev5vP0,1826
|
|
7
|
+
sphinx_typst_render-0.2.0.dist-info/licenses/LICENSE,sha256=A_xkn-aGzrYZ3aU9BR4EE05ERy45KL-kKk56LEEZavY,1094
|
|
8
|
+
sphinx_typst_render-0.2.0.dist-info/WHEEL,sha256=_d8F1e7SqtoW6CDj4Gi8lFC26a_7I17R7zPLCKTp4Fg,81
|
|
9
|
+
sphinx_typst_render-0.2.0.dist-info/entry_points.txt,sha256=wm-CUpUtUsxQ8nJ4D2nwDK4B3Fg3M3pAOyynzOcvD3A,66
|
|
10
|
+
sphinx_typst_render-0.2.0.dist-info/METADATA,sha256=-nOcSDlpck-N0tdEqf0imL48R3U_xTLHoLwW0UhAqXg,6749
|
|
11
|
+
sphinx_typst_render-0.2.0.dist-info/RECORD,,
|
|
@@ -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.
|