diff --git a/Cargo.lock b/Cargo.lock index a39620f96..543cddfe1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -910,12 +910,12 @@ dependencies = [ [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "fixed_decimal", "icu_decimal", @@ -938,7 +938,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "craft-winprint-gdi", "log", @@ -952,7 +952,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -972,7 +972,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "chrono", "dunce", @@ -1018,7 +1018,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "egui", "objc2 0.6.5", @@ -1029,7 +1029,7 @@ dependencies = [ [[package]] name = "craft-winprint-gdi" version = "0.1.0" -source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312" +source = "git+https://github.com/storytold/winprint.rs?rev=64e7afd4d1483512385012c340ecaaaf0f3beb7f#64e7afd4d1483512385012c340ecaaaf0f3beb7f" dependencies = [ "windows-sys 0.61.2", ] @@ -2197,6 +2197,7 @@ dependencies = [ "skrifa", "sys-locale", "toml", + "unicode-bidi", "web-sys", "web-time", "zbus", @@ -6485,7 +6486,7 @@ dependencies = [ [[package]] name = "winprint" version = "0.2.1" -source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49" +source = "git+https://github.com/storytold/winprint.rs?rev=51934db382f225454cf152a8657eb03789be63c4#51934db382f225454cf152a8657eb03789be63c4" dependencies = [ "flate2", "fmt-derive", @@ -6935,7 +6936,6 @@ dependencies = [ "nix", "typed-path", "windows-sys 0.61.2", - "zopfli", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 594b29273..1941c9d1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,9 +26,9 @@ gridcraft-mcp = { path = "crates/mcp" } gridcraft-pdf = { path = "crates/pdf" } gridcraft-math = { path = "crates/math" } # Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in -# the app) comes from one storytold/craft-libs revision. +# the app) comes from one craft-libs revision. # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", features = ["egui"] } # foundation thiserror = "2" @@ -71,7 +71,7 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] } epaint_default_fonts = "0.37" # One native window per workbook: the window list, Window menu titles and the immediate-viewport # driver. Same craft-libs revision as craft-update above. -craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", default-features = false, features = ["egui"] } +craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", default-features = false, features = ["egui"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/ROADMAP.md b/ROADMAP.md index 340bb2d06..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,8 @@ 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 | | 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) | @@ -276,6 +277,8 @@ 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 | | 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row | diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index a7472821f..75acee3ff 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -34,7 +34,7 @@ winit = { version = "0.30", default-features = false, features = ["x11", "waylan [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Same craft-libs revision as the workspace's craft-update and craft-window (see the root # Cargo.toml). -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" } craft-update = { workspace = true } rfd = { workspace = true } arboard = { workspace = true, features = ["image-data"] } diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 2aa98b6eb..cc67e241a 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -64,6 +64,9 @@ impl eframe::App for App { fn on_exit(&mut self) { print::finish_pending_jobs(); save_prefs(&self.0); + // A ready update installs once the process has exited. The macOS exit below skips + // dropping the app, so the helper is started here. + self.0.start_pending_update(); // macOS (#117): end the process here, before eframe tears the window down. AccessKit's // macOS adapter subclasses the window's content view and restores its class on teardown; // AppKit's Touch Bar support observes that view (KVO), so in the next display cycle diff --git a/crates/core/src/value.rs b/crates/core/src/value.rs index 0000028e9..a0b4fcad6 100644 --- a/crates/core/src/value.rs +++ b/crates/core/src/value.rs @@ -346,19 +346,91 @@ pub fn compare_numbers(a: f64, b: f64) -> Ordering { round15(a).partial_cmp(&round15(b)).unwrap_or(Ordering::Equal) } -/// Case-insensitive text order (Unicode lowercase). +/// Case-insensitive text order in three levels, as Excel's collation sorts: first by letters and +/// digits alone (Arabic marks, tatweel and invisible format characters skipped, Arabic-Indic digits +/// read as digits); then, letter by letter, by the marks on each letter (none first, then in +/// Excel's mark order) and the digit script (ASCII, Arabic-Indic, Extended Arabic-Indic); then, to +/// break a remaining tie, by every character (Unicode lowercase). Only text that is equal at the +/// last level compares equal. pub fn compare_text(a: &str, b: &str) -> Ordering { - let mut ai = a.chars().flat_map(char::to_lowercase); - let mut bi = b.chars().flat_map(char::to_lowercase); - loop { - match (ai.next(), bi.next()) { - (None, None) => return Ordering::Equal, - (None, Some(_)) => return Ordering::Less, - (Some(_), None) => return Ordering::Greater, - (Some(x), Some(y)) if x != y => return x.cmp(&y), - _ => {} + if !(a.is_ascii() && b.is_ascii()) { + let primary = primary_chars(a).cmp(primary_chars(b)); + if primary != Ordering::Equal { + return primary; + } + let secondary = secondary_weights(a).cmp(secondary_weights(b)); + if secondary != Ordering::Equal { + return secondary; } } + lowercase_chars(a).cmp(lowercase_chars(b)) +} + +fn lowercase_chars(s: &str) -> impl Iterator + '_ { + s.chars().flat_map(char::to_lowercase) +} + +/// Characters Excel's text order skips entirely: tatweel and invisible format characters (soft +/// hyphen, combining grapheme joiner, zero-width (non-)joiner, bidi marks, embeddings, overrides +/// and isolates, word joiner, byte order mark). The zero-width space is not one. +fn ignorable(c: char) -> bool { + matches!( + c, + '\u{00AD}' + | '\u{034F}' + | '\u{061C}' + | '\u{0640}' + | '\u{200C}'..='\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2060}' + | '\u{2066}'..='\u{2069}' + | '\u{FEFF}' + ) +} + +/// Arabic combining marks in the order Excel sorts a letter carrying them; an unmarked letter +/// sorts before all of them. Measured in Excel 16.0 (build 20527, en-US) with one mark on a letter. +/// U+06D6 and U+06D7 sort as letters in Excel, so they are not listed. +const ARABIC_MARK_ORDER: [char; 48] = [ + '\u{06EC}', '\u{0611}', '\u{0612}', '\u{0613}', '\u{0614}', '\u{06DB}', '\u{0656}', '\u{0657}', '\u{0610}', '\u{06EB}', '\u{0615}', '\u{06D9}', + '\u{06E4}', '\u{06E1}', '\u{06DA}', '\u{06DC}', '\u{06EA}', '\u{06E3}', '\u{06D8}', '\u{0655}', '\u{065F}', '\u{0654}', '\u{06E7}', '\u{0653}', + '\u{06E8}', '\u{06ED}', '\u{06E2}', '\u{0658}', '\u{064B}', '\u{064C}', '\u{064D}', '\u{064E}', '\u{064F}', '\u{0650}', '\u{0652}', '\u{0651}', + '\u{0670}', '\u{0659}', '\u{065A}', '\u{065B}', '\u{065C}', '\u{065D}', '\u{065E}', '\u{0616}', '\u{0617}', '\u{0618}', '\u{0619}', '\u{061A}', +]; + +/// Rank of an Arabic mark that letter order skips (its index in `ARABIC_MARK_ORDER`). +fn arabic_mark_rank(c: char) -> Option { + ARABIC_MARK_ORDER.iter().position(|&m| m == c).and_then(|i| u16::try_from(i).ok()) +} + +/// Digit script of `c` (0 ASCII, 1 Arabic-Indic, 2 Extended Arabic-Indic) and its ASCII digit. +fn digit(c: char) -> Option<(u16, char)> { + let (script, zero) = match c { + '0'..='9' => return Some((0, c)), + '\u{0660}'..='\u{0669}' => (1, 0x0660), + '\u{06F0}'..='\u{06F9}' => (2, 0x06F0), + _ => return None, + }; + char::from_u32(u32::from(c) - zero + u32::from('0')).map(|d| (script, d)) +} + +/// The characters that decide text order before any tie-break. +fn primary_chars(s: &str) -> impl Iterator + '_ { + lowercase_chars(s).filter_map(|c| if ignorable(c) || arabic_mark_rank(c).is_some() { None } else { Some(digit(c).map_or(c, |(_, d)| d)) }) +} + +/// The second level, for text whose primary characters are equal: per letter, its digit script and +/// then its marks, the letters' lists closed by 0 so that an unmarked letter sorts first. Marks +/// before the first letter form a list of their own. +fn secondary_weights(s: &str) -> impl Iterator + '_ { + lowercase_chars(s) + .filter(|&c| !ignorable(c)) + .flat_map(|c| match arabic_mark_rank(c) { + Some(rank) => [Some(rank.saturating_add(1)), None], + None => [Some(0), Some(digit(c).map_or(0, |(script, _)| script))], + }) + .flatten() + .chain(std::iter::once(0)) } /// Sorting order used by Sort (ascending): numbers, text, booleans, errors, then blanks last. @@ -457,6 +529,182 @@ mod tests { assert_eq!(sort_compare(&Value::Empty, &Value::Number(1.0)), Ordering::Greater); } + #[test] + fn arabic_diacritics_sort_with_their_base_letter() { + assert_eq!(compare_text("بَاب", "بيت"), Ordering::Less); + assert_eq!(compare_text("بيت", "بَاب"), Ordering::Greater); + assert_eq!(compare_text("كِتاب", "كتب"), Ordering::Less); + assert_eq!(compare_text("رحمٰن", "رحمي"), Ordering::Less); + assert_eq!(compare(&Value::from("بَاب"), &Value::from("بيت")), Ordering::Less); + } + + #[test] + fn arabic_indic_digits_sort_with_digits() { + assert_eq!(compare_text("١٢", "ب"), Ordering::Less); + assert_eq!(compare_text("۱۲", "ب"), Ordering::Less); + assert_eq!(compare_text("١", "a"), Ordering::Less); + assert_eq!(compare_text("١٢", "13"), Ordering::Less); + assert_eq!(compare_text("۱۴", "13"), Ordering::Greater); + } + + #[test] + fn tatweel_and_bidi_marks_do_not_move_sort_order() { + assert_eq!(compare_text("بـاب", "بت"), Ordering::Less); + assert_eq!(compare_text("\u{200F}apple", "banana"), Ordering::Less); + assert_eq!(compare_text("\u{200E}zebra", "apple"), Ordering::Greater); + assert_eq!(compare_text("\u{061C}a", "b"), Ordering::Less); + assert_eq!(compare_text("a\u{200C}b", "ac"), Ordering::Less); + assert_eq!(compare_text("a\u{200D}b", "ac"), Ordering::Less); + } + + #[test] + fn primary_ties_keep_equality_unchanged() { + assert_eq!(compare_text("ABC", "abc"), Ordering::Equal); + assert_eq!(compare_text("باب", "بَاب"), Ordering::Less); + assert_eq!(compare_text("12", "١٢"), Ordering::Less); + assert_eq!(compare_text("a", "\u{200F}a"), Ordering::Less); + let samples = [ + "", + "abc", + "ABC", + "abd", + "ab", + "باب", + "بَاب", + "بِاب", + "بـاب", + "بيت", + "١٢", + "۱۲", + "12", + "\u{200F}12", + "\u{200F}a", + "a", + "A\u{200C}", + "a\u{200F}", + "b", + "Straße", + "STRASSE", + "ΣΑΣ", + "σας", + "İ", + "i̇", + ]; + let lowered = |s: &str| s.chars().flat_map(char::to_lowercase).collect::(); + for a in samples { + for b in samples { + let order = compare_text(a, b); + assert_eq!(order.is_eq(), lowered(a) == lowered(b), "{a:?} vs {b:?}"); + assert_eq!(order, compare_text(b, a).reverse(), "{a:?} vs {b:?}"); + for c in samples { + if order.is_le() && compare_text(b, c).is_le() { + assert!(compare_text(a, c).is_le(), "{a:?} <= {b:?} <= {c:?}"); + } + } + } + } + } + + /// Sorts `expected` from reversed and rotated starts and checks each comes back as `expected`. + #[track_caller] + fn assert_sorts_to(expected: &[&str]) { + let mut reversed: Vec<&str> = expected.iter().rev().copied().collect(); + reversed.sort_by(|a, b| compare_text(a, b)); + assert_eq!(reversed, expected); + let mut rotated: Vec<&str> = expected.to_vec(); + rotated.rotate_left(expected.len() / 2); + rotated.sort_by(|a, b| compare_text(a, b)); + assert_eq!(rotated, expected); + } + + // The expected orders below are Excel's, observed with Range.Sort, =SORT() and the `<` + // operator in Excel 16.0 (build 20527, en-US). Strings Excel ranks equal are left out. + + #[test] + fn excel_orders_a_letter_by_its_mark() { + assert_sorts_to(&[ + "باب", + "ب\u{0611}اب", + "ب\u{0610}اب", + "ب\u{0654}اب", + "ب\u{064B}اب", + "ب\u{064C}اب", + "ب\u{064D}اب", + "ب\u{064E}اب", + "ب\u{064F}اب", + "ب\u{0650}اب", + "ب\u{0652}اب", + "ب\u{0651}اب", + "ب\u{0670}اب", + "ب\u{065E}اب", + "ب\u{061A}اب", + ]); + // Quranic marks are skipped by letter order like harakat (hamza sorts before alef). + assert_eq!(compare_text("ب\u{0610}ء", "باب"), Ordering::Less); + assert_eq!(compare_text("ب\u{06E1}ء", "باب"), Ordering::Less); + } + + #[test] + fn excel_compares_marks_letter_by_letter() { + assert_sorts_to(&[ + "بت", + "بت\u{064E}", + "بت\u{064F}", + "بت\u{0650}", + "ب\u{064E}ت", + "ب\u{064E}ت\u{064E}", + "ب\u{064E}ت\u{064F}", + "ب\u{064F}ت", + "ب\u{064F}ت\u{064E}", + "ب\u{0650}ت\u{0650}", + ]); + assert_sorts_to(&["مـحـمـد", "محم\u{0651}د", "م\u{064E}حمد", "م\u{064F}ح\u{064E}م\u{064E}\u{0651}د", "محمدي"]); + } + + #[test] + fn excel_weighs_marks_and_digit_scripts_by_position() { + // A mark on an earlier letter outranks the digit script or letter after it, whatever the + // code point of that next character. + assert_sorts_to(&[ + "ب", + "ب\u{064E}", + "ب1", + "ب١", + "ب۱", + "ب\u{064E}1", + "ب\u{064E}١", + "ب\u{064E}۱", + "بa", + "ب\u{064E}a", + "با", + "ب\u{064E}ا", + "ب\u{067E}", + "ب\u{064E}\u{067E}", + "ب\u{06CC}", + "ب\u{064E}\u{06CC}", + ]); + // And an earlier digit script outranks a later mark. + assert_sorts_to(&["12", "1٢", "١2", "١٢", "1ب", "1ب\u{064E}", "1ب\u{0650}", "١ب", "١ب\u{064E}", "١ب\u{0650}", "۱ب", "۱ب\u{064E}"]); + assert_sorts_to(&["ب1ب", "ب1ب\u{064E}", "ب١ب", "ب\u{064E}ب", "بب1", "بب١", "ب\u{064E}ب1"]); + } + + #[test] + fn excel_skips_invisible_format_characters() { + for c in ['\u{00AD}', '\u{034F}', '\u{061C}', '\u{200C}', '\u{200D}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}'] + .into_iter() + .chain(['\u{202C}', '\u{202D}', '\u{202E}', '\u{2060}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', '\u{FEFF}']) + { + let s = format!("{c}b"); + assert_eq!(compare_text(&s, "a"), Ordering::Greater, "{s:?}"); + assert_eq!(compare_text(&s, "ba"), Ordering::Less, "{s:?}"); + assert_eq!(compare_text(&format!("ب{c}\u{064E}اب"), "باب\u{064B}"), Ordering::Greater, "{c:?}"); + } + // Not even as a tie-break: a leading mark doesn't push text past marked or other-script forms. + assert_sorts_to(&["\u{200F}باب", "ب\u{064B}اب", "ب\u{064E}اب", "ب\u{0670}اب"]); + assert_sorts_to(&["\u{200F}12", "١٢", "۱۲"]); + assert_sorts_to(&["ب\u{200F}ب\u{064E}", "\u{200F}ب\u{064E}ب"]); + } + #[test] fn errors() { for e in CellError::ALL { 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/data/filter.rs b/crates/engine/src/cmd/data/filter.rs index 437bd7887..cda2160bf 100644 --- a/crates/engine/src/cmd/data/filter.rs +++ b/crates/engine/src/cmd/data/filter.rs @@ -317,5 +317,9 @@ pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool let key = if blank { sort_key(&Value::Empty) } else { sort_key(&v) }; seen.entry((key, t)).or_insert(on); } - seen.into_iter().map(|((_, t), on)| (t, on)).collect() + let mut values: Vec<_> = seen.into_iter().collect(); + values.sort_by(|((a, ta), _), ((b, tb), _)| { + a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then_with(|| gridcraft_core::compare_text(&a.2, &b.2)).then_with(|| ta.cmp(tb)) + }); + values.into_iter().map(|((_, t), on)| (t, on)).collect() } diff --git a/crates/engine/src/cmd/data/tests.rs b/crates/engine/src/cmd/data/tests.rs index 3c2782444..e073e23a5 100644 --- a/crates/engine/src/cmd/data/tests.rs +++ b/crates/engine/src/cmd/data/tests.rs @@ -53,6 +53,17 @@ fn subtotal_puts_summary_rows_below_their_detail() { assert_eq!(sh.value(CellRef::new(3, 0)).display(), "West"); } +#[test] +fn filter_list_orders_arabic_like_sort() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["بج"], ["بُت"], ["\u{200F}بب"]]})).unwrap(); + s.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); + let doc = s.doc().unwrap(); + let list: Vec = filter_values(&doc.wb, 0, 0).into_iter().map(|(t, _)| t).collect(); + assert_eq!(list, ["\u{200F}بب", "بُت", "بج"]); +} + #[test] fn filter_by_huge_numeric_column_is_rejected_not_overflowed() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 1066f48e2..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 ), @@ -870,6 +870,52 @@ mod tests { assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"]["num_fmt"], "0.00"); } + #[test] + fn arabic_vowel_and_direction_marks_add_no_width() { + let plain = approx_text_width("محمد يدرس في المدرسة", 11.0, false); + assert_eq!(approx_text_width("مُحَمَّد يَدْرُسُ فِي الْمَدْرَسَةِ", 11.0, false), plain); + assert_eq!(approx_text_width("\u{200F}محمد يدرس في المدرسة\u{200C}", 11.0, false), plain); + assert_eq!(approx_text_width("ب\u{0897}\u{0898}\u{089F}", 11.0, false), approx_text_width("ب", 11.0, false)); + assert!(approx_text_width("\u{08E2}", 11.0, false) > 0.0, "the disputed end of ayah is a visible sign"); + } + + #[test] + fn arabic_text_fits_a_column_about_as_wide_as_latin_text_of_the_same_length() { + let mut s = crate::Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["مُحَمَّد يَدْرُسُ فِي الْمَدْرَسَةِ", "Hello there in school"]]})).unwrap(); + s.execute("home.autofitColumnWidth", json!({"cols": "A:B"})).unwrap(); + let doc = s.doc().unwrap(); + let width = |c: u32| doc.wb.active().and_then(|sh| sh.cols.get(&c)).and_then(|e| e.size).unwrap(); + let ratio = width(0) / width(1); + assert!((0.75..=1.15).contains(&ratio), "Arabic {} vs Latin {}", width(0), width(1)); + } + + #[test] + fn arabic_word_ligatures_are_wider_than_letters() { + let em = |t: &str| approx_text_width(t, 72.0, false) / 96.0; + assert!(em("\u{FDFD}") > 8.0); + assert!(em("\u{FDFA}") > 1.0); + let honorifics = ['\u{FD40}', '\u{FD4B}', '\u{FD4C}', '\u{FD4F}', '\u{FDC8}', '\u{FDCF}', '\u{FDFE}', '\u{FDFF}']; + for honorific in honorifics.into_iter().chain(['\u{FBC3}', '\u{FBD2}', '\u{FD90}', '\u{FD91}']) { + assert!(em(&honorific.to_string()) > 1.0, "U+{:04X}", u32::from(honorific)); + } + assert!(em("ب") < 0.6); + } + + #[test] + fn arabic_digits_are_as_wide_as_ascii_digits_and_punctuation_as_a_letter() { + let width = |t: &str| approx_text_width(t, 11.0, false); + assert_eq!(width("١٢٣"), width("123")); + assert_eq!(width("۴۵۶"), width("456")); + assert_eq!(width("،؟"), width("بب")); + } + + #[test] + fn cjk_text_keeps_a_full_em_per_character() { + assert_eq!(approx_text_width("中文", 12.0, false), 2.0 * 12.0 * 96.0 / 72.0); + } + #[test] fn width_units() { assert_eq!(chars_to_points(8.43), 64.0); diff --git a/crates/engine/src/cmd/format/sizes.rs b/crates/engine/src/cmd/format/sizes.rs index c79327678..8b304c889 100644 --- a/crates/engine/src/cmd/format/sizes.rs +++ b/crates/engine/src/cmd/format/sizes.rs @@ -136,12 +136,55 @@ pub fn approx_text_width(text: &str, size: f32, bold: bool) -> f32 { c if c.is_ascii_uppercase() => 0.65, c if c.is_ascii_digit() => 0.56, c if c.is_ascii() => 0.52, - _ => 1.0, + c if is_zero_width(c) => 0.0, + c => arabic_em(c).unwrap_or(1.0), }; } w * size * if bold { 1.07 } else { 1.0 } * 96.0 / 72.0 } +/// Arabic vowel and Quranic marks, which sit on the previous letter, and invisible format +/// characters such as direction marks and zero-width joiners. +fn is_zero_width(c: char) -> bool { + matches!( + c, + '\u{0610}'..='\u{061A}' + | '\u{061C}' + | '\u{064B}'..='\u{065F}' + | '\u{0670}' + | '\u{06D6}'..='\u{06DC}' + | '\u{06DF}'..='\u{06E4}' + | '\u{06E7}'..='\u{06E8}' + | '\u{06EA}'..='\u{06ED}' + | '\u{0897}'..='\u{089F}' + | '\u{08CA}'..='\u{08E1}' + | '\u{08E3}'..='\u{08FF}' + | '\u{200B}'..='\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2060}'..='\u{2069}' + | '\u{FE20}'..='\u{FE2F}' + | '\u{FEFF}' + ) +} + +/// Width in ems of an Arabic-script character, measured from shaped Noto Sans Arabic, Noto Naskh +/// Arabic and DejaVu Sans: joined letters average about half an em, word ligatures such as ﷺ and the +/// rial sign about 1.2 em, the honorific ligatures (U+FD40–FD4B) about 1.6 em, and the bismillah +/// ligature about 11 em. The later honorific ligatures (U+FBC3–FBD2, U+FD4C–FD4F, U+FD90–FD91, +/// U+FDC8–FDCF) are not measured and get the 1.6 em of their siblings rather than the letter width. +/// Arabic-Indic digits are as wide as ASCII digits (0.53–0.57 em in Noto Sans Arabic, Arial, Tahoma +/// and Times New Roman); Arabic punctuation (0.2–0.5 em in the same fonts) keeps the letter width. +fn arabic_em(c: char) -> Option { + match c { + '\u{FDFD}' => Some(11.0), + '\u{FBC3}'..='\u{FBD2}' | '\u{FD40}'..='\u{FD4F}' | '\u{FD90}'..='\u{FD91}' | '\u{FDC8}'..='\u{FDCF}' => Some(1.6), + '\u{FDF0}'..='\u{FDFC}' | '\u{FDFE}'..='\u{FDFF}' => Some(1.2), + '\u{0660}'..='\u{0669}' | '\u{06F0}'..='\u{06F9}' => Some(0.56), + '\u{0600}'..='\u{06FF}' | '\u{0750}'..='\u{077F}' | '\u{0870}'..='\u{08FF}' | '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFC}' => Some(0.48), + _ => None, + } +} + /// Distinct texts AutoFit measures exactly; past this many, `Session::text_width` is asked for a /// quick measure so a huge column doesn't lay out every value. const EXACT_TEXT_WIDTHS: usize = 2_000; diff --git a/crates/engine/src/pivot/items.rs b/crates/engine/src/pivot/items.rs index 79b74da1a..6dee1dc5a 100644 --- a/crates/engine/src/pivot/items.rs +++ b/crates/engine/src/pivot/items.rs @@ -21,7 +21,7 @@ pub(super) struct FieldItems { } fn cmp_items(a: &Item, b: &Item) -> Ordering { - a.class.cmp(&b.class).then(a.num.total_cmp(&b.num)).then_with(|| a.text.cmp(&b.text)) + a.class.cmp(&b.class).then(a.num.total_cmp(&b.num)).then_with(|| gridcraft_core::compare_text(&a.text, &b.text)) } /// (identity key, item) for a source value under a field's date grouping. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f97e1bc10..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(); @@ -1459,6 +1497,28 @@ fn case_sensitive_sort_puts_lowercase_first() { assert_eq!(col, ["a", "A", "aB", "Ab", "b", "B"].map(Value::from)); } +#[test] +fn data_sort_orders_vowelled_arabic_by_letters() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["بيت"], ["بَاب"], ["تمر"], ["\u{200F}بحر"]]})).unwrap(); + s.execute("data.sort", json!({"range": "A1:A4", "header": false, "keys": [{"column": "A"}]})).unwrap(); + let col: Vec = (1..=4).map(|r| v(&s, &format!("A{r}"))).collect(); + assert_eq!(col, ["بَاب", "\u{200F}بحر", "بيت", "تمر"].map(Value::from)); +} + +#[test] +fn data_sort_orders_marks_and_digit_scripts_like_excel() { + // Excel 16.0 (build 20527, en-US) sorts this column, as text, into exactly this order. + let want = ["١٢", "۱۲", "ب\u{064E}1", "ب\u{064E}١", "بa", "\u{200F}باب", "ب\u{064B}اب", "ب\u{064E}اب", "ب\u{0652}اب", "ب\u{0651}اب"]; + let mut s = s(); + s.execute("home.numberFormat", json!({"range": "A1:A10", "code": "@"})).unwrap(); + let input: Vec> = want.iter().rev().map(|t| vec![*t]).collect(); + s.execute("range.setValues", json!({"range": "A1", "values": input})).unwrap(); + s.execute("data.sort", json!({"range": "A1:A10", "header": false, "keys": [{"column": "A"}]})).unwrap(); + let col: Vec = (1..=10).map(|r| v(&s, &format!("A{r}"))).collect(); + assert_eq!(col, want.map(Value::from)); +} + #[test] fn sort_with_a_sheet_prefix_sorts_the_named_sheet() { let col = |s: &Session, sheet: &str| -> Vec { 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/sheet.rs b/crates/model/src/sheet.rs index 440647d42..409e93023 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -566,7 +566,9 @@ impl LineIndex { let mut prev = 0u32; for (&line, info) in custom { pos += (line - prev) as f64 * default; - let size = if info.hidden { 0.0 } else { info.size.map_or(default, |s| s as f64) }; + // A negative size (only a malformed file has one) counts as zero, so positions only + // grow and the searches below stay valid. + let size = if info.hidden { 0.0 } else { info.size.map_or(default, |s| s as f64).max(0.0) }; lines.push((line, pos, size)); pos += size; prev = line + 1; @@ -584,6 +586,44 @@ impl LineIndex { }, } } + /// The first line at or after `line` (and before the axis end) with a size above zero, or + /// `None` when every line from there on is hidden or zero-sized. O(log n): a run of hidden + /// lines all start where the next shown line starts, so one search steps over the whole run. + pub fn next_visible(&self, line: u32) -> Option { + if line >= self.max { + return None; + } + let i = self.lines.partition_point(|e| e.0 < line); + let custom = self.lines.get(i).is_some_and(|e| e.0 == line); + if !custom && self.default > 0.0 { + return Some(line); + } + // Every line from `line` up to the answer is zero-sized, so they all start at `p`; the + // last custom line starting at `p` either has a size (the answer) or is the end of the run. + let p = self.start(line); + let k = self.lines.partition_point(|e| e.1 <= p); + let &(last, _, size) = self.lines.get(k.checked_sub(1)?)?; + if last < line { + return None; + } + if size > 0.0 { + return Some(last).filter(|l| *l < self.max); + } + // The line after the run has no custom line (a custom one would start at `p` too and be + // part of the run): it is shown when the default size is. + let next = last.checked_add(1).filter(|n| *n < self.max)?; + (self.default > 0.0).then_some(next) + } + /// The lines from `first` to `last` (inclusive) with a size above zero, in order. Runs of + /// hidden lines cost one O(log n) step each, not one per line. + pub fn visible(&self, first: u32, last: u32) -> impl Iterator + '_ { + let mut next = self.next_visible(first); + std::iter::from_fn(move || { + let line = next.filter(|l| *l <= last)?; + next = line.checked_add(1).and_then(|n| self.next_visible(n)); + Some(line) + }) + } /// The line containing `pos`. pub fn at(&self, pos: f64) -> u32 { if pos <= 0.0 { @@ -644,6 +684,63 @@ mod tests { use super::*; use crate::cell::Formula; + /// `LineIndex::visible` lists exactly the lines a line-by-line size check shows, for hidden + /// runs at the start, in the middle and up to the end of the axis, custom sizes (zero and + /// negative included), lines with only a style, and a zero default size. + #[test] + fn visible_lines_match_a_line_by_line_check() { + let max = 400u32; + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut rnd = |n: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed % n + }; + for case in 0..300 { + let mut custom = BTreeMap::new(); + let runs = rnd(6); + for _ in 0..runs { + let start = rnd(u64::from(max)) as u32; + let len = rnd(if case % 3 == 0 { 400 } else { 30 }) as u32; + for l in start..(start + len).min(max) { + custom.insert(l, LineInfo { hidden: true, ..Default::default() }); + } + } + // Runs that start at the first line or end at the last one. + if case % 5 == 0 { + for l in 0..rnd(20) as u32 { + custom.insert(l, LineInfo { hidden: true, ..Default::default() }); + } + } + if case % 5 == 1 { + for l in max - rnd(20) as u32 - 1..max { + custom.insert(l, LineInfo { hidden: true, ..Default::default() }); + } + } + for _ in 0..rnd(8) { + let size = [Some(0.0), Some(3.0), Some(20.0), Some(-5.0), None][rnd(5) as usize]; + custom.insert(rnd(u64::from(max)) as u32, LineInfo { size, ..Default::default() }); + } + let default = if case % 7 == 0 { 0.0 } else { 15.0 }; + let size = |l: u32| match custom.get(&l) { + Some(i) if i.hidden => 0.0, + Some(LineInfo { size: Some(s), .. }) => s.max(0.0), + _ => default, + }; + let index = LineIndex::new(&custom, default, max); + for (first, last) in [(0, max - 1), (rnd(u64::from(max)) as u32, max - 1), (5, 5), (max - 1, max - 1)] { + let want: Vec = (first..=last).filter(|&l| size(l) > 0.0).collect(); + assert_eq!(index.visible(first, last).collect::>(), want, "case {case}, {first}..={last}"); + } + assert_eq!(index.next_visible(max), None); + } + // The row after a million hidden rows comes straight after the last shown one. + let custom: BTreeMap = (47..1_048_422).map(|r| (r, LineInfo { hidden: true, ..Default::default() })).collect(); + let index = LineIndex::new(&custom, 15.0, 1_048_576); + assert_eq!(index.visible(40, 1_048_424).collect::>(), [40, 41, 42, 43, 44, 45, 46, 1_048_422, 1_048_423, 1_048_424]); + } + #[test] fn a_sheet_keeps_its_identity_through_clones_and_a_new_sheet_gets_its_own() { let a = Sheet::new("A"); 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