mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-09 20:09:57 -06:00
Arabic bidi layout and cell reading direction in the grid and editors (#947)
* M12.AR4: preserve Arabic logical text in bidi display and editing * M12.AR4: drop the stale "no bidi" note from the RTL sheet status * M12.AR4: open the Arabic editors and place the caret through key and pointer events in tests * M12.AR4: skip the conditional-format lookup in the formula bar unless a rule sets reading order * M12.AR4: test that the caret after typed Arabic is drawn at the text's left end (#1070) * M12.AR4: pass the theme body font in the reading-order style test * M12.AR4: adapt the Arabic UI tests to egui 0.37 (row direction, editor ids, Ctrl+End to the logical end) * M12.AR4: read the active sheet through Workbook::active_sheet() in the formula bar main made `Workbook::active_sheet` an `ActiveSheet` (shared between windows on one workbook); the formula bar's reading-order lookup takes the index from the accessor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M12.AR4: keep explicit Left on wrapped right-to-left cells and trailing spaces in the editor Wrapped cell text lays out every row from the left when the painted alignment is Left, instead of aligning right-to-left rows to their start. The cell and formula bar editor keep trailing whitespace in their layout so a right-to-left entry ending in a space keeps the caret inside the text. * M12.AR4: keep Right and General on right-to-left sheets aligned right in wrapped cells * M12.AR4: keep right-aligned Arabic with a marked first letter inside its cell --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com> Co-authored-by: FredApps <FredApps@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
FredApps
FredApps
parent
cda767b49b
commit
e9addf52fe
Generated
+1
@@ -2197,6 +2197,7 @@ dependencies = [
|
||||
"skrifa",
|
||||
"sys-locale",
|
||||
"toml",
|
||||
"unicode-bidi",
|
||||
"web-sys",
|
||||
"web-time",
|
||||
"zbus",
|
||||
|
||||
+4
-3
@@ -43,8 +43,7 @@ long `.xls` track, so about half parallelizes (≈30–50 wall-clock hours with
|
||||
|
||||
Why alpha: all six core workflows in the [alpha gate](docs/roadmap.md#alpha-gate) pass end to end on macOS with save and reopen, and the core workflow works end to end (open an XLSX, enter formulas and data, format, sort
|
||||
and filter, chart, pivot, print to PDF, save XLSX), but users still hit blocking gaps on real
|
||||
files: 100 MB workbooks don't open (#175), there's no `.xls` (#121), Arabic and Devanagari text
|
||||
isn't shaped (Chinese text still reported in #142), charts lose their type and options on round trip, and
|
||||
files: 100 MB workbooks don't open (#175), there's no `.xls` (#121), Devanagari text needs shaping verification (Chinese text still reported in #142), charts lose their type and options on round trip, and
|
||||
nobody has yet confirmed that Excel opens our output without a repair prompt across a real
|
||||
corpus. Beta needs those closed (see [beta gates](docs/roadmap.md#beta-gates)).
|
||||
|
||||
@@ -105,7 +104,7 @@ Localized German formulas and numbers are in PR #163. Detail: [localization-pari
|
||||
| Simplified Chinese | zh-Hans | menus only | ~12% |
|
||||
| Spanish | es | none | 0% |
|
||||
| Hindi | hi | none | 0% |
|
||||
| Arabic | ar | none (no RTL layout, no shaping) | 0% |
|
||||
| Arabic | ar | catalog pending; shared shaping/bidi works | 0% |
|
||||
| French | fr | none | 0% |
|
||||
| Portuguese | pt-BR | menus, File view, dialogs | ~43% |
|
||||
| Indonesian | id | none | 0% |
|
||||
@@ -149,6 +148,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-31 | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) |
|
||||
| 2026-10-31 | Recently Used lists the functions most recently entered in a formula from any source (ribbon, control channel, MCP, scripts), kept with the preferences and returned by `formulas.recentlyUsed`; the Insert Function dialog takes Excel's title and a working category list (Most Recently Used, All, each category) |
|
||||
| 2026-10-31 | Formulas › Function Library as a big Insert Function button, the AutoSum split button and three columns of labelled small buttons, each category book in its own colour; category menus end with Insert Function… on that category; More Functions cascades into the remaining categories; Home › Editing shares the AutoSum menu; the MR key tip opens Recently Used |
|
||||
| 2026-10-26 | Arabic bidi display/editing preserve logical source text; per-cell reading direction is editable and survives undo, XLSX and conditional-format priority |
|
||||
| 2026-10-26 | Arabic text shows in cells and the interface through a system, fontconfig or craft-fonts Arabic face; workbook fonts keep their script fallbacks |
|
||||
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
|
||||
| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step |
|
||||
@@ -277,6 +277,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) |
|
||||
| 2026-10-31 | minor | Progress log: Recently Used and Insert Function categories |
|
||||
| 2026-10-31 | minor | Progress log: Function Library |
|
||||
| 2026-10-26 | minor | Progress log: Arabic bidi display and editing |
|
||||
| 2026-10-26 | minor | Progress log: Arabic font fallback |
|
||||
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
|
||||
| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row |
|
||||
|
||||
@@ -368,6 +368,9 @@ impl CfCache {
|
||||
/// Adds the parts `lower` sets that `style` leaves unset (differential styles: default parts
|
||||
/// are unset).
|
||||
fn layer_under(style: &mut Style, lower: &Style) {
|
||||
if style.align.reading_order.is_none() {
|
||||
style.align.reading_order = lower.align.reading_order;
|
||||
}
|
||||
if style.fill.pattern == PatternType::None {
|
||||
style.fill = lower.fill;
|
||||
}
|
||||
@@ -417,6 +420,28 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reading_order_respects_conditional_rule_priority_and_explicit_context() {
|
||||
for (higher, expected) in [
|
||||
(json!({"font": {"bold": true}}), "RightToLeft"),
|
||||
(json!({"align": {"reading_order": "Context"}}), "Context"),
|
||||
(json!({"align": {"reading_order": "LeftToRight"}}), "LeftToRight"),
|
||||
] {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "مرحبا 123 ABC"})).unwrap();
|
||||
for style in [json!({"align": {"reading_order": "RightToLeft"}}), higher] {
|
||||
s.execute("home.conditionalFormat", json!({"range": "A1", "rule": {"type": "noBlanks", "style": style}})).unwrap();
|
||||
}
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(wb).unwrap()).unwrap();
|
||||
for wb in [wb, &reopened] {
|
||||
let style = CfCache::new().look(wb, 0, CellRef::new(0, 0)).unwrap().style.unwrap();
|
||||
assert_eq!(serde_json::to_value(style.align).unwrap()["reading_order"], expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_matching_rule_applies() {
|
||||
let mut s = crate::Session::new();
|
||||
|
||||
@@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Format Cells…",
|
||||
["Home", "Cells", "Format"],
|
||||
Some("Cmd+1"),
|
||||
"{range?, style: {font?, fill?, borders?, align?, numFmt?, protection?}} (partial Style JSON merged in)",
|
||||
"{range?, style: {font?, fill?, borders?, align?: {reading_order?: Context|LeftToRight|RightToLeft|null, ...}, numFmt?, protection?}} (partial Style JSON merged in; null clears reading_order)",
|
||||
has_doc,
|
||||
format_cells
|
||||
),
|
||||
|
||||
@@ -18,6 +18,44 @@ fn v(s: &Session, a: &str) -> Value {
|
||||
s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(a).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_cells_reading_order_survives_undo_and_xlsx() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "مرحبا 123 ABC"})).unwrap();
|
||||
s.execute("home.bold", json!({"range": "A1"})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "A1", "style": {"align": {"reading_order": "RightToLeft"}}})).unwrap();
|
||||
let alignment = |s: &Session| {
|
||||
let d = s.doc().unwrap();
|
||||
let style = d.wb.styles.get(d.wb.active().unwrap().style_id(CellRef::new(0, 0)));
|
||||
assert!(style.font.bold);
|
||||
serde_json::to_value(style.align).unwrap()
|
||||
};
|
||||
assert_eq!(alignment(&s)["reading_order"], "RightToLeft");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(alignment(&s)["reading_order"].is_null());
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(alignment(&s)["reading_order"], "RightToLeft");
|
||||
for order in [json!("invalid"), json!(2)] {
|
||||
assert!(s.execute("home.formatCells", json!({"range": "A1", "style": {"align": {"reading_order": order}}})).is_err());
|
||||
assert_eq!(alignment(&s)["reading_order"], "RightToLeft");
|
||||
}
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(wb).unwrap()).unwrap();
|
||||
let cell = CellRef::new(0, 0);
|
||||
assert_eq!(reopened.styles.get(reopened.active().unwrap().style_id(cell)), wb.styles.get(wb.active().unwrap().style_id(cell)));
|
||||
assert_eq!(reopened.active().unwrap().value(cell), Value::text("مرحبا 123 ABC"));
|
||||
for order in ["LeftToRight", "Context"] {
|
||||
s.execute("home.formatCells", json!({"range": "A1", "style": {"align": {"reading_order": order}}})).unwrap();
|
||||
assert_eq!(alignment(&s)["reading_order"], order);
|
||||
}
|
||||
s.execute("home.formatCells", json!({"range": "A1", "style": {"align": {"reading_order": null}}})).unwrap();
|
||||
assert!(alignment(&s)["reading_order"].is_null());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(alignment(&s)["reading_order"], "Context");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert!(alignment(&s)["reading_order"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entering_empty_cells_preserves_whole_sheet_formatting() {
|
||||
let mut s = s();
|
||||
|
||||
@@ -230,6 +230,30 @@ pub enum CfRule {
|
||||
},
|
||||
}
|
||||
|
||||
impl CfRule {
|
||||
/// The differential style a matching rule applies; colour scales, data bars and icon sets have none.
|
||||
pub fn style(&self) -> Option<&Style> {
|
||||
match self {
|
||||
CfRule::CellIs { style, .. }
|
||||
| CfRule::Expression { style, .. }
|
||||
| CfRule::ContainsText { style, .. }
|
||||
| CfRule::NotContainsText { style, .. }
|
||||
| CfRule::BeginsWith { style, .. }
|
||||
| CfRule::EndsWith { style, .. }
|
||||
| CfRule::Blanks { style }
|
||||
| CfRule::NoBlanks { style }
|
||||
| CfRule::Errors { style }
|
||||
| CfRule::NoErrors { style }
|
||||
| CfRule::Duplicate { style }
|
||||
| CfRule::Unique { style }
|
||||
| CfRule::Top10 { style, .. }
|
||||
| CfRule::AboveAverage { style, .. }
|
||||
| CfRule::TimePeriod { style, .. } => Some(style),
|
||||
CfRule::ColorScale { .. } | CfRule::DataBar { .. } | CfRule::IconSet { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of icons in an icon set (`3Arrows` → 3, `5Quarters` → 5), always 3 to 5.
|
||||
pub fn icon_count(set: &str) -> usize {
|
||||
set.chars().next().and_then(|c| c.to_digit(10)).unwrap_or(3).clamp(3, 5) as usize
|
||||
|
||||
@@ -26,8 +26,8 @@ pub use python::{PyCode, PyOutput};
|
||||
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetUid, SheetViewMode, Visibility, points_to_chars};
|
||||
pub use store::CellStore;
|
||||
pub use style::{
|
||||
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline,
|
||||
VAlign, VertAlign,
|
||||
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, ReadingOrder, Style, StyleId, StyleTable, Theme,
|
||||
Underline, VAlign, VertAlign,
|
||||
};
|
||||
pub use workbook::{
|
||||
CalcMode, CalcSettings, DefinedName, DocProps, EQUATION_BUDGET_MESSAGE, MAX_EQUATION_LAYOUT_BYTES, PhoneticProps, PhoneticRun, PhoneticString,
|
||||
|
||||
@@ -350,6 +350,14 @@ pub enum VAlign {
|
||||
Distributed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ReadingOrder {
|
||||
#[default]
|
||||
Context,
|
||||
LeftToRight,
|
||||
RightToLeft,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct Alignment {
|
||||
pub h: HAlign,
|
||||
@@ -359,6 +367,9 @@ pub struct Alignment {
|
||||
pub indent: u8,
|
||||
/// Degrees -90..=90, or 255 for vertical stacked text.
|
||||
pub rotation: i16,
|
||||
/// Unspecified inherits in differential styles and uses context for ordinary cells.
|
||||
#[serde(default)]
|
||||
pub reading_order: Option<ReadingOrder>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
@@ -488,6 +499,25 @@ static DEFAULT_STYLE: std::sync::LazyLock<Style> = std::sync::LazyLock::new(Styl
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reading_order_defaults_for_older_json_and_distinguishes_styles() {
|
||||
let align: Alignment = serde_json::from_str(r#"{"h":"General","v":"Bottom","wrap":false,"shrink":false,"indent":0,"rotation":0}"#).unwrap();
|
||||
assert_eq!(align.reading_order, None);
|
||||
let mut styles = StyleTable::default();
|
||||
let context = styles.derive(StyleId::DEFAULT, |s| s.align.reading_order = Some(ReadingOrder::Context));
|
||||
let ltr = styles.derive(StyleId::DEFAULT, |s| s.align.reading_order = Some(ReadingOrder::LeftToRight));
|
||||
let rtl = styles.derive(StyleId::DEFAULT, |s| s.align.reading_order = Some(ReadingOrder::RightToLeft));
|
||||
assert_ne!(ltr, rtl);
|
||||
assert_ne!(context, StyleId::DEFAULT);
|
||||
assert_ne!(context, ltr);
|
||||
assert_ne!(context, rtl);
|
||||
assert_ne!(ltr, StyleId::DEFAULT);
|
||||
assert_ne!(rtl, StyleId::DEFAULT);
|
||||
assert_eq!(styles.derive(ltr, |s| s.align.reading_order = Some(ReadingOrder::RightToLeft)), rtl);
|
||||
assert_eq!(styles.derive(rtl, |s| s.align.reading_order = Some(ReadingOrder::Context)), context);
|
||||
assert_eq!(styles.derive(context, |s| s.align.reading_order = None), StyleId::DEFAULT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interning() {
|
||||
let mut t = StyleTable::default();
|
||||
|
||||
@@ -24,6 +24,7 @@ serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
image = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
unicode-bidi = "=0.3.18"
|
||||
web-time = { workspace = true }
|
||||
|
||||
# Linux only, where winit reports no system theme: the desktop portal's light/dark answer (see
|
||||
|
||||
@@ -69,7 +69,7 @@ pub(super) fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog
|
||||
st.num_fmt = gridcraft_engine::model::NumFmt::new(&code);
|
||||
}
|
||||
"Alignment" => {
|
||||
use gridcraft_engine::model::{HAlign, VAlign};
|
||||
use gridcraft_engine::model::{HAlign, ReadingOrder, VAlign};
|
||||
ui.label(egui::RichText::new(lang.tr("Horizontal")).strong());
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (h, n) in [
|
||||
@@ -97,6 +97,17 @@ pub(super) fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog
|
||||
ui.radio_value(&mut st.align.v, v, lang.tr(n));
|
||||
}
|
||||
});
|
||||
ui.label(egui::RichText::new(lang.tr("Reading direction")).strong());
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (order, name) in
|
||||
[(ReadingOrder::Context, "Context"), (ReadingOrder::LeftToRight, "Left to right"), (ReadingOrder::RightToLeft, "Right to left")]
|
||||
{
|
||||
let selected = st.align.reading_order.unwrap_or_default() == order;
|
||||
if ui.radio(selected, lang.tr(name)).clicked() {
|
||||
st.align.reading_order = Some(order);
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut indent = st.align.indent as f32;
|
||||
ui.add(egui::Slider::new(&mut indent, 0.0..=15.0).text(lang.tr("Indent")));
|
||||
st.align.indent = indent as u8;
|
||||
|
||||
@@ -148,7 +148,12 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
let job = code_editor::layout_job(&text, Lang::Python, &code_editor::font(), color, &t.syntax, inner.width());
|
||||
child.fonts_mut(|f| f.layout_job(job))
|
||||
} else {
|
||||
child.painter().layout(text, font.clone(), color, inner.width())
|
||||
let order = app.session.active().and_then(|doc| cell_reading_order(app, doc.wb.active_sheet(), doc.selection.active));
|
||||
let direction = crate::text_direction::cell_direction(order, &text);
|
||||
child.painter().layout_job(egui::text::LayoutJob {
|
||||
direction,
|
||||
..egui::text::LayoutJob::simple(text, font.clone(), color, inner.width())
|
||||
})
|
||||
};
|
||||
let lines = galley.rows.len();
|
||||
// Each cell's view starts at its top: the offset is kept per cell, not per bar.
|
||||
@@ -241,6 +246,25 @@ pub(crate) fn active_spill_anchor(app: &SheetApp) -> Option<gridcraft_engine::co
|
||||
sh.spill_anchor(at)
|
||||
}
|
||||
|
||||
fn cell_reading_order(app: &SheetApp, sheet_index: usize, cell: gridcraft_engine::core::CellRef) -> Option<gridcraft_engine::model::ReadingOrder> {
|
||||
let doc = app.session.active()?;
|
||||
let sheet = doc.wb.sheet(sheet_index)?;
|
||||
let cell = sheet.merge_at(cell).map_or(cell, |range| range.start);
|
||||
let base = doc.wb.styles.get(sheet.style_id(cell)).align.reading_order;
|
||||
let a_rule_sets_reading_order = sheet
|
||||
.cond_formats
|
||||
.iter()
|
||||
.any(|cf| cf.ranges.iter().any(|r| r.contains(cell)) && cf.rule.style().is_some_and(|style| style.align.reading_order.is_some()));
|
||||
if !a_rule_sets_reading_order {
|
||||
return base;
|
||||
}
|
||||
gridcraft_engine::cf::CfCache::default()
|
||||
.look(&doc.wb, sheet_index, cell)
|
||||
.and_then(|look| look.style)
|
||||
.and_then(|style| style.align.reading_order)
|
||||
.or(base)
|
||||
}
|
||||
|
||||
/// The active cell's Python cell (itself, or the anchor whose result spills over it): its sheet,
|
||||
/// cell and code.
|
||||
pub(crate) fn active_python(app: &SheetApp) -> Option<(usize, gridcraft_engine::core::CellRef, std::sync::Arc<gridcraft_engine::model::PyCode>)> {
|
||||
@@ -474,9 +498,14 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
let on_sheet = id == egui::Id::unique("gridcraft.cell_editor");
|
||||
let cell_text_color = if on_sheet { t.cell_text } else { t.text };
|
||||
let font2 = font.clone();
|
||||
let reading_order = cell_reading_order(app, ed.sheet, ed.cell);
|
||||
let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
|
||||
let s = buf.as_str();
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
let mut job = egui::text::LayoutJob {
|
||||
direction: crate::text_direction::cell_direction(reading_order, s),
|
||||
keep_trailing_whitespace: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut pos = 0usize;
|
||||
let mut spans: Vec<(usize, usize, usize)> = refs.iter().filter(|r| r.1 <= s.len()).map(|r| (r.0, r.1, r.4)).collect();
|
||||
spans.sort();
|
||||
|
||||
@@ -59,6 +59,9 @@ pub(super) fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut Cf
|
||||
if st.num_fmt.as_str() != "General" {
|
||||
style.num_fmt = st.num_fmt.clone();
|
||||
}
|
||||
if st.align.reading_order.is_some() {
|
||||
style.align.reading_order = st.align.reading_order;
|
||||
}
|
||||
for (dst, src) in [
|
||||
(&mut style.borders.left, st.borders.left),
|
||||
(&mut style.borders.right, st.borders.right),
|
||||
|
||||
@@ -363,11 +363,13 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
|
||||
return;
|
||||
}
|
||||
let color = ncolor.unwrap_or(lk.text_color);
|
||||
let direction = crate::text_direction::cell_direction(st.align.reading_order, &text);
|
||||
let halign = match st.align.h {
|
||||
HAlign::General => match v {
|
||||
Value::Number(_) => HAlign::Right,
|
||||
Value::Bool(_) | Value::Error(_) => HAlign::Center,
|
||||
_ if numeric => HAlign::Right,
|
||||
_ if direction == egui::epaint::text::TextDirection::RightToLeft && !sh.right_to_left => HAlign::Right,
|
||||
_ => HAlign::Left,
|
||||
},
|
||||
h => painted_halign(h, geo.rtl),
|
||||
@@ -379,16 +381,18 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
|
||||
if !rotated && (st.align.wrap || st.align.h == HAlign::Justify) {
|
||||
let wrap_w = (rect.width() - 2.0 * pad - indent).max(4.0);
|
||||
let mut job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, wrap_w);
|
||||
job.halign = match halign {
|
||||
job.direction = direction;
|
||||
job.halign_min_is_start = false;
|
||||
job.halign = match painted_halign(halign, geo.rtl) {
|
||||
HAlign::Center | HAlign::CenterAcross => egui::Align::Center,
|
||||
HAlign::Right => egui::Align::RIGHT,
|
||||
_ => egui::Align::LEFT,
|
||||
};
|
||||
let galley = p.layout_job(job);
|
||||
let x = match halign {
|
||||
HAlign::Center | HAlign::CenterAcross => rect.center().x,
|
||||
HAlign::Right => rect.right() - pad,
|
||||
_ => text_left + pad + indent,
|
||||
HAlign::Center | HAlign::CenterAcross => rect.center().x - galley.rect.center().x,
|
||||
HAlign::Right => rect.right() - pad - galley.rect.right(),
|
||||
_ => text_left + pad + indent - galley.rect.left(),
|
||||
};
|
||||
let y = wrapped_cell_text_y(st.align.v, *rect, galley.size().y, z);
|
||||
let text_clip = wrapped_text_clip(p, *rect, &galley, y, st.align.v);
|
||||
@@ -406,7 +410,13 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
|
||||
.flatten();
|
||||
let shrunk = small.is_some();
|
||||
let font = small.unwrap_or(font);
|
||||
let mut galley = p.layout_no_wrap(first_line.clone(), font.clone(), color);
|
||||
let layout = |text: String| {
|
||||
let mut job = egui::text::LayoutJob::simple(text, font.clone(), color, f32::INFINITY);
|
||||
job.direction = direction;
|
||||
job.halign_min_is_start = false;
|
||||
p.layout_job(job)
|
||||
};
|
||||
let mut galley = layout(first_line.clone());
|
||||
let mut w = galley.size().x;
|
||||
// Numbers and dates never overflow: they show #### when too narrow.
|
||||
let hashed = !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill;
|
||||
@@ -414,7 +424,7 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
|
||||
// Measured on `#` itself: it is wider than `0` in some fonts, and #### must fit its cell.
|
||||
let hash_w = p.layout_no_wrap("#".to_string(), font.clone(), color).size().x.max(1.0);
|
||||
let n = ((avail_w - 2.0 * pad) / hash_w).floor().max(1.0) as usize;
|
||||
galley = p.layout_no_wrap("#".repeat(n), font.clone(), color);
|
||||
galley = layout("#".repeat(n));
|
||||
w = galley.size().x;
|
||||
}
|
||||
// Repeat fill (`*` in number formats) / Fill alignment. A number already shown as #### keeps
|
||||
@@ -431,7 +441,7 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
|
||||
if first_line.is_char_boundary(pos) {
|
||||
s.insert_str(pos, &ch.to_string().repeat(n));
|
||||
}
|
||||
galley = p.layout_no_wrap(s, font.clone(), color);
|
||||
galley = layout(s);
|
||||
w = galley.size().x;
|
||||
}
|
||||
// Center Across Selection: the text is centred over this cell and the empty cells to its right that also use it.
|
||||
@@ -495,12 +505,13 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
|
||||
let angle = -(st.align.rotation as f32).to_radians();
|
||||
// Upright (±90°) wrapped text wraps to the row height: lines then stack across the column.
|
||||
let galley = if st.align.wrap && st.align.rotation.abs() == 90 {
|
||||
let job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, (rect.height() - 2.0 * pad).max(4.0));
|
||||
let mut job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, (rect.height() - 2.0 * pad).max(4.0));
|
||||
job.direction = direction;
|
||||
p.layout_job(job)
|
||||
} else {
|
||||
galley.clone()
|
||||
};
|
||||
let pos = rotated_text_pos(*rect, galley.size(), angle, painted_halign(st.align.h, geo.rtl), st.align.v, pad, indent);
|
||||
let pos = rotated_text_pos(*rect, galley.size(), angle, halign, st.align.v, pad, indent);
|
||||
let mut shape = egui::epaint::TextShape::new(pos, galley, color);
|
||||
shape.angle = angle;
|
||||
cp.add(shape);
|
||||
|
||||
@@ -53,6 +53,7 @@ pub mod ribbon;
|
||||
pub mod selection_glide;
|
||||
pub mod tabs;
|
||||
pub mod text_box;
|
||||
mod text_direction;
|
||||
pub mod theme;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod updates_ui;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Cell reading direction follows OOXML context rules, independently of sheet geometry.
|
||||
|
||||
use egui::epaint::text::TextDirection;
|
||||
use gridcraft_engine::model::ReadingOrder;
|
||||
|
||||
pub(crate) fn cell_direction(order: Option<ReadingOrder>, text: &str) -> TextDirection {
|
||||
match order.unwrap_or_default() {
|
||||
ReadingOrder::LeftToRight => TextDirection::LeftToRight,
|
||||
ReadingOrder::RightToLeft => TextDirection::RightToLeft,
|
||||
ReadingOrder::Context => match text.chars().find(|ch| !ch.is_whitespace()).map(unicode_bidi::bidi_class) {
|
||||
Some(unicode_bidi::BidiClass::R | unicode_bidi::BidiClass::AL) => TextDirection::RightToLeft,
|
||||
_ => TextDirection::LeftToRight,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn context_uses_the_first_non_whitespace_character() {
|
||||
for text in ["مرحبا 123", " עברית", "\n\tالعربية"] {
|
||||
assert_eq!(cell_direction(None, text), TextDirection::RightToLeft);
|
||||
}
|
||||
for text in ["123 مرحبا", "(مرحبا)", "ABC العربية", "", " "] {
|
||||
assert_eq!(cell_direction(None, text), TextDirection::LeftToRight);
|
||||
}
|
||||
assert_eq!(cell_direction(Some(ReadingOrder::LeftToRight), "مرحبا"), TextDirection::LeftToRight);
|
||||
assert_eq!(cell_direction(Some(ReadingOrder::RightToLeft), "123 ABC"), TextDirection::RightToLeft);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Spreadsheet text remains logical while the shared renderer lays it out visually.
|
||||
|
||||
use egui::{Event, Key, Modifiers, Shape, vec2};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::{
|
||||
Session,
|
||||
core::{CellRef, Value},
|
||||
};
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn app(text: &str, order: &str, sheet_rtl: bool) -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("cell.set", json!({"cell": "B2", "input": text})).unwrap();
|
||||
session.execute("home.formatCells", json!({"range":"B2", "style":{"align":{"reading_order":order},"font":{"size":20}}})).unwrap();
|
||||
session.execute("selection.set", json!({"cell":"B2"})).unwrap();
|
||||
if sheet_rtl {
|
||||
session.execute("view.rightToLeft", json!({"on":true})).unwrap();
|
||||
}
|
||||
let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn text_shapes<'a>(shape: &'a Shape, text: &str, out: &mut Vec<&'a egui::epaint::TextShape>) {
|
||||
match shape {
|
||||
Shape::Text(s) if s.galley.text() == text => out.push(s),
|
||||
Shape::Vec(shapes) => {
|
||||
for s in shapes {
|
||||
text_shapes(s, text, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_direction_reaches_cells_on_both_sheet_geometries() {
|
||||
let text = "ABC مرحبا 123";
|
||||
for sheet_rtl in [false, true] {
|
||||
for (order, rtl) in [("LeftToRight", false), ("RightToLeft", true)] {
|
||||
let h = app(text, order, sheet_rtl);
|
||||
let mut painted = Vec::new();
|
||||
for shape in &h.output().shapes {
|
||||
text_shapes(&shape.shape, text, &mut painted);
|
||||
}
|
||||
assert!(painted.iter().any(|s| s.galley.job.direction
|
||||
== if rtl { egui::epaint::text::TextDirection::RightToLeft } else { egui::epaint::text::TextDirection::LeftToRight }));
|
||||
for shape in painted {
|
||||
assert_eq!(shape.galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| g.chr)).collect::<String>(), text);
|
||||
assert_eq!(shape.galley.rows[0].rtl_paragraph, rtl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marked_arabic_survives_copy_paste_commit_undo_and_xlsx() {
|
||||
let text = "مَرْحَبًا 123 (ABC)";
|
||||
for formula_bar in [false, true] {
|
||||
let mut h = app(text, "RightToLeft", false);
|
||||
if formula_bar {
|
||||
h.get_by_label(text).click();
|
||||
} else {
|
||||
h.key_press(Key::F2);
|
||||
}
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().editor.as_ref().map(|e| e.from_formula_bar), Some(formula_bar));
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
|
||||
h.run_steps(1);
|
||||
h.input_mut().events.push(Event::Copy);
|
||||
h.run_steps(1);
|
||||
assert!(h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == text)));
|
||||
let replacement = "تَجْرِبَة ١٢٣ (ABC)";
|
||||
h.input_mut().events.push(Event::Paste(replacement.into()));
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(2);
|
||||
let value = |h: &Harness| h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::new(1, 1));
|
||||
assert_eq!(value(&h), Value::text(replacement));
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(value(&h), Value::text(text));
|
||||
h.state_mut().session.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(value(&h), Value::text(replacement));
|
||||
let wb = &h.state().session.active().unwrap().wb;
|
||||
let bytes = gridcraft_xlsx::write_xlsx(wb).unwrap();
|
||||
let (reopened, _) = gridcraft_xlsx::read_xlsx(&bytes).unwrap();
|
||||
assert_eq!(reopened.active().unwrap().value(CellRef::new(1, 1)), Value::text(replacement));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_reading_order_reaches_the_formula_bar() {
|
||||
let text = "ABC مرحبا 123";
|
||||
for (rule_style, rtl) in [(json!({"align": {"reading_order": "RightToLeft"}}), true), (json!({"font": {"bold": true}}), false)] {
|
||||
let mut h = app(text, "LeftToRight", false);
|
||||
h.state_mut().session.execute("home.conditionalFormat", json!({"range": "B2", "rule": {"type": "noBlanks", "style": rule_style}})).unwrap();
|
||||
h.run_steps(2);
|
||||
let mut painted = Vec::new();
|
||||
for shape in &h.output().shapes {
|
||||
text_shapes(&shape.shape, text, &mut painted);
|
||||
}
|
||||
let formula_bar = painted.iter().min_by(|a, b| a.pos.y.total_cmp(&b.pos.y)).unwrap();
|
||||
assert_eq!(formula_bar.galley.rows[0].rtl_paragraph, rtl);
|
||||
}
|
||||
}
|
||||
|
||||
fn painted_galleys(h: &Harness, text: &str) -> Vec<std::sync::Arc<egui::Galley>> {
|
||||
let mut painted = Vec::new();
|
||||
for shape in &h.output().shapes {
|
||||
text_shapes(&shape.shape, text, &mut painted);
|
||||
}
|
||||
painted.into_iter().map(|s| s.galley.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_left_keeps_wrapped_right_to_left_lines_on_the_left() {
|
||||
let text = "مرحبا بالعالم\nا";
|
||||
let mut h = app(text, "RightToLeft", false);
|
||||
h.state_mut()
|
||||
.session
|
||||
.execute("home.formatCells", json!({"range": "B2", "style": {"align": {"h": "Left", "wrap": true, "reading_order": "RightToLeft"}}}))
|
||||
.unwrap();
|
||||
h.run_steps(2);
|
||||
let mut painted = Vec::new();
|
||||
for shape in &h.output().shapes {
|
||||
text_shapes(&shape.shape, text, &mut painted);
|
||||
}
|
||||
let cell = painted.iter().max_by(|a, b| a.pos.y.total_cmp(&b.pos.y)).unwrap();
|
||||
assert!(cell.galley.rows.len() > 1);
|
||||
assert!(cell.galley.rows.iter().all(|row| row.rtl_paragraph));
|
||||
for row in &cell.galley.rows {
|
||||
let left = row.pos.x + row.glyphs.iter().map(|g| g.pos.x).fold(f32::INFINITY, f32::min);
|
||||
assert!(left.abs() < 0.5, "row starts at {left}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_to_left_editor_keeps_a_trailing_space_inside_the_text() {
|
||||
let text = "مرحبا ";
|
||||
let mut h = app("", "RightToLeft", false);
|
||||
h.key_press(Key::F2);
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.push(Event::Text(text.into()));
|
||||
h.run_steps(3);
|
||||
let galleys = painted_galleys(&h, text);
|
||||
assert!(!galleys.is_empty());
|
||||
for galley in galleys {
|
||||
for row in &galley.rows {
|
||||
for glyph in &row.glyphs {
|
||||
let x = row.pos.x + glyph.pos.x;
|
||||
assert!(x >= -0.5, "{:?} at {x}", glyph.chr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wrapped_row_edges(sheet_rtl: bool, h_align: &str, text: &str) -> Vec<(f32, f32)> {
|
||||
let mut h = app(text, "RightToLeft", sheet_rtl);
|
||||
h.state_mut().session.execute("home.formatCells", json!({"range": "B2", "style": {"align": {"h": h_align, "wrap": true}}})).unwrap();
|
||||
h.run_steps(2);
|
||||
let mut painted = Vec::new();
|
||||
for shape in &h.output().shapes {
|
||||
text_shapes(&shape.shape, text, &mut painted);
|
||||
}
|
||||
let cell = painted.iter().max_by(|a, b| a.pos.y.total_cmp(&b.pos.y)).unwrap();
|
||||
assert!(cell.galley.rows.len() > 1);
|
||||
cell.galley.rows.iter().map(|row| (cell.pos.x + row.rect().left(), cell.pos.x + row.rect().right())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_right_to_left_rows_follow_the_physical_alignment_on_both_sheet_geometries() {
|
||||
let text = "مرحبا بالعالم\nا";
|
||||
for (sheet_rtl, h_align, on_right) in [
|
||||
(false, "General", true),
|
||||
(false, "Left", false),
|
||||
(false, "Right", true),
|
||||
(true, "General", true),
|
||||
(true, "Left", false),
|
||||
(true, "Right", true),
|
||||
] {
|
||||
let edges = wrapped_row_edges(sheet_rtl, h_align, text);
|
||||
let side: Vec<f32> = edges.iter().map(|&(left, right)| if on_right { right } else { left }).collect();
|
||||
let first = side[0];
|
||||
assert!(side.iter().all(|x| (x - first).abs() <= 1.0), "sheet_rtl {sheet_rtl}, {h_align}: rows at {edges:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn single_line_right_edge(text: &str, order: &str, sheet_rtl: bool) -> f32 {
|
||||
let h = app(text, order, sheet_rtl);
|
||||
let mut painted = Vec::new();
|
||||
for shape in &h.output().shapes {
|
||||
text_shapes(&shape.shape, text, &mut painted);
|
||||
}
|
||||
let cell = painted.iter().max_by(|a, b| a.pos.y.total_cmp(&b.pos.y)).unwrap();
|
||||
assert_eq!(cell.galley.rows.len(), 1);
|
||||
cell.galley
|
||||
.rows
|
||||
.iter()
|
||||
.flat_map(|row| row.glyphs.iter().map(move |g| cell.pos.x + row.pos.x + g.pos.x + g.advance_width))
|
||||
.fold(f32::NEG_INFINITY, f32::max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mark_on_the_first_letter_keeps_single_line_right_to_left_text_flush_right() {
|
||||
for (marked, plain) in [("يُرجى مراجعة", "يرجى مراجعة"), ("بَاب", "باب")] {
|
||||
for sheet_rtl in [false, true] {
|
||||
for order in ["RightToLeft", "Context"] {
|
||||
let marked_right = single_line_right_edge(marked, order, sheet_rtl);
|
||||
let plain_right = single_line_right_edge(plain, order, sheet_rtl);
|
||||
assert!(
|
||||
(marked_right - plain_right).abs() <= 1.0,
|
||||
"{marked}, sheet_rtl {sheet_rtl}, {order}: ends at {marked_right}, unmarked at {plain_right}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! Arabic word navigation must not panic or delete unrelated cell text.
|
||||
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Shape, vec2};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::{
|
||||
Session,
|
||||
core::{CellRef, Value},
|
||||
};
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
fn editing(text: &str, formula_bar: bool) -> egui_kittest::Harness<'static, SheetApp> {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("cell.set", json!({"cell": "A1", "input": text})).unwrap();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(session, Default::default()),
|
||||
);
|
||||
h.run_steps(4);
|
||||
if formula_bar {
|
||||
h.get_by_label(text).click();
|
||||
} else {
|
||||
h.key_press(Key::F2);
|
||||
}
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().editor.as_ref().map(|e| e.from_formula_bar), Some(formula_bar));
|
||||
h.key_press_modifiers(Modifiers::CTRL, Key::End);
|
||||
h.run_steps(2);
|
||||
let id = egui::Id::unique(if formula_bar { "gridcraft.formula_bar" } else { "gridcraft.cell_editor" });
|
||||
let caret = egui::text_edit::TextEditState::load(&h.ctx, id).and_then(|state| state.cursor.char_range()).map(|range| range.primary.index.0);
|
||||
assert_eq!(caret, Some(text.chars().count()), "the caret starts at the text's logical end");
|
||||
h
|
||||
}
|
||||
|
||||
/// The in-cell editor's laid-out text and its screen position: the lowest painted copy, below the formula bar.
|
||||
fn cell_editor_galley(h: &egui_kittest::Harness<'static, SheetApp>, text: &str) -> Option<(Pos2, std::sync::Arc<egui::Galley>)> {
|
||||
fn collect(shape: &Shape, text: &str, out: &mut Vec<(Pos2, std::sync::Arc<egui::Galley>)>) {
|
||||
match shape {
|
||||
Shape::Text(s) if s.galley.text() == text => out.push((s.pos, s.galley.clone())),
|
||||
Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, text, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut found = Vec::new();
|
||||
for clipped in &h.output().shapes {
|
||||
collect(&clipped.shape, text, &mut found);
|
||||
}
|
||||
found.into_iter().max_by(|a, b| a.0.y.total_cmp(&b.0.y))
|
||||
}
|
||||
|
||||
fn cell_editor_text_right_edge(h: &egui_kittest::Harness<'static, SheetApp>, text: &str) -> Option<Pos2> {
|
||||
cell_editor_galley(h, text).map(|(pos, galley)| pos + vec2(galley.rect.max.x - 1.0, galley.rect.center().y))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_after_typing_arabic_is_drawn_at_its_left_end() {
|
||||
let mut h = editing("", false);
|
||||
h.input_mut().events.push(Event::Text("بسم الله".into()));
|
||||
h.run_steps(2);
|
||||
let state = egui::text_edit::TextEditState::load(&h.ctx, egui::Id::unique("gridcraft.cell_editor")).unwrap();
|
||||
let caret = state.cursor.char_range().unwrap().primary;
|
||||
assert_eq!(caret.index.0, "بسم الله".chars().count());
|
||||
let (_, galley) = cell_editor_galley(&h, "بسم الله").unwrap();
|
||||
let x = galley.pos_from_cursor(caret).min.x;
|
||||
assert!(x <= galley.rect.min.x + 1.0, "caret at x={x}, text spans {:?}", galley.rect.x_range());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previous_word_in_arabic_cell_does_not_panic() {
|
||||
let mut h = editing("Aurora بب", false);
|
||||
h.key_press_modifiers(Modifiers::CTRL, Key::ArrowLeft);
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.push(egui::Event::Text("x".into()));
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(2);
|
||||
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
// End is the logical end at the Arabic run's left edge. Ctrl+Left follows visual
|
||||
// word order across the Latin prefix, preserving all original characters.
|
||||
assert_eq!(sheet.value(CellRef::new(0, 0)), Value::text("xAurora بب"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_previous_arabic_word_keeps_the_latin_prefix() {
|
||||
let mut h = editing("Aurora بب", false);
|
||||
h.key_press_modifiers(Modifiers::CTRL, Key::Backspace);
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(2);
|
||||
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sheet.value(CellRef::new(0, 0)), Value::text("Aurora "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_previous_arabic_word_in_formula_bar_keeps_the_prefix() {
|
||||
let mut h = editing("Aurora بب", true);
|
||||
h.key_press_modifiers(Modifiers::CTRL, Key::Backspace);
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(2);
|
||||
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sheet.value(CellRef::new(0, 0)), Value::text("Aurora "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previous_visual_word_crosses_arabic_from_its_right_edge() {
|
||||
let mut h = editing("Aurora بب", false);
|
||||
let pos = cell_editor_text_right_edge(&h, "Aurora بب").unwrap();
|
||||
h.input_mut().events.extend([
|
||||
Event::PointerMoved(pos),
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
h.run_steps(2);
|
||||
let state = egui::text_edit::TextEditState::load(&h.ctx, egui::Id::unique("gridcraft.cell_editor")).unwrap();
|
||||
let caret = state.cursor.char_range().map(|r| (r.primary.index.0, r.primary.prefer_next_row));
|
||||
assert_eq!(caret, Some((7, true)), "the click lands on the Arabic run's right edge");
|
||||
h.key_press_modifiers(Modifiers::CTRL, Key::ArrowLeft);
|
||||
h.run_steps(2);
|
||||
h.input_mut().events.push(egui::Event::Text("x".into()));
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(2);
|
||||
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sheet.value(CellRef::new(0, 0)), Value::text("Aurora ببx"));
|
||||
}
|
||||
@@ -6,7 +6,8 @@ use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_model::style::{DxfStated, Protection};
|
||||
use gridcraft_model::{
|
||||
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, Underline, VAlign, VertAlign, Workbook,
|
||||
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, ReadingOrder, Style, Underline, VAlign, VertAlign,
|
||||
Workbook,
|
||||
};
|
||||
|
||||
use crate::tables::{INDEXED, builtin_format, builtin_id};
|
||||
@@ -268,6 +269,12 @@ fn read_alignment(a: &El) -> Alignment {
|
||||
shrink: a.flag("shrinkToFit", false),
|
||||
indent: a.attr_u32("indent").unwrap_or(0).min(250) as u8,
|
||||
rotation: 0,
|
||||
reading_order: match a.attr_u32("readingOrder") {
|
||||
Some(0) => Some(ReadingOrder::Context),
|
||||
Some(1) => Some(ReadingOrder::LeftToRight),
|
||||
Some(2) => Some(ReadingOrder::RightToLeft),
|
||||
_ => None,
|
||||
},
|
||||
};
|
||||
if let Some(r) = a.attr_i64("textRotation") {
|
||||
al.rotation = match r {
|
||||
@@ -602,6 +609,12 @@ fn alignment_xml(a: &Alignment) -> Option<String> {
|
||||
if a.shrink {
|
||||
s.push_str(" shrinkToFit=\"1\"");
|
||||
}
|
||||
match a.reading_order {
|
||||
None => {}
|
||||
Some(ReadingOrder::Context) => s.push_str(" readingOrder=\"0\""),
|
||||
Some(ReadingOrder::LeftToRight) => s.push_str(" readingOrder=\"1\""),
|
||||
Some(ReadingOrder::RightToLeft) => s.push_str(" readingOrder=\"2\""),
|
||||
}
|
||||
s.push_str("/>");
|
||||
Some(s)
|
||||
}
|
||||
@@ -813,3 +826,50 @@ pub fn dxf_id(dxfs: &mut Vec<Style>, s: &Style) -> u32 {
|
||||
dxfs.push(s.clone());
|
||||
(dxfs.len() - 1) as u32
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn absent_or_invalid_reading_order_is_unspecified() {
|
||||
for attribute in ["", " readingOrder=\"3\"", " readingOrder=\"-1\"", " readingOrder=\"rtl\"", " readingOrder=\"4294967296\""] {
|
||||
let xml = format!("<alignment{attribute}/>");
|
||||
let align = read_alignment(&crate::xml::parse(xml.as_bytes()).unwrap());
|
||||
assert_eq!(align, Alignment::default(), "{attribute}");
|
||||
assert_eq!(alignment_xml(&align), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_order_survives_cell_named_and_differential_style_roundtrips() {
|
||||
for order in [0, 1, 2] {
|
||||
let xml = format!(
|
||||
r#"<styleSheet><cellXfs><xf/><xf><alignment readingOrder="{order}"/></xf></cellXfs>
|
||||
<cellStyleXfs><xf><alignment readingOrder="{order}"/></xf></cellStyleXfs>
|
||||
<cellStyles><cellStyle name="Directional" xfId="0"/></cellStyles>
|
||||
<dxfs><dxf><alignment readingOrder="{order}"/></dxf></dxfs></styleSheet>"#
|
||||
);
|
||||
let styles = read_styles(&crate::xml::parse(xml.as_bytes()).unwrap(), &[], "Calibri");
|
||||
let mut wb = Workbook::new();
|
||||
wb.styles.intern(styles.xfs[1].clone());
|
||||
wb.cell_styles = styles.named.clone();
|
||||
let (xml, _) = write_styles(&wb, &styles.dxfs, &[]);
|
||||
let root = crate::xml::parse(xml.as_bytes()).unwrap();
|
||||
for section in ["dxfs", "cellXfs", "cellStyleXfs"] {
|
||||
let orders: Vec<_> = root
|
||||
.child(section)
|
||||
.unwrap()
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|style| style.child("alignment").and_then(|a| a.attr_u32("readingOrder")))
|
||||
.collect();
|
||||
assert_eq!(orders, [order], "{section}");
|
||||
}
|
||||
let reopened = read_styles(&root, &[], "Calibri");
|
||||
assert_eq!(reopened.xfs[1].align, styles.xfs[1].align);
|
||||
assert_eq!(reopened.named, styles.named);
|
||||
assert_eq!(reopened.dxfs, styles.dxfs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,15 @@ fn sample() -> Workbook {
|
||||
s.fill = Fill::solid(Color::Rgb(0xFFEE00));
|
||||
s.borders.bottom = BorderLine { style: BorderStyle::Double, color: Color::Rgb(0xFF0000) };
|
||||
s.borders.diag_down = BorderLine::thin();
|
||||
s.align = Alignment { h: HAlign::Center, v: VAlign::Top, wrap: true, shrink: false, indent: 1, rotation: -30 };
|
||||
s.align = Alignment {
|
||||
h: HAlign::Center,
|
||||
v: VAlign::Top,
|
||||
wrap: true,
|
||||
shrink: false,
|
||||
indent: 1,
|
||||
rotation: -30,
|
||||
reading_order: Some(ReadingOrder::RightToLeft),
|
||||
};
|
||||
s.protection.locked = false;
|
||||
});
|
||||
let money = wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new("#,##0.00 \"€\""));
|
||||
|
||||
@@ -339,6 +339,7 @@ fn xf_style(xf: &Xf, fonts: &[Font], formats: &HashMap<u16, String>, table: &[u3
|
||||
},
|
||||
indent: a2 & 0x0F,
|
||||
shrink: a2 & 0x10 != 0,
|
||||
reading_order: None,
|
||||
};
|
||||
let b1 = word32(4);
|
||||
let b2 = word32(8);
|
||||
|
||||
@@ -14,6 +14,8 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and
|
||||
Legacy scalar `INDEX(range, MATCH(...))` formulas remain ordinary formulas on save, including when
|
||||
shared formulas are expanded during import (`array_export.rs`, #1505).
|
||||
|
||||
Cell text now uses shared Unicode bidi layout while retaining logical source indexes for editing, copy and XLSX. Format Cells exposes Context, Left to right and Right to left reading direction, independent of sheet geometry. OOXML Context uses the first non-whitespace character. Optional direction preserves absent versus explicit Context in differential styles. The renderer is pinned to a reviewed egui 0.36.2 backport with real-font, editing, accessibility and WASM checks. Physical screen-reader behavior remains unverified.
|
||||
|
||||
## Workbook window settings
|
||||
|
||||
GridCraft keeps one modelled workbook window. On XLSX import it selects the first visible
|
||||
@@ -179,6 +181,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
|
||||
| 2026-11-01 | trivial | XLSX selects the first visible workbook view as one unit and bounds imported/exported tab ratios; isolated attributes and sheet-edit round trips covered, with firstSheet left to #2264 (#3057) |
|
||||
| 2026-10-31 | minor | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) |
|
||||
| 2026-10-31 | minor | XLSX open reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)` export wrapper as the formula inside when GridCraft calculates every function in it (plain, shared and array formulas, whole or nested; Google-only functions and unparseable, over-long or over-deep inner text keep the wrapper and the cached value) and keeps the cached value; saving writes the real formula with Excel's `_xlfn.` prefixes (#2871) |
|
||||
| 2026-10-26 | minor | Alignment readingOrder 0/1/2 survives cell, named-style and differential-style round-trips; Format Cells exposes reading direction and CF priority preserves unspecified inheritance |
|
||||
| 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI `<img>`) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged |
|
||||
| 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `<rPh sb="…" eb="…">` with `<phoneticPr>`) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `<phoneticPr>` without runs is kept on its own, and when duplicate `<si>` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) |
|
||||
| 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (`<a:font script="Arab" typeface="…"/>`, also Hebr, Jpan and the rest) are read and written back in file order after `<a:cs>`; they were dropped before. A hostile file is held to 64 entries of 256 characters |
|
||||
|
||||
@@ -54,6 +54,11 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
- **Estimate:** 25–35 h for beta (40–60 h full). [chart-parity.md](chart-parity.md)
|
||||
|
||||
### 5. Text in non-Latin scripts doesn't render or export (L)
|
||||
- **Missing:** cross-platform script coverage, Devanagari/Thai shaping verification, non-Latin PDF and Arabic UI layout.
|
||||
- **Evidence:** system-font fallback for CJK, Thai and Hebrew landed in #38 (closed #32); #142
|
||||
(Chinese text) awaits confirmation; #54 (symbols as boxes on Fedora). Arabic shared shaping/bidi,
|
||||
editing and cell Context/LTR/RTL now have focused regressions and full gates. Arabic font
|
||||
delivery and PDF output are separate PRs; other script shaping still needs verification.
|
||||
- **Missing:** CJK font fallback, complex shaping (Arabic, Devanagari, Thai), bidi, non-Latin PDF.
|
||||
- **Evidence:** system-font fallback for CJK, Thai and Hebrew landed in #38 (closed #32), and for
|
||||
Arabic (system, fontconfig and craft-fonts faces); a UI font with Arabic of its own (DejaVu Sans
|
||||
@@ -195,6 +200,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
| 2026-11-01 | minor | §4: chart data tables implemented for column, bar, line, area and compatible combo charts, with legend keys and border settings, live source values, XLSX round-trip and print/PDF; other chart gaps remain. |
|
||||
| 2026-10-31 | minor | §13: Solver Options (Max Evaluations, Integer Optimality and the Evolutionary options), saved with the model |
|
||||
| 2026-10-31 | minor | §13: Solver Load/Save Model (`data.solverSaveModel`, `data.solverLoadModel`) and Reset All (`data.solverReset`), with buttons in the Solver Parameters dialog |
|
||||
| 2026-10-26 | minor | Arabic shared bidi display/editing and persisted cell reading direction; other script and PDF work remain separate |
|
||||
| 2026-10-26 | minor | §5: Arabic text renders in cells and the interface: a system Arabic face (Tahoma, Arial or Segoe UI on Windows, Geeza Pro on macOS, Noto Sans or Naskh Arabic on Linux) joins the script fallbacks, fontconfig's Arabic choice, when it has Arabic letters, leads the Arabic fallbacks and draws Arabic wherever the UI or cell font has none, such as the default cell fonts (part of #229; a UI font with Arabic of its own, such as DejaVu Sans in the Flatpak, still draws interface Arabic, left for a follow-up), and craft-fonts' Noto Sans Arabic is a required face allowed on the web. Workbook fonts without Arabic keep every script fallback. Shaping and bidirectional layout remain separate work |
|
||||
| 2026-10-24 | minor | §8: PivotTable Enable show details: `pivot.options` `enableShowDetails` turns Show Details off for a PivotTable, read and written in XLSX as `enableDrill` |
|
||||
| 2026-10-23 | minor | §8: PivotTable Show Details: double-clicking a value cell lists its source records on a new sheet (#2364) |
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (Arabic font fallback; missing translations are listed by `cargo xtask i18n-report` and never fail a check); previously 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
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (Arabic bidi display, editing and cell reading direction; missing translations are listed by `cargo xtask i18n-report` and never fail a check); previously minor (Arabic font fallback; missing translations are listed by `cargo xtask i18n-report` and never fail a check); previously 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,
|
||||
right-to-left sheets and full complex-script support. Part of
|
||||
[target-app-parity.md](target-app-parity.md). **Localization: ~10% ready, 100–165 h** (estimated).
|
||||
|
||||
Cell text now uses shared Unicode bidi layout while retaining logical source indexes for editing, copy and XLSX. Format Cells exposes Context, Left to right and Right to left reading direction, independent of sheet geometry. OOXML Context uses the first non-whitespace character. Optional direction preserves absent versus explicit Context in differential styles. The renderer is pinned to a reviewed egui 0.36.2 backport with real-font, editing, accessibility and WASM checks. Physical screen-reader behavior remains unverified.
|
||||
|
||||
## Where we are (measured from source on `main`, after the 2026-10-10 merges)
|
||||
|
||||
- **A first catalog exists, for the ribbon only.** `crates/ui-egui/src/i18n.rs` (merged 10-10 in
|
||||
@@ -60,14 +62,16 @@ right-to-left sheets and full complex-script support. Part of
|
||||
`/run/host/fonts`, `/run/host/local-fonts` and `/run/host/user-fonts` layouts, so installed
|
||||
Noto CJK and WenQuanYi faces can render Chinese without a broad filesystem grant. This does
|
||||
not change the installed-font catalogue or add bundled fonts. Lao falls back to Lao Sangam MN / Lao MN (macOS),
|
||||
Leelawadee UI / DokChampa (Windows) or Noto Sans Lao / Phetsarath OT (Linux). egui uses harfrust shaping and Unicode bidi layout; script-specific coverage
|
||||
beyond Arabic still needs verification. **Sheet Right-to-Left**
|
||||
Leelawadee UI / DokChampa (Windows) or Noto Sans Lao / Phetsarath OT (Linux). Arabic falls back to a system Arabic face, fontconfig's
|
||||
Arabic choice or craft-fonts' Noto Sans Arabic; fontconfig's choice leads these fallbacks, but a UI font with Arabic of its
|
||||
own (DejaVu Sans in the Flatpak) still draws interface Arabic (#229). There is no complex-text shaping: Arabic and
|
||||
Devanagari don't join or reorder, and there is no bidirectional layout. **Sheet Right-to-Left**
|
||||
(`view.rightToLeft`, Page Layout › Sheet Options, XLSX `rightToLeft`, `.xls` WINDOW2 fRightToLeft) mirrors the grid (#146):
|
||||
column A and the row headers on the right, the vertical scrollbar on the left; hit-testing,
|
||||
arrow keys, editor, popups and floating objects follow; text stays left-to-right inside its box
|
||||
(no bidi), and the sheet tabs, horizontal scrollbar and printing/PDF are not mirrored yet.
|
||||
arrow keys, editor, popups and floating objects follow; text follows its cell reading direction inside its box,
|
||||
and the sheet tabs, horizontal scrollbar and printing/PDF are not mirrored yet.
|
||||
Text boxes and shapes keep their paragraphs' right-to-left direction (`a:pPr rtl`) through XLSX
|
||||
save, though GridCraft still draws that text left to right. IME input goes
|
||||
through egui/winit and is unverified for Chinese, Japanese and Korean.
|
||||
@@ -88,7 +92,7 @@ separators, number/date formats, machine translation with agent review, and scri
|
||||
| Simplified Chinese (Mandarin) | zh-Hans | 266 (~12%) | no | CJK system-font fallback (#38); IME unverified; PDF can't export | no | menus only | 8–13 h |
|
||||
| Spanish | es | 0 (0%) | no | Latin: yes | no | none | 6–10 h |
|
||||
| Hindi | hi | 0 (0%) | no | Devanagari: **no shaping** | no | none | 12–18 h |
|
||||
| Arabic | ar | 0 (0%) | no | **no shaping, no RTL UI**; Sheet Right-to-Left mirrors the grid (#146) | no | none | 25–35 h |
|
||||
| Arabic | ar | 0 (0%) | no | shared shaping/bidi; no Arabic UI catalog or mirrored shell; Sheet RTL mirrors the grid (#146) | no | none | 25–35 h |
|
||||
| French | fr | 0 (0%) | no | Latin: yes | no | none | 6–10 h |
|
||||
| Portuguese | pt-BR | 943 (~43%) | dialogs and tooltips (dialogs take effect with #669) | Latin: yes | no | menus, File view, dialogs | 2–4 h |
|
||||
| Indonesian | id | 0 (0%) | no | Latin: yes | no | none | 5–8 h |
|
||||
@@ -126,6 +130,7 @@ language (none so far) and of localized function names.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-26 | minor | Shared bidi layout supports Arabic clusters, marks, visual cursor navigation and directional accessibility; cell Context/LTR/RTL is stored separately from sheet RTL and round-trips through XLSX |
|
||||
| 2026-10-26 | minor | Arabic text renders in cells and the interface: a system Arabic face (Tahoma, Arial or Segoe UI on Windows, Geeza Pro on macOS, Noto Sans or Naskh Arabic on Linux) joins the script fallbacks, fontconfig's Arabic choice, when it has Arabic letters, leads the Arabic fallbacks and draws Arabic wherever the UI or cell font has none, such as the default cell fonts (part of #229; a UI font with Arabic of its own, such as DejaVu Sans in the Flatpak, still draws interface Arabic, left for a follow-up), and craft-fonts' Noto Sans Arabic is a required face allowed on the web. Workbook fonts without Arabic keep every script fallback. Shaping and bidirectional layout remain separate work |
|
||||
| 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 |
|
||||
|
||||
Reference in New Issue
Block a user