From a10ead1c09abe3f72698929dae47ce9d372d572d Mon Sep 17 00:00:00 2001 From: Mouhand-Kaddo Date: Mon, 19 Oct 2026 15:59:46 +0400 Subject: [PATCH] M12.AR11: mark right-to-left sheets and Arabic text in HTML export --- crates/engine/src/io.rs | 177 +++++++++++++++++++++++++++++++++--- docs/file-format-parity.md | 3 +- docs/localization-parity.md | 3 +- 3 files changed, 167 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 3e84036cb..25edc8557 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -251,7 +251,7 @@ fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } -fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef) -> String { +fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef, dir: CellDir) -> String { let st = wb.styles.get(sh.style_id(c)); let mut css = String::new(); if st.font.bold { @@ -270,16 +270,59 @@ fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef) -> String { } let v = sh.value(c); let align = match st.align.h { - gridcraft_model::HAlign::Center => "center", - gridcraft_model::HAlign::Right => "right", - gridcraft_model::HAlign::Left => "left", - _ if v.is_number() => "right", - _ => "left", + gridcraft_model::HAlign::Center => Some("center"), + gridcraft_model::HAlign::Right => Some("right"), + gridcraft_model::HAlign::Left => Some("left"), + _ if v.is_number() => Some(if sh.right_to_left { "left" } else { "right" }), + _ if dir == CellDir::Auto => None, + _ => Some("left"), }; - let _ = write!(css, "text-align:{align};"); + if let Some(align) = align { + let _ = write!(css, "text-align:{align};"); + } css } +/// How a cell marks its direction in HTML. Text on a right-to-left sheet, or text with +/// right-to-left letters, follows its first strong character (`dir="auto"`), so General +/// alignment puts Arabic and Hebrew on the right. Numbers on a right-to-left sheet stay left to +/// right so `-5` doesn't read `5-`. +#[derive(Clone, Copy, PartialEq, Eq)] +enum CellDir { + Inherit, + Auto, + Ltr, +} + +impl CellDir { + fn of(sh: &Sheet, c: CellRef, text: &str) -> CellDir { + if sh.value(c).is_number() { + if sh.right_to_left { CellDir::Ltr } else { CellDir::Inherit } + } else if sh.right_to_left || text.chars().any(is_right_to_left_letter) { + CellDir::Auto + } else { + CellDir::Inherit + } + } + + fn attr(self) -> &'static str { + match self { + CellDir::Inherit => "", + CellDir::Auto => " dir=\"auto\"", + CellDir::Ltr => " dir=\"ltr\"", + } + } +} + +/// Hebrew, Arabic, Syriac, Thaana, N'Ko and their presentation forms. +fn is_right_to_left_letter(c: char) -> bool { + matches!(c, '\u{0590}'..='\u{08FF}' | '\u{FB1D}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}') +} + +fn table_dir(sh: &Sheet) -> &'static str { + if sh.right_to_left { " dir=\"rtl\"" } else { "" } +} + const CLIPBOARD_HTML_MAX_BYTES: usize = 4 * 1024 * 1024; const CLIPBOARD_HTML_MAX_CELLS: u64 = 10_000; @@ -292,7 +335,7 @@ pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: & } let sh = wb.sheet(sheet)?; let mut out = ClipboardHtml(String::new()); - out.write_str("").ok()?; + write!(out, "", table_dir(sh)).ok()?; for row in r.start.row..=r.end.row { if sh.is_row_hidden(row) { continue; @@ -310,11 +353,12 @@ pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: & } else { String::new() }; - let css = html_cell_css(wb, sh, c); - write!(out, "
").ok()?; // Use the visible selected cell, even when the merge's original anchor // is outside the range or hidden. This matches the plain-text copy. let text = crate::display::cell_text(wb, sh, c); + let dir = CellDir::of(sh, c, &text); + let css = html_cell_css(wb, sh, c, dir); + write!(out, "", dir.attr()).ok()?; let mut chars = text.chars().peekable(); while let Some(ch) = chars.next() { match ch { @@ -363,8 +407,9 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result { let mut out = String::new(); let _ = write!( out, - "{}", - esc(&sh.name) + "{}", + esc(&sh.name), + table_dir(sh) ); if let Some(r) = sh.used_range() { if r.height() > HTML_EXPORT_MAX_ROWS { @@ -383,14 +428,16 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result { if sh.merges.iter().any(|m| m.contains(c) && m.start != c) { continue; } - let css = html_cell_css(wb, sh, c); + let text = crate::display::cell_text(wb, sh, c); + let dir = CellDir::of(sh, c, &text); + let css = html_cell_css(wb, sh, c, dir); let span = sh .merges .iter() .find(|m| m.start == c) .map(|m| format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width())) .unwrap_or_default(); - let _ = write!(out, "", esc(&crate::display::cell_text(wb, sh, c))); + let _ = write!(out, "", dir.attr(), esc(&text)); } out.push_str(""); } @@ -500,6 +547,108 @@ pub fn image_size(data: &[u8]) -> Option<(u32, u32)> { mod tests { use super::*; + fn book(rtl: bool, cells: &[(&str, gridcraft_core::Value, gridcraft_model::HAlign)]) -> Workbook { + let mut wb = Workbook::new(); + let ids: Vec<_> = cells.iter().map(|(_, _, h)| wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.align.h = *h)).collect(); + let sh = wb.sheet_mut(0).unwrap(); + sh.right_to_left = rtl; + for ((at, value, _), id) in cells.iter().zip(ids) { + let c = CellRef::parse(at).unwrap(); + sh.set_value(c, value.clone()); + sh.set_style(c, id); + } + wb + } + + fn copied(wb: &Workbook, range: &str) -> String { + let r = RangeRef::parse(range).unwrap(); + range_html(wb, 0, r, &crate::display::range_text(wb, 0, r)).unwrap() + } + + #[test] + fn html_export_marks_right_to_left_sheets() { + use gridcraft_core::Value; + use gridcraft_model::HAlign; + let wb = book(true, &[("A1", Value::text("Hello!"), HAlign::General), ("B1", Value::Number(-5.0), HAlign::General)]); + let html = to_html(&wb, 0).unwrap(); + assert!(html.contains("
{}{}
"), "{html}"); + assert!(html.contains(""), "Latin text keeps its own direction: {html}"); + assert!( + html.contains(""), + "numbers mirror to the left and keep their sign in front: {html}" + ); + let clip = copied(&wb, "A1:B1"); + assert!(clip.starts_with("
Hello!-5
-5"), "{clip}"); + + let ltr = book(false, &[("A1", Value::text("Hello!"), HAlign::General)]); + assert!(to_html(<r, 0).unwrap().contains("
")); + assert!(copied(<r, "A1").starts_with("
مرحبا!"), "General Arabic text aligns by its direction: {html}"); + assert!(html.contains(""), "explicit alignment stays: {html}"); + assert!(html.contains(""), "{html}"); + assert!(html.contains(""), "{html}"); + assert!(html.contains("
שלוםمرحباHello
"), "a left-to-right sheet has no table direction: {html}"); + + let clip = copied(&wb, "A1:D1"); + let cells: Vec<&str> = clip.split("
"), HAlign::General), + ("B2", Value::Number(3.0), HAlign::General), + ("A3", Value::text("Total"), HAlign::Right), + ], + ); + wb.sheet_mut(0).unwrap().merges.push(RangeRef::parse("A3:B3").unwrap()); + assert_eq!( + to_html(&wb, 0).unwrap(), + "Sheet1\ + \ + \ + \ +
NameQty
Apples & <pears>3
Total
" + ); + let cell = "border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;"; + assert_eq!( + copied(&wb, "A1:B2"), + format!( + "\ + \ +
NameQty
Apples & <pears>3
" + ) + ); + } + #[test] fn html_export_fails_instead_of_dropping_cells_past_the_row_limit() { let mut wb = Workbook::new(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 158149fb3..3684e57df 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; previously trivial: XLSX chart sheets are kept on save (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (HTML export keeps right-to-left) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -85,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | HTML (Save as Web Page and clipboard) marks right-to-left sheets and right-to-left cell text with `dir` | | 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped | | 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) | | 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 09197ff6c..35a476e92 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-12 · **Change:** minor (Format Cells dialog looks its labels up in the catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Save as Web Page and copied HTML keep right-to-left) · **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, @@ -78,6 +78,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | Save as Web Page and copied HTML mark right-to-left sheets `dir="rtl"`, let General-aligned Arabic and Hebrew text align by its own direction (`dir="auto"`), and keep numbers on right-to-left sheets left to right and on the left | | 2026-10-12 | minor | Format Cells looks up its tabs, labels, options and note through `Language::tr` (Fill tab keyed `Format Cells\|Fill`); dialog titles and OK/Cancel too. Labels already in a table (Number, Currency, Fill…) show translated; the rest fall back to English until the tables add them | | 2026-10-12 | minor | Brazilian Portuguese catalog (`i18n/pt.rs`) grows from 377 to 923 entries: every key the other catalogs translate, including the dialog strings #669 starts looking up (~42%) | | 2026-10-11 | minor | German ribbon catalog (`i18n/de.rs`, 266 labels, ~12%); `de` locale tags (de-DE, de-AT, de-CH…) resolve to German. PR #163 (localized German formulas/numbers) still open |