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,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
+ ]