diff --git a/crates/numfmt/src/calendar.rs b/crates/numfmt/src/calendar.rs new file mode 100644 index 000000000..ab5bd4bbd --- /dev/null +++ b/crates/numfmt/src/calendar.rs @@ -0,0 +1,206 @@ +//! 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 { + 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 { + // 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 = (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); + } + } +} diff --git a/crates/numfmt/src/lib.rs b/crates/numfmt/src/lib.rs index cbb11bc31..57384cc89 100644 --- a/crates/numfmt/src/lib.rs +++ b/crates/numfmt/src/lib.rs @@ -4,6 +4,7 @@ #![forbid(unsafe_code)] mod builtin; +mod calendar; mod decimal; mod parse; mod render; @@ -96,6 +97,9 @@ struct Inner { num_sections: usize, /// Index of the section that applies to text, if any. text_section: Option, + /// 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. @@ -130,7 +134,8 @@ 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 degrades to General. + /// a code longer than [`MAX_FORMAT_CODE_CHARS`] characters, or one Excel rejects for a + /// misplaced `B1`/`B2` calendar prefix, degrades to General. pub fn parse(code: &str) -> NumberFormat { Self::parse_inner(code, false) } @@ -147,12 +152,16 @@ 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 NumberFormat { - inner: Arc::new(Inner { code: code.to_string(), general: true, sections: Vec::new(), num_sections: 0, text_section: None }), - }; + return general(false); } 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 { @@ -169,7 +178,7 @@ impl NumberFormat { } else { len }; - NumberFormat { inner: Arc::new(Inner { code: code.to_string(), general: false, sections, num_sections, text_section }) } + NumberFormat { inner: Arc::new(Inner { code: code.to_string(), general: false, sections, num_sections, text_section, invalid: false }) } } pub fn general() -> NumberFormat { @@ -487,6 +496,9 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result return Err(e), Value::Empty => 0.0, diff --git a/crates/numfmt/src/parse.rs b/crates/numfmt/src/parse.rs index ac905d36b..c2b121940 100644 --- a/crates/numfmt/src/parse.rs +++ b/crates/numfmt/src/parse.rs @@ -1,6 +1,7 @@ //! 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; @@ -24,6 +25,11 @@ 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(_)) } @@ -122,6 +128,10 @@ 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. @@ -251,17 +261,60 @@ struct Raw { color: Option, cond: Option, currency: bool, + calendar: Calendar, + /// The section's calendar prefix as written, `'1'` or `'2'` (whether or not a locale tag + /// cancels it). + prefix: Option, + /// 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 = src.chars().collect(); - let mut raw = Raw { toks: Vec::new(), color: None, cond: None, currency: false }; + let mut raw = Raw { toks: Vec::new(), color: None, cond: None, currency: false, calendar: Calendar::Gregorian, prefix: None, invalid: 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; @@ -283,6 +336,8 @@ 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; @@ -383,6 +438,11 @@ 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 } @@ -438,7 +498,7 @@ fn date_part(t: &Tok) -> Option { } fn analyze(raw: Raw) -> Section { - let Raw { toks, color, cond, currency } = raw; + let Raw { toks, color, cond, currency, calendar, prefix, invalid } = 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 { @@ -455,6 +515,8 @@ 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; @@ -469,6 +531,9 @@ 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) } diff --git a/crates/numfmt/src/render.rs b/crates/numfmt/src/render.rs index bb7fd1514..bfd31e56c 100644 --- a/crates/numfmt/src/render.rs +++ b/crates/numfmt/src/render.rs @@ -1,8 +1,9 @@ //! Rendering a value with a parsed section. use gridcraft_core::DateSystem; -use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, max_serial}; +use gridcraft_core::date::{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}; @@ -72,6 +73,15 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option 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(..)))); @@ -80,8 +90,8 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option 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() }; - 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(""); + // 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 mut out = Out::default(); for t in &sec.toks { match t { @@ -89,21 +99,21 @@ pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option let s = match *d { DatePart::Year(n) => { if n <= 2 { - format!("{:02}", dt.year.rem_euclid(100)) + format!("{:02}", date.year.rem_euclid(100)) } else { - format!("{:04}", dt.year) + format!("{:04}", date.year) } } DatePart::Month(n) => match n { - 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(), + 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(), }, DatePart::Day(n) => match n { - 1 | 2 => pad(dt.day as i64, n), - 3 => day_name.chars().take(3).collect(), - _ => day_name.to_string(), + 1 | 2 => pad(date.day as i64, n), + 3 => date.day_short.to_string(), + _ => date.day_name.to_string(), }, DatePart::Hour(n) => { let h = if ampm { diff --git a/crates/numfmt/src/tests.rs b/crates/numfmt/src/tests.rs index b2709b320..0e9a169a5 100644 --- a/crates/numfmt/src/tests.rs +++ b/crates/numfmt/src/tests.rs @@ -902,6 +902,240 @@ 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] diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 3f1dacb96..d504c19ea 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** 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-27 · **Change:** 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 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, @@ -40,7 +40,21 @@ right-to-left sheets and full complex-script support. Part of - **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). + 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. - **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 @@ -110,6 +124,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 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 |