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,651 @@
|
|
|
1
|
+
"""A theme, compiled to Word styles — the other half of ``to_css``.
|
|
2
|
+
|
|
3
|
+
The same declaration of fonts, sizes, colour and page geometry comes out here
|
|
4
|
+
as a set of style definitions on a ``python-docx`` document, so that the Word
|
|
5
|
+
file and the PDF are the same document rather than two documents that happen
|
|
6
|
+
to share a source. Nothing in the Word renderer names a font or a colour, for
|
|
7
|
+
the same reason nothing in the PDF renderer does.
|
|
8
|
+
|
|
9
|
+
Three things are worth knowing before changing anything here.
|
|
10
|
+
|
|
11
|
+
Word's built-in styles are not neutral. ``Heading 1`` arrives bound to the
|
|
12
|
+
document theme's major font and to ``accent1``, and those bindings are written
|
|
13
|
+
as *separate attributes* that win over the plain ones — so setting a font name
|
|
14
|
+
is not enough, the theme binding has to be taken off as well.
|
|
15
|
+
|
|
16
|
+
Only three levels of list style exist (``List Bullet`` … ``List Bullet 3``),
|
|
17
|
+
so deeper nesting has to be clamped rather than continued.
|
|
18
|
+
|
|
19
|
+
And this module reaches into :mod:`amethyst.ooxml` for shading and
|
|
20
|
+
borders, which is the one place the theme layer looks at the render layer.
|
|
21
|
+
Word styles are XML, so a compiler that targets them needs the same handful of
|
|
22
|
+
elements the renderer does, and the alternative — a second copy of ``w:shd``
|
|
23
|
+
— is worse than the import.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
from collections.abc import Callable, Sequence
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from docx.enum.style import WD_STYLE_TYPE
|
|
33
|
+
from docx.enum.text import WD_TAB_ALIGNMENT, WD_TAB_LEADER
|
|
34
|
+
from docx.oxml.ns import qn
|
|
35
|
+
from docx.shared import Emu, Length, Pt, RGBColor
|
|
36
|
+
|
|
37
|
+
from amethyst.ooxml import clear_properties_child, set_borders, shade
|
|
38
|
+
from amethyst.theme import Theme
|
|
39
|
+
|
|
40
|
+
#: Paragraph and character styles Amethyst defines because Word has nothing
|
|
41
|
+
#: close enough. Prefixed so they are recognisable in Word's style pane and
|
|
42
|
+
#: cannot collide with a built-in.
|
|
43
|
+
CODE_STYLE = "Amethyst Code"
|
|
44
|
+
CODE_INLINE_STYLE = "Amethyst Code Inline"
|
|
45
|
+
TABLE_TEXT_STYLE = "Amethyst Table Text"
|
|
46
|
+
FOOTNOTE_STYLE = "Amethyst Footnote"
|
|
47
|
+
COVER_DATE_STYLE = "Amethyst Cover Date"
|
|
48
|
+
|
|
49
|
+
#: Built-in styles the theme restyles rather than replaces. A Word user who
|
|
50
|
+
#: knows these names should find them doing what the names say.
|
|
51
|
+
BODY_STYLE = "Normal"
|
|
52
|
+
QUOTE_STYLE = "Quote"
|
|
53
|
+
FOOTER_STYLE = "Footer"
|
|
54
|
+
HEADER_STYLE = "Header"
|
|
55
|
+
LINK_STYLE = "Hyperlink"
|
|
56
|
+
TABLE_STYLE = "Table Grid"
|
|
57
|
+
TITLE_STYLE = "Title"
|
|
58
|
+
SUBTITLE_STYLE = "Subtitle"
|
|
59
|
+
TOC_HEADING_STYLE = "TOC Heading"
|
|
60
|
+
|
|
61
|
+
HEADING_STYLES = tuple(f"Heading {level}" for level in range(1, 7))
|
|
62
|
+
|
|
63
|
+
#: Contents entries, one style per heading level. Word writes these itself
|
|
64
|
+
#: when it refreshes the field, so they exist to be found rather than to be
|
|
65
|
+
#: chosen: a refreshed contents comes back styled by the theme and not by
|
|
66
|
+
#: Word's factory defaults.
|
|
67
|
+
TOC_STYLES = tuple(f"TOC {level}" for level in range(1, 7))
|
|
68
|
+
|
|
69
|
+
#: List styles, outermost level first. Word defines three and no more, so the
|
|
70
|
+
#: fourth level of nesting and everything under it reuses the third.
|
|
71
|
+
BULLET_STYLES = ("List Bullet", "List Bullet 2", "List Bullet 3")
|
|
72
|
+
NUMBER_STYLES = ("List Number", "List Number 2", "List Number 3")
|
|
73
|
+
|
|
74
|
+
#: How far each level of the built-in list styles is indented. Written down
|
|
75
|
+
#: because block content inside a list item — a code block under a bullet —
|
|
76
|
+
#: has to line up with the text of the item it belongs to, and no style says
|
|
77
|
+
#: so on its behalf.
|
|
78
|
+
LIST_INDENT_STEP = Pt(18)
|
|
79
|
+
|
|
80
|
+
#: Generic CSS families, which name a category rather than a font. Word wants
|
|
81
|
+
#: an installed font, so these are skipped when the stack is reduced to the one
|
|
82
|
+
#: name a Word style can hold.
|
|
83
|
+
GENERIC_FAMILIES = {
|
|
84
|
+
"serif": "Times New Roman",
|
|
85
|
+
"sans-serif": "Arial",
|
|
86
|
+
"monospace": "Courier New",
|
|
87
|
+
"cursive": "Segoe Script",
|
|
88
|
+
"fantasy": "Impact",
|
|
89
|
+
"system-ui": "Calibri",
|
|
90
|
+
"ui-serif": "Times New Roman",
|
|
91
|
+
"ui-sans-serif": "Arial",
|
|
92
|
+
"ui-monospace": "Courier New",
|
|
93
|
+
"ui-rounded": "Arial",
|
|
94
|
+
"math": "Cambria Math",
|
|
95
|
+
"emoji": "Segoe UI Emoji",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#: Named sheet sizes in millimetres, portrait, as CSS paged media spells them.
|
|
99
|
+
NAMED_PAGE_SIZES = {
|
|
100
|
+
"a3": (297.0, 420.0),
|
|
101
|
+
"a4": (210.0, 297.0),
|
|
102
|
+
"a5": (148.0, 210.0),
|
|
103
|
+
"b4": (250.0, 353.0),
|
|
104
|
+
"b5": (176.0, 250.0),
|
|
105
|
+
"letter": (215.9, 279.4),
|
|
106
|
+
"legal": (215.9, 355.6),
|
|
107
|
+
"ledger": (279.4, 431.8),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#: How many of each unit make an inch, which is the unit Word measures in.
|
|
111
|
+
UNITS_PER_INCH = {
|
|
112
|
+
"in": 1.0,
|
|
113
|
+
"cm": 2.54,
|
|
114
|
+
"mm": 25.4,
|
|
115
|
+
"q": 101.6,
|
|
116
|
+
"pt": 72.0,
|
|
117
|
+
"pc": 6.0,
|
|
118
|
+
"px": 96.0,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
EMU_PER_INCH = 914400
|
|
122
|
+
|
|
123
|
+
LENGTH = re.compile(r"\A([+-]?(?:\d+\.?\d*|\.\d+))([a-z]*)\Z")
|
|
124
|
+
|
|
125
|
+
#: The four sides of the box a code block sits in, and the padding inside it
|
|
126
|
+
#: in points. Named here rather than written into the style because the Word
|
|
127
|
+
#: renderer redraws the same box when a dark highlighting style replaces the
|
|
128
|
+
#: theme's fill, and a box that came back a different size would be visible.
|
|
129
|
+
CODE_BOX_SIDES = ("top", "left", "bottom", "right")
|
|
130
|
+
CODE_BOX_PADDING = 6
|
|
131
|
+
|
|
132
|
+
#: What Word calls a font weight: there is only bold, so a theme's numeric
|
|
133
|
+
#: weight is a threshold rather than a value.
|
|
134
|
+
BOLD_AT = 600
|
|
135
|
+
|
|
136
|
+
#: Heading line height, and the gap above and below one, as multiples of the
|
|
137
|
+
#: body size. These mirror the structural stylesheet, which sets the same
|
|
138
|
+
#: relationships in CSS and is where they are explained.
|
|
139
|
+
HEADING_LEADING = 1.2
|
|
140
|
+
CODE_LEADING = 1.4
|
|
141
|
+
HEADING_SPACE_BEFORE = 1.4
|
|
142
|
+
HEADING_SPACE_AFTER = 0.5
|
|
143
|
+
|
|
144
|
+
#: The title page and the contents, in the same multiples of the body size the
|
|
145
|
+
#: stylesheet uses for them. Word cannot read a stylesheet and CSS cannot read
|
|
146
|
+
#: a Word style, so these are two copies of one decision — which is why they
|
|
147
|
+
#: are written here, beside the rest of that decision, rather than inline.
|
|
148
|
+
TITLE_LEADING = 1.15
|
|
149
|
+
TITLE_SPACE_AFTER = 0.3
|
|
150
|
+
SUBTITLE_LEADING = 1.3
|
|
151
|
+
SUBTITLE_SPACE_AFTER = 3.0
|
|
152
|
+
COVER_SPACE_AFTER = 0.15
|
|
153
|
+
#: How far down the sheet the title starts: ``.title-page { padding-top }``.
|
|
154
|
+
TITLE_PAGE_OFFSET = 8.0
|
|
155
|
+
#: One level of contents indent, and the gap under an entry.
|
|
156
|
+
TOC_INDENT = 1.2
|
|
157
|
+
TOC_SPACE_AFTER = 0.3
|
|
158
|
+
#: The air above a top-level contents entry, which groups the ones under it.
|
|
159
|
+
TOC_SPACE_BEFORE = 0.7
|
|
160
|
+
|
|
161
|
+
Warn = Callable[[str], None]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _discard(message: str) -> None:
|
|
165
|
+
"""The warning sink for callers with nowhere to put a warning."""
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def apply_theme(document: Any, theme: Theme, *, warn: Warn = _discard) -> None:
|
|
169
|
+
"""Restyle a fresh Word document to match a theme, page geometry included.
|
|
170
|
+
|
|
171
|
+
Called once, on an empty document, before anything is written into it —
|
|
172
|
+
everything after this reads a style by name and never sets a font.
|
|
173
|
+
"""
|
|
174
|
+
_body(document, theme)
|
|
175
|
+
_headings(document, theme)
|
|
176
|
+
_lists(document, theme)
|
|
177
|
+
_quote(document, theme)
|
|
178
|
+
_code(document, theme)
|
|
179
|
+
_tables(document, theme)
|
|
180
|
+
_footnotes(document, theme)
|
|
181
|
+
_links(document, theme)
|
|
182
|
+
_running(document, theme)
|
|
183
|
+
apply_page(document.sections[0], theme, warn=warn)
|
|
184
|
+
# After the page, because a contents entry needs a tab stop at the right
|
|
185
|
+
# edge of the text column and there is no column until the sheet is set.
|
|
186
|
+
_cover(document, theme)
|
|
187
|
+
_contents(document, theme)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# --- styles ---------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _body(document: Any, theme: Theme) -> None:
|
|
194
|
+
"""``Normal``, which every other style inherits from — so it goes first."""
|
|
195
|
+
style = document.styles[BODY_STYLE]
|
|
196
|
+
_font(
|
|
197
|
+
style, family(theme.fonts.body), size=theme.type.size, color=theme.colors.text
|
|
198
|
+
)
|
|
199
|
+
paragraph = style.paragraph_format
|
|
200
|
+
paragraph.line_spacing = theme.type.line_height
|
|
201
|
+
paragraph.space_before = Pt(0)
|
|
202
|
+
paragraph.space_after = Pt(theme.type.size * theme.spacing.block)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _headings(document: Any, theme: Theme) -> None:
|
|
206
|
+
"""``Heading 1``-``6``, at the theme's scale and in the theme's face."""
|
|
207
|
+
heading_font = family(theme.fonts.heading)
|
|
208
|
+
for level, (name, scale) in enumerate(
|
|
209
|
+
zip(HEADING_STYLES, theme.type.headings, strict=True), start=1
|
|
210
|
+
):
|
|
211
|
+
style = document.styles[name]
|
|
212
|
+
# The last level is set in the muted colour rather than the text one,
|
|
213
|
+
# which is what the structural stylesheet does with h6: at body size
|
|
214
|
+
# and body colour it would be indistinguishable from a bold paragraph.
|
|
215
|
+
color = (
|
|
216
|
+
theme.colors.muted if level == len(HEADING_STYLES) else theme.colors.text
|
|
217
|
+
)
|
|
218
|
+
_font(
|
|
219
|
+
style,
|
|
220
|
+
heading_font,
|
|
221
|
+
size=theme.type.size * scale,
|
|
222
|
+
color=color,
|
|
223
|
+
bold=theme.type.heading_weight >= BOLD_AT,
|
|
224
|
+
# Word's Heading 6 arrives italic, and the stylesheet's does not.
|
|
225
|
+
italic=False,
|
|
226
|
+
)
|
|
227
|
+
paragraph = style.paragraph_format
|
|
228
|
+
paragraph.line_spacing = HEADING_LEADING
|
|
229
|
+
# A top-level heading opens a section and has nothing to be pushed
|
|
230
|
+
# away from, so it alone starts flush.
|
|
231
|
+
before = 0.0 if level == 1 else HEADING_SPACE_BEFORE
|
|
232
|
+
paragraph.space_before = Pt(theme.type.size * before)
|
|
233
|
+
paragraph.space_after = Pt(theme.type.size * HEADING_SPACE_AFTER)
|
|
234
|
+
# A heading stranded at the foot of a page is the most visible
|
|
235
|
+
# typesetting failure there is, and these are the two rules that fix it.
|
|
236
|
+
paragraph.keep_with_next = True
|
|
237
|
+
paragraph.keep_together = True
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _lists(document: Any, theme: Theme) -> None:
|
|
241
|
+
"""Tighten the list styles, which are otherwise as loose as a paragraph."""
|
|
242
|
+
for name in (*BULLET_STYLES, *NUMBER_STYLES):
|
|
243
|
+
paragraph = document.styles[name].paragraph_format
|
|
244
|
+
paragraph.space_before = Pt(0)
|
|
245
|
+
paragraph.space_after = Pt(theme.type.size * 0.2)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _quote(document: Any, theme: Theme) -> None:
|
|
249
|
+
"""``Quote``: muted, indented, and ruled down the left like the PDF's."""
|
|
250
|
+
style = document.styles[QUOTE_STYLE]
|
|
251
|
+
# Word's Quote is italic; the PDF's is not, and a blockquote holding a
|
|
252
|
+
# paragraph of prose should not be set in italic for its whole length.
|
|
253
|
+
_font(style, family(theme.fonts.body), color=theme.colors.muted, italic=False)
|
|
254
|
+
paragraph = style.paragraph_format
|
|
255
|
+
paragraph.left_indent = quote_indent(theme)
|
|
256
|
+
paragraph.space_after = Pt(theme.type.size * theme.spacing.block)
|
|
257
|
+
set_borders(
|
|
258
|
+
style.element.get_or_add_pPr(),
|
|
259
|
+
["left"],
|
|
260
|
+
color=theme.colors.rule,
|
|
261
|
+
size=12,
|
|
262
|
+
space=6,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _code(document: Any, theme: Theme) -> None:
|
|
267
|
+
"""The two code styles: a shaded, bordered block and an inline span."""
|
|
268
|
+
block = _new_style(document, CODE_STYLE, WD_STYLE_TYPE.PARAGRAPH)
|
|
269
|
+
_font(
|
|
270
|
+
block,
|
|
271
|
+
family(theme.fonts.mono),
|
|
272
|
+
size=theme.type.size * theme.type.code,
|
|
273
|
+
color=theme.colors.text,
|
|
274
|
+
)
|
|
275
|
+
paragraph = block.paragraph_format
|
|
276
|
+
paragraph.line_spacing = CODE_LEADING
|
|
277
|
+
paragraph.space_before = Pt(0)
|
|
278
|
+
paragraph.space_after = Pt(theme.type.size * theme.spacing.block)
|
|
279
|
+
properties = block.element.get_or_add_pPr()
|
|
280
|
+
shade(properties, theme.colors.fill)
|
|
281
|
+
set_borders(
|
|
282
|
+
properties,
|
|
283
|
+
CODE_BOX_SIDES,
|
|
284
|
+
color=theme.colors.rule,
|
|
285
|
+
space=CODE_BOX_PADDING,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
inline = _new_style(document, CODE_INLINE_STYLE, WD_STYLE_TYPE.CHARACTER)
|
|
289
|
+
_font(inline, family(theme.fonts.mono), size=theme.type.size * theme.type.code)
|
|
290
|
+
shade(inline.element.get_or_add_rPr(), theme.colors.fill)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _tables(document: Any, theme: Theme) -> None:
|
|
294
|
+
"""A cell sets a size smaller than the text around it, as the PDF does."""
|
|
295
|
+
style = _new_style(document, TABLE_TEXT_STYLE, WD_STYLE_TYPE.PARAGRAPH)
|
|
296
|
+
_font(
|
|
297
|
+
style, family(theme.fonts.body), size=theme.type.small, color=theme.colors.text
|
|
298
|
+
)
|
|
299
|
+
paragraph = style.paragraph_format
|
|
300
|
+
paragraph.space_before = Pt(0)
|
|
301
|
+
paragraph.space_after = Pt(0)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _footnotes(document: Any, theme: Theme) -> None:
|
|
305
|
+
"""The endnote-style list Word gets instead of real footnotes: small, muted."""
|
|
306
|
+
style = _new_style(document, FOOTNOTE_STYLE, WD_STYLE_TYPE.PARAGRAPH)
|
|
307
|
+
_font(
|
|
308
|
+
style, family(theme.fonts.body), size=theme.type.small, color=theme.colors.muted
|
|
309
|
+
)
|
|
310
|
+
paragraph = style.paragraph_format
|
|
311
|
+
paragraph.space_before = Pt(0)
|
|
312
|
+
paragraph.space_after = Pt(theme.type.size * 0.35)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _links(document: Any, theme: Theme) -> None:
|
|
316
|
+
"""``Hyperlink``, in the accent colour and without Word's underline."""
|
|
317
|
+
# "Hyperlink" is a Word style name rather than one of ours, so it is
|
|
318
|
+
# declared as built-in: Word applies it to a link the user types, and a
|
|
319
|
+
# document with two spellings of the same idea in its style pane is worse
|
|
320
|
+
# than one that reuses the name Word already knows.
|
|
321
|
+
style = _new_style(document, LINK_STYLE, WD_STYLE_TYPE.CHARACTER, builtin=True)
|
|
322
|
+
_font(style, color=theme.colors.accent)
|
|
323
|
+
style.font.underline = False
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _running(document: Any, theme: Theme) -> None:
|
|
327
|
+
"""The head and the foot, which are the same small muted register."""
|
|
328
|
+
for name in (HEADER_STYLE, FOOTER_STYLE):
|
|
329
|
+
_font(
|
|
330
|
+
document.styles[name],
|
|
331
|
+
family(theme.fonts.body),
|
|
332
|
+
size=theme.type.small,
|
|
333
|
+
color=theme.colors.muted,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _cover(document: Any, theme: Theme) -> None:
|
|
338
|
+
"""Word's ``Title`` and ``Subtitle``, restyled into a title page.
|
|
339
|
+
|
|
340
|
+
Both arrive bound to the document theme rather than to this one — the
|
|
341
|
+
title is ruled off underneath in an accent colour that belongs to no theme
|
|
342
|
+
here, and the subtitle is italic, coloured and, oddly, carries a numbering
|
|
343
|
+
reference. All of that has to be taken off rather than overridden.
|
|
344
|
+
"""
|
|
345
|
+
title = document.styles[TITLE_STYLE]
|
|
346
|
+
_font(
|
|
347
|
+
title,
|
|
348
|
+
family(theme.fonts.heading),
|
|
349
|
+
size=theme.type.size * theme.type.title,
|
|
350
|
+
color=theme.colors.text,
|
|
351
|
+
bold=theme.type.heading_weight >= BOLD_AT,
|
|
352
|
+
italic=False,
|
|
353
|
+
)
|
|
354
|
+
properties = title.element.get_or_add_pPr()
|
|
355
|
+
clear_properties_child(properties, "w:pBdr")
|
|
356
|
+
title.paragraph_format.line_spacing = TITLE_LEADING
|
|
357
|
+
title.paragraph_format.space_before = Pt(0)
|
|
358
|
+
title.paragraph_format.space_after = Pt(theme.type.size * TITLE_SPACE_AFTER)
|
|
359
|
+
|
|
360
|
+
subtitle = document.styles[SUBTITLE_STYLE]
|
|
361
|
+
_font(
|
|
362
|
+
subtitle,
|
|
363
|
+
family(theme.fonts.body),
|
|
364
|
+
size=theme.type.size * theme.type.headings[2],
|
|
365
|
+
color=theme.colors.muted,
|
|
366
|
+
bold=False,
|
|
367
|
+
italic=False,
|
|
368
|
+
)
|
|
369
|
+
clear_properties_child(subtitle.element.get_or_add_pPr(), "w:numPr")
|
|
370
|
+
subtitle.paragraph_format.line_spacing = SUBTITLE_LEADING
|
|
371
|
+
subtitle.paragraph_format.space_before = Pt(0)
|
|
372
|
+
subtitle.paragraph_format.space_after = Pt(theme.type.size * SUBTITLE_SPACE_AFTER)
|
|
373
|
+
|
|
374
|
+
date = _new_style(document, COVER_DATE_STYLE, WD_STYLE_TYPE.PARAGRAPH)
|
|
375
|
+
_font(
|
|
376
|
+
date, family(theme.fonts.body), size=theme.type.small, color=theme.colors.muted
|
|
377
|
+
)
|
|
378
|
+
date.paragraph_format.space_before = Pt(0)
|
|
379
|
+
date.paragraph_format.space_after = Pt(0)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _contents(document: Any, theme: Theme) -> None:
|
|
383
|
+
"""The contents entry styles, one per heading level.
|
|
384
|
+
|
|
385
|
+
Each carries a right tab stop with a dotted leader at the edge of the text
|
|
386
|
+
column, which is what turns "heading, tab, page number" into a line of
|
|
387
|
+
dots ending in a number — the stylesheet's ``leader('.')`` by another
|
|
388
|
+
name.
|
|
389
|
+
"""
|
|
390
|
+
width = text_width(document.sections[0])
|
|
391
|
+
for level, name in enumerate(TOC_STYLES, start=1):
|
|
392
|
+
style = _new_style(document, name, WD_STYLE_TYPE.PARAGRAPH, builtin=True)
|
|
393
|
+
_font(
|
|
394
|
+
style,
|
|
395
|
+
family(theme.fonts.body),
|
|
396
|
+
size=theme.type.size,
|
|
397
|
+
color=theme.colors.text,
|
|
398
|
+
# Only the top level is set apart, as the stylesheet sets it.
|
|
399
|
+
bold=level == 1 and theme.type.heading_weight >= BOLD_AT,
|
|
400
|
+
italic=False,
|
|
401
|
+
)
|
|
402
|
+
paragraph = style.paragraph_format
|
|
403
|
+
paragraph.left_indent = Pt(theme.type.size * TOC_INDENT * (level - 1))
|
|
404
|
+
paragraph.space_before = Pt(
|
|
405
|
+
theme.type.size * (TOC_SPACE_BEFORE if level == 1 else 0)
|
|
406
|
+
)
|
|
407
|
+
paragraph.space_after = Pt(theme.type.size * TOC_SPACE_AFTER)
|
|
408
|
+
paragraph.line_spacing = HEADING_LEADING
|
|
409
|
+
if width is not None:
|
|
410
|
+
paragraph.tab_stops.add_tab_stop(
|
|
411
|
+
width, WD_TAB_ALIGNMENT.RIGHT, WD_TAB_LEADER.DOTS
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def text_width(section: Any) -> Length | None:
|
|
416
|
+
"""The width of the text column, which is the sheet less its margins.
|
|
417
|
+
|
|
418
|
+
``None`` when the section declares no width of its own: there is then
|
|
419
|
+
nothing to measure a picture or a tab stop against, and whatever wanted
|
|
420
|
+
one has to do without.
|
|
421
|
+
"""
|
|
422
|
+
if section.page_width is None:
|
|
423
|
+
return None
|
|
424
|
+
return Emu(
|
|
425
|
+
int(section.page_width)
|
|
426
|
+
- int(section.left_margin or 0)
|
|
427
|
+
- int(section.right_margin or 0)
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# --- page geometry --------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def quote_indent(theme: Theme) -> Length:
|
|
435
|
+
"""How far one level of quoting sets its content in.
|
|
436
|
+
|
|
437
|
+
The ``Quote`` style carries this, and so does the walker — a paragraph
|
|
438
|
+
with an indent of its own replaces the style's rather than adding to it,
|
|
439
|
+
so the two have to agree on the step, and this is where they do.
|
|
440
|
+
"""
|
|
441
|
+
return Pt(theme.type.size)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def apply_page(section: Any, theme: Theme, *, warn: Warn = _discard) -> None:
|
|
445
|
+
"""Set a section's sheet size and margins from the theme.
|
|
446
|
+
|
|
447
|
+
A theme's page geometry is written as CSS, because the PDF path hands it
|
|
448
|
+
straight to a paged-media descriptor. Word needs it as four numbers, and
|
|
449
|
+
CSS is the larger language — so a value this cannot read is reported and
|
|
450
|
+
stepped over rather than raised. A document that converts with the wrong
|
|
451
|
+
margins and says so beats one that refuses to convert at all, given the
|
|
452
|
+
same theme makes a PDF perfectly well.
|
|
453
|
+
"""
|
|
454
|
+
size = page_size(theme.page.size)
|
|
455
|
+
if size is None:
|
|
456
|
+
warn(
|
|
457
|
+
f"page size {theme.page.size!r} is not one the Word renderer "
|
|
458
|
+
f"understands; keeping the default sheet."
|
|
459
|
+
)
|
|
460
|
+
else:
|
|
461
|
+
section.page_width, section.page_height = size
|
|
462
|
+
|
|
463
|
+
edges = page_margins(theme.page.margin)
|
|
464
|
+
if edges is None:
|
|
465
|
+
warn(
|
|
466
|
+
f"margin {theme.page.margin!r} is not one the Word renderer "
|
|
467
|
+
f"understands; keeping the default margins."
|
|
468
|
+
)
|
|
469
|
+
else:
|
|
470
|
+
(
|
|
471
|
+
section.top_margin,
|
|
472
|
+
section.right_margin,
|
|
473
|
+
section.bottom_margin,
|
|
474
|
+
section.left_margin,
|
|
475
|
+
) = edges
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def page_size(value: str) -> tuple[Length, Length] | None:
|
|
479
|
+
"""Read a CSS ``size`` descriptor as a width and a height.
|
|
480
|
+
|
|
481
|
+
Understands what a theme is likely to say — ``A4``, ``Letter landscape``,
|
|
482
|
+
``210mm 297mm`` — and nothing more exotic.
|
|
483
|
+
"""
|
|
484
|
+
words = value.lower().split()
|
|
485
|
+
landscape = "landscape" in words
|
|
486
|
+
words = [word for word in words if word not in {"portrait", "landscape"}]
|
|
487
|
+
|
|
488
|
+
if len(words) == 1 and words[0] in NAMED_PAGE_SIZES:
|
|
489
|
+
millimetres = NAMED_PAGE_SIZES[words[0]]
|
|
490
|
+
sheet = (_from_units(millimetres[0], "mm"), _from_units(millimetres[1], "mm"))
|
|
491
|
+
else:
|
|
492
|
+
lengths = [length(word) for word in words]
|
|
493
|
+
if not 1 <= len(lengths) <= 2 or None in lengths:
|
|
494
|
+
return None
|
|
495
|
+
measured = [item for item in lengths if item is not None]
|
|
496
|
+
sheet = (measured[0], measured[-1])
|
|
497
|
+
|
|
498
|
+
return (sheet[1], sheet[0]) if landscape else sheet
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def page_margins(value: str) -> tuple[Length, Length, Length, Length] | None:
|
|
502
|
+
"""Read a CSS ``margin`` shorthand as top, right, bottom and left."""
|
|
503
|
+
lengths = [length(word) for word in value.split()]
|
|
504
|
+
if not 1 <= len(lengths) <= 4 or None in lengths:
|
|
505
|
+
return None
|
|
506
|
+
edges = [item for item in lengths if item is not None]
|
|
507
|
+
if len(edges) == 1:
|
|
508
|
+
edges *= 4
|
|
509
|
+
elif len(edges) == 2:
|
|
510
|
+
edges = [edges[0], edges[1], edges[0], edges[1]]
|
|
511
|
+
elif len(edges) == 3:
|
|
512
|
+
edges = [edges[0], edges[1], edges[2], edges[1]]
|
|
513
|
+
return (edges[0], edges[1], edges[2], edges[3])
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def length(value: str) -> Length | None:
|
|
517
|
+
"""One CSS length as Word's own unit, or ``None`` if it is not one."""
|
|
518
|
+
match = LENGTH.match(value.strip().lower())
|
|
519
|
+
if match is None:
|
|
520
|
+
return None
|
|
521
|
+
number, unit = match.groups()
|
|
522
|
+
if not unit:
|
|
523
|
+
# A bare number is a length only when it is zero; CSS says so, and a
|
|
524
|
+
# theme that means centimetres has to write them.
|
|
525
|
+
return Emu(0) if float(number) == 0 else None
|
|
526
|
+
if unit not in UNITS_PER_INCH:
|
|
527
|
+
return None
|
|
528
|
+
return _from_units(float(number), unit)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _from_units(value: float, unit: str) -> Length:
|
|
532
|
+
"""A CSS length in a known unit, as the EMU that Word measures in."""
|
|
533
|
+
return Emu(round(value * EMU_PER_INCH / UNITS_PER_INCH[unit]))
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# --- fonts and colour -----------------------------------------------------
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def family(stack: Sequence[str]) -> str:
|
|
540
|
+
"""Reduce a CSS font stack to the one name a Word style can hold.
|
|
541
|
+
|
|
542
|
+
A stack's later entries are fallbacks, and Word does its own falling back
|
|
543
|
+
from a single name, so the first real family is the one to keep. Generic
|
|
544
|
+
families are skipped rather than passed through: Word would read
|
|
545
|
+
``ui-monospace`` as the name of a font nobody has and quietly set code in
|
|
546
|
+
the body face.
|
|
547
|
+
"""
|
|
548
|
+
for name in stack:
|
|
549
|
+
if name.lower() not in GENERIC_FAMILIES:
|
|
550
|
+
return name
|
|
551
|
+
return GENERIC_FAMILIES[stack[0].lower()]
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def rgb(value: str) -> RGBColor:
|
|
555
|
+
"""A theme colour as Word wants it: three bytes."""
|
|
556
|
+
return RGBColor.from_string(value.lstrip("#").upper())
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _new_style(
|
|
560
|
+
document: Any, name: str, kind: WD_STYLE_TYPE, *, builtin: bool = False
|
|
561
|
+
) -> Any:
|
|
562
|
+
"""Add a style, or return the one already there under that name.
|
|
563
|
+
|
|
564
|
+
A paragraph style Amethyst defines is based on ``Normal``, which the theme
|
|
565
|
+
has already been applied to, so it starts from the document's own type
|
|
566
|
+
rather than from Word's factory defaults and only has to say what differs.
|
|
567
|
+
"""
|
|
568
|
+
try:
|
|
569
|
+
return document.styles[name]
|
|
570
|
+
except KeyError:
|
|
571
|
+
style = document.styles.add_style(name, kind, builtin=builtin)
|
|
572
|
+
if kind == WD_STYLE_TYPE.PARAGRAPH:
|
|
573
|
+
style.base_style = document.styles[BODY_STYLE]
|
|
574
|
+
return style
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _font(
|
|
578
|
+
style: Any,
|
|
579
|
+
name: str | None = None,
|
|
580
|
+
*,
|
|
581
|
+
size: float | None = None,
|
|
582
|
+
color: str | None = None,
|
|
583
|
+
bold: bool | None = None,
|
|
584
|
+
italic: bool | None = None,
|
|
585
|
+
) -> None:
|
|
586
|
+
"""Set what a style says about its type, and unbind it from Word's theme.
|
|
587
|
+
|
|
588
|
+
The unbinding is the part that is easy to miss. A built-in heading style
|
|
589
|
+
names its font twice — once plainly and once as a reference to the document
|
|
590
|
+
theme's major font — and the reference wins, so a style whose ``w:ascii``
|
|
591
|
+
was set and whose ``w:asciiTheme`` was left alone still comes out in
|
|
592
|
+
Calibri.
|
|
593
|
+
"""
|
|
594
|
+
if name is not None:
|
|
595
|
+
style.font.name = name
|
|
596
|
+
_unbind_theme_font(style)
|
|
597
|
+
if size is not None:
|
|
598
|
+
# Word stores a size in half-points and truncates what it is given, so
|
|
599
|
+
# a scale step that lands between two of them — 11pt × 1.22 — would
|
|
600
|
+
# come out a third of a point smaller than asked for. Rounding first
|
|
601
|
+
# costs nothing and keeps the two formats on the same scale.
|
|
602
|
+
style.font.size = Pt(round(size * 2) / 2)
|
|
603
|
+
if color is not None:
|
|
604
|
+
style.font.color.rgb = rgb(color)
|
|
605
|
+
if bold is not None:
|
|
606
|
+
style.font.bold = bold
|
|
607
|
+
if italic is not None:
|
|
608
|
+
style.font.italic = italic
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _unbind_theme_font(style: Any) -> None:
|
|
612
|
+
"""Remove the theme references that outrank a plainly named font."""
|
|
613
|
+
fonts = style.element.get_or_add_rPr().find(qn("w:rFonts"))
|
|
614
|
+
if fonts is None:
|
|
615
|
+
return
|
|
616
|
+
for attribute in ("asciiTheme", "hAnsiTheme", "eastAsiaTheme", "cstheme"):
|
|
617
|
+
fonts.attrib.pop(qn(f"w:{attribute}"), None)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
__all__ = [
|
|
621
|
+
"BODY_STYLE",
|
|
622
|
+
"BULLET_STYLES",
|
|
623
|
+
"CODE_INLINE_STYLE",
|
|
624
|
+
"CODE_STYLE",
|
|
625
|
+
"COVER_DATE_STYLE",
|
|
626
|
+
"COVER_SPACE_AFTER",
|
|
627
|
+
"FOOTNOTE_STYLE",
|
|
628
|
+
"FOOTER_STYLE",
|
|
629
|
+
"HEADER_STYLE",
|
|
630
|
+
"HEADING_STYLES",
|
|
631
|
+
"LINK_STYLE",
|
|
632
|
+
"LIST_INDENT_STEP",
|
|
633
|
+
"NUMBER_STYLES",
|
|
634
|
+
"QUOTE_STYLE",
|
|
635
|
+
"SUBTITLE_STYLE",
|
|
636
|
+
"TABLE_STYLE",
|
|
637
|
+
"TABLE_TEXT_STYLE",
|
|
638
|
+
"TITLE_PAGE_OFFSET",
|
|
639
|
+
"TITLE_STYLE",
|
|
640
|
+
"TOC_HEADING_STYLE",
|
|
641
|
+
"TOC_STYLES",
|
|
642
|
+
"apply_page",
|
|
643
|
+
"apply_theme",
|
|
644
|
+
"family",
|
|
645
|
+
"length",
|
|
646
|
+
"page_margins",
|
|
647
|
+
"page_size",
|
|
648
|
+
"quote_indent",
|
|
649
|
+
"rgb",
|
|
650
|
+
"text_width",
|
|
651
|
+
]
|