mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-09 12:00:01 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17cb3c83d9 |
Generated
+17
-18
@@ -2022,7 +2022,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -2049,7 +2049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-calc"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -2061,7 +2061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-chart"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-core",
|
||||
@@ -2072,7 +2072,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-cli"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
@@ -2084,7 +2084,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-core"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"js-sys",
|
||||
@@ -2095,7 +2095,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-engine"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-chart",
|
||||
@@ -2118,7 +2118,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-formula"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"serde",
|
||||
@@ -2127,7 +2127,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-functions"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"regex",
|
||||
@@ -2135,7 +2135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-math"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"ratex-font",
|
||||
"ratex-layout",
|
||||
@@ -2145,7 +2145,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-mcp"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"log",
|
||||
@@ -2154,7 +2154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-model"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -2167,18 +2167,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-numfmt"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-pdf"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-ui-egui"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -2197,7 +2197,6 @@ dependencies = [
|
||||
"skrifa",
|
||||
"sys-locale",
|
||||
"toml",
|
||||
"unicode-bidi",
|
||||
"web-sys",
|
||||
"web-time",
|
||||
"zbus",
|
||||
@@ -2205,7 +2204,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-web"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
@@ -2224,7 +2223,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"gridcraft-core",
|
||||
@@ -6675,7 +6674,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.16.0"
|
||||
version = "0.18.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
+3
-6
@@ -43,7 +43,8 @@ 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), Devanagari text needs shaping verification (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), Arabic and Devanagari text
|
||||
isn't shaped (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)).
|
||||
|
||||
@@ -104,7 +105,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 | catalog pending; shared shaping/bidi works | 0% |
|
||||
| Arabic | ar | none (no RTL layout, no shaping) | 0% |
|
||||
| French | fr | none | 0% |
|
||||
| Portuguese | pt-BR | menus, File view, dialogs | ~43% |
|
||||
| Indonesian | id | none | 0% |
|
||||
@@ -148,8 +149,6 @@ 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`) |
|
||||
@@ -277,8 +276,6 @@ 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 |
|
||||
|
||||
+10
-258
@@ -346,91 +346,19 @@ pub fn compare_numbers(a: f64, b: f64) -> Ordering {
|
||||
round15(a).partial_cmp(&round15(b)).unwrap_or(Ordering::Equal)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Case-insensitive text order (Unicode lowercase).
|
||||
pub fn compare_text(a: &str, b: &str) -> Ordering {
|
||||
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;
|
||||
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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
lowercase_chars(a).cmp(lowercase_chars(b))
|
||||
}
|
||||
|
||||
fn lowercase_chars(s: &str) -> impl Iterator<Item = char> + '_ {
|
||||
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<u16> {
|
||||
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<Item = char> + '_ {
|
||||
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<Item = u16> + '_ {
|
||||
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.
|
||||
@@ -529,182 +457,6 @@ 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::<String>();
|
||||
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 {
|
||||
|
||||
@@ -368,9 +368,6 @@ 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;
|
||||
}
|
||||
@@ -420,28 +417,6 @@ 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();
|
||||
|
||||
@@ -317,9 +317,5 @@ 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);
|
||||
}
|
||||
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()
|
||||
seen.into_iter().map(|((_, t), on)| (t, on)).collect()
|
||||
}
|
||||
|
||||
@@ -53,17 +53,6 @@ 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<String> = 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();
|
||||
|
||||
@@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Format Cells…",
|
||||
["Home", "Cells", "Format"],
|
||||
Some("Cmd+1"),
|
||||
"{range?, style: {font?, fill?, borders?, align?: {reading_order?: Context|LeftToRight|RightToLeft|null, ...}, numFmt?, protection?}} (partial Style JSON merged in; null clears reading_order)",
|
||||
"{range?, style: {font?, fill?, borders?, align?, numFmt?, protection?}} (partial Style JSON merged in)",
|
||||
has_doc,
|
||||
format_cells
|
||||
),
|
||||
@@ -870,52 +870,6 @@ 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);
|
||||
|
||||
@@ -136,55 +136,12 @@ 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,
|
||||
c if is_zero_width(c) => 0.0,
|
||||
c => arabic_em(c).unwrap_or(1.0),
|
||||
_ => 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<f32> {
|
||||
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;
|
||||
|
||||
@@ -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(|| gridcraft_core::compare_text(&a.text, &b.text))
|
||||
a.class.cmp(&b.class).then(a.num.total_cmp(&b.num)).then_with(|| a.text.cmp(&b.text))
|
||||
}
|
||||
|
||||
/// (identity key, item) for a source value under a field's date grouping.
|
||||
|
||||
@@ -18,44 +18,6 @@ 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();
|
||||
@@ -1497,28 +1459,6 @@ 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<Value> = (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<Vec<&str>> = 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<Value> = (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<Value> {
|
||||
|
||||
@@ -230,30 +230,6 @@ 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
|
||||
|
||||
@@ -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, ReadingOrder, Style, StyleId, StyleTable, Theme,
|
||||
Underline, VAlign, VertAlign,
|
||||
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline,
|
||||
VAlign, VertAlign,
|
||||
};
|
||||
pub use workbook::{
|
||||
CalcMode, CalcSettings, DefinedName, DocProps, EQUATION_BUDGET_MESSAGE, MAX_EQUATION_LAYOUT_BYTES, PhoneticProps, PhoneticRun, PhoneticString,
|
||||
|
||||
@@ -350,14 +350,6 @@ 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,
|
||||
@@ -367,9 +359,6 @@ 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<ReadingOrder>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -499,25 +488,6 @@ static DEFAULT_STYLE: std::sync::LazyLock<Style> = std::sync::LazyLock::new(Styl
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reading_order_defaults_for_older_json_and_distinguishes_styles() {
|
||||
let align: Alignment = serde_json::from_str(r#"{"h":"General","v":"Bottom","wrap":false,"shrink":false,"indent":0,"rotation":0}"#).unwrap();
|
||||
assert_eq!(align.reading_order, None);
|
||||
let mut styles = StyleTable::default();
|
||||
let context = styles.derive(StyleId::DEFAULT, |s| s.align.reading_order = Some(ReadingOrder::Context));
|
||||
let ltr = styles.derive(StyleId::DEFAULT, |s| s.align.reading_order = Some(ReadingOrder::LeftToRight));
|
||||
let rtl = styles.derive(StyleId::DEFAULT, |s| s.align.reading_order = Some(ReadingOrder::RightToLeft));
|
||||
assert_ne!(ltr, rtl);
|
||||
assert_ne!(context, StyleId::DEFAULT);
|
||||
assert_ne!(context, ltr);
|
||||
assert_ne!(context, rtl);
|
||||
assert_ne!(ltr, StyleId::DEFAULT);
|
||||
assert_ne!(rtl, StyleId::DEFAULT);
|
||||
assert_eq!(styles.derive(ltr, |s| s.align.reading_order = Some(ReadingOrder::RightToLeft)), rtl);
|
||||
assert_eq!(styles.derive(rtl, |s| s.align.reading_order = Some(ReadingOrder::Context)), context);
|
||||
assert_eq!(styles.derive(context, |s| s.align.reading_order = None), StyleId::DEFAULT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interning() {
|
||||
let mut t = StyleTable::default();
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
//! Calendars a format section can show dates in: the `B1` (Gregorian) and `B2` (Hijri) prefixes.
|
||||
|
||||
use gridcraft_core::date::{DateTime, MONTHS, WEEKDAYS, days_from_civil, days_in_month};
|
||||
|
||||
/// The calendar a section's year, month and day parts count in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub(crate) enum Calendar {
|
||||
#[default]
|
||||
Gregorian,
|
||||
/// The tabular (arithmetic) Islamic calendar.
|
||||
Hijri,
|
||||
}
|
||||
|
||||
/// Arabic names of the Hijri months, Muharram first. Two-word names are joined by a no-break
|
||||
/// space (U+00A0), as Excel writes them.
|
||||
const HIJRI_MONTHS: [&str; 12] = [
|
||||
"محرم",
|
||||
"صفر",
|
||||
"ربيع\u{a0}الأول",
|
||||
"ربيع\u{a0}الثاني",
|
||||
"جمادى\u{a0}الأولى",
|
||||
"جمادى\u{a0}الثانية",
|
||||
"رجب",
|
||||
"شعبان",
|
||||
"رمضان",
|
||||
"شوال",
|
||||
"ذو\u{a0}القعدة",
|
||||
"ذو\u{a0}الحجة",
|
||||
];
|
||||
|
||||
/// Arabic weekday names, Sunday first, shown for `ddd` and `dddd` under `B2`.
|
||||
const ARABIC_WEEKDAYS: [&str; 7] = ["الأحد", "الإثنين", "الثلاثاء", "الأربعاء", "الخميس", "الجمعة", "السبت"];
|
||||
|
||||
/// Year, month and day of a date in a section's calendar, with the month and weekday names.
|
||||
pub(crate) struct CalendarDate {
|
||||
pub year: i64,
|
||||
pub month: u32,
|
||||
pub day: u32,
|
||||
/// `mmmm`.
|
||||
pub month_name: &'static str,
|
||||
/// `mmm`.
|
||||
pub month_short: &'static str,
|
||||
/// `dddd`.
|
||||
pub day_name: &'static str,
|
||||
/// `ddd`.
|
||||
pub day_short: &'static str,
|
||||
}
|
||||
|
||||
impl Calendar {
|
||||
/// The date of a day in this calendar, `None` when it has none: under `B2`, the 1900 system's
|
||||
/// fictitious 29 February 1900 (serial 60, which Excel shows as `#VALUE!`) and days before
|
||||
/// 1 Muharram 1 AH.
|
||||
pub(crate) fn date(self, dt: &DateTime) -> Option<CalendarDate> {
|
||||
match self {
|
||||
Calendar::Hijri => hijri_date(dt),
|
||||
Calendar::Gregorian => Some(gregorian_date(dt)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gregorian_date(dt: &DateTime) -> CalendarDate {
|
||||
let month_name = MONTHS.get((dt.month as usize).wrapping_sub(1)).copied().unwrap_or("");
|
||||
let day_name = WEEKDAYS.get(dt.weekday as usize).copied().unwrap_or("");
|
||||
CalendarDate {
|
||||
year: dt.year.into(),
|
||||
month: dt.month,
|
||||
day: dt.day,
|
||||
month_name,
|
||||
month_short: month_name.get(..3).unwrap_or(month_name),
|
||||
day_name,
|
||||
day_short: day_name.get(..3).unwrap_or(day_name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Days from 1 Muharram 1 AH to the first of a Hijri month. Leap years (30 days in the last
|
||||
/// month) are years 2, 5, 7, 10, 13, 15, 18, 21, 24, 26 and 29 of each 30-year cycle (10,631
|
||||
/// days), which is what Excel shows; odd months have 30 days and even months 29.
|
||||
fn days_before_month(year: i64, month: i64) -> i64 {
|
||||
// `(4 + 11 * year) / 30` is the number of leap years before `year`.
|
||||
(year - 1) * 354 + (4 + 11 * year).div_euclid(30) + 29 * (month - 1) + month / 2
|
||||
}
|
||||
|
||||
/// Hijri year, month and day of the day `days` after 1 Muharram 1 AH (`days >= 0`).
|
||||
fn hijri_from_days(days: i64) -> Option<(i64, i64, i64)> {
|
||||
if days < 0 {
|
||||
return None;
|
||||
}
|
||||
// The estimate is the mean year length rounded; it can be one year off at a year boundary.
|
||||
// Estimate and `days_before_month` both move by 30 years per 10,631 days, so one step is
|
||||
// enough everywhere if it is within one cycle (`every_day_round_trips` checks many).
|
||||
let mut year = (30 * days + 10646) / 10631;
|
||||
if days < days_before_month(year, 1) {
|
||||
year -= 1;
|
||||
} else if days >= days_before_month(year + 1, 1) {
|
||||
year += 1;
|
||||
}
|
||||
let month = ((11 * (days - days_before_month(year, 1)) + 330) / 325).clamp(1, 12);
|
||||
let day = days - days_before_month(year, month) + 1;
|
||||
Some((year, month, day))
|
||||
}
|
||||
|
||||
/// 1 Muharram 1 AH is 18 July 622 (proleptic Gregorian), so that 1 January 2001 is 6 Shawwal 1421.
|
||||
fn hijri_date(dt: &DateTime) -> Option<CalendarDate> {
|
||||
// 29 February 1900 (serial 60) is no real day and has no Hijri date. The 1900 system's day 0
|
||||
// (`1900-01-00`, serial 0) shows as 1 January 1900, as Excel shows it.
|
||||
if dt.day > days_in_month(dt.year, dt.month) {
|
||||
return None;
|
||||
}
|
||||
let (year, month, day) = hijri_from_days(days_from_civil(dt.year, dt.month, dt.day.max(1)) - days_from_civil(622, 7, 18))?;
|
||||
let month_name = HIJRI_MONTHS.get(usize::try_from(month - 1).ok()?).copied()?;
|
||||
// Excel shows the full Arabic name for `ddd` as well as `dddd`, as it does for `mmm`.
|
||||
let day_name = ARABIC_WEEKDAYS.get(dt.weekday as usize).copied()?;
|
||||
Some(CalendarDate {
|
||||
year,
|
||||
month: u32::try_from(month).ok()?,
|
||||
day: u32::try_from(day).ok()?,
|
||||
month_name,
|
||||
month_short: month_name,
|
||||
day_name,
|
||||
day_short: day_name,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gridcraft_core::DateSystem;
|
||||
use gridcraft_core::date::{datetime_from_serial, max_serial};
|
||||
|
||||
/// Leap years of the 30-year cycle (Excel's set, per the reviewer's 43,512-serial comparison).
|
||||
const LEAP: [i64; 11] = [2, 5, 7, 10, 13, 15, 18, 21, 24, 26, 29];
|
||||
|
||||
fn is_leap(year: i64) -> bool {
|
||||
LEAP.contains(&((year - 1).rem_euclid(30) + 1))
|
||||
}
|
||||
|
||||
fn month_len(year: i64, month: i64) -> i64 {
|
||||
if month == 12 { 29 + i64::from(is_leap(year)) } else { 30 - (month + 1) % 2 }
|
||||
}
|
||||
|
||||
/// Years 1 to 10,000 (every cycle up to and past 31 December 9999, which is 9666 AH): year,
|
||||
/// month and cycle lengths follow the leap-year set.
|
||||
#[test]
|
||||
fn year_and_month_lengths_follow_the_leap_set() {
|
||||
assert_eq!(days_before_month(1, 1), 0);
|
||||
for year in 1..=10_000 {
|
||||
let start = days_before_month(year, 1);
|
||||
let len = days_before_month(year + 1, 1) - start;
|
||||
assert_eq!(len, if is_leap(year) { 355 } else { 354 }, "year {year}");
|
||||
for month in 1..=12 {
|
||||
let next = if month == 12 { days_before_month(year + 1, 1) } else { days_before_month(year, month + 1) };
|
||||
assert_eq!(next - days_before_month(year, month), month_len(year, month), "{year}-{month}");
|
||||
}
|
||||
assert_eq!(days_before_month(year + 30, 1) - start, 10_631, "cycle from {year}");
|
||||
}
|
||||
// One cycle: exactly the eleven leap years, 10,631 days.
|
||||
let cycle: Vec<i64> = (1..=30).filter(|&y| days_before_month(y + 1, 1) - days_before_month(y, 1) == 355).collect();
|
||||
assert_eq!(cycle, LEAP);
|
||||
}
|
||||
|
||||
/// Every day from 1 Muharram 1 AH to one cycle past 31 December 9999: day -> Hijri -> day
|
||||
/// round-trips, the day is within its month, and consecutive days follow each other.
|
||||
#[test]
|
||||
fn every_day_round_trips() {
|
||||
let last = days_from_civil(9999, 12, 31) - days_from_civil(622, 7, 18) + 10_631;
|
||||
let mut prev: Option<(i64, i64, i64)> = None;
|
||||
for days in 0..=last {
|
||||
let (y, m, d) = hijri_from_days(days).unwrap();
|
||||
assert!((1..=12).contains(&m) && (1..=month_len(y, m)).contains(&d), "{days}: {y}-{m}-{d}");
|
||||
assert_eq!(days_before_month(y, m) + d - 1, days, "{y}-{m}-{d}");
|
||||
if let Some((py, pm, pd)) = prev {
|
||||
let expected = if pd < month_len(py, pm) {
|
||||
(py, pm, pd + 1)
|
||||
} else if pm < 12 {
|
||||
(py, pm + 1, 1)
|
||||
} else {
|
||||
(py + 1, 1, 1)
|
||||
};
|
||||
assert_eq!((y, m, d), expected, "after {py}-{pm}-{pd}");
|
||||
}
|
||||
prev = Some((y, m, d));
|
||||
}
|
||||
assert_eq!(hijri_from_days(-1), None);
|
||||
}
|
||||
|
||||
/// Every serial of the 1900 system: the Hijri date names the serial's day, one day apart.
|
||||
#[test]
|
||||
fn every_serial_has_the_right_hijri_day() {
|
||||
let epoch = days_from_civil(622, 7, 18);
|
||||
let mut prev = None;
|
||||
for serial in 1..=max_serial(DateSystem::D1900) {
|
||||
if serial == 60 {
|
||||
continue;
|
||||
}
|
||||
let dt = datetime_from_serial(DateSystem::D1900, serial as f64).unwrap();
|
||||
let h = Calendar::Hijri.date(&dt).unwrap();
|
||||
assert!((1..=month_len(h.year, i64::from(h.month))).contains(&i64::from(h.day)), "serial {serial}");
|
||||
let days = days_before_month(h.year, i64::from(h.month)) + i64::from(h.day) - 1;
|
||||
assert_eq!(days, days_from_civil(dt.year, dt.month, dt.day) - epoch, "serial {serial}");
|
||||
if let Some(p) = prev {
|
||||
assert_eq!(days, p + 1, "serial {serial}");
|
||||
}
|
||||
prev = Some(days);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod builtin;
|
||||
mod calendar;
|
||||
mod decimal;
|
||||
mod parse;
|
||||
mod render;
|
||||
@@ -97,9 +96,6 @@ struct Inner {
|
||||
num_sections: usize,
|
||||
/// Index of the section that applies to text, if any.
|
||||
text_section: Option<usize>,
|
||||
/// A code Excel rejects (a misplaced `B1`/`B2` calendar prefix): shown as General, and
|
||||
/// `TEXT` returns `#VALUE!`, as for a code that is too long.
|
||||
invalid: bool,
|
||||
}
|
||||
|
||||
/// A parsed number format code. Cheap to clone.
|
||||
@@ -134,8 +130,7 @@ fn too_long(code: &str) -> bool {
|
||||
|
||||
impl NumberFormat {
|
||||
/// Parses a format code. Never fails: unknown or malformed parts degrade to literal text, and
|
||||
/// a code longer than [`MAX_FORMAT_CODE_CHARS`] characters, or one Excel rejects for a
|
||||
/// misplaced `B1`/`B2` calendar prefix, degrades to General.
|
||||
/// a code longer than [`MAX_FORMAT_CODE_CHARS`] characters degrades to General.
|
||||
pub fn parse(code: &str) -> NumberFormat {
|
||||
Self::parse_inner(code, false)
|
||||
}
|
||||
@@ -152,16 +147,12 @@ impl NumberFormat {
|
||||
let trimmed = code.trim();
|
||||
// `checkbox` marks a checkbox cell (Insert › Checkbox): TRUE/FALSE draw as a box, any other
|
||||
// value displays as General.
|
||||
let general = |invalid: bool| NumberFormat {
|
||||
inner: Arc::new(Inner { code: code.to_string(), general: true, sections: Vec::new(), num_sections: 0, text_section: None, invalid }),
|
||||
};
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("general") || trimmed == "checkbox" || (!uncapped && too_long(code)) {
|
||||
return general(false);
|
||||
return NumberFormat {
|
||||
inner: Arc::new(Inner { code: code.to_string(), general: true, sections: Vec::new(), num_sections: 0, text_section: None }),
|
||||
};
|
||||
}
|
||||
let sections = parse::parse_sections(code);
|
||||
if sections.iter().any(|s| s.invalid) {
|
||||
return general(true);
|
||||
}
|
||||
let len = sections.len();
|
||||
let last_is_text = sections.last().is_some_and(|s| s.kind == SecKind::Text);
|
||||
let text_section = if len >= 4 {
|
||||
@@ -178,7 +169,7 @@ impl NumberFormat {
|
||||
} else {
|
||||
len
|
||||
};
|
||||
NumberFormat { inner: Arc::new(Inner { code: code.to_string(), general: false, sections, num_sections, text_section, invalid: false }) }
|
||||
NumberFormat { inner: Arc::new(Inner { code: code.to_string(), general: false, sections, num_sections, text_section }) }
|
||||
}
|
||||
|
||||
pub fn general() -> NumberFormat {
|
||||
@@ -496,9 +487,6 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, C
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
let fmt = NumberFormat::parse_cached(code);
|
||||
if fmt.inner.invalid {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
let n = match v.scalar() {
|
||||
Value::Error(e) => return Err(e),
|
||||
Value::Empty => 0.0,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Format code parsing: sections, bracket tags and tokens.
|
||||
|
||||
use crate::FormatColor;
|
||||
use crate::calendar::Calendar;
|
||||
|
||||
/// At most this many sections are honoured (`pos;neg;zero;text`); extra ones are ignored.
|
||||
const MAX_SECTIONS: usize = 4;
|
||||
@@ -25,11 +24,6 @@ pub(crate) enum DatePart {
|
||||
}
|
||||
|
||||
impl DatePart {
|
||||
/// `[h]`, `[m]` or `[s]`.
|
||||
pub(crate) fn is_elapsed(self) -> bool {
|
||||
matches!(self, DatePart::ElapsedH(_) | DatePart::ElapsedM(_) | DatePart::ElapsedS(_))
|
||||
}
|
||||
|
||||
pub(crate) fn is_calendar(self) -> bool {
|
||||
matches!(self, DatePart::Year(_) | DatePart::Month(_) | DatePart::Day(_))
|
||||
}
|
||||
@@ -128,10 +122,6 @@ pub(crate) struct Section {
|
||||
pub fraction: bool,
|
||||
pub currency: bool,
|
||||
pub fill: bool,
|
||||
pub calendar: Calendar,
|
||||
/// Excel rejects the whole code because of this section: a `B` and digit that is not a
|
||||
/// calendar prefix, or a prefix before a number format (`B20.00`).
|
||||
pub invalid: bool,
|
||||
}
|
||||
|
||||
/// Splits a code into sections at unquoted, unescaped, unbracketed semicolons.
|
||||
@@ -261,60 +251,17 @@ struct Raw {
|
||||
color: Option<FormatColor>,
|
||||
cond: Option<Cond>,
|
||||
currency: bool,
|
||||
calendar: Calendar,
|
||||
/// The section's calendar prefix as written, `'1'` or `'2'` (whether or not a locale tag
|
||||
/// cancels it).
|
||||
prefix: Option<char>,
|
||||
/// An unquoted `B` and digit that is not a calendar prefix: Excel rejects the code.
|
||||
invalid: bool,
|
||||
}
|
||||
|
||||
/// Where a section's body starts: after its leading `[...]` tags, read as `tokenize` reads them.
|
||||
/// Also whether one of those tags is a `[$...]` locale tag.
|
||||
fn after_leading_brackets(cs: &[char]) -> (usize, bool) {
|
||||
let mut i = 0usize;
|
||||
let mut locale = false;
|
||||
while cs.get(i) == Some(&'[') {
|
||||
match cs.get(i + 1..).and_then(|r| r.iter().position(|&d| d == ']')) {
|
||||
Some(p) => {
|
||||
locale |= cs.get(i + 1) == Some(&'$');
|
||||
i += p + 2;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
(i, locale)
|
||||
}
|
||||
|
||||
fn tokenize(src: &str) -> Raw {
|
||||
let cs: Vec<char> = src.chars().collect();
|
||||
let mut raw = Raw { toks: Vec::new(), color: None, cond: None, currency: false, calendar: Calendar::Gregorian, prefix: None, invalid: false };
|
||||
let mut raw = Raw { toks: Vec::new(), color: None, cond: None, currency: false };
|
||||
let toks = &mut raw.toks;
|
||||
// With no `]` at or after a `[`, that `[` is a literal; checking this once keeps the scan linear.
|
||||
let last_close = cs.iter().rposition(|&c| c == ']');
|
||||
let (body_start, locale_first) = after_leading_brackets(&cs);
|
||||
// Where the next calendar prefix may stand: the body's start, or right after another prefix.
|
||||
let mut prefix_at = body_start;
|
||||
let mut i = 0usize;
|
||||
while let Some(&c) = cs.get(i) {
|
||||
match c {
|
||||
// A calendar prefix, never shown: `B1` or `B2`, any case, which Excel accepts repeated
|
||||
// (`B2B2`) but not mixed (`B2B1`) or with more digits (`B12`, `B02`).
|
||||
'B' | 'b' if i == prefix_at && cs.get(i + 1).is_some_and(char::is_ascii_digit) => {
|
||||
let d = cs.get(i + 1).copied();
|
||||
let ok = matches!(d, Some('1' | '2')) && !cs.get(i + 2).is_some_and(char::is_ascii_digit) && raw.prefix.is_none_or(|p| Some(p) == d);
|
||||
raw.invalid |= !ok;
|
||||
raw.prefix = d;
|
||||
i += 2;
|
||||
prefix_at = i;
|
||||
continue;
|
||||
}
|
||||
// Any other `B` and digit (`B3dd`, `dd/mm/yyyyB2`, `dd B2 mm`, `B2[Red]B2`) makes Excel
|
||||
// reject the code.
|
||||
'B' | 'b' if cs.get(i + 1).is_some_and(char::is_ascii_digit) => {
|
||||
raw.invalid = true;
|
||||
push_lit(toks, c.encode_utf8(&mut [0; 4]));
|
||||
}
|
||||
'"' => {
|
||||
let mut s = String::new();
|
||||
i += 1;
|
||||
@@ -336,8 +283,6 @@ fn tokenize(src: &str) -> Raw {
|
||||
if is_currency_char(d) {
|
||||
raw.currency = true;
|
||||
}
|
||||
// Excel rejects an escaped `\B2` as well.
|
||||
raw.invalid |= matches!(d, 'B' | 'b') && cs.get(i + 2).is_some_and(char::is_ascii_digit);
|
||||
push_lit(toks, d.encode_utf8(&mut [0; 4]));
|
||||
}
|
||||
i += 2;
|
||||
@@ -438,11 +383,6 @@ fn tokenize(src: &str) -> Raw {
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
// Excel keeps the date Gregorian when a `[$-401]`-style locale or currency tag comes before
|
||||
// `B2` (`B2[$-401]` is still Hijri).
|
||||
if raw.prefix == Some('2') && !locale_first {
|
||||
raw.calendar = Calendar::Hijri;
|
||||
}
|
||||
raw
|
||||
}
|
||||
|
||||
@@ -498,7 +438,7 @@ fn date_part(t: &Tok) -> Option<DatePart> {
|
||||
}
|
||||
|
||||
fn analyze(raw: Raw) -> Section {
|
||||
let Raw { toks, color, cond, currency, calendar, prefix, invalid } = raw;
|
||||
let Raw { toks, color, cond, currency } = raw;
|
||||
let is_date = toks.iter().any(|t| matches!(t, Tok::Date(_)));
|
||||
let has_general = toks.iter().any(|t| matches!(t, Tok::General));
|
||||
let mut sec = Section {
|
||||
@@ -515,8 +455,6 @@ fn analyze(raw: Raw) -> Section {
|
||||
fraction: false,
|
||||
currency,
|
||||
fill: toks.iter().any(|t| matches!(t, Tok::Fill(_))),
|
||||
calendar,
|
||||
invalid,
|
||||
};
|
||||
if is_date {
|
||||
sec.kind = SecKind::Date;
|
||||
@@ -531,9 +469,6 @@ fn analyze(raw: Raw) -> Section {
|
||||
sec.kind = SecKind::Text;
|
||||
return analyze_text(toks, sec);
|
||||
}
|
||||
// Number section. Excel rejects a calendar prefix before digits (`B20.00`, `B1#,##0`);
|
||||
// `B2General`, `B2@` and `B2"x"` show as without the prefix.
|
||||
sec.invalid |= prefix.is_some() && sec.has_digits;
|
||||
analyze_number(toks, sec)
|
||||
}
|
||||
|
||||
|
||||
+12
-22
@@ -1,9 +1,8 @@
|
||||
//! Rendering a value with a parsed section.
|
||||
|
||||
use gridcraft_core::DateSystem;
|
||||
use gridcraft_core::date::{datetime_from_serial, max_serial};
|
||||
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, max_serial};
|
||||
|
||||
use crate::calendar::Calendar;
|
||||
use crate::decimal::{decimal_digits, fixed, fixed_from_digits, format_general_fit};
|
||||
use crate::parse::{DatePart, Section, Tok};
|
||||
|
||||
@@ -73,15 +72,6 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option<Out>
|
||||
day += 1;
|
||||
}
|
||||
let total_ms = day.saturating_mul(86_400_000).saturating_add(ms);
|
||||
// Under `B2`, Excel shows the 1900 system's fictitious 29 February (serial 60) as `#VALUE!`
|
||||
// whenever a clock or calendar part is shown, even for `hh:mm` (`[h]` alone still works).
|
||||
if sec.calendar == Calendar::Hijri
|
||||
&& sys == DateSystem::D1900
|
||||
&& day == 60
|
||||
&& sec.toks.iter().any(|t| matches!(t, Tok::Date(d) if !d.is_elapsed()))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let needs_date = sec.toks.iter().any(|t| matches!(t, Tok::Date(d) if d.is_calendar()));
|
||||
let dt = if needs_date { datetime_from_serial(sys, day as f64)? } else { datetime_from_serial(sys, 0.0).unwrap_or_default() };
|
||||
let ampm = sec.toks.iter().any(|t| matches!(t, Tok::Date(DatePart::AmPm | DatePart::AP(..))));
|
||||
@@ -90,8 +80,8 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option<Out>
|
||||
let second = (ms / 1000) % 60;
|
||||
let milli = ms % 1000;
|
||||
let pad = |n: i64, w: u8| if w >= 2 { format!("{:0w$}", n, w = w.min(20) as usize) } else { n.to_string() };
|
||||
// No date in the section's calendar (serial 60 under `B2`): shown like an out-of-range date.
|
||||
let date = sec.calendar.date(&dt)?;
|
||||
let month_name = MONTHS.get((dt.month as usize).wrapping_sub(1)).copied().unwrap_or("");
|
||||
let day_name = WEEKDAYS.get(dt.weekday as usize).copied().unwrap_or("");
|
||||
let mut out = Out::default();
|
||||
for t in &sec.toks {
|
||||
match t {
|
||||
@@ -99,21 +89,21 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option<Out>
|
||||
let s = match *d {
|
||||
DatePart::Year(n) => {
|
||||
if n <= 2 {
|
||||
format!("{:02}", date.year.rem_euclid(100))
|
||||
format!("{:02}", dt.year.rem_euclid(100))
|
||||
} else {
|
||||
format!("{:04}", date.year)
|
||||
format!("{:04}", dt.year)
|
||||
}
|
||||
}
|
||||
DatePart::Month(n) => match n {
|
||||
1 | 2 => pad(date.month as i64, n),
|
||||
3 => date.month_short.to_string(),
|
||||
5 => date.month_name.chars().take(1).collect(),
|
||||
_ => date.month_name.to_string(),
|
||||
1 | 2 => pad(dt.month as i64, n),
|
||||
3 => month_name.chars().take(3).collect(),
|
||||
5 => month_name.chars().take(1).collect(),
|
||||
_ => month_name.to_string(),
|
||||
},
|
||||
DatePart::Day(n) => match n {
|
||||
1 | 2 => pad(date.day as i64, n),
|
||||
3 => date.day_short.to_string(),
|
||||
_ => date.day_name.to_string(),
|
||||
1 | 2 => pad(dt.day as i64, n),
|
||||
3 => day_name.chars().take(3).collect(),
|
||||
_ => day_name.to_string(),
|
||||
},
|
||||
DatePart::Hour(n) => {
|
||||
let h = if ampm {
|
||||
|
||||
@@ -902,240 +902,6 @@ fn closed_brackets_still_hold_their_semicolons() {
|
||||
assert_eq!(f("\"[\"0;0", 5.0), "[5");
|
||||
}
|
||||
|
||||
const JAN_1_2001: f64 = 36892.0;
|
||||
|
||||
#[test]
|
||||
fn b1_prefix_is_hidden_and_gregorian() {
|
||||
assert_eq!(f("B1dd/mm/yyyy", 46314.0), "19/10/2026");
|
||||
assert_eq!(f("b1dddd, mmmm d, yyyy", 46314.0), "Monday, October 19, 2026");
|
||||
assert_eq!(f("B1yyyy-mm-dd hh:mm", 46314.5), "2026-10-19 12:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b2_prefix_renders_hijri_date() {
|
||||
assert_eq!(f("B2dd/mm/yyyy", JAN_1_2001), "06/10/1421");
|
||||
assert_eq!(f("b2d/m/yy", JAN_1_2001), "6/10/21");
|
||||
assert_eq!(f("B2dddd dd/mm/yyyy hh:mm", JAN_1_2001 + 0.75), "الإثنين 06/10/1421 18:00");
|
||||
assert_eq!(text_function(&Value::Number(46314.0), "B2dd/mm/yyyy", S).as_deref(), Ok("08/05/1448"));
|
||||
assert_eq!(f("B2e", 46314.0), "1448");
|
||||
assert_eq!(f("B2ee", 46314.0), "1448");
|
||||
assert_eq!(f("B2d/m/yyyy;@", 46314.0), "8/5/1448");
|
||||
}
|
||||
|
||||
/// Under `B2`, Excel shows Arabic weekday names, the full name for `ddd` as well as `dddd`.
|
||||
#[test]
|
||||
fn b2_prefix_names_weekdays_in_arabic() {
|
||||
// Monday 19 and Tuesday 20 October 2026.
|
||||
assert_eq!(f("B2dddd", 46314.0), "الإثنين");
|
||||
assert_eq!(f("B2ddd", 46314.0), "الإثنين");
|
||||
assert_eq!(f("B2dddd", 46315.0), "الثلاثاء");
|
||||
assert_eq!(f("B1dddd", 46314.0), "Monday");
|
||||
assert_eq!(f("B1ddd", 46315.0), "Tue");
|
||||
// Every weekday has a name.
|
||||
for v in 46314..46321 {
|
||||
assert!(!f("B2dddd", v as f64).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// `B2` on a General or text section changes nothing: Excel shows the plain serial.
|
||||
#[test]
|
||||
fn b2_prefix_on_general_and_text_sections() {
|
||||
assert_eq!(f("B2General", 46314.0), "46314");
|
||||
assert_eq!(f("B2@", 46314.0), "46314");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b2_prefix_names_hijri_months() {
|
||||
assert_eq!(f("B2d mmmm yyyy", JAN_1_2001), "6 شوال 1421");
|
||||
assert_eq!(f("B2mmm", 37034.0), "ربيع\u{a0}الأول");
|
||||
assert_eq!(f("B2mmmm", JAN_1_2001 + 23.0), "شوال");
|
||||
assert_eq!(f("B2mmmm", JAN_1_2001 + 24.0), "ذو\u{a0}القعدة");
|
||||
assert_eq!(f("B2mmmmm", JAN_1_2001 + 24.0), "ذ");
|
||||
}
|
||||
|
||||
/// All twelve month names and seven weekday names as Excel 16.0 (build 20527, en-US) shows them,
|
||||
/// read with TEXT and a cell format: two-word month names are joined by a no-break space.
|
||||
#[test]
|
||||
fn b2_names_match_excel() {
|
||||
let months = [
|
||||
(46219.0, "صفر"),
|
||||
(46248.0, "ربيع\u{a0}الأول"),
|
||||
(46278.0, "ربيع\u{a0}الثاني"),
|
||||
(46307.0, "جمادى\u{a0}الأولى"),
|
||||
(46337.0, "جمادى\u{a0}الثانية"),
|
||||
(46366.0, "رجب"),
|
||||
(46396.0, "شعبان"),
|
||||
(46425.0, "رمضان"),
|
||||
(46455.0, "شوال"),
|
||||
(46484.0, "ذو\u{a0}القعدة"),
|
||||
(46514.0, "ذو\u{a0}الحجة"),
|
||||
(46543.0, "محرم"),
|
||||
];
|
||||
for (serial, name) in months {
|
||||
assert_eq!(f("B2dd", serial), "01", "{serial}");
|
||||
assert_eq!(f("B2mmmm", serial), name, "{serial}");
|
||||
assert_eq!(f("B2mmm", serial), name, "{serial}");
|
||||
assert_eq!(f("B2mmmmmm", serial), name, "{serial}");
|
||||
}
|
||||
// Sunday 18 to Saturday 24 October 2026.
|
||||
let days = ["الأحد", "الإثنين", "الثلاثاء", "الأربعاء", "الخميس", "الجمعة", "السبت"];
|
||||
for (k, name) in days.iter().enumerate() {
|
||||
let serial = 46313.0 + k as f64;
|
||||
assert_eq!(f("B2dddd", serial), *name, "{serial}");
|
||||
assert_eq!(f("B2ddd", serial), *name, "{serial}");
|
||||
}
|
||||
assert_eq!(f("B2yy", 46314.0), "48");
|
||||
assert_eq!(f("B2y", 46314.0), "48");
|
||||
assert_eq!(f("B2yyy", 46314.0), "1448");
|
||||
assert_eq!(f("B2yyyyy", 46314.0), "1448");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b2_month_and_year_rollover() {
|
||||
assert_eq!(f("B2dd/mm/yyyy", 36915.0), "29/10/1421");
|
||||
assert_eq!(f("B2dd/mm/yyyy", 36916.0), "01/11/1421");
|
||||
assert_eq!(f("B2dd/mm/yyyy", 36974.0), "29/12/1421");
|
||||
assert_eq!(f("B2dd/mm/yyyy", 36975.0), "01/01/1422");
|
||||
assert_eq!(f("B2dd/mm/yyyy", 37683.0), "30/12/1423");
|
||||
assert_eq!(f("B2dd/mm/yyyy", 37684.0), "01/01/1424");
|
||||
}
|
||||
|
||||
/// Cycle years 15 (1425, leap) and 16 (1426, not leap): Excel's leap year is 15, not 16. The
|
||||
/// 2 March 2005 and 29 January 2006 values were read from Excel by a reviewer; the year
|
||||
/// boundaries follow from them and the 354/355-day years. The same comparison covered every
|
||||
/// serial from 36,000 to 49,000 with no disagreement.
|
||||
#[test]
|
||||
fn b2_leap_year_is_cycle_year_15() {
|
||||
assert_eq!(f("B2yyyy-mm-dd", 38413.0), "1426-01-21"); // 2005-03-02, from Excel
|
||||
assert_eq!(f("B2yyyy-mm-dd", 38746.0), "1426-12-29"); // 2006-01-29, from Excel
|
||||
assert_eq!(f("B2dd/mm/yyyy", 38037.0), "29/12/1424"); // 2004-02-20
|
||||
assert_eq!(f("B2dd/mm/yyyy", 38038.0), "01/01/1425"); // 2004-02-21
|
||||
assert_eq!(f("B2dd/mm/yyyy", 38392.0), "30/12/1425"); // 2005-02-09
|
||||
assert_eq!(f("B2dd/mm/yyyy", 38393.0), "01/01/1426"); // 2005-02-10
|
||||
assert_eq!(f("B2dd/mm/yyyy", 38747.0), "01/01/1427"); // 2006-01-30
|
||||
// Anchors the reviewer read from Excel.
|
||||
assert_eq!(f("B2dd/mm/yyyy", 46314.0), "08/05/1448"); // 2026-10-19
|
||||
assert_eq!(f("B2dd/mm/yyyy", 46313.0), "07/05/1448"); // 2026-10-18
|
||||
assert_eq!(f("B2dd/mm/yyyy", JAN_1_2001), "06/10/1421");
|
||||
assert_eq!(f("B2dd/mm/yyyy", 61.0), "29/10/1317"); // 1900-03-01
|
||||
assert_eq!(f("B2dd/mm/yyyy", 64970.0), "01/01/1501"); // 2077-11-16
|
||||
assert_eq!(f("B2dd/mm/yyyy", 64971.0), "02/01/1501"); // 2077-11-17
|
||||
assert_eq!(f("B2dd/mm/yyyy", 0.0), "29/08/1317");
|
||||
assert_eq!(f("B2dd/mm/yyyy", 2958465.0), "03/04/9666"); // 9999-12-31
|
||||
}
|
||||
|
||||
/// Excel honours `B2` after a colour tag, but a `[$-...]` locale tag before it keeps the date
|
||||
/// Gregorian (the prefix is still hidden); `B2[$-401]` is Hijri.
|
||||
#[test]
|
||||
fn calendar_prefix_and_bracket_tags() {
|
||||
assert_eq!(f("[Red]B2dd/mm/yyyy", 46314.0), "08/05/1448");
|
||||
assert_eq!(f("[<0]0;B2dd/mm/yyyy", JAN_1_2001), "06/10/1421");
|
||||
assert_eq!(f("B2[$-401]dd/mm/yyyy", 46314.0), "08/05/1448");
|
||||
for code in ["[$-401]B2dd/mm/yyyy", "[$-409]B2dd/mm/yyyy", "[$-1010401]B2dd/mm/yyyy", "[$-ar-SA]B2dd/mm/yyyy"] {
|
||||
assert_eq!(f(code, 46314.0), "19/10/2026", "{code}");
|
||||
assert_eq!(text_function(&Value::Number(46314.0), code, S).as_deref(), Ok("19/10/2026"), "{code}");
|
||||
}
|
||||
assert_eq!(f("[Red][$-401]B1dd/mm/yyyy", JAN_1_2001), "01/01/2001");
|
||||
// Checked in Excel 16.0 (build 20527, en-US): a currency tag cancels `B2` like a locale tag,
|
||||
// a tag after the prefix does not, and a repeated prefix is accepted.
|
||||
assert_eq!(f("[$€-407]B2dd/mm/yyyy", 46314.0), "€19/10/2026");
|
||||
assert_eq!(f("[$$-409]B2dd/mm/yyyy", 46314.0), "$19/10/2026");
|
||||
assert_eq!(f("[$-401][Red]B2dd/mm/yyyy", 46314.0), "19/10/2026");
|
||||
assert_eq!(f("B2[Red]dd/mm/yyyy", 46314.0), "08/05/1448");
|
||||
for code in ["B2B2dd/mm/yyyy", "B2B2B2dd/mm/yyyy", "B2b2dd/mm/yyyy", "[Red]B2B2dd/mm/yyyy"] {
|
||||
assert_eq!(f(code, 46314.0), "08/05/1448", "{code}");
|
||||
}
|
||||
assert_eq!(f("B1B1dd/mm/yyyy", 46314.0), "19/10/2026");
|
||||
assert_eq!(f("B2 dd/mm/yyyy", 46314.0), " 08/05/1448");
|
||||
assert_eq!(f("B2\"x\"dd", 46314.0), "x08");
|
||||
for code in ["B1\"x\"", "B2\"x\""] {
|
||||
assert_eq!(f(code, 46314.0), "x", "{code}");
|
||||
}
|
||||
for code in ["B1General", "B1@", "0;0;0;B2@", "0;B2dd/mm/yyyy"] {
|
||||
assert_eq!(f(code, 46314.0), "46314", "{code}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A quoted `B2` is literal text; any other `B` and digit makes Excel reject the code (`TEXT` is
|
||||
/// `#VALUE!`), as does a prefix before a number format. A rejected code shows as General. All
|
||||
/// checked in Excel 16.0 (build 20527, en-US), with TEXT and with a cell format.
|
||||
#[test]
|
||||
fn misplaced_calendar_prefix_is_rejected() {
|
||||
assert_eq!(f("\"B2\"dd/mm/yyyy", 46314.0), "B219/10/2026");
|
||||
assert_eq!(f("dd/\"B2\"/yyyy", 46314.0), "19/B2/2026");
|
||||
for code in [
|
||||
"B0dd/mm/yyyy",
|
||||
"B3dd/mm/yyyy",
|
||||
"B4dd/mm/yyyy",
|
||||
"B9dd/mm/yyyy",
|
||||
"B02dd",
|
||||
"B12dd",
|
||||
"B21dd",
|
||||
"B1B2dd/mm/yyyy",
|
||||
"B2B1dd/mm/yyyy",
|
||||
"B2[Red]B2dd/mm/yyyy",
|
||||
"B2 B2dd",
|
||||
"dd/mm/yyyyB2",
|
||||
"dd/mm/yyyyB1",
|
||||
"dd B2 mm",
|
||||
"dd b2 mm",
|
||||
"dd \\B2 yyyy",
|
||||
"B2\\B2dd",
|
||||
"B20.00",
|
||||
"B2#,##0",
|
||||
"B10.00",
|
||||
"B1#,##0",
|
||||
"0;B3dd",
|
||||
] {
|
||||
assert_eq!(text_function(&Value::Number(46314.0), code, S), Err(CellError::Value), "{code}");
|
||||
assert_eq!(f(code, 46314.0), "46314", "{code}");
|
||||
assert!(NumberFormat::parse(code).is_general(), "{code}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b2_edges_of_the_date_range() {
|
||||
// Serial 60 is the 1900 system's fictitious 29 February: Excel has no Hijri date for it.
|
||||
assert_eq!(text_function(&Value::Number(60.0), "B2dd/mm/yyyy", S), Err(CellError::Value));
|
||||
assert_eq!(f("B2dd/mm/yyyy", 60.0), "########");
|
||||
assert_eq!(f("B1dd/mm/yyyy", 60.0), "29/02/1900");
|
||||
assert_eq!(f("B2dd/mm/yyyy", 59.0), "28/10/1317");
|
||||
assert_eq!(f("B2dd/mm/yyyy", 61.0), "29/10/1317");
|
||||
// So does any clock part (Excel 16.0, build 20527, en-US), but not an elapsed `[h]`, a literal,
|
||||
// or a locale tag that cancels `B2`.
|
||||
for code in ["B2hh:mm", "B2hh", "B2ss", "B2am/pm", "B2hh:mm AM/PM", "B2[$-401]hh:mm", "B2dd/mm/yyyy;\"x\""] {
|
||||
assert_eq!(text_function(&Value::Number(60.0), code, S), Err(CellError::Value), "{code}");
|
||||
assert_eq!(text_function(&Value::Number(60.5), code, S), Err(CellError::Value), "{code}");
|
||||
}
|
||||
assert_eq!(f("B2[h]", 60.0), "1440");
|
||||
assert_eq!(f("B2\"x\"", 60.0), "x");
|
||||
assert_eq!(f("[$-401]B2hh:mm", 60.0), "00:00");
|
||||
assert_eq!(f("B1hh:mm", 60.0), "00:00");
|
||||
assert_eq!(f("B2hh:mm", 61.0), "00:00");
|
||||
// Serial 0 shows as serial 1.
|
||||
assert_eq!(f("B2dd/mm/yyyy", 0.0), "29/08/1317");
|
||||
assert_eq!(f("B2dd/mm/yyyy", 1.0), "29/08/1317");
|
||||
// No upper limit short of 31 December 9999: 16 and 17 November 2077, 31 December 9999.
|
||||
assert_eq!(f("B2dd/mm/yyyy", 64970.0), "01/01/1501");
|
||||
assert_eq!(f("B2dd/mm/yyyy", 64971.0), "02/01/1501");
|
||||
assert_eq!(f("B2dd/mm/yyyy", 2958465.0), "03/04/9666");
|
||||
// In the 1904 system, serial 60 is a real day.
|
||||
assert_ne!(format_value(&Value::Number(60.0), &NumberFormat::parse("B2dd/mm/yyyy"), DateSystem::D1904).text, "########");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b2_out_of_range_does_not_panic() {
|
||||
for v in [-1.0, 0.0, 60.0, 60.5, 2958465.0, 2958466.0, 1e300, f64::NAN] {
|
||||
let _ = f("B2dd/mm/yyyy mmmm dddd", v);
|
||||
let _ = text_function(&Value::Number(v), "B2dd/mm/yyyy mmmm dddd", S);
|
||||
}
|
||||
assert_eq!(f("B2dd/mm/yyyy", -1.0), f("dd/mm/yyyy", -1.0));
|
||||
assert_eq!(f("B2dd/mm/yyyy", 2958466.0), f("dd/mm/yyyy", 2958466.0));
|
||||
// Before 1 Muharram 1 AH there is no Hijri date (no serial reaches it).
|
||||
let before_epoch = gridcraft_core::date::DateTime { year: 600, month: 3, day: 9, ..Default::default() };
|
||||
assert!(calendar::Calendar::Hijri.date(&before_epoch).is_none());
|
||||
}
|
||||
|
||||
/// "Set precision as displayed": the value each format stores. Expected values observed in Excel
|
||||
/// (COM, `Workbook.PrecisionAsDisplayed = True`) with synthetic values.
|
||||
#[test]
|
||||
|
||||
@@ -24,7 +24,6 @@ serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
image = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
unicode-bidi = "=0.3.18"
|
||||
web-time = { workspace = true }
|
||||
|
||||
# Linux only, where winit reports no system theme: the desktop portal's light/dark answer (see
|
||||
|
||||
@@ -24,7 +24,6 @@ pub const REQUIRED_FACES: &[(&str, &str, &[&str])] = &[
|
||||
("NanumGothic", "Regular", &["Hang", "Kore"]),
|
||||
("Noto Sans Symbols", "Regular", &["Zsym"]),
|
||||
("Noto Sans Symbols 2", "Regular", &["Zsym"]),
|
||||
("Noto Sans Arabic", "Regular", &["Arab"]),
|
||||
];
|
||||
|
||||
/// The native-only Simplified Chinese face from craft-fonts' separate
|
||||
@@ -42,7 +41,6 @@ pub const WEB_ALLOWED: &[(&str, &str)] = &[
|
||||
("NanumGothic", "Regular"),
|
||||
("Noto Sans Symbols", "Regular"),
|
||||
("Noto Sans Symbols 2", "Regular"),
|
||||
("Noto Sans Arabic", "Regular"),
|
||||
];
|
||||
|
||||
/// One validated `fonts/manifest.txt` row. Only the fields GridCraft consumes are kept.
|
||||
@@ -174,7 +172,6 @@ mod tests {
|
||||
("NanumGothic", "Regular"),
|
||||
("Noto Sans Symbols", "Regular"),
|
||||
("Noto Sans Symbols 2", "Regular"),
|
||||
("Noto Sans Arabic", "Regular"),
|
||||
]
|
||||
);
|
||||
for (family, _) in WEB_ALLOWED {
|
||||
|
||||
@@ -69,7 +69,7 @@ pub(super) fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog
|
||||
st.num_fmt = gridcraft_engine::model::NumFmt::new(&code);
|
||||
}
|
||||
"Alignment" => {
|
||||
use gridcraft_engine::model::{HAlign, ReadingOrder, VAlign};
|
||||
use gridcraft_engine::model::{HAlign, VAlign};
|
||||
ui.label(egui::RichText::new(lang.tr("Horizontal")).strong());
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (h, n) in [
|
||||
@@ -97,17 +97,6 @@ pub(super) fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog
|
||||
ui.radio_value(&mut st.align.v, v, lang.tr(n));
|
||||
}
|
||||
});
|
||||
ui.label(egui::RichText::new(lang.tr("Reading direction")).strong());
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (order, name) in
|
||||
[(ReadingOrder::Context, "Context"), (ReadingOrder::LeftToRight, "Left to right"), (ReadingOrder::RightToLeft, "Right to left")]
|
||||
{
|
||||
let selected = st.align.reading_order.unwrap_or_default() == order;
|
||||
if ui.radio(selected, lang.tr(name)).clicked() {
|
||||
st.align.reading_order = Some(order);
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut indent = st.align.indent as f32;
|
||||
ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text(lang.tr("Indent")));
|
||||
st.align.indent = indent as u8;
|
||||
|
||||
@@ -19,24 +19,6 @@ use std::sync::OnceLock;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_FONT_FILE: u64 = 64 << 20;
|
||||
|
||||
/// Reads a font file of at most [`MAX_FONT_FILE`] bytes, even when it grows after it was found.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn read_font(path: &std::path::Path) -> Option<Vec<u8>> {
|
||||
use std::io::Read as _;
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
if file.metadata().ok()?.len() > MAX_FONT_FILE {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_FONT_FILE + 1).read_to_end(&mut bytes).ok()?;
|
||||
(bytes.len() as u64 <= MAX_FONT_FILE).then_some(bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn read_font(_path: &std::path::Path) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// A face keeps at most this many family names, so a hostile `name` table can't grow the catalog.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_FAMILY_NAMES: usize = 16;
|
||||
@@ -190,7 +172,7 @@ pub fn scan_dirs(dirs: &[PathBuf]) -> Vec<Face> {
|
||||
if !is_font || std::fs::metadata(&path).map(|m| m.len() > MAX_FONT_FILE).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let Some(data) = read_font(&path) else { continue };
|
||||
let Ok(data) = std::fs::read(&path) else { continue };
|
||||
for (index, family, names, style) in file_faces(&data) {
|
||||
out.push(Face { family, names, path: path.clone(), index, weight: style_weight(&style), italic: style_italic(&style) });
|
||||
}
|
||||
@@ -353,7 +335,7 @@ pub fn bind_faces(fonts: &mut egui::FontDefinitions, family: &str, faces: &[Face
|
||||
continue;
|
||||
}
|
||||
if !fonts.font_data.contains_key(&key) {
|
||||
let Some(bytes) = read_font(&face.path) else { continue };
|
||||
let Ok(bytes) = std::fs::read(&face.path) else { continue };
|
||||
let mut fd = egui::FontData::from_owned(bytes);
|
||||
fd.index = face.index;
|
||||
fonts.font_data.insert(key.clone(), std::sync::Arc::new(fd));
|
||||
@@ -371,6 +353,11 @@ pub fn bind_faces(fonts: &mut egui::FontDefinitions, family: &str, faces: &[Face
|
||||
bound
|
||||
}
|
||||
|
||||
/// Bind every installed face of `family`, found by any of its names (see [`bind_faces`]).
|
||||
pub fn bind_family(fonts: &mut egui::FontDefinitions, family: &str) -> Vec<String> {
|
||||
bind_faces(fonts, family, &faces_of(family))
|
||||
}
|
||||
|
||||
/// The role families currently bound to font data. `cell_family` consults this, so it never names a
|
||||
/// family egui hasn't been given.
|
||||
static BOUND: OnceLock<std::sync::RwLock<HashSet<String>>> = OnceLock::new();
|
||||
@@ -485,8 +472,6 @@ mod tests {
|
||||
}
|
||||
// A family with no faces binds nothing and never touches the map.
|
||||
assert!(bind_faces(&mut fonts, "No Such Font", &[]).is_empty());
|
||||
std::fs::File::create(&face.path).expect("sparse file").set_len(MAX_FONT_FILE + 1).expect("oversized file");
|
||||
assert!(read_font(&face.path).is_none(), "an oversized font is not read");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -148,12 +148,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
let job = code_editor::layout_job(&text, Lang::Python, &code_editor::font(), color, &t.syntax, inner.width());
|
||||
child.fonts_mut(|f| f.layout_job(job))
|
||||
} else {
|
||||
let order = app.session.active().and_then(|doc| cell_reading_order(app, doc.wb.active_sheet(), doc.selection.active));
|
||||
let direction = crate::text_direction::cell_direction(order, &text);
|
||||
child.painter().layout_job(egui::text::LayoutJob {
|
||||
direction,
|
||||
..egui::text::LayoutJob::simple(text, font.clone(), color, inner.width())
|
||||
})
|
||||
child.painter().layout(text, font.clone(), color, inner.width())
|
||||
};
|
||||
let lines = galley.rows.len();
|
||||
// Each cell's view starts at its top: the offset is kept per cell, not per bar.
|
||||
@@ -246,25 +241,6 @@ pub(crate) fn active_spill_anchor(app: &SheetApp) -> Option<gridcraft_engine::co
|
||||
sh.spill_anchor(at)
|
||||
}
|
||||
|
||||
fn cell_reading_order(app: &SheetApp, sheet_index: usize, cell: gridcraft_engine::core::CellRef) -> Option<gridcraft_engine::model::ReadingOrder> {
|
||||
let doc = app.session.active()?;
|
||||
let sheet = doc.wb.sheet(sheet_index)?;
|
||||
let cell = sheet.merge_at(cell).map_or(cell, |range| range.start);
|
||||
let base = doc.wb.styles.get(sheet.style_id(cell)).align.reading_order;
|
||||
let a_rule_sets_reading_order = sheet
|
||||
.cond_formats
|
||||
.iter()
|
||||
.any(|cf| cf.ranges.iter().any(|r| r.contains(cell)) && cf.rule.style().is_some_and(|style| style.align.reading_order.is_some()));
|
||||
if !a_rule_sets_reading_order {
|
||||
return base;
|
||||
}
|
||||
gridcraft_engine::cf::CfCache::default()
|
||||
.look(&doc.wb, sheet_index, cell)
|
||||
.and_then(|look| look.style)
|
||||
.and_then(|style| style.align.reading_order)
|
||||
.or(base)
|
||||
}
|
||||
|
||||
/// The active cell's Python cell (itself, or the anchor whose result spills over it): its sheet,
|
||||
/// cell and code.
|
||||
pub(crate) fn active_python(app: &SheetApp) -> Option<(usize, gridcraft_engine::core::CellRef, std::sync::Arc<gridcraft_engine::model::PyCode>)> {
|
||||
@@ -498,14 +474,9 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
let on_sheet = id == egui::Id::unique("gridcraft.cell_editor");
|
||||
let cell_text_color = if on_sheet { t.cell_text } else { t.text };
|
||||
let font2 = font.clone();
|
||||
let reading_order = cell_reading_order(app, ed.sheet, ed.cell);
|
||||
let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
|
||||
let s = buf.as_str();
|
||||
let mut job = egui::text::LayoutJob {
|
||||
direction: crate::text_direction::cell_direction(reading_order, s),
|
||||
keep_trailing_whitespace: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
let mut pos = 0usize;
|
||||
let mut spans: Vec<(usize, usize, usize)> = refs.iter().filter(|r| r.1 <= s.len()).map(|r| (r.0, r.1, r.4)).collect();
|
||||
spans.sort();
|
||||
|
||||
@@ -59,9 +59,6 @@ pub(super) fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut Cf
|
||||
if st.num_fmt.as_str() != "General" {
|
||||
style.num_fmt = st.num_fmt.clone();
|
||||
}
|
||||
if st.align.reading_order.is_some() {
|
||||
style.align.reading_order = st.align.reading_order;
|
||||
}
|
||||
for (dst, src) in [
|
||||
(&mut style.borders.left, st.borders.left),
|
||||
(&mut style.borders.right, st.borders.right),
|
||||
|
||||
@@ -363,13 +363,11 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
|
||||
return;
|
||||
}
|
||||
let color = ncolor.unwrap_or(lk.text_color);
|
||||
let direction = crate::text_direction::cell_direction(st.align.reading_order, &text);
|
||||
let halign = match st.align.h {
|
||||
HAlign::General => match v {
|
||||
Value::Number(_) => HAlign::Right,
|
||||
Value::Bool(_) | Value::Error(_) => HAlign::Center,
|
||||
_ if numeric => HAlign::Right,
|
||||
_ if direction == egui::epaint::text::TextDirection::RightToLeft && !sh.right_to_left => HAlign::Right,
|
||||
_ => HAlign::Left,
|
||||
},
|
||||
h => painted_halign(h, geo.rtl),
|
||||
@@ -381,18 +379,16 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
|
||||
if !rotated && (st.align.wrap || st.align.h == HAlign::Justify) {
|
||||
let wrap_w = (rect.width() - 2.0 * pad - indent).max(4.0);
|
||||
let mut job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, wrap_w);
|
||||
job.direction = direction;
|
||||
job.halign_min_is_start = false;
|
||||
job.halign = match painted_halign(halign, geo.rtl) {
|
||||
job.halign = match halign {
|
||||
HAlign::Center | HAlign::CenterAcross => egui::Align::Center,
|
||||
HAlign::Right => egui::Align::RIGHT,
|
||||
_ => egui::Align::LEFT,
|
||||
};
|
||||
let galley = p.layout_job(job);
|
||||
let x = match halign {
|
||||
HAlign::Center | HAlign::CenterAcross => rect.center().x - galley.rect.center().x,
|
||||
HAlign::Right => rect.right() - pad - galley.rect.right(),
|
||||
_ => text_left + pad + indent - galley.rect.left(),
|
||||
HAlign::Center | HAlign::CenterAcross => rect.center().x,
|
||||
HAlign::Right => rect.right() - pad,
|
||||
_ => text_left + pad + indent,
|
||||
};
|
||||
let y = wrapped_cell_text_y(st.align.v, *rect, galley.size().y, z);
|
||||
let text_clip = wrapped_text_clip(p, *rect, &galley, y, st.align.v);
|
||||
@@ -410,13 +406,7 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
|
||||
.flatten();
|
||||
let shrunk = small.is_some();
|
||||
let font = small.unwrap_or(font);
|
||||
let layout = |text: String| {
|
||||
let mut job = egui::text::LayoutJob::simple(text, font.clone(), color, f32::INFINITY);
|
||||
job.direction = direction;
|
||||
job.halign_min_is_start = false;
|
||||
p.layout_job(job)
|
||||
};
|
||||
let mut galley = layout(first_line.clone());
|
||||
let mut galley = p.layout_no_wrap(first_line.clone(), font.clone(), color);
|
||||
let mut w = galley.size().x;
|
||||
// Numbers and dates never overflow: they show #### when too narrow.
|
||||
let hashed = !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill;
|
||||
@@ -424,7 +414,7 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
|
||||
// Measured on `#` itself: it is wider than `0` in some fonts, and #### must fit its cell.
|
||||
let hash_w = p.layout_no_wrap("#".to_string(), font.clone(), color).size().x.max(1.0);
|
||||
let n = ((avail_w - 2.0 * pad) / hash_w).floor().max(1.0) as usize;
|
||||
galley = layout("#".repeat(n));
|
||||
galley = p.layout_no_wrap("#".repeat(n), font.clone(), color);
|
||||
w = galley.size().x;
|
||||
}
|
||||
// Repeat fill (`*` in number formats) / Fill alignment. A number already shown as #### keeps
|
||||
@@ -441,7 +431,7 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
|
||||
if first_line.is_char_boundary(pos) {
|
||||
s.insert_str(pos, &ch.to_string().repeat(n));
|
||||
}
|
||||
galley = layout(s);
|
||||
galley = p.layout_no_wrap(s, font.clone(), color);
|
||||
w = galley.size().x;
|
||||
}
|
||||
// Center Across Selection: the text is centred over this cell and the empty cells to its right that also use it.
|
||||
@@ -505,13 +495,12 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
|
||||
let angle = -(st.align.rotation as f32).to_radians();
|
||||
// Upright (±90°) wrapped text wraps to the row height: lines then stack across the column.
|
||||
let galley = if st.align.wrap && st.align.rotation.abs() == 90 {
|
||||
let mut job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, (rect.height() - 2.0 * pad).max(4.0));
|
||||
job.direction = direction;
|
||||
let job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, (rect.height() - 2.0 * pad).max(4.0));
|
||||
p.layout_job(job)
|
||||
} else {
|
||||
galley.clone()
|
||||
};
|
||||
let pos = rotated_text_pos(*rect, galley.size(), angle, halign, st.align.v, pad, indent);
|
||||
let pos = rotated_text_pos(*rect, galley.size(), angle, painted_halign(st.align.h, geo.rtl), st.align.v, pad, indent);
|
||||
let mut shape = egui::epaint::TextShape::new(pos, galley, color);
|
||||
shape.angle = angle;
|
||||
cp.add(shape);
|
||||
|
||||
@@ -53,7 +53,6 @@ pub mod ribbon;
|
||||
pub mod selection_glide;
|
||||
pub mod tabs;
|
||||
pub mod text_box;
|
||||
mod text_direction;
|
||||
pub mod theme;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod updates_ui;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
//! Cell reading direction follows OOXML context rules, independently of sheet geometry.
|
||||
|
||||
use egui::epaint::text::TextDirection;
|
||||
use gridcraft_engine::model::ReadingOrder;
|
||||
|
||||
pub(crate) fn cell_direction(order: Option<ReadingOrder>, text: &str) -> TextDirection {
|
||||
match order.unwrap_or_default() {
|
||||
ReadingOrder::LeftToRight => TextDirection::LeftToRight,
|
||||
ReadingOrder::RightToLeft => TextDirection::RightToLeft,
|
||||
ReadingOrder::Context => match text.chars().find(|ch| !ch.is_whitespace()).map(unicode_bidi::bidi_class) {
|
||||
Some(unicode_bidi::BidiClass::R | unicode_bidi::BidiClass::AL) => TextDirection::RightToLeft,
|
||||
_ => TextDirection::LeftToRight,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn context_uses_the_first_non_whitespace_character() {
|
||||
for text in ["مرحبا 123", " עברית", "\n\tالعربية"] {
|
||||
assert_eq!(cell_direction(None, text), TextDirection::RightToLeft);
|
||||
}
|
||||
for text in ["123 مرحبا", "(مرحبا)", "ABC العربية", "", " "] {
|
||||
assert_eq!(cell_direction(None, text), TextDirection::LeftToRight);
|
||||
}
|
||||
assert_eq!(cell_direction(Some(ReadingOrder::LeftToRight), "مرحبا"), TextDirection::LeftToRight);
|
||||
assert_eq!(cell_direction(Some(ReadingOrder::RightToLeft), "123 ABC"), TextDirection::RightToLeft);
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,7 @@ mod font_setup;
|
||||
|
||||
pub use font_fallback::symbol_face_installed;
|
||||
#[cfg(test)]
|
||||
use font_fallback::{
|
||||
PREFERRED_ARABIC, arabic, cjk_han_japanese, cjk_han_simplified, cjk_hangul, fallback_faces, hebrew, lao, load_faces, preferred_file,
|
||||
symbols_more, thai,
|
||||
};
|
||||
#[cfg(all(test, unix, not(target_os = "macos")))]
|
||||
use font_fallback::{covers_arabic, fontconfig_arabic, parse_fc_match};
|
||||
use font_fallback::{cjk_han_japanese, cjk_han_simplified, cjk_hangul, fallback_faces, hebrew, lao, symbols_more, thai};
|
||||
use font_setup::pick;
|
||||
pub use font_setup::{
|
||||
CELL, CELL_BOLD, CELL_BOLD_ITALIC, CELL_ITALIC, CraftFont, HanOrder, MATH, MATH_BOLD, MATH_ITALIC, MONO, SERIF, UI, UI_BOLD, available_text,
|
||||
@@ -38,7 +33,7 @@ pub use font_setup::{
|
||||
font_definitions, font_definitions_for_language, font_definitions_with, pinned_font_definitions, symbol_fallback, ui_bold, ui_font,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use font_setup::{candidates, craft_font_faces, font_definitions_impl, try_load};
|
||||
use font_setup::{candidates, craft_font_faces, try_load};
|
||||
#[cfg(test)]
|
||||
pub(crate) use font_setup::{drop_unparseable_fonts, font_parses};
|
||||
|
||||
|
||||
@@ -12,25 +12,12 @@ use super::font_setup::{in_dirs, try_load};
|
||||
/// is bundled, and a machine without the face keeps the old behaviour.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn load_fallback(fonts: &mut FontDefinitions, han: HanOrder) -> Vec<String> {
|
||||
let loaded = preferred_file(fonts, fontconfig_arabic());
|
||||
load_faces(fonts, fallback_faces(han), loaded)
|
||||
}
|
||||
|
||||
/// The preferred Arabic file, once [`load_preferred_arabic`] has loaded it: a file that failed to
|
||||
/// load must not keep the system Arabic faces out of the chains.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn preferred_file(fonts: &FontDefinitions, preferred: Option<(String, u32)>) -> Vec<String> {
|
||||
preferred.filter(|_| fonts.font_data.contains_key(PREFERRED_ARABIC)).map(|(path, _)| path).into_iter().collect()
|
||||
}
|
||||
|
||||
/// Loads the first candidate of each fallback face that loads and returns their keys. `loaded`
|
||||
/// holds the files already loaded as a fallback face. A file serves at most one key: the
|
||||
/// region-merged Noto Sans CJK .ttc found for Han also carries Hangul (and Tahoma carries both Thai
|
||||
/// and Hebrew), so loading it again for a later script would only duplicate tens of MB in memory.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn load_faces(fonts: &mut FontDefinitions, faces: Vec<(&'static str, Vec<(String, u32)>)>, mut loaded: Vec<String>) -> Vec<String> {
|
||||
let mut keys = Vec::new();
|
||||
for (key, cands) in faces {
|
||||
// Files already loaded as a fallback face. A file serves at most one key: the region-merged
|
||||
// Noto Sans CJK .ttc found for Han also carries Hangul (and Tahoma carries both Thai and
|
||||
// Hebrew), so loading it again for a later script would only duplicate tens of MB in memory.
|
||||
let mut loaded: Vec<String> = Vec::new();
|
||||
for (key, cands) in fallback_faces(han) {
|
||||
for (path, index) in &cands {
|
||||
if loaded.contains(path) {
|
||||
break; // a face loaded for an earlier script already covers this one
|
||||
@@ -51,60 +38,6 @@ pub(super) fn load_fallback(_fonts: &mut FontDefinitions, _han: HanOrder) -> Vec
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// The face fontconfig prefers for Arabic leads the script faces (#229), ahead of the system and
|
||||
/// bundled Arabic faces.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn load_preferred_arabic(fonts: &mut FontDefinitions) -> Vec<String> {
|
||||
let Some((path, index)) = fontconfig_arabic() else { return Vec::new() };
|
||||
let Some(fd) = try_load(&[(path.as_str(), index)]) else { return Vec::new() };
|
||||
fonts.font_data.insert(PREFERRED_ARABIC.into(), Arc::new(fd));
|
||||
vec![PREFERRED_ARABIC.into()]
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn load_preferred_arabic(_fonts: &mut FontDefinitions) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) const PREFERRED_ARABIC: &str = "sys-arabic-preferred";
|
||||
|
||||
/// The face fontconfig prefers for Arabic, such as a Vazirmatn the user set as their Arabic font
|
||||
/// (#229), kept only when it has Arabic letters. Asked once per process.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
pub(super) fn fontconfig_arabic() -> Option<(String, u32)> {
|
||||
static PREFERRED: std::sync::OnceLock<Option<(String, u32)>> = std::sync::OnceLock::new();
|
||||
PREFERRED
|
||||
.get_or_init(|| {
|
||||
let out = std::process::Command::new("fc-match").args(["--format=%{file}\n%{index}\n", ":lang=ar"]).output().ok()?;
|
||||
let (file, index) = parse_fc_match(&String::from_utf8_lossy(&out.stdout)).filter(|_| out.status.success())?;
|
||||
let bytes = crate::fonts::read_font(std::path::Path::new(&file))?;
|
||||
covers_arabic(&bytes, index).then_some((file, index))
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[cfg(all(not(target_arch = "wasm32"), not(all(unix, not(target_os = "macos")))))]
|
||||
pub(super) fn fontconfig_arabic() -> Option<(String, u32)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The file and face index from `fc-match --format='%{file}\n%{index}\n'`. fontconfig packs a
|
||||
/// variable font's named instance into the upper 16 bits of the index; only the face index is kept.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
pub(super) fn parse_fc_match(out: &str) -> Option<(String, u32)> {
|
||||
let mut lines = out.lines().map(str::trim);
|
||||
let file = lines.next().filter(|file| !file.is_empty())?;
|
||||
let index = lines.next().and_then(|index| index.parse::<u32>().ok()).map_or(0, |packed| packed & 0xFFFF);
|
||||
Some((file.to_owned(), index))
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
pub(super) fn covers_arabic(bytes: &[u8], index: u32) -> bool {
|
||||
use skrifa::MetadataProvider;
|
||||
skrifa::FontRef::from_index(bytes, index).is_ok_and(|font| "العربية".chars().all(|ch| font.charmap().map(ch).is_some()))
|
||||
}
|
||||
|
||||
/// Every fallback face we look for, in chain order: `(key, candidate files)`. One file per script
|
||||
/// family is enough — epaint picks the first face with the glyph, and a script's glyphs live in one
|
||||
/// file. Order only matters between faces that both cover a glyph (Japanese vs Chinese kanji), so
|
||||
@@ -124,7 +57,6 @@ pub(super) fn fallback_faces(han: HanOrder) -> Vec<(&'static str, Vec<(String, u
|
||||
("sys-thai", thai()),
|
||||
("sys-lao", lao()),
|
||||
("sys-hebrew", hebrew()),
|
||||
("sys-arabic", arabic()),
|
||||
("sys-symbols", symbols()),
|
||||
("sys-symbols-2", symbols_more()),
|
||||
]
|
||||
@@ -311,23 +243,6 @@ pub(super) fn hebrew() -> Vec<(String, u32)> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Arabic faces. Windows has Tahoma, Arial and Segoe UI with Arabic; macOS has Geeza Pro; Linux
|
||||
/// usually has Noto Sans Arabic or Noto Naskh Arabic. Installed Noto and Amiri faces found by the
|
||||
/// font scan come last, for layouts the paths miss.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn arabic() -> Vec<(String, u32)> {
|
||||
let mut v = script_fonts(
|
||||
&["tahoma.ttf", "arial.ttf", "segoeui.ttf"],
|
||||
&["/System/Library/Fonts/GeezaPro.ttc", "/System/Library/Fonts/Supplemental/GeezaPro.ttc"],
|
||||
&["NotoSansArabic-Regular.ttf", "NotoNaskhArabic-Regular.ttf", "noto/NotoSansArabic-Regular.ttf", "noto/NotoNaskhArabic-Regular.ttf"],
|
||||
"/usr/share/fonts/google-noto-vf/NotoSansArabic[wght].ttf",
|
||||
);
|
||||
for family in ["Noto Sans Arabic", "Noto Naskh Arabic", "Amiri"] {
|
||||
v.extend(crate::fonts::faces_of(family).into_iter().map(|face| (face.path.to_string_lossy().into_owned(), face.index)));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Symbol faces behind the UI font. Interface fonts such as Noto Sans and Cantarell (and egui's
|
||||
/// built-in Ubuntu) lack arrows, check marks, geometric shapes and keyboard symbols (▾ ✓ ✕ ▲ ↳ ⇧),
|
||||
/// which then render as empty boxes (#197, #54). DejaVu Sans covers most of them; the paths cover
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use super::Blob;
|
||||
use super::font_fallback::{load_fallback, load_preferred_arabic};
|
||||
use super::font_fallback::load_fallback;
|
||||
use super::{Arc, Color32, FontData, FontDefinitions, FontFamily, FontId};
|
||||
|
||||
/// A font from the optional craft-fonts build input (`CRAFT_FONTS_DIR` at build time; see
|
||||
@@ -152,7 +152,7 @@ fn font_file(path: &str, index: u32) -> Option<Blob> {
|
||||
let bytes: &[u8] = (**blob).as_ref();
|
||||
return font_parses(bytes, index).then(|| std::sync::Arc::clone(blob));
|
||||
}
|
||||
let bytes = crate::fonts::read_font(std::path::Path::new(path))?;
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
if !font_parses(&bytes, index) {
|
||||
log::warn!("skipping font {path} (face {index}): not a font file egui can read");
|
||||
return None;
|
||||
@@ -466,45 +466,37 @@ pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDef
|
||||
/// machine see the same font set. Craft-fonts remains a pinned build input rather than a repository
|
||||
/// asset; without it only egui's bundled faces are available, which is the documented no-font mode.
|
||||
pub fn pinned_font_definitions() -> FontDefinitions {
|
||||
font_definitions_impl(crate::i18n::Language::En, Vec::new(), false)
|
||||
font_definitions_impl(crate::i18n::Language::En, &[], false)
|
||||
}
|
||||
|
||||
/// [`font_definitions_for_language`] that also binds the workbook fonts in `used` that are
|
||||
/// installed, so cells set in them render with them; the rest fall back to the roles. The bound
|
||||
/// families are recorded in [`crate::fonts`] so [`cell_family`] only names a family egui has.
|
||||
pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) -> FontDefinitions {
|
||||
let installed = crate::fonts::installed_among(used).into_iter().map(|family| {
|
||||
let faces = crate::fonts::faces_of(&family);
|
||||
(family, faces)
|
||||
});
|
||||
font_definitions_impl(language, installed.collect(), true)
|
||||
font_definitions_impl(language, used, true)
|
||||
}
|
||||
|
||||
/// Builds the role font chains, script fallbacks, installed workbook fonts and optional craft-font faces, drops unparseable data and records which families were bound.
|
||||
pub(super) fn font_definitions_impl(
|
||||
language: crate::i18n::Language,
|
||||
installed: Vec<(String, Vec<crate::fonts::Face>)>,
|
||||
system: bool,
|
||||
) -> FontDefinitions {
|
||||
fn font_definitions_impl(language: crate::i18n::Language, used: &[String], system: bool) -> FontDefinitions {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let base_prop: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
|
||||
let base_mono: Vec<String> = fonts.families.get(&FontFamily::Monospace).cloned().unwrap_or_default();
|
||||
let mut bound: Vec<String> = Vec::new();
|
||||
// The non-Latin fallback faces have to sit at the end of each chain: a Latin font owns Latin,
|
||||
// and epaint picks the first face that has the glyph, so they only catch their own scripts.
|
||||
let fallback = if system {
|
||||
let mut fallback = load_preferred_arabic(&mut fonts);
|
||||
fallback.extend(load_fallback(&mut fonts, HanOrder::of(language)));
|
||||
fallback
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let fallback = if system { load_fallback(&mut fonts, HanOrder::of(language)) } else { Vec::new() };
|
||||
let hack = fonts.font_data.contains_key(HACK).then_some(HACK);
|
||||
for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO, MATH, MATH_ITALIC, MATH_BOLD] {
|
||||
let chain = build_role_chain(&mut fonts, role, &base_prop, &base_mono, &fallback, hack, system);
|
||||
fonts.families.insert(FontFamily::Name(role.into()), chain);
|
||||
bound.push(role.to_string());
|
||||
}
|
||||
// Installed workbook fonts, so a cell's chosen font actually renders in it.
|
||||
if system {
|
||||
for family in crate::fonts::installed_among(used) {
|
||||
bound.extend(crate::fonts::bind_family(&mut fonts, &family));
|
||||
}
|
||||
}
|
||||
// Interface text (formula bar, name box, dialogs) falls back to the cell font before the
|
||||
// script faces: whatever a cell can draw, the formula bar can draw too, even where no script
|
||||
// fallback face was found (#349: macOS UI font without Hebrew, cells in Arial with it).
|
||||
@@ -513,11 +505,6 @@ pub(super) fn font_definitions_impl(
|
||||
if let Some(ui) = fonts.families.get(&FontFamily::Name(UI.into())).cloned() {
|
||||
fonts.families.insert(FontFamily::Proportional, ui);
|
||||
}
|
||||
// Installed workbook fonts, so a cell's chosen font actually renders in it. They are bound
|
||||
// after the proportional chain has the script faces, which their chains end with.
|
||||
for (family, faces) in installed {
|
||||
bound.extend(crate::fonts::bind_faces(&mut fonts, &family, &faces));
|
||||
}
|
||||
// The optional craft-fonts faces go at the very end of every family: a system or bundled face
|
||||
// that has the script keeps its glyph shapes, while the permitted OFL faces cover scripts and
|
||||
// symbols the host image lacks (Japanese, Korean, Thai, Arabic, Georgian and the UI symbols).
|
||||
|
||||
@@ -76,7 +76,6 @@ fn the_required_craft_faces_keep_their_exact_scripts() {
|
||||
("NanumGothic", "Regular", ["Hang", "Kore"].as_slice()),
|
||||
("Noto Sans Symbols", "Regular", ["Zsym"].as_slice()),
|
||||
("Noto Sans Symbols 2", "Regular", ["Zsym"].as_slice()),
|
||||
("Noto Sans Arabic", "Regular", ["Arab"].as_slice()),
|
||||
] {
|
||||
assert!(
|
||||
faces.iter().any(|(f, s, declared)| *f == family && *s == style && *declared == scripts),
|
||||
@@ -122,11 +121,11 @@ fn language_reorders_only_the_han_faces() {
|
||||
let keys = |han| fallback_faces(han).into_iter().map(|(k, _)| k).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
keys(HanOrder::Japanese),
|
||||
["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-arabic", "sys-symbols", "sys-symbols-2"]
|
||||
["sys-cjk-han-ja", "sys-cjk-han-zh", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"]
|
||||
);
|
||||
assert_eq!(
|
||||
keys(HanOrder::Chinese),
|
||||
["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-arabic", "sys-symbols", "sys-symbols-2"]
|
||||
["sys-cjk-han-zh", "sys-cjk-han-ja", "sys-cjk-hangul", "sys-thai", "sys-lao", "sys-hebrew", "sys-symbols", "sys-symbols-2"]
|
||||
);
|
||||
assert_eq!(HanOrder::of(Language::Ja), HanOrder::Japanese);
|
||||
for l in [Language::En, Language::Zh, Language::Ko, Language::Ru, Language::PtBr, Language::De, Language::Cs, Language::Fr, Language::Tr] {
|
||||
@@ -185,7 +184,7 @@ fn fallback_covers_its_script() {
|
||||
// face or the optional craft-fonts input provides one; the printed skip is a recorded
|
||||
// gate, and a present face that is not wired in fails.
|
||||
type Case = (&'static str, Vec<(String, u32)>, &'static str);
|
||||
let cases: [Case; 11] = [
|
||||
let cases: [Case; 10] = [
|
||||
("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"),
|
||||
// Chinese uses a system Han face or the native-only permitted LXGW craft face; the
|
||||
// barred Noto CJK collection never supplies it.
|
||||
@@ -194,7 +193,6 @@ fn fallback_covers_its_script() {
|
||||
("sys-thai", thai(), "ทดสอบ"),
|
||||
("sys-lao", lao(), "ພາສາລາວ"),
|
||||
("sys-hebrew", hebrew(), "עברית"),
|
||||
("sys-arabic", arabic(), "العربية١٢٣"),
|
||||
("craft-noto-sans-arabic", Vec::new(), "مرحبا"),
|
||||
("craft-noto-sans-georgian", Vec::new(), "ქართული"),
|
||||
("ui", candidates(UI), "Русский"),
|
||||
@@ -223,138 +221,6 @@ fn fallback_covers_its_script() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A preferred Arabic file keeps the system Arabic faces from loading it again only once it is
|
||||
/// loaded: one that failed to load leaves them free to load it.
|
||||
fn an_unloaded_preferred_arabic_file_does_not_block_the_system_face() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-theme-preferred-arabic-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let path = dir.join("Arabic.ttf");
|
||||
std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT).expect("font fixture");
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
let preferred = || Some((path.clone(), 0));
|
||||
let faces = || vec![("sys-arabic", vec![(path.clone(), 0)])];
|
||||
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let loaded = preferred_file(&fonts, preferred());
|
||||
assert!(loaded.is_empty(), "a preferred face that did not load reserves no file");
|
||||
assert_eq!(load_faces(&mut fonts, faces(), loaded), ["sys-arabic"]);
|
||||
|
||||
let mut fonts = FontDefinitions::default();
|
||||
fonts.font_data.insert(PREFERRED_ARABIC.into(), Arc::new(FontData::from_static(epaint_default_fonts::UBUNTU_LIGHT)));
|
||||
let loaded = preferred_file(&fonts, preferred());
|
||||
assert_eq!(loaded, std::slice::from_ref(&path));
|
||||
assert!(load_faces(&mut fonts, faces(), loaded).is_empty(), "a loaded preferred file is not loaded a second time");
|
||||
assert!(!fonts.font_data.contains_key("sys-arabic"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn with_installed_latin_font() -> std::io::Result<FontDefinitions> {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-theme-latin-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = dir.join("Ubuntu-Light.ttf");
|
||||
std::fs::write(&path, epaint_default_fonts::UBUNTU_LIGHT)?;
|
||||
let face = crate::fonts::Face { family: "Ubuntu".into(), names: vec!["Ubuntu".into()], path, index: 0, weight: 300.0, italic: false };
|
||||
let fonts = font_definitions_impl(Language::En, vec![("Ubuntu".into(), vec![face])], true);
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
Ok(fonts)
|
||||
}
|
||||
|
||||
fn installed_latin_chains(fonts: &FontDefinitions) -> Vec<(FontFamily, Option<&Vec<String>>)> {
|
||||
[(false, false), (true, false), (false, true), (true, true)]
|
||||
.into_iter()
|
||||
.map(|(bold, italic)| {
|
||||
let family = FontFamily::Name(crate::fonts::role_key("Ubuntu", bold, italic).into());
|
||||
let chain = fonts.families.get(&family);
|
||||
(family, chain)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn chain_covers(fonts: &FontDefinitions, chain: &[String], ch: char) -> bool {
|
||||
use skrifa::MetadataProvider;
|
||||
chain
|
||||
.iter()
|
||||
.filter_map(|key| fonts.font_data.get(key))
|
||||
.any(|data| skrifa::FontRef::from_index((*data.font).as_ref(), data.index).is_ok_and(|font| font.charmap().map(ch).is_some()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A workbook font with no Arabic or Hangul still shows them through the system script faces.
|
||||
fn installed_latin_fonts_keep_system_script_fallbacks() {
|
||||
let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let fonts = with_installed_latin_font().expect("Latin-only fixture");
|
||||
let loaded: Vec<&str> =
|
||||
fallback_faces(HanOrder::Chinese).into_iter().map(|(key, _)| key).filter(|key| fonts.font_data.contains_key(*key)).collect();
|
||||
for (family, chain) in installed_latin_chains(&fonts) {
|
||||
let chain = chain.expect("bound fixture family");
|
||||
for key in &loaded {
|
||||
assert!(chain.iter().any(|k| k == key), "{family:?} lost the {key} fallback");
|
||||
}
|
||||
if fonts.font_data.contains_key("sys-arabic") {
|
||||
assert!(chain_covers(&fonts, chain, 'ع'), "{family:?} has no glyph for ع");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The craft Arabic face reaches workbook fonts and the pinned web set, without help from system faces.
|
||||
fn the_craft_arabic_face_reaches_every_chain() {
|
||||
assert!(crate::craft_fonts_policy::web_allowed("Noto Sans Arabic", "Regular"));
|
||||
if !craft_fonts_cover("العربية١٢٣") {
|
||||
assert!(!craft_fonts_required(), "CRAFT_FONTS_REQUIRED=1 but no craft-fonts face covers Arabic");
|
||||
return;
|
||||
}
|
||||
let _g = crate::fonts::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut fonts = with_installed_latin_font().expect("Latin-only fixture");
|
||||
fonts.font_data.retain(|key, _| !key.starts_with("sys-"));
|
||||
for (family, chain) in installed_latin_chains(&fonts) {
|
||||
let chain = chain.expect("bound fixture family");
|
||||
assert!("العربية١٢٣".chars().all(|ch| chain_covers(&fonts, chain, ch)), "{family:?} has no Arabic glyphs");
|
||||
}
|
||||
let pinned = pinned_font_definitions();
|
||||
for (family, chain) in &pinned.families {
|
||||
assert!("العربية".chars().all(|ch| chain_covers(&pinned, chain, ch)), "pinned {family:?} has no Arabic glyphs");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
/// fontconfig's Arabic face, when it has Arabic letters, comes first among the Arabic fallbacks in every chain (#229). A
|
||||
/// UI or cell face with Arabic of its own sits ahead of every fallback and still draws Arabic first.
|
||||
fn the_fontconfig_arabic_choice_comes_first() {
|
||||
assert_eq!(
|
||||
parse_fc_match("/home/u/.local/share/fonts/Vazirmatn-Regular.ttf\n0\n"),
|
||||
Some(("/home/u/.local/share/fonts/Vazirmatn-Regular.ttf".into(), 0))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_fc_match("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc\n2\n"),
|
||||
Some(("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc".into(), 2))
|
||||
);
|
||||
assert_eq!(parse_fc_match("/usr/share/fonts/a.ttf"), Some(("/usr/share/fonts/a.ttf".into(), 0)));
|
||||
assert_eq!(
|
||||
parse_fc_match("/usr/share/fonts/vazirmatn/Vazirmatn[wght].ttf\n262145\n"),
|
||||
Some(("/usr/share/fonts/vazirmatn/Vazirmatn[wght].ttf".into(), 1)),
|
||||
"a named instance's bits are not part of the face index"
|
||||
);
|
||||
assert_eq!(parse_fc_match(""), None);
|
||||
assert!(!covers_arabic(epaint_default_fonts::UBUNTU_LIGHT, 0), "a Latin-only face is not an Arabic choice");
|
||||
if fontconfig_arabic().is_some() {
|
||||
let fonts = font_definitions();
|
||||
for (family, chain) in fonts.families.iter().filter(|(_, chain)| chain.iter().any(|key| key.starts_with("sys-"))) {
|
||||
let preferred = chain.iter().position(|key| key == PREFERRED_ARABIC);
|
||||
let other_arabic = chain.iter().position(|key| key == "sys-arabic" || key.starts_with("craft-"));
|
||||
assert!(preferred.is_some(), "{family:?} lacks the preferred Arabic face");
|
||||
assert!(other_arabic.is_none_or(|other| preferred < Some(other)), "{family:?} puts another Arabic face first");
|
||||
}
|
||||
if let (Some(preferred), Some(system)) = (fonts.font_data.get(PREFERRED_ARABIC), fonts.font_data.get("sys-arabic")) {
|
||||
assert!((*preferred.font).as_ref() != (*system.font).as_ref(), "the system Arabic fallback is a different file from the preferred face");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chromeos_font_layout_is_probed() {
|
||||
// #1762: Crostini mounts the host's fonts under /usr/share/fonts/chromeos, one folder per
|
||||
@@ -363,7 +229,6 @@ fn chromeos_font_layout_is_probed() {
|
||||
let cros = "/usr/share/fonts/chromeos";
|
||||
assert!(probes(thai(), &format!("{cros}/noto/NotoSansThai-Regular.ttf")));
|
||||
assert!(probes(hebrew(), &format!("{cros}/noto/NotoSansHebrew-Regular.ttf")));
|
||||
assert!(probes(arabic(), &format!("{cros}/noto/NotoSansArabic-Regular.ttf")));
|
||||
assert!(probes(cjk_han_japanese(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc")));
|
||||
assert!(probes(cjk_han_simplified(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc")));
|
||||
assert!(probes(cjk_hangul(), &format!("{cros}/notocjk/NotoSansCJK-Regular.ttc")));
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
//! Spreadsheet text remains logical while the shared renderer lays it out visually.
|
||||
|
||||
use egui::{Event, Key, Modifiers, Shape, vec2};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::{
|
||||
Session,
|
||||
core::{CellRef, Value},
|
||||
};
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn app(text: &str, order: &str, sheet_rtl: bool) -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("cell.set", json!({"cell": "B2", "input": text})).unwrap();
|
||||
session.execute("home.formatCells", json!({"range":"B2", "style":{"align":{"reading_order":order},"font":{"size":20}}})).unwrap();
|
||||
session.execute("selection.set", json!({"cell":"B2"})).unwrap();
|
||||
if sheet_rtl {
|
||||
session.execute("view.rightToLeft", json!({"on":true})).unwrap();
|
||||
}
|
||||
let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn text_shapes<'a>(shape: &'a Shape, text: &str, out: &mut Vec<&'a egui::epaint::TextShape>) {
|
||||
match shape {
|
||||
Shape::Text(s) if s.galley.text() == text => out.push(s),
|
||||
Shape::Vec(shapes) => {
|
||||
for s in shapes {
|
||||
text_shapes(s, text, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_direction_reaches_cells_on_both_sheet_geometries() {
|
||||
let text = "ABC مرحبا 123";
|
||||
for sheet_rtl in [false, true] {
|
||||
for (order, rtl) in [("LeftToRight", false), ("RightToLeft", true)] {
|
||||
let h = app(text, order, sheet_rtl);
|
||||
let mut painted = Vec::new();
|
||||
for shape in &h.output().shapes {
|
||||
text_shapes(&shape.shape, text, &mut painted);
|
||||
}
|
||||
assert!(painted.iter().any(|s| s.galley.job.direction
|
||||
== if rtl { egui::epaint::text::TextDirection::RightToLeft } else { egui::epaint::text::TextDirection::LeftToRight }));
|
||||
for shape in painted {
|
||||
assert_eq!(shape.galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| g.chr)).collect::<String>(), text);
|
||||
assert_eq!(shape.galley.rows[0].rtl_paragraph, rtl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marked_arabic_survives_copy_paste_commit_undo_and_xlsx() {
|
||||
let text = "مَرْحَبًا 123 (ABC)";
|
||||
for formula_bar in [false, true] {
|
||||
let mut h = app(text, "RightToLeft", false);
|
||||
if formula_bar {
|
||||
h.get_by_label(text).click();
|
||||
} else {
|
||||
h.key_press(Key::F2);
|
||||
}
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().editor.as_ref().map(|e| e.from_formula_bar), Some(formula_bar));
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.push(Event::Copy);
|
||||
h.run_steps(1);
|
||||
assert!(h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == text)));
|
||||
let replacement = "تَجْرِبَة ١٢٣ (ABC)";
|
||||
h.input_mut().events.push(Event::Paste(replacement.into()));
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(2);
|
||||
let value = |h: &Harness| h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::new(1, 1));
|
||||
assert_eq!(value(&h), Value::text(replacement));
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(value(&h), Value::text(text));
|
||||
h.state_mut().session.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(value(&h), Value::text(replacement));
|
||||
let wb = &h.state().session.active().unwrap().wb;
|
||||
let bytes = gridcraft_xlsx::write_xlsx(wb).unwrap();
|
||||
let (reopened, _) = gridcraft_xlsx::read_xlsx(&bytes).unwrap();
|
||||
assert_eq!(reopened.active().unwrap().value(CellRef::new(1, 1)), Value::text(replacement));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_reading_order_reaches_the_formula_bar() {
|
||||
let text = "ABC مرحبا 123";
|
||||
for (rule_style, rtl) in [(json!({"align": {"reading_order": "RightToLeft"}}), true), (json!({"font": {"bold": true}}), false)] {
|
||||
let mut h = app(text, "LeftToRight", false);
|
||||
h.state_mut().session.execute("home.conditionalFormat", json!({"range": "B2", "rule": {"type": "noBlanks", "style": rule_style}})).unwrap();
|
||||
h.run_steps(2);
|
||||
let mut painted = Vec::new();
|
||||
for shape in &h.output().shapes {
|
||||
text_shapes(&shape.shape, text, &mut painted);
|
||||
}
|
||||
let formula_bar = painted.iter().min_by(|a, b| a.pos.y.total_cmp(&b.pos.y)).unwrap();
|
||||
assert_eq!(formula_bar.galley.rows[0].rtl_paragraph, rtl);
|
||||
}
|
||||
}
|
||||
|
||||
fn painted_galleys(h: &Harness, text: &str) -> Vec<std::sync::Arc<egui::Galley>> {
|
||||
let mut painted = Vec::new();
|
||||
for shape in &h.output().shapes {
|
||||
text_shapes(&shape.shape, text, &mut painted);
|
||||
}
|
||||
painted.into_iter().map(|s| s.galley.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_left_keeps_wrapped_right_to_left_lines_on_the_left() {
|
||||
let text = "مرحبا بالعالم\nا";
|
||||
let mut h = app(text, "RightToLeft", false);
|
||||
h.state_mut()
|
||||
.session
|
||||
.execute("home.formatCells", json!({"range": "B2", "style": {"align": {"h": "Left", "wrap": true, "reading_order": "RightToLeft"}}}))
|
||||
.unwrap();
|
||||
h.run_steps(2);
|
||||
let mut painted = Vec::new();
|
||||
for shape in &h.output().shapes {
|
||||
text_shapes(&shape.shape, text, &mut painted);
|
||||
}
|
||||
let cell = painted.iter().max_by(|a, b| a.pos.y.total_cmp(&b.pos.y)).unwrap();
|
||||
assert!(cell.galley.rows.len() > 1);
|
||||
assert!(cell.galley.rows.iter().all(|row| row.rtl_paragraph));
|
||||
for row in &cell.galley.rows {
|
||||
let left = row.pos.x + row.glyphs.iter().map(|g| g.pos.x).fold(f32::INFINITY, f32::min);
|
||||
assert!(left.abs() < 0.5, "row starts at {left}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_to_left_editor_keeps_a_trailing_space_inside_the_text() {
|
||||
let text = "مرحبا ";
|
||||
let mut h = app("", "RightToLeft", false);
|
||||
h.key_press(Key::F2);
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.push(Event::Text(text.into()));
|
||||
h.run_steps(3);
|
||||
let galleys = painted_galleys(&h, text);
|
||||
assert!(!galleys.is_empty());
|
||||
for galley in galleys {
|
||||
for row in &galley.rows {
|
||||
for glyph in &row.glyphs {
|
||||
let x = row.pos.x + glyph.pos.x;
|
||||
assert!(x >= -0.5, "{:?} at {x}", glyph.chr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wrapped_row_edges(sheet_rtl: bool, h_align: &str, text: &str) -> Vec<(f32, f32)> {
|
||||
let mut h = app(text, "RightToLeft", sheet_rtl);
|
||||
h.state_mut().session.execute("home.formatCells", json!({"range": "B2", "style": {"align": {"h": h_align, "wrap": true}}})).unwrap();
|
||||
h.run_steps(2);
|
||||
let mut painted = Vec::new();
|
||||
for shape in &h.output().shapes {
|
||||
text_shapes(&shape.shape, text, &mut painted);
|
||||
}
|
||||
let cell = painted.iter().max_by(|a, b| a.pos.y.total_cmp(&b.pos.y)).unwrap();
|
||||
assert!(cell.galley.rows.len() > 1);
|
||||
cell.galley.rows.iter().map(|row| (cell.pos.x + row.rect().left(), cell.pos.x + row.rect().right())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_right_to_left_rows_follow_the_physical_alignment_on_both_sheet_geometries() {
|
||||
let text = "مرحبا بالعالم\nا";
|
||||
for (sheet_rtl, h_align, on_right) in [
|
||||
(false, "General", true),
|
||||
(false, "Left", false),
|
||||
(false, "Right", true),
|
||||
(true, "General", true),
|
||||
(true, "Left", false),
|
||||
(true, "Right", true),
|
||||
] {
|
||||
let edges = wrapped_row_edges(sheet_rtl, h_align, text);
|
||||
let side: Vec<f32> = edges.iter().map(|&(left, right)| if on_right { right } else { left }).collect();
|
||||
let first = side[0];
|
||||
assert!(side.iter().all(|x| (x - first).abs() <= 1.0), "sheet_rtl {sheet_rtl}, {h_align}: rows at {edges:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn single_line_right_edge(text: &str, order: &str, sheet_rtl: bool) -> f32 {
|
||||
let h = app(text, order, sheet_rtl);
|
||||
let mut painted = Vec::new();
|
||||
for shape in &h.output().shapes {
|
||||
text_shapes(&shape.shape, text, &mut painted);
|
||||
}
|
||||
let cell = painted.iter().max_by(|a, b| a.pos.y.total_cmp(&b.pos.y)).unwrap();
|
||||
assert_eq!(cell.galley.rows.len(), 1);
|
||||
cell.galley
|
||||
.rows
|
||||
.iter()
|
||||
.flat_map(|row| row.glyphs.iter().map(move |g| cell.pos.x + row.pos.x + g.pos.x + g.advance_width))
|
||||
.fold(f32::NEG_INFINITY, f32::max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mark_on_the_first_letter_keeps_single_line_right_to_left_text_flush_right() {
|
||||
for (marked, plain) in [("يُرجى مراجعة", "يرجى مراجعة"), ("بَاب", "باب")] {
|
||||
for sheet_rtl in [false, true] {
|
||||
for order in ["RightToLeft", "Context"] {
|
||||
let marked_right = single_line_right_edge(marked, order, sheet_rtl);
|
||||
let plain_right = single_line_right_edge(plain, order, sheet_rtl);
|
||||
assert!(
|
||||
(marked_right - plain_right).abs() <= 1.0,
|
||||
"{marked}, sheet_rtl {sheet_rtl}, {order}: ends at {marked_right}, unmarked at {plain_right}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
//! Arabic word navigation must not panic or delete unrelated cell text.
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Shape, vec2};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::{
|
||||
Session,
|
||||
core::{CellRef, Value},
|
||||
};
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
fn editing(text: &str, formula_bar: bool) -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("cell.set", json!({"cell": "A1", "input": text})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
if formula_bar {
|
||||
h.get_by_label(text).click();
|
||||
} else {
|
||||
h.key_press(Key::F2);
|
||||
}
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().editor.as_ref().map(|e| e.from_formula_bar), Some(formula_bar));
|
||||
h.key_press_modifiers(Modifiers::CTRL, Key::End);
|
||||
h.run_steps(2);
|
||||
let id = egui::Id::unique(if formula_bar { "gridcraft.formula_bar" } else { "gridcraft.cell_editor" });
|
||||
let caret = egui::text_edit::TextEditState::load(&h.ctx, id).and_then(|state| state.cursor.char_range()).map(|range| range.primary.index.0);
|
||||
assert_eq!(caret, Some(text.chars().count()), "the caret starts at the text's logical end");
|
||||
h
|
||||
}
|
||||
|
||||
/// The in-cell editor's laid-out text and its screen position: the lowest painted copy, below the formula bar.
|
||||
fn cell_editor_galley(h: &egui_kittest::Harness<'static, SheetApp>, text: &str) -> Option<(Pos2, std::sync::Arc<egui::Galley>)> {
|
||||
fn collect(shape: &Shape, text: &str, out: &mut Vec<(Pos2, std::sync::Arc<egui::Galley>)>) {
|
||||
match shape {
|
||||
Shape::Text(s) if s.galley.text() == text => out.push((s.pos, s.galley.clone())),
|
||||
Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, text, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut found = Vec::new();
|
||||
for clipped in &h.output().shapes {
|
||||
collect(&clipped.shape, text, &mut found);
|
||||
}
|
||||
found.into_iter().max_by(|a, b| a.0.y.total_cmp(&b.0.y))
|
||||
}
|
||||
|
||||
fn cell_editor_text_right_edge(h: &egui_kittest::Harness<'static, SheetApp>, text: &str) -> Option<Pos2> {
|
||||
cell_editor_galley(h, text).map(|(pos, galley)| pos + vec2(galley.rect.max.x - 1.0, galley.rect.center().y))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_after_typing_arabic_is_drawn_at_its_left_end() {
|
||||
let mut h = editing("", false);
|
||||
h.input_mut().events.push(Event::Text("بسم الله".into()));
|
||||
h.run_steps(2);
|
||||
let state = egui::text_edit::TextEditState::load(&h.ctx, egui::Id::unique("gridcraft.cell_editor")).unwrap();
|
||||
let caret = state.cursor.char_range().unwrap().primary;
|
||||
assert_eq!(caret.index.0, "بسم الله".chars().count());
|
||||
let (_, galley) = cell_editor_galley(&h, "بسم الله").unwrap();
|
||||
let x = galley.pos_from_cursor(caret).min.x;
|
||||
assert!(x <= galley.rect.min.x + 1.0, "caret at x={x}, text spans {:?}", galley.rect.x_range());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previous_word_in_arabic_cell_does_not_panic() {
|
||||
let mut h = editing("Aurora بب", false);
|
||||
h.key_press_modifiers(Modifiers::CTRL, Key::ArrowLeft);
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.push(egui::Event::Text("x".into()));
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(2);
|
||||
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
// End is the logical end at the Arabic run's left edge. Ctrl+Left follows visual
|
||||
// word order across the Latin prefix, preserving all original characters.
|
||||
assert_eq!(sheet.value(CellRef::new(0, 0)), Value::text("xAurora بب"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_previous_arabic_word_keeps_the_latin_prefix() {
|
||||
let mut h = editing("Aurora بب", false);
|
||||
h.key_press_modifiers(Modifiers::CTRL, Key::Backspace);
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(2);
|
||||
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sheet.value(CellRef::new(0, 0)), Value::text("Aurora "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_previous_arabic_word_in_formula_bar_keeps_the_prefix() {
|
||||
let mut h = editing("Aurora بب", true);
|
||||
h.key_press_modifiers(Modifiers::CTRL, Key::Backspace);
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(2);
|
||||
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sheet.value(CellRef::new(0, 0)), Value::text("Aurora "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previous_visual_word_crosses_arabic_from_its_right_edge() {
|
||||
let mut h = editing("Aurora بب", false);
|
||||
let pos = cell_editor_text_right_edge(&h, "Aurora بب").unwrap();
|
||||
h.input_mut().events.extend([
|
||||
Event::PointerMoved(pos),
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
h.run_steps(2);
|
||||
let state = egui::text_edit::TextEditState::load(&h.ctx, egui::Id::unique("gridcraft.cell_editor")).unwrap();
|
||||
let caret = state.cursor.char_range().map(|r| (r.primary.index.0, r.primary.prefer_next_row));
|
||||
assert_eq!(caret, Some((7, true)), "the click lands on the Arabic run's right edge");
|
||||
h.key_press_modifiers(Modifiers::CTRL, Key::ArrowLeft);
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.push(egui::Event::Text("x".into()));
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(2);
|
||||
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sheet.value(CellRef::new(0, 0)), Value::text("Aurora ببx"));
|
||||
}
|
||||
@@ -6,8 +6,7 @@ use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_model::style::{DxfStated, Protection};
|
||||
use gridcraft_model::{
|
||||
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, ReadingOrder, Style, Underline, VAlign, VertAlign,
|
||||
Workbook,
|
||||
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, Underline, VAlign, VertAlign, Workbook,
|
||||
};
|
||||
|
||||
use crate::tables::{INDEXED, builtin_format, builtin_id};
|
||||
@@ -269,12 +268,6 @@ fn read_alignment(a: &El) -> Alignment {
|
||||
shrink: a.flag("shrinkToFit", false),
|
||||
indent: a.attr_u32("indent").unwrap_or(0).min(250) as u8,
|
||||
rotation: 0,
|
||||
reading_order: match a.attr_u32("readingOrder") {
|
||||
Some(0) => Some(ReadingOrder::Context),
|
||||
Some(1) => Some(ReadingOrder::LeftToRight),
|
||||
Some(2) => Some(ReadingOrder::RightToLeft),
|
||||
_ => None,
|
||||
},
|
||||
};
|
||||
if let Some(r) = a.attr_i64("textRotation") {
|
||||
al.rotation = match r {
|
||||
@@ -609,12 +602,6 @@ fn alignment_xml(a: &Alignment) -> Option<String> {
|
||||
if a.shrink {
|
||||
s.push_str(" shrinkToFit=\"1\"");
|
||||
}
|
||||
match a.reading_order {
|
||||
None => {}
|
||||
Some(ReadingOrder::Context) => s.push_str(" readingOrder=\"0\""),
|
||||
Some(ReadingOrder::LeftToRight) => s.push_str(" readingOrder=\"1\""),
|
||||
Some(ReadingOrder::RightToLeft) => s.push_str(" readingOrder=\"2\""),
|
||||
}
|
||||
s.push_str("/>");
|
||||
Some(s)
|
||||
}
|
||||
@@ -826,50 +813,3 @@ pub fn dxf_id(dxfs: &mut Vec<Style>, s: &Style) -> u32 {
|
||||
dxfs.push(s.clone());
|
||||
(dxfs.len() - 1) as u32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn absent_or_invalid_reading_order_is_unspecified() {
|
||||
for attribute in ["", " readingOrder=\"3\"", " readingOrder=\"-1\"", " readingOrder=\"rtl\"", " readingOrder=\"4294967296\""] {
|
||||
let xml = format!("<alignment{attribute}/>");
|
||||
let align = read_alignment(&crate::xml::parse(xml.as_bytes()).unwrap());
|
||||
assert_eq!(align, Alignment::default(), "{attribute}");
|
||||
assert_eq!(alignment_xml(&align), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_order_survives_cell_named_and_differential_style_roundtrips() {
|
||||
for order in [0, 1, 2] {
|
||||
let xml = format!(
|
||||
r#"<styleSheet><cellXfs><xf/><xf><alignment readingOrder="{order}"/></xf></cellXfs>
|
||||
<cellStyleXfs><xf><alignment readingOrder="{order}"/></xf></cellStyleXfs>
|
||||
<cellStyles><cellStyle name="Directional" xfId="0"/></cellStyles>
|
||||
<dxfs><dxf><alignment readingOrder="{order}"/></dxf></dxfs></styleSheet>"#
|
||||
);
|
||||
let styles = read_styles(&crate::xml::parse(xml.as_bytes()).unwrap(), &[], "Calibri");
|
||||
let mut wb = Workbook::new();
|
||||
wb.styles.intern(styles.xfs[1].clone());
|
||||
wb.cell_styles = styles.named.clone();
|
||||
let (xml, _) = write_styles(&wb, &styles.dxfs, &[]);
|
||||
let root = crate::xml::parse(xml.as_bytes()).unwrap();
|
||||
for section in ["dxfs", "cellXfs", "cellStyleXfs"] {
|
||||
let orders: Vec<_> = root
|
||||
.child(section)
|
||||
.unwrap()
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|style| style.child("alignment").and_then(|a| a.attr_u32("readingOrder")))
|
||||
.collect();
|
||||
assert_eq!(orders, [order], "{section}");
|
||||
}
|
||||
let reopened = read_styles(&root, &[], "Calibri");
|
||||
assert_eq!(reopened.xfs[1].align, styles.xfs[1].align);
|
||||
assert_eq!(reopened.named, styles.named);
|
||||
assert_eq!(reopened.dxfs, styles.dxfs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,15 +40,7 @@ fn sample() -> Workbook {
|
||||
s.fill = Fill::solid(Color::Rgb(0xFFEE00));
|
||||
s.borders.bottom = BorderLine { style: BorderStyle::Double, color: Color::Rgb(0xFF0000) };
|
||||
s.borders.diag_down = BorderLine::thin();
|
||||
s.align = Alignment {
|
||||
h: HAlign::Center,
|
||||
v: VAlign::Top,
|
||||
wrap: true,
|
||||
shrink: false,
|
||||
indent: 1,
|
||||
rotation: -30,
|
||||
reading_order: Some(ReadingOrder::RightToLeft),
|
||||
};
|
||||
s.align = Alignment { h: HAlign::Center, v: VAlign::Top, wrap: true, shrink: false, indent: 1, rotation: -30 };
|
||||
s.protection.locked = false;
|
||||
});
|
||||
let money = wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new("#,##0.00 \"€\""));
|
||||
|
||||
@@ -339,7 +339,6 @@ fn xf_style(xf: &Xf, fonts: &[Font], formats: &HashMap<u16, String>, table: &[u3
|
||||
},
|
||||
indent: a2 & 0x0F,
|
||||
shrink: a2 & 0x10 != 0,
|
||||
reading_order: None,
|
||||
};
|
||||
let b1 = word32(4);
|
||||
let b2 = word32(8);
|
||||
|
||||
@@ -14,8 +14,6 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and
|
||||
Legacy scalar `INDEX(range, MATCH(...))` formulas remain ordinary formulas on save, including when
|
||||
shared formulas are expanded during import (`array_export.rs`, #1505).
|
||||
|
||||
Cell text now uses shared Unicode bidi layout while retaining logical source indexes for editing, copy and XLSX. Format Cells exposes Context, Left to right and Right to left reading direction, independent of sheet geometry. OOXML Context uses the first non-whitespace character. Optional direction preserves absent versus explicit Context in differential styles. The renderer is pinned to a reviewed egui 0.36.2 backport with real-font, editing, accessibility and WASM checks. Physical screen-reader behavior remains unverified.
|
||||
|
||||
## Workbook window settings
|
||||
|
||||
GridCraft keeps one modelled workbook window. On XLSX import it selects the first visible
|
||||
@@ -181,7 +179,6 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
|
||||
| 2026-11-01 | trivial | XLSX selects the first visible workbook view as one unit and bounds imported/exported tab ratios; isolated attributes and sheet-edit round trips covered, with firstSheet left to #2264 (#3057) |
|
||||
| 2026-10-31 | minor | 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 | minor | XLSX open reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)` export wrapper as the formula inside when GridCraft calculates every function in it (plain, shared and array formulas, whole or nested; Google-only functions and unparseable, over-long or over-deep inner text keep the wrapper and the cached value) and keeps the cached value; saving writes the real formula with Excel's `_xlfn.` prefixes (#2871) |
|
||||
| 2026-10-26 | minor | Alignment readingOrder 0/1/2 survives cell, named-style and differential-style round-trips; Format Cells exposes reading direction and CF priority preserves unspecified inheritance |
|
||||
| 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI `<img>`) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged |
|
||||
| 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `<rPh sb="…" eb="…">` with `<phoneticPr>`) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `<phoneticPr>` without runs is kept on its own, and when duplicate `<si>` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) |
|
||||
| 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (`<a:font script="Arab" typeface="…"/>`, also Hebr, Jpan and the rest) are read and written back in file order after `<a:cs>`; they were dropped before. A hostile file is held to 64 entries of 256 characters |
|
||||
|
||||
+1
-10
@@ -54,15 +54,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
- **Estimate:** 25–35 h for beta (40–60 h full). [chart-parity.md](chart-parity.md)
|
||||
|
||||
### 5. Text in non-Latin scripts doesn't render or export (L)
|
||||
- **Missing:** cross-platform script coverage, Devanagari/Thai shaping verification, non-Latin PDF and Arabic UI layout.
|
||||
- **Evidence:** system-font fallback for CJK, Thai and Hebrew landed in #38 (closed #32); #142
|
||||
(Chinese text) awaits confirmation; #54 (symbols as boxes on Fedora). Arabic shared shaping/bidi,
|
||||
editing and cell Context/LTR/RTL now have focused regressions and full gates. Arabic font
|
||||
delivery and PDF output are separate PRs; other script shaping still needs verification.
|
||||
- **Missing:** CJK font fallback, complex shaping (Arabic, Devanagari, Thai), bidi, non-Latin PDF.
|
||||
- **Evidence:** system-font fallback for CJK, Thai and Hebrew landed in #38 (closed #32), and for
|
||||
Arabic (system, fontconfig and craft-fonts faces); a UI font with Arabic of its own (DejaVu Sans
|
||||
in the Flatpak) still draws interface Arabic ahead of fontconfig's choice (#229); #142
|
||||
- **Evidence:** system-font fallback for CJK, Thai and Hebrew landed in #38 (closed #32); #142
|
||||
(Chinese text) awaits confirmation; #54 (symbols as boxes on Fedora); no shaping for Arabic or
|
||||
Devanagari; PDF writer uses 14 standard fonts only.
|
||||
- **Impact:** blocks most of the world's users even with an English UI.
|
||||
@@ -200,8 +193,6 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
| 2026-11-01 | minor | §4: chart data tables implemented for column, bar, line, area and compatible combo charts, with legend keys and border settings, live source values, XLSX round-trip and print/PDF; other chart gaps remain. |
|
||||
| 2026-10-31 | minor | §13: Solver Options (Max Evaluations, Integer Optimality and the Evolutionary options), saved with the model |
|
||||
| 2026-10-31 | minor | §13: Solver Load/Save Model (`data.solverSaveModel`, `data.solverLoadModel`) and Reset All (`data.solverReset`), with buttons in the Solver Parameters dialog |
|
||||
| 2026-10-26 | minor | Arabic shared bidi display/editing and persisted cell reading direction; other script and PDF work remain separate |
|
||||
| 2026-10-26 | minor | §5: Arabic text renders in cells and the interface: a system Arabic face (Tahoma, Arial or Segoe UI on Windows, Geeza Pro on macOS, Noto Sans or Naskh Arabic on Linux) joins the script fallbacks, fontconfig's Arabic choice, when it has Arabic letters, leads the Arabic fallbacks and draws Arabic wherever the UI or cell font has none, such as the default cell fonts (part of #229; a UI font with Arabic of its own, such as DejaVu Sans in the Flatpak, still draws interface Arabic, left for a follow-up), and craft-fonts' Noto Sans Arabic is a required face allowed on the web. Workbook fonts without Arabic keep every script fallback. Shaping and bidirectional layout remain separate work |
|
||||
| 2026-10-24 | minor | §8: PivotTable Enable show details: `pivot.options` `enableShowDetails` turns Show Details off for a PivotTable, read and written in XLSX as `enableDrill` |
|
||||
| 2026-10-23 | minor | §8: PivotTable Show Details: double-clicking a value cell lists its source records on a new sheet (#2364) |
|
||||
| 2026-10-22 | minor | §20: a native window per workbook window, New Window on the same workbook and Arrange All on the desktop; left: Arrange All across monitors, the web's one window, pen pressure (estimate 10–14 h → 5–8 h) |
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (Arabic bidi display, editing and cell reading direction; missing translations are listed by `cargo xtask i18n-report` and never fail a check); previously minor (Arabic font fallback; missing translations are listed by `cargo xtask i18n-report` and never fail a check); previously minor (number formats honour the `B1`/`B2` calendar prefixes, checked against Excel; earlier: missing translations are listed by `cargo xtask i18n-report` and never fail a check; Arabic text sorts by its letters, not its vowel marks); previously minor (AutoFit estimates Arabic text width from measured glyph widths); previously minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check; Arabic text sorts by its letters, not its vowel marks) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Per-language status against Excel, which ships its UI in dozens of languages (Windows language
|
||||
packs cover 100+), with localized function names, argument separators, number and date formats,
|
||||
right-to-left sheets and full complex-script support. Part of
|
||||
[target-app-parity.md](target-app-parity.md). **Localization: ~10% ready, 100–165 h** (estimated).
|
||||
|
||||
Cell text now uses shared Unicode bidi layout while retaining logical source indexes for editing, copy and XLSX. Format Cells exposes Context, Left to right and Right to left reading direction, independent of sheet geometry. OOXML Context uses the first non-whitespace character. Optional direction preserves absent versus explicit Context in differential styles. The renderer is pinned to a reviewed egui 0.36.2 backport with real-font, editing, accessibility and WASM checks. Physical screen-reader behavior remains unverified.
|
||||
|
||||
## Where we are (measured from source on `main`, after the 2026-10-10 merges)
|
||||
|
||||
- **A first catalog exists, for the ribbon only.** `crates/ui-egui/src/i18n.rs` (merged 10-10 in
|
||||
@@ -42,36 +40,18 @@ Cell text now uses shared Unicode bidi layout while retaining logical source ind
|
||||
- **en-US only formulas and formats.** Function names, `,` argument separator, `.` decimal
|
||||
separator, en-US number and date formats (`crates/numfmt` says "en-US only"). Users ask for
|
||||
localized function names and `;` separators (#45). German with localized numbers and formulas
|
||||
is in PR #163 (open). The `B1` (Gregorian) and `B2` (Hijri) calendar prefixes that Arabic
|
||||
Excel writes at the start of a format section are hidden; under `B2` the year, month number,
|
||||
day, month names and weekday names (Arabic; `mmm` and `ddd` show the full name) count in the
|
||||
tabular Hijri calendar (leap years 2, 5, 7, 10, 13, 15, 18, 21, 24, 26 and 29 of each 30-year
|
||||
cycle). Checked against Excel 16.0 (build 20527, en-US) on every serial from 1 to 2,958,465
|
||||
and about 1,700 format/value pairs: a `[$...]` locale or currency tag before `B2` keeps the
|
||||
date Gregorian; a repeated prefix (`B2B2`) is accepted; any other `B` and digit (`B3`,
|
||||
`B12`, `B1B2`, `dd B2`, `\B2`), or a prefix before a number format (`B10.00`), is
|
||||
rejected (`TEXT` gives `#VALUE!`, a cell shows General); serial 60 shows `#VALUE!` under `B2`
|
||||
for any date or clock part; two-word month names use a no-break space. Differs from Excel:
|
||||
in a `B2` section with a weekday and a month name, Excel prints the weekday for
|
||||
`mmmm`/`mmmmm` after `ddd`/`dddd`, and the weekday numbered by the month (Saturday from
|
||||
month 7) for `ddd`/`dddd` after `mmm`/`mmmm`; a `B2` section showing only the weekday uses
|
||||
the real weekday for serials 0–59 and gives `#VALUE!` for the last days of 9999. Excel's
|
||||
Buddhist-year `b`/`bb`/`bbbb` tokens are not supported.
|
||||
is in PR #163 (open).
|
||||
- **Script support.** System-font fallback for CJK, Thai and Hebrew landed in #38 (closes #32;
|
||||
#142 is still open pending confirmation). Native CJK fallback also probes Flatpak's read-only
|
||||
`/run/host/fonts`, `/run/host/local-fonts` and `/run/host/user-fonts` layouts, so installed
|
||||
Noto CJK and WenQuanYi faces can render Chinese without a broad filesystem grant. This does
|
||||
not change the installed-font catalogue or add bundled fonts. Lao falls back to Lao Sangam MN / Lao MN (macOS),
|
||||
Leelawadee UI / DokChampa (Windows) or Noto Sans Lao / Phetsarath OT (Linux). egui uses harfrust shaping and Unicode bidi layout; script-specific coverage
|
||||
beyond Arabic still needs verification. **Sheet Right-to-Left**
|
||||
Leelawadee UI / DokChampa (Windows) or Noto Sans Lao / Phetsarath OT (Linux). Arabic falls back to a system Arabic face, fontconfig's
|
||||
Arabic choice or craft-fonts' Noto Sans Arabic; fontconfig's choice leads these fallbacks, but a UI font with Arabic of its
|
||||
own (DejaVu Sans in the Flatpak) still draws interface Arabic (#229). There is no complex-text shaping: Arabic and
|
||||
Leelawadee UI / DokChampa (Windows) or Noto Sans Lao / Phetsarath OT (Linux). There is no complex-text shaping: Arabic and
|
||||
Devanagari don't join or reorder, and there is no bidirectional layout. **Sheet Right-to-Left**
|
||||
(`view.rightToLeft`, Page Layout › Sheet Options, XLSX `rightToLeft`, `.xls` WINDOW2 fRightToLeft) mirrors the grid (#146):
|
||||
column A and the row headers on the right, the vertical scrollbar on the left; hit-testing,
|
||||
arrow keys, editor, popups and floating objects follow; text follows its cell reading direction inside its box,
|
||||
and the sheet tabs, horizontal scrollbar and printing/PDF are not mirrored yet.
|
||||
arrow keys, editor, popups and floating objects follow; text stays left-to-right inside its box
|
||||
(no bidi), and the sheet tabs, horizontal scrollbar and printing/PDF are not mirrored yet.
|
||||
Text boxes and shapes keep their paragraphs' right-to-left direction (`a:pPr rtl`) through XLSX
|
||||
save, though GridCraft still draws that text left to right. IME input goes
|
||||
through egui/winit and is unverified for Chinese, Japanese and Korean.
|
||||
@@ -92,7 +72,7 @@ separators, number/date formats, machine translation with agent review, and scri
|
||||
| Simplified Chinese (Mandarin) | zh-Hans | 266 (~12%) | no | CJK system-font fallback (#38); IME unverified; PDF can't export | no | menus only | 8–13 h |
|
||||
| Spanish | es | 0 (0%) | no | Latin: yes | no | none | 6–10 h |
|
||||
| Hindi | hi | 0 (0%) | no | Devanagari: **no shaping** | no | none | 12–18 h |
|
||||
| Arabic | ar | 0 (0%) | no | shared shaping/bidi; no Arabic UI catalog or mirrored shell; Sheet RTL mirrors the grid (#146) | no | none | 25–35 h |
|
||||
| Arabic | ar | 0 (0%) | no | **no shaping, no RTL UI**; Sheet Right-to-Left mirrors the grid (#146) | no | none | 25–35 h |
|
||||
| French | fr | 0 (0%) | no | Latin: yes | no | none | 6–10 h |
|
||||
| Portuguese | pt-BR | 943 (~43%) | dialogs and tooltips (dialogs take effect with #669) | Latin: yes | no | menus, File view, dialogs | 2–4 h |
|
||||
| Indonesian | id | 0 (0%) | no | Latin: yes | no | none | 5–8 h |
|
||||
@@ -130,10 +110,6 @@ language (none so far) and of localized function names.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-26 | minor | Shared bidi layout supports Arabic clusters, marks, visual cursor navigation and directional accessibility; cell Context/LTR/RTL is stored separately from sheet RTL and round-trips through XLSX |
|
||||
| 2026-10-26 | minor | Arabic text renders in cells and the interface: a system Arabic face (Tahoma, Arial or Segoe UI on Windows, Geeza Pro on macOS, Noto Sans or Naskh Arabic on Linux) joins the script fallbacks, fontconfig's Arabic choice, when it has Arabic letters, leads the Arabic fallbacks and draws Arabic wherever the UI or cell font has none, such as the default cell fonts (part of #229; a UI font with Arabic of its own, such as DejaVu Sans in the Flatpak, still draws interface Arabic, left for a follow-up), and craft-fonts' Noto Sans Arabic is a required face allowed on the web. Workbook fonts without Arabic keep every script fallback. Shaping and bidirectional layout remain separate work |
|
||||
| 2026-10-27 | minor | Number formats honour the `B1`/`B2` calendar prefixes: never shown; `B2` renders year, month, day and Arabic month and weekday names in the tabular Hijri calendar (1 Jan 2001 = 6 Shawwal 1421; leap years 2, 5, 7, 10, 13, 15, 18, 21, 24, 26 and 29 of the 30-year cycle, which is what Excel shows). Checked against Excel 16.0 (build 20527, en-US) on every serial and about 1,700 format/value pairs: a locale or currency tag before `B2` keeps the date Gregorian, a misplaced `B` and digit or a prefix before a number format is rejected, serial 60 has no Hijri date, serial 0 shows as serial 1, two-word month names use a no-break space. Not matched: Excel's weekday/month-name mix-ups in one `B2` section and its weekday-only quirks |
|
||||
| 2026-10-27 | minor | AutoFit column width, wrapped-row height and PivotTable column fit estimate Arabic letters at ~0.48 em (measured 0.36–0.53 em shaped, Noto Sans/Naskh Arabic and DejaVu Sans) instead of 1 em, Arabic-Indic digits as wide as ASCII digits, and give vowel marks and direction marks no width; vowelled Arabic no longer fits ~3.5× too wide |
|
||||
| 2026-10-23 | minor | Missing translations are reported, never a failure: `cargo xtask i18n-report` prints, per language, the strings other catalogs translate and it lacks (plus Spanish function help rows keyed by English the function catalog no longer has) and always passes; `cargo xtask ci` runs it first. Coverage and per-language parity tests and tests of one language's exact wording are gone; UI tests take the expected text from the catalog. Duplicate-key, placeholder, context-prefix and line-shape checks stay |
|
||||
| 2026-10-21 | minor | Romanian interface (`ro`, Română): 970 labels covering every key of the other catalogs (`crates/ui-egui/src/i18n/ro.rs`), `ro-*` system locales, the View-tab switcher and `app.language.set`; context keys show no prefix (File › Options test). English formulas and formats; AI-assisted draft pending native-speaker review |
|
||||
| 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `<phoneticPr>` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `<phoneticPr>` without runs is kept too, while duplicate texts with different readings keep only the first, reported on open). Display of the readings is still missing (`crates/xlsx/src/tests/phonetic_tests.rs`) |
|
||||
@@ -141,7 +117,6 @@ language (none so far) and of localized function names.
|
||||
| 2026-10-20 | minor | Dutch (nl) interface catalog (`i18n/nl.rs`, 518 labels, ~24%): the shared key set every language must cover (ribbon, context menus, File view, dialog titles, Options page), matching the German, French and Czech catalogs; `nl-NL`, `nl-BE` locale tags resolve to Dutch |
|
||||
| 2026-10-20 | minor | XLSX: a theme's per-script typefaces (`<a:font script="Arab">`, Hebr, Jpan…) are kept through open + save, so Arabic Excel's theme fonts for shapes, charts and theme-font cells survive a GridCraft save |
|
||||
| 2026-10-20 | trivial | XLSX: a workbook theme's major/minor East Asian (`<a:ea>`) and complex-script (`<a:cs>`) typefaces are kept through open + save, so a Japanese workbook keeps its theme's East Asian font; rendering does not resolve them yet (follow-up) |
|
||||
| 2026-10-19 | minor | Text order (Sort, SORT/SORTBY, `<`/`>`, approximate MATCH/XLOOKUP, `"<x"` criteria) compares letters first: Arabic vowel and Quranic marks, tatweel and invisible format characters (bidi marks, embeddings and isolates, ZWJ/ZWNJ, BOM, soft hyphen) no longer decide where text sorts, and Arabic-Indic digits sort as digits. Text with the same letters then sorts letter by letter, unmarked before marked, marks in Excel's order (sukun before shadda) and digits ASCII, Arabic-Indic, Extended Arabic-Indic; checked against Excel 16.0 build 20527 (en-US). Equality is unchanged |
|
||||
| 2026-10-19 | minor | XLSX fonts retain charset and family metadata so systems can select a substitute font with the intended script when the named font is unavailable |
|
||||
| 2026-10-19 | minor | Arabic CSV and text imports decode Windows-1256 text; Arabic shaping and bidirectional UI remain unsupported |
|
||||
| 2026-10-19 | minor | Defined-name length counts characters, not bytes: Arabic names up to 255 letters are accepted, and Create from Selection keeps long Arabic headers |
|
||||
|
||||
Reference in New Issue
Block a user