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.
@@ -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"