1//! Style themes: `themes.json` in `.snowbound` keeps a notebook's own themes and which
2//! theme its notebook, sections and pages take. A theme gives OneNote 2010's eleven gallery
3//! styles their formatting; a page wears it as its paragraph style objects, so OneNote draws
4//! the look and keeps the names (`resources/styles.md`). Writers merge as `tags.json`'s do:
5//! themes by id and assignments by scope, the later timestamp winning, then the greater
6//! entry, so writers agree whatever order they read in.
7
8use crate::{Result, session::Storage};
9use onestore::{
10 document::{Format, Kind},
11 page::Definition,
12};
13use serde::{Deserialize, Serialize};
14use std::{collections::BTreeMap, io};
15
16const FILE: &str = ".snowbound/themes.json";
17
18/// OneNote 2010's gallery, in its order: each style's stored name and the name it shows.
19pub const STYLES: [(&str, &str); 11] = [
20 ("h1", "Heading 1"),
21 ("h2", "Heading 2"),
22 ("h3", "Heading 3"),
23 ("h4", "Heading 4"),
24 ("h5", "Heading 5"),
25 ("h6", "Heading 6"),
26 ("PageTitle", "Page Title"),
27 ("cite", "Citation"),
28 ("blockquote", "Quote"),
29 ("code", "Code"),
30 ("p", "Normal"),
31];
32
33/// A style's look in a theme.
34#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
35pub struct ThemeStyle {
36 pub font: String,
37 /// Points, in half points.
38 pub size: f32,
39 pub bold: bool,
40 pub italic: bool,
41 /// `#rrggbb`, or none for automatic. Under `accent`, the accent of a section without a
42 /// colour, which versions that don't know `accent` take.
43 pub color: Option<String>,
44 /// Takes the page's section's accent (the "Theme" colour) in place of `color`.
45 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
46 pub accent: bool,
47 /// Space above and below the paragraph, in points.
48 pub before: f32,
49 pub after: f32,
50}
51
52#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
53pub struct Theme {
54 pub id: String,
55 pub name: String,
56 /// By stored style name; a theme names all eleven.
57 pub styles: BTreeMap<String, ThemeStyle>,
58 /// When it last changed, as a FILETIME; built-in themes have none.
59 #[serde(default)]
60 pub modified: u64,
61 #[serde(default)]
62 pub deleted: bool,
63}
64
65/// What a theme is assigned to.
66#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
67#[serde(rename_all = "lowercase")]
68pub enum Scope {
69 Notebook,
70 /// A section, by its file identity, which survives renames and moves.
71 Section(String),
72 /// A page, by its notebook-management identity.
73 Page(String),
74}
75
76impl Scope {
77 pub fn section(identity: [u8; 16]) -> Self {
78 Self::Section(hex(identity))
79 }
80
81 pub fn page(identity: [u8; 16]) -> Self {
82 Self::Page(hex(identity))
83 }
84}
85
86fn hex(identity: [u8; 16]) -> String {
87 identity.iter().map(|byte| format!("{byte:02x}")).collect()
88}
89
90#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
91pub struct Assignment {
92 pub scope: Scope,
93 /// None takes the scope's override away.
94 pub theme: Option<String>,
95 pub assigned: u64,
96}
97
98/// A notebook's themes file.
99#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
100pub struct Themes {
101 #[serde(default)]
102 pub themes: Vec<Theme>,
103 #[serde(default)]
104 pub assignments: Vec<Assignment>,
105}
106
107impl Themes {
108 /// The built-in themes, then the notebook's own that aren't deleted.
109 pub fn all(&self) -> Vec<Theme> {
110 let mut all = built_in();
111 all.extend(self.themes.iter().filter(|theme| !theme.deleted).cloned());
112 all
113 }
114
115 /// The theme `id` names, retired built-ins too, which pages may still wear.
116 pub fn theme(&self, id: &str) -> Option<Theme> {
117 shipped()
118 .into_iter()
119 .chain(self.themes.iter().filter(|theme| !theme.deleted).cloned())
120 .find(|theme| theme.id == id)
121 }
122
123 /// The theme `scope` names itself, if one it can find.
124 pub fn assigned(&self, scope: &Scope) -> Option<Theme> {
125 let assignment = self.assignments.iter().find(|kept| kept.scope == *scope)?;
126 self.theme(assignment.theme.as_deref()?)
127 }
128
129 /// The theme a page wears: its own, else its section's, else the notebook's.
130 pub fn effective(&self, section: Option<[u8; 16]>, page: Option<[u8; 16]>) -> Option<Theme> {
131 page.map(Scope::page)
132 .into_iter()
133 .chain(section.map(Scope::section))
134 .chain([Scope::Notebook])
135 .find_map(|scope| self.assigned(&scope))
136 }
137}
138
139/// `into` with `other`'s entries merged by the rule the module describes.
140pub fn merge(into: &mut Themes, other: Themes) {
141 fn later<T: Serialize>(new: &T, new_at: u64, kept: &T, kept_at: u64) -> bool {
142 let json = |value: &T| serde_json::to_string(value).unwrap_or_default();
143 (new_at, json(new)) > (kept_at, json(kept))
144 }
145 for theme in other.themes {
146 match into.themes.iter_mut().find(|kept| kept.id == theme.id) {
147 Some(kept) => {
148 if later(&theme, theme.modified, kept, kept.modified) {
149 *kept = theme;
150 }
151 }
152 None => into.themes.push(theme),
153 }
154 }
155 for assignment in other.assignments {
156 match into
157 .assignments
158 .iter_mut()
159 .find(|kept| kept.scope == assignment.scope)
160 {
161 Some(kept) => {
162 if later(&assignment, assignment.assigned, kept, kept.assigned) {
163 *kept = assignment;
164 }
165 }
166 None => into.assignments.push(assignment),
167 }
168 }
169}
170
171/// The themes file: empty without one, and without entries it holds unreadably.
172pub(crate) fn read(storage: &dyn Storage) -> Result<Themes> {
173 let bytes = match storage.read_file(FILE, super::LIMIT) {
174 Err(crate::Error::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
175 return Ok(Themes::default());
176 }
177 bytes => bytes?,
178 };
179 let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
180 let entries = |key: &str| -> Vec<serde_json::Value> {
181 value
182 .get(key)
183 .and_then(|list| list.as_array().cloned())
184 .unwrap_or_default()
185 };
186 let mut themes = Themes::default();
187 merge(
188 &mut themes,
189 Themes {
190 themes: entries("themes")
191 .into_iter()
192 .filter_map(|entry| serde_json::from_value(entry).ok())
193 .filter(|theme: &Theme| !is_built_in(&theme.id))
194 .collect(),
195 assignments: entries("assignments")
196 .into_iter()
197 .filter_map(|entry| serde_json::from_value(entry).ok())
198 .collect(),
199 },
200 );
201 Ok(themes)
202}
203
204/// Merges `change` into the notebook's themes file; returns the file as it then stands.
205pub(crate) fn write(storage: &dyn Storage, change: Themes) -> Result<Themes> {
206 if change.themes.iter().any(|theme| is_built_in(&theme.id)) {
207 return Err(io::Error::from(io::ErrorKind::InvalidInput).into());
208 }
209 match storage.create_directory(super::FOLDER) {
210 Err(crate::Error::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => {}
211 created => created?,
212 }
213 storage.hide(super::FOLDER)?;
214 for _ in 0..3 {
215 let mut merged = read(storage)?;
216 merge(&mut merged, change.clone());
217 let written = super::temporary(FILE);
218 let json = serde_json::to_vec_pretty(&merged).map_err(io::Error::from)?;
219 storage.create(&written, &json)?;
220 storage.replace(&written, FILE)?;
221 let kept = read(storage)?;
222 let mut held = kept.clone();
223 merge(&mut held, change.clone());
224 if held == kept {
225 return Ok(kept);
226 }
227 }
228 Err(io::Error::from(io::ErrorKind::ResourceBusy).into())
229}
230
231/// Whether `id` names a theme Snowbound ships, or once offered.
232pub fn is_built_in(id: &str) -> bool {
233 shipped().iter().any(|theme| theme.id == id)
234}
235
236/// The accent of a section coloured `section` (a COLORREF; none for OneNote's None), as a
237/// COLORREF: its hue at a fixed saturation and lightness, grey staying grey. Pages store it,
238/// so it never changes: versions resolving it apart would restyle each other's pages.
239pub fn accent(section: Option<u32>) -> u32 {
240 // OneNote's blue for a section without a colour, as Snowbound's tabs show one.
241 let [red, green, blue, _] = section.unwrap_or(0x00E4_A88A).to_le_bytes();
242 let [red, green, blue] = [red, green, blue].map(|byte| f32::from(byte) / 255.0);
243 let (max, min) = (red.max(green).max(blue), red.min(green).min(blue));
244 let range = max - min;
245 let sector = if range == 0.0 {
246 0.0
247 } else if max == red {
248 (green - blue) / range
249 } else if max == green {
250 (blue - red) / range + 2.0
251 } else {
252 (red - green) / range + 4.0
253 };
254 let hue = (sector * 60.0).rem_euclid(360.0);
255 let (saturation, lightness) = (if range == 0.0 { 0.0 } else { 0.60 }, 0.45);
256 let chroma = (1.0 - (2.0 * lightness - 1.0f32).abs()) * saturation;
257 let x = chroma * (1.0 - ((hue / 60.0) % 2.0 - 1.0).abs());
258 let [r, g, b] = match hue {
259 h if h < 60.0 => [chroma, x, 0.0],
260 h if h < 120.0 => [x, chroma, 0.0],
261 h if h < 180.0 => [0.0, chroma, x],
262 h if h < 240.0 => [0.0, x, chroma],
263 h if h < 300.0 => [x, 0.0, chroma],
264 _ => [chroma, 0.0, x],
265 };
266 let byte = |value: f32| {
267 ((value + lightness - chroma / 2.0) * 255.0)
268 .round()
269 .clamp(0.0, 255.0) as u32
270 };
271 byte(r) | byte(g) << 8 | byte(b) << 16
272}
273
274/// A COLORREF as `#rrggbb`, as themes keep colours.
275pub fn color_hex(colorref: u32) -> String {
276 let [red, green, blue, _] = colorref.to_le_bytes();
277 format!("#{red:02X}{green:02X}{blue:02X}")
278}
279
280impl ThemeStyle {
281 /// The style's colour as a COLORREF in a section coloured `section`; none for automatic.
282 pub fn colorref(&self, section: Option<u32>) -> Option<u32> {
283 if self.accent {
284 return Some(accent(section));
285 }
286 let rgb = u32::from_str_radix(self.color.as_deref()?.strip_prefix('#')?, 16).ok()?;
287 Some((rgb >> 16) | (rgb & 0xff00) | ((rgb & 0xff) << 16))
288 }
289}
290
291/// Stored style `name`'s paragraph style as `style` gives it in a section coloured `section`:
292/// every character flag set, as OneNote 2010 writes its own, headings followed by Normal.
293pub fn definition(name: &str, style: &ThemeStyle, section: Option<u32>) -> Definition {
294 let color = style.colorref(section).unwrap_or(0xff00_0000);
295 // Paragraph spacing is stored in half inches.
296 let stored = |points: f32| points / 36.0 * 36.0;
297 Definition {
298 kind: Kind::Style {
299 name: Some(name.into()),
300 next: matches!(name, "h1" | "h2" | "h3" | "h4" | "h5" | "h6").then(|| "p".into()),
301 },
302 format: Format {
303 bold: Some(style.bold),
304 italic: Some(style.italic),
305 underline: Some(false),
306 strike: Some(false),
307 superscript: Some(false),
308 subscript: Some(false),
309 font: Some(style.font.clone()),
310 font_size: Some((style.size * 2.0).round() / 2.0),
311 color: Some(color),
312 highlight: Some(0xff00_0000),
313 space_before: Some(stored(style.before)),
314 space_after: Some(stored(style.after)),
315 line_spacing: Some(0.0),
316 ..Format::default()
317 },
318 }
319}
320
321impl Theme {
322 /// The paragraph style definitions the theme gives in a section coloured `section`, by
323 /// stored name.
324 pub fn sheet(&self, section: Option<u32>) -> BTreeMap<String, Definition> {
325 self.styles
326 .iter()
327 .map(|(name, style)| (name.clone(), definition(name, style, section)))
328 .collect()
329 }
330}
331
332/// A style in `font` at `size`, `bold` and `italic`, in `color`, with spacing.
333fn style(
334 font: &str,
335 size: f32,
336 [bold, italic]: [bool; 2],
337 color: Option<&str>,
338 [before, after]: [f32; 2],
339) -> ThemeStyle {
340 ThemeStyle {
341 font: font.into(),
342 size,
343 bold,
344 italic,
345 color: color.map(Into::into),
346 accent: false,
347 before,
348 after,
349 }
350}
351
352const PLAIN: [bool; 2] = [false, false];
353const BOLD: [bool; 2] = [true, false];
354const ITALIC: [bool; 2] = [false, true];
355const BOTH: [bool; 2] = [true, true];
356const NONE: [f32; 2] = [0.0; 2];
357
358/// The theme new notebooks take unless the user chooses another.
359pub const DEFAULT: &str = "modern-3";
360
361/// Built-ins a page wears still but no longer offered, each with the one offered in its
362/// place: a changed look ships under a new id.
363const RETIRED: [(&str, &str); 2] = [("modern", "modern-3"), ("modern-2", "modern-3")];
364
365/// The themes Snowbound offers, OneNote 2010's first.
366pub fn built_in() -> Vec<Theme> {
367 let mut offered = shipped();
368 offered.retain(|theme| RETIRED.iter().all(|(retired, _)| theme.id != *retired));
369 offered
370}
371
372/// The built-in offered in place of theme `id`, as a choice kept for later takes it; `id`
373/// where it isn't retired.
374pub fn successor(id: &str) -> &str {
375 (RETIRED.iter())
376 .find(|(retired, _)| *retired == id)
377 .map_or(id, |(_, successor)| successor)
378}
379
380/// Every theme Snowbound ever shipped. Once shipped a built-in's look never changes: two
381/// versions disagreeing would restyle a page back and forth as each opens it.
382fn shipped() -> Vec<Theme> {
383 let theme = |id: &str, name: &str, styles: [ThemeStyle; 11]| Theme {
384 id: id.into(),
385 name: name.into(),
386 styles: STYLES
387 .iter()
388 .zip(styles)
389 .map(|((stored, _), style)| ((*stored).into(), style))
390 .collect(),
391 modified: 0,
392 deleted: false,
393 };
394 let blue = Some("#366092");
395 let tinted = |style: ThemeStyle| ThemeStyle {
396 color: Some(color_hex(accent(None))),
397 accent: true,
398 ..style
399 };
400 vec![
401 // OneNote 2010's own (lab, 2026-09-30).
402 theme(
403 "onenote",
404 "OneNote",
405 [
406 style("Calibri", 16.0, BOLD, Some("#17365D"), NONE),
407 style("Calibri", 13.0, BOLD, blue, NONE),
408 style("Calibri", 11.0, BOLD, blue, NONE),
409 style("Calibri", 11.0, BOTH, blue, NONE),
410 style("Calibri", 11.0, PLAIN, blue, NONE),
411 style("Calibri", 11.0, ITALIC, blue, NONE),
412 style("Calibri", 17.0, PLAIN, None, NONE),
413 style("Calibri", 9.0, PLAIN, Some("#595959"), NONE),
414 style("Calibri", 11.0, ITALIC, Some("#595959"), NONE),
415 style("Courier New", 11.0, PLAIN, None, NONE),
416 style("Calibri", 11.0, PLAIN, None, NONE),
417 ],
418 ),
419 theme(
420 "manuscript",
421 "Manuscript",
422 [
423 style("Georgia", 20.0, PLAIN, Some("#3B2F2A"), [12.0, 4.0]),
424 style("Georgia", 16.0, BOLD, Some("#3B2F2A"), [10.0, 2.0]),
425 style("Georgia", 13.0, BOLD, Some("#3B2F2A"), [8.0, 2.0]),
426 style("Georgia", 12.0, BOTH, Some("#3B2F2A"), [6.0, 0.0]),
427 style("Georgia", 12.0, PLAIN, Some("#3B2F2A"), [6.0, 0.0]),
428 style("Georgia", 12.0, ITALIC, Some("#3B2F2A"), [6.0, 0.0]),
429 style("Georgia", 26.0, PLAIN, Some("#1F1A17"), NONE),
430 style("Georgia", 9.0, PLAIN, Some("#7A6E66"), NONE),
431 style("Georgia", 12.0, ITALIC, Some("#6B5E55"), [0.0, 4.0]),
432 style("Courier New", 10.5, PLAIN, Some("#2B2B2B"), NONE),
433 style("Georgia", 12.0, PLAIN, Some("#2B2B2B"), [0.0, 4.0]),
434 ],
435 ),
436 theme(
437 "editorial",
438 "Editorial",
439 [
440 style("Georgia", 18.0, BOLD, Some("#9A3B1F"), [12.0, 2.0]),
441 style("Georgia", 14.0, BOLD, Some("#202020"), [10.0, 2.0]),
442 style("Georgia", 12.0, BOLD, Some("#202020"), [8.0, 0.0]),
443 style("Georgia", 11.0, BOTH, Some("#202020"), [6.0, 0.0]),
444 style("Georgia", 11.0, PLAIN, Some("#9A3B1F"), [6.0, 0.0]),
445 style("Georgia", 11.0, ITALIC, Some("#9A3B1F"), [6.0, 0.0]),
446 style("Georgia", 24.0, PLAIN, Some("#202020"), NONE),
447 style("Calibri", 9.0, PLAIN, Some("#707070"), NONE),
448 style("Georgia", 12.0, ITALIC, Some("#555555"), [0.0, 3.0]),
449 style("Courier New", 10.0, PLAIN, Some("#262626"), NONE),
450 style("Calibri", 11.5, PLAIN, Some("#262626"), [0.0, 3.0]),
451 ],
452 ),
453 // OneNote's page title in Arial, its text automatic but for the section's accent.
454 theme(
455 "modern-3",
456 "Modern",
457 [
458 tinted(style("Arial", 16.0, BOLD, None, [10.0, 2.0])),
459 style("Arial", 13.0, BOLD, None, [8.0, 2.0]),
460 style("Arial", 11.0, BOLD, None, [6.0, 0.0]),
461 style("Arial", 10.5, BOTH, None, [6.0, 0.0]),
462 tinted(style("Arial", 10.5, BOLD, None, [6.0, 0.0])),
463 tinted(style("Arial", 10.5, ITALIC, None, [6.0, 0.0])),
464 style("Arial", 17.0, PLAIN, None, NONE),
465 style("Arial", 8.0, PLAIN, None, NONE),
466 style("Arial", 10.5, ITALIC, None, [0.0, 3.0]),
467 style("Courier New", 10.0, PLAIN, None, NONE),
468 style("Arial", 10.5, PLAIN, None, [0.0, 3.0]),
469 ],
470 ),
471 theme(
472 "modern-2",
473 "Modern (coloured text)",
474 [
475 tinted(style("Arial", 16.0, BOLD, None, [10.0, 2.0])),
476 style("Arial", 13.0, BOLD, Some("#1B2631"), [8.0, 2.0]),
477 style("Arial", 11.0, BOLD, Some("#1B2631"), [6.0, 0.0]),
478 style("Arial", 10.5, BOTH, Some("#1B2631"), [6.0, 0.0]),
479 tinted(style("Arial", 10.5, BOLD, None, [6.0, 0.0])),
480 tinted(style("Arial", 10.5, ITALIC, None, [6.0, 0.0])),
481 style("Arial", 17.0, PLAIN, None, NONE),
482 style("Arial", 8.0, PLAIN, Some("#7B868C"), NONE),
483 style("Arial", 10.5, ITALIC, Some("#5F6B73"), [0.0, 3.0]),
484 style("Courier New", 10.0, PLAIN, Some("#1B2631"), NONE),
485 style("Arial", 10.5, PLAIN, Some("#2E2E2E"), [0.0, 3.0]),
486 ],
487 ),
488 theme(
489 "modern",
490 "Modern (original)",
491 [
492 style("Arial", 16.0, BOLD, Some("#0E6E6E"), [10.0, 2.0]),
493 style("Arial", 13.0, BOLD, Some("#1B2631"), [8.0, 2.0]),
494 style("Arial", 11.0, BOLD, Some("#1B2631"), [6.0, 0.0]),
495 style("Arial", 10.5, BOTH, Some("#1B2631"), [6.0, 0.0]),
496 style("Arial", 10.5, BOLD, Some("#0E6E6E"), [6.0, 0.0]),
497 style("Arial", 10.5, ITALIC, Some("#0E6E6E"), [6.0, 0.0]),
498 style("Arial", 22.0, BOLD, Some("#1B2631"), NONE),
499 style("Arial", 8.0, PLAIN, Some("#7B868C"), NONE),
500 style("Arial", 10.5, ITALIC, Some("#5F6B73"), [0.0, 3.0]),
501 style("Courier New", 10.0, PLAIN, Some("#1B2631"), NONE),
502 style("Arial", 10.5, PLAIN, Some("#2E2E2E"), [0.0, 3.0]),
503 ],
504 ),
505 ]
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 /// OneNote 2010's theme is what OneNote writes, as Snowbound reads it back, so a page
513 /// OneNote styled has nothing to restyle under it.
514 #[test]
515 fn the_onenote_theme_is_what_onenote_writes() {
516 let source = include_bytes!("../../../../corpus/styles/onenote/Styles.one");
517 let store = onestore::Store::parse(source).unwrap();
518 let index = onestore::RevisionIndex::parse(&store).unwrap();
519 let document = onestore::document::Document::parse(&index).unwrap();
520 let (space, _) = document.pages().unwrap()[0];
521 let page = onestore::page::Page::from_space(&document, space).unwrap();
522 let sheet = built_in()[0].sheet(None);
523 for (name, definition) in &sheet {
524 assert!(
525 page.definitions.values().any(|stored| stored == definition),
526 "{name}"
527 );
528 }
529 // Only the heading pasted in from a page styled through COM differs.
530 let ops = onestore::op::restyle(&page, &sheet).unwrap();
531 assert_eq!(ops.len(), 1);
532 let onestore::op::PageOp::Restyle { style, into, .. } = &ops[0] else {
533 unreachable!()
534 };
535 assert_eq!(
536 page.definitions[style].format.font.as_deref(),
537 Some("Georgia")
538 );
539 assert_eq!(page.definitions[into], sheet["h1"]);
540 }
541
542 /// OneNote 2010 on a page Snowbound dressed in Manuscript: Ctrl+Alt+2 and Enter add its
543 /// own `h2` and `p` beside the theme's (`corpus/styles/onenote-edit`). Snowbound reads
544 /// them under those names, and restyling heals both: one edit, every style the theme's.
545 #[test]
546 fn styles_onenote_adds_to_a_themed_page_are_healed() {
547 let source = include_bytes!("../../../../corpus/styles/onenote-edit/Manuscript.one");
548 let arena = onestore::Arena::default();
549 let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap();
550 let (space, _, _) = section.pages().unwrap()[0];
551 let page = section.page(space).unwrap();
552 let sheet = built_in()
553 .into_iter()
554 .find(|theme| theme.id == "manuscript")
555 .unwrap()
556 .sheet(None);
557 let calibri: Vec<&str> = page
558 .definitions
559 .values()
560 .filter_map(|stored| match &stored.kind {
561 Kind::Style {
562 name: Some(name), ..
563 } if stored.format.font.as_deref() == Some("Calibri") => Some(name.as_str()),
564 _ => None,
565 })
566 .collect();
567 assert_eq!(calibri, ["h2", "p"]);
568 let ops = onestore::op::restyle(&page, &sheet).unwrap();
569 assert_eq!(ops.len(), 2);
570 let edit = onestore::op::Edit {
571 at: 134_000_000_000_000_000,
572 ops: ops
573 .into_iter()
574 .map(|op| onestore::op::Op::Page { space, op })
575 .collect(),
576 };
577 section.apply("Author", &edit).unwrap();
578 section.seal().unwrap();
579 let healed = onestore::Section::open(&arena, section.image())
580 .unwrap()
581 .page(space)
582 .unwrap();
583 for stored in healed.definitions.values() {
584 if let Kind::Style {
585 name: Some(name), ..
586 } = &stored.kind
587 {
588 assert_eq!(Some(stored), sheet.get(name), "{name}");
589 }
590 }
591 assert!(onestore::op::restyle(&healed, &sheet).unwrap().is_empty());
592 }
593
594 /// The "Theme" colour is the section's hue at the accent's shade, OneNote's blue for a
595 /// section without a colour, and grey for a grey section.
596 #[test]
597 fn a_theme_coloured_style_takes_its_section_s_accent() {
598 assert_eq!(color_hex(accent(None)), "#2E5CB8");
599 assert_eq!(color_hex(accent(Some(0x0000_00FF))), "#B82E2E");
600 assert_eq!(color_hex(accent(Some(0x0080_8080))), "#737373");
601 let style = ThemeStyle {
602 accent: true,
603 ..style("Arial", 16.0, BOLD, Some("#2E5CB8"), NONE)
604 };
605 let red = definition("h1", &style, Some(0x0000_00FF));
606 assert_eq!(red.format.color, Some(0x002E_2EB8));
607 // Versions that don't know the accent read the colour beside it.
608 let json = serde_json::to_value(&style).unwrap();
609 assert_eq!(json["color"], "#2E5CB8");
610 assert_eq!(json["accent"], true);
611 }
612
613 /// Modern's title is OneNote's in Arial, and its text automatic but for the accent. The Moderns shipped
614 /// before are no longer offered, but pages given them keep them, and a default kept for
615 /// new notebooks moves on.
616 #[test]
617 fn modern_keeps_onenote_s_page_title_and_the_first_modern_stays() {
618 let [onenote, modern] = ["onenote", "modern-3"]
619 .map(|id| built_in().into_iter().find(|theme| theme.id == id).unwrap());
620 let title = |theme: &Theme| definition("PageTitle", &theme.styles["PageTitle"], None);
621 let mut expected = title(&onenote);
622 expected.format.font = Some("Arial".into());
623 assert_eq!(title(&modern), expected);
624 for (name, style) in &modern.styles {
625 let accent = matches!(name.as_str(), "h1" | "h5" | "h6");
626 assert_eq!(style.accent, accent, "{name}");
627 assert_eq!(style.colorref(None).is_none(), !accent, "{name}");
628 }
629 assert!(
630 built_in()
631 .iter()
632 .all(|theme| !theme.id.starts_with("modern-2"))
633 );
634 assert!(built_in().iter().all(|theme| theme.id != "modern"));
635 assert_eq!(successor("modern-2"), DEFAULT);
636 assert_eq!(successor("editorial"), "editorial");
637 let themes = Themes {
638 assignments: vec![Assignment {
639 scope: Scope::Notebook,
640 theme: Some("modern".into()),
641 assigned: 1,
642 }],
643 ..Themes::default()
644 };
645 assert!(themes.effective(None, None).unwrap().styles["PageTitle"].bold);
646 assert!(themes.all().iter().all(|theme| theme.id != "modern"));
647 }
648
649 #[test]
650 fn every_built_in_theme_names_every_gallery_style() {
651 for theme in shipped() {
652 let sheet = theme.sheet(None);
653 for (name, _) in STYLES {
654 assert!(sheet.contains_key(name), "{} {name}", theme.id);
655 }
656 }
657 }
658
659 /// A page takes its own theme, else its section's, else the notebook's; a scope naming a
660 /// deleted theme, or none, falls through.
661 #[test]
662 fn a_page_takes_the_nearest_theme() {
663 let (section, page) = ([1; 16], [2; 16]);
664 let assign = |scope, theme: Option<&str>, assigned| Assignment {
665 scope,
666 theme: theme.map(Into::into),
667 assigned,
668 };
669 let mut custom = built_in().remove(1);
670 custom.id = "essay".into();
671 custom.deleted = true;
672 let mut themes = Themes {
673 themes: vec![custom],
674 assignments: vec![assign(Scope::Notebook, Some("modern"), 1)],
675 };
676 let effective = |themes: &Themes| themes.effective(Some(section), Some(page)).unwrap().id;
677 assert_eq!(effective(&themes), "modern");
678 merge(
679 &mut themes,
680 Themes {
681 assignments: vec![
682 assign(Scope::section(section), Some("editorial"), 2),
683 assign(Scope::page(page), Some("essay"), 2),
684 ],
685 ..Default::default()
686 },
687 );
688 assert_eq!(effective(&themes), "editorial");
689 themes.themes[0].deleted = false;
690 assert_eq!(effective(&themes), "essay");
691 merge(
692 &mut themes,
693 Themes {
694 assignments: vec![assign(Scope::page(page), None, 3)],
695 ..Default::default()
696 },
697 );
698 assert_eq!(effective(&themes), "editorial");
699 assert_eq!(themes.effective(None, None).unwrap().id, "modern");
700 }
701
702 /// Writers merging in either order agree: the later entry wins, then the greater.
703 #[test]
704 fn merges_agree_in_either_order() {
705 let theme = |name: &str, modified| {
706 let mut theme = built_in().remove(2);
707 theme.id = "mine".into();
708 theme.name = name.into();
709 theme.modified = modified;
710 theme
711 };
712 let a = Themes {
713 themes: vec![theme("A", 5)],
714 assignments: vec![Assignment {
715 scope: Scope::Notebook,
716 theme: Some("mine".into()),
717 assigned: 7,
718 }],
719 };
720 let b = Themes {
721 themes: vec![theme("B", 5)],
722 assignments: vec![Assignment {
723 scope: Scope::Notebook,
724 theme: None,
725 assigned: 9,
726 }],
727 };
728 let mut ab = a.clone();
729 merge(&mut ab, b.clone());
730 let mut ba = b;
731 merge(&mut ba, a);
732 assert_eq!(ab, ba);
733 assert_eq!(ab.themes[0].name, "B");
734 assert_eq!(ab.assignments[0].theme, None);
735 }
736}