amethyst-cli 0.1.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.
- amethyst/__init__.py +5 -0
- amethyst/__main__.py +6 -0
- amethyst/cli.py +644 -0
- amethyst/config.py +387 -0
- amethyst/document.py +228 -0
- amethyst/errors.py +66 -0
- amethyst/ooxml.py +549 -0
- amethyst/parse/__init__.py +20 -0
- amethyst/parse/assets.py +106 -0
- amethyst/parse/frontmatter.py +62 -0
- amethyst/parse/markdown.py +49 -0
- amethyst/remote.py +236 -0
- amethyst/render/__init__.py +42 -0
- amethyst/render/base.py +85 -0
- amethyst/render/docx.py +1060 -0
- amethyst/render/furniture.py +112 -0
- amethyst/render/highlight.py +315 -0
- amethyst/render/html.py +266 -0
- amethyst/render/pdf.py +219 -0
- amethyst/theme/__init__.py +493 -0
- amethyst/theme/builtin/academic.toml +45 -0
- amethyst/theme/builtin/css/base.css +361 -0
- amethyst/theme/builtin/default.toml +42 -0
- amethyst/theme/builtin/github.toml +44 -0
- amethyst/theme/to_css.py +182 -0
- amethyst/theme/to_docx.py +651 -0
- amethyst_cli-0.1.0.dist-info/METADATA +293 -0
- amethyst_cli-0.1.0.dist-info/RECORD +31 -0
- amethyst_cli-0.1.0.dist-info/WHEEL +4 -0
- amethyst_cli-0.1.0.dist-info/entry_points.txt +2 -0
- amethyst_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""The theme layer: one declaration of fonts, scale, colour and page geometry.
|
|
2
|
+
|
|
3
|
+
A theme is what keeps the two output formats looking like the same document.
|
|
4
|
+
It is a small TOML file, and it compiles two ways: to the CSS custom properties
|
|
5
|
+
``base.css`` reads, and to the style definitions the Word file carries. Neither
|
|
6
|
+
renderer holds an opinion about a font or a colour.
|
|
7
|
+
|
|
8
|
+
Anything a theme leaves out is filled in from the builtin ``default``, so a
|
|
9
|
+
custom theme can be three lines that change the accent colour and nothing else.
|
|
10
|
+
That also means every ``Theme`` is complete by construction, and a renderer
|
|
11
|
+
never has to ask whether a value is there.
|
|
12
|
+
|
|
13
|
+
Sizes are declared as bare numbers rather than CSS lengths on purpose. The two
|
|
14
|
+
that are absolute — the body and small text sizes — are points, because that is
|
|
15
|
+
what print measures in and what Word wants; everything else is a multiple of
|
|
16
|
+
the body size, so a theme scales as a whole and neither compiler has to parse a
|
|
17
|
+
unit out of a string. Page geometry is the exception, and is CSS: the PDF hands
|
|
18
|
+
it straight to a paged-media descriptor, and the Word side reads what it can of
|
|
19
|
+
it and says so when it cannot.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
from dataclasses import dataclass, replace
|
|
27
|
+
from functools import cache
|
|
28
|
+
from importlib.resources import files
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from amethyst.errors import ThemeError, UsageError
|
|
33
|
+
|
|
34
|
+
if sys.version_info >= (3, 11):
|
|
35
|
+
import tomllib
|
|
36
|
+
else: # pragma: no cover - 3.10 only
|
|
37
|
+
import tomli as tomllib
|
|
38
|
+
|
|
39
|
+
#: Location of the structural stylesheet inside this package. It is package
|
|
40
|
+
#: data, not a file next to the source: an installed wheel has no source tree,
|
|
41
|
+
#: and reading it any other way works in a checkout and fails once shipped.
|
|
42
|
+
BASE_CSS_PARTS = ("builtin", "css", "base.css")
|
|
43
|
+
|
|
44
|
+
#: Where the builtin themes live, inside this package.
|
|
45
|
+
BUILTIN_DIR = "builtin"
|
|
46
|
+
|
|
47
|
+
#: The theme every other one is completed from, and the one used when the user
|
|
48
|
+
#: names none.
|
|
49
|
+
DEFAULT_THEME = "default"
|
|
50
|
+
|
|
51
|
+
#: Heading levels a theme declares a size for: h1 through h6, no more, no less.
|
|
52
|
+
HEADING_LEVELS = 6
|
|
53
|
+
|
|
54
|
+
#: Colours are hex, three or six digits. Nothing else: the CSS side would take
|
|
55
|
+
#: any colour notation going, but a Word style needs three bytes, and a theme
|
|
56
|
+
#: that renders in one format and not the other is the failure this whole layer
|
|
57
|
+
#: exists to prevent.
|
|
58
|
+
HEX_COLOR = re.compile(r"\A#?(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\Z")
|
|
59
|
+
|
|
60
|
+
#: Characters that would end a declaration or open a block, and so turn a bad
|
|
61
|
+
#: value into a broken stylesheet rather than an error message.
|
|
62
|
+
CSS_UNSAFE = re.compile(r"[;{}<>\"'\\]|/\*")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Fonts:
|
|
67
|
+
"""Three font stacks, most-preferred family first."""
|
|
68
|
+
|
|
69
|
+
body: tuple[str, ...]
|
|
70
|
+
heading: tuple[str, ...]
|
|
71
|
+
mono: tuple[str, ...]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Type:
|
|
76
|
+
"""The type scale: two absolute sizes in points, the rest relative."""
|
|
77
|
+
|
|
78
|
+
#: Body text size, in points.
|
|
79
|
+
size: float
|
|
80
|
+
#: Footnotes, table cells and page furniture, in points.
|
|
81
|
+
small: float
|
|
82
|
+
#: Code, as a multiple of the body size. Relative rather than absolute so
|
|
83
|
+
#: that code sits with the prose around it however the document is scaled
|
|
84
|
+
#: — and declared once here because both compilers need it, and a Word
|
|
85
|
+
#: style cannot read it off the stylesheet.
|
|
86
|
+
code: float
|
|
87
|
+
#: The title on a title page, as a multiple of the body size. Its own
|
|
88
|
+
#: setting rather than the h1 size because a cover is not a section
|
|
89
|
+
#: opening: a title set at exactly the size of the heading on the next
|
|
90
|
+
#: page reads as a heading that was given a page to itself.
|
|
91
|
+
title: float
|
|
92
|
+
#: Line height, as a multiple of the font size.
|
|
93
|
+
line_height: float
|
|
94
|
+
#: CSS font weight for headings, 100–900.
|
|
95
|
+
heading_weight: int
|
|
96
|
+
#: h1–h6, as multiples of the body size.
|
|
97
|
+
headings: tuple[float, ...]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class Colors:
|
|
102
|
+
"""The palette, as six-digit hex."""
|
|
103
|
+
|
|
104
|
+
text: str
|
|
105
|
+
muted: str
|
|
106
|
+
accent: str
|
|
107
|
+
rule: str
|
|
108
|
+
fill: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class Spacing:
|
|
113
|
+
"""Vertical and horizontal rhythm, as multiples of the body size."""
|
|
114
|
+
|
|
115
|
+
#: Gap after a paragraph, list, table or code block.
|
|
116
|
+
block: float
|
|
117
|
+
#: How far a list or a definition is indented.
|
|
118
|
+
indent: float
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class Page:
|
|
123
|
+
"""Sheet geometry. Both are CSS, because both are paged-media descriptors."""
|
|
124
|
+
|
|
125
|
+
size: str
|
|
126
|
+
margin: str
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class Theme:
|
|
131
|
+
"""One complete declaration of how a document should look."""
|
|
132
|
+
|
|
133
|
+
name: str
|
|
134
|
+
description: str
|
|
135
|
+
fonts: Fonts
|
|
136
|
+
type: Type
|
|
137
|
+
colors: Colors
|
|
138
|
+
spacing: Spacing
|
|
139
|
+
page: Page
|
|
140
|
+
|
|
141
|
+
def with_page(self, *, size: str | None = None, margin: str | None = None) -> Theme:
|
|
142
|
+
"""Return this theme with the page geometry a flag overrode.
|
|
143
|
+
|
|
144
|
+
``None`` means the flag was not passed, which leaves the theme's own
|
|
145
|
+
value in place. This is the only way page geometry is overridden, so
|
|
146
|
+
there is exactly one place a renderer has to look for it.
|
|
147
|
+
"""
|
|
148
|
+
if size is None and margin is None:
|
|
149
|
+
return self
|
|
150
|
+
page = Page(size=size or self.page.size, margin=margin or self.page.margin)
|
|
151
|
+
return replace(self, page=page)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@cache
|
|
155
|
+
def base_css() -> str:
|
|
156
|
+
"""Return the structural stylesheet every PDF is built on."""
|
|
157
|
+
resource = files(__name__)
|
|
158
|
+
for part in BASE_CSS_PARTS:
|
|
159
|
+
resource = resource / part
|
|
160
|
+
return resource.read_text(encoding="utf-8")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@cache
|
|
164
|
+
def builtin_names() -> tuple[str, ...]:
|
|
165
|
+
"""The names of the themes shipped inside the package, sorted."""
|
|
166
|
+
directory = files(__name__) / BUILTIN_DIR
|
|
167
|
+
return tuple(
|
|
168
|
+
sorted(
|
|
169
|
+
entry.name.removesuffix(".toml")
|
|
170
|
+
for entry in directory.iterdir()
|
|
171
|
+
if entry.name.endswith(".toml")
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def locate_theme(source: str) -> str:
|
|
177
|
+
"""Check that a theme is there, returning it as given.
|
|
178
|
+
|
|
179
|
+
Only existence is settled here. A name that is not a builtin, or a path
|
|
180
|
+
with no file at it, is a mistyped invocation and raises ``UsageError``; a
|
|
181
|
+
theme that is found but will not parse raises ``ThemeError`` when it is
|
|
182
|
+
read. The two are different mistakes and get different exit codes.
|
|
183
|
+
"""
|
|
184
|
+
if is_theme_path(source):
|
|
185
|
+
if not Path(source).is_file():
|
|
186
|
+
raise UsageError(f"No theme file at {source}.")
|
|
187
|
+
return source
|
|
188
|
+
names = builtin_names()
|
|
189
|
+
if source not in names:
|
|
190
|
+
raise UsageError(
|
|
191
|
+
f"Unknown theme {source!r}.",
|
|
192
|
+
hint=f"Builtin themes: {', '.join(names)}.",
|
|
193
|
+
)
|
|
194
|
+
return source
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def read_theme_text(source: str) -> str:
|
|
198
|
+
"""Return a theme's TOML as written, for showing or copying."""
|
|
199
|
+
locate_theme(source)
|
|
200
|
+
if is_theme_path(source):
|
|
201
|
+
return _read_file(Path(source))
|
|
202
|
+
return _read_builtin(source)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def load_theme(source: str) -> Theme:
|
|
206
|
+
"""Load a theme by builtin name or path, filling in what it leaves out."""
|
|
207
|
+
text = read_theme_text(source)
|
|
208
|
+
data = _parse(text, source)
|
|
209
|
+
_reject_unknown(data, source)
|
|
210
|
+
merged = _merge(_default_sections(), data)
|
|
211
|
+
return _build(
|
|
212
|
+
merged,
|
|
213
|
+
name=Path(source).stem,
|
|
214
|
+
description=_description(data, source),
|
|
215
|
+
source=source,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@cache
|
|
220
|
+
def default_theme() -> Theme:
|
|
221
|
+
"""The theme a document gets when the user names none."""
|
|
222
|
+
return load_theme(DEFAULT_THEME)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# --- locating and reading -------------------------------------------------
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def is_theme_path(source: str) -> bool:
|
|
229
|
+
"""Whether a theme was named as a file rather than as a builtin.
|
|
230
|
+
|
|
231
|
+
A bare word is a builtin name; anything carrying a directory or a ``.toml``
|
|
232
|
+
extension is a path. Written out so the check that a theme exists and the
|
|
233
|
+
read that follows it can never disagree about which one they are doing —
|
|
234
|
+
and public, because the config layer has to resolve a declared theme path
|
|
235
|
+
against the file that declared it, and must agree about which ones those
|
|
236
|
+
are.
|
|
237
|
+
"""
|
|
238
|
+
candidate = Path(source)
|
|
239
|
+
return candidate.suffix.lower() == ".toml" or candidate.parent != Path(".")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _read_file(path: Path) -> str:
|
|
243
|
+
"""Read a theme file, reporting a failure as one line rather than a trace."""
|
|
244
|
+
try:
|
|
245
|
+
return path.read_text(encoding="utf-8")
|
|
246
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
247
|
+
detail = getattr(exc, "strerror", None) or "it is not valid UTF-8 text"
|
|
248
|
+
raise ThemeError(
|
|
249
|
+
f"Could not read the theme at {path}: {detail.lower()}."
|
|
250
|
+
) from exc
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _read_builtin(name: str) -> str:
|
|
254
|
+
"""Read a builtin theme out of the package's data."""
|
|
255
|
+
resource = files(__name__) / BUILTIN_DIR / f"{name}.toml"
|
|
256
|
+
return resource.read_text(encoding="utf-8")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _parse(text: str, source: str) -> dict[str, Any]:
|
|
260
|
+
"""Parse a theme's TOML, naming the line when it will not parse."""
|
|
261
|
+
try:
|
|
262
|
+
return tomllib.loads(text)
|
|
263
|
+
except tomllib.TOMLDecodeError as exc:
|
|
264
|
+
raise ThemeError(f"{source} is not valid TOML: {exc}.") from exc
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@cache
|
|
268
|
+
def _default_sections() -> dict[str, dict[str, Any]]:
|
|
269
|
+
"""The default theme's settings — which are also the schema.
|
|
270
|
+
|
|
271
|
+
Every setting a theme may declare is one this file declares, so validating
|
|
272
|
+
a theme against it needs no second statement of what the settings are, and
|
|
273
|
+
adding one to the default file is the whole of adding one to the format.
|
|
274
|
+
|
|
275
|
+
``description`` is left out: it is the only top-level key, and it is the
|
|
276
|
+
one thing a theme does not inherit. A custom theme that says nothing about
|
|
277
|
+
itself should say nothing, not describe the default.
|
|
278
|
+
"""
|
|
279
|
+
data = _parse(_read_builtin(DEFAULT_THEME), DEFAULT_THEME)
|
|
280
|
+
return {key: value for key, value in data.items() if isinstance(value, dict)}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# --- validation -----------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _reject_unknown(data: dict[str, Any], source: str) -> None:
|
|
287
|
+
"""Refuse a setting that does not exist, rather than silently ignoring it.
|
|
288
|
+
|
|
289
|
+
A theme is edited by hand and looked at afterwards. A misspelled key that
|
|
290
|
+
is quietly dropped looks exactly like a theme that does not work, which is
|
|
291
|
+
a much worse afternoon than being told which line is wrong.
|
|
292
|
+
"""
|
|
293
|
+
defaults = _default_sections()
|
|
294
|
+
sections = ", ".join(defaults)
|
|
295
|
+
for key, value in data.items():
|
|
296
|
+
if key == "description":
|
|
297
|
+
continue
|
|
298
|
+
if key not in defaults:
|
|
299
|
+
raise ThemeError(
|
|
300
|
+
f"{source}: unknown section [{key}].",
|
|
301
|
+
hint=f"A theme declares: {sections}.",
|
|
302
|
+
)
|
|
303
|
+
if not isinstance(value, dict):
|
|
304
|
+
raise ThemeError(f"{source}: [{key}] must be a table of settings.")
|
|
305
|
+
for name in value:
|
|
306
|
+
if name not in defaults[key]:
|
|
307
|
+
known = ", ".join(defaults[key])
|
|
308
|
+
raise ThemeError(
|
|
309
|
+
f"{source}: unknown setting {name!r} in [{key}].",
|
|
310
|
+
hint=f"[{key}] takes: {known}.",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _merge(
|
|
315
|
+
defaults: dict[str, dict[str, Any]], data: dict[str, Any]
|
|
316
|
+
) -> dict[str, dict[str, Any]]:
|
|
317
|
+
"""Lay a theme over the defaults, one section at a time."""
|
|
318
|
+
return {
|
|
319
|
+
section: {**values, **data.get(section, {})}
|
|
320
|
+
for section, values in defaults.items()
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _description(data: dict[str, Any], source: str) -> str:
|
|
325
|
+
"""A theme's own one-line description, which is never inherited."""
|
|
326
|
+
value = data.get("description", "")
|
|
327
|
+
if not isinstance(value, str):
|
|
328
|
+
raise ThemeError(f"{source}: description must be text.")
|
|
329
|
+
return value.strip()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _build(
|
|
333
|
+
data: dict[str, dict[str, Any]], *, name: str, description: str, source: str
|
|
334
|
+
) -> Theme:
|
|
335
|
+
"""Turn merged data into a validated theme."""
|
|
336
|
+
return Theme(
|
|
337
|
+
name=name,
|
|
338
|
+
description=description,
|
|
339
|
+
fonts=Fonts(
|
|
340
|
+
body=_families(data, "fonts", "body", source),
|
|
341
|
+
heading=_families(data, "fonts", "heading", source),
|
|
342
|
+
mono=_families(data, "fonts", "mono", source),
|
|
343
|
+
),
|
|
344
|
+
type=Type(
|
|
345
|
+
size=_positive(data, "type", "size", source),
|
|
346
|
+
small=_positive(data, "type", "small", source),
|
|
347
|
+
code=_positive(data, "type", "code", source),
|
|
348
|
+
title=_positive(data, "type", "title", source),
|
|
349
|
+
line_height=_positive(data, "type", "line_height", source),
|
|
350
|
+
heading_weight=_weight(data, "type", "heading_weight", source),
|
|
351
|
+
headings=_scale(data, "type", "headings", source),
|
|
352
|
+
),
|
|
353
|
+
colors=Colors(
|
|
354
|
+
text=_color(data, "colors", "text", source),
|
|
355
|
+
muted=_color(data, "colors", "muted", source),
|
|
356
|
+
accent=_color(data, "colors", "accent", source),
|
|
357
|
+
rule=_color(data, "colors", "rule", source),
|
|
358
|
+
fill=_color(data, "colors", "fill", source),
|
|
359
|
+
),
|
|
360
|
+
spacing=Spacing(
|
|
361
|
+
block=_positive(data, "spacing", "block", source),
|
|
362
|
+
indent=_positive(data, "spacing", "indent", source),
|
|
363
|
+
),
|
|
364
|
+
page=Page(
|
|
365
|
+
size=_css(data, "page", "size", source),
|
|
366
|
+
margin=_css(data, "page", "margin", source),
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _value(data: dict[str, dict[str, Any]], section: str, key: str, source: str) -> Any:
|
|
372
|
+
"""One setting, after the merge — so an absence is a broken default file."""
|
|
373
|
+
try:
|
|
374
|
+
return data[section][key]
|
|
375
|
+
except KeyError:
|
|
376
|
+
raise ThemeError(f"{source}: [{section}] has no {key!r}.") from None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _invalid(section: str, key: str, source: str, must: str, value: Any) -> ThemeError:
|
|
380
|
+
"""The one shape every validation failure is reported in."""
|
|
381
|
+
return ThemeError(f"{source}: {section}.{key} must be {must}, not {value!r}.")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _number(value: Any) -> float | None:
|
|
385
|
+
"""A TOML number as a float, or ``None``. Booleans are not numbers here."""
|
|
386
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
387
|
+
return None
|
|
388
|
+
return float(value)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _positive(
|
|
392
|
+
data: dict[str, dict[str, Any]], section: str, key: str, source: str
|
|
393
|
+
) -> float:
|
|
394
|
+
"""A number greater than zero: a size, a multiplier, a line height."""
|
|
395
|
+
value = _value(data, section, key, source)
|
|
396
|
+
number = _number(value)
|
|
397
|
+
if number is None or number <= 0:
|
|
398
|
+
raise _invalid(section, key, source, "a positive number", value)
|
|
399
|
+
return number
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _weight(
|
|
403
|
+
data: dict[str, dict[str, Any]], section: str, key: str, source: str
|
|
404
|
+
) -> int:
|
|
405
|
+
"""A CSS font weight. Word wants one of these too, so the range is checked."""
|
|
406
|
+
value = _value(data, section, key, source)
|
|
407
|
+
if isinstance(value, bool) or not isinstance(value, int) or not 100 <= value <= 900:
|
|
408
|
+
raise _invalid(section, key, source, "a font weight from 100 to 900", value)
|
|
409
|
+
return value
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _scale(
|
|
413
|
+
data: dict[str, dict[str, Any]], section: str, key: str, source: str
|
|
414
|
+
) -> tuple[float, ...]:
|
|
415
|
+
"""The heading scale: one multiplier per level, h1 first, none of them optional.
|
|
416
|
+
|
|
417
|
+
All six are required rather than defaulted, because a scale with a gap in
|
|
418
|
+
it is a theme that renders one heading level at body size and looks broken.
|
|
419
|
+
"""
|
|
420
|
+
value = _value(data, section, key, source)
|
|
421
|
+
must = f"a list of {HEADING_LEVELS} positive numbers, h1 first"
|
|
422
|
+
if not isinstance(value, list) or len(value) != HEADING_LEVELS:
|
|
423
|
+
raise _invalid(section, key, source, must, value)
|
|
424
|
+
sizes = [_number(item) for item in value]
|
|
425
|
+
if any(size is None or size <= 0 for size in sizes):
|
|
426
|
+
raise _invalid(section, key, source, must, value)
|
|
427
|
+
return tuple(size for size in sizes if size is not None)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _color(data: dict[str, dict[str, Any]], section: str, key: str, source: str) -> str:
|
|
431
|
+
"""A hex colour, normalised to six lowercase digits.
|
|
432
|
+
|
|
433
|
+
Hex rather than any CSS notation, and normalised rather than passed
|
|
434
|
+
through, because the DOCX side needs a ``RGBColor`` — which takes six
|
|
435
|
+
digits and nothing else. Restricting it here means neither compiler has to
|
|
436
|
+
parse a colour.
|
|
437
|
+
"""
|
|
438
|
+
value = _value(data, section, key, source)
|
|
439
|
+
if not isinstance(value, str) or not HEX_COLOR.match(value):
|
|
440
|
+
raise _invalid(section, key, source, 'a hex colour like "#6a3fa0"', value)
|
|
441
|
+
digits = value.lstrip("#").lower()
|
|
442
|
+
if len(digits) == 3:
|
|
443
|
+
digits = "".join(digit * 2 for digit in digits)
|
|
444
|
+
return f"#{digits}"
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _families(
|
|
448
|
+
data: dict[str, dict[str, Any]], section: str, key: str, source: str
|
|
449
|
+
) -> tuple[str, ...]:
|
|
450
|
+
"""A font stack, most-preferred first, with nothing CSS-unsafe in a name.
|
|
451
|
+
|
|
452
|
+
The names are written into a stylesheet, so a family carrying a quote or a
|
|
453
|
+
semicolon could end the declaration and change what follows it.
|
|
454
|
+
"""
|
|
455
|
+
value = _value(data, section, key, source)
|
|
456
|
+
must = "a list of font family names"
|
|
457
|
+
if not isinstance(value, list) or not value:
|
|
458
|
+
raise _invalid(section, key, source, must, value)
|
|
459
|
+
for family in value:
|
|
460
|
+
if not isinstance(family, str) or not family.strip():
|
|
461
|
+
raise _invalid(section, key, source, must, value)
|
|
462
|
+
if CSS_UNSAFE.search(family):
|
|
463
|
+
raise _invalid(section, key, source, "a plain font family name", family)
|
|
464
|
+
return tuple(family.strip() for family in value)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _css(data: dict[str, dict[str, Any]], section: str, key: str, source: str) -> str:
|
|
468
|
+
"""A value written straight into the stylesheet, so checked before it is."""
|
|
469
|
+
value = _value(data, section, key, source)
|
|
470
|
+
if not isinstance(value, str) or not value.strip():
|
|
471
|
+
raise _invalid(section, key, source, "a CSS value", value)
|
|
472
|
+
if CSS_UNSAFE.search(value):
|
|
473
|
+
raise _invalid(section, key, source, "a single CSS value", value)
|
|
474
|
+
return value.strip()
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
__all__ = [
|
|
478
|
+
"DEFAULT_THEME",
|
|
479
|
+
"HEADING_LEVELS",
|
|
480
|
+
"Colors",
|
|
481
|
+
"Fonts",
|
|
482
|
+
"Page",
|
|
483
|
+
"Spacing",
|
|
484
|
+
"Theme",
|
|
485
|
+
"Type",
|
|
486
|
+
"base_css",
|
|
487
|
+
"builtin_names",
|
|
488
|
+
"default_theme",
|
|
489
|
+
"is_theme_path",
|
|
490
|
+
"load_theme",
|
|
491
|
+
"locate_theme",
|
|
492
|
+
"read_theme_text",
|
|
493
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# A paper, a thesis chapter, a report meant to be read on paper.
|
|
2
|
+
#
|
|
3
|
+
# The two things that make a page look academic rather than merely printed are
|
|
4
|
+
# a narrow measure and a quiet palette. Both are here: the margins are wide
|
|
5
|
+
# enough to bring the line length down to something readable, and nothing on
|
|
6
|
+
# the page is coloured except a link.
|
|
7
|
+
|
|
8
|
+
description = "A quiet book serif, a narrow measure and generous margins."
|
|
9
|
+
|
|
10
|
+
[fonts]
|
|
11
|
+
# Charter is on macOS and in most Linux font packages; Cambria covers Windows
|
|
12
|
+
# and Office. A heading in the same face as the text is the traditional choice
|
|
13
|
+
# and the one that looks least like a slide.
|
|
14
|
+
body = ["Charter", "Bitstream Charter", "Cambria", "Times New Roman", "serif"]
|
|
15
|
+
heading = ["Charter", "Bitstream Charter", "Cambria", "Times New Roman", "serif"]
|
|
16
|
+
mono = ["ui-monospace", "SF Mono", "Menlo", "DejaVu Sans Mono", "monospace"]
|
|
17
|
+
|
|
18
|
+
[type]
|
|
19
|
+
size = 11
|
|
20
|
+
small = 9
|
|
21
|
+
code = 0.82
|
|
22
|
+
title = 2.2
|
|
23
|
+
# Roomier than the default. A long document is read for an hour at a time.
|
|
24
|
+
line_height = 1.55
|
|
25
|
+
heading_weight = 700
|
|
26
|
+
# Restrained: a section heading announces a section, it does not open a
|
|
27
|
+
# chapter of its own.
|
|
28
|
+
headings = [1.7, 1.35, 1.15, 1, 1, 1]
|
|
29
|
+
|
|
30
|
+
[colors]
|
|
31
|
+
text = "#111111"
|
|
32
|
+
muted = "#555555"
|
|
33
|
+
accent = "#20558a"
|
|
34
|
+
rule = "#cccccc"
|
|
35
|
+
fill = "#f4f4f4"
|
|
36
|
+
|
|
37
|
+
[spacing]
|
|
38
|
+
block = 0.8
|
|
39
|
+
indent = 1.5
|
|
40
|
+
|
|
41
|
+
[page]
|
|
42
|
+
size = "A4"
|
|
43
|
+
# Wide enough to bring an A4 line down to about 15cm, which is roughly the
|
|
44
|
+
# 70 characters a page is comfortable to read at this size.
|
|
45
|
+
margin = "2.5cm 3cm"
|