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
@@ -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");@@ -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)),@@ -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.@@ -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]