Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ cargo run --release -p gridcraft -- --sample sales --control 7979 # + JSON con
cargo xtask ci # fmt, clippy, tests, assets, layering, wasm
```

Choose Español under View → Interface language for [Spanish formula names, decimal commas, and semicolon arguments](docs/formula-input.md).
Choose Español, Deutsch, Português (Brasil) or Русский under View → Interface language for [local formula names, booleans, errors and separators](docs/formula-input.md); Deutsch also shows and reads numbers and dates as German Excel does (`1.234,56`, `13.10.2026`).
Workbooks, clipboard exchange, and programmatic APIs retain canonical English syntax.

### Web
Expand Down
13 changes: 10 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away

> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (German formulas and numbers as Excel 365 de-DE shows them) · **Target:** Microsoft Excel (Microsoft 365)

GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
Expand Down Expand Up @@ -93,8 +93,12 @@ The ribbon is translated into Japanese, Simplified Chinese, Korean, Brazilian Po
and German (266–268 labels each, ~12% of ~2,200 strings; merged 10-10 in #31, #104, #82, German
added 10-11), the
interface follows the system language, and CJK, Thai and Hebrew text falls back to system fonts
(#38). Dialogs, menus and messages are English; formulas and number formats are en-US only.
Localized German formulas and numbers are in PR #163. Detail: [localization-parity.md](docs/localization-parity.md).
(#38). Dialogs, menus and messages are English. Formulas follow the interface language in
Spanish, German, Brazilian Portuguese and Russian (function names, booleans, errors, table
specifiers and separators at the editor; files keep English: [formula-input.md](docs/formula-input.md));
German was observed in Excel 365 de-DE. With German, numbers and dates show and are typed as in
German Excel (`1.234,56`, `13.10.2026`, Use system separators, `;` CSV); other languages keep
en-US numbers. Detail: [localization-parity.md](docs/localization-parity.md).

| Language | Code | Status | UI strings |
|---|---|---|---|
Expand Down Expand Up @@ -132,6 +136,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in

| Date | What landed |
|---|---|
| 2026-10-14 | German as Excel 365 de-DE shows it (observed): all 514 functions' German names (MAP stays MAP, `#CALC!`, `[@[A]:[B]]`), formula words in dialogs and cell values, and German numbers and dates: `1.234,56`, `13.10.2026`, `13. Okt 26` in cells, editor, print and PDF; typing `1,5`, `-3,2%`, `5 €`, `13.10.2026`, `13. Okt 2026` with Excel's formats; File › Options › Advanced › Use system separators; CSV with `;`. Files, CLI and MCP stay canonical (builds on #443) |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
Expand All @@ -148,6 +153,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up |
| 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) |
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
| 2026-10-11 | Formulas in German, Brazilian Portuguese and Russian, and the full Spanish table: 403–459 local function names per language, local booleans, errors and table specifiers, each language's separators, Unicode autocomplete and Insert Function search; files and APIs keep English |
| 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
| 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) |
| 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) |
Expand Down Expand Up @@ -200,6 +206,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) |
| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) |
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | minor | Languages: formulas follow the interface language in es, de, pt-BR and ru; progress log row |
| 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row |
| 2026-10-11 | minor | Forecast Sheet (#322); progress log row |
| 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row |
Expand Down
13 changes: 13 additions & 0 deletions crates/calc/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1266,3 +1266,16 @@ fn offset_extreme_arguments_return_ref() {
assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{formula}");
}
}

#[test]
fn formula_language_tables_only_name_functions_the_engine_knows() {
// A typo'd canonical name in a language table would turn a valid local name into #NAME?.
// Excel's names GridCraft doesn't compute as functions may be listed too: a file's GETPIVOTDATA
// shows as PIVOTDATENZUORDNEN in German, a data table's TABLE as MEHRFACHOPERATION.
const ALSO_LISTED: [&str; 5] = ["DETECTLANGUAGE", "GETPIVOTDATA", "IMAGE", "TABLE", "TRANSLATE"];
for locale in gridcraft_formula::FormulaLocale::ALL {
let unknown: Vec<&str> =
locale.translations().iter().map(|(en, _)| *en).filter(|en| !crate::eval::is_known_function(en) && !ALSO_LISTED.contains(en)).collect();
assert!(unknown.is_empty(), "{locale:?}: {unknown:?}");
}
}
2 changes: 2 additions & 0 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@

pub mod addr;
pub mod date;
pub mod locale;
pub mod parse;
pub mod value;

