diff --git a/crates/core/src/color.rs b/crates/core/src/color.rs new file mode 100644 index 000000000..f2ea01fc7 --- /dev/null +++ b/crates/core/src/color.rs @@ -0,0 +1,20 @@ +//! Shared legacy indexed colours used by XLSX and number-format rendering. + +/// The 64-entry legacy indexed colour palette, stored as `RRGGBB`. +pub const INDEXED: [u32; 64] = [ + 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF, 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, + 0xFF00FF, 0x00FFFF, 0x800000, 0x008000, 0x000080, 0x808000, 0x800080, 0x008080, 0xC0C0C0, 0x808080, 0x9999FF, 0x993366, 0xFFFFCC, 0xCCFFFF, + 0x660066, 0xFF8080, 0x0066CC, 0xCCCCFF, 0x000080, 0xFF00FF, 0xFFFF00, 0x00FFFF, 0x800080, 0x800000, 0x008080, 0x0000FF, 0x00CCFF, 0xCCFFFF, + 0xCCFFCC, 0xFFFF99, 0x99CCFF, 0xFF99CC, 0xCC99FF, 0xFFCC99, 0x3366FF, 0x33CCCC, 0x99CC00, 0xFFCC00, 0xFF9900, 0xFF6600, 0x666699, 0x969696, + 0x003366, 0x339966, 0x003300, 0x333300, 0x993300, 0x993366, 0x333399, 0x333333, +]; + +/// Resolve an Excel number-format color index (`Color1` through `Color56`). +pub fn indexed_color(index: u8) -> Option<[u8; 3]> { + if !(1..=56).contains(&index) { + return None; + } + let slot = index.checked_add(7)?; + let rgb = *INDEXED.get(slot as usize)?; + Some([(rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8]) +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index d5adbc07e..6ceba5096 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -4,6 +4,7 @@ #![forbid(unsafe_code)] pub mod addr; +pub mod color; pub mod date; pub mod parse; pub mod value; diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index a10f19d46..f35455386 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -604,20 +604,6 @@ fn rgb_of(c: Color, wb: &Workbook) -> Option { c.resolve(&wb.theme) } -fn numfmt_rgb(c: gridcraft_numfmt::FormatColor) -> Rgb { - use gridcraft_numfmt::FormatColor as F; - match c { - F::Black | F::Indexed(_) => [0, 0, 0], - F::Blue => [0, 0, 255], - F::Cyan => [0, 255, 255], - F::Green => [0, 128, 0], - F::Magenta => [255, 0, 255], - F::Red => [255, 0, 0], - F::White => [255, 255, 255], - F::Yellow => [255, 255, 0], - } -} - fn blend(fg: [u8; 4]) -> Rgb { let a = fg[3] as u16; let mix = |c: u8| ((c as u16 * a + 255 * (255 - a)) / 255) as u8; @@ -1061,7 +1047,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3 let avail = (w - 2.0 * pad - indent).max(0.0); let code = st.num_fmt.as_str(); let formatted = crate::display::format(&v, code, wb); - let mut color = formatted.color.map(numfmt_rgb).or_else(|| rgb_of(st.font.color, wb)).unwrap_or([0, 0, 0]); + let mut color = formatted.color.and_then(gridcraft_numfmt::FormatColor::rgb).or_else(|| rgb_of(st.font.color, wb)).unwrap_or([0, 0, 0]); if l.cf.as_ref().and_then(|x| x.style.as_ref()).is_some_and(|s| s.font.color != Color::Auto) { color = rgb_of(st.font.color, wb).unwrap_or(color); } @@ -1932,6 +1918,16 @@ mod tests { assert_eq!(expand_header_footer("&", &cx)[1].text, ""); } + #[test] + fn pdf_uses_indexed_number_format_palette_color() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "42"})).unwrap(); + s.execute("home.numberFormat", json!({"range": "A1", "format": "[Color4]0"})).unwrap(); + let (pdf, pages) = pdf_of(&mut s, json!({})); + assert_eq!(pages, 1); + assert!(contains(&pdf, "0 1 0 rg"), "expected indexed Color4 green in PDF text stream"); + } + #[test] fn export_writes_file() { let mut s = s(); diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index 6280e1d26..06d0a8693 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -38,6 +38,24 @@ pub enum FormatColor { Indexed(u8), } +impl FormatColor { + /// RGB value used to render this number-format color. + pub fn rgb(self) -> Option<[u8; 3]> { + use FormatColor as F; + Some(match self { + F::Black => [0, 0, 0], + F::Blue => [0, 0, 255], + F::Cyan => [0, 255, 255], + F::Green => [0, 255, 0], + F::Magenta => [255, 0, 255], + F::Red => [255, 0, 0], + F::White => [255, 255, 255], + F::Yellow => [255, 255, 0], + F::Indexed(index) => return gridcraft_core::color::indexed_color(index), + }) + } +} + /// Best guess at the ribbon's number-format category. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum FormatKind { diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index e5687f5bd..acd09249b 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -1,5 +1,18 @@ use super::*; +#[test] +fn format_colors_resolve_named_and_indexed_rgb_safely() { + assert_eq!(FormatColor::Green.rgb(), Some([0, 255, 0])); + assert_eq!(FormatColor::Black.rgb(), Some([0, 0, 0])); + assert_eq!(FormatColor::Indexed(1).rgb(), Some([0, 0, 0])); + assert_eq!(FormatColor::Indexed(10).rgb(), Some([0, 128, 0])); + assert_eq!(FormatColor::Indexed(46).rgb(), Some([255, 102, 0])); + assert_eq!(FormatColor::Indexed(56).rgb(), Some([51, 51, 51])); + for index in [0, 57, 255] { + assert_eq!(FormatColor::Indexed(index).rgb(), None); + } +} + const S: DateSystem = DateSystem::D1900; fn f(code: &str, n: f64) -> String { diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 97b787ef3..93d579f79 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -432,18 +432,7 @@ fn col32(c: Color, wb: &Workbook, default: Color32) -> Color32 { } fn numfmt_color(c: gridcraft_engine::numfmt::FormatColor) -> Color32 { - use gridcraft_engine::numfmt::FormatColor as F; - match c { - F::Black => Color32::BLACK, - F::Blue => Color32::from_rgb(0, 0, 255), - F::Cyan => Color32::from_rgb(0, 255, 255), - F::Green => Color32::from_rgb(0, 128, 0), - F::Magenta => Color32::from_rgb(255, 0, 255), - F::Red => Color32::from_rgb(255, 0, 0), - F::White => Color32::WHITE, - F::Yellow => Color32::from_rgb(255, 255, 0), - F::Indexed(_) => Color32::BLACK, - } + c.rgb().map(|[r, g, b]| Color32::from_rgb(r, g, b)).unwrap_or(Color32::BLACK) } /// Last column of a Center Across Selection run starting at `start`: it extends over following columns (up to `limit`) while `continues` holds. @@ -3321,6 +3310,19 @@ mod selection_fill_tests { mod tests { use super::*; + #[test] + fn number_format_colors_resolve_named_and_indexed_rgb() { + use gridcraft_engine::numfmt::FormatColor as F; + assert_eq!(numfmt_color(F::Green), Color32::from_rgb(0, 255, 0)); + assert_eq!(numfmt_color(F::Indexed(1)), Color32::BLACK); + assert_eq!(numfmt_color(F::Indexed(10)), Color32::from_rgb(0, 128, 0)); + assert_eq!(numfmt_color(F::Indexed(46)), Color32::from_rgb(255, 102, 0)); + assert_eq!(numfmt_color(F::Indexed(56)), Color32::from_rgb(51, 51, 51)); + for index in [0, 57, 255] { + assert_eq!(numfmt_color(F::Indexed(index)), Color32::BLACK); + } + } + #[test] fn wheel_zoom_steps_along_the_ladder_and_stops_at_100() { for (from, zoom_in, to) in [ diff --git a/crates/xlsx/src/tables.rs b/crates/xlsx/src/tables.rs index fe7cbdc42..aba547f79 100644 --- a/crates/xlsx/src/tables.rs +++ b/crates/xlsx/src/tables.rs @@ -52,17 +52,7 @@ pub fn builtin_id(code: &str) -> Option { BUILTIN_FORMATS.iter().find(|(i, c)| *c == code && !(5..=8).contains(i)).map(|(i, _)| *i) } -/// The 64-entry legacy palette (`indexed` colours), RRGGBB. -pub const INDEXED: [u32; 64] = [ - 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF, // 0-7 - 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF, // 8-15 - 0x800000, 0x008000, 0x000080, 0x808000, 0x800080, 0x008080, 0xC0C0C0, 0x808080, // 16-23 - 0x9999FF, 0x993366, 0xFFFFCC, 0xCCFFFF, 0x660066, 0xFF8080, 0x0066CC, 0xCCCCFF, // 24-31 - 0x000080, 0xFF00FF, 0xFFFF00, 0x00FFFF, 0x800080, 0x800000, 0x008080, 0x0000FF, // 32-39 - 0x00CCFF, 0xCCFFFF, 0xCCFFCC, 0xFFFF99, 0x99CCFF, 0xFF99CC, 0xCC99FF, 0xFFCC99, // 40-47 - 0x3366FF, 0x33CCCC, 0x99CC00, 0xFFCC00, 0xFF9900, 0xFF6600, 0x666699, 0x969696, // 48-55 - 0x003366, 0x339966, 0x003300, 0x333300, 0x993300, 0x993366, 0x333399, 0x333333, // 56-63 -]; +pub use gridcraft_core::color::INDEXED; /// Paper sizes (`pageSetup paperSize`) we name. const PAPERS: [(u32, &str); 14] = [ diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..fbd4f6553 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (number-format named and indexed colors render in grid and PDF, #1452) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -48,6 +48,7 @@ this logic, with one undo step. | Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | +| Number-format colors (`[Green]`, `[Color1]`–`[Color56]`) | Rendered in grid and PDF from shared RGB mappings (#1452) | | Point mode (click/drag/arrow references), reference colouring, F4 | done | | AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) | | Header resize and double-click AutoFit | done (#42, #88 for header dividers) | @@ -161,6 +162,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Number-format named colors use their RGB values; indexed `[Color1]`–`[Color56]` resolve through the shared legacy palette in grid and PDF (#1452) | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | | 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md |