Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_austenite/src/theme.rs

60.6 KiB, 330 runs

created by r1870400018:39019, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! `Theme`: a document's styling held as data rather than scattered constants.
2//!
3//! Austenite began with a flat `Copy` `Style` struct filled by string-scraping a template's config.
4//! This module replaces it with a grouped, serialisable `Theme`: the same values today's renderer
5//! reads, arranged by the element they style (text, paragraphs, headings, lists, tables, furniture,
6//! colours) with reserved groups (figure, code, callout, equation, per-part page geometry) that a
7//! later unit fills from the document's own `#set`/`#show` declarations.
8//!
9//! **Canonical serialisation.** [`Theme::to_dat`] emits an ordered map whose key order is fixed and
10//! documented here, and every sub-struct does the same in field-declaration order. The order is a
11//! stable contract: a later unit hashes a group's `to_dat` bytes as one input to a block address, so
12//! the ordering must not change without changing that address deliberately. The canonical group order
13//! is: `text, par, heading, opener, list, enumeration, table, figure, code, callout, equation, page,
14//! furniture, colours, calibration`.
15//!
16//! **Block-address encoding.** [`Theme::group_dat`] returns one named group's daticle so a rule's block
17//! address hashes only the groups that rule reads, not the whole theme -- a footnote-size change must
18//! not invalidate every heading block's address. The daticle is hashed under **bdat**
19//! (`fe2o3_jdat`'s insertion-order-preserving binary encoding), chosen over `json_canonical` because
20//! the group's field order is already a deliberate, documented contract that bdat preserves verbatim,
21//! whereas `json_canonical` re-sorts keys and so discards that order for no gain here; the float-refusal
22//! that distinguishes them is moot, since every leaf survives `to_dat` as an integer (a length as
23//! scaled-point `I32`, a colour channel as `U8`, the calibration constant as its IEEE-754 `U64` bits),
24//! so no float ever reaches the encoder. This unit computes no address; it fixes the ordering and the
25//! per-group selector the address will read.
26//!
27//! **Defaults reproduce the old `Style` exactly**, so a corpus rendered with a default (or
28//! config-built) `Theme` is byte-identical to the pre-`Theme` build. The gate on the unit that landed
29//! this file is exactly that identity.
30
31use crate::doc::HeadingStyle;
32use crate::ir::Length;
33use crate::ir::Sp;
34
35use oxedyne_fe2o3_core::prelude::*;
36use oxedyne_fe2o3_jdat::prelude::*;
37use oxedyne_fe2o3_graphics::colour::Rgba;
38
39// ┌───────────────────────────────────────────────────────────────────────────┐
40// │ THE THEME │
41// └───────────────────────────────────────────────────────────────────────────┘
42
43/// A document's styling as data. Grouped by the element each value styles; see the module header for
44/// the canonical serialisation order the block-address hash later depends on.
45#[derive(Clone, Debug, PartialEq)]
46pub struct Theme {
47 pub text: ThemeText,
48 pub par: ThemePar,
49 pub heading: ThemeHeading,
50 pub opener: ThemeOpener,
51 pub list: ThemeList,
52 pub enumeration: ThemeEnum,
53 pub table: ThemeTable,
54 pub figure: ThemeFigure,
55 pub code: ThemeCode,
56 pub callout: ThemeCallout,
57 pub equation: ThemeEquation,
58 pub page: ThemePage,
59 pub furniture: ThemeFurniture,
60 pub colours: ThemeColours,
61 pub calibration: ThemeCalibration,
62}
63
64impl Default for Theme {
65 fn default() -> Self {
66 Self {
67 text: ThemeText::default(),
68 par: ThemePar::default(),
69 heading: ThemeHeading::default(),
70 opener: ThemeOpener::default(),
71 list: ThemeList::default(),
72 enumeration: ThemeEnum::default(),
73 table: ThemeTable::default(),
74 figure: ThemeFigure::default(),
75 code: ThemeCode::default(),
76 callout: ThemeCallout::default(),
77 equation: ThemeEquation::default(),
78 page: ThemePage::default(),
79 furniture: ThemeFurniture::default(),
80 colours: ThemeColours::default(),
81 calibration: ThemeCalibration::default(),
82 }
83 }
84}
85
86impl Theme {
87 /// The `levels` index a heading of this level reads: level 0 (a part divider) and level 1 both take
88 /// index 0 (the chapter title), and a level past the last entry clamps to it. Now that `levels` is a
89 /// `Vec` this is one place rather than three parallel matches, and it reaches `levels[3]` for level 4
90 /// rather than stopping at `levels[2]`.
91 fn level_idx(&self, level: u8) -> usize {
92 let i = if level <= 1 { 0 } else { (level as usize) - 1 };
93 i.min(self.heading.levels.len().saturating_sub(1))
94 }
95
96 /// The type size a heading of this level is set at. Level 0 (a part divider) takes the chapter-title
97 /// size, and any level past the last takes the last level's size.
98 pub fn heading_size(&self, level: u8) -> Sp {
99 self.heading.levels[self.level_idx(level)].size
100 }
101
102 /// The space set above a heading of this level, always greater than the space below it, so a heading
103 /// binds visually to the text it introduces rather than to the text it follows.
104 pub fn space_above(&self, level: u8) -> Sp {
105 self.heading.levels[self.level_idx(level)].space_above
106 }
107
108 /// The space set below a heading of this level. See [`Theme::space_above`] for the level mapping.
109 pub fn space_below(&self, level: u8) -> Sp {
110 self.heading.levels[self.level_idx(level)].space_below
111 }
112}
113
114// ┌───────────────────────────────────────────────────────────────────────────┐
115// │ GROUPS │
116// └───────────────────────────────────────────────────────────────────────────┘
117
118/// Body text: the size and baseline the flow sets, and reserved run-level switches a later unit lowers
119/// from `set text(...)`.
120#[derive(Clone, Debug, PartialEq)]
121pub struct ThemeText {
122 pub body_size: Sp,
123 pub leading: Sp, // baseline-to-baseline distance, not a gap; see the calibration group
124 pub tracking: Sp, // reserved: extra letter spacing, unread until a later unit lowers it
125 pub ligatures: bool, // reserved
126 pub hyphenate: bool, // reserved
127 pub justify: bool, // reserved
128 pub fill: Rgba, // prose text colour from `#set text(fill:)`, default black
129 pub faces: FaceSet, // role -> family names, resolved to loaded faces at render
130}
131
132impl Default for ThemeText {
133 fn default() -> Self {
134 Self {
135 body_size: Sp::from_pt(11.0),
136 leading: Sp::from_pt(13.2), // 1.2x the body
137 tracking: Sp::ZERO,
138 ligatures: true,
139 hyphenate: true,
140 justify: true,
141 fill: Rgba::BLACK,
142 faces: FaceSet::default(),
143 }
144 }
145}
146
147/// The family each text role is set in. `body` is Typst's `text.font`: a fallback list, tried in order for
148/// each character, and empty to take the loaded default (Libertinus Serif). `emphasis` and `mono` are
149/// reserved; nothing lowers into them yet. The heading display face is not here -- it is read for headings,
150/// so it lives in the `heading` group ([`ThemeHeading::face`]) where a heading block's read-set finds it.
151#[derive(Clone, Debug, PartialEq, Default)]
152pub struct FaceSet {
153 pub body: Vec<String>,
154 pub emphasis: Option<String>,
155 pub mono: Option<String>,
156}
157
158/// Paragraph shape: the space between one paragraph and the next, and the first-line indent a
159/// paragraph following another takes.
160#[derive(Clone, Debug, PartialEq)]
161pub struct ThemePar {
162 pub skip: Sp, // extra space between one paragraph and the next
163 pub indent: Sp, // first-line indent of a paragraph following another paragraph
164}
165
166impl Default for ThemePar {
167 fn default() -> Self {
168 Self {
169 skip: Sp::from_pt(6.0),
170 indent: Sp::ZERO, // no first-line indent unless a config sets one
171 }
172 }
173}
174
175/// One heading level's styling. Only `size`, `space_above` and `space_below` are read today; the
176/// face, weight, italic, smallcaps and numbering fields are reserved for the unit that lowers a
177/// document's `set heading(numbering: ...)` and per-level `show` rules.
178#[derive(Clone, Debug, PartialEq)]
179pub struct ThemeHeadingLevel {
180 pub size: Sp,
181 pub face: Option<String>, // reserved
182 pub weight: Option<u16>, // reserved: font weight, 100-900
183 pub italic: bool, // reserved
184 pub smallcaps: bool, // reserved
185 pub numbering: Option<String>, // reserved: a Typst numbering pattern, e.g. "1.1"
186 pub space_above: Sp,
187 pub space_below: Sp,
188}
189
190impl ThemeHeadingLevel {
191 fn new(size_pt: f64, above_pt: f64, below_pt: f64) -> Self {
192 Self {
193 size: Sp::from_pt(size_pt),
194 face: None,
195 weight: None,
196 italic: false,
197 smallcaps: false,
198 numbering: None,
199 space_above: Sp::from_pt(above_pt),
200 space_below: Sp::from_pt(below_pt),
201 }
202 }
203}
204
205/// Headings: which opener and numbering the top level takes, and the per-level styling indexed from 0
206/// (level 1). A `Vec` rather than a fixed four, so a document with headings deeper than level 4 carries a
207/// level for each; the default holds four. The chapter opener's own geometry lives apart, in
208/// [`ThemeOpener`], so a rule reading a heading's size does not depend on the opener grid.
209#[derive(Clone, Debug, PartialEq)]
210pub struct ThemeHeading {
211 pub kind: HeadingStyle, // which top-level opener and numbering the headings take
212 pub face: Option<String>, // role-default display face for the top levels (read for headings)
213 pub levels: Vec<ThemeHeadingLevel>, // index 0 is level 1; the default holds four, more are allowed
214}
215
216impl Default for ThemeHeading {
217 fn default() -> Self {
218 Self {
219 kind: HeadingStyle::BookOpener,
220 face: None,
221 levels: vec![
222 ThemeHeadingLevel::new(16.0, 20.0, 8.0), // level 1, the chapter title
223 ThemeHeadingLevel::new(13.0, 15.0, 6.0), // level 2
224 ThemeHeadingLevel::new(12.0, 12.0, 5.0), // level 3
225 ThemeHeadingLevel::new(11.0, 12.0, 5.0), // level 4
226 ],
227 }
228 }
229}
230
231/// The chapter opener's geometry, kept apart from the generic per-level `heading` styling: the giant
232/// chapter number's size and the opener grid rows. One template's opener draws a large number over a
233/// grid; a book without that opener leaves these at their defaults, unread. Separated so a rule that
234/// reads the `heading` group is not invalidated when only the opener geometry changes.
235#[derive(Clone, Debug, PartialEq)]
236pub struct ThemeOpener {
237 pub chap_num_size: Sp, // the giant chapter number on a chapter-opening page
238 pub chap_grid: [Sp; 4], // opener grid rows: number band, gap, title band, gap-to-body
239}
240
241impl Default for ThemeOpener {
242 fn default() -> Self {
243 Self {
244 chap_num_size: Sp::from_pt(54.0),
245 chap_grid: [Sp::from_pt(72.0), Sp::from_pt(8.0), Sp::from_pt(36.0), Sp::from_pt(20.0)],
246 }
247 }
248}
249
250/// Bulleted lists: the gap after a marker and the space between one item and the next.
251#[derive(Clone, Debug, PartialEq)]
252pub struct ThemeList {
253 pub marker_gap: Sp, // space between a list marker and the item text it introduces
254 pub item_skip: Option<Sp>, // `#set list(spacing:)` override; None keeps Typst's auto (tight body pitch, loose block spacing)
255}
256
257impl Default for ThemeList {
258 fn default() -> Self {
259 Self {
260 marker_gap: Sp::from_pt(6.0),
261 item_skip: None,
262 }
263 }
264}
265
266/// Numbered lists. Reserved: today the enumeration is set with the same metrics as a bulleted list;
267/// a later unit lowers `set enum(...)` into its own marker pattern here.
268#[derive(Clone, Debug, PartialEq)]
269pub struct ThemeEnum {
270 pub marker_gap: Sp,
271 pub item_skip: Option<Sp>, // `#set enum(spacing:)` override; None keeps Typst's auto (tight body pitch, loose block spacing)
272 pub numbering: Option<String>, // reserved: a Typst numbering pattern, e.g. "1."
273}
274
275impl Default for ThemeEnum {
276 fn default() -> Self {
277 Self {
278 marker_gap: Sp::from_pt(6.0),
279 item_skip: None,
280 numbering: None,
281 }
282 }
283}
284
285/// Tables: the space around a table, the cell padding, the inter-line leading within a cell, and the
286/// two rule weights.
287#[derive(Clone, Debug, PartialEq)]
288pub struct ThemeTable {
289 pub skip: Sp, // space set above and below a table
290 pub cell_pad_x: Sp, // horizontal padding between a cell's text and its column rules
291 pub cell_pad_y: Sp, // vertical padding above and below a cell's lines
292 pub line_gap: Sp, // leading between the wrapped lines within one cell
293 pub rule_thin: Sp, // an interior grid rule (also the maths fraction bar)
294 pub rule_thick: Sp, // the frame and the rule beneath a header
295}
296
297impl Default for ThemeTable {
298 fn default() -> Self {
299 Self {
300 skip: Sp::from_pt(10.0),
301 cell_pad_x: Sp::from_pt(5.0),
302 cell_pad_y: Sp::from_pt(3.0),
303 line_gap: Sp::from_pt(3.0),
304 rule_thin: Sp::from_pt(0.4),
305 rule_thick: Sp::from_pt(0.8),
306 }
307 }
308}
309
310/// Figures and their captions. `caption_size` sets a drawn figure's caption (the footnote size, matching
311/// the template); `skip` is reserved for the space around a figure.
312#[derive(Clone, Debug, PartialEq)]
313pub struct ThemeFigure {
314 pub caption_size: Sp,
315 pub skip: Sp, // reserved: space around a figure
316}
317
318impl Default for ThemeFigure {
319 fn default() -> Self {
320 Self {
321 caption_size: Sp::from_pt(9.0), // the footnote size, the drawn-figure caption's own
322 skip: Sp::from_pt(10.0),
323 }
324 }
325}
326
327/// Code blocks. `size` sets a verbatim block's mono type (a touch below the body, the template's own);
328/// `background` is reserved for a later unit that lowers a raw-block wash.
329#[derive(Clone, Debug, PartialEq)]
330pub struct ThemeCode {
331 pub size: Sp,
332 pub background: Rgba, // reserved
333}
334
335impl Default for ThemeCode {
336 fn default() -> Self {
337 Self {
338 size: Sp::from_pt(9.0), // the footnote size a code block has always set at
339 background: Rgba::opaque(245, 245, 245),
340 }
341 }
342}
343
344/// Callout boxes. The block layer washes a callout with a fixed lilac and lays it out at the
345/// `#styled-box` template's own inset and radius; a rule's `block.with(fill:, inset:, radius:)` overlays
346/// `fill` and, when it names them, `inset_x`/`inset_top`/`inset_bot`/`radius` too. Each override is `None`
347/// until a rule sets it, and the renderer then falls back to the template's own constants (one body em,
348/// 1.2 body em and 4pt respectively), so a document with no such rule renders byte-identically.
349#[derive(Clone, Debug, PartialEq)]
350pub struct ThemeCallout {
351 pub fill: Rgba, // reserved
352 pub inset_x: Option<Sp>, // a rule's `inset:` (or its dict form's `x:`); None keeps the body em
353 pub inset_left: Option<Sp>, // an asymmetric `inset.left`; overrides inset_x on the left when set
354 pub inset_right: Option<Sp>, // an asymmetric `inset.right`; overrides inset_x on the right when set
355 pub inset_top: Option<Sp>, // as inset_x, but the top pad (the dict form's `y:`)
356 pub inset_bot: Option<Sp>, // as inset_x, but the foot pad (the dict form's `bottom:`, 1.2em default)
357 pub radius: Option<Sp>, // a rule's `radius:`; None keeps the template's 4pt
358 pub stroke_left_w: Option<Sp>, // a `stroke: (left: <w> + <colour>)` rule width; None draws no left rule
359 pub stroke_left_col: Option<Rgba>, // the left rule's colour; a rule draws only when both width and colour are set
360}
361
362impl Default for ThemeCallout {
363 fn default() -> Self {
364 Self {
365 fill: Rgba::opaque(245, 230, 255),
366 inset_x: None,
367 inset_left: None,
368 inset_right: None,
369 inset_top: None,
370 inset_bot: None,
371 radius: None,
372 stroke_left_w: None,
373 stroke_left_col: None,
374 }
375 }
376}
377
378/// Equations. Reserved for the unit that lowers `set math.equation(numbering: ...)`.
379#[derive(Clone, Debug, PartialEq)]
380pub struct ThemeEquation {
381 pub numbering: Option<String>, // reserved
382}
383
384impl Default for ThemeEquation {
385 fn default() -> Self {
386 Self { numbering: None }
387 }
388}
389
390/// One page class's geometry: the trim and the four margins, each an optional override on the
391/// document-level geometry the driver still supplies today. Every field is `None` until a unit lowers a
392/// document's `set page(...)` into it, so an unset class takes the driver's geometry unchanged.
393#[derive(Clone, Debug, PartialEq, Default)]
394pub struct ThemePageGeom {
395 pub width: Option<Sp>,
396 pub height: Option<Sp>,
397 pub margin_inside: Option<Sp>,
398 pub margin_outside: Option<Sp>,
399 pub margin_top: Option<Sp>,
400 pub margin_bottom: Option<Sp>,
401}
402
403/// A book part's page geometry, per page class. `default` applies to every page of the part; `recto` and
404/// `verso` override it on the right- and left-hand pages, and `opener` on a part- or chapter-opening
405/// page. All are `None` today -- the part takes the document-level geometry passed to the driver -- and
406/// are reserved for the folds and running furniture a later unit lowers into them; keeping them here now
407/// is free, whereas widening the shape once a unit reads it is not.
408#[derive(Clone, Debug, PartialEq, Default)]
409pub struct ThemePagePart {
410 pub default: ThemePageGeom,
411 pub recto: ThemePageGeom,
412 pub verso: ThemePageGeom,
413 pub opener: ThemePageGeom,
414}
415
416/// Page geometry per part of the book, each part per page class (reserved; see [`ThemePagePart`]), and the
417/// columns the body flows in: Typst's `page.columns`, and `columns.gutter` between two, a fraction of the
418/// content width (4% by default) or an absolute length.
419#[derive(Clone, Debug, PartialEq)]
420pub struct ThemePage {
421 pub front: ThemePagePart,
422 pub body: ThemePagePart,
423 pub back: ThemePagePart,
424 pub columns: usize,
425 pub column_gutter: Length,
426}
427
428impl Default for ThemePage {
429 fn default() -> Self {
430 Self {
431 front: ThemePagePart::default(),
432 body: ThemePagePart::default(),
433 back: ThemePagePart::default(),
434 columns: 1,
435 column_gutter: Length::Rel(0.04),
436 }
437 }
438}
439
440impl ThemePage {
441 /// The gutter between two columns of a content block `width` wide.
442 pub fn gutter_for(&self, width: Sp) -> Sp {
443 match self.column_gutter {
444 Length::Rel(f) => Sp::from_pt(width.to_pt() * f),
445 Length::Abs(pt) => Sp::from_pt(pt),
446 }
447 }
448}
449
450/// Page furniture: the running head, the folio, and the footnote text metrics.
451#[derive(Clone, Debug, PartialEq)]
452pub struct ThemeFurniture {
453 pub header_size: Sp, // the running head's size
454 pub folio_size: Sp,
455 pub foot_size: Sp, // the footnote text's size, a touch below the body
456 pub foot_leading: Sp, // leading between the wrapped lines of one footnote
457}
458
459impl Default for ThemeFurniture {
460 fn default() -> Self {
461 Self {
462 header_size: Sp::from_pt(9.5),
463 folio_size: Sp::from_pt(10.0),
464 foot_size: Sp::from_pt(9.0),
465 foot_leading: Sp::from_pt(10.8), // 1.2x the footnote size
466 }
467 }
468}
469
470/// The theme's named colours.
471#[derive(Clone, Debug, PartialEq)]
472pub struct ThemeColours {
473 pub chap_num_grey: Rgba, // the fill of the giant chapter number, a light grey
474 pub header_fill: Rgba, // the wash behind a table header row
475}
476
477impl Default for ThemeColours {
478 fn default() -> Self {
479 Self {
480 chap_num_grey: Rgba::opaque(200, 200, 200), // Typst's luma(200)
481 header_fill: Rgba::opaque(235, 238, 241), // #E9ECEF lightened 10%, the template's header1
482 }
483 }
484}
485
486/// Calibration constants the flow is measured against. `line_box_em` is the Libertinus line box Typst
487/// sets, as a fraction of the em: Typst's config leading is the gap added between line boxes, and the
488/// baseline-to-baseline skip is that gap plus the box. The box is not the face's nominal ascender +
489/// descender (fe2o3_font reports ~1.14 em for Libertinus, ~30% too loose); Typst's rendered Libertinus
490/// line box measures ~0.66 em. For an 11pt body at 0.78em leading that gives (0.66 + 0.78) x 11 = 15.84
491/// pt baseline-to-baseline, matching the Lucronics oracle measured at 300 DPI (66 px). An earlier 0.682
492/// set the pitch 0.24 pt too loose, losing ~1 line per page and driving a whole-book pagination drift.
493#[derive(Clone, Debug, PartialEq)]
494pub struct ThemeCalibration {
495 pub line_box_em: f64,
496}
497
498impl Default for ThemeCalibration {
499 fn default() -> Self {
500 Self { line_box_em: 0.660 }
501 }
502}
503
504// ┌───────────────────────────────────────────────────────────────────────────┐
505// │ THEME PATCH │
506// └───────────────────────────────────────────────────────────────────────────┘
507
508/// A sparse overlay on a [`Theme`]: the same fourteen groups in the same order, every leaf an `Option`
509/// of the theme's own field type, so `None` leaves a field as it stands and `Some(v)` sets it to `v`.
510/// Lowering a document's `#set`/`#show` declarations ([`crate::lang::set`]) builds one of these rather
511/// than mutating a theme in place, and [`Theme::apply`] folds it onto a theme at the scope it governs --
512/// the document, an included chapter, or a `#styled-box` body. A leaf whose theme type is itself optional
513/// (a face name, a numbering pattern) takes an `Option<Option<..>>`, so a patch can set it, clear it, or
514/// leave it, keeping the overlay fully general for the rule engine that reuses this machinery to apply a
515/// patch to a selected subtree.
516///
517/// The field declaration order mirrors [`Theme`]'s canonical serialisation order (see the module header),
518/// so a future patch serialisation lines up with a theme's without a second ordering contract to keep.
519#[derive(Clone, Debug, Default, PartialEq)]
520pub struct ThemePatch {
521 pub text: ThemeTextPatch,
522 pub par: ThemeParPatch,
523 pub heading: ThemeHeadingPatch,
524 pub opener: ThemeOpenerPatch,
525 pub list: ThemeListPatch,
526 pub enumeration: ThemeEnumPatch,
527 pub table: ThemeTablePatch,
528 pub figure: ThemeFigurePatch,
529 pub code: ThemeCodePatch,
530 pub callout: ThemeCalloutPatch,
531 pub equation: ThemeEquationPatch,
532 pub page: ThemePagePatch,
533 pub furniture: ThemeFurniturePatch,
534 pub colours: ThemeColoursPatch,
535 pub calibration: ThemeCalibrationPatch,
536}
537
538#[derive(Clone, Debug, Default, PartialEq)]
539pub struct FaceSetPatch {
540 pub body: Option<Vec<String>>,
541 pub emphasis: Option<Option<String>>,
542 pub mono: Option<Option<String>>,
543}
544
545#[derive(Clone, Debug, Default, PartialEq)]
546pub struct ThemeTextPatch {
547 pub body_size: Option<Sp>,
548 pub leading: Option<Sp>,
549 pub tracking: Option<Sp>,
550 pub ligatures: Option<bool>,
551 pub hyphenate: Option<bool>,
552 pub justify: Option<bool>,
553 pub fill: Option<Rgba>,
554 pub faces: FaceSetPatch,
555}
556
557#[derive(Clone, Debug, Default, PartialEq)]
558pub struct ThemeParPatch {
559 pub skip: Option<Sp>,
560 pub indent: Option<Sp>,
561}
562
563#[derive(Clone, Debug, Default, PartialEq)]
564pub struct ThemeHeadingLevelPatch {
565 pub size: Option<Sp>,
566 pub face: Option<Option<String>>,
567 pub weight: Option<Option<u16>>,
568 pub italic: Option<bool>,
569 pub smallcaps: Option<bool>,
570 pub numbering: Option<Option<String>>,
571 pub space_above: Option<Sp>,
572 pub space_below: Option<Sp>,
573}
574
575#[derive(Clone, Debug, Default, PartialEq)]
576pub struct ThemeHeadingPatch {
577 pub kind: Option<HeadingStyle>,
578 pub face: Option<Option<String>>,
579 // `set heading(numbering: ...)` applies one pattern across every level, whatever the theme's level
580 // count, so it is a group-level leaf rather than a per-level one.
581 pub numbering_all: Option<Option<String>>,
582 // A `#show heading: set text(size: ...)` with no `level:` predicate sizes every level alike, the way
583 // `numbering_all` numbers them alike; a level-predicated rule uses a per-level `levels` entry instead.
584 pub size_all: Option<Sp>,
585 // A `#show heading: set text(font: ...)` with no `level:` predicate sets every level in the family alike.
586 pub face_all: Option<Option<String>>,
587 // Per-level overrides, index i onto theme level i; shorter than the theme's `levels` leaves the deeper
588 // levels untouched, longer ignores the surplus.
589 pub levels: Vec<ThemeHeadingLevelPatch>,
590}
591
592#[derive(Clone, Debug, Default, PartialEq)]
593pub struct ThemeOpenerPatch {
594 pub chap_num_size: Option<Sp>,
595 pub chap_grid: Option<[Sp; 4]>,
596}
597
598#[derive(Clone, Debug, Default, PartialEq)]
599pub struct ThemeListPatch {
600 pub marker_gap: Option<Sp>,
601 pub item_skip: Option<Option<Sp>>, // outer: was the spacing set; inner: the override value (None never occurs from set)
602}
603
604#[derive(Clone, Debug, Default, PartialEq)]
605pub struct ThemeEnumPatch {
606 pub marker_gap: Option<Sp>,
607 pub item_skip: Option<Option<Sp>>, // outer: was the spacing set; inner: the override value
608 pub numbering: Option<Option<String>>,
609}
610
611#[derive(Clone, Debug, Default, PartialEq)]
612pub struct ThemeTablePatch {
613 pub skip: Option<Sp>,
614 pub cell_pad_x: Option<Sp>,
615 pub cell_pad_y: Option<Sp>,
616 pub line_gap: Option<Sp>,
617 pub rule_thin: Option<Sp>,
618 pub rule_thick: Option<Sp>,
619}
620
621#[derive(Clone, Debug, Default, PartialEq)]
622pub struct ThemeFigurePatch {
623 pub caption_size: Option<Sp>,
624 pub skip: Option<Sp>,
625}
626
627#[derive(Clone, Debug, Default, PartialEq)]
628pub struct ThemeCodePatch {
629 pub size: Option<Sp>,
630 pub background: Option<Rgba>,
631}
632
633#[derive(Clone, Debug, Default, PartialEq)]
634pub struct ThemeCalloutPatch {
635 pub fill: Option<Rgba>,
636 pub inset_x: Option<Sp>,
637 pub inset_left: Option<Sp>,
638 pub inset_right: Option<Sp>,
639 pub inset_top: Option<Sp>,
640 pub inset_bot: Option<Sp>,
641 pub radius: Option<Sp>,
642 pub stroke_left_w: Option<Sp>,
643 pub stroke_left_col: Option<Rgba>,
644}
645
646#[derive(Clone, Debug, Default, PartialEq)]
647pub struct ThemeEquationPatch {
648 pub numbering: Option<Option<String>>,
649}
650
651#[derive(Clone, Debug, Default, PartialEq)]
652pub struct ThemePageGeomPatch {
653 pub width: Option<Option<Sp>>,
654 pub height: Option<Option<Sp>>,
655 pub margin_inside: Option<Option<Sp>>,
656 pub margin_outside: Option<Option<Sp>>,
657 pub margin_top: Option<Option<Sp>>,
658 pub margin_bottom: Option<Option<Sp>>,
659}
660
661#[derive(Clone, Debug, Default, PartialEq)]
662pub struct ThemePagePartPatch {
663 pub default: ThemePageGeomPatch,
664 pub recto: ThemePageGeomPatch,
665 pub verso: ThemePageGeomPatch,
666 pub opener: ThemePageGeomPatch,
667}
668
669#[derive(Clone, Debug, Default, PartialEq)]
670pub struct ThemePagePatch {
671 pub front: ThemePagePartPatch,
672 pub body: ThemePagePartPatch,
673 pub back: ThemePagePartPatch,
674 pub columns: Option<usize>,
675 pub column_gutter: Option<Length>,
676}
677
678#[derive(Clone, Debug, Default, PartialEq)]
679pub struct ThemeFurniturePatch {
680 pub header_size: Option<Sp>,
681 pub folio_size: Option<Sp>,
682 pub foot_size: Option<Sp>,
683 pub foot_leading: Option<Sp>,
684}
685
686#[derive(Clone, Debug, Default, PartialEq)]
687pub struct ThemeColoursPatch {
688 pub chap_num_grey: Option<Rgba>,
689 pub header_fill: Option<Rgba>,
690}
691
692#[derive(Clone, Debug, Default, PartialEq)]
693pub struct ThemeCalibrationPatch {
694 pub line_box_em: Option<f64>,
695}
696
697// Folds one patch leaf onto its theme field: a `Some` overwrites, a `None` leaves the field as it stands.
698// The clone covers the non-`Copy` leaves (a face name, a numbering pattern); a `Copy` leaf clones for free.
699macro_rules! patch_merge {
700 ($from:expr, $to:expr) => {
701 if let Some(v) = &$from {
702 $to = v.clone();
703 }
704 };
705}
706
707impl Theme {
708 /// Folds `patch` onto this theme in place: every field the patch names is overwritten, every field it
709 /// leaves `None` stands. Applying an empty patch is a no-op, so a scope that declares nothing changes
710 /// nothing -- which is what keeps a document with no `#set` byte-identical.
711 pub fn apply(&mut self, patch: &ThemePatch) {
712 patch.text.apply(&mut self.text);
713 patch.par.apply(&mut self.par);
714 patch.heading.apply(&mut self.heading);
715 patch.opener.apply(&mut self.opener);
716 patch.list.apply(&mut self.list);
717 patch.enumeration.apply(&mut self.enumeration);
718 patch.table.apply(&mut self.table);
719 patch.figure.apply(&mut self.figure);
720 patch.code.apply(&mut self.code);
721 patch.callout.apply(&mut self.callout);
722 patch.equation.apply(&mut self.equation);
723 patch.page.apply(&mut self.page);
724 patch.furniture.apply(&mut self.furniture);
725 patch.colours.apply(&mut self.colours);
726 patch.calibration.apply(&mut self.calibration);
727 }
728}
729
730impl FaceSetPatch {
731 fn apply(&self, t: &mut FaceSet) {
732 patch_merge!(self.body, t.body);
733 patch_merge!(self.emphasis, t.emphasis);
734 patch_merge!(self.mono, t.mono);
735 }
736}
737
738impl ThemeTextPatch {
739 fn apply(&self, t: &mut ThemeText) {
740 patch_merge!(self.body_size, t.body_size);
741 patch_merge!(self.leading, t.leading);
742 patch_merge!(self.tracking, t.tracking);
743 patch_merge!(self.ligatures, t.ligatures);
744 patch_merge!(self.hyphenate, t.hyphenate);
745 patch_merge!(self.justify, t.justify);
746 patch_merge!(self.fill, t.fill);
747 self.faces.apply(&mut t.faces);
748 }
749}
750
751impl ThemeParPatch {
752 fn apply(&self, t: &mut ThemePar) {
753 patch_merge!(self.skip, t.skip);
754 patch_merge!(self.indent, t.indent);
755 }
756}
757
758impl ThemeHeadingLevelPatch {
759 fn apply(&self, l: &mut ThemeHeadingLevel) {
760 patch_merge!(self.size, l.size);
761 patch_merge!(self.face, l.face);
762 patch_merge!(self.weight, l.weight);
763 patch_merge!(self.italic, l.italic);
764 patch_merge!(self.smallcaps, l.smallcaps);
765 patch_merge!(self.numbering, l.numbering);
766 patch_merge!(self.space_above, l.space_above);
767 patch_merge!(self.space_below, l.space_below);
768 }
769}
770
771impl ThemeHeadingPatch {
772 fn apply(&self, h: &mut ThemeHeading) {
773 patch_merge!(self.kind, h.kind);
774 patch_merge!(self.face, h.face);
775 // A uniform numbering pattern applies to every level the theme carries, whatever their count.
776 if let Some(n) = &self.numbering_all {
777 for l in &mut h.levels {
778 l.numbering = n.clone();
779 }
780 }
781 // A uniform size (an unpredicated heading text-size rule) applies to every level alike.
782 if let Some(s) = self.size_all {
783 for l in &mut h.levels {
784 l.size = s;
785 }
786 }
787 // A uniform face likewise, overriding any per-level face set before it.
788 if let Some(f) = &self.face_all {
789 for l in &mut h.levels {
790 l.face = f.clone();
791 }
792 }
793 for (p, l) in self.levels.iter().zip(h.levels.iter_mut()) {
794 p.apply(l);
795 }
796 }
797}
798
799impl ThemeOpenerPatch {
800 fn apply(&self, o: &mut ThemeOpener) {
801 patch_merge!(self.chap_num_size, o.chap_num_size);
802 patch_merge!(self.chap_grid, o.chap_grid);
803 }
804}
805
806impl ThemeListPatch {
807 fn apply(&self, t: &mut ThemeList) {
808 patch_merge!(self.marker_gap, t.marker_gap);
809 patch_merge!(self.item_skip, t.item_skip);
810 }
811}
812
813impl ThemeEnumPatch {
814 fn apply(&self, t: &mut ThemeEnum) {
815 patch_merge!(self.marker_gap, t.marker_gap);
816 patch_merge!(self.item_skip, t.item_skip);
817 patch_merge!(self.numbering, t.numbering);
818 }
819}
820
821impl ThemeTablePatch {
822 fn apply(&self, t: &mut ThemeTable) {
823 patch_merge!(self.skip, t.skip);
824 patch_merge!(self.cell_pad_x, t.cell_pad_x);
825 patch_merge!(self.cell_pad_y, t.cell_pad_y);
826 patch_merge!(self.line_gap, t.line_gap);
827 patch_merge!(self.rule_thin, t.rule_thin);
828 patch_merge!(self.rule_thick, t.rule_thick);
829 }
830}
831
832impl ThemeFigurePatch {
833 fn apply(&self, t: &mut ThemeFigure) {
834 patch_merge!(self.caption_size, t.caption_size);
835 patch_merge!(self.skip, t.skip);
836 }
837}
838
839impl ThemeCodePatch {
840 fn apply(&self, t: &mut ThemeCode) {
841 patch_merge!(self.size, t.size);
842 patch_merge!(self.background, t.background);
843 }
844}
845
846impl ThemeCalloutPatch {
847 fn apply(&self, t: &mut ThemeCallout) {
848 patch_merge!(self.fill, t.fill);
849 // These are already `Option<Sp>` on the theme side (an unset override, not an unset length),
850 // so a named override replaces the option outright rather than unwrapping into it.
851 if self.inset_x.is_some() { t.inset_x = self.inset_x; }
852 if self.inset_left.is_some() { t.inset_left = self.inset_left; }
853 if self.inset_right.is_some() { t.inset_right = self.inset_right; }
854 if self.inset_top.is_some() { t.inset_top = self.inset_top; }
855 if self.inset_bot.is_some() { t.inset_bot = self.inset_bot; }
856 if self.radius.is_some() { t.radius = self.radius; }
857 if self.stroke_left_w.is_some() { t.stroke_left_w = self.stroke_left_w; }
858 if self.stroke_left_col.is_some() { t.stroke_left_col = self.stroke_left_col; }
859 }
860}
861
862impl ThemeEquationPatch {
863 fn apply(&self, t: &mut ThemeEquation) {
864 patch_merge!(self.numbering, t.numbering);
865 }
866}
867
868impl ThemePageGeomPatch {
869 fn apply(&self, t: &mut ThemePageGeom) {
870 patch_merge!(self.width, t.width);
871 patch_merge!(self.height, t.height);
872 patch_merge!(self.margin_inside, t.margin_inside);
873 patch_merge!(self.margin_outside, t.margin_outside);
874 patch_merge!(self.margin_top, t.margin_top);
875 patch_merge!(self.margin_bottom, t.margin_bottom);
876 }
877}
878
879impl ThemePagePartPatch {
880 fn apply(&self, t: &mut ThemePagePart) {
881 self.default.apply(&mut t.default);
882 self.recto.apply(&mut t.recto);
883 self.verso.apply(&mut t.verso);
884 self.opener.apply(&mut t.opener);
885 }
886}
887
888impl ThemePagePatch {
889 fn apply(&self, t: &mut ThemePage) {
890 self.front.apply(&mut t.front);
891 self.body.apply(&mut t.body);
892 self.back.apply(&mut t.back);
893 patch_merge!(self.columns, t.columns);
894 patch_merge!(self.column_gutter, t.column_gutter);
895 }
896}
897
898impl ThemeFurniturePatch {
899 fn apply(&self, t: &mut ThemeFurniture) {
900 patch_merge!(self.header_size, t.header_size);
901 patch_merge!(self.folio_size, t.folio_size);
902 patch_merge!(self.foot_size, t.foot_size);
903 patch_merge!(self.foot_leading, t.foot_leading);
904 }
905}
906
907impl ThemeColoursPatch {
908 fn apply(&self, t: &mut ThemeColours) {
909 patch_merge!(self.chap_num_grey, t.chap_num_grey);
910 patch_merge!(self.header_fill, t.header_fill);
911 }
912}
913
914impl ThemeCalibrationPatch {
915 fn apply(&self, t: &mut ThemeCalibration) {
916 patch_merge!(self.line_box_em, t.line_box_em);
917 }
918}
919
920// ┌───────────────────────────────────────────────────────────────────────────┐
921// │ SERIALISATION HELPERS │
922// └───────────────────────────────────────────────────────────────────────────┘
923
924fn sp_dat(s: Sp) -> Dat { dat!(s.raw()) }
925
926fn sp_from(d: Dat) -> Outcome<Sp> {
927 Ok(Sp(try_extract_dat!(d, I32)))
928}
929
930fn opt_sp_dat(o: Option<Sp>) -> Dat {
931 Dat::Opt(Box::new(o.map(sp_dat)))
932}
933
934fn opt_sp_from(d: Dat) -> Outcome<Option<Sp>> {
935 match d {
936 Dat::Opt(b) => match *b {
937 Some(inner) => Ok(Some(res!(sp_from(inner)))),
938 None => Ok(None),
939 },
940 other => Err(err!("A theme optional length must be a jdat Opt, found a {:?}.", other.kind(); Input, Invalid)),
941 }
942}
943
944fn opt_str_dat(o: &Option<String>) -> Dat {
945 Dat::Opt(Box::new(o.as_ref().map(|s| dat!(s.clone()))))
946}
947
948fn opt_str_from(d: Dat) -> Outcome<Option<String>> {
949 match d {
950 Dat::Opt(b) => match *b {
951 Some(inner) => Ok(Some(try_extract_dat!(inner, Str))),
952 None => Ok(None),
953 },
954 other => Err(err!("A theme optional string must be a jdat Opt, found a {:?}.", other.kind(); Input, Invalid)),
955 }
956}
957
958fn opt_u16_dat(o: Option<u16>) -> Dat {
959 Dat::Opt(Box::new(o.map(|v| dat!(v))))
960}
961
962fn opt_u16_from(d: Dat) -> Outcome<Option<u16>> {
963 match d {
964 Dat::Opt(b) => match *b {
965 Some(inner) => Ok(Some(try_extract_dat!(inner, U16))),
966 None => Ok(None),
967 },
968 other => Err(err!("A theme optional weight must be a jdat Opt, found a {:?}.", other.kind(); Input, Invalid)),
969 }
970}
971
972fn rgba_dat(c: Rgba) -> Dat {
973 omapdat!{
974 "r" => dat!(c.r),
975 "g" => dat!(c.g),
976 "b" => dat!(c.b),
977 "a" => dat!(c.a),
978 }
979}
980
981fn rgba_from(mut d: Dat) -> Outcome<Rgba> {
982 let r = try_extract_dat!(res!(d.map_remove_must(&dat!("r"))), U8);
983 let g = try_extract_dat!(res!(d.map_remove_must(&dat!("g"))), U8);
984 let b = try_extract_dat!(res!(d.map_remove_must(&dat!("b"))), U8);
985 let a = try_extract_dat!(res!(d.map_remove_must(&dat!("a"))), U8);
986 Ok(Rgba::new(r, g, b, a))
987}
988
989fn opt_rgba_dat(o: Option<Rgba>) -> Dat {
990 Dat::Opt(Box::new(o.map(rgba_dat)))
991}
992
993fn opt_rgba_from(d: Dat) -> Outcome<Option<Rgba>> {
994 match d {
995 Dat::Opt(b) => match *b {
996 Some(inner) => Ok(Some(res!(rgba_from(inner)))),
997 None => Ok(None),
998 },
999 other => Err(err!("A theme optional colour must be a jdat Opt, found a {:?}.", other.kind(); Input, Invalid)),
1000 }
1001}
1002
1003fn strs_from(d: Dat) -> Outcome<Vec<String>> {
1004 let list = try_extract_dat!(d, List);
1005 let mut out = Vec::with_capacity(list.len());
1006 for item in list {
1007 out.push(try_extract_dat!(item, Str));
1008 }
1009 Ok(out)
1010}
1011
1012fn sp4_dat(a: &[Sp; 4]) -> Dat {
1013 Dat::List(a.iter().map(|s| sp_dat(*s)).collect())
1014}
1015
1016fn sp4_from(d: Dat) -> Outcome<[Sp; 4]> {
1017 let list = try_extract_dat!(d, List);
1018 if list.len() != 4 {
1019 return Err(err!("A theme length quartet must hold 4 entries, found {}.", list.len(); Input, Invalid));
1020 }
1021 let mut it = list.into_iter();
1022 let a0 = res!(sp_from(res!(it.next().ok_or_else(|| err!("missing quartet entry 0"; Input, Missing)))));
1023 let a1 = res!(sp_from(res!(it.next().ok_or_else(|| err!("missing quartet entry 1"; Input, Missing)))));
1024 let a2 = res!(sp_from(res!(it.next().ok_or_else(|| err!("missing quartet entry 2"; Input, Missing)))));
1025 let a3 = res!(sp_from(res!(it.next().ok_or_else(|| err!("missing quartet entry 3"; Input, Missing)))));
1026 Ok([a0, a1, a2, a3])
1027}
1028
1029fn heading_kind_dat(k: HeadingStyle) -> Dat {
1030 let s = match k {
1031 HeadingStyle::BookOpener => "book-opener",
1032 HeadingStyle::DocBanner => "doc-banner",
1033 HeadingStyle::DocInline => "doc-inline",
1034 HeadingStyle::DocGrid => "doc-grid",
1035 };
1036 dat!(s.to_string())
1037}
1038
1039fn heading_kind_from(d: Dat) -> Outcome<HeadingStyle> {
1040 let s = try_extract_dat!(d, Str);
1041 match s.as_str() {
1042 "book-opener" => Ok(HeadingStyle::BookOpener),
1043 "doc-banner" => Ok(HeadingStyle::DocBanner),
1044 "doc-inline" => Ok(HeadingStyle::DocInline),
1045 "doc-grid" => Ok(HeadingStyle::DocGrid),
1046 other => Err(err!("A heading opener kind must be one of book-opener/doc-banner/doc-inline/doc-grid, found {:?}.", other; Input, Invalid)),
1047 }
1048}
1049
1050fn map_must(d: &mut Dat, key: &str) -> Outcome<Dat> {
1051 d.map_remove_must(&dat!(key.to_string()))
1052}
1053
1054// ┌───────────────────────────────────────────────────────────────────────────┐
1055// │ SUB-STRUCT SERIALISATION │
1056// └───────────────────────────────────────────────────────────────────────────┘
1057
1058impl FaceSet {
1059 fn to_dat(&self) -> Outcome<Dat> {
1060 Ok(omapdat!{
1061 "body" => Dat::List(self.body.iter().map(|f| dat!(f.clone())).collect()),
1062 "emphasis" => opt_str_dat(&self.emphasis),
1063 "mono" => opt_str_dat(&self.mono),
1064 })
1065 }
1066 fn from_dat(mut d: Dat) -> Outcome<Self> {
1067 Ok(Self {
1068 body: res!(strs_from(res!(map_must(&mut d, "body")))),
1069 emphasis: res!(opt_str_from(res!(map_must(&mut d, "emphasis")))),
1070 mono: res!(opt_str_from(res!(map_must(&mut d, "mono")))),
1071 })
1072 }
1073}
1074
1075impl ThemeText {
1076 fn to_dat(&self) -> Outcome<Dat> {
1077 Ok(omapdat!{
1078 "body_size" => sp_dat(self.body_size),
1079 "leading" => sp_dat(self.leading),
1080 "tracking" => sp_dat(self.tracking),
1081 "ligatures" => dat!(self.ligatures),
1082 "hyphenate" => dat!(self.hyphenate),
1083 "justify" => dat!(self.justify),
1084 "fill" => rgba_dat(self.fill),
1085 "faces" => res!(self.faces.to_dat()),
1086 })
1087 }
1088 fn from_dat(mut d: Dat) -> Outcome<Self> {
1089 Ok(Self {
1090 body_size: res!(sp_from(res!(map_must(&mut d, "body_size")))),
1091 leading: res!(sp_from(res!(map_must(&mut d, "leading")))),
1092 tracking: res!(sp_from(res!(map_must(&mut d, "tracking")))),
1093 ligatures: try_extract_dat!(res!(map_must(&mut d, "ligatures")), Bool),
1094 hyphenate: try_extract_dat!(res!(map_must(&mut d, "hyphenate")), Bool),
1095 justify: try_extract_dat!(res!(map_must(&mut d, "justify")), Bool),
1096 fill: res!(rgba_from(res!(map_must(&mut d, "fill")))),
1097 faces: res!(FaceSet::from_dat(res!(map_must(&mut d, "faces")))),
1098 })
1099 }
1100}
1101
1102impl ThemePar {
1103 fn to_dat(&self) -> Outcome<Dat> {
1104 Ok(omapdat!{
1105 "skip" => sp_dat(self.skip),
1106 "indent" => sp_dat(self.indent),
1107 })
1108 }
1109 fn from_dat(mut d: Dat) -> Outcome<Self> {
1110 Ok(Self {
1111 skip: res!(sp_from(res!(map_must(&mut d, "skip")))),
1112 indent: res!(sp_from(res!(map_must(&mut d, "indent")))),
1113 })
1114 }
1115}
1116
1117impl ThemeHeadingLevel {
1118 fn to_dat(&self) -> Outcome<Dat> {
1119 Ok(omapdat!{
1120 "size" => sp_dat(self.size),
1121 "face" => opt_str_dat(&self.face),
1122 "weight" => opt_u16_dat(self.weight),
1123 "italic" => dat!(self.italic),
1124 "smallcaps" => dat!(self.smallcaps),
1125 "numbering" => opt_str_dat(&self.numbering),
1126 "space_above" => sp_dat(self.space_above),
1127 "space_below" => sp_dat(self.space_below),
1128 })
1129 }
1130 fn from_dat(mut d: Dat) -> Outcome<Self> {
1131 Ok(Self {
1132 size: res!(sp_from(res!(map_must(&mut d, "size")))),
1133 face: res!(opt_str_from(res!(map_must(&mut d, "face")))),
1134 weight: res!(opt_u16_from(res!(map_must(&mut d, "weight")))),
1135 italic: try_extract_dat!(res!(map_must(&mut d, "italic")), Bool),
1136 smallcaps: try_extract_dat!(res!(map_must(&mut d, "smallcaps")), Bool),
1137 numbering: res!(opt_str_from(res!(map_must(&mut d, "numbering")))),
1138 space_above: res!(sp_from(res!(map_must(&mut d, "space_above")))),
1139 space_below: res!(sp_from(res!(map_must(&mut d, "space_below")))),
1140 })
1141 }
1142}
1143
1144impl ThemeHeading {
1145 fn to_dat(&self) -> Outcome<Dat> {
1146 let mut levels = Vec::with_capacity(self.levels.len());
1147 for l in &self.levels {
1148 levels.push(res!(l.to_dat()));
1149 }
1150 Ok(omapdat!{
1151 "kind" => heading_kind_dat(self.kind),
1152 "face" => opt_str_dat(&self.face),
1153 "levels" => Dat::List(levels),
1154 })
1155 }
1156 fn from_dat(mut d: Dat) -> Outcome<Self> {
1157 let kind = res!(heading_kind_from(res!(map_must(&mut d, "kind"))));
1158 let face = res!(opt_str_from(res!(map_must(&mut d, "face"))));
1159 let levels_list = try_extract_dat!(res!(map_must(&mut d, "levels")), List);
1160 if levels_list.is_empty() {
1161 return Err(err!("A theme heading must hold at least one level, found none."; Input, Invalid));
1162 }
1163 let mut levels = Vec::with_capacity(levels_list.len());
1164 for entry in levels_list {
1165 levels.push(res!(ThemeHeadingLevel::from_dat(entry)));
1166 }
1167 Ok(Self { kind, face, levels })
1168 }
1169}
1170
1171impl ThemeOpener {
1172 fn to_dat(&self) -> Outcome<Dat> {
1173 Ok(omapdat!{
1174 "chap_num_size" => sp_dat(self.chap_num_size),
1175 "chap_grid" => sp4_dat(&self.chap_grid),
1176 })
1177 }
1178 fn from_dat(mut d: Dat) -> Outcome<Self> {
1179 Ok(Self {
1180 chap_num_size: res!(sp_from(res!(map_must(&mut d, "chap_num_size")))),
1181 chap_grid: res!(sp4_from(res!(map_must(&mut d, "chap_grid")))),
1182 })
1183 }
1184}
1185
1186impl ThemeList {
1187 fn to_dat(&self) -> Outcome<Dat> {
1188 Ok(omapdat!{
1189 "marker_gap" => sp_dat(self.marker_gap),
1190 "item_skip" => opt_sp_dat(self.item_skip),
1191 })
1192 }
1193 fn from_dat(mut d: Dat) -> Outcome<Self> {
1194 Ok(Self {
1195 marker_gap: res!(sp_from(res!(map_must(&mut d, "marker_gap")))),
1196 item_skip: res!(opt_sp_from(res!(map_must(&mut d, "item_skip")))),
1197 })
1198 }
1199}
1200
1201impl ThemeEnum {
1202 fn to_dat(&self) -> Outcome<Dat> {
1203 Ok(omapdat!{
1204 "marker_gap" => sp_dat(self.marker_gap),
1205 "item_skip" => opt_sp_dat(self.item_skip),
1206 "numbering" => opt_str_dat(&self.numbering),
1207 })
1208 }
1209 fn from_dat(mut d: Dat) -> Outcome<Self> {
1210 Ok(Self {
1211 marker_gap: res!(sp_from(res!(map_must(&mut d, "marker_gap")))),
1212 item_skip: res!(opt_sp_from(res!(map_must(&mut d, "item_skip")))),
1213 numbering: res!(opt_str_from(res!(map_must(&mut d, "numbering")))),
1214 })
1215 }
1216}
1217
1218impl ThemeTable {
1219 fn to_dat(&self) -> Outcome<Dat> {
1220 Ok(omapdat!{
1221 "skip" => sp_dat(self.skip),
1222 "cell_pad_x" => sp_dat(self.cell_pad_x),
1223 "cell_pad_y" => sp_dat(self.cell_pad_y),
1224 "line_gap" => sp_dat(self.line_gap),
1225 "rule_thin" => sp_dat(self.rule_thin),
1226 "rule_thick" => sp_dat(self.rule_thick),
1227 })
1228 }
1229 fn from_dat(mut d: Dat) -> Outcome<Self> {
1230 Ok(Self {
1231 skip: res!(sp_from(res!(map_must(&mut d, "skip")))),
1232 cell_pad_x: res!(sp_from(res!(map_must(&mut d, "cell_pad_x")))),
1233 cell_pad_y: res!(sp_from(res!(map_must(&mut d, "cell_pad_y")))),
1234 line_gap: res!(sp_from(res!(map_must(&mut d, "line_gap")))),
1235 rule_thin: res!(sp_from(res!(map_must(&mut d, "rule_thin")))),
1236 rule_thick: res!(sp_from(res!(map_must(&mut d, "rule_thick")))),
1237 })
1238 }
1239}
1240
1241impl ThemeFigure {
1242 fn to_dat(&self) -> Outcome<Dat> {
1243 Ok(omapdat!{
1244 "caption_size" => sp_dat(self.caption_size),
1245 "skip" => sp_dat(self.skip),
1246 })
1247 }
1248 fn from_dat(mut d: Dat) -> Outcome<Self> {
1249 Ok(Self {
1250 caption_size: res!(sp_from(res!(map_must(&mut d, "caption_size")))),
1251 skip: res!(sp_from(res!(map_must(&mut d, "skip")))),
1252 })
1253 }
1254}
1255
1256impl ThemeCode {
1257 fn to_dat(&self) -> Outcome<Dat> {
1258 Ok(omapdat!{
1259 "size" => sp_dat(self.size),
1260 "background" => rgba_dat(self.background),
1261 })
1262 }
1263 fn from_dat(mut d: Dat) -> Outcome<Self> {
1264 Ok(Self {
1265 size: res!(sp_from(res!(map_must(&mut d, "size")))),
1266 background: res!(rgba_from(res!(map_must(&mut d, "background")))),
1267 })
1268 }
1269}
1270
1271impl ThemeCallout {
1272 fn to_dat(&self) -> Outcome<Dat> {
1273 Ok(omapdat!{
1274 "fill" => rgba_dat(self.fill),
1275 "inset_x" => opt_sp_dat(self.inset_x),
1276 "inset_left" => opt_sp_dat(self.inset_left),
1277 "inset_right" => opt_sp_dat(self.inset_right),
1278 "inset_top" => opt_sp_dat(self.inset_top),
1279 "inset_bot" => opt_sp_dat(self.inset_bot),
1280 "radius" => opt_sp_dat(self.radius),
1281 "stroke_left_w" => opt_sp_dat(self.stroke_left_w),
1282 "stroke_left_col" => opt_rgba_dat(self.stroke_left_col),
1283 })
1284 }
1285 fn from_dat(mut d: Dat) -> Outcome<Self> {
1286 Ok(Self {
1287 fill: res!(rgba_from(res!(map_must(&mut d, "fill")))),
1288 inset_x: res!(opt_sp_from(res!(map_must(&mut d, "inset_x")))),
1289 inset_left: res!(opt_sp_from(res!(map_must(&mut d, "inset_left")))),
1290 inset_right: res!(opt_sp_from(res!(map_must(&mut d, "inset_right")))),
1291 inset_top: res!(opt_sp_from(res!(map_must(&mut d, "inset_top")))),
1292 inset_bot: res!(opt_sp_from(res!(map_must(&mut d, "inset_bot")))),
1293 radius: res!(opt_sp_from(res!(map_must(&mut d, "radius")))),
1294 stroke_left_w: res!(opt_sp_from(res!(map_must(&mut d, "stroke_left_w")))),
1295 stroke_left_col: res!(opt_rgba_from(res!(map_must(&mut d, "stroke_left_col")))),
1296 })
1297 }
1298}
1299
1300impl ThemeEquation {
1301 fn to_dat(&self) -> Outcome<Dat> {
1302 Ok(omapdat!{ "numbering" => opt_str_dat(&self.numbering) })
1303 }
1304 fn from_dat(mut d: Dat) -> Outcome<Self> {
1305 Ok(Self { numbering: res!(opt_str_from(res!(map_must(&mut d, "numbering")))) })
1306 }
1307}
1308
1309impl ThemePageGeom {
1310 fn to_dat(&self) -> Outcome<Dat> {
1311 Ok(omapdat!{
1312 "width" => opt_sp_dat(self.width),
1313 "height" => opt_sp_dat(self.height),
1314 "margin_inside" => opt_sp_dat(self.margin_inside),
1315 "margin_outside" => opt_sp_dat(self.margin_outside),
1316 "margin_top" => opt_sp_dat(self.margin_top),
1317 "margin_bottom" => opt_sp_dat(self.margin_bottom),
1318 })
1319 }
1320 fn from_dat(mut d: Dat) -> Outcome<Self> {
1321 Ok(Self {
1322 width: res!(opt_sp_from(res!(map_must(&mut d, "width")))),
1323 height: res!(opt_sp_from(res!(map_must(&mut d, "height")))),
1324 margin_inside: res!(opt_sp_from(res!(map_must(&mut d, "margin_inside")))),
1325 margin_outside: res!(opt_sp_from(res!(map_must(&mut d, "margin_outside")))),
1326 margin_top: res!(opt_sp_from(res!(map_must(&mut d, "margin_top")))),
1327 margin_bottom: res!(opt_sp_from(res!(map_must(&mut d, "margin_bottom")))),
1328 })
1329 }
1330}
1331
1332impl ThemePagePart {
1333 fn to_dat(&self) -> Outcome<Dat> {
1334 Ok(omapdat!{
1335 "default" => res!(self.default.to_dat()),
1336 "recto" => res!(self.recto.to_dat()),
1337 "verso" => res!(self.verso.to_dat()),
1338 "opener" => res!(self.opener.to_dat()),
1339 })
1340 }
1341 fn from_dat(mut d: Dat) -> Outcome<Self> {
1342 Ok(Self {
1343 default: res!(ThemePageGeom::from_dat(res!(map_must(&mut d, "default")))),
1344 recto: res!(ThemePageGeom::from_dat(res!(map_must(&mut d, "recto")))),
1345 verso: res!(ThemePageGeom::from_dat(res!(map_must(&mut d, "verso")))),
1346 opener: res!(ThemePageGeom::from_dat(res!(map_must(&mut d, "opener")))),
1347 })
1348 }
1349}
1350
1351impl ThemePage {
1352 fn to_dat(&self) -> Outcome<Dat> {
1353 let (kind, value) = match self.column_gutter {
1354 Length::Rel(f) => ("rel", f),
1355 Length::Abs(pt) => ("abs", pt),
1356 };
1357 Ok(omapdat!{
1358 "front" => res!(self.front.to_dat()),
1359 "body" => res!(self.body.to_dat()),
1360 "back" => res!(self.back.to_dat()),
1361 "columns" => dat!(self.columns as u64),
1362 "gutter_kind" => dat!(kind.to_string()),
1363 "gutter_bits" => dat!(value.to_bits()),
1364 })
1365 }
1366 fn from_dat(mut d: Dat) -> Outcome<Self> {
1367 let columns = try_extract_dat!(res!(map_must(&mut d, "columns")), U64) as usize;
1368 let kind = try_extract_dat!(res!(map_must(&mut d, "gutter_kind")), Str);
1369 let value = f64::from_bits(try_extract_dat!(res!(map_must(&mut d, "gutter_bits")), U64));
1370 let column_gutter = match kind.as_str() {
1371 "rel" => Length::Rel(value),
1372 "abs" => Length::Abs(value),
1373 other => return Err(err!(
1374 "A theme column gutter must be of kind rel or abs, found {:?}.", other; Input, Invalid)),
1375 };
1376 Ok(Self {
1377 front: res!(ThemePagePart::from_dat(res!(map_must(&mut d, "front")))),
1378 body: res!(ThemePagePart::from_dat(res!(map_must(&mut d, "body")))),
1379 back: res!(ThemePagePart::from_dat(res!(map_must(&mut d, "back")))),
1380 columns,
1381 column_gutter,
1382 })
1383 }
1384}
1385
1386impl ThemeFurniture {
1387 fn to_dat(&self) -> Outcome<Dat> {
1388 Ok(omapdat!{
1389 "header_size" => sp_dat(self.header_size),
1390 "folio_size" => sp_dat(self.folio_size),
1391 "foot_size" => sp_dat(self.foot_size),
1392 "foot_leading" => sp_dat(self.foot_leading),
1393 })
1394 }
1395 fn from_dat(mut d: Dat) -> Outcome<Self> {
1396 Ok(Self {
1397 header_size: res!(sp_from(res!(map_must(&mut d, "header_size")))),
1398 folio_size: res!(sp_from(res!(map_must(&mut d, "folio_size")))),
1399 foot_size: res!(sp_from(res!(map_must(&mut d, "foot_size")))),
1400 foot_leading: res!(sp_from(res!(map_must(&mut d, "foot_leading")))),
1401 })
1402 }
1403}
1404
1405impl ThemeColours {
1406 fn to_dat(&self) -> Outcome<Dat> {
1407 Ok(omapdat!{
1408 "chap_num_grey" => rgba_dat(self.chap_num_grey),
1409 "header_fill" => rgba_dat(self.header_fill),
1410 })
1411 }
1412 fn from_dat(mut d: Dat) -> Outcome<Self> {
1413 Ok(Self {
1414 chap_num_grey: res!(rgba_from(res!(map_must(&mut d, "chap_num_grey")))),
1415 header_fill: res!(rgba_from(res!(map_must(&mut d, "header_fill")))),
1416 })
1417 }
1418}
1419
1420impl ThemeCalibration {
1421 // Serialised as the f64's raw IEEE-754 bits (a `U64`) so the value round-trips bit-for-bit without a
1422 // float dependency, and the canonical byte order the block-address hash later reads is exact.
1423 fn to_dat(&self) -> Outcome<Dat> {
1424 Ok(omapdat!{ "line_box_em_bits" => dat!(self.line_box_em.to_bits()) })
1425 }
1426 fn from_dat(mut d: Dat) -> Outcome<Self> {
1427 let bits = try_extract_dat!(res!(map_must(&mut d, "line_box_em_bits")), U64);
1428 Ok(Self { line_box_em: f64::from_bits(bits) })
1429 }
1430}
1431
1432// ┌───────────────────────────────────────────────────────────────────────────┐
1433// │ TOP-LEVEL SERIALISATION │
1434// └───────────────────────────────────────────────────────────────────────────┘
1435
1436impl ToDat for Theme {
1437 fn to_dat(&self) -> Outcome<Dat> {
1438 Ok(omapdat!{
1439 "text" => res!(self.text.to_dat()),
1440 "par" => res!(self.par.to_dat()),
1441 "heading" => res!(self.heading.to_dat()),
1442 "opener" => res!(self.opener.to_dat()),
1443 "list" => res!(self.list.to_dat()),
1444 "enumeration" => res!(self.enumeration.to_dat()),
1445 "table" => res!(self.table.to_dat()),
1446 "figure" => res!(self.figure.to_dat()),
1447 "code" => res!(self.code.to_dat()),
1448 "callout" => res!(self.callout.to_dat()),
1449 "equation" => res!(self.equation.to_dat()),
1450 "page" => res!(self.page.to_dat()),
1451 "furniture" => res!(self.furniture.to_dat()),
1452 "colours" => res!(self.colours.to_dat()),
1453 "calibration" => res!(self.calibration.to_dat()),
1454 })
1455 }
1456}
1457
1458impl FromDat for Theme {
1459 fn from_dat(mut dat: Dat) -> Outcome<Self> {
1460 if dat.kind() != Kind::OrdMap && dat.kind() != Kind::Map {
1461 return Err(err!(
1462 "A theme must decode from a jdat map, found a {:?}.", dat.kind();
1463 Input, Invalid, Mismatch));
1464 }
1465 Ok(Self {
1466 text: res!(ThemeText::from_dat(res!(map_must(&mut dat, "text")))),
1467 par: res!(ThemePar::from_dat(res!(map_must(&mut dat, "par")))),
1468 heading: res!(ThemeHeading::from_dat(res!(map_must(&mut dat, "heading")))),
1469 opener: res!(ThemeOpener::from_dat(res!(map_must(&mut dat, "opener")))),
1470 list: res!(ThemeList::from_dat(res!(map_must(&mut dat, "list")))),
1471 enumeration: res!(ThemeEnum::from_dat(res!(map_must(&mut dat, "enumeration")))),
1472 table: res!(ThemeTable::from_dat(res!(map_must(&mut dat, "table")))),
1473 figure: res!(ThemeFigure::from_dat(res!(map_must(&mut dat, "figure")))),
1474 code: res!(ThemeCode::from_dat(res!(map_must(&mut dat, "code")))),
1475 callout: res!(ThemeCallout::from_dat(res!(map_must(&mut dat, "callout")))),
1476 equation: res!(ThemeEquation::from_dat(res!(map_must(&mut dat, "equation")))),
1477 page: res!(ThemePage::from_dat(res!(map_must(&mut dat, "page")))),
1478 furniture: res!(ThemeFurniture::from_dat(res!(map_must(&mut dat, "furniture")))),
1479 colours: res!(ThemeColours::from_dat(res!(map_must(&mut dat, "colours")))),
1480 calibration: res!(ThemeCalibration::from_dat(res!(map_must(&mut dat, "calibration")))),
1481 })
1482 }
1483}
1484
1485// ┌───────────────────────────────────────────────────────────────────────────┐
1486// │ PER-GROUP DATICLE (for a block address) │
1487// └───────────────────────────────────────────────────────────────────────────┘
1488
1489/// One named group of a [`Theme`], in canonical order. A rule's block address hashes only the groups the
1490/// rule reads, named by this enum, so a change to a group a block does not read cannot move that block's
1491/// address.
1492#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1493pub enum ThemeGroup {
1494 Text,
1495 Par,
1496 Heading,
1497 Opener,
1498 List,
1499 Enumeration,
1500 Table,
1501 Figure,
1502 Code,
1503 Callout,
1504 Equation,
1505 Page,
1506 Furniture,
1507 Colours,
1508 Calibration,
1509}
1510
1511impl Theme {
1512 /// The daticle of one group, in the group's own canonical field order (see the module header). A block
1513 /// address hashes the bdat encoding of this for each group a rule reads, rather than the whole theme, so
1514 /// an unrelated group's change leaves the address unmoved.
1515 pub fn group_dat(&self, group: ThemeGroup) -> Outcome<Dat> {
1516 match group {
1517 ThemeGroup::Text => self.text.to_dat(),
1518 ThemeGroup::Par => self.par.to_dat(),
1519 ThemeGroup::Heading => self.heading.to_dat(),
1520 ThemeGroup::Opener => self.opener.to_dat(),
1521 ThemeGroup::List => self.list.to_dat(),
1522 ThemeGroup::Enumeration => self.enumeration.to_dat(),
1523 ThemeGroup::Table => self.table.to_dat(),
1524 ThemeGroup::Figure => self.figure.to_dat(),
1525 ThemeGroup::Code => self.code.to_dat(),
1526 ThemeGroup::Callout => self.callout.to_dat(),
1527 ThemeGroup::Equation => self.equation.to_dat(),
1528 ThemeGroup::Page => self.page.to_dat(),
1529 ThemeGroup::Furniture => self.furniture.to_dat(),
1530 ThemeGroup::Colours => self.colours.to_dat(),
1531 ThemeGroup::Calibration => self.calibration.to_dat(),
1532 }
1533 }
1534}
1535
1536#[cfg(test)]
1537mod tests {
1538 use super::*;
1539
1540 /// The default theme survives a jdat round-trip unchanged -- the identity the block-address hash
1541 /// later relies on, and the guard that every field is both written and read.
1542 #[test]
1543 fn theme_round_trips_through_jdat() -> Outcome<()> {
1544 let theme = Theme::default();
1545 let dat = res!(theme.to_dat());
1546 let back = res!(Theme::from_dat(dat));
1547 if theme != back {
1548 return Err(err!("The default theme did not round-trip through jdat: {:?} vs {:?}", theme, back; Test, Mismatch));
1549 }
1550 Ok(())
1551 }
1552
1553 /// A theme with every group nudged off its default still round-trips, so the identity is not an
1554 /// accident of the defaults (an unread field would pass the default test but fail here).
1555 #[test]
1556 fn theme_round_trips_when_populated() -> Outcome<()> {
1557 let mut theme = Theme::default();
1558 theme.text.tracking = Sp::from_pt(0.5);
1559 theme.text.justify = false;
1560 theme.text.faces.body = vec!["Felipa".to_string(), "Libertinus Serif".to_string()];
1561 theme.heading.kind = HeadingStyle::DocInline;
1562 theme.heading.levels[0].numbering = Some("1.1".to_string());
1563 theme.heading.levels[2].weight = Some(700);
1564 theme.opener.chap_num_size = Sp::from_pt(48.0);
1565 theme.equation.numbering = Some("(1)".to_string());
1566 theme.page.body.default.margin_inside = Some(Sp::from_pt(19.0));
1567 theme.page.body.opener.margin_top = Some(Sp::from_pt(40.0));
1568 theme.page.columns = 2;
1569 theme.page.column_gutter = Length::Abs(12.0);
1570 theme.calibration.line_box_em = 0.682;
1571 let dat = res!(theme.to_dat());
1572 let back = res!(Theme::from_dat(dat));
1573 if theme != back {
1574 return Err(err!("A populated theme did not round-trip through jdat."; Test, Mismatch));
1575 }
1576 Ok(())
1577 }
1578
1579 /// A `ThemePatch` folds only the fields it names onto a theme: a `Some` leaf overwrites, a `None` leaf
1580 /// leaves the theme's own value, and an `Option<Option<..>>` leaf can set a face or clear it. An empty
1581 /// patch is a no-op -- the identity the byte-identical gate rests on.
1582 #[test]
1583 fn theme_patch_applies_only_named_fields() {
1584 let mut theme = Theme::default();
1585 let before = theme.clone();
1586
1587 // An empty patch changes nothing.
1588 theme.apply(&ThemePatch::default());
1589 assert_eq!(theme, before);
1590
1591 // A patch naming a handful of fields across groups overwrites exactly those. `numbering_all` folds
1592 // onto every level; a per-level override targets one; the opener and page groups take their own.
1593 let mut patch = ThemePatch::default();
1594 patch.text.body_size = Some(Sp::from_pt(12.0));
1595 patch.text.faces.body = Some(vec!["Libertinus Serif".to_string()]);
1596 patch.par.indent = Some(Sp::from_pt(18.0));
1597 patch.heading.numbering_all = Some(Some("1.1".to_string()));
1598 patch.opener.chap_num_size = Some(Sp::from_pt(48.0));
1599 patch.equation.numbering = Some(Some("(1)".to_string()));
1600 patch.page.body.default.width = Some(Some(Sp::from_pt(400.0)));
1601 theme.apply(&patch);
1602
1603 assert_eq!(theme.text.body_size, Sp::from_pt(12.0));
1604 assert_eq!(theme.text.faces.body, vec!["Libertinus Serif".to_string()]);
1605 assert_eq!(theme.par.indent, Sp::from_pt(18.0));
1606 // numbering_all reached every level, not just the first.
1607 assert_eq!(theme.heading.levels[0].numbering, Some("1.1".to_string()));
1608 assert_eq!(theme.heading.levels[1].numbering, Some("1.1".to_string()));
1609 assert_eq!(theme.opener.chap_num_size, Sp::from_pt(48.0));
1610 assert_eq!(theme.equation.numbering, Some("(1)".to_string()));
1611 assert_eq!(theme.page.body.default.width, Some(Sp::from_pt(400.0)));
1612 // A field the patch did not name kept its default.
1613 assert_eq!(theme.text.leading, Theme::default().text.leading);
1614 assert_eq!(theme.page.body.recto.width, None);
1615
1616 // An `Option<Option<..>>` leaf set to `Some(None)` clears the theme's own value, and an empty family
1617 // list returns the body to the loaded default.
1618 theme.text.faces.mono = Some("Libertinus Mono".to_string());
1619 let mut clear = ThemePatch::default();
1620 clear.text.faces.mono = Some(None);
1621 clear.text.faces.body = Some(Vec::new());
1622 theme.apply(&clear);
1623 assert_eq!(theme.text.faces.mono, None);
1624 assert!(theme.text.faces.body.is_empty());
1625 }
1626
1627 /// `group_dat` returns one group's daticle, so a block address over one group is unmoved by a change
1628 /// to another -- the isolation the per-group hash exists for -- while a change within the group moves it.
1629 #[test]
1630 fn group_dat_isolates_one_group() -> Outcome<()> {
1631 let base = Theme::default();
1632 let heading_dat = res!(base.group_dat(ThemeGroup::Heading));
1633
1634 // A change in an unrelated group leaves the heading group's daticle identical.
1635 let mut other = Theme::default();
1636 other.furniture.foot_size = Sp::from_pt(7.0);
1637 assert_eq!(res!(other.group_dat(ThemeGroup::Heading)), heading_dat,
1638 "a furniture change must not move the heading group's daticle");
1639 assert_ne!(res!(other.group_dat(ThemeGroup::Furniture)), res!(base.group_dat(ThemeGroup::Furniture)),
1640 "but it must move the furniture group's own");
1641
1642 // A change within the heading group moves it.
1643 let mut deeper = Theme::default();
1644 deeper.heading.levels[0].size = Sp::from_pt(20.0);
1645 assert_ne!(res!(deeper.group_dat(ThemeGroup::Heading)), heading_dat);
1646
1647 // The opener geometry is its own group now, so a chapter-number tweak leaves the heading group alone.
1648 let mut op = Theme::default();
1649 op.opener.chap_num_size = Sp::from_pt(60.0);
1650 assert_eq!(res!(op.group_dat(ThemeGroup::Heading)), heading_dat,
1651 "an opener change must not move the heading group's daticle");
1652
1653 // The role-default heading face lives in the heading group now (read for headings), not `text`: a
1654 // change to it moves the heading group's daticle and leaves the text group's untouched, so a heading
1655 // block's read-set is the heading group alone (C4: group contents match what the element consumes).
1656 let text_dat = res!(base.group_dat(ThemeGroup::Text));
1657 let mut hf = Theme::default();
1658 hf.heading.face = Some("Radley".to_string());
1659 assert_ne!(res!(hf.group_dat(ThemeGroup::Heading)), heading_dat,
1660 "a heading face change must move the heading group's daticle");
1661 assert_eq!(res!(hf.group_dat(ThemeGroup::Text)), text_dat,
1662 "a heading face change must not move the text group's daticle");
1663 Ok(())
1664 }
1665}