pub use addr::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, col_to_letters, letters_to_col};
pub use date::DateSystem;
pub use locale::NumberLocale;
pub use value::{Array, CellError, Value, compare, compare_numbers, compare_text, number_to_text, sort_compare};
208 changes: 208 additions & 0 deletions crates/core/src/locale.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
//! Regional number formats: how numbers and dates look, and how they are typed, in one region.
//!
//! Workbooks, formulas, files and commands keep one canonical (en-US) spelling. A
//! [`NumberLocale`] only applies where people read or type values: the cells on screen, the cell
//! editor, print, and delimited text written for people. It is the region's fixed conventions
//! ([`Region`]: date order, month and day names, the built-in formats Excel draws in the region's
//! own way) plus the decimal and thousands separators, which the user may override (Excel's
//! File › Options › Advanced › "Use system separators").
//!
//! Regions are data. de-DE was observed black-box in Excel 365 (German interface, de-DE regional
//! format) on 2026-10-13 through its automation interface: typed entries read back with their
//! value and automatic format, and values shown through format codes. Other regions use en-US
//! until their conventions are observed.

/// The order of day, month and year in a short date.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DateOrder {
/// `10/13/2026` (en-US).
Mdy,
/// `13.10.2026` (de-DE).
Dmy,
}

/// The fixed conventions of one regional format.
#[derive(Debug, PartialEq, Eq)]
pub struct Region {
/// BCP 47 tag, e.g. `de-DE`.
pub tag: &'static str,
/// The separators the region uses ("Use system separators").
pub decimal: char,
pub thousands: char,
pub date_order: DateOrder,
/// What `/` in a date format code shows, and what typed dates are separated by.
pub date_separator: char,
pub months: [&'static str; 12],
pub months_short: [&'static str; 12],
/// Sunday first.
pub weekdays: [&'static str; 7],
pub weekdays_short: [&'static str; 7],
/// More month spellings typed dates accept (`Mär`, `Sept`), besides the names above.
pub month_aliases: &'static [(&'static str, u32)],
/// Built-in date and time formats the region draws its own way: `(canonical code, code used
/// to draw it)`. Excel stores id 14 as `m/d/yyyy` and shows it as the system short date.
pub builtin_formats: &'static [(&'static str, &'static str)],
/// What the system long date and time tags (`[$-F800]`, `[$-F400]`, Excel's Long Date and
/// Time formats) draw; `None` draws the code after the tag.
pub long_date: Option<&'static str>,
pub long_time: Option<&'static str>,
/// A currency symbol typed after the number (`5 €`), with the formats Excel then applies
/// (whole, two decimals).
pub currency_after: Option<(&'static str, &'static str, &'static str)>,
}

pub const EN_US: Region = Region {
tag: "en-US",
decimal: '.',
thousands: ',',
date_order: DateOrder::Mdy,
date_separator: '/',
months: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
months_short: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
weekdays: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
weekdays_short: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
month_aliases: &[],
builtin_formats: &[],
long_date: None,
long_time: None,
currency_after: None,
};

/// Deutsch (Deutschland), as observed in Excel 365 with the de-DE regional format.
pub const DE_DE: Region = Region {
tag: "de-DE",
decimal: ',',
thousands: '.',
date_order: DateOrder::Dmy,
date_separator: '.',
months: ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"],
months_short: ["Jan", "Feb", "Mrz", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"],
weekdays: ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"],
weekdays_short: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"],
month_aliases: &[("Mär", 3), ("Sept", 9)],
// Observed: 14 `TT.MM.JJJJ`, 15 `TT. MMM JJ`, 16 `TT. MMM`, 17 `MMM JJ`, 20 `hh:mm`,
// 21 `hh:mm:ss`, 22 `TT.MM.JJJJ hh:mm`; the AM/PM and elapsed-time ones are unchanged.
builtin_formats: &[
("m/d/yyyy", "dd/mm/yyyy"),
("d-mmm-yy", "dd\\.\\ mmm\\ yy"),
("d-mmm", "dd\\.\\ mmm"),
("mmm-yy", "mmm\\ yy"),
("h:mm", "hh:mm"),
("h:mm:ss", "hh:mm:ss"),
("m/d/yyyy h:mm", "dd/mm/yyyy hh:mm"),
],
// Observed: `[$-F800]dddd, mmmm dd, yyyy` shows `Dienstag, 13. Oktober 2026`; `[$-F400]h:mm:ss
// AM/PM` shows `14:30:00`.
long_date: Some("dddd, d\\. mmmm yyyy"),
long_time: Some("hh:mm:ss"),
// Observed: `5 €` gets `#.##0 €;[Rot]-#.##0 €`, `5,50 €` gets `#.##0,00 €;[Rot]-#.##0,00 €`.
currency_after: Some(("€", "#,##0 \"€\";[Red]-#,##0 \"€\"", "#,##0.00 \"€\";[Red]-#,##0.00 \"€\"")),
};

/// How numbers and dates are shown and typed: a region's conventions and the separators in use.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NumberLocale {
pub region: &'static Region,
pub decimal: char,
pub thousands: char,
}

impl Default for NumberLocale {
fn default() -> Self {
NumberLocale::EN_US
}
}

impl NumberLocale {
/// The canonical locale: what files, formulas and commands use.
pub const EN_US: NumberLocale = NumberLocale::of(&EN_US);
pub const DE_DE: NumberLocale = NumberLocale::of(&DE_DE);

/// A region with its own separators.
pub const fn of(region: &'static Region) -> NumberLocale {
NumberLocale { region, decimal: region.decimal, thousands: region.thousands }
}

/// The same region with other separators. An unusable pair ([`separators_usable`]) keeps
/// the region's.
pub fn with_separators(self, decimal: char, thousands: char) -> NumberLocale {
if separators_usable(decimal, thousands) { NumberLocale { decimal, thousands, ..self } } else { self }
}

/// True when shown and typed values use the canonical spelling (nothing to translate).
pub fn is_canonical(&self) -> bool {
*self == NumberLocale::EN_US
}

/// The code a built-in format is drawn with in this region (`m/d/yyyy` → `dd/mm/yyyy` in
/// de-DE); the code itself otherwise.
pub fn regional_code<'a>(&self, code: &'a str) -> &'a str {
let tag = |t: &str| code.get(..t.len()).is_some_and(|p| p.eq_ignore_ascii_case(t));
if tag("[$-F800]")
&& let Some(long) = self.region.long_date
{
return long;
}
if tag("[$-F400]")
&& let Some(long) = self.region.long_time
{
return long;
}
self.region.builtin_formats.iter().find(|(canonical, _)| *canonical == code).map_or(code, |(_, shown)| shown)
}
}

/// Whether a decimal and thousands separator pair can be used: different characters, neither a
/// digit, a letter or a character with another meaning in typed numbers and dates (signs, `%`,
/// parentheses, quotes, `/`, `:`, `#`, `$`, `;`, control characters). The thousands separator may
/// be a space, a no-break space or an apostrophe (`1'234.5`); the decimal separator may not.
pub fn separators_usable(decimal: char, thousands: char) -> bool {
let bad = |c: char| {
c.is_alphanumeric() || c.is_control() || matches!(c, '+' | '-' | '%' | '(' | ')' | '"' | '#' | '$' | '/' | ':' | '@' | '[' | ']' | ';')
};
let space = |c: char| c.is_whitespace();
decimal != thousands
&& !bad(decimal)
&& !space(decimal)
&& !matches!(decimal, '\'' | '\u{2019}')
&& !bad(thousands)
&& (!space(thousands) || matches!(thousands, ' ' | '\u{a0}' | '\u{202f}'))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn only_en_us_is_canonical() {
assert!(NumberLocale::EN_US.is_canonical());
assert!(NumberLocale::default().is_canonical());
assert!(!NumberLocale::DE_DE.is_canonical());
// en-US conventions with other separators are not canonical either.
assert!(!NumberLocale::EN_US.with_separators(',', '.').is_canonical());
}

#[test]
fn separators_are_checked() {
let de = NumberLocale::DE_DE;
assert_eq!(de.with_separators('.', ',').decimal, '.');
assert_eq!(de.with_separators(',', ' ').thousands, ' ');
assert_eq!(de.with_separators(',', '\u{a0}').thousands, '\u{a0}');
assert_eq!(de.with_separators('.', '\'').thousands, '\'');
for (d, t) in [(',', ','), ('1', '.'), (',', 'a'), (' ', '.'), ('-', '.'), (',', '%'), ('E', '.'), ('\'', '.'), (',', '\n'), (',', ';')] {
assert_eq!(de.with_separators(d, t), de, "{d:?} {t:?}");
}
}

#[test]
fn built_in_formats_follow_the_region() {
assert_eq!(NumberLocale::DE_DE.regional_code("m/d/yyyy"), "dd/mm/yyyy");
assert_eq!(NumberLocale::DE_DE.regional_code("h:mm AM/PM"), "h:mm AM/PM");
assert_eq!(NumberLocale::DE_DE.regional_code("0.00"), "0.00");
assert_eq!(NumberLocale::EN_US.regional_code("m/d/yyyy"), "m/d/yyyy");
// Excel's Long Date and Time formats carry the system tags.
assert_eq!(NumberLocale::DE_DE.regional_code("[$-F800]dddd, mmmm dd, yyyy"), "dddd, d\\. mmmm yyyy");
assert_eq!(NumberLocale::DE_DE.regional_code("[$-f400]h:mm:ss AM/PM"), "hh:mm:ss");
assert_eq!(NumberLocale::EN_US.regional_code("[$-F800]dddd, mmmm dd, yyyy"), "[$-F800]dddd, mmmm dd, yyyy");
}
}
Loading