From e9addf52fe68847e6c1310fcfa5d42b7aa8b9650 Mon Sep 17 00:00:00 2001 From: Mouhand Kaddo Date: Mon, 9 Nov 2026 18:55:43 +0400 Subject: [PATCH] Arabic bidi layout and cell reading direction in the grid and editors (#947) * M12.AR4: preserve Arabic logical text in bidi display and editing * M12.AR4: drop the stale "no bidi" note from the RTL sheet status * M12.AR4: open the Arabic editors and place the caret through key and pointer events in tests * M12.AR4: skip the conditional-format lookup in the formula bar unless a rule sets reading order * M12.AR4: test that the caret after typed Arabic is drawn at the text's left end (#1070) * M12.AR4: pass the theme body font in the reading-order style test * M12.AR4: adapt the Arabic UI tests to egui 0.37 (row direction, editor ids, Ctrl+End to the logical end) * M12.AR4: read the active sheet through Workbook::active_sheet() in the formula bar main made `Workbook::active_sheet` an `ActiveSheet` (shared between windows on one workbook); the formula bar's reading-order lookup takes the index from the accessor. Co-Authored-By: Claude Opus 5.5 * M12.AR4: keep explicit Left on wrapped right-to-left cells and trailing spaces in the editor Wrapped cell text lays out every row from the left when the painted alignment is Left, instead of aligning right-to-left rows to their start. The cell and formula bar editor keep trailing whitespace in their layout so a right-to-left entry ending in a space keeps the caret inside the text. * M12.AR4: keep Right and General on right-to-left sheets aligned right in wrapped cells * M12.AR4: keep right-aligned Arabic with a marked first letter inside its cell --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: FredApps --- Cargo.lock | 1 + ROADMAP.md | 7 +- crates/engine/src/cf.rs | 25 +++ crates/engine/src/cmd/format.rs | 2 +- crates/engine/src/tests.rs | 38 ++++ crates/model/src/features.rs | 24 +++ crates/model/src/lib.rs | 4 +- crates/model/src/style.rs | 30 +++ crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/dialogs/format_cells.rs | 13 +- crates/ui-egui/src/formula_bar.rs | 33 ++- crates/ui-egui/src/grid/cell_paint.rs | 3 + crates/ui-egui/src/grid/quadrant.rs | 29 ++- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/text_direction.rs | 32 +++ crates/ui-egui/tests/arabic_bidi.rs | 228 +++++++++++++++++++++ crates/ui-egui/tests/arabic_editing.rs | 131 ++++++++++++ crates/xlsx/src/styles.rs | 62 +++++- crates/xlsx/src/tests/roundtrip.rs | 10 +- crates/xlsx/src/xls/styles.rs | 1 + docs/file-format-parity.md | 3 + docs/gaps.md | 6 + docs/localization-parity.md | 13 +- 23 files changed, 673 insertions(+), 24 deletions(-) create mode 100644 crates/ui-egui/src/text_direction.rs create mode 100644 crates/ui-egui/tests/arabic_bidi.rs create mode 100644 crates/ui-egui/tests/arabic_editing.rs diff --git a/Cargo.lock b/Cargo.lock index cb783e546..543cddfe1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2197,6 +2197,7 @@ dependencies = [ "skrifa", "sys-locale", "toml", + "unicode-bidi", "web-sys", "web-time", "zbus", diff --git a/ROADMAP.md b/ROADMAP.md index f12cd112a..5906fda99 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -43,8 +43,7 @@ long `.xls` track, so about half parallelizes (≈30–50 wall-clock hours with Why alpha: all six core workflows in the [alpha gate](docs/roadmap.md#alpha-gate) pass end to end on macOS with save and reopen, and the core workflow works end to end (open an XLSX, enter formulas and data, format, sort and filter, chart, pivot, print to PDF, save XLSX), but users still hit blocking gaps on real -files: 100 MB workbooks don't open (#175), there's no `.xls` (#121), Arabic and Devanagari text -isn't shaped (Chinese text still reported in #142), charts lose their type and options on round trip, and +files: 100 MB workbooks don't open (#175), there's no `.xls` (#121), Devanagari text needs shaping verification (Chinese text still reported in #142), charts lose their type and options on round trip, and nobody has yet confirmed that Excel opens our output without a repair prompt across a real corpus. Beta needs those closed (see [beta gates](docs/roadmap.md#beta-gates)). @@ -105,7 +104,7 @@ Localized German formulas and numbers are in PR #163. Detail: [localization-pari | Simplified Chinese | zh-Hans | menus only | ~12% | | Spanish | es | none | 0% | | Hindi | hi | none | 0% | -| Arabic | ar | none (no RTL layout, no shaping) | 0% | +| Arabic | ar | catalog pending; shared shaping/bidi works | 0% | | French | fr | none | 0% | | Portuguese | pt-BR | menus, File view, dialogs | ~43% | | Indonesian | id | none | 0% | @@ -149,6 +148,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-31 | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) | | 2026-10-31 | Recently Used lists the functions most recently entered in a formula from any source (ribbon, control channel, MCP, scripts), kept with the preferences and returned by `formulas.recentlyUsed`; the Insert Function dialog takes Excel's title and a working category list (Most Recently Used, All, each category) | | 2026-10-31 | Formulas › Function Library as a big Insert Function button, the AutoSum split button and three columns of labelled small buttons, each category book in its own colour; category menus end with Insert Function… on that category; More Functions cascades into the remaining categories; Home › Editing shares the AutoSum menu; the MR key tip opens Recently Used | +| 2026-10-26 | Arabic bidi display/editing preserve logical source text; per-cell reading direction is editable and survives undo, XLSX and conditional-format priority | | 2026-10-26 | Arabic text shows in cells and the interface through a system, fontconfig or craft-fonts Arabic face; workbook fonts keep their script fallbacks | | 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before | | 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step | @@ -277,6 +277,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) | | 2026-10-31 | minor | Progress log: Recently Used and Insert Function categories | | 2026-10-31 | minor | Progress log: Function Library | +| 2026-10-26 | minor | Progress log: Arabic bidi display and editing | | 2026-10-26 | minor | Progress log: Arabic font fallback | | 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column | | 2026-10-24 | minor | Macro interface on the Automate tab; progress log row | diff --git a/crates/engine/src/cf.rs b/crates/engine/src/cf.rs index 38f5bb1fe..0af7a9df5 100644 --- a/crates/engine/src/cf.rs +++ b/crates/engine/src/cf.rs @@ -368,6 +368,9 @@ impl CfCache { /// Adds the parts `lower` sets that `style` leaves unset (differential styles: default parts /// are unset). fn layer_under(style: &mut Style, lower: &Style) { + if style.align.reading_order.is_none() { + style.align.reading_order = lower.align.reading_order; + } if style.fill.pattern == PatternType::None { style.fill = lower.fill; } @@ -417,6 +420,28 @@ mod tests { use super::*; + #[test] + fn reading_order_respects_conditional_rule_priority_and_explicit_context() { + for (higher, expected) in [ + (json!({"font": {"bold": true}}), "RightToLeft"), + (json!({"align": {"reading_order": "Context"}}), "Context"), + (json!({"align": {"reading_order": "LeftToRight"}}), "LeftToRight"), + ] { + let mut s = crate::Session::new(); + s.new_workbook(); + s.execute("cell.set", json!({"cell": "A1", "input": "مرحبا 123 ABC"})).unwrap(); + for style in [json!({"align": {"reading_order": "RightToLeft"}}), higher] { + s.execute("home.conditionalFormat", json!({"range": "A1", "rule": {"type": "noBlanks", "style": style}})).unwrap(); + } + let wb = &s.doc().unwrap().wb; + let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(wb).unwrap()).unwrap(); + for wb in [wb, &reopened] { + let style = CfCache::new().look(wb, 0, CellRef::new(0, 0)).unwrap().style.unwrap(); + assert_eq!(serde_json::to_value(style.align).unwrap()["reading_order"], expected); + } + } + } + #[test] fn every_matching_rule_applies() { let mut s = crate::Session::new(); diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index d5eac5b5d..aa2638801 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -85,7 +85,7 @@ pub fn specs() -> Vec { "Format Cells…", ["Home", "Cells", "Format"], Some("Cmd+1"), - "{range?, style: {font?, fill?, borders?, align?, numFmt?, protection?}} (partial Style JSON merged in)", + "{range?, style: {font?, fill?, borders?, align?: {reading_order?: Context|LeftToRight|RightToLeft|null, ...}, numFmt?, protection?}} (partial Style JSON merged in; null clears reading_order)", has_doc, format_cells ), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index bf36bd15b..fcaf19c36 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -18,6 +18,44 @@ fn v(s: &Session, a: &str) -> Value { s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(a).unwrap()) } +#[test] +fn format_cells_reading_order_survives_undo_and_xlsx() { + let mut s = s(); + s.execute("cell.set", json!({"cell": "A1", "input": "مرحبا 123 ABC"})).unwrap(); + s.execute("home.bold", json!({"range": "A1"})).unwrap(); + s.execute("home.formatCells", json!({"range": "A1", "style": {"align": {"reading_order": "RightToLeft"}}})).unwrap(); + let alignment = |s: &Session| { + let d = s.doc().unwrap(); + let style = d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::new(0, 0))); + assert!(style.font.bold); + serde_json::to_value(style.align).unwrap() + }; + assert_eq!(alignment(&s)["reading_order"], "RightToLeft"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(alignment(&s)["reading_order"].is_null()); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(alignment(&s)["reading_order"], "RightToLeft"); + for order in [json!("invalid"), json!(2)] { + assert!(s.execute("home.formatCells", json!({"range": "A1", "style": {"align": {"reading_order": order}}})).is_err()); + assert_eq!(alignment(&s)["reading_order"], "RightToLeft"); + } + let wb = &s.doc().unwrap().wb; + let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(wb).unwrap()).unwrap(); + let cell = CellRef::new(0, 0); + assert_eq!(reopened.styles.get(reopened.active().unwrap().style_id(cell)), wb.styles.get(wb.active().unwrap().style_id(cell))); + assert_eq!(reopened.active().unwrap().value(cell), Value::text("مرحبا 123 ABC")); + for order in ["LeftToRight", "Context"] { + s.execute("home.formatCells", json!({"range": "A1", "style": {"align": {"reading_order": order}}})).unwrap(); + assert_eq!(alignment(&s)["reading_order"], order); + } + s.execute("home.formatCells", json!({"range": "A1", "style": {"align": {"reading_order": null}}})).unwrap(); + assert!(alignment(&s)["reading_order"].is_null()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(alignment(&s)["reading_order"], "Context"); + s.execute("edit.redo", json!({})).unwrap(); + assert!(alignment(&s)["reading_order"].is_null()); +} + #[test] fn entering_empty_cells_preserves_whole_sheet_formatting() { let mut s = s(); diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 06e68839f..8ab74606a 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -230,6 +230,30 @@ pub enum CfRule { }, } +impl CfRule { + /// The differential style a matching rule applies; colour scales, data bars and icon sets have none. + pub fn style(&self) -> Option<&Style> { + match self { + CfRule::CellIs { style, .. } + | CfRule::Expression { style, .. } + | CfRule::ContainsText { style, .. } + | CfRule::NotContainsText { style, .. } + | CfRule::BeginsWith { style, .. } + | CfRule::EndsWith { style, .. } + | CfRule::Blanks { style } + | CfRule::NoBlanks { style } + | CfRule::Errors { style } + | CfRule::NoErrors { style } + | CfRule::Duplicate { style } + | CfRule::Unique { style } + | CfRule::Top10 { style, .. } + | CfRule::AboveAverage { style, .. } + | CfRule::TimePeriod { style, .. } => Some(style), + CfRule::ColorScale { .. } | CfRule::DataBar { .. } | CfRule::IconSet { .. } => None, + } + } +} + /// Number of icons in an icon set (`3Arrows` → 3, `5Quarters` → 5), always 3 to 5. pub fn icon_count(set: &str) -> usize { set.chars().next().and_then(|c| c.to_digit(10)).unwrap_or(3).clamp(3, 5) as usize diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 35cfa315f..b1f875b3c 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -26,8 +26,8 @@ pub use python::{PyCode, PyOutput}; pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetUid, SheetViewMode, Visibility, points_to_chars}; pub use store::CellStore; pub use style::{ - Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline, - VAlign, VertAlign, + Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, ReadingOrder, Style, StyleId, StyleTable, Theme, + Underline, VAlign, VertAlign, }; pub use workbook::{ CalcMode, CalcSettings, DefinedName, DocProps, EQUATION_BUDGET_MESSAGE, MAX_EQUATION_LAYOUT_BYTES, PhoneticProps, PhoneticRun, PhoneticString, diff --git a/crates/model/src/style.rs b/crates/model/src/style.rs index d2f10780e..9ad61abec 100644 --- a/crates/model/src/style.rs +++ b/crates/model/src/style.rs @@ -350,6 +350,14 @@ pub enum VAlign { Distributed, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ReadingOrder { + #[default] + Context, + LeftToRight, + RightToLeft, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct Alignment { pub h: HAlign, @@ -359,6 +367,9 @@ pub struct Alignment { pub indent: u8, /// Degrees -90..=90, or 255 for vertical stacked text. pub rotation: i16, + /// Unspecified inherits in differential styles and uses context for ordinary cells. + #[serde(default)] + pub reading_order: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -488,6 +499,25 @@ static DEFAULT_STYLE: std::sync::LazyLock