collective

Browse / github.com/storytold/wordcraft / patch

ODT page background, Page Setup colour controls, and Automatic text on a coloured sheet

ODT page background, Page Setup colour controls, and Automatic text on a coloured sheet ## What Three pieces that make the page's own colour complete: - **ODT**: the page layout's `fo:background-color` is read into `settings.page_color` and written back on export (DOCX `w:background` and the model already did this). - **Page Setup**: the dialog gains a Page Color group (swatch, hex, red/green/blue, No Color); OK applies it with the rest of the page setup in one undo step. - **Automatic text colour** resolves against the sheet: on a dark page `Auto` text is white, as Word and Writer draw it; on a light page it stays black. - **Canvas**: paper fill uses the document's own colour

Status
open
Score
0.00
Adopters
0 other operators
Author
lucasmelo-mike01 (imported from pull request #4441, open)
Base
6a2b520fa49aa0d0595cea36589aaf16ca371cd3
Size
+150 −27 in 4 files
Review
pending
Shared
2026-10-28
Id
b58b3093b3bc70bfa59a9ce8

How it went

No other operator has reported using it yet.

Diff

raw · crates/formats/src/odt.rs crates/layout/src/display.rs crates/ui-egui/src/canvas.rs crates/ui-egui/src/dialogs.rs

diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs--- a/crates/formats/src/odt.rs+++ b/crates/formats/src/odt.rs@@ -841,7 +841,7 @@ fn columns_xml(c: &Columns) -> String {  /// One master page per entry of `pages` (name, setup), each with its own page layout `pm1`, `pm2`… /// (§16.5, §16.9); the first is the default one, "Standard".-fn styles_xml(pages: &[(String, wordcraft_doc::SectionProps)], body_font: &str) -> String {+fn styles_xml(pages: &[(String, wordcraft_doc::SectionProps)], body_font: &str, page_color: Option<Rgb>) -> String {     let mut s = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-styles {NS}><office:styles>");     s.push_str(&format!(         "<style:default-style style:family=\"paragraph\"><style:paragraph-properties fo:margin-bottom=\"8pt\"/><style:text-properties fo:font-family=\"'{}'\" fo:font-size=\"12pt\"/></style:default-style>",@@ -861,11 +861,13 @@ fn styles_xml(pages: &[(String, wordcraft_doc::SectionProps)], body_font: &str)     s.push_str("<style:style style:name=\"Quotations\" style:family=\"paragraph\" style:parent-style-name=\"Standard\" style:class=\"html\"><style:paragraph-properties fo:margin-left=\"36pt\" fo:margin-right=\"36pt\"/><style:text-properties fo:font-style=\"italic\" fo:color=\"#404040\"/></style:style>");     s.push_str(&format!("<style:style style:name=\"Preformatted_20_Text\" style:display-name=\"Preformatted Text\" style:family=\"paragraph\" style:parent-style-name=\"Standard\" style:class=\"html\"><style:paragraph-properties fo:margin-top=\"0pt\" fo:margin-bottom=\"0pt\"/><style:text-properties fo:font-family=\"'{}'\" style:font-family-generic=\"modern\" style:font-pitch=\"fixed\" fo:font-size=\"10pt\"/></style:style>", model::MONO_FONT));     s.push_str("</office:styles><office:automatic-styles>");+    // The page background is a document setting: write it on the page layouts so Writer shows it.+    let bg = page_color.map(|c| format!(" fo:background-color=\"#{}\"", c.hex().to_ascii_lowercase())).unwrap_or_default();     for (i, (_, sec)) in pages.iter().enumerate() {         // A page of several columns carries them in its layout.         let cols = if sec.columns.count > 1 { columns_xml(&sec.columns) } else { String::new() };         s.push_str(&format!(-            "<style:page-layout style:name=\"pm{}\"><style:page-layout-properties fo:page-width=\"{}\" fo:page-height=\"{}\" fo:margin-top=\"{}\" fo:margin-bottom=\"{}\" fo:margin-left=\"{}\" fo:margin-right=\"{}\" style:print-orientation=\"{}\">{cols}</style:page-layout-properties></style:page-layout>",+            "<style:page-layout style:name=\"pm{}\"><style:page-layout-properties fo:page-width=\"{}\" fo:page-height=\"{}\" fo:margin-top=\"{}\" fo:margin-bottom=\"{}\" fo:margin-left=\"{}\" fo:margin-right=\"{}\" style:print-orientation=\"{}\"{}>{cols}</style:page-layout-properties></style:page-layout>",             i + 1,             pt(sec.page_w),             pt(sec.page_h),@@ -873,7 +875,8 @@ fn styles_xml(pages: &[(String, wordcraft_doc::SectionProps)], body_font: &str)             pt(sec.margin_bottom),             pt(sec.margin_left),             pt(sec.margin_right),-            if sec.page_w > sec.page_h { "landscape" } else { "portrait" }+            if sec.page_w > sec.page_h { "landscape" } else { "portrait" },+            bg         ));     }     s.push_str("</office:automatic-styles><office:master-styles>");@@ -987,7 +990,7 @@ pub fn export(doc: &Document) -> Result<Vec<u8>, String> {         w.automatic_styles()     );     let body_font = doc.styles.default_chr.font.clone().unwrap_or_else(|| wordcraft_doc::styles::BODY_FONT.to_string());-    let styles = styles_xml(&pages, &body_font);+    let styles = styles_xml(&pages, &body_font, doc.settings.page_color);     let meta = meta_xml(&flow.meta);     let mut manifest = String::from(         "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest:manifest xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\" manifest:version=\"1.3\">\n<manifest:file-entry manifest:full-path=\"/\" manifest:version=\"1.3\" manifest:media-type=\"application/vnd.oasis.opendocument.text\"/>\n<manifest:file-entry manifest:full-path=\"content.xml\" manifest:media-type=\"text/xml\"/>\n<manifest:file-entry manifest:full-path=\"styles.xml\" manifest:media-type=\"text/xml\"/>\n<manifest:file-entry manifest:full-path=\"meta.xml\" manifest:media-type=\"text/xml\"/>\n",@@ -2582,7 +2585,7 @@ fn parse_full(bytes: &[u8]) -> Result<(Flow, SectionProps), String> { }  /// [`parse_full`], plus whether change tracking is on (`None`: the document has no tracked changes).-fn parse_tracked(bytes: &[u8]) -> Result<(Flow, SectionProps, Option<bool>, Option<String>, page_parts::Parts), String> {+fn parse_tracked(bytes: &[u8]) -> Result<(Flow, SectionProps, Option<bool>, Option<String>, page_parts::Parts, Option<Rgb>), String> {     let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("ODT: not a valid package ({e})"))?;     if let Some(mt) = read_entry(&mut z, "mimetype") {         let mt = String::from_utf8_lossy(&mt);@@ -2622,13 +2625,13 @@ fn parse_tracked(bytes: &[u8]) -> Result<(Flow, SectionProps, Option<bool>, Opti         blocks.push(FBlock::Para(Para::default()));     }     let track = body.track;-    Ok((Flow { blocks, meta }, last, track, styles.doc_font(), parts))+    Ok((Flow { blocks, meta }, last, track, styles.doc_font(), parts, pages.background)) }  /// Parse an `.odt` package into a document. pub fn import(bytes: &[u8]) -> Result<Document, String> {     model::begin_import();-    let (flow, last, track, font, parts) = parse_tracked(bytes)?;+    let (flow, last, track, font, parts, page_color) = parse_tracked(bytes)?;     let mut doc = model::to_doc(&flow);     if font.is_some() {         doc.styles.default_chr.font = font;@@ -2638,6 +2641,7 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {     if let Some(t) = track {         doc.settings.track_changes = t;     }+    doc.settings.page_color = page_color;     Ok(doc) } @@ -2648,6 +2652,9 @@ struct Pages {     masters: HashMap<String, SectionProps>,     first: Option<SectionProps>,     first_name: Option<String>,+    /// The page background of the default master page: a document-wide setting in WordCraft's+    /// model, as it is in the DOCX reader (`w:background`).+    background: Option<Rgb>, }  fn read_pages(xml: &[u8]) -> Pages {@@ -2700,6 +2707,7 @@ fn read_pages(xml: &[u8]) -> Pages {         if pages.first.is_none() {             pages.first = Some(sec.clone());             pages.first_name = Some(name.clone());+            pages.background = get(a, "fo:background-color").and_then(model::parse_color);         }         pages.masters.entry(name).or_insert(sec);     }@@ -2746,6 +2754,23 @@ fn apply_layout(a: &Attrs, sec: &mut SectionProps) { mod tests {     use super::*; +    /// The page background (`fo:background-color` of the page layout) is a document setting, as in+    /// the DOCX reader: the reference Writer document has `#181818`, and WordCraft keeps it.+    #[test]+    fn a_page_background_survives_a_round_trip() {+        let mut d = Document::new();+        d.ensure_nonempty();+        d.settings.page_color = Some(Rgb(0x18, 0x18, 0x18));+        let bytes = super::export(&d).unwrap();+        let back = super::import(&bytes).unwrap();+        assert_eq!(back.settings.page_color, Some(Rgb(0x18, 0x18, 0x18)));++        let mut plain = Document::new();+        plain.ensure_nonempty();+        let plain = super::import(&super::export(&plain).unwrap()).unwrap();+        assert_eq!(plain.settings.page_color, None, "no background, none in the file");+    }+     #[test]     fn escaper_drops_noncharacters() {         assert_eq!(x("a\u{FFFF}b\u{FFFE}c\u{1}d\te"), "abcd\te");diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs--- a/crates/layout/src/display.rs+++ b/crates/layout/src/display.rs@@ -929,9 +929,11 @@ fn lines(         StoryRef::Part(id) => doc.parts.get(&id).and_then(|p| p.body.auto_color),         StoryRef::Body => None,     };+    // Writer's "Automatic" text follows the page: white on a dark page, black on a light one.+    let page_bg = doc.settings.page_color;     let ink = |c: &TextColor, background: Option<Rgb>| match (c, auto) {         (TextColor::Auto, Some(rgb)) => rgb,-        _ => text_color(c, background),+        _ => text_color(c, background.or(page_bg)),     };     for li in l0..l1 {         let Some(line) = pl.lines.get(li) else { continue };@@ -1153,7 +1155,7 @@ fn lines(             // Moved text marked (none) is plain text, its colour setting aside (Word for Mac).             let plain_move = (moved_from && m.move_from_mark == DeleteMark::None) || (moved_to && m.move_to_mark == InsertMark::None);             let color = match (ins, del) {-                _ if plain_move => text_color(&rc.color, rc.shading.or(rc.highlight)),+                _ if plain_move => ink(&rc.color, rc.shading.or(rc.highlight)),                 (_, Some(r)) if moved_from => m.move_from_color.unwrap_or_else(|| author(r)),                 (Some(r), _) if moved_to => m.move_to_color.unwrap_or_else(|| author(r)),                 (Some(r), _) => m.insert_color.unwrap_or_else(|| author(r)),diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs--- a/crates/ui-egui/src/canvas.rs+++ b/crates/ui-egui/src/canvas.rs@@ -1112,14 +1112,6 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {         app.canvas.read_target_page = None;         app.canvas.scroll_request = Some(vec2(page as f32 * step, 0.0));     }-    // View › Switch Modes: dark paper, inverted document colours.-    // In Read Mode the Page color setting is used instead: Inverse draws the dark page, Sepia-    // draws sepia paper and None draws white paper.-    use wordcraft_engine::cmd::view::ReadPageColor;-    let read_mode = app.session.view.read_mode;-    let dark_page = if read_mode { app.session.view.read_page_color == ReadPageColor::Inverse } else { app.session.view.dark_mode };-    let sepia_page = read_mode && app.session.view.read_page_color == ReadPageColor::Sepia;-    let dark_paper = wordcraft_render::DARK_PAPER;     let show_ruler = app.session.view.ruler         && app.session.view.mode == wordcraft_layout::ViewMode::Print         && !app.session.view.read_mode@@ -1420,17 +1412,12 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {             } else {                 painter.with_clip_rect(painter.clip_rect().intersect(band))             };-            // Shadow and paper.+            // Shadow and paper. The paper under the raster is the one the renderer paints+            // (`paper_of`: the document's own colour, Read Mode's paper, or the theme's), so no+            // other colour flashes at the edges while a page or tile is re-rendered.+            let paper_opts = page_render_options(app, dim_body);             painter.rect_filled(visual_sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow);-            let paper = if dark_page {-                Color32::from_gray(dark_paper)-            } else if sepia_page {-                let s = wordcraft_render::SEPIA_PAPER;-                Color32::from_rgb(s.0, s.1, s.2)-            } else {-                Color32::WHITE-            };-            painter.rect_filled(visual_sr, 0.0, paper);+            painter.rect_filled(visual_sr, 0.0, crate::theme::c32(paper_opts.paper_of(&app.session.doc)));             if tiled {                 // Render before painting to keep the frame sharp immediately after an edit or                 // zoom. Tiles are fixed to raster-pixel boundaries, independent of scrolling.diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs--- a/crates/ui-egui/src/dialogs.rs+++ b/crates/ui-egui/src/dialogs.rs@@ -273,6 +273,9 @@ pub enum Dialog {         /// first page (the section's), applied only if changed (#1230).         odd_even: bool,         first_page: bool,+        /// The sheet's own colour (#RRGGBB, or empty for none); `page_color_was` as opened.+        page_color: String,+        page_color_was: String,     },     Link {         url: String,@@ -1334,7 +1337,10 @@ impl Dialog {                 let size = app.session.doc.styles.resolve_char(Some("Normal"), &Default::default()).size;                 let grid = (kind, sp.grid_lines_per_page().min(1000), sp.grid_chars_per_line(size).min(1000));                 let paper = (sp.page_w / crate::options::unit().pt_per_unit(), sp.page_h / crate::options::unit().pt_per_unit());+                let page_color = app.session.doc.settings.page_color.map(|c| c.hex()).unwrap_or_default();                 Dialog::PageSetup {+                    page_color_was: page_color.clone(),+                    page_color,                     paper,                     paper_was: paper,                     paper_custom: false,@@ -1817,6 +1823,49 @@ fn page_paper_group(ui: &mut Ui, paper: &mut (f32, f32), landscape: &mut bool, c     }); } +/// Page Setup › Page Color: the sheet's own colour (the document's `settings.pageColor`), with+/// the palette, a hex field and RGB values; empty means no colour, and the page picture or the+/// theme's paper shows instead. It is a document property, independent of the interface theme.+fn page_color_group(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String) {+    let t = Tokens::get(ui.ctx());+    ui.horizontal(|ui| {+        color_field(ui, theme, value, "No Color", "");+        let c = wordcraft_doc::Rgb::parse(value);+        // Preview: the sheet as it will be painted, and a crossed swatch for no colour.+        let (r, _) = ui.allocate_exact_size(vec2(28.0, 18.0), Sense::hover());+        match c {+            Some(c) => {+                ui.painter().rect_filled(r, 2.0, crate::theme::c32(c));+            }+            None => {+                ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE);+                ui.painter().line_segment([r.left_bottom(), r.right_top()], egui::Stroke::new(1.0, t.text_dim));+            }+        }+        ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside);+    });+    ui.horizontal(|ui| {+        let hex_label = ui.label(tl!("Hex:"));+        ui.label("#");+        ui.add(egui::TextEdit::singleline(value).desired_width(76.0).char_limit(8)).labelled_by(hex_label.id);+        if let Some(c) = wordcraft_doc::Rgb::parse(value) {+            let mut rgb = [c.0, c.1, c.2];+            let mut changed = false;+            for (label, v) in ["Red:", "Green:", "Blue:"].into_iter().zip(rgb.iter_mut()) {+                ui.label(tl!(label));+                changed |= ui.add(egui::DragValue::new(v).range(0..=255).speed(1.0)).changed();+            }+            if changed {+                *value = wordcraft_doc::Rgb(rgb[0], rgb[1], rgb[2]).hex();+            }+        } else {+            // Nothing to show for a half-typed or invalid hex: the sheet keeps no colour until+            // the field parses, and OK stays disabled (see the dialog's OK).+            ui.label(tl!("No Color")).on_hover_text(value.as_str());+        }+    });+}+ /// Cancel key for dialogs: Escape everywhere, plus ⌘. (Command+Period) on macOS. pub(crate) fn cancel_key_pressed(ui: &Ui) -> bool {     ui.input(|i| i.key_pressed(egui::Key::Escape) || (cfg!(target_os = "macos") && i.modifiers.command && i.key_pressed(egui::Key::Period)))@@ -2415,6 +2464,8 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {             valign,             odd_even,             first_page,+            page_color,+            page_color_was,         } => {             ui.label(egui::RichText::new(tl!("Margins")).font(semibold(12.5)));             egui::Grid::new("ps").num_columns(4).show(ui, |ui| {@@ -2443,6 +2494,10 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {             ui.label(egui::RichText::new(tl!("Pages")).font(semibold(12.5)));             ui.checkbox(mirror, tl!("Mirror margins"));             page_paper_group(ui, paper, landscape, paper_custom);+            ui.add_space(2.0);+            ui.label(egui::RichText::new(tl!("Page Color")).font(semibold(12.5)));+            page_color_group(ui, &app.session.doc.settings.theme_colors.clone(), page_color);+            ui.add_space(2.0);             ui.label(egui::RichText::new(tl!("Document Grid")).font(semibold(12.5)));             ui.horizontal_wrapped(|ui| {                 ui.radio_value(&mut grid.0, 0, tl!("No grid"));@@ -2470,7 +2525,9 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {             ui.label(egui::RichText::new(tl!("Headers and footers")).font(semibold(12.5)));             ui.checkbox(odd_even, tl!("Different Odd & Even Pages"));             ui.checkbox(first_page, tl!("Different First Page"));+            let color_ok = page_color.is_empty() || wordcraft_doc::Rgb::parse(page_color).is_some();             let (mut ok, cancel) = buttons(ui, tl!("OK"));+            ok &= color_ok;             if ok {                 // Everything OK changes is one undo step; a refused command applies nothing.                 let result = page_layout_commands(app, (*paper, *paper_was), *landscape, (*top, *bottom, *left, *right))@@ -2503,6 +2560,10 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {                         if *first_page != wordcraft_engine::cmd::page::sect(&app.session).title_page {                             commands.push(("layout.differentFirstPage", json!({"value": *first_page})));                         }+                        if page_color != page_color_was {+                            let c = wordcraft_doc::Rgb::parse(page_color).map(|c| c.hex());+                            commands.push(("design.pageColor", json!({"color": c})));+                        }                         run_as_one_step(app, commands)                     });                 if let Err(e) = result {@@ -5303,6 +5364,54 @@ mod tests {         ((paper.0 * ppu, paper.1 * ppu), paper_entry(paper))     } +    /// Page Setup's Page Color: the sheet's own colour lives in the document (not the theme),+    /// applies on OK, stays in the document on Cancel, and an invalid hex keeps OK from applying.+    #[test]+    fn page_setup_sets_the_pages_own_colour() {+        use egui_kittest::kittest::Queryable;+        let mut h = app_harness(app_with("Page"));+        h.state_mut().run("ui.dialog", json!({"name": "pageSetup"})).unwrap();+        h.run_steps(3);+        assert!(matches!(h.state().dialog, Some(Dialog::PageSetup { ref page_color, .. }) if page_color.is_empty()));+        if let Some(Dialog::PageSetup { page_color, .. }) = h.state_mut().dialog.as_mut() {+            *page_color = "181818".into();+        }+        h.run_steps(2);+        h.get_by_label("OK").click();+        h.run_steps(3);+        assert_eq!(h.state().session.doc.settings.page_color, Some(wordcraft_doc::Rgb(0x18, 0x18, 0x18)), "applied on OK");+        // Reopening shows the document's colour; Cancel leaves it as it was.+        h.state_mut().run("ui.dialog", json!({"name": "pageSetup"})).unwrap();+        h.run_steps(3);+        assert!(matches!(h.state().dialog, Some(Dialog::PageSetup { ref page_color, .. }) if page_color == "181818"));+        if let Some(Dialog::PageSetup { page_color, .. }) = h.state_mut().dialog.as_mut() {+            *page_color = "FFFFFF".into();+        }+        h.run_steps(2);+        h.get_by_label("Cancel").click();+        h.run_steps(3);+        assert_eq!(h.state().session.doc.settings.page_color, Some(wordcraft_doc::Rgb(0x18, 0x18, 0x18)), "Cancel changes nothing");+        // A half-typed hex doesn't apply: the dialog stays open with the document untouched.+        h.state_mut().run("ui.dialog", json!({"name": "pageSetup"})).unwrap();+        h.run_steps(3);+        if let Some(Dialog::PageSetup { page_color, .. }) = h.state_mut().dialog.as_mut() {+            *page_color = "18".into();+        }+        h.run_steps(2);+        h.get_by_label("OK").click();+        h.run_steps(3);+        assert!(h.state().dialog.is_some(), "an invalid colour leaves the dialog open");+        assert_eq!(h.state().session.doc.settings.page_color, Some(wordcraft_doc::Rgb(0x18, 0x18, 0x18)));+        // Clearing it gives the document no page colour again.+        if let Some(Dialog::PageSetup { page_color, .. }) = h.state_mut().dialog.as_mut() {+            page_color.clear();+        }+        h.run_steps(2);+        h.get_by_label("OK").click();+        h.run_steps(3);+        assert_eq!(h.state().session.doc.settings.page_color, None, "No Color restores the default");+    }+     /// #1433: Page Setup's Paper size shows the named size the section has, in either     /// orientation, and Custom size (None) for anything else.     #[test]