From c35234e5f555201cf39669c7164a1fc9f5267cf8 Mon Sep 17 00:00:00 2001 From: Mouhand Kaddo Date: Mon, 9 Nov 2026 18:54:05 +0400 Subject: [PATCH] Number formats: honour the B1/B2 calendar prefixes (Hijri dates for B2) (#1918) * M12.AR6: honour the B1/B2 calendar prefixes in number formats (Hijri for B2) * M12.AR6: match Excel's B1/B2 behaviour measured in review Changes from a reviewer's Excel run (TEXT and Range.NumberFormat): - A [$-...] locale tag before B2 keeps the date Gregorian (prefix still hidden); B2[$-401] stays Hijri. - Any other unquoted B and digit (B3dd, dd/mm/yyyyB2, dd B2 mm), and B2 before a number format (B20.00), make the code invalid: TEXT gives #VALUE!, a cell shows General, as for a code over 255 characters. - Under B2, ddd and dddd show the Arabic weekday name (full name for both). - Serial 60 has no Hijri date: TEXT gives #VALUE!, a cell shows ####. - Serial 0 shows as serial 1 (29/08/1317). Also fixes a break from merging main: days_from_civil now returns 0 for day 0 and 29 February 1900, which sent serials 0 and 60 to 1970 (23/10/1389) under B2. The Hijri day count no longer passes those dates. Docs: localization-parity states the behaviour, including the one measured case not matched (mmmm next to dddd prints the weekday in Excel). Co-Authored-By: Claude Opus 5.5 * M12.AR6: Hijri leap year in cycle year 15, not 16, as Excel shows A reviewer compared Excel's B2yyyy-mm-dd on 43,512 serials with this formula: it disagreed by one day on 1,372, all in cycle years 15 and 16, and moving the leap year from 16 to 15 left no disagreement. The leap years are now 2, 5, 7, 10, 13, 15, 18, 21, 24, 26 and 29 (still 10,631 days a cycle). The count of leap years before a year becomes (4 + 11y) div 30; it differs from the old (3 + 11y) div 30 only when y = 16 (mod 30), which is exactly the move from 16 to 15. The inverse's year estimate can now be one year off at a boundary and is corrected by one step. Tests: Excel values for 2 March 2005 (1426-01-21) and 29 January 2006 (1426-12-29) plus the 1425/1426 year boundaries they imply, the reviewer's other anchors, and exhaustive checks: year, month and 30-year cycle lengths for Hijri years 1 to 10,000; day -> Hijri -> day round trip and day-by-day succession for every day from 1 Muharram 1 AH to one cycle past 31 December 9999; every 1900-system serial 1 to 2,958,465 (except 60). Co-Authored-By: Claude Opus 5.5 * M12.AR6: match B1/B2 details checked in Excel 16.0 build 20527 Compared GridCraft with Excel 16.0 (build 20527, en-US) through COM: TEXT with B2dd/mm/yyyy on every serial from 1 to 2,958,465 (no difference), and about 1,750 format/value pairs through both a cell's NumberFormat and TEXT. Fixed what differed and is in scope: - Two-word Hijri month names use a no-break space (U+00A0), as Excel does. - B1 before a number format is rejected like B2 (B10.00, B1#,##0). - Prefix runs: a repeated prefix is accepted (B2B2, B2b2, [Red]B2B2, B1B1); a mixed one (B1B2, B2B1), more digits (B12, B21, B02), a prefix after a tag or space (B2[Red]B2, B2 B2) and an escaped \B2 are rejected. - Serial 60 under B2 is #VALUE! for any date or clock part (hh:mm, ss, AM/PM), not only date parts; [h] and literal-only sections still show. Regression tests use the Excel values (all 12 month and 7 weekday names, the accepted and rejected codes, serial 60). Not changed, listed in the docs: Excel's weekday/month-name mix-ups when ddd(d) and mmm(m) share a B2 section, weekday-only B2 sections (real weekday for serials 0-59, #VALUE! for the last days of 9999), and Buddhist-year b/bb/bbbb tokens. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: FredApps --- crates/numfmt/src/calendar.rs | 206 ++++++++++++++++++++++++++++++ crates/numfmt/src/lib.rs | 22 +++- crates/numfmt/src/parse.rs | 69 +++++++++- crates/numfmt/src/render.rs | 34 +++-- crates/numfmt/src/tests.rs | 234 ++++++++++++++++++++++++++++++++++ docs/localization-parity.md | 19 ++- 6 files changed, 563 insertions(+), 21 deletions(-) create mode 100644 crates/numfmt/src/calendar.rs 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 |