diff --git a/ROADMAP.md b/ROADMAP.md index 486d0ec38..064e2627c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-11 | Ctrl+drag on the fill handle swaps copying and extending a series (#264) | | 2026-10-11 | Odd-period bond functions ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD: Financial 55 / 55 (#275) | | 2026-10-11 | PERCENTOF and EUROCONVERT worksheet functions (#279) | +| 2026-10-11 | BAHTTEXT: amounts in Thai words with Baht and Satang (#291) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | @@ -148,6 +149,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in |---|---|---| | 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row | | 2026-10-11 | minor | Worksheet functions 503 / 523 (PERCENTOF, EUROCONVERT); progress log row | +| 2026-10-11 | minor | Worksheet functions 502 / 523 (BAHTTEXT); progress log row | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) | | 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha | diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 172c93444..2eeba276e 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -403,6 +403,88 @@ fn dollar(a: &[Arg], _c: &mut dyn Ctx) -> R { text_val(if r < 0.0 { format!("(${body})") } else { format!("${body}") }) } +// --------------------------------------------------------------------------------------------- +// BAHTTEXT + +const THAI_DIGITS: [&str; 10] = ["ศูนย์", "หนึ่ง", "สอง", "สาม", "สี่", "ห้า", "หก", "เจ็ด", "แปด", "เก้า"]; +/// Units, tens, hundreds, thousands, ten thousands, hundred thousands. +const THAI_PLACES: [&str; 6] = ["", "สิบ", "ร้อย", "พัน", "หมื่น", "แสน"]; + +/// Thai words for a group of up to six decimal digits (most significant first); empty for zero. +/// One in the tens is สิบ, two is ยี่สิบ, and a final one after higher digits is เอ็ด. +fn thai_group(digits: &[u8]) -> String { + let n = digits.len(); + let higher = digits.iter().take(n.saturating_sub(1)).any(|d| *d != 0); + let mut out = String::new(); + for (i, &d) in digits.iter().enumerate() { + let place = n - 1 - i; + match (place, d) { + (_, 0) => {} + (0, 1) if higher => out.push_str("เอ็ด"), + (1, 1) => out.push_str("สิบ"), + (1, 2) => out.push_str("ยี่สิบ"), + _ => { + out.push_str(THAI_DIGITS.get(d as usize).copied().unwrap_or_default()); + out.push_str(THAI_PLACES.get(place).copied().unwrap_or_default()); + } + } + } + out +} + +/// Thai words for a whole number written in decimal digits: groups of six digits, each but the +/// last followed by ล้าน (so 10^12 is หนึ่งล้านล้าน). +fn thai_number(digits: &str) -> String { + let d: Vec = digits.bytes().filter(u8::is_ascii_digit).map(|b| b - b'0').skip_while(|x| *x == 0).collect(); + if d.is_empty() { + return "ศูนย์".to_string(); + } + let first = match d.len() % 6 { + 0 => 6, + r => r, + }; + let mut out = String::new(); + let mut start = 0; + let mut len = first; + while let Some(group) = d.get(start..start + len) { + out.push_str(&thai_group(group)); + start += len; + len = 6; + if start < d.len() { + out.push_str("ล้าน"); + } + } + out +} + +/// BAHTTEXT(number): the amount in Thai words with บาท, rounded to satang (สตางค์); a whole +/// number of baht ends in ถ้วน and a negative amount starts with ลบ. +fn bahttext(a: &[Arg], _c: &mut dyn Ctx) -> R { + let x = round_half_away(num(a, 0)?, 2); + if !x.is_finite() { + return Err(CellError::Num); + } + let fixed = format!("{:.2}", x.abs()); + let (whole, frac) = fixed.split_once('.').unwrap_or((fixed.as_str(), "00")); + let baht = whole.bytes().any(|b| b != b'0'); + let satang = frac.bytes().any(|b| b != b'0'); + let mut out = String::new(); + if x < 0.0 && (baht || satang) { + out.push_str("ลบ"); + } + if baht || !satang { + out.push_str(&thai_number(whole)); + out.push_str("บาท"); + } + if satang { + out.push_str(&thai_number(frac)); + out.push_str("สตางค์"); + } else { + out.push_str("ถ้วน"); + } + text_val(out) +} + fn t_fn(a: &[Arg], _c: &mut dyn Ctx) -> R { match scalar(arg(a, 0)?) { Value::Text(t) => Ok(Value::Text(t)), @@ -762,6 +844,7 @@ pub(crate) fn specs() -> Vec { fixed ), f!("DOLLAR", 1, 2, Text, S, "DOLLAR(number, [decimals])", "Formats a number as currency text.", dollar), + f!("BAHTTEXT", 1, 1, Text, S, "BAHTTEXT(number)", "Spells out a number in Thai words with Baht.", bahttext), f!("T", 1, 1, Text, S, "T(value)", "Returns the value if it is text, otherwise empty text.", t_fn), f!( "TEXTBEFORE", @@ -856,6 +939,30 @@ mod tests { use crate::util::testutil::*; + #[test] + fn bahttext() { + let bt = |x: f64| ev("BAHTTEXT", vec![n(x)]); + // Microsoft's example. + is_text(bt(1234.0), "หนึ่งพันสองร้อยสามสิบสี่บาทถ้วน"); + // Tens, เอ็ด, satang, millions and beyond, sign and rounding to satang. + is_text(bt(0.0), "ศูนย์บาทถ้วน"); + is_text(bt(21.25), "ยี่สิบเอ็ดบาทยี่สิบห้าสตางค์"); + is_text(bt(0.5), "ห้าสิบสตางค์"); + is_text(bt(101.0), "หนึ่งร้อยเอ็ดบาทถ้วน"); + is_text(bt(1_000_000.0), "หนึ่งล้านบาทถ้วน"); + is_text(bt(11_000_000.0), "สิบเอ็ดล้านบาทถ้วน"); + is_text(bt(2_000_005_000_000.0), "สองล้านห้าล้านบาทถ้วน"); + is_text(bt(1e12), "หนึ่งล้านล้านบาทถ้วน"); + is_text(bt(-15.5), "ลบสิบห้าบาทห้าสิบสตางค์"); + is_text(bt(1.999), "สองบาทถ้วน"); + is_text(bt(0.005), "หนึ่งสตางค์"); + is_text(bt(-0.001), "ศูนย์บาทถ้วน"); + is_err(ev("BAHTTEXT", vec![t("abc")]), CellError::Value); + // Huge numbers give text (or #NUM! where rounding overflows), never a panic. + assert!(matches!(bt(-1e300), Value::Text(_))); + assert!(matches!(bt(f64::MAX), Value::Text(_) | Value::Error(CellError::Num))); + } + #[test] fn concat_family() { is_text(ev("CONCATENATE", vec![t("a"), n(1.5), b(true)]), "a1.5TRUE"); diff --git a/docs/function-parity.md b/docs/function-parity.md index 6c1592177..2fff02428 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -14,6 +14,7 @@ Excel's worksheet functions, category by category, against GridCraft's function PY (Microsoft 365 functions not on that page when fetched). - **Ours (505):** every function spec in `crates/functions/src/*.rs` (`f!("NAME", …)`) plus the - **Ours (503):** every function spec in `crates/functions/src/*.rs` (`f!("NAME", …)`) plus the +- **Ours (502):** every function spec in `crates/functions/src/*.rs` (`f!("NAME", …)`) plus the evaluator's special forms in `crates/calc/src/eval.rs` (`SPECIAL_FUNCTIONS`: IF, IFS, CHOOSE, OFFSET, INDIRECT, INDEX, SUBTOTAL, AGGREGATE, TEXT, LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL, MAKEARRAY…), excluding the test-only `ADD2`. Every one is an Excel name. @@ -32,7 +33,7 @@ Excel's worksheet functions, category by category, against GridCraft's function | Math and trigonometry | 80 | 79 | 79 | 99% | PERCENTOF | | Financial | 55 | 55 | 55 | 100% | — | | Engineering | 54 | 54 | 54 | 100% | — | -| Text | 50 | 47 | 47 | 94% | BAHTTEXT, DETECTLANGUAGE, TRANSLATE | +| Text | 50 | 48 | 48 | 96% | DETECTLANGUAGE, TRANSLATE | | Lookup and reference | 40 | 36 | 36 | 90% | GETPIVOTDATA, IMAGE, RTD, TRIMRANGE | | Compatibility | 40 | 40 | 40 | 100% | — | | Date and time | 25 | 25 | 25 | 100% | — | @@ -57,6 +58,7 @@ PIVOTBY, REGEXTEST…) are all present. | FORECAST.ETS family (4) | yes | 4–6 h | AAA exponential smoothing with seasonality detection; needed for Forecast Sheet too | | GETPIVOTDATA | yes | 2–3 h | Needs the pivot engine's result cache; also the "Generate GetPivotData" option | | TRIMRANGE, BAHTTEXT | yes | 1–2 h | TRIMRANGE also implies the `.:` trim-ref operators | +| TRIMRANGE, PERCENTOF | yes | 1–2 h | TRIMRANGE also implies the `.:` trim-ref operators | | IMAGE | yes | 3–5 h | Pictures in cells (see PR #105); fetching URLs needs a privacy decision | | CUBE* (7), WEBSERVICE, FILTERXML | partly | 4–8 h | FILTERXML can work offline; CUBE needs an OLAP/data-model source | | TRANSLATE, DETECTLANGUAGE, STOCKHISTORY, COPILOT, PY | no (cloud) | — | Microsoft cloud services; open equivalents only by owner decision | @@ -98,4 +100,5 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds |---|---|---| | 2026-10-11 | minor | ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added: Financial 55 / 55; 505 / 523 by name, 496 working | | 2026-10-11 | minor | PERCENTOF and EUROCONVERT added: 503 / 523 by name, 494 working | +| 2026-10-11 | minor | BAHTTEXT added: 502 / 523 by name, 493 working | | 2026-10-10 | major | Created: 501 / 523 by name, 492 working, per category, against Microsoft's published list | diff --git a/docs/gaps.md b/docs/gaps.md index f54cfd6a9..70b2a4233 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -108,6 +108,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 15. Remaining functions and an oracle suite (F) - 18 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 16–26 h. - 20 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 17–29 h. +- 21 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 18–30 h. [function-parity.md](function-parity.md) ### 16. Collaboration and review (F) @@ -152,6 +153,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- |---|---|---| | 2026-10-11 | minor | §15: ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added (18 functions missing) | | 2026-10-11 | minor | §15: PERCENTOF and EUROCONVERT added (20 functions missing) | +| 2026-10-11 | minor | §15: BAHTTEXT added (21 functions missing) | | 2026-10-10 | minor | Updated for the 10-10 merges (#17, #31, #38, #62, #82, #104, #116) and the new chart resize bug (#190) | | 2026-10-10 | minor | Noted that no gap blocks the alpha gate | | 2026-10-10 | major | Created: 24 ranked gaps with evidence, from the full re-measure and the 60 open issues | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f15dddec1..76f79128f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -334,6 +334,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land |---|---|---| | 2026-10-11 | minor | Worksheet functions 505 / 523 (96.6%; 496 working): ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD | | 2026-10-11 | minor | Worksheet functions 503 / 523 (96.2%; 494 working): PERCENTOF, EUROCONVERT | +| 2026-10-11 | minor | Worksheet functions 502 / 523 (96.0%; 493 working): BAHTTEXT | | 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for each of the three numbers (full 650–1,050, mainstream 200–310, essentials 60–100). Full number confirmed as the additive weighted sum over the written dimension weights (no change) | | 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%) with written weights and discounts, and user-evidence counts; re-checked the full number after the 10-10 merges (charts 38→30, localization 5→10; still 51.6, ~50%) | | 2026-10-10 | minor | Stage checked against the new alpha gate (six core workflows, all pass): stays alpha |