collective

Browse / github.com/storytold/wordcraft / patch

Defaults for new documents: styles, page setup and page colour, persisted in ui.json

Defaults for new documents: styles, page setup and page colour, persisted in ui.json ## What A defaults store for new documents, in the engine and on disk: - `wordcraft_doc::defaults::DocumentDefaults`: the styles adopted for new documents (a whole sheet or a set of style ids) and the page setup (size, orientation, margins, mirror, sheet colour). It serializes with `serde`, sanitizes hostile values on load, and is applied by `Document::apply_defaults`. - `Session::defaults`, persisted by the host in `ui.json` beside the other preferences: **new blank documents** (Ctrl+N, File › New, the first document of a window) start from it. An open document, or a file being opened, neve

Status
open
Score
0.00
Adopters
0 other operators
Author
lucasmelo-mike01 (imported from pull request #4449, open)
Base
6a2b520fa49aa0d0595cea36589aaf16ca371cd3
Size
+773 −11 in 13 files
Review
pending
Shared
2026-10-28
Id
d947934577cb1a5e6055eebd

How it went

No other operator has reported using it yet.

Diff

raw · apps/wordcraft/src/main.rs crates/doc/src/defaults.rs crates/doc/src/lib.rs crates/doc/src/section.rs crates/engine/src/cmd/defaults.rs crates/engine/src/cmd/mod.rs crates/engine/src/cmd/page.rs crates/engine/src/cmd/para.rs crates/engine/src/session.rs crates/engine/src/session/documents.rs crates/engine/src/tests.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/window_ui.rs

diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs--- a/apps/wordcraft/src/main.rs+++ b/apps/wordcraft/src/main.rs@@ -466,9 +466,10 @@ fn main() -> eframe::Result {             app.set_startup_update_check(true);             load_prefs(&mut app);             load_user_dictionary(&mut app);-            // A region chosen in File › Options replaces the system's for the first blank-            // document too (#838).-            if !sample && app.session.prefs.region.is_some() {+            // The window's first document is made once the preferences are in: the person's+            // defaults (styles, page setup) and, when chosen, their region's paper (#838) must+            // both be in place.+            if !sample {                 let doc = app.session.blank_document();                 app.session.set_document(doc);             }diff --git a/crates/doc/src/defaults.rs b/crates/doc/src/defaults.rsnew file mode 100644--- /dev/null+++ b/crates/doc/src/defaults.rs@@ -0,0 +1,222 @@+//! Defaults for new documents (File › Options › Defaults): the styles and page setup a person+//! adopts from a document. They live in the app's preferences (`UiState::defaults`, saved in+//! `ui.json`) and are applied by [`Document::apply_defaults`] when a blank document is made —+//! never to a document being opened, which keeps its own styles and page.++use serde::{Deserialize, Serialize};++use crate::Document;+use crate::props::Rgb;+use crate::section::{MAX_PAGE_SIDE, MIN_PAGE_SIDE, SectionProps};+use crate::styles::{Style, StyleSheet};++/// The page setup adopted for new documents.+#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]+#[serde(default, rename_all = "camelCase")]+pub struct PageDefaults {+    pub page_w: f32,+    pub page_h: f32,+    pub landscape: bool,+    pub margin_top: f32,+    pub margin_bottom: f32,+    pub margin_left: f32,+    pub margin_right: f32,+    /// Mirror margins (a document-wide setting, like the page colour).+    pub mirror: bool,+    /// The sheet's own colour; `None` leaves the theme's paper.+    pub page_color: Option<Rgb>,+}++impl Default for PageDefaults {+    fn default() -> Self {+        let s = SectionProps::default();+        PageDefaults {+            page_w: s.page_w,+            page_h: s.page_h,+            landscape: s.landscape,+            margin_top: s.margin_top,+            margin_bottom: s.margin_bottom,+            margin_left: s.margin_left,+            margin_right: s.margin_right,+            mirror: false,+            page_color: None,+        }+    }+}++impl PageDefaults {+    /// The page setup a document's section has (its size, orientation and margins), with the+    /// document's mirror margins and page colour.+    pub fn from_section(s: &SectionProps, mirror: bool, page_color: Option<Rgb>) -> Self {+        PageDefaults {+            page_w: s.page_w,+            page_h: s.page_h,+            landscape: s.landscape,+            margin_top: s.margin_top,+            margin_bottom: s.margin_bottom,+            margin_left: s.margin_left,+            margin_right: s.margin_right,+            mirror,+            page_color,+        }+    }++    /// Keep the values inside the page model's limits: a hand-edited or older `ui.json` must not+    /// put an impossible page into every new document.+    pub fn sanitize(&mut self) {+        let side = |v: f32, fallback: f32| if v.is_finite() { v.clamp(MIN_PAGE_SIDE, MAX_PAGE_SIDE) } else { fallback };+        let default = SectionProps::default();+        self.page_w = side(self.page_w, default.page_w);+        self.page_h = side(self.page_h, default.page_h);+        self.margin_top = self.margin_top.clamp(0.0, self.page_h);+        self.margin_bottom = self.margin_bottom.clamp(0.0, self.page_h);+        self.margin_left = self.margin_left.clamp(0.0, self.page_w);+        self.margin_right = self.margin_right.clamp(0.0, self.page_w);+        // Margins that leave no text room are brought back to the app's margins.+        if self.margin_left + self.margin_right > self.page_w - 18.0 || self.margin_top + self.margin_bottom > self.page_h - 18.0 {+            let d = SectionProps::default();+            self.margin_top = d.margin_top;+            self.margin_bottom = d.margin_bottom;+            self.margin_left = d.margin_left;+            self.margin_right = d.margin_right;+        }+    }++    fn apply(&self, doc: &mut Document) {+        let mut p = *self;+        p.sanitize();+        doc.last_section.page_w = p.page_w;+        doc.last_section.page_h = p.page_h;+        doc.last_section.landscape = p.landscape;+        doc.last_section.margin_top = p.margin_top;+        doc.last_section.margin_bottom = p.margin_bottom;+        doc.last_section.margin_left = p.margin_left;+        doc.last_section.margin_right = p.margin_right;+        // The header/footer distances stay on the page.+        doc.last_section.header = doc.last_section.header.clamp(0.0, p.page_h);+        doc.last_section.footer = doc.last_section.footer.clamp(0.0, p.page_h);+        doc.settings.mirror_margins = p.mirror;+        doc.settings.page_color = p.page_color;+    }+}++/// Defaults for new documents: a whole style sheet (every style adopted), individual style+/// definitions, and the page setup. Empty means the app's own built-in defaults.+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]+#[serde(default, rename_all = "camelCase")]+pub struct DocumentDefaults {+    /// A whole sheet adopted from a document (`adopt all styles`); replaces the built-in sheet.+    pub sheet: Option<StyleSheet>,+    /// Style definitions adopted one by one, merged over the sheet (or the built-in sheet).+    pub styles: Vec<Style>,+    /// The page setup for new documents; `None` keeps the app's regional paper.+    pub page: Option<PageDefaults>,+}++impl DocumentDefaults {+    pub fn is_empty(&self) -> bool {+        self.sheet.is_none() && self.styles.is_empty() && self.page.is_none()+    }++    /// Keep the defaults inside sane limits (a hand-edited or older `ui.json`).+    pub fn sanitize(&mut self) {+        const MAX_STYLES: usize = 5000;+        self.styles.retain(|s| !s.id.is_empty() && s.id.chars().count() <= 200);+        self.styles.truncate(MAX_STYLES);+        if let Some(sheet) = &mut self.sheet {+            sheet.styles.truncate(MAX_STYLES);+        }+        if let Some(page) = &mut self.page {+            page.sanitize();+        }+    }++    /// The style sheet these defaults stand for: the adopted sheet (all styles), or the built-in+    /// sheet with the adopted styles merged over it. The UI edits and previews this.+    pub fn styles_sheet(&self) -> StyleSheet {+        let mut sheet = self.sheet.clone().unwrap_or_default();+        if self.sheet.is_none() {+            for style in &self.styles {+                match sheet.styles.iter_mut().find(|s| s.id == style.id) {+                    Some(slot) => *slot = style.clone(),+                    None => sheet.styles.push(style.clone()),+                }+            }+        }+        sheet+    }++    /// The ids this default defines, in order (the sheet's, or the adopted ones).+    pub fn style_ids(&self) -> Vec<String> {+        match &self.sheet {+            Some(sheet) => sheet.styles.iter().map(|s| s.id.clone()).collect(),+            None => self.styles.iter().map(|s| s.id.clone()).collect(),+        }+    }+}++impl Document {+    /// Start this (new) document from `defaults`: the adopted styles and page setup. An empty+    /// default leaves the document as it is. Existing documents are never passed here.+    pub fn apply_defaults(&mut self, defaults: &DocumentDefaults) {+        let mut d = defaults.clone();+        d.sanitize();+        if let Some(sheet) = d.sheet {+            self.styles = sheet;+        }+        for style in d.styles {+            match self.styles.styles.iter_mut().find(|s| s.id == style.id) {+                Some(slot) => *slot = style,+                None => self.styles.styles.push(style),+            }+        }+        if let Some(page) = d.page {+            page.apply(self);+        }+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn defaults_replace_styles_and_page_of_a_new_document() {+        let mut d = Document::new();+        let mut style = d.styles.styles.iter().find(|s| s.id == "Normal").cloned().unwrap();+        style.chr.size = Some(15.0);+        let mut page = PageDefaults::from_section(&d.last_section, false, Some(Rgb(0x18, 0x18, 0x18)));+        page.margin_top = 40.0;+        page.page_h = 600.0 * 72.0 / 2.54;+        let defaults = DocumentDefaults { sheet: None, styles: vec![style], page: Some(page) };+        d.apply_defaults(&defaults);+        let normal = d.styles.styles.iter().find(|s| s.id == "Normal").unwrap();+        assert_eq!(normal.chr.size, Some(15.0));+        assert_eq!(d.last_section.margin_top, 40.0);+        assert_eq!(d.last_section.page_h, page.page_h);+        assert_eq!(d.settings.page_color, Some(Rgb(0x18, 0x18, 0x18)));+        // Other styles keep the built-in sheet.+        assert!(d.styles.styles.iter().any(|s| s.id == "Heading1"));+    }++    #[test]+    fn an_empty_default_leaves_a_document_alone() {+        let mut d = Document::new();+        let before = d.clone();+        d.apply_defaults(&DocumentDefaults::default());+        assert_eq!(d.styles, before.styles);+        assert_eq!(d.last_section, before.last_section);+    }++    #[test]+    fn junk_defaults_are_brought_back_into_limits() {+        let mut d = Document::new();+        let page = PageDefaults { page_w: f32::NAN, page_h: 1e30, margin_left: -5.0, margin_right: 1e9, ..Default::default() };+        let defaults = DocumentDefaults { sheet: None, styles: vec![], page: Some(page) };+        d.apply_defaults(&defaults);+        assert!(d.last_section.page_w.is_finite() && d.last_section.page_w >= MIN_PAGE_SIDE);+        assert_eq!(d.last_section.page_h, MAX_PAGE_SIDE);+        assert!(d.last_section.margin_left >= 0.0);+        assert!(d.last_section.margin_left + d.last_section.margin_right < d.last_section.page_w);+    }+}diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs--- a/crates/doc/src/lib.rs+++ b/crates/doc/src/lib.rs@@ -19,6 +19,7 @@ pub mod cluster; pub mod collate; pub mod connector; pub mod control;+pub mod defaults; pub mod edit; pub mod effects; pub mod encoding;diff --git a/crates/doc/src/section.rs b/crates/doc/src/section.rs--- a/crates/doc/src/section.rs+++ b/crates/doc/src/section.rs@@ -717,6 +717,15 @@ impl DocGrid {     } } +/// The smallest page side WordCraft accepts, points (0.1", as Word allows).+pub const MIN_PAGE_SIDE: f32 = 7.2;++/// The largest page side WordCraft accepts, points: 600 cm, LibreOffice's limit for a custom page+/// size (Word itself stops at 22 in / 1584 pt). The page stays this size through layout, saving+/// and export; only the PDF writer, capped by the PDF format at 200 in, and Word itself are+/// narrower.+pub const MAX_PAGE_SIDE: f32 = 17_008.0;+ /// Page setup and section-level settings. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")]diff --git a/crates/engine/src/cmd/defaults.rs b/crates/engine/src/cmd/defaults.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/cmd/defaults.rs@@ -0,0 +1,446 @@+//! Defaults for new documents (File › Options › Defaults): adopting the current document's styles+//! or page setup as the starting point of every new document. The values are kept in the app's+//! preferences (`UiState::defaults` in `ui.json`) and used by `Session::blank_document`; an+//! existing document is never touched.++use serde_json::{Value, json};+use wordcraft_doc::defaults::PageDefaults;+use wordcraft_doc::props::Rgb;+use wordcraft_doc::section::SectionProps;++use super::page;+use crate::{CmdError, CmdResult, CommandSpec, Session, p};++pub fn specs() -> Vec<CommandSpec> {+    vec![+        CommandSpec::new("defaults.state", "Defaults", "File › Options › Defaults", state)+            .params(r#"{} → {"styles": {"all": bool, "ids": [string]}, "page": {…} | null}"#)+            .pure(),+        CommandSpec::new("defaults.saveStyles", "Set as Default", "File › Options › Defaults", save_styles)+            .params(r#"{"what": "all" | [style id]}"#),+        CommandSpec::new("defaults.savePage", "Set as Default", "File › Options › Defaults", save_page)+            .params(r#"{}"#)+            .pure(),+        CommandSpec::new("defaults.savePageValues", "Set as Default", "File › Options › Defaults", save_page_values)+            .params(r#"{"pageW"?: pt, "pageH"?: pt, "landscape"?: bool, "marginTop"?: pt, "marginBottom"?: pt, "marginLeft"?: pt, "marginRight"?: pt, "mirror"?: bool, "color"?: "RRGGBB" | null}"#),+        CommandSpec::new("defaults.saveStyle", "Set as Default in the App", "File › Options › Defaults", save_style)+            .params(r#"{"style": string, "name"?: string, "next"?: string, "chr"?: CharProps, "para"?: ParaProps, "apply"?: bool (also apply it to the open document, for the Modify Style dialog's button)}"#),+        CommandSpec::new("defaults.resetStyle", "Reset", "File › Options › Defaults", reset_style).params(r#"{"style": string}"#),+        CommandSpec::new("defaults.reset", "Reset", "File › Options › Defaults", reset)+            .params(r#"{"what": "all" | "view" | "styles" | "page"}"#),+    ]+}++/// What the defaults hold, as agents and the Options dialog read it: the adopted styles (all, or+/// their ids) and the page setup.+fn state(s: &mut Session, _: &Value) -> CmdResult {+    Ok(describe(&s.defaults, &s.view))+}++fn describe(d: &wordcraft_doc::defaults::DocumentDefaults, view: &crate::ViewState) -> Value {+    let page = d.page.map(|p| {+        json!({+            "pageW": p.page_w,+            "pageH": p.page_h,+            "landscape": p.landscape,+            "marginTop": p.margin_top,+            "marginBottom": p.margin_bottom,+            "marginLeft": p.margin_left,+            "marginRight": p.margin_right,+            "mirror": p.mirror,+            "color": p.page_color.map(|c| c.hex()),+        })+    });+    json!({+        "styles": {"all": d.sheet.is_some(), "ids": d.style_ids()},+        "page": page,+        "view": {"ruler": view.ruler, "gridlines": view.gridlines, "tableGridlines": view.table_gridlines, "marks": view.marks},+    })+}++fn save_styles(s: &mut Session, v: &Value) -> CmdResult {+    let what = v.get("what").ok_or_else(|| CmdError::Params("`what` (\"all\" or style ids) is required".into()))?;+    match what {+        Value::String(name) if name == "all" => {+            // The whole sheet, as the document has it (built-in and custom styles).+            s.defaults.sheet = Some(s.doc.styles.clone());+            s.defaults.styles.clear();+        }+        Value::Array(ids) => {+            if ids.is_empty() {+                return Err(CmdError::Params("`what` lists no styles".into()));+            }+            if ids.len() > 500 {+                return Err(CmdError::Params("`what` lists more than 500 styles".into()));+            }+            // Validate the whole list first: a bad id must not leave the earlier ones adopted.+            let mut found = Vec::with_capacity(ids.len());+            for id in ids {+                let Some(id) = id.as_str() else {+                    return Err(CmdError::Params("`what` must hold style ids".into()));+                };+                let Some(style) = s.doc.styles.styles.iter().find(|st| st.id == id).cloned() else {+                    return Err(CmdError::Params(format!("unknown style `{id}`")));+                };+                found.push(style);+            }+            for style in found {+                let id = style.id.clone();+                // With a whole sheet adopted, the sheet is the source of truth: update it there,+                // so the two lists never disagree.+                if let Some(sheet) = &mut s.defaults.sheet {+                    match sheet.styles.iter_mut().find(|st| st.id == id) {+                        Some(slot) => *slot = style,+                        None => sheet.styles.push(style),+                    }+                } else {+                    match s.defaults.styles.iter_mut().find(|st| st.id == id) {+                        Some(slot) => *slot = style,+                        None => s.defaults.styles.push(style),+                    }+                }+            }+        }+        _ => return Err(CmdError::Params("`what` must be \"all\" or a list of style ids".into())),+    }+    s.defaults.sanitize();+    Ok(describe(&s.defaults, &s.view))+}++/// Adopt the current document's page setup: the caret section's size, orientation and margins,+/// with the document's mirror margins and page colour.+fn save_page(s: &mut Session, _: &Value) -> CmdResult {+    let sect = page::sect(s);+    s.defaults.page = Some(PageDefaults::from_section(&sect, s.doc.settings.mirror_margins, s.doc.settings.page_color));+    s.defaults.sanitize();+    Ok(describe(&s.defaults, &s.view))+}++/// Adopt page setup values that are not (yet) the document's: the Page Setup dialog's Set as+/// Default, which must not change the open document.+fn save_page_values(s: &mut Session, v: &Value) -> CmdResult {+    // Start from the defaults already adopted (what the editor shows), not from the open+    // document: fields the caller leaves out keep their defaults, not another document's page.+    let mut sect = match s.defaults.page {+        Some(p) => SectionProps {+            page_w: p.page_w,+            page_h: p.page_h,+            landscape: p.landscape,+            margin_top: p.margin_top,+            margin_bottom: p.margin_bottom,+            margin_left: p.margin_left,+            margin_right: p.margin_right,+            ..SectionProps::default()+        },+        None => page::sect(s),+    };+    for (key, slot) in [+        ("pageW", &mut sect.page_w),+        ("pageH", &mut sect.page_h),+        ("marginTop", &mut sect.margin_top),+        ("marginBottom", &mut sect.margin_bottom),+        ("marginLeft", &mut sect.margin_left),+        ("marginRight", &mut sect.margin_right),+    ] {+        if let Some(n) = v.get(key) {+            if n.is_null() {+                continue;+            }+            *slot = p::req_f32(v, key)?;+        }+    }+    if let Some(land) = v.get("landscape").filter(|b| !b.is_null()) {+        let Some(land) = land.as_bool() else { return Err(CmdError::Params("`landscape` must be true or false".into())) };+        sect.landscape = land;+    }+    let mirror = match v.get("mirror") {+        None | Some(Value::Null) => s.defaults.page.map(|p| p.mirror).unwrap_or(s.doc.settings.mirror_margins),+        Some(Value::Bool(b)) => *b,+        Some(_) => return Err(CmdError::Params("`mirror` must be true or false".into())),+    };+    let color = match v.get("color") {+        None => s.defaults.page.map(|p| p.page_color).unwrap_or(s.doc.settings.page_color),+        Some(Value::Null) => None,+        Some(Value::String(hex)) => Some(Rgb::parse(hex).ok_or_else(|| CmdError::Params("`color` is not a colour (RRGGBB)".into()))?),+        Some(_) => return Err(CmdError::Params("`color` must be a string or null".into())),+    };+    // The same rules as Layout › Page Setup: an impossible page is refused, not stored.+    sect.margin_top = sect.margin_top.max(0.0);+    sect.margin_bottom = sect.margin_bottom.max(0.0);+    sect.margin_left = sect.margin_left.max(0.0);+    sect.margin_right = sect.margin_right.max(0.0);+    page::validate_section(&mut sect)?;+    s.defaults.page = Some(PageDefaults::from_section(&sect, mirror, color));+    s.defaults.sanitize();+    Ok(describe(&s.defaults, &s.view))+}++/// Adopt the definition the Modify Style dialog shows for one style as the default for new+/// documents. With `apply`, the same definition goes to the open document's style too (the+/// dialog's "Set as Default in the App"), so the two never disagree.+fn save_style(s: &mut Session, v: &Value) -> CmdResult {+    let id = p::req_str(v, "style")?;+    let chr: Option<wordcraft_doc::props::CharProps> =+        v.get("chr").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;+    let para: Option<wordcraft_doc::props::ParaProps> =+        v.get("para").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;+    let name = p::str(v, "name");+    let next = p::str(v, "next");+    let apply = p::bool(v, "apply").unwrap_or(false);++    // The definition to adopt: the document's style when it has one (its `basedOn`, next style+    // and kind ride along), else the current default, else the built-in one.+    let base = s+        .doc+        .styles+        .get(id)+        .cloned()+        .or_else(|| s.defaults.styles_sheet().get(id).cloned())+        .ok_or_else(|| CmdError::Params(format!("unknown style `{id}`")))?;+    let mut adopted = base.clone();+    if let Some(c) = &chr {+        adopted.chr.overlay(c);+    }+    if let Some(pp) = &para {+        adopted.para.overlay(pp);+    }+    if let Some(n) = next {+        adopted.next = Some(n.to_string());+    }+    if let Some(n) = name.filter(|n| !n.trim().is_empty()) {+        adopted.name = n.to_string();+    }+    adopted.id = id.to_string();++    // Apply to the open document first: it is the operation that can fail (an unknown style),+    // and the defaults then describe exactly what the document has.+    if apply {+        super::para::overlay_style(s, id, chr.as_ref(), para.as_ref(), name, next)?;+    }+    store_default_style(&mut s.defaults, adopted);+    s.defaults.sanitize();+    Ok(describe(&s.defaults, &s.view))+}++/// Put `style` into the defaults: in the adopted sheet when there is one (it is the source of+/// truth), otherwise among the individually adopted styles.+fn store_default_style(d: &mut wordcraft_doc::defaults::DocumentDefaults, style: wordcraft_doc::styles::Style) {+    if let Some(sheet) = &mut d.sheet {+        match sheet.styles.iter_mut().find(|s| s.id == style.id) {+            Some(slot) => *slot = style,+            None => sheet.styles.push(style),+        }+    } else {+        match d.styles.iter_mut().find(|s| s.id == style.id) {+            Some(slot) => *slot = style,+            None => d.styles.push(style),+        }+    }+}++/// Restore one style in the defaults to the application's own definition (a custom style is+/// simply removed from the defaults).+fn reset_style(s: &mut Session, v: &Value) -> CmdResult {+    let id = p::req_str(v, "style")?;+    let builtin = wordcraft_doc::styles::StyleSheet::builtin().get(id).cloned();+    if let Some(sheet) = &mut s.defaults.sheet {+        match builtin {+            Some(b) => match sheet.styles.iter_mut().find(|s| s.id == id) {+                Some(slot) => *slot = b,+                None => sheet.styles.push(b),+            },+            None => sheet.styles.retain(|s| s.id != id),+        }+    } else {+        s.defaults.styles.retain(|s| s.id != id);+    }+    Ok(describe(&s.defaults, &s.view))+}++fn reset(s: &mut Session, v: &Value) -> CmdResult {+    let what = p::str(v, "what").unwrap_or("all");+    match what {+        "all" | "view" => {+            let d = crate::ViewState::default();+            s.view.ruler = d.ruler;+            s.view.gridlines = d.gridlines;+            s.view.table_gridlines = d.table_gridlines;+            s.view.marks = d.marks;+        }+        "styles" | "page" => {}+        _ => return Err(CmdError::Params("`what` must be all, view, styles or page".into())),+    }+    match what {+        "all" => {+            s.defaults.sheet = None;+            s.defaults.styles.clear();+            s.defaults.page = None;+        }+        "styles" => {+            s.defaults.sheet = None;+            s.defaults.styles.clear();+        }+        "page" => s.defaults.page = None,+        _ => {}+    }+    Ok(describe(&s.defaults, &s.view))+}++#[cfg(test)]+mod tests {+    use super::*;++    fn session() -> Session {+        Session::new(wordcraft_doc::Document::from_text("one"))+    }++    #[test]+    fn adopting_styles_and_page_changes_only_the_defaults() {+        let mut s = session();+        let before = s.doc.clone();+        // One style, by id.+        s.run("defaults.saveStyles", &json!({"what": ["Normal"]})).unwrap();+        assert_eq!(s.defaults.style_ids(), ["Normal"]);+        assert!(s.defaults.sheet.is_none());+        // The whole sheet.+        s.run("defaults.saveStyles", &json!({"what": "all"})).unwrap();+        assert!(s.defaults.sheet.is_some());+        assert_eq!(s.defaults.style_ids().len(), s.doc.styles.styles.len());+        // The page setup, from the document.+        s.run("defaults.savePage", &json!({})).unwrap();+        assert!(s.defaults.page.is_some());+        // None of it touched the open document.+        assert_eq!(s.doc.styles, before.styles);+        assert_eq!(s.doc.last_section, before.last_section);+        // An unknown style is refused.+        assert!(s.run("defaults.saveStyles", &json!({"what": ["NoSuchStyle"]})).is_err());+    }++    #[test]+    fn page_values_from_the_dialog_are_checked() {+        let mut s = session();+        s.run(+            "defaults.savePageValues",+            &json!({"pageW": 600.0, "pageH": 800.0, "marginTop": 40.0, "marginBottom": 40.0, "marginLeft": 40.0, "marginRight": 40.0}),+        )+        .unwrap();+        let p = s.defaults.page.expect("page defaults");+        assert_eq!((p.page_w, p.page_h, p.margin_top), (600.0, 800.0, 40.0));+        // Margins that leave no text room are refused, and the defaults stay as they were.+        assert!(s.run("defaults.savePageValues", &json!({"marginLeft": 590.0, "marginRight": 590.0})).is_err());+        assert_eq!(s.defaults.page.map(|p| p.margin_left), Some(40.0));+        // A colour goes with the page setup.+        s.run("defaults.savePageValues", &json!({"color": "181818"})).unwrap();+        assert_eq!(s.defaults.page.unwrap().page_color, Some(Rgb(0x18, 0x18, 0x18)));+    }++    #[test]+    fn a_new_document_starts_from_the_defaults() {+        let mut s = session();+        // Change the Normal style and the page, adopt both, then make a new document.+        if let Some(normal) = s.doc.styles.styles.iter_mut().find(|st| st.id == "Normal") {+            normal.chr.size = Some(16.0);+        }+        s.run("defaults.saveStyles", &json!({"what": ["Normal"]})).unwrap();+        s.run(+            "defaults.savePageValues",+            &json!({"pageW": 500.0, "pageH": 700.0, "marginTop": 30.0, "marginBottom": 30.0, "marginLeft": 30.0, "marginRight": 30.0, "color": "181818"}),+        )+        .unwrap();+        s.run("file.new", &json!({})).unwrap();+        let normal = s.doc.styles.styles.iter().find(|st| st.id == "Normal").expect("Normal");+        assert_eq!(normal.chr.size, Some(16.0), "the adopted style starts the new document");+        assert_eq!((s.doc.last_section.page_w, s.doc.last_section.page_h), (500.0, 700.0));+        assert_eq!(s.doc.settings.page_color, Some(Rgb(0x18, 0x18, 0x18)));+        // A heading not adopted keeps the built-in definition.+        let h1 = s.doc.styles.styles.iter().find(|st| st.id == "Heading1").expect("Heading 1");+        let builtin = wordcraft_doc::Document::new();+        assert_eq!(h1.chr.size, builtin.styles.styles.iter().find(|st| st.id == "Heading1").unwrap().chr.size);+    }++    #[test]+    fn opening_a_document_keeps_its_own_styles_and_page() {+        let dir = std::env::temp_dir().join(format!("wc-defaults-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let path = dir.join("own.docx");+        // A document with its own Normal size and its own page.+        let mut own = session();+        if let Some(normal) = own.doc.styles.styles.iter_mut().find(|st| st.id == "Normal") {+            normal.chr.size = Some(13.0);+        }+        own.doc.last_section.page_w = 612.0;+        own.run("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();+        // The person now adopts different defaults.+        let mut s = session();+        s.run("defaults.saveStyles", &json!({"what": "all"})).unwrap();+        s.run(+            "defaults.savePageValues",+            &json!({"pageW": 400.0, "pageH": 400.0, "marginTop": 13.0, "marginBottom": 13.0, "marginLeft": 13.0, "marginRight": 13.0}),+        )+        .unwrap();+        s.run("file.open", &json!({"path": path.to_string_lossy()})).unwrap();+        let normal = s.doc.styles.styles.iter().find(|st| st.id == "Normal").expect("Normal");+        assert_eq!(normal.chr.size, Some(13.0), "the file's own style, not the new defaults");+        assert_eq!(s.doc.last_section.page_w, 612.0, "the file's own page");+        let _ = std::fs::remove_dir_all(&dir);+    }++    /// The Modify Style dialog's definition for one style becomes the default: only that style+    /// is adopted, and a new document starts with it (the others keep the built-in sheet).+    #[test]+    fn a_style_saved_from_the_dialog_is_the_new_default() {+        let mut s = session();+        let chr = json!({"font": "Georgia", "size": 15.0, "bold": false, "italic": false, "color": {"Rgb": [0x11, 0x22, 0x33]}});+        s.run("defaults.saveStyle", &json!({"style": "Normal", "chr": chr, "para": {"spaceBefore": 6.0, "spaceAfter": 4.0}})).unwrap();+        assert_eq!(s.defaults.style_ids(), ["Normal"], "only the edited style is adopted");+        s.run("file.new", &json!({})).unwrap();+        let normal = s.doc.styles.get("Normal").expect("Normal");+        assert_eq!(normal.chr.font.as_deref(), Some("Georgia"));+        assert_eq!(normal.chr.size, Some(15.0));+        assert_eq!(normal.para.space_before, Some(6.0));+        assert_eq!(normal.para.space_after, Some(4.0));+        // Heading 1 keeps the application's own definition.+        let builtin = wordcraft_doc::styles::StyleSheet::builtin();+        assert_eq!(s.doc.styles.get("Heading1"), builtin.get("Heading1"));+        // Restoring the style drops the adoption.+        s.run("defaults.resetStyle", &json!({"style": "Normal"})).unwrap();+        assert!(s.defaults.styles.is_empty());+    }++    /// The dialog's "Set as Default in the App" applies the definition to the open document and+    /// adopts it for new documents, leaving the other styles alone.+    #[test]+    fn the_dialogs_button_applies_and_adopts_together() {+        let mut s = session();+        let other = s.doc.styles.get("Heading1").cloned();+        s.run("defaults.saveStyle", &json!({"style": "Normal", "apply": true, "chr": {"font": "Georgia", "size": 15.0}})).unwrap();+        assert_eq!(s.doc.styles.get("Normal").unwrap().chr.font.as_deref(), Some("Georgia"), "the document's style changed");+        assert_eq!(s.doc.styles.get("Heading1").cloned(), other, "the other styles didn't");+        assert_eq!(s.defaults.styles.len(), 1);+        assert_eq!(s.defaults.styles[0].chr.size, Some(15.0));+        // An unknown style is refused and nothing is adopted.+        let before = s.defaults.clone();+        assert!(s.run("defaults.saveStyle", &json!({"style": "NoSuchStyle", "apply": true})).is_err());+        assert_eq!(s.defaults, before);+        // A colour the model can't parse is refused the same way (the dialog only sends colours+        // it managed to parse).+        assert!(s.run("defaults.saveStyle", &json!({"style": "Normal", "chr": {"color": "not-a-colour"}})).is_err());+        assert_eq!(s.defaults, before);+    }++    #[test]+    fn reset_restores_the_app_defaults() {+        let mut s = session();+        s.view.ruler = false;+        s.view.gridlines = true;+        s.run("defaults.saveStyles", &json!({"what": "all"})).unwrap();+        s.run("defaults.savePage", &json!({})).unwrap();+        let d = s.run("defaults.reset", &json!({"what": "all"})).unwrap();+        assert!(s.defaults.is_empty());+        assert!(s.view.ruler && !s.view.gridlines && s.view.table_gridlines && !s.view.marks);+        assert_eq!(d["view"]["ruler"], true);+        // A fresh blank document again has the app's own styles and page.+        let doc = s.blank_document();+        assert_eq!(doc.styles, wordcraft_doc::Document::new().styles);+    }+}diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs--- a/crates/engine/src/cmd/mod.rs+++ b/crates/engine/src/cmd/mod.rs@@ -8,6 +8,7 @@ pub mod citations; pub mod column; pub mod connectors; pub mod controls;+pub mod defaults; pub mod design; pub mod draw; pub mod edit;@@ -76,6 +77,7 @@ pub fn registry() -> Registry {     v.extend(insert::specs());     v.extend(equation::specs());     v.extend(page::specs());+    v.extend(defaults::specs());     v.extend(table::specs());     v.extend(table_style::specs());     v.extend(table_draw::specs());diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs--- a/crates/engine/src/cmd/page.rs+++ b/crates/engine/src/cmd/page.rs@@ -408,7 +408,7 @@ pub fn columns_preset_at(s: &Session) -> Option<&'static str> { /// Checks a whole section handed to `layout.pageSetup` by the rules of Size, Margins, Columns, /// Line Numbers, Footnotes and Page Borders: a non-finite number or margins that leave no room /// for text is an error; the rest is clamped to the ranges those commands use.-fn validate_section(x: &mut SectionProps) -> Result<(), CmdError> {+pub(crate) fn validate_section(x: &mut SectionProps) -> Result<(), CmdError> {     let bad = |what: &str| CmdError::Params(format!("section: {what} must be a finite number"));     let finite = [x.page_w, x.page_h, x.margin_top, x.margin_bottom, x.margin_left, x.margin_right, x.header, x.footer, x.gutter, x.columns.space];     if finite.iter().any(|n| !n.is_finite()) {diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs--- a/crates/engine/src/cmd/para.rs+++ b/crates/engine/src/cmd/para.rs@@ -974,22 +974,37 @@ fn modify_style(s: &mut Session, v: &Value) -> CmdResult {         v.get("para").map(|c| serde_json::from_value(c.clone())).transpose().map_err(|e| CmdError::Params(e.to_string()))?;     let next = p::str(v, "next").map(str::to_string);     let name = p::str(v, "name").map(str::to_string);-    let st = s.doc.styles.get_mut(&id).ok_or_else(|| CmdError::Params("no such style".into()))?;+    overlay_style(s, &id, chr.as_ref(), para.as_ref(), name.as_deref(), next.as_deref())?;+    sel_result(s)+}++/// Apply the Modify Style dialog's properties to a style of the open document: an overlay, so+/// properties the dialog doesn't show (list numbering, borders, the paragraph after…) stay.+/// Shared with the defaults, which adopt the same definition for new documents.+pub(crate) fn overlay_style(+    s: &mut Session,+    id: &str,+    chr: Option<&CharProps>,+    para: Option<&ParaProps>,+    name: Option<&str>,+    next: Option<&str>,+) -> Result<(), CmdError> {+    let st = s.doc.styles.get_mut(id).ok_or_else(|| CmdError::Params("no such style".into()))?;     if let Some(c) = chr {-        st.chr.overlay(&c);+        st.chr.overlay(c);     }     if let Some(pp) = para {-        st.para.overlay(&pp);+        st.para.overlay(pp);     }     if let Some(n) = next {-        st.next = Some(n);+        st.next = Some(n.to_string());     }     if let Some(n) = name.filter(|n| !n.trim().is_empty()) {-        st.name = n;+        st.name = n.to_string();     }     // Every paragraph's layout depends on styles: bump all revisions.     touch_all(s);-    sel_result(s)+    Ok(()) }  /// Update Style to Match Selection: the style takes on how the selection looks — its paragraphdiff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs--- a/crates/engine/src/session.rs+++ b/crates/engine/src/session.rs@@ -451,6 +451,9 @@ pub struct Session {     pub doc: Document,     pub sel: Selection,     pub view: ViewState,+    /// Defaults for new documents (File › Options › Defaults): the styles and page setup the+    /// person adopted. Persisted by the host in `ui.json`; applied only to blank documents.+    pub defaults: wordcraft_doc::defaults::DocumentDefaults,     /// Formatting picked with a collapsed caret, applied to the next typed text.     pub pending: Option<CharProps>,     /// Last highlighter choice for the shortcut; palette state, outside document undo.@@ -895,6 +898,7 @@ impl Session {             doc,             sel,             view,+            defaults: wordcraft_doc::defaults::DocumentDefaults::default(),             pending,             last_highlight: wordcraft_doc::props::Highlight::Yellow,             path,@@ -1607,8 +1611,12 @@ impl Session {      /// A new blank document (the Normal template) on the region's paper: Letter in the US and     /// other Letter regions, A4 elsewhere (#838). Margins stay 2.54 cm (1") all round.+    /// A blank document for File › New (and the window's first document): the app's paper for the+    /// region, then the person's defaults for new documents (styles, page setup, page colour).     pub fn blank_document(&self) -> Document {-        blank_document(&self.region())+        let mut d = blank_document(&self.region());+        d.apply_defaults(&self.defaults);+        d     }      /// Replace the document (open/new).@@ -1675,6 +1683,8 @@ impl Session {             doc,             sel,             view: _,+            // App preferences for future documents, not part of this one's edit state.+            defaults: _,             pending: _,             last_highlight: _,             path: _,diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs--- a/crates/engine/src/session/documents.rs+++ b/crates/engine/src/session/documents.rs@@ -196,6 +196,8 @@ impl Session {             doc: _,             sel: _,             view: _,+            // App preferences for future documents, shared by every open document.+            defaults: _,             pending: _,             path: _,             file_name: _,diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -21,6 +21,49 @@ fn text(s: &Session) -> String {     s.doc.plain_text(StoryRef::Body) } +/// File › Options › Defaults: the style and the page setup adopted for new documents are what a+/// new document starts from, and the open document is never touched.+#[test]+fn adopted_defaults_start_every_new_document() {+    let mut s = s();+    run(&mut s, "defaults.saveStyle", json!({"style": "Normal", "chr": {"font": "Courier New", "size": 11.0}}));+    run(&mut s, "defaults.savePageValues", json!({"pageW": 420.0, "pageH": 595.0, "marginTop": 42.0}));+    let state = run(&mut s, "defaults.state", json!({}));+    assert_eq!(state["styles"]["ids"], json!(["Normal"]));+    assert_eq!(state["page"]["pageW"], json!(420.0));++    let d = s.blank_document();+    assert_eq!((d.last_section.page_w, d.last_section.page_h), (420.0, 595.0), "the adopted page");+    assert_eq!(d.last_section.margin_top, 42.0, "the adopted margin");+    let normal = d.styles.get("Normal").expect("Normal is a built-in");+    assert_eq!((normal.chr.font.as_deref(), normal.chr.size), (Some("Courier New"), Some(11.0)), "the adopted style");+    // A document already open keeps its own page and styles.+    assert_ne!((s.doc.last_section.page_w, s.doc.last_section.page_h), (420.0, 595.0));++    // Reset takes the app's own page back.+    run(&mut s, "defaults.reset", json!({"what": "page"}));+    let d = s.blank_document();+    assert_ne!((d.last_section.page_w, d.last_section.page_h), (420.0, 595.0), "the app's paper again");+}++/// A `defaults.saveStyles` naming an unknown style changes nothing: the ids before the bad one+/// are not adopted either, and a page update keeps the values it does not name.+#[test]+fn a_failed_save_styles_changes_nothing() {+    let mut s = s();+    run(&mut s, "defaults.savePageValues", json!({"pageW": 420.0, "pageH": 595.0, "marginTop": 30.0}));+    let before = run(&mut s, "defaults.state", json!({}));+    let r = s.run("defaults.saveStyles", &json!({"what": ["Normal", "NoSuchStyle"]}));+    assert!(r.is_err(), "the unknown style is refused");+    assert_eq!(run(&mut s, "defaults.state", json!({})), before, "nothing was adopted");++    // A page update builds on what is already adopted (not on another document's page).+    run(&mut s, "defaults.savePageValues", json!({"marginTop": 42.0}));+    let state = run(&mut s, "defaults.state", json!({}));+    assert_eq!(state["page"]["pageW"], json!(420.0), "the size it did not name");+    assert_eq!(state["page"]["marginTop"], json!(42.0), "the margin it did name");+}+ #[test] fn doc_extension_dispatches_to_docbin() {     // Garbage bytes with a .doc name must produce an error through the Word 97-2003diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -314,6 +314,10 @@ pub struct UiState {     /// Headings whose sub-headings the Navigation pane hides (#1997); per session, not saved.     #[serde(skip)]     pub nav_collapsed: std::collections::HashSet<wordcraft_doc::Path>,+    /// Defaults for new documents (File › Options › Defaults): styles and page setup. The session+    /// owns them while running (see [`WordApp::prefs`]); they are never applied to an open+    /// document.+    pub defaults: wordcraft_doc::defaults::DocumentDefaults,     pub show_discord: bool,     /// User name (File › Options) for comments and tracked changes; empty keeps the default.     pub author: String,@@ -437,6 +441,7 @@ impl Default for UiState {             pane_places: Default::default(),             pane_order: Vec::new(),             nav_collapsed: Default::default(),+            defaults: Default::default(),             show_discord: true,             author: String::new(),             window: None,@@ -958,6 +963,7 @@ impl WordApp {         ui.read_aloud_skip_citations = self.session.read_aloud.skip_citations;         ui.dark_page = self.session.view.dark_mode;         ui.nav_pane = self.session.view.nav_pane;+        ui.defaults = self.session.defaults.clone();         ui.styles_pane_dock = self.session.view.styles_pane_dock;         ui.check_spelling = self.session.view.proofing;         ui.keyboard = self.session.keymap.clone();@@ -1007,6 +1013,10 @@ impl WordApp {         self.session.read_aloud.skip_citations = self.ui.read_aloud_skip_citations;         self.session.view.dark_mode = self.ui.dark_page;         self.session.view.nav_pane = self.ui.nav_pane;+        // Defaults for new documents: cleaned up, then owned by the session.+        let mut defaults = std::mem::take(&mut self.ui.defaults);+        defaults.sanitize();+        self.session.defaults = defaults;         self.session.view.styles_pane_dock = self.ui.styles_pane_dock;         self.session.view.proofing = self.ui.check_spelling;         // Keys for commands this version doesn't have are dropped.diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs--- a/crates/ui-egui/src/window_ui.rs+++ b/crates/ui-egui/src/window_ui.rs@@ -275,6 +275,7 @@ impl WordApp {             last_caption_label: _,             ink_pens: _,             initials: _,+            defaults: _,             options_pane: _,             recent_count: _,             mini_toolbar: _,