diff --git a/Cargo.lock b/Cargo.lock index 6ad7443..94e8acc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4984,6 +4984,7 @@ dependencies = [ "log", "serde", "serde_json", + "unicode-bidi", "unicode-linebreak", "unicode-segmentation", "wordcraft-doc", diff --git a/ROADMAP.md b/ROADMAP.md index 9c46fb1..2dd3181 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,12 +8,12 @@ and agent control. This file tracks where we are honestly. Generated numbers com | Measure | Value | |---|---| -| Commands (every action, scriptable by CLI/MCP/control channel) | **390** | -| Feature catalog coverage (Word ribbon/menu features with a command) | **355 / 405 (88%)** | +| Commands (every action, scriptable by CLI/MCP/control channel) | **422** | +| Feature catalog coverage (Word ribbon/menu features with a command) | **378 / 428 (88%)** | | **Estimated real feature parity** (depth and fidelity, not just a command) | **~62%** | | **Distance to alpha** | **~85% of the way; ≈20–25 h of Claude wall-clock work** | | **Distance to 100% parity** | **≈120–150 h of Claude Opus 5.5 wall-clock work** (with parallel agents) | -| Tests | ~225 test functions (unit, round-trip, fuzz/proptest, MCP acceptance) | +| Tests | ~325 tests (unit, round-trip, fuzz/proptest, MCP acceptance) | | Layout speed (188-page document) | 61 ms cold, **1.4 ms** relayout after an edit | | Code | ~44k lines of Rust in 12 crates and 3 apps | @@ -65,6 +65,7 @@ the icon art). | File formats: Word 97-2003 `.doc`/`.dot` import | Text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks, pictures (spec-based reader; metafile images and Word 6/95 rejected) | 60% | | View modes (print, web, draft, read, focus, zoom, navigation pane) | Working | 70% | | Backstage (new from templates, open, info, export, options) | Working; printing goes through PDF | 55% | +| Right-to-left and complex scripts (Persian, Arabic, Hebrew) | Unicode Bidirectional Algorithm, Arabic shaping (joining, ZWNJ, lam-alef, mirrored brackets), mixed Persian/Latin/number lines, mirrored paragraphs (indents, tabs, list labels, ruler), visual caret movement and hit testing, complex-script fonts/sizes in DOCX; no kashida justification, RTL sections/tables or right-to-left UI yet | 55% | | Agent control (CLI, MCP, control channel, macros) | Beyond Word | 100%+ | ## Estimate to 100% @@ -80,7 +81,9 @@ the icon art). 5. **Equation editor** — done: OMML read/write, 2D layout, in-place editing with Word's Equation tab (structures, symbols, built-ins, Unicode/LaTeX input, build-up, Math AutoCorrect, `#` numbers, structure commands). Remaining: line breaking of long display equations, ink equations (≈4 h). 6. **Dialog depth**: every Word dialog with all its options (Font, Paragraph, Tabs, Borders and Shading, Page Setup, Styles, Columns, Index/TOC options, Mail Merge wizard, Options panes) (≈20 h). -7. **Accessibility (screen readers), localisation, RTL and complex scripts** (≈15 h). +7. **Accessibility (screen readers), localisation, and the rest of right-to-left** (≈12 h): right-to-left + sections and tables (`w:bidi` in `w:sectPr`, `w:bidiVisual`), kashida justification, a Persian UI + translation and a mirrored interface, RTL in the HTML/ODT/RTF converters, Indic shaping checks. 8. **Long tail**: kerning/ligature options, drawn tables, nested-table polish, more templates, Word's less-used commands still missing from the catalog (≈10 h). @@ -105,6 +108,9 @@ the icon art). | M14 | 1.0 polish, packaging, signing | pipeline written; waiting on remote and secrets | ## Recently landed +- Right-to-left and Persian text: bidi reordering (UAX #9), Arabic-script shaping, right-to-left + paragraphs (Left-to-Right / Right-to-Left Text Direction), visual arrow keys, `w:rtl`/`w:cs`/`w:szCs` + /`w:bCs`/`w:iCs`/`w:rFonts w:cs`/`w:lang w:bidi` round-trip, Persian font substitutes. - Ukrainian interface catalog: all existing 771 labels, Ukrainian locale detection and a persisted language choice (#85). - Brazilian Portuguese interface catalog: all existing 771 labels, `pt-BR` locale detection and a persisted language choice (#127). - Text wraps around floating pictures and shapes; text boxes lay out their own text. diff --git a/contributors/people.toml b/contributors/people.toml index 1977622..cdaacd5 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -26,3 +26,6 @@ sync_github_name = false real_name = "Chiranjeevi Uddanti" display_name = "Chiranjeevi U" sync_github_name = false + +[people.saeedemperor] +real_name = "Saeed Azarehgar" diff --git a/crates/doc/src/bidi.rs b/crates/doc/src/bidi.rs new file mode 100644 index 0000000..5da0dab --- /dev/null +++ b/crates/doc/src/bidi.rs @@ -0,0 +1,60 @@ +//! Script classification for bidirectional and complex-script text (Persian, Arabic, Hebrew…). +//! +//! The full Unicode Bidirectional Algorithm (UAX #9) runs in layout; this module only answers +//! the cheap questions the model and the engine need: does a character belong to a +//! right-to-left script, and is it formatted with the complex-script properties? + +/// A character of a right-to-left script: Hebrew, Arabic (Persian, Urdu…), Syriac, Thaana, N'Ko, +/// Samaritan, Mandaic, and their presentation forms. Includes the Arabic-Indic and Persian +/// (Extended Arabic-Indic) digits and the Arabic combining marks. +pub fn is_rtl_script(c: char) -> bool { + matches!(u32::from(c), + 0x0590..=0x08FF + | 0xFB1D..=0xFDFF + | 0xFE70..=0xFEFF + | 0x10800..=0x10FFF + | 0x1E800..=0x1EFFF) +} + +/// A character Word formats with the complex-script properties (`w:cs` font, `w:szCs`, `w:bCs`, +/// `w:iCs`): right-to-left scripts, the Indic scripts, Thai and Lao. +pub fn is_complex_script(c: char) -> bool { + is_rtl_script(c) || matches!(u32::from(c), 0x0900..=0x0EFF) +} + +/// A character that belongs to a word for word selection and Ctrl+arrow movement: letters, +/// digits, `_` and `'`, plus what sits inside Persian and Arabic words: the zero-width non-joiner +/// (نیم‌فاصله, as in «می‌خواهم»), the zero-width joiner, tatweel and the Arabic combining marks. +pub fn is_word_char(c: char) -> bool { + c.is_alphanumeric() + || c == '_' + || c == '\'' + || matches!(u32::from(c), 0x200C | 0x200D | 0x0640 | 0x0610..=0x061A | 0x064B..=0x065F | 0x0670 | 0x06D6..=0x06DC | 0x06DF..=0x06E8 | 0x06EA..=0x06ED | 0x08D3..=0x08FF | 0x0300..=0x036F) +} + +/// Does `text` contain a right-to-left character? +pub fn has_rtl(text: &str) -> bool { + text.chars().any(is_rtl_script) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_persian_and_latin() { + // Persian letters, the Persian-only letters (پ چ ژ گ ک ی), ZWNJ-free word, digits. + assert!("سلام".chars().all(is_rtl_script)); + assert!("پچژگکی".chars().all(is_rtl_script)); + assert!("۰۱۲۳۴۵۶۷۸۹".chars().all(is_rtl_script)); + assert!(is_rtl_script('\u{064E}'), "fatha (a combining mark) is Arabic script"); + assert!("שלום".chars().all(is_rtl_script)); + assert!(!"Hello 123".chars().any(is_rtl_script)); + assert!(!is_rtl_script('\u{200C}'), "ZWNJ is a format character, not a letter"); + assert!(is_complex_script('क') && !is_rtl_script('क')); + assert!(has_rtl("Word ورد 2024") && !has_rtl("Word 2024")); + assert!("می‌خواهم".chars().all(is_word_char), "ZWNJ is inside the word"); + assert!("کِتاب".chars().all(is_word_char), "so are harakat"); + assert!(!is_word_char(' ') && !is_word_char('،')); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 55bfa01..ada4b6c 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -10,6 +10,7 @@ //! compare in document order. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod bidi; pub mod edit; pub mod encoding; pub mod fields; diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index d136db0..1012908 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -730,7 +730,7 @@ impl Paragraph { let mut idx = off; let mut seen_word = false; for (i, c) in before.char_indices().rev() { - let w = c.is_alphanumeric() || c == '_' || c == '\''; + let w = crate::bidi::is_word_char(c); if w { seen_word = true; idx = i; @@ -748,10 +748,10 @@ impl Paragraph { let Some(after) = self.text.get(off..) else { return self.text.len() }; let it = after.char_indices().peekable(); let mut end = self.text.len(); - let first_word = after.chars().next().is_some_and(|c| c.is_alphanumeric() || c == '_'); + let first_word = after.chars().next().is_some_and(|c| crate::bidi::is_word_char(c) && c != '\''); let mut in_space = false; for (i, c) in it { - let w = c.is_alphanumeric() || c == '_' || c == '\''; + let w = crate::bidi::is_word_char(c); if c == ' ' || c == NBSP { in_space = true; continue; @@ -766,7 +766,7 @@ impl Paragraph { /// The word around `off` (double-click): (start, end) without trailing space. pub fn word_at(&self, off: usize) -> (usize, usize) { let off = self.clamp(off); - let is_w = |c: char| c.is_alphanumeric() || c == '_' || c == '\''; + let is_w = |c: char| crate::bidi::is_word_char(c); let mut a = off; for (i, c) in self.text.get(..off).unwrap_or("").char_indices().rev() { if !is_w(c) { diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 0f7c1f6..e60c2ec 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -286,8 +286,26 @@ pub struct CharProps { pub lang: Option, #[serde(skip_serializing_if = "Option::is_none")] pub no_proof: Option, + /// Right-to-left run (OOXML `w:rtl`): its characters are complex script and read right to left. #[serde(skip_serializing_if = "Option::is_none")] pub rtl: Option, + /// Format the whole run with the complex-script properties below (OOXML `w:cs`). + #[serde(skip_serializing_if = "Option::is_none")] + pub cs: Option, + /// Complex-script font (Arabic, Persian, Hebrew…; OOXML `w:rFonts/@w:cs`). `None` = `font`. + #[serde(skip_serializing_if = "Option::is_none")] + pub font_cs: Option, + /// Complex-script size, points (`w:szCs`). `None` = the same as `size` (see [`CharProps::overlay`]). + #[serde(skip_serializing_if = "Option::is_none")] + pub size_cs: Option, + /// Complex-script bold / italic (`w:bCs`, `w:iCs`). `None` = the same as `bold` / `italic`. + #[serde(skip_serializing_if = "Option::is_none")] + pub bold_cs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub italic_cs: Option, + /// BCP 47 language of complex-script text (`w:lang/@w:bidi`, e.g. `fa-IR`). + #[serde(skip_serializing_if = "Option::is_none")] + pub lang_bidi: Option, /// Hyperlink target: a URL, or `#bookmark` for an internal link. #[serde(skip_serializing_if = "Option::is_none")] pub link: Option, @@ -307,10 +325,25 @@ macro_rules! overlay_fields { impl CharProps { /// Apply every `Some` field of `patch` on top of `self`. + /// + /// Size, bold and italic come in pairs with their complex-script values (Word's own buttons + /// set both): a patch that sets the plain value and leaves the complex-script one `None` sets + /// both, so a run's size or bold isn't overridden for its Persian/Arabic characters by an + /// inherited `w:szCs` / `w:bCs`. The complex-script font inherits on its own (Word often + /// sets only the Latin font and lets complex-script text keep the style's). pub fn overlay(&mut self, patch: &CharProps) { overlay_fields!(self, patch; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl, - link, ins, del); + cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, link, ins, del); + if patch.size.is_some() && patch.size_cs.is_none() { + self.size_cs = None; + } + if patch.bold.is_some() && patch.bold_cs.is_none() { + self.bold_cs = None; + } + if patch.italic.is_some() && patch.italic_cs.is_none() { + self.italic_cs = None; + } } pub fn overlaid(mut self, patch: &CharProps) -> CharProps { self.overlay(patch); @@ -337,6 +370,19 @@ pub enum Align { Distribute, } +impl Align { + /// Alignment is logical (`Left` is the start edge, ISO 29500 `start`): the alignment as seen + /// on the page, or the logical one for a visual choice, in a paragraph that reads right to + /// left when `rtl` (Left and Right swap; the swap is its own inverse). + pub fn visual(self, rtl: bool) -> Align { + match self { + Align::Left if rtl => Align::Right, + Align::Right if rtl => Align::Left, + a => a, + } + } +} + /// Line spacing rule. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "rule", content = "value")] diff --git a/crates/doc/src/resolve.rs b/crates/doc/src/resolve.rs index 87f02d0..d559d3a 100644 --- a/crates/doc/src/resolve.rs +++ b/crates/doc/src/resolve.rs @@ -41,6 +41,15 @@ pub struct ResolvedChar { pub del: Option, pub no_proof: bool, pub lang: Option, + /// Right-to-left run (`w:rtl`). + pub rtl: bool, + /// The whole run uses the complex-script properties (`w:cs`). + pub cs: bool, + /// Complex-script font, size, bold and italic (Arabic, Persian, Hebrew… text). + pub font_cs: String, + pub size_cs: f32, + pub bold_cs: bool, + pub italic_cs: bool, } impl ResolvedChar { @@ -74,8 +83,24 @@ impl ResolvedChar { del: c.del, no_proof: c.no_proof.unwrap_or(false), lang: c.lang.clone(), + rtl: c.rtl.unwrap_or(false), + cs: c.cs.unwrap_or(false), + font_cs: c.font_cs.clone().or_else(|| c.font.clone()).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()), + size_cs: c.size_cs.or(c.size).unwrap_or(11.0).clamp(1.0, 1638.0), + bold_cs: c.bold_cs.or(c.bold).unwrap_or(false), + italic_cs: c.italic_cs.or(c.italic).unwrap_or(false), } } + /// The formatting complex-script text in this run is drawn with: the complex-script font, + /// size, bold and italic in place of the others (Word formats Arabic, Persian and Hebrew + /// characters, and every character of an `rtl`/`cs` run, this way). + pub fn complex(&self) -> ResolvedChar { + ResolvedChar { font: self.font_cs.clone(), size: self.size_cs, bold: self.bold_cs, italic: self.italic_cs, ..self.clone() } + } + /// Does a character of this run use the complex-script properties? + pub fn uses_complex(&self, c: char) -> bool { + self.rtl || self.cs || crate::bidi::is_complex_script(c) + } /// The size glyphs are drawn at (super/subscript shrink to ~2/3). pub fn draw_size(&self) -> f32 { if self.vert_align == VertAlign::Baseline { self.size } else { self.size * 0.65 } @@ -120,6 +145,15 @@ pub struct ResolvedPara { const MAX_INDENT: f32 = 1584.0; // 22" +impl ResolvedPara { + /// Visual (left, right) indents. Indents, like alignment, are logical: `indent_left` is the + /// start (leading) edge, which is the right edge of a right-to-left paragraph (ISO 29500 + /// `w:ind/@w:start`, read from `w:left` too). + pub fn visual_indents(&self) -> (f32, f32) { + if self.bidi { (self.indent_right, self.indent_left) } else { (self.indent_left, self.indent_right) } + } +} + impl ResolvedPara { fn from(p: &ParaProps, style: &str) -> ResolvedPara { let clampi = |v: Option| v.unwrap_or(0.0).clamp(-MAX_INDENT, MAX_INDENT); diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index d3bf1e0..72b324d 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -55,9 +55,15 @@ impl PropCtx { .filter(|f| !f.is_empty()) .map(str::to_string) }); + // The complex-script font (Persian, Arabic, Hebrew text). A `w:cstheme` font is + // left to inherit: the theme's per-script fonts aren't read. + c.font_cs = k.attr("w:cs").filter(|f| !f.is_empty() && f.len() < 256).map(str::to_string); } "w:b" => c.bold = Some(on_off(k)), + "w:bCs" => c.bold_cs = Some(on_off(k)), "w:i" => c.italic = Some(on_off(k)), + "w:iCs" => c.italic_cs = Some(on_off(k)), + "w:cs" => c.cs = Some(on_off(k)), "w:caps" => c.caps = Some(on_off(k)), "w:smallCaps" => c.small_caps = Some(on_off(k)), "w:strike" => c.strike = Some(on_off(k)), @@ -81,6 +87,7 @@ impl PropCtx { "w:kern" => c.kern = k.attr("w:val").and_then(|v| measure(v, 2.0)).map(|v| v.clamp(0.0, 1638.0)), "w:position" => c.position = k.attr("w:val").and_then(|v| measure(v, 2.0)).map(|v| v.clamp(-1584.0, 1584.0)), "w:sz" => c.size = k.attr("w:val").and_then(|v| measure(v, 2.0)).filter(|v| *v > 0.0).map(|v| v.clamp(1.0, 1638.0)), + "w:szCs" => c.size_cs = k.attr("w:val").and_then(|v| measure(v, 2.0)).filter(|v| *v > 0.0).map(|v| v.clamp(1.0, 1638.0)), "w:highlight" => c.highlight = k.attr("w:val").map(Highlight::from_ooxml), "w:u" => { if let Some(v) = k.attr("w:val") { @@ -99,10 +106,24 @@ impl PropCtx { _ => VertAlign::Baseline, }) } - "w:lang" => c.lang = k.attr("w:val").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string), + "w:lang" => { + c.lang = k.attr("w:val").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string); + c.lang_bidi = k.attr("w:bidi").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string); + } _ => {} } } + // A complex-script value equal to its plain one is the same as none (see + // `CharProps::overlay`), which keeps documents round-tripping exactly. + if c.size_cs.is_some() && c.size_cs == c.size { + c.size_cs = None; + } + if c.bold_cs.is_some() && c.bold_cs == c.bold { + c.bold_cs = None; + } + if c.italic_cs.is_some() && c.italic_cs == c.italic { + c.italic_cs = None; + } c } diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index 8daec13..a58a51b 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -26,11 +26,19 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) { if let Some(s) = &c.style { w.val("w:rStyle", s); } - if let Some(f) = &c.font { - w.empty("w:rFonts", &[("w:ascii", f), ("w:hAnsi", f), ("w:cs", f)]); + // The complex-script font is written only when set: without it, Persian/Arabic text keeps + // the style's. Complex-script bold, italic and size default to the plain ones (they come in + // pairs, see `CharProps::overlay`), so text formatted here looks the same in Word. + match (&c.font, &c.font_cs) { + (Some(f), Some(cs)) => w.empty("w:rFonts", &[("w:ascii", f), ("w:hAnsi", f), ("w:cs", cs)]), + (Some(f), None) => w.empty("w:rFonts", &[("w:ascii", f), ("w:hAnsi", f)]), + (None, Some(cs)) => w.empty("w:rFonts", &[("w:cs", cs)]), + (None, None) => {} } toggle(w, "w:b", c.bold); + toggle(w, "w:bCs", c.bold_cs.or(c.bold)); toggle(w, "w:i", c.italic); + toggle(w, "w:iCs", c.italic_cs.or(c.italic)); toggle(w, "w:caps", c.caps); toggle(w, "w:smallCaps", c.small_caps); toggle(w, "w:strike", c.strike); @@ -58,10 +66,12 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) { if let Some(p) = c.position { w.val("w:position", &n(wordcraft_geom::to_half_points(p.clamp(-1584.0, 1584.0)))); } + let half = |s: f32| n(wordcraft_geom::to_half_points(s.clamp(1.0, 1638.0)).max(2)); if let Some(s) = c.size { - let hp = n(wordcraft_geom::to_half_points(s.clamp(1.0, 1638.0)).max(2)); - w.val("w:sz", &hp); - w.val("w:szCs", &hp); + w.val("w:sz", &half(s)); + } + if let Some(s) = c.size_cs.or(c.size) { + w.val("w:szCs", &half(s)); } if let Some(h) = c.highlight { w.val("w:highlight", if h == Highlight::None { "none" } else { h.ooxml() }); @@ -89,8 +99,12 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) { ); } toggle(w, "w:rtl", c.rtl); - if let Some(l) = &c.lang { - w.val("w:lang", l); + toggle(w, "w:cs", c.cs); + match (&c.lang, &c.lang_bidi) { + (Some(l), Some(b)) => w.empty("w:lang", &[("w:val", l), ("w:bidi", b)]), + (Some(l), None) => w.val("w:lang", l), + (None, Some(b)) => w.empty("w:lang", &[("w:bidi", b)]), + (None, None) => {} } } diff --git a/crates/docx/tests/bidi.rs b/crates/docx/tests/bidi.rs new file mode 100644 index 0000000..d0719b5 --- /dev/null +++ b/crates/docx/tests/bidi.rs @@ -0,0 +1,141 @@ +//! Right-to-left and complex-script OOXML: `w:bidi` paragraphs, `w:rtl` / `w:cs` runs, the +//! complex-script font, size, bold and italic, and the bidi language. Fixtures are hand-written +//! from ECMA-376 Part 1 §17.3.1.6 (bidi), §17.3.2 (run properties). + +use std::io::{Read, Write}; + +use wordcraft_doc::props::{Align, CharProps, ParaProps}; +use wordcraft_doc::{Document, Paragraph, para_block}; + +fn zip(entries: &[(&str, &[u8])]) -> Vec { + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in entries { + zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +/// A package whose body is `body`. +fn docx(body: &str) -> Vec { + let root = r#""#; + let doc = format!( + r#"{body}"# + ); + zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())]) +} + +fn paras(d: &Document) -> Vec<&Paragraph> { + d.body.iter().filter_map(|b| b.as_para()).collect() +} + +/// `word/document.xml` of a written package. +fn document_xml(bytes: &[u8]) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut s = String::new(); + z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap(); + s +} + +/// Persian text with a ZWNJ (نیم‌فاصله), Persian digits, Latin and punctuation. +const FA: &str = "می‌خواهم نسخهٔ ۲٫۵ از WordCraft را (امروز) نصب کنم."; + +fn persian_run() -> CharProps { + CharProps { + rtl: Some(true), + font_cs: Some("B Nazanin".into()), + size_cs: Some(14.0), + bold_cs: Some(true), + italic_cs: Some(false), + lang_bidi: Some("fa-IR".into()), + ..Default::default() + } +} + +#[test] +fn persian_paragraph_round_trips() { + let mut rtl = Paragraph::with_text(FA, persian_run()); + rtl.props = ParaProps { bidi: Some(true), align: Some(Align::Justify), ..Default::default() }; + let ltr = Paragraph::with_text("English with فارسی inside", CharProps { cs: Some(true), ..Default::default() }); + let mut d = Document::new(); + d.body = vec![para_block(rtl.clone()), para_block(ltr.clone())]; + let bytes = wordcraft_docx::write(&d).unwrap(); + let r = wordcraft_docx::read(&bytes).unwrap(); + let ps = paras(&r); + assert_eq!(ps[0].text, FA, "Persian text, ZWNJ and digits survive byte for byte"); + assert_eq!(ps[0].props.bidi, Some(true)); + assert_eq!(ps[0].props.align, Some(Align::Justify)); + assert_eq!(ps[0].runs[0].props, persian_run()); + assert_eq!(ps[1].text, ltr.text); + assert_eq!(ps[1].props.bidi, None); + assert_eq!(ps[1].runs[0].props.cs, Some(true)); +} + +#[test] +fn bidi_markup_is_written_in_schema_order() { + let mut p = + Paragraph::with_text(FA, CharProps { bold: Some(true), italic: Some(true), size: Some(12.0), font: Some("Arial".into()), ..persian_run() }); + p.props.bidi = Some(true); + let mut d = Document::new(); + d.body = vec![para_block(p)]; + let xml = document_xml(&wordcraft_docx::write(&d).unwrap()); + assert!(xml.contains(""), "paragraph direction: {xml}"); + assert!(xml.contains(r#"w:cs="B Nazanin""#), "complex-script font"); + assert!(xml.contains(r#""#), "complex-script size in half points"); + assert!(xml.contains(r#"w:bidi="fa-IR""#), "bidi language"); + // CT_RPr is a sequence: b, bCs, i, iCs … sz, szCs … rtl, cs, lang. + let pos = |t: &str| xml.find(t).unwrap_or_else(|| panic!("{t} missing: {xml}")); + assert!(pos("")); + assert!(pos("") < pos("") && pos("") < pos("")); + assert!(pos("") < pos(r#""#)); + assert!(pos(r#""#) < pos("") < pos(" + + سلام دنیا + + Word 2025 + +x"#; + let d = wordcraft_docx::read(&docx(body)).unwrap(); + let ps = paras(&d); + assert_eq!(ps[0].text, "سلام دنیا Word 2025"); + assert_eq!(ps[0].props.bidi, Some(true)); + assert_eq!(ps[0].props.indent_left, Some(36.0), "w:left is the start indent"); + assert_eq!(ps[0].props.align, Some(Align::Left), "jc=left is the start edge"); + let fa = &ps[0].runs[0].props; + assert_eq!(fa.font.as_deref(), Some("Times New Roman")); + assert_eq!(fa.font_cs.as_deref(), Some("B Lotus")); + assert_eq!((fa.size, fa.size_cs), (Some(12.0), Some(16.0))); + // b + bCs alike is stored once: an unset complex-script bold is the same as `bold`. + assert_eq!((fa.bold, fa.bold_cs, fa.rtl), (Some(true), None, Some(true))); + assert_eq!((fa.lang.as_deref(), fa.lang_bidi.as_deref()), (Some("en-US"), Some("fa-IR"))); + assert_eq!(ps[1].props.bidi, Some(false)); + let x = &ps[1].runs[0].props; + assert_eq!((x.cs, x.italic_cs, x.italic), (Some(true), Some(true), None)); + // The resolved complex-script formatting is what Persian characters are drawn with. + let rc = d.styles.resolve_char(None, fa); + let cs = rc.complex(); + assert_eq!((cs.font.as_str(), cs.size, cs.bold), ("B Lotus", 16.0, true)); + assert!(rc.uses_complex('س') && rc.uses_complex('W'), "every character of an rtl run is complex script"); +} + +#[test] +fn hostile_bidi_markup_is_bounded() { + let long = "x".repeat(10_000); + let body = format!( + r#"ok"# + ); + let d = wordcraft_docx::read(&docx(&body)).unwrap(); + let c = ¶s(&d)[0].runs[0].props; + assert_eq!(c.font_cs, None, "absurd font names are dropped"); + assert_eq!(c.lang_bidi, None); + assert!(c.size_cs.is_none_or(|s| (1.0..=1638.0).contains(&s))); +} diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index f756bda..adcf892 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -82,6 +82,12 @@ fn every_char_prop_round_trips() { lang: Some("fr-FR".into()), no_proof: Some(true), rtl: Some(false), + cs: Some(true), + font_cs: Some("B Nazanin".into()), + size_cs: Some(13.0), + bold_cs: Some(false), + italic_cs: Some(true), + lang_bidi: Some("fa-IR".into()), link: None, ins: None, del: None, diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 04e2205..adbd11d 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -104,7 +104,8 @@ Home|Paragraph|Page Break Before|para.pageBreakBefore Home|Paragraph|Widow/Orphan Control|para.widowControl Home|Paragraph|Outline Level|para.outlineLevel Home|Paragraph|Hanging Indent|para.hangingIndent -Home|Paragraph|Text Direction RTL|para.rtl +Home|Paragraph|Right-to-Left Text Direction|para.rtl +Home|Paragraph|Left-to-Right Text Direction|para.ltr Home|Paragraph|Asian Typography|para.asianTypography Home|Styles|Styles Gallery|para.style Home|Styles|Normal|para.normal diff --git a/crates/engine/src/cmd/caret.rs b/crates/engine/src/cmd/caret.rs index d548d92..509afe8 100644 --- a/crates/engine/src/cmd/caret.rs +++ b/crates/engine/src/cmd/caret.rs @@ -14,8 +14,12 @@ pub fn specs() -> Vec { CommandSpec::new("caret.right", "Right", "Navigation", |s, v| lr(s, v, false)).key("Right").params(EXTEND).pure(), CommandSpec::new("caret.up", "Up", "Navigation", |s, v| vert(s, v, -1)).key("Up").params(EXTEND).pure(), CommandSpec::new("caret.down", "Down", "Navigation", |s, v| vert(s, v, 1)).key("Down").params(EXTEND).pure(), - CommandSpec::new("caret.wordLeft", "Word Left", "Navigation", |s, v| mv(s, v, word_left)).key("Mod+Left / Alt+Left").params(EXTEND).pure(), - CommandSpec::new("caret.wordRight", "Word Right", "Navigation", |s, v| mv(s, v, word_right)) + // In a right-to-left paragraph the word to the left is the next one. + CommandSpec::new("caret.wordLeft", "Word Left", "Navigation", |s, v| mv(s, v, if rtl_para(s) { word_right } else { word_left })) + .key("Mod+Left / Alt+Left") + .params(EXTEND) + .pure(), + CommandSpec::new("caret.wordRight", "Word Right", "Navigation", |s, v| mv(s, v, if rtl_para(s) { word_left } else { word_right })) .key("Mod+Right / Alt+Right") .params(EXTEND) .pure(), @@ -79,17 +83,64 @@ fn mv(s: &mut Session, v: &Value, f: fn(&mut Session, &Pos) -> Pos) -> CmdResult sel_result(s) } +/// Does the caret's paragraph read right to left? +fn rtl_para(s: &Session) -> bool { + s.doc.para_at(&s.sel.focus).is_some_and(|p| s.doc.styles.resolve_para(&p.props).bidi) +} + +/// Is the caret's paragraph bidirectional (right to left, or with right-to-left text)? Arrow keys +/// move visually there. +fn bidi_para(s: &Session) -> bool { + s.doc.para_at(&s.sel.focus).is_some_and(|p| wordcraft_doc::bidi::has_rtl(&p.text) || s.doc.styles.resolve_para(&p.props).bidi) +} + /// Left/Right: with a selection and no Shift, collapse to its edge. fn lr(s: &mut Session, v: &Value, is_left: bool) -> CmdResult { if !extend(v) && !s.sel.is_collapsed() { let (a, b) = s.sel.ordered(); - s.sel = Selection::caret(if is_left { a } else { b }); + // The left edge of a selection in right-to-left text is its logical end. + let to_start = is_left != rtl_para(s); + s.sel = Selection::caret(if to_start { a } else { b }); s.goal_x = None; return sel_result(s); } + if bidi_para(s) { + return visual_lr(s, v, is_left); + } mv(s, v, if is_left { left } else { right }) } +/// Left/Right in bidirectional text: one caret position to the left or right on the screen. +/// Past the line's visual end the caret goes to the neighbouring line in reading order (the +/// previous line when moving toward the paragraph's start edge). +fn visual_lr(s: &mut Session, v: &Value, is_left: bool) -> CmdResult { + let ext = extend(v); + s.goal_x = None; + let from = s.sel.focus.clone(); + let l = s.layout(); + let to = match l.visual_step(&from, is_left, s.page_hint) { + Some(wordcraft_layout::VisualStep::Moved(p)) => p, + Some(wordcraft_layout::VisualStep::Edge { start, stop, last_line, rtl }) => { + let at = |off| Pos { off, ..from.clone() }; + if is_left != rtl { + match s.doc.para_at(&from) { + Some(para) if start > 0 => at(para.prev_boundary(start)), + _ => left(s, &at(0)), + } + } else if !last_line { + at(stop) + } else { + let len = s.doc.para_at(&from).map_or(stop, |p| p.len()); + right(s, &at(len)) + } + } + None if is_left => left(s, &from), + None => right(s, &from), + }; + apply(s, to, ext); + sel_result(s) +} + fn left(s: &mut Session, p: &Pos) -> Pos { if let Some(para) = s.doc.para_at(p) && p.off > 0 diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 8f596c3..0c373c2 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -13,12 +13,34 @@ pub const SIZES: [f32; 17] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0 pub fn specs() -> Vec { vec![ - CommandSpec::new("format.bold", "Bold", "Home › Font", |s, v| toggle(s, v, |r| r.bold, |c, on| c.bold = Some(on))) - .key("Mod+B") - .params(r#"{"value"?: bool}"#), - CommandSpec::new("format.italic", "Italic", "Home › Font", |s, v| toggle(s, v, |r| r.italic, |c, on| c.italic = Some(on))) - .key("Mod+I") - .params(r#"{"value"?: bool}"#), + CommandSpec::new("format.bold", "Bold", "Home › Font", |s, v| { + // Like Word's button, for complex-script (Persian, Arabic) text too: an unset `bold_cs` + // follows `bold`. + toggle( + s, + v, + |r| r.bold, + |c, on| { + c.bold = Some(on); + c.bold_cs = None; + }, + ) + }) + .key("Mod+B") + .params(r#"{"value"?: bool}"#), + CommandSpec::new("format.italic", "Italic", "Home › Font", |s, v| { + toggle( + s, + v, + |r| r.italic, + |c, on| { + c.italic = Some(on); + c.italic_cs = None; + }, + ) + }) + .key("Mod+I") + .params(r#"{"value"?: bool}"#), CommandSpec::new("format.underline", "Underline", "Home › Font", underline) .key("Mod+U") .params(r#"{"value"?: bool, "style"?: "single|double|thick|dotted|dash|dotDash|dotDotDash|wave|words"}"#), @@ -214,7 +236,11 @@ fn font(s: &mut Session, v: &Value) -> CmdResult { if name.is_empty() || name.len() > 128 { return Err(CmdError::Params("bad font name".into())); } - apply(s, &|c| c.font = Some(name.clone())) + // The font applies to Persian/Arabic text in the selection too (its complex-script font). + apply(s, &|c| { + c.font = Some(name.clone()); + c.font_cs = Some(name.clone()); + }) } fn size(s: &mut Session, v: &Value) -> CmdResult { @@ -224,7 +250,10 @@ fn size(s: &mut Session, v: &Value) -> CmdResult { } // Word rounds to half points. let sz = (sz * 2.0).round() / 2.0; - apply(s, &|c| c.size = Some(sz)) + apply(s, &|c| { + c.size = Some(sz); + c.size_cs = None; + }) } fn current_size(s: &Session) -> f32 { @@ -240,12 +269,18 @@ fn step_size(s: &mut Session, dir: i32) -> CmdResult { }; let next = next.clamp(1.0, 1638.0); // Each run steps from its own size when the selection mixes sizes. - apply(s, &|c| c.size = Some(next)) + apply(s, &|c| { + c.size = Some(next); + c.size_cs = None; + }) } fn nudge_size(s: &mut Session, d: f32) -> CmdResult { let next = (current_size(s) + d).clamp(1.0, 1638.0); - apply(s, &|c| c.size = Some(next)) + apply(s, &|c| { + c.size = Some(next); + c.size_cs = None; + }) } fn color(s: &mut Session, v: &Value) -> CmdResult { diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index 8a1dba3..696f8f1 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -23,11 +23,19 @@ pub fn specs() -> Vec { "right" => Align::Right, "justify" | "both" => Align::Justify, "distribute" => Align::Distribute, + // Logical edges, whatever the paragraph's direction. + "start" => return fmt(s, &|p| p.align = Some(Align::Left)), + "end" => return fmt(s, &|p| p.align = Some(Align::Right)), x => return Err(CmdError::Params(format!("unknown alignment `{x}`"))), }; - align(s, a) + // An explicit value sets it (no toggling back to the start edge like the buttons). + set_align(s, a) }) - .params(r#"{"value": "left|center|right|justify|distribute"}"#), + .params(r#"{"value": "left|center|right|justify|distribute (as seen on the page) | start|end (reading direction)"}"#), + CommandSpec::new("para.rtl", "Right-to-Left Text Direction", "Home › Paragraph", |s, v| direction(s, v, true)) + .params(r#"{"value"?: bool (false = left to right)}"#), + CommandSpec::new("para.ltr", "Left-to-Right Text Direction", "Home › Paragraph", |s, v| direction(s, v, false)) + .params(r#"{"value"?: bool (false = right to left)}"#), CommandSpec::new("para.indent", "Increase Indent", "Home › Paragraph", indent).key("Mod+M"), CommandSpec::new("para.outdent", "Decrease Indent", "Home › Paragraph", outdent).key("Mod+Shift+M"), CommandSpec::new("para.hangingIndent", "Hanging Indent", "Home › Paragraph › Paragraph", |s, _| { @@ -193,11 +201,33 @@ pub fn fmt(s: &mut Session, f: &dyn Fn(&mut ParaProps)) -> CmdResult { sel_result(s) } +/// `a` is the alignment as seen on the page (the buttons): in a right-to-left paragraph +/// Align Left is its end edge. fn align(s: &mut Session, a: Align) -> CmdResult { - // Clicking the active alignment again returns to left (Word). - let now = cur(s).align; - let target = if now == a && a != Align::Left { Align::Left } else { a }; - fmt(s, &|p| p.align = Some(target)) + let r = cur(s); + // Clicking the active alignment again returns to the start edge (Word). + let logical = a.visual(r.bidi); + let target = if r.align == logical && logical != Align::Left { None } else { Some(a) }; + let focus_rtl = r.bidi; + fmt(s, &|p| { + let rtl = p.bidi.unwrap_or(focus_rtl); + p.align = Some(target.map_or(Align::Left, |a| a.visual(rtl))); + }) +} + +/// Set the alignment as seen on the page in every selected paragraph, in its own direction. +fn set_align(s: &mut Session, a: Align) -> CmdResult { + let focus_rtl = cur(s).bidi; + fmt(s, &|p| p.align = Some(a.visual(p.bidi.unwrap_or(focus_rtl)))) +} + +/// Paragraph reading order (Word's Right-to-Left / Left-to-Right Text Direction buttons). Only +/// the direction changes: alignment and indents are logical, so a start-aligned paragraph moves +/// to the other side, as in Word. `rtl` is what the command turns on; `value: false` turns it off. +fn direction(s: &mut Session, v: &Value, rtl: bool) -> CmdResult { + let on = p::bool(v, "value").unwrap_or(true); + let want = if on { rtl } else { !rtl }; + fmt(s, &|p| p.bidi = Some(want)) } fn tog(s: &mut Session, v: &Value, get: fn(&ParaProps) -> Option, set: fn(&mut ParaProps, bool)) -> CmdResult { @@ -306,11 +336,15 @@ fn apply_style(s: &mut Session, v: &Value) -> CmdResult { let len = para.len(); para.format(0, len, &|c| { c.font = None; + c.font_cs = None; c.size = None; + c.size_cs = None; c.color = None; })?; para.mark.font = None; + para.mark.font_cs = None; para.mark.size = None; + para.mark.size_cs = None; para.mark.color = None; } } diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 85163f9..7ec80f8 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -353,13 +353,6 @@ pub fn specs() -> Vec { }) }) .params(r#"{"format": "decimal|upperRoman|lowerLetter…", "text"?: "%1.", "start"?: n}"#), - CommandSpec::new("para.rtl", "Right-to-Left Text Direction", "Home › Paragraph", |s, v| { - let on = p::bool(v, "value").unwrap_or_else(|| !s.doc.para_at(&s.sel.focus).and_then(|x| x.props.bidi).unwrap_or(false)); - super::para::fmt(s, &|pp| { - pp.bidi = Some(on); - pp.align = Some(if on { wordcraft_doc::Align::Right } else { wordcraft_doc::Align::Left }); - }) - }), CommandSpec::new("review.language", "Language", "Review › Language", |s, v| { let lang = p::str(v, "lang").unwrap_or("en-US").to_string(); let no_proof = p::bool(v, "noProof"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index de4fe41..752f92c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -156,4 +156,6 @@ pub mod p { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_bidi; +#[cfg(test)] mod tests_typing; diff --git a/crates/engine/src/tests_bidi.rs b/crates/engine/src/tests_bidi.rs new file mode 100644 index 0000000..e7151a5 --- /dev/null +++ b/crates/engine/src/tests_bidi.rs @@ -0,0 +1,185 @@ +//! Right-to-left editing commands: paragraph direction, alignment as seen on the page, font +//! commands on Persian text, arrow keys in bidirectional text, DOCX save and open. + +use serde_json::json; +use wordcraft_doc::props::Align; +use wordcraft_doc::{Pos, StoryRef}; + +use crate::Session; + +fn run(s: &mut Session, id: &str, v: serde_json::Value) -> serde_json::Value { + s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +/// A session with `text` (paragraphs split on `\n`), every paragraph right to left when `rtl`. +fn session(text: &str, rtl: bool) -> Session { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "document.setText", json!({"text": text})); + if rtl { + run(&mut s, "select.all", json!({})); + run(&mut s, "para.rtl", json!({})); + run(&mut s, "caret.docStart", json!({})); + } + s +} + +fn props(s: &Session, i: usize) -> wordcraft_doc::props::ParaProps { + s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(i)).unwrap().props.clone() +} + +fn caret_x(s: &mut Session) -> f32 { + let l = s.layout(); + l.caret(&s.sel.focus).unwrap().x +} + +#[test] +fn direction_commands_change_only_the_reading_order() { + let mut s = session("سلام دنیا\nHello", false); + run(&mut s, "para.alignCenter", json!({})); + run(&mut s, "para.rtl", json!({})); + assert_eq!(props(&s, 0).bidi, Some(true)); + assert_eq!(props(&s, 0).align, Some(Align::Center), "direction doesn't touch alignment"); + // Left-to-right again, and `value: false` means the other direction. + run(&mut s, "para.ltr", json!({})); + assert_eq!(props(&s, 0).bidi, Some(false)); + run(&mut s, "para.ltr", json!({"value": false})); + assert_eq!(props(&s, 0).bidi, Some(true)); + // A new right-to-left paragraph starts at the right margin (start-aligned). + let mut s = session("متن", true); + assert!((caret_x(&mut s) - 540.0).abs() < 0.5, "caret at {}", caret_x(&mut s)); +} + +#[test] +fn alignment_buttons_mean_what_they_show_in_rtl_paragraphs() { + let mut s = session("متن فارسی", true); + // Align Left in a right-to-left paragraph moves text to the left: its end edge. + run(&mut s, "para.alignLeft", json!({})); + assert_eq!(props(&s, 0).align, Some(Align::Right)); + let x_end = s.layout().caret(&Pos::body(0, "متن فارسی".len())).unwrap().x; + assert!((x_end - 72.0).abs() < 0.5, "text ends at the left margin: {x_end}"); + // Align Right is the start edge; pressing it again keeps it there. + run(&mut s, "para.alignRight", json!({})); + assert_eq!(props(&s, 0).align, Some(Align::Left)); + run(&mut s, "para.alignRight", json!({})); + assert_eq!(props(&s, 0).align, Some(Align::Left)); + // Logical values for scripts. + run(&mut s, "para.align", json!({"value": "end"})); + assert_eq!(props(&s, 0).align, Some(Align::Right)); + run(&mut s, "para.align", json!({"value": "right"})); + assert_eq!(props(&s, 0).align, Some(Align::Left)); +} + +#[test] +fn font_commands_reach_persian_text() { + let mut s = session("کتاب خوب", true); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.bold", json!({})); + run(&mut s, "format.size", json!({"size": 18})); + run(&mut s, "format.font", json!({"name": "Vazirmatn"})); + let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap(); + let c = p.props_of_char(0).clone(); + let rc = s.doc.styles.resolve_char(None, &c).complex(); + assert_eq!((rc.font.as_str(), rc.size, rc.bold), ("Vazirmatn", 18.0, true), "the complex-script formatting follows"); + // Saved and reopened, Word sees bold and size for the Persian text too. + let bytes = crate::io::save_bytes("x.docx", &s.doc).unwrap(); + let d = crate::io::open_bytes("x.docx", &bytes).unwrap(); + let c = d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props_of_char(0).clone(); + let rc = d.styles.resolve_char(None, &c).complex(); + assert_eq!((rc.font.as_str(), rc.size, rc.bold), ("Vazirmatn", 18.0, true)); + assert_eq!(d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.bidi, Some(true)); +} + +#[test] +fn arrow_keys_move_visually_in_rtl_text() { + let fa = "سلام دنیا"; + let mut s = session(fa, true); + // From the start (the right edge), Left moves forward through the text. + let x0 = caret_x(&mut s); + run(&mut s, "caret.left", json!({})); + assert!(s.sel.focus.off > 0, "Left moves to the next character in right-to-left text"); + assert!(caret_x(&mut s) < x0, "and the caret moves left on the screen"); + run(&mut s, "caret.right", json!({})); + assert_eq!(s.sel.focus.off, 0); + // Right at the start edge goes nowhere in the first paragraph (no previous one). + run(&mut s, "caret.right", json!({})); + assert_eq!(s.sel.focus.off, 0); + // Left all the way reaches the end, then the next paragraph. + let mut s = session(&format!("{fa}\nبعدی"), true); + // (Stepping until the paragraph changes: the لا ligature is one caret position for two letters.) + let mut steps = 0; + while s.sel.focus.path.last() == 0 && steps < 40 { + run(&mut s, "caret.left", json!({})); + steps += 1; + } + assert!(steps <= fa.chars().count() + 1, "{steps} steps"); + assert_eq!(s.sel.focus.path.last(), 1, "past the left end of the line: the next paragraph"); + assert_eq!(s.sel.focus.off, 0, "at its start (right edge)"); + // Shift+Left selects forward in logical order. + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "caret.left", json!({"extend": true})); + let (a, b) = s.sel.ordered(); + assert_eq!((a.off, b.off > 0), (0, true)); + // Left with a selection collapses to its left edge: the logical end. + run(&mut s, "caret.left", json!({})); + assert_eq!(s.sel.focus.off, b.off); +} + +#[test] +fn arrow_keys_in_mixed_text_visit_each_position() { + // A left-to-right paragraph with a Persian word: Right walks across the screen. + let t = "ab سلام cd"; + let mut s = session(t, false); + let mut xs = vec![caret_x(&mut s)]; + for _ in 0..30 { + let before = s.sel.focus.clone(); + run(&mut s, "caret.right", json!({})); + if s.sel.focus == before { + break; + } + xs.push(caret_x(&mut s)); + } + assert!(xs.windows(2).all(|w| w[1] > w[0]), "each Right moves right: {xs:?}"); + assert_eq!(s.sel.focus.off, t.len(), "ends at the end of the paragraph"); +} + +#[test] +fn word_moves_follow_reading_order() { + let mut s = session("یک دو سه", true); + run(&mut s, "caret.wordLeft", json!({})); + assert!(s.sel.focus.off > 0, "Ctrl+Left goes to the next word in right-to-left text"); + run(&mut s, "caret.wordRight", json!({})); + assert_eq!(s.sel.focus.off, 0); +} + +#[test] +fn typing_persian_keeps_logical_order() { + let mut s = session("", true); + for w in ["سلام", " ", "WordCraft", " ", "۱۴۰۳"] { + run(&mut s, "text.insert", json!({"text": w})); + } + assert_eq!(s.doc.plain_text(StoryRef::Body), "سلام WordCraft ۱۴۰۳"); + // The caret after typing a number at the end sits right after it on the screen. + let l = s.layout(); + let c = l.caret(&s.sel.focus).unwrap(); + assert!(c.x < 540.0 && c.x > 72.0); + // select.text finds Persian text. + run(&mut s, "select.text", json!({"text": "سلام"})); + assert_eq!(s.selected_text(), "سلام"); +} + +#[test] +fn explicit_alignment_sets_without_toggling() { + for rtl in [false, true] { + let mut s = session("متن", rtl); + for v in ["center", "center", "right", "right", "left", "left"] { + run(&mut s, "para.align", json!({"value": v})); + let shown = props(&s, 0).align.unwrap_or_default().visual(rtl); + let want = match v { + "center" => Align::Center, + "right" => Align::Right, + _ => Align::Left, + }; + assert_eq!(shown, want, "{v} twice stays {v} (rtl={rtl})"); + } + } +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index ed589d6..de2b2f5 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -242,6 +242,26 @@ struct SysFallback { misses: std::collections::HashSet, } +/// Families preferred for right-to-left scripts (Persian, Arabic, Urdu, Hebrew) when the +/// document's font lacks them: open fonts with full Persian coverage first (the Persian-only +/// letters پ چ ژ گ, the Persian digits), then the platforms' own. +pub const RTL_FALLBACKS: &[&str] = &[ + "Vazirmatn", + "Noto Naskh Arabic", + "Noto Sans Arabic", + "Noto Sans Arabic UI", + "Sahel", + "Shabnam", + "Samim", + "Noto Sans Hebrew", + "Segoe UI", + "Tahoma", + "Geeza Pro", + "SF Arabic", + "Arial", + "DejaVu Sans", +]; + /// Families tried (when installed) for characters the loaded fonts lack: CJK, symbols, emoji. #[cfg(not(target_arch = "wasm32"))] const SYSTEM_FALLBACKS: &[&str] = &[ @@ -272,6 +292,12 @@ const SYSTEM_FALLBACKS: &[&str] = &[ "Noto Color Emoji", ]; +/// Arabic-script marks and digits (bidi classes NSM / AN): they belong with the Arabic letters +/// around them when choosing a fallback font. +fn arabic_block(c: char) -> bool { + matches!(u32::from(c), 0x0600..=0x06FF | 0x0750..=0x077F | 0x08A0..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFF) +} + static NEXT_ID: AtomicU32 = AtomicU32::new(1); const OUTLINE_CACHE_MAX: usize = 50_000; @@ -811,20 +837,44 @@ impl FontDb { /// First face (fallback family first, then load order) that covers `c`; on native, system /// fonts are cataloged and loaded lazily the first time no loaded face covers a character. pub fn fallback_for(&self, c: char, exclude: u32) -> Option> { - if let Some(f) = self.loaded_fallback(c, exclude) { - return Some(f); + self.fallback_styled(c, exclude, false, false) + } + + /// [`Self::fallback_for`], preferring a bold and/or italic face of the chosen family (so + /// bold Persian text in a font without Persian letters stays bold). + pub fn fallback_styled(&self, c: char, exclude: u32, bold: bool, italic: bool) -> Option> { + let base = self.loaded_fallback(c, exclude).or_else(|| { + #[cfg(not(target_arch = "wasm32"))] + if self.system_fallback(c) { + return self.loaded_fallback(c, exclude); + } + None + })?; + if !bold && !italic { + return Some(base); } - #[cfg(not(target_arch = "wasm32"))] - if self.system_fallback(c) { - return self.loaded_fallback(c, exclude); - } - None + let style = match (bold, italic) { + (true, true) => "Bold Italic", + (true, false) => "Bold", + _ => "Italic", + }; + let styled = self.face(&base.family, style); + Some(if styled.family.eq_ignore_ascii_case(&base.family) && styled.covers(c) && styled.id != exclude { styled } else { base }) } fn loaded_fallback(&self, c: char, exclude: u32) -> Option> { let faces = self.read_faces(); let mut order: Vec<&Arc> = faces.iter().filter(|f| f.id != exclude).collect(); - order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32)); + // Right-to-left letters prefer a font made for the script, so a word isn't split across + // fonts (that breaks Arabic joining). + let rank = |f: &FontFace| { + if crate::is_rtl(c) || arabic_block(c) { + RTL_FALLBACKS.iter().position(|n| f.family.eq_ignore_ascii_case(n)).unwrap_or(RTL_FALLBACKS.len()) + } else { + 0 + } + }; + order.sort_by_key(|f| (rank(f), !f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32)); order.into_iter().find(|f| f.covers(c)).cloned() } @@ -843,7 +893,9 @@ impl FontDb { } let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c)); let cataloged: Vec = self.read_catalog().iter().map(|e| e.family.clone()).collect(); - for fam in SYSTEM_FALLBACKS { + let rtl = crate::is_rtl(c) || arabic_block(c); + let preferred = RTL_FALLBACKS.iter().filter(|_| rtl); + for fam in preferred.chain(SYSTEM_FALLBACKS.iter()) { if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) { self.load_cataloged(fam); if covered(self) { diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 09c7d27..5d0ca4f 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -143,27 +143,37 @@ pub fn shape(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(cha } let mut out = Vec::with_capacity(text.len()); for (r, rtl) in direction_runs(text) { - let mut g = shape_dir(face, &text[r.clone()], features, &map, rtl); + let Some(sub) = text.get(r.clone()) else { continue }; + let mut g = shape_run(face, sub, features, &map, rtl); for x in &mut g { x.cluster += r.start; } - if rtl { - // Visual (clusters descending) → logical, cluster by cluster. - let mut groups: Vec> = Vec::new(); - for x in g { - match groups.last_mut() { - Some(last) if last[0].cluster == x.cluster => last.push(x), - _ => groups.push(vec![x]), - } - } - groups.sort_by_key(|grp| grp[0].cluster); - g = groups.into_iter().flatten().collect(); - } out.extend(g); } out } +/// Shape `text` as one run in a known direction (the caller ran the bidi algorithm: `rtl` is an +/// odd embedding level). Right-to-left runs get Arabic joining, mirrored brackets and right-to-left +/// mark positioning. Glyphs come back in logical order like [`shape`]: clusters in text order, +/// each cluster's glyphs in the shaper's (visual) order. +pub fn shape_run(face: &FontFace, text: &str, features: &[Feature], map: impl Fn(char) -> char, rtl: bool) -> Vec { + let g = shape_dir(face, text, features, &map, rtl); + if !rtl { + return g; + } + // Visual (clusters descending) → logical, cluster by cluster. + let mut groups: Vec> = Vec::new(); + for x in g { + match groups.last_mut() { + Some(last) if last.first().is_some_and(|f| f.cluster == x.cluster) => last.push(x), + _ => groups.push(vec![x]), + } + } + groups.sort_by_key(|grp| grp.first().map_or(0, |f| f.cluster)); + groups.into_iter().flatten().collect() +} + fn shape_dir(face: &FontFace, text: &str, features: &[Feature], map: &impl Fn(char) -> char, rtl: bool) -> Vec { let mut out = Vec::with_capacity(text.len()); let shaped = face.hb().map(|hb| { diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index 55630dc..5357d10 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -31,7 +31,18 @@ fn substitutes(family: &str) -> &'static [&'static str] { const ARIAL: &[&str] = &["Liberation Sans", "Helvetica", "Helvetica Neue", "Arimo", "Source Sans 3"]; const TIMES: &[&str] = &["Liberation Serif", "Times", "Tinos", "Source Serif 4"]; const SYMBOL: &[&str] = &["Apple Symbols", "Segoe UI Symbol", "DejaVu Sans", "Noto Sans Symbols", "Source Sans 3"]; + // Persian / Arabic document fonts. Naskh (book) faces such as B Nazanin, B Lotus, B Zar and + // Traditional Arabic, then the sans faces (B Yekan, B Titr, IRANSans) people use for + // headings and screens. Open fonts with full Persian coverage first. + const NASKH: &[&str] = &["Noto Naskh Arabic", "XB Zar", "XB Niloofar", "Amiri", "Vazirmatn", "Noto Sans Arabic", "DejaVu Sans", "Source Serif 4"]; + const ARABIC_SANS: &[&str] = &["Vazirmatn", "Sahel", "Shabnam", "Samim", "Noto Sans Arabic", "Noto Naskh Arabic", "DejaVu Sans", "Source Sans 3"]; let f = family.to_ascii_lowercase(); + if is_persian_naskh(&f) { + return NASKH; + } + if is_persian_sans(&f) { + return ARABIC_SANS; + } match f.as_str() { "arial" | "helvetica" | "arial nova" => ARIAL, "times new roman" | "times" => TIMES, @@ -57,6 +68,86 @@ fn substitutes(family: &str) -> &'static [&'static str] { } } +/// Naskh-style Persian/Arabic document fonts (the "B …" family names come from the common +/// Persian font packs; matched by name only). +fn is_persian_naskh(f: &str) -> bool { + const NAMES: &[&str] = &[ + "b nazanin", + "b lotus", + "b zar", + "b mitra", + "b badr", + "b yagut", + "b roya", + "b compset", + "b nazanin bold", + "b lotus bold", + "nazanin", + "lotus", + "zar", + "mitra", + "badr", + "yagut", + "roya", + "ir nazanin", + "ir lotus", + "ir zar", + "ir mitra", + "ir badr", + "irnazanin", + "irlotus", + "irzar", + "irmitra", + "irbadr", + "traditional arabic", + "simplified arabic", + "arabic typesetting", + "sakkal majalla", + "adobe arabic", + "andalus", + "times new roman (arabic)", + ]; + NAMES.contains(&f) +} + +/// Sans Persian/Arabic fonts for headings and screens. +fn is_persian_sans(f: &str) -> bool { + const NAMES: &[&str] = &[ + "b yekan", + "b titr", + "b homa", + "b koodak", + "b traffic", + "b jadid", + "b elham", + "b kamran", + "b davat", + "b ferdosi", + "yekan", + "titr", + "homa", + "koodak", + "traffic", + "ir titr", + "ir homa", + "irtitr", + "irhoma", + "iransans", + "iransansweb", + "iran sans", + "iranyekan", + "iran yekan", + "iranyekanweb", + "dana", + "estedad", + "dubai", + "aldhabi", + "urdu typesetting", + "arabic transparent", + ]; + NAMES.contains(&f) || f.starts_with("iransans") || f.starts_with("iranyekan") +} + fn style_name(bold: bool, italic: bool) -> &'static str { match (bold, italic) { (false, false) => "Regular", diff --git a/crates/layout/Cargo.toml b/crates/layout/Cargo.toml index 8758c94..ac24018 100644 --- a/crates/layout/Cargo.toml +++ b/crates/layout/Cargo.toml @@ -13,6 +13,7 @@ wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } wordcraft-fonts = { workspace = true } wordcraft-proof = { workspace = true } +unicode-bidi = { workspace = true } unicode-linebreak = { workspace = true } unicode-segmentation = { workspace = true } serde = { workspace = true } diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 7f83496..cab1039 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -33,6 +33,9 @@ pub enum Draw { /// The text these glyphs show (for PDF text extraction), and the hyperlink. text: String, link: Option, + /// Byte range of `text` each glyph shows (one per glyph; a ligature's range covers all + /// its characters). Empty when unknown: the PDF writer then guesses from the font. + ranges: Vec>, }, Fill { rect: Rect, @@ -213,17 +216,17 @@ fn lines( if b <= line.start || a >= line.stop { continue; } - if let (Some(x0), Some(x1)) = (pl.x_of(li, a.max(line.start)), pl.x_of(li, b.min(line.stop))) - && x1 > x0 - { - out.push(Draw::Fill { rect: Rect::new(x + x0, top, x1 - x0, line.height), color: Rgb(0xEF, 0xE3, 0xF7), alpha }); + for (x0, x1) in pl.x_spans(li, a.max(line.start), b.min(line.stop)) { + if x1 > x0 { + out.push(Draw::Fill { rect: Rect::new(x + x0, top, x1 - x0, line.height), color: Rgb(0xEF, 0xE3, 0xF7), alpha }); + } } } } // Backgrounds first: highlight and character shading; character borders group equal adjacent borders. let mut open: Option<(Border, f32, f32)> = None; for k in line.c0..line.c1 { - let (Some(c), Some(cx), Some(nx)) = (pl.clusters.get(k), line.xs.get(k - line.c0), line.xs.get(k + 1 - line.c0)) else { continue }; + let (Some(c), Some(cx), Some(nx)) = (pl.clusters.get(k), line.cl_left(k), line.cl_right(k)) else { continue }; let Some(st) = pl.styles.get(c.style as usize) else { continue }; if c.kind == ClKind::Marker { continue; @@ -232,10 +235,12 @@ fn lines( out.push(Draw::Fill { rect: Rect::new(x + cx, top, (nx - cx).max(0.0), line.height), color: h, alpha }); } let b = st.rc.border; - if let Some((ob, _, x1)) = open.as_mut() + if let Some((ob, x0, x1)) = open.as_mut() && Some(*ob) == b { - *x1 = x + nx; + // Right-to-left text grows leftwards. + *x0 = x0.min(x + cx); + *x1 = x1.max(x + nx); continue; } if let Some((ob, x0, x1)) = open.take() { @@ -262,13 +267,14 @@ fn lines( synth_italic: st.synth_italic, text: lab.text.clone(), link: None, + ranges: Vec::new(), }); } // Hyphen at a hyphenated line end. if let Some((si, gid, adv)) = line.hyphen && let Some(st) = pl.styles.get(si as usize) { - let hx = x + line.xs.last().copied().unwrap_or(0.0) - adv; + let hx = x + line.hyphen_x(adv); out.push(Draw::Glyphs { face: st.face, size: st.size, @@ -279,6 +285,7 @@ fn lines( synth_italic: st.synth_italic, text: "-".into(), link: None, + ranges: Vec::new(), }); } // Glyph runs grouped by style. @@ -292,25 +299,53 @@ fn lines( }; let mut glyphs = Vec::new(); let mut text = String::new(); + let mut ranges: Vec> = Vec::new(); let run_start = k; while k < line.c1 { let Some(c) = pl.clusters.get(k) else { break }; if c.style != si { break; } - let cx = x + line.xs.get(k - line.c0).copied().unwrap_or(0.0); + let cx = x + line.cl_left(k).unwrap_or(0.0); if matches!(c.kind, ClKind::Text | ClKind::Space) { - for g in pl.glyphs.get(c.g0 as usize..c.g1 as usize).into_iter().flatten() { - glyphs.push((g.gid, cx + g.dx, base - st.shift - g.dy)); - } + let a = text.len(); // An object's cluster (a field, a note number) stands for the text it shows. + let mut shown = false; if let Ok(i) = pl.shown.binary_search_by_key(&k, |(i, _)| *i) && let Some((_, s)) = pl.shown.get(i) { text.push_str(s); + shown = true; } else if let Some(p) = para { text.push_str(p.text.get(c.start..c.end).unwrap_or("")); } + let b = text.len(); + let cg = pl.glyphs.get(c.g0 as usize..c.g1 as usize).unwrap_or(&[]); + if shown && cg.len() > 1 { + // A whole field result is one cluster of many glyphs: give them a character + // each (the last takes any remainder), not the whole text as one ligature. + let bounds: Vec = text.get(a..b).unwrap_or("").char_indices().map(|(i, _)| a + i).collect(); + for (n, g) in cg.iter().enumerate() { + let from = bounds.get(n).or(bounds.last()).copied().unwrap_or(a); + let to = if n + 1 == cg.len() { b } else { bounds.get(n + 1).copied().unwrap_or(b) }; + glyphs.push((g.gid, cx + g.dx, base - st.shift - g.dy)); + ranges.push(from..to.max(from)); + } + k += 1; + continue; + } + if cg.is_empty() { + // No glyph of its own (the second letter of a ligature such as لا): its + // text belongs to the glyphs before it. + let from = ranges.last().map(|r| r.start); + for r in ranges.iter_mut().rev().take_while(|r| Some(r.start) == from) { + r.end = b; + } + } + for g in cg { + glyphs.push((g.gid, cx + g.dx, base - st.shift - g.dy)); + ranges.push(a..b); + } } k += 1; } @@ -336,10 +371,11 @@ fn lines( synth_italic: st.synth_italic, text, link: rc.link.clone(), + ranges, }); } - // Decorations across the run (underline skips trailing spaces of the line). - let x0 = x + line.xs.get(run_start - line.c0).copied().unwrap_or(0.0); + // Decorations across the run (underline skips trailing spaces of the line). Right-to-left + // text inside a run can split it into several visual spans. let mut end_k = run_end; while end_k > run_start && pl.clusters.get(end_k - 1).is_some_and(|c| matches!(c.kind, ClKind::Space | ClKind::Marker | ClKind::LineBreak)) @@ -347,31 +383,37 @@ fn lines( { end_k -= 1; } - let x1 = x + line.xs.get(end_k - line.c0).copied().unwrap_or(0.0); let thick = (st.size / 18.0).max(0.5); let underline = if rc.ins.is_some() && opts.markup { Underline::Single } else { rc.underline }; - if underline != Underline::None && x1 > x0 { - let uy = base - st.shift + st.size * 0.12; - let ucolor = rc.underline_color.unwrap_or(color); - if underline == Underline::Words { - for kk in run_start..end_k { - let Some(c) = pl.clusters.get(kk) else { continue }; - if c.kind != ClKind::Text { - continue; + let struck = rc.strike || rc.double_strike || (rc.del.is_some() && opts.markup); + let spans = if underline != Underline::None || struck { line.spans(run_start, end_k) } else { Vec::new() }; + for (x0, x1) in spans.into_iter().map(|(a, b)| (x + a, x + b)).filter(|(a, b)| b > a) { + if underline != Underline::None { + let uy = base - st.shift + st.size * 0.12; + let ucolor = rc.underline_color.unwrap_or(color); + if underline == Underline::Words { + for kk in run_start..end_k { + let Some(c) = pl.clusters.get(kk) else { continue }; + if c.kind != ClKind::Text { + continue; + } + let a = x + line.cl_left(kk).unwrap_or(0.0); + let b = x + line.cl_right(kk).unwrap_or(a - x); + if a < x0 - 0.01 || b > x1 + 0.01 { + continue; // drawn with its own span + } + out.push(Draw::Line { x0: a, y0: uy, x1: b, y1: uy, width: thick, color: ucolor, stroke: Stroke::Solid, alpha }); } - let a = x + line.xs.get(kk - line.c0).copied().unwrap_or(0.0); - let b = x + line.xs.get(kk + 1 - line.c0).copied().unwrap_or(a); - out.push(Draw::Line { x0: a, y0: uy, x1: b, y1: uy, width: thick, color: ucolor, stroke: Stroke::Solid, alpha }); + } else { + let w = if underline == Underline::Thick { thick * 2.0 } else { thick }; + out.push(Draw::Line { x0, y0: uy, x1, y1: uy, width: w, color: ucolor, stroke: stroke_of(underline), alpha }); } - } else { - let w = if underline == Underline::Thick { thick * 2.0 } else { thick }; - out.push(Draw::Line { x0, y0: uy, x1, y1: uy, width: w, color: ucolor, stroke: stroke_of(underline), alpha }); } - } - if (rc.strike || rc.double_strike || (rc.del.is_some() && opts.markup)) && x1 > x0 { - let sy = base - st.shift - st.size * 0.28; - let stroke = if rc.double_strike { Stroke::Double } else { Stroke::Solid }; - out.push(Draw::Line { x0, y0: sy, x1, y1: sy, width: thick, color, stroke, alpha }); + if struck { + let sy = base - st.shift - st.size * 0.28; + let stroke = if rc.double_strike { Stroke::Double } else { Stroke::Solid }; + out.push(Draw::Line { x0, y0: sy, x1, y1: sy, width: thick, color, stroke, alpha }); + } } } // Proofing squiggles. @@ -379,26 +421,27 @@ fn lines( if *b <= line.start || *a >= line.stop { continue; } - let (Some(x0), Some(x1)) = (pl.x_of(li, (*a).max(line.start)), pl.x_of(li, (*b).min(line.stop))) else { continue }; - if x1 - x0 < 1.0 { - continue; + for (x0, x1) in pl.x_spans(li, (*a).max(line.start), (*b).min(line.stop)) { + if x1 - x0 < 1.0 { + continue; + } + let y = base + 2.5; + let color = if *grammar { Rgb(0x2B, 0x57, 0xC0) } else { Rgb(0xE0, 0x24, 0x24) }; + out.push(Draw::Line { + x0: x + x0, + y0: y, + x1: x + x1, + y1: y, + width: 0.8, + color, + stroke: if *grammar { Stroke::Double } else { Stroke::Wave }, + alpha, + }); } - let y = base + 2.5; - let color = if *grammar { Rgb(0x2B, 0x57, 0xC0) } else { Rgb(0xE0, 0x24, 0x24) }; - out.push(Draw::Line { - x0: x + x0, - y0: y, - x1: x + x1, - y1: y, - width: 0.8, - color, - stroke: if *grammar { Stroke::Double } else { Stroke::Wave }, - alpha, - }); } // Tab leaders. for (k, leader) in &line.leaders { - let (Some(c), Some(a), Some(b)) = (pl.clusters.get(*k), line.xs.get(k - line.c0), line.xs.get(k + 1 - line.c0)) else { continue }; + let (Some(c), Some(a), Some(b)) = (pl.clusters.get(*k), line.cl_left(*k), line.cl_right(*k)) else { continue }; let Some(st) = pl.styles.get(c.style as usize) else { continue }; let Some(ch) = leader.char() else { continue }; let gid = st.face.glyph_for(ch); @@ -421,6 +464,7 @@ fn lines( synth_italic: false, text: String::new(), link: None, + ranges: Vec::new(), }); } // Inline objects. @@ -430,7 +474,7 @@ fn lines( if c.obj_h <= 0.0 { continue; } - let cx = x + line.xs.get(k - line.c0).copied().unwrap_or(0.0); + let cx = x + line.cl_left(k).unwrap_or(0.0); let obj = para.and_then(|p| p.objects.get(oi)); let rect = inline_rect(obj, cx, base, c.adv, c.obj_h); match obj { @@ -451,12 +495,13 @@ fn lines( let msize = pl.styles.first().map(|s| s.size).unwrap_or(11.0); for k in line.c0..line.c1 { let Some(c) = pl.clusters.get(k) else { continue }; - let cx = x + line.xs.get(k - line.c0).copied().unwrap_or(0.0); - let nx = x + line.xs.get(k + 1 - line.c0).copied().unwrap_or(cx); + let cx = x + line.cl_left(k).unwrap_or(0.0); + let nx = x + line.cl_right(k).unwrap_or(cx - x); let size = pl.styles.get(c.style as usize).map(|s| s.size).unwrap_or(msize); + let arrow = if line.rtl { '←' } else { '→' }; match c.kind { ClKind::Space => out.push(Draw::Mark { x: (cx + nx) / 2.0 - size * 0.12, baseline: base - size * 0.08, size, ch: '·' }), - ClKind::Tab => out.push(Draw::Mark { x: cx + ((nx - cx) / 2.0 - size * 0.3).max(0.0), baseline: base, size, ch: '→' }), + ClKind::Tab => out.push(Draw::Mark { x: cx + ((nx - cx) / 2.0 - size * 0.3).max(0.0), baseline: base, size, ch: arrow }), ClKind::LineBreak => out.push(Draw::Mark { x: cx + 1.0, baseline: base, size, ch: '↵' }), ClKind::PageBreak | ClKind::ColumnBreak => { let label = if c.kind == ClKind::PageBreak { '⤓' } else { '⇥' }; @@ -476,9 +521,11 @@ fn lines( } } if line.end == LineEnd::Para { - let ex = x + line.xs.last().copied().unwrap_or(line.left); + let ex = x + line.end_x(); let size = pl.lines.first().map(|_| msize).unwrap_or(msize); - out.push(Draw::Mark { x: ex + 1.0, baseline: base, size, ch: '¶' }); + // A right-to-left paragraph's mark sits at its end, on the left. + let mx = if line.rtl { ex - 1.0 - size * 0.6 } else { ex + 1.0 }; + out.push(Draw::Mark { x: mx, baseline: base, size, ch: '¶' }); } } let _ = bottom; @@ -499,6 +546,8 @@ fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: & synth_italic: *synth_italic, text: text.clone(), link: None, + // Equation text maps back through the font (no per-glyph ranges from the math layout). + ranges: Vec::new(), }), MItem::Rect { x: rx, y, w, h, color } => out.push(Draw::Fill { rect: Rect::new(x + rx, base - y - h, *w, *h), color: *color, alpha }), MItem::Line { x0, y0, x1, y1, width, color, dotted } => out.push(Draw::Line { diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 4dcd77d..1c6a4c2 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -16,6 +16,16 @@ pub struct Caret { pub height: f32, } +/// Result of [`DocLayout::visual_step`]. +#[derive(Clone, Debug, PartialEq)] +pub enum VisualStep { + /// The position one visual step away on the same line. + Moved(Pos), + /// Already at that visual end of the line: the line's byte range, whether it is the + /// paragraph's last line, and whether the paragraph reads right to left. + Edge { start: usize, stop: usize, last_line: bool, rtl: bool }, +} + /// One placed line, resolved to page coordinates. #[derive(Clone, Debug)] pub struct LineHit<'a> { @@ -312,7 +322,9 @@ impl DocLayout { if matches!(l.end, crate::LineEnd::LineBreak | crate::LineEnd::PageBreak | crate::LineEnd::ColumnBreak) && end > l.start { end = para.clusters.get(l.c1.saturating_sub(1)).map(|c| c.start).unwrap_or(end); } else if l.end == crate::LineEnd::Wrap { - end = para.off_at_x(li, l.right + 1000.0); + // Before the wrapped line's trailing space: its last reachable offset, in logical + // order (the far edge is the left one in right-to-left text). + end = para.line_offsets(li).into_iter().max().unwrap_or(end); } let mk = |off| Pos { story: pos.story, path: pos.path.clone(), off }; return Some((mk(l.start), mk(end))); @@ -341,6 +353,20 @@ impl DocLayout { if from > to { continue; } + let top = y + (l.top - first.top); + if !l.vis.is_empty() { + // Bidirectional line: the selected text can be several visual pieces. + for (x0, x1) in para.x_spans(li, from, to) { + out.push((pi, Rect::new(x + x0, top, (x1 - x0).max(0.0), l.height))); + } + // Selection continuing past the paragraph end shows the paragraph mark. + if li + 1 == para.lines.len() && path != b.path { + let ex = l.end_x(); + let x0 = if l.rtl { ex - 5.0 } else { ex }; + out.push((pi, Rect::new(x + x0, top, 5.0, l.height))); + } + continue; + } let x0 = para.x_of(li, from).unwrap_or(l.left); let mut x1 = para.x_of(li, to).unwrap_or(x0); // Selection continuing past the paragraph end shows the paragraph mark. @@ -354,7 +380,6 @@ impl DocLayout { if x1 <= x0 && !(last_line && path != b.path) { continue; } - let top = y + (l.top - first.top); out.push((pi, Rect::new(x + x0, top, (x1 - x0).max(0.0), l.height))); } } @@ -362,6 +387,34 @@ impl DocLayout { out } + /// One step left or right from `pos` in visual order on its line (arrow keys in + /// bidirectional text). `Edge` when `pos` is already at that end of the line. + pub fn visual_step(&self, pos: &Pos, left: bool, page_hint: usize) -> Option { + for (_, it) in self.pieces(pos.story, &pos.path, page_hint) { + let Placed::Lines { para, l0, l1, .. } = it else { continue }; + let li = para.line_of(pos.off); + if li < *l0 || li >= *l1 { + continue; + } + let l = para.lines.get(li)?; + let here = para.x_of(li, pos.off)?; + let mut best: Option<(usize, f32)> = None; + for off in para.line_offsets(li) { + let Some(x) = para.x_of(li, off) else { continue }; + let ahead = if left { x < here - 0.01 } else { x > here + 0.01 }; + let d = (x - here).abs(); + if ahead && best.is_none_or(|b| d < b.1) { + best = Some((off, d)); + } + } + return Some(match best { + Some((off, _)) => VisualStep::Moved(Pos { story: pos.story, path: pos.path.clone(), off }), + None => VisualStep::Edge { start: l.start, stop: l.stop, last_line: li + 1 == para.lines.len(), rtl: l.rtl }, + }); + } + None + } + /// Where the equation at `pos` (its U+FFFC) is drawn: page, x of its left edge and its /// baseline (page coordinates), and its layout. pub fn equation_geom(&self, pos: &Pos, page_hint: usize) -> Option<(usize, f32, f32, std::sync::Arc)> { @@ -380,7 +433,7 @@ impl DocLayout { continue; } let ml = para.maths.iter().find(|(o, _)| *o == oi)?.1.clone(); - let cx = l.xs.get(k - l.c0).copied()?; + let cx = l.cl_left(k)?; let base = y + (l.baseline - first.top); return Some((pi, x + cx, base, ml)); } @@ -398,7 +451,7 @@ impl DocLayout { let Some(c) = l.para.clusters.get(k) else { continue }; let crate::para::ClKind::Object(oi) = c.kind else { continue }; let Some((_, ml)) = l.para.maths.iter().find(|(o, _)| *o == oi) else { continue }; - let x0 = l.x + line.xs.get(k - line.c0).copied().unwrap_or(0.0); + let x0 = l.x + line.cl_left(k).unwrap_or(0.0); let pad = 2.0; if x < x0 - pad || x > x0 + ml.width + pad || y < base - ml.ascent - pad || y > base + ml.descent + pad { continue; diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index dc8ae3b..5e3984d 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -27,6 +27,7 @@ use wordcraft_doc::{Block, Blocks, Document, Paragraph, Path, StoryRef}; use wordcraft_geom::{Point, Rect}; pub use fields::FieldCtx; +pub use hit::VisualStep; pub use para::{LineEnd, ParaLayout}; /// How the document is viewed. @@ -554,8 +555,11 @@ fn push_para(items: &mut Vec, story: StoryRef, path: &[u32], pl: &Arc f64 { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_bidi; diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 3e3edf4..9d30c22 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -111,6 +111,96 @@ pub struct Line { /// The text continues the previous line's row on the far side of a floating object (same top /// and height): one row, two lines. Row counts (line numbers, keeping rows on one page) skip it. pub beside: bool, + /// Bidirectional lines (right-to-left text, or any line of a right-to-left paragraph): each + /// cluster's visual box, in cluster order (len = c1 - c0), after the Unicode Bidirectional + /// Algorithm reordered the line. Empty for plain left-to-right lines, whose clusters sit at + /// `xs`. Read cluster geometry through [`Line::cl_left`] and friends, which handle both. + pub vis: Vec, + /// The paragraph reads right to left (its start edge is on the right). + pub rtl: bool, +} + +/// Where a cluster of a bidirectional line is drawn. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct VisCl { + /// Left edge and width, points (paragraph coordinates, like `Line::xs`). + pub x: f32, + pub w: f32, + /// Embedding level after rule L1 of UAX #9 (odd = right to left). + pub level: u8, +} + +impl VisCl { + pub fn is_rtl(&self) -> bool { + self.level % 2 == 1 + } + /// The edge a caret before this cluster sits at (right edge of right-to-left text). + pub fn leading(&self) -> f32 { + if self.is_rtl() { self.x + self.w } else { self.x } + } + /// The edge a caret after this cluster sits at. + pub fn trailing(&self) -> f32 { + if self.is_rtl() { self.x } else { self.x + self.w } + } +} + +impl Line { + /// Left edge of cluster `k` (a paragraph cluster index on this line). + pub fn cl_left(&self, k: usize) -> Option { + let i = k.checked_sub(self.c0)?; + if self.vis.is_empty() { self.xs.get(i).copied() } else { self.vis.get(i).map(|v| v.x) } + } + /// Right edge of cluster `k`. + pub fn cl_right(&self, k: usize) -> Option { + let i = k.checked_sub(self.c0)?; + if self.vis.is_empty() { self.xs.get(i + 1).copied() } else { self.vis.get(i).map(|v| v.x + v.w) } + } + /// Is cluster `k` right-to-left text? + pub fn cl_rtl(&self, k: usize) -> bool { + k.checked_sub(self.c0).and_then(|i| self.vis.get(i)).is_some_and(VisCl::is_rtl) + } + /// Caret x after the line's last cluster (its logical end), hyphen included. + pub fn end_x(&self) -> f32 { + let h = self.hyphen.map_or(0.0, |h| h.2); + match self.vis.last() { + Some(v) if v.is_rtl() => v.x - h, + Some(v) => v.x + v.w + h, + None => self.xs.last().copied().unwrap_or(self.left), + } + } + /// x where the hyphen of a hyphenated line (advance `adv`) is drawn. + pub fn hyphen_x(&self, adv: f32) -> f32 { + match self.vis.last() { + Some(v) if v.is_rtl() => v.x - adv, + Some(v) => v.x + v.w, + None => self.xs.last().copied().unwrap_or(0.0) - adv, + } + } + /// Visual extents, left to right, of clusters `k0..k1`: one span on a left-to-right line, + /// possibly several where bidirectional text splits a logical range. + pub fn spans(&self, k0: usize, k1: usize) -> Vec<(f32, f32)> { + let (k0, k1) = (k0.max(self.c0), k1.min(self.c1)); + if k0 >= k1 { + return Vec::new(); + } + if self.vis.is_empty() { + return match (self.cl_left(k0), self.cl_left(k1).or_else(|| self.xs.last().copied())) { + (Some(a), Some(b)) => vec![(a, b)], + _ => Vec::new(), + }; + } + let mut boxes: Vec<(f32, f32)> = + self.vis.get(k0 - self.c0..k1 - self.c0).unwrap_or(&[]).iter().filter(|v| v.w > 0.0).map(|v| (v.x, v.x + v.w)).collect(); + boxes.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut out: Vec<(f32, f32)> = Vec::new(); + for (a, b) in boxes { + match out.last_mut() { + Some(last) if a <= last.1 + 0.01 => last.1 = last.1.max(b), + _ => out.push((a, b)), + } + } + out + } } /// The list label drawn on the first line. @@ -145,6 +235,11 @@ pub struct ParaLayout { pub drop_cap: Option<(usize, u8, f32)>, /// Clusters after which the line may break with a hyphen (soft hyphens, auto hyphenation), sorted. pub hyph_after: Vec, + /// Bidi embedding level of each byte of the paragraph text (UAX #9, before the per-line + /// rule L1). Empty when the paragraph is plain left-to-right text. + pub bidi_levels: Vec, + /// Width of the drop cap letter itself (without the gap to the text). + pub drop_cap_w: f32, /// Laid-out equations by object index. pub maths: Vec<(usize, Arc)>, /// Display equations (on lines of their own): cluster index and justification. @@ -204,10 +299,79 @@ struct Builder<'a> { style_index: std::collections::HashMap>, glyphs: Vec, clusters: Vec, + /// Bidi level per byte of the paragraph text (empty: all left to right). + levels: Vec, /// Text drawn by clusters that stand for an object, by cluster index (see `ParaLayout::shown`). shown: Vec<(usize, String)>, } +/// What a piece of text is shaped as: one face, case, script formatting and direction. +#[derive(Clone, Copy)] +struct SegKey { + face: Option, + small: bool, + /// Formatted with the complex-script properties (Persian, Arabic, Hebrew…). + complex: bool, + /// Odd bidi level: shaped right to left. + rtl: bool, +} + +impl SegKey { + fn same(&self, o: &SegKey) -> bool { + self.face.map(|f| f.id()) == o.face.map(|f| f.id()) && self.small == o.small && self.complex == o.complex && self.rtl == o.rtl + } +} + +/// Combining marks and joiners (bidi classes NSM, BN: harakat, ZWNJ, ZWJ) belong to the +/// character before them: same font, same shaping run, so Arabic joining isn't broken. +fn is_mark(c: char) -> bool { + use unicode_bidi::BidiClass::{BN, NSM}; + matches!(unicode_bidi::bidi_class(c), NSM | BN) && !c.is_control() +} + +/// Bidi embedding levels per byte of `text` (UAX #9 rules up to I2), or empty when the +/// paragraph is left to right and has no right-to-left characters. +pub fn bidi_levels(text: &str, rtl_para: bool) -> Vec { + if !rtl_para && !text.chars().any(wordcraft_fonts::is_rtl) { + return Vec::new(); + } + let level = if rtl_para { unicode_bidi::Level::rtl() } else { unicode_bidi::Level::ltr() }; + let info = unicode_bidi::ParagraphBidiInfo::new(text, Some(level)); + info.levels.iter().map(|l| l.number()).collect() +} + +/// Rule L2 of UAX #9: the visual order (left to right) of items with these levels: from the +/// highest level down to the lowest odd one, reverse every run at that level or above. +pub fn visual_order(levels: &[u8]) -> Vec { + let mut order: Vec = (0..levels.len()).collect(); + let Some(min_odd) = levels.iter().copied().filter(|l| l % 2 == 1).min() else { return order }; + let max = levels.iter().copied().max().unwrap_or(0); + let level_at = |order: &[usize], i: usize| order.get(i).and_then(|&o| levels.get(o)).copied().unwrap_or(0); + let mut lvl = max; + while lvl >= min_odd { + let mut i = 0; + while i < order.len() { + if level_at(&order, i) >= lvl { + let mut j = i; + while j < order.len() && level_at(&order, j) >= lvl { + j += 1; + } + if let Some(run) = order.get_mut(i..j) { + run.reverse(); + } + i = j; + } else { + i += 1; + } + } + if lvl == 0 { + break; + } + lvl -= 1; + } + order +} + impl<'a> Builder<'a> { /// Style index for a resolved char in `face` (caps-scaled `size` override for small caps). fn style(&mut self, rc: &Arc, face_override: Option, small: bool) -> u16 { @@ -239,11 +403,17 @@ impl<'a> Builder<'a> { let size = rc.draw_size() * if small { 0.8 } else { 1.0 }; let (a, d) = wordcraft_fonts::word::line_metrics(&face); let k = size as f64 / face.upem.max(1.0); + // A fallback face (letters the requested font lacks) is emboldened or slanted when it has + // no bold or italic of its own. + let (synth_bold, synth_italic) = match face_override { + None => (r.synth_bold, r.synth_italic), + Some(f) => (rc.bold && f.get().weight < 600.0, rc.italic && !f.get().italic), + }; let st = StyleRun { face, size, - synth_bold: r.synth_bold && face_override.is_none(), - synth_italic: r.synth_italic && face_override.is_none(), + synth_bold, + synth_italic, rc: rc.clone(), ascent: (a * k) as f32, descent: (d * k) as f32, @@ -257,26 +427,49 @@ impl<'a> Builder<'a> { /// Shape `text` (a piece of the paragraph starting at byte `base`) in one style and append clusters. fn shape(&mut self, text: &str, base: usize, rc: &Arc, kind_override: Option) { + self.shape_with(text, base, rc, kind_override, true); + } + + /// `para_text`: `text` is paragraph text at `base`, so the paragraph's bidi levels apply + /// (false for field results and list labels, which are shaped on their own). + fn shape_with(&mut self, text: &str, base: usize, rc: &Arc, kind_override: Option, para_text: bool) { if text.is_empty() { return; } - // Split by font coverage (fallback faces) and small caps case. + // Complex-script characters (Persian, Arabic…) use the run's complex-script font, size, + // bold and italic. + let rc_cs: Option> = text.chars().any(|c| rc.uses_complex(c)).then(|| Arc::new(rc.complex())); let primary = wordcraft_fonts::word::resolve(&rc.font, rc.bold, rc.italic).face; + let primary_cs = rc_cs.as_ref().map(|r| wordcraft_fonts::word::resolve(&r.font, r.bold, r.italic).face); + let use_levels = para_text && !self.levels.is_empty(); + // Split by font coverage (fallback faces), small caps case, script formatting and direction. let mut seg_start = 0; - let mut cur: Option<(Option, bool)> = None; - let mut segs: Vec<(usize, usize, Option, bool)> = Vec::new(); + let mut cur: Option = None; + let mut segs: Vec<(usize, usize, SegKey)> = Vec::new(); for (i, c) in text.char_indices() { - let face = if primary.covers(c) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN { - None - } else { - wordcraft_fonts::FontDb::global().fallback_for(c, primary.id()).map(|f| FaceRef::of(&f)) + let mark = is_mark(c); + let complex = match cur { + Some(k) if mark => k.complex, + _ => rc_cs.is_some() && rc.uses_complex(c), + }; + let prim = if complex { primary_cs.unwrap_or(primary) } else { primary }; + let face = match cur { + Some(k) if mark => k.face, + _ if prim.covers(c) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None, + // Stay in the fallback face the word started in while it covers the letters. + Some(SegKey { face: Some(f), .. }) if f.covers(c) => Some(f), + _ => { + let (bold, italic) = if complex { (rc.bold_cs, rc.italic_cs) } else { (rc.bold, rc.italic) }; + wordcraft_fonts::FontDb::global().fallback_styled(c, prim.id(), bold, italic).map(|f| FaceRef::of(&f)) + } }; let small = rc.small_caps && !rc.caps && c.is_lowercase(); - let k = (face, small); + let rtl = use_levels && self.levels.get(base + i).is_some_and(|l| l % 2 == 1); + let k = SegKey { face, small, complex, rtl }; match cur { - Some(p) if p.0.map(|f| f.id()) == k.0.map(|f| f.id()) && p.1 == k.1 => {} + Some(p) if p.same(&k) => {} Some(p) => { - segs.push((seg_start, i, p.0, p.1)); + segs.push((seg_start, i, p)); seg_start = i; cur = Some(k); } @@ -284,14 +477,25 @@ impl<'a> Builder<'a> { } } if let Some(p) = cur { - segs.push((seg_start, text.len(), p.0, p.1)); + segs.push((seg_start, text.len(), p)); } - for (a, b, face, small) in segs { + for (a, b, key) in segs { let Some(sub) = text.get(a..b) else { continue }; - let si = self.style(rc, face, small); + let rc = match (&rc_cs, key.complex) { + (Some(cs), true) => cs.clone(), + _ => rc.clone(), + }; + let rc = &rc; + let small = key.small; + let si = self.style(rc, key.face, small); let Some(st) = self.styles.get(si as usize).cloned() else { continue }; let upper = rc.caps || small; - let shaped = wordcraft_fonts::shape(&st.face, sub, &[], |c| if upper { c.to_uppercase().next().unwrap_or(c) } else { c }); + let map = |c: char| if upper { c.to_uppercase().next().unwrap_or(c) } else { c }; + let shaped = if use_levels { + wordcraft_fonts::shape_run(&st.face, sub, &[], map, key.rtl) + } else { + wordcraft_fonts::shape(&st.face, sub, &[], map) + }; let k = st.size / st.face.upem.max(1.0) as f32; let hscale = rc.scale / 100.0; // Group glyphs by cluster byte offset; graphemes may span several shaper clusters. @@ -340,7 +544,7 @@ impl<'a> Builder<'a> { /// One cluster for a whole object with its display text (fields, note references). fn shape_atomic(&mut self, text: &str, start: usize, end: usize, rc: &Arc) { let before = self.clusters.len(); - self.shape(text, start, rc, None); + self.shape_with(text, start, rc, None, false); let added: Vec = self.clusters.drain(before..).collect(); let Some(first) = added.first() else { let si = self.style(rc, None, false); @@ -398,7 +602,9 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { let para_style = p.props.style.as_deref(); let table_chr = env.table.map(|t| &t.chr); let resolve = |c: &CharProps| -> Arc { Arc::new(doc.styles.resolve_char_in(para_style, table_chr, c)) }; - let mut b = Builder { env, styles: Vec::new(), style_index: Default::default(), glyphs: Vec::new(), clusters: Vec::new(), shown: Vec::new() }; + let levels = bidi_levels(&p.text, rp.bidi); + let mut b = + Builder { env, styles: Vec::new(), style_index: Default::default(), glyphs: Vec::new(), clusters: Vec::new(), levels, shown: Vec::new() }; let mark_rc = resolve(&p.mark); let mark_style = b.style(&mark_rc, None, false); let mut has_page_fields = false; @@ -411,6 +617,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { .filter(|c| !matches!(*c, '\t' | LINE_BREAK | PAGE_BREAK | COLUMN_BREAK | OBJ | '\r' | ' ')) .filter(|c| p.text.len() > c.len_utf8()); let mut drop_cap = None; + let mut drop_cap_w = 0.0; let mut maths = Vec::new(); let mut displays = Vec::new(); let mut eq_counter = env.eq_number; @@ -472,6 +679,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { b.shape(first, 0, &Arc::new(big), None); let w: f32 = b.clusters.get(before..).map(|c| c.iter().map(|c| c.adv).sum()).unwrap_or(0.0); drop_cap = Some((b.clusters.len() - before, rp.drop_cap, w + rc.size * 0.3)); + drop_cap_w = w; // The letter hangs beside the text; lines flow at the indent `break_lines` adds. for c in b.clusters.iter_mut().skip(before) { c.adv = 0.0; @@ -629,8 +837,15 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { .collect(); let before_c = b.clusters.len(); let before_g = b.glyphs.len(); - b.shape(&text, 0, &rc, None); - let cl: Vec = b.clusters.drain(before_c..).collect(); + b.shape_with(&text, 0, &rc, None, false); + let mut cl: Vec = b.clusters.drain(before_c..).collect(); + // A right-to-left paragraph's label reads right to left too ("1." shows as ".1"). + if rp.bidi { + let lv = bidi_levels(&text, true); + let cl_lv: Vec = cl.iter().map(|c| lv.get(c.start).copied().unwrap_or(1)).collect(); + let order = visual_order(&cl_lv); + cl = order.iter().filter_map(|&i| cl.get(i).cloned()).collect(); + } let mut glyphs = Vec::new(); let mut x = 0.0; let mut style = mark_style; @@ -660,6 +875,8 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { issues: if env.proofing { proof_issues(p) } else { Vec::new() }, drop_cap, hyph_after: Vec::new(), + bidi_levels: b.levels, + drop_cap_w, maths, displays, left_out: Vec::new(), @@ -703,6 +920,19 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti let shrink_spaces = rp.align == Align::Justify && env.doc.settings.compat_mode >= wordcraft_doc::COMPAT_MODE_CURRENT; let hanging_at = if rp.indent_first < 0.0 { Some(rp.indent_left) } else { None }; let n = pl.clusters.len(); + // A right-to-left paragraph is laid out from its start edge, as if mirrored: indents, tabs, + // the list label and alignment are measured from the right. Lines are mirrored back (and + // bidirectional text reordered) once broken; floating objects are mirrored in. + let rtl_para = rp.bidi; + let bidi = rtl_para || !pl.bidi_levels.is_empty(); + let mirrored: Vec; + let exclusions: &[Exclusion] = if rtl_para { + mirrored = env.exclusions.iter().map(|e| Exclusion { left: width - e.right, right: width - e.left, ..*e }).collect(); + &mirrored + } else { + env.exclusions + }; + let mut first_x0: Option = None; let mut hcache: Vec<(u16, u32, f32)> = Vec::new(); let mut lines: Vec = Vec::new(); let mut i = 0usize; @@ -731,7 +961,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti { lo = first_left.max(rp.indent_left) + dw; } - match row_spans(env.exclusions, top, est_h, lo, base_right).map(|mut spans| (spans.pop_front(), spans)) { + match row_spans(exclusions, top, est_h, lo, base_right).map(|mut spans| (spans.pop_front(), spans)) { Ok((Some((a, b)), rest)) => { row_rest = rest; row_first = lines.len(); @@ -1083,7 +1313,31 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti } let start = pl.clusters.get(c0).map(|c| c.start).unwrap_or(pl.text_len); let stop = if c1 > c0 { pl.clusters.get(c1 - 1).map(|c| c.end).unwrap_or(pl.text_len) } else { start }; - lines.push(Line { top, height, baseline, c0, c1, xs, leaders, end, start, stop, left, right: right_edge, hyphen, beside }); + if first_x0.is_none() { + first_x0 = xs.first().copied(); + } + let mut line = Line { + top, + height, + baseline, + c0, + c1, + xs, + leaders, + end, + start, + stop, + left, + right: right_edge, + hyphen, + beside, + vis: Vec::new(), + rtl: rtl_para, + }; + if bidi { + reorder_line(pl, &mut line, width); + } + lines.push(line); first = false; i = j; // Text that wrapped continues on the row's far side; anything else ends the row. @@ -1111,6 +1365,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti LineSpacing::Exactly(v) => v, }; let x0 = rp.indent_left; + let (x0, left, right) = if rtl_para { (width - x0, width - base_right, width - x0) } else { (x0, x0, base_right) }; lines.push(Line { top, height: h, @@ -1122,10 +1377,12 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti end: LineEnd::Para, start: pl.text_len, stop: pl.text_len, - left: x0, - right: base_right, + left, + right, hyphen: None, beside: false, + vis: Vec::new(), + rtl: rtl_para, }); top += h; } @@ -1141,7 +1398,9 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti { let short = lines.len() < dl as usize; let dy = if short { (dl as f32 - 1.0) * l0.height } else { ln.baseline - l0.baseline }; - let dx = dw.min(l0.xs.first().copied().unwrap_or(0.0) - first_left.max(rp.indent_left)); + let dx = dw.min(first_x0.unwrap_or(0.0) - first_left.max(rp.indent_left)); + // Toward the start edge: left, or right (and from the letter's right edge) when mirrored. + let dx = if rtl_para { pl.drop_cap_w - dx } else { dx }; if short { top = top.max(l0.top + l0.height * dl as f32); } @@ -1152,10 +1411,78 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti } } } + if rtl_para && let Some(lab) = pl.label.as_mut() { + lab.x = width - lab.x - lab.width; + } pl.height = top; pl.lines = lines; } +/// Finish a broken line of a bidirectional paragraph: resolve each cluster's level for the line +/// (UAX #9 rule L1), order the clusters visually (rule L2) and give each its box. `line.xs` +/// arrive in logical order measured from the paragraph's start edge (mirrored for a +/// right-to-left paragraph); afterwards `line.vis` holds the boxes and `xs` their left edges. +fn reorder_line(pl: &ParaLayout, line: &mut Line, width: f32) { + let rtl_para = line.rtl; + if rtl_para { + let (l, r) = (line.left, line.right); + line.left = width - r; + line.right = width - l; + } + let (c0, c1) = (line.c0, line.c1); + let n = c1.saturating_sub(c0); + if n == 0 { + if rtl_para { + line.xs = line.xs.iter().map(|x| width - x).collect(); + } + return; + } + let para_level = u8::from(rtl_para); + let mut levels: Vec = (c0..c1).map(|k| pl.clusters.get(k).and_then(|c| pl.bidi_levels.get(c.start)).copied().unwrap_or(para_level)).collect(); + // L1: tabs and breaks, and the whitespace before them or at the end of the line, take the + // paragraph level (so trailing spaces stay at the line's end edge). + let mut trailing = true; + for (i, k) in (c0..c1).enumerate().rev() { + let kind = pl.clusters.get(k).map(|c| c.kind); + let reset = match kind { + Some(ClKind::Tab | ClKind::LineBreak) => { + trailing = true; + true + } + Some(ClKind::Space | ClKind::PageBreak | ClKind::ColumnBreak | ClKind::Marker) => trailing, + _ => { + trailing = false; + false + } + }; + if reset && let Some(v) = levels.get_mut(i) { + *v = para_level; + } + } + if !rtl_para && levels.iter().all(|l| *l == 0) { + return; // A left-to-right line of a paragraph with right-to-left text elsewhere. + } + let order = visual_order(&levels); + let hyph = line.hyphen.map_or(0.0, |h| h.2); + let mut w: Vec = (0..n).map(|i| line.xs.get(i + 1).zip(line.xs.get(i)).map_or(0.0, |(b, a)| b - a)).collect(); + if let Some(last) = w.last_mut() { + *last -= hyph; + } + let total: f32 = w.iter().sum(); + let x0 = line.xs.first().copied().unwrap_or(0.0); + let mut x = if rtl_para { width - x0 - total } else { x0 }; + let mut vis = vec![VisCl { x: 0.0, w: 0.0, level: para_level }; n]; + for &i in &order { + if let (Some(v), Some(wi), Some(l)) = (vis.get_mut(i), w.get(i), levels.get(i)) { + *v = VisCl { x, w: *wi, level: *l }; + x += *wi; + } + } + line.vis = vis; + let end = line.end_x(); + line.xs = line.vis.iter().map(|v| v.x).chain(std::iter::once(end)).collect(); +} + /// Narrowest span text flows into beside a floating object, points. const MIN_SPAN: f32 = 36.0; @@ -1253,8 +1580,10 @@ fn hyphenation_points(text: &str, to_para: impl Fn(usize) -> Option, pl: match (start, word_char) { (None, true) => start = Some(i), (Some(a), false) => { + // The patterns are for Latin-script languages: never hyphenate Persian/Arabic words. if let Some(w) = text.get(a..i) && w.chars().count() >= lim.min_word + && !w.chars().any(wordcraft_fonts::is_rtl) { let offs: Vec = w.char_indices().map(|(o, _)| o).collect(); for pt in wordcraft_proof::hyphen::hyphen_points(w, &lim) { @@ -1316,6 +1645,9 @@ impl ParaLayout { /// x of byte offset `off` within line `li` (clamped to the line). pub fn x_of(&self, li: usize, off: usize) -> Option { let l = self.lines.get(li)?; + if !l.vis.is_empty() { + return self.x_of_bidi(l, off); + } for k in l.c0..l.c1 { let c = self.clusters.get(k)?; if off <= c.start { @@ -1342,9 +1674,66 @@ impl ParaLayout { } self.lines.len().saturating_sub(1) } + /// Caret x on a bidirectional line: the leading edge of the cluster after `off` (the right + /// edge of right-to-left text), except where `off` leaves an embedded run (the cluster before + /// it has the higher level), which keeps the caret at that run's trailing edge, so each + /// visual boundary of the common mixed cases has its own offset. + fn x_of_bidi(&self, l: &Line, off: usize) -> Option { + let k = (l.c0..l.c1).find(|&k| self.clusters.get(k).is_some_and(|c| off < c.end || off <= c.start)); + let Some(k) = k else { return Some(l.end_x()) }; + let c = self.clusters.get(k)?; + let here = l.vis.get(k - l.c0)?; + if off > c.start || k == l.c0 { + return Some(here.leading()); + } + let prev = l.vis.get(k - 1 - l.c0)?; + Some(if prev.level > here.level { prev.trailing() } else { here.leading() }) + } + + /// Visual extents, left to right, of the text between byte offsets `from..to` on line `li` + /// (one span unless bidirectional text splits the range). + pub fn x_spans(&self, li: usize, from: usize, to: usize) -> Vec<(f32, f32)> { + let Some(l) = self.lines.get(li) else { return Vec::new() }; + if l.vis.is_empty() { + return match (self.x_of(li, from), self.x_of(li, to)) { + (Some(a), Some(b)) => vec![(a, b)], + _ => Vec::new(), + }; + } + let ks: Vec = (l.c0..l.c1).filter(|&k| self.clusters.get(k).is_some_and(|c| c.start < to && c.end > from)).collect(); + match (ks.first(), ks.last()) { + (Some(&a), Some(&b)) => l.spans(a, b + 1), + _ => Vec::new(), + } + } + + /// Caret offsets a click or arrow key can reach on line `li`: each cluster start and the line + /// end, except after a line-ending break or past a wrapped line's trailing space. + pub fn line_offsets(&self, li: usize) -> Vec { + let Some(l) = self.lines.get(li) else { return Vec::new() }; + let mut v: Vec = (l.c0..l.c1).filter_map(|k| self.clusters.get(k).map(|c| c.start)).collect(); + let last_break = + l.c1 > l.c0 && self.clusters.get(l.c1 - 1).is_some_and(|c| matches!(c.kind, ClKind::LineBreak | ClKind::PageBreak | ClKind::ColumnBreak)); + let wrapped = l.c1 > l.c0 && l.end == LineEnd::Wrap && self.lines.get(li + 1).is_some(); + if !last_break && !wrapped || l.c1 == l.c0 { + v.push(l.stop); + } + v + } + /// Byte offset closest to x on line `li`. pub fn off_at_x(&self, li: usize, x: f32) -> usize { let Some(l) = self.lines.get(li) else { return self.text_len }; + if !l.vis.is_empty() { + let mut best = (l.start, f32::MAX); + for off in self.line_offsets(li) { + let d = self.x_of(li, off).map_or(f32::MAX, |cx| (cx - x).abs()); + if d < best.1 { + best = (off, d); + } + } + return best.0; + } let mut best = l.start; let mut bestd = f32::MAX; for k in l.c0..=l.c1 { diff --git a/crates/layout/src/tests_bidi.rs b/crates/layout/src/tests_bidi.rs new file mode 100644 index 0000000..6a3545a --- /dev/null +++ b/crates/layout/src/tests_bidi.rs @@ -0,0 +1,425 @@ +//! Right-to-left and bidirectional layout: Persian paragraphs, mixed Persian/Latin lines, +//! numbers and punctuation, caret and selection geometry. + +use super::*; +use crate::para::{bidi_levels, visual_order}; +use wordcraft_doc::props::{Align, ParaProps}; +use wordcraft_doc::{Pos, StoryRef}; + +fn lay(doc: &Document) -> DocLayout { + let mut c = LayoutCache::new(); + layout(doc, &mut c, &LayoutOptions::default()) +} + +/// A document of `paras`, each right to left when its flag is set. +fn doc_of(paras: &[(&str, bool)]) -> Document { + let text: Vec<&str> = paras.iter().map(|p| p.0).collect(); + let mut d = Document::from_text(&text.join("\n")); + for (i, (_, rtl)) in paras.iter().enumerate() { + if *rtl { + let at = Pos::body(i, 0); + d.format_paragraphs(&at, &at, &|p: &mut ParaProps| p.bidi = Some(true)).unwrap(); + } + } + d +} + +/// The first line of body paragraph `i` with its x offset on the page. +fn first_line(l: &DocLayout, i: u32) -> (&ParaLayout, f32) { + for p in &l.pages { + for it in &p.items { + if let Placed::Lines { story: StoryRef::Body, path, para, x, .. } = it + && path.0 == vec![i] + { + return (para, *x); + } + } + } + panic!("paragraph {i} not laid out"); +} + +/// Page x of the caret at `off` in paragraph `i`. +fn caret_x(l: &DocLayout, i: usize, off: usize) -> f32 { + l.caret(&Pos::body(i, off)).unwrap().x +} + +/// Left edge (page x) of the cluster starting at byte `off` of paragraph `i`'s first line. +fn cluster_x(l: &DocLayout, i: u32, off: usize) -> f32 { + let (pl, x) = first_line(l, i); + let line = &pl.lines[0]; + let k = (line.c0..line.c1).find(|&k| pl.clusters[k].start == off).unwrap_or_else(|| panic!("no cluster at {off}")); + x + line.cl_left(k).unwrap() +} + +/// Byte offset of the `n`th char of `s`. +fn at(s: &str, n: usize) -> usize { + s.char_indices().nth(n).map(|(i, _)| i).unwrap_or(s.len()) +} + +#[test] +fn visual_order_follows_uax9_rule_l2() { + // The example from UAX #9 / unicode-bidi: levels 0 0 0 1 1 1 2 2. + assert_eq!(visual_order(&[0, 0, 0, 1, 1, 1, 2, 2]), vec![0, 1, 2, 6, 7, 5, 4, 3]); + assert_eq!(visual_order(&[1, 1, 1]), vec![2, 1, 0]); + assert_eq!(visual_order(&[0, 0]), vec![0, 1]); + assert_eq!(visual_order(&[]), Vec::::new()); + // Hostile levels never panic. + assert_eq!(visual_order(&[125, 0, 125]).len(), 3); +} + +#[test] +fn levels_for_persian_with_latin_and_numbers() { + assert!(bidi_levels("plain English", false).is_empty(), "pure LTR paragraphs skip the algorithm"); + let s = "سلام Word ۱۴۰۳"; + let lv = bidi_levels(s, true); + assert_eq!(lv.len(), s.len()); + assert_eq!(lv[0] % 2, 1, "Persian letters are right to left"); + assert_eq!(lv[at(s, 5)], 2, "Latin inside a right-to-left paragraph is embedded at level 2"); + assert_eq!(lv[at(s, 10)], 2, "Persian digits are numbers: left to right inside right-to-left text"); + // In a left-to-right paragraph Persian is embedded at level 1. + let lv = bidi_levels("Hello سلام", false); + assert_eq!(lv[0], 0); + assert_eq!(lv[at("Hello سلام", 6)], 1); +} + +#[test] +fn rtl_paragraph_is_right_aligned_and_reads_right_to_left() { + let s = "سلام دنیا"; + let d = doc_of(&[(s, true)]); + let l = lay(&d); + let right = 612.0 - 72.0; + // The caret at the start of a right-to-left paragraph is at the right margin. + assert!((caret_x(&l, 0, 0) - right).abs() < 0.5, "start caret at {}", caret_x(&l, 0, 0)); + // Each next character sits further left. + let xs: Vec = (0..s.chars().count()).map(|n| caret_x(&l, 0, at(s, n))).collect(); + assert!(xs.windows(2).all(|w| w[1] < w[0] + 0.01), "caret moves leftwards: {xs:?}"); + assert!(caret_x(&l, 0, s.len()) < xs[xs.len() - 1]); + // Glyph boxes: the first letter is the rightmost. + assert!(cluster_x(&l, 0, 0) > cluster_x(&l, 0, at(s, 5))); +} + +#[test] +fn ltr_paragraph_with_embedded_persian_word() { + let s = "Hello سلام world"; + let d = doc_of(&[(s, false)]); + let l = lay(&d); + let (h, sin, mim, w) = (cluster_x(&l, 0, 0), cluster_x(&l, 0, at(s, 6)), cluster_x(&l, 0, at(s, 9)), cluster_x(&l, 0, at(s, 11))); + assert!((h - 72.0).abs() < 0.5, "left-to-right paragraphs still start at the left margin"); + assert!(h < mim && mim < sin && sin < w, "the Persian word reads right to left between the English ones: {h} {mim} {sin} {w}"); +} + +#[test] +fn numbers_and_latin_inside_persian_keep_their_order() { + // "Price 250 toman" in Persian, with a Latin product name and Persian punctuation. + let s = "قیمت 250 تومان برای Word، امسال ۱۴۰۳."; + let d = doc_of(&[(s, true)]); + let l = lay(&d); + let x = |n: usize| cluster_x(&l, 0, at(s, n)); + // Digits run left to right. + assert!(x(5) < x(6) && x(6) < x(7), "250 reads left to right: {} {} {}", x(5), x(6), x(7)); + assert!(x(32) < x(33) && x(33) < x(34) && x(34) < x(35), "۱۴۰۳ reads left to right"); + // "Word" reads left to right, and the words around it right to left. + assert!(x(20) < x(21) && x(21) < x(22) && x(22) < x(23), "Word reads left to right"); + assert!(x(0) > x(9), "قیمت comes before تومان, so it is further right"); + assert!(x(9) > x(20), "تومان is right of Word"); + // The final full stop is at the paragraph's end: the far left. + let dot = x(s.chars().count() - 1); + for n in 0..s.chars().count() - 1 { + assert!(dot <= x(n) + 0.01, "the full stop is leftmost (char {n})"); + } +} + +#[test] +fn caret_and_hit_agree_in_bidi_text() { + for (s, rtl) in [("سلام world ۱۲۳", true), ("Hello سلام دنیا end", false), ("کتاب «WordCraft» (نسخه ۲)", true)] { + let d = doc_of(&[(s, rtl)]); + let l = lay(&d); + let (pl, _) = first_line(&l, 0); + let offs = pl.line_offsets(0); + let mut seen: Vec<(usize, f32)> = Vec::new(); + for off in offs { + let c = l.caret(&Pos::body(0, off)).unwrap(); + let back = l.hit(c.page, c.x, c.top + c.height / 2.0, StoryRef::Body).unwrap(); + // A click on a caret position finds an offset drawn at the same place. + let bx = l.caret(&back).unwrap().x; + assert!((bx - c.x).abs() < 0.01, "{s:?}: offset {off} at {} came back as {} at {bx}", c.x, back.off); + seen.push((off, c.x)); + } + assert!(seen.len() > 5); + } +} + +#[test] +fn visual_steps_cover_the_line_in_order() { + let s = "سلام world"; + let d = doc_of(&[(s, true)]); + let l = lay(&d); + // From the logical start (right edge), stepping left visits every position once. + let mut pos = Pos::body(0, 0); + let mut xs = vec![l.caret(&pos).unwrap().x]; + for _ in 0..40 { + match l.visual_step(&pos, true, 0).unwrap() { + hit::VisualStep::Moved(p) => { + pos = p; + xs.push(l.caret(&pos).unwrap().x); + } + hit::VisualStep::Edge { rtl, .. } => { + assert!(rtl); + break; + } + } + } + assert!(xs.windows(2).all(|w| w[1] < w[0]), "each step moves left: {xs:?}"); + // Every distinct caret position is visited (a lam-alef ligature, لا in سلام, puts two + // offsets at one spot). + let (pl, _) = first_line(&l, 0); + let mut distinct: Vec = pl.line_offsets(0).iter().filter_map(|o| pl.x_of(0, *o)).collect(); + distinct.sort_by(f32::total_cmp); + distinct.dedup_by(|a, b| (*a - *b).abs() < 0.01); + assert_eq!(xs.len(), distinct.len(), "every caret position on the line is visited"); +} + +#[test] +fn selection_of_mixed_text_can_be_two_pieces() { + // Logical range "lo wo" of "Hello سلام world"? Select across the Persian word's start: + // "lo سل" is two visual pieces (the end of Hello, and the right part of سلام). + let s = "Hello سلام world"; + let d = doc_of(&[(s, false)]); + let l = lay(&d); + let rects = l.selection_rects(&d, &Pos::body(0, at(s, 3)), &Pos::body(0, at(s, 8)), 0); + assert_eq!(rects.len(), 2, "{rects:?}"); + // A whole right-to-left word is one piece. + let rects = l.selection_rects(&d, &Pos::body(0, at(s, 6)), &Pos::body(0, at(s, 10)), 0); + assert_eq!(rects.len(), 1, "{rects:?}"); +} + +#[test] +fn rtl_indents_and_alignment_are_measured_from_the_right() { + let s = "متن فارسی"; + let mut d = doc_of(&[(s, true), (s, true), (s, true)]); + let p0 = Pos::body(0, 0); + d.format_paragraphs(&p0, &p0, &|p: &mut ParaProps| p.indent_left = Some(36.0)).unwrap(); + let p1 = Pos::body(1, 0); + // `Align::Right` is the end edge: the left in a right-to-left paragraph. + d.format_paragraphs(&p1, &p1, &|p: &mut ParaProps| p.align = Some(Align::Right)).unwrap(); + let p2 = Pos::body(2, 0); + d.format_paragraphs(&p2, &p2, &|p: &mut ParaProps| p.align = Some(Align::Center)).unwrap(); + let l = lay(&d); + assert!((caret_x(&l, 0, 0) - (540.0 - 36.0)).abs() < 0.5, "start indent on the right: {}", caret_x(&l, 0, 0)); + assert!((caret_x(&l, 1, s.len()) - 72.0).abs() < 0.5, "end-aligned text ends at the left margin: {}", caret_x(&l, 1, s.len())); + let mid = (caret_x(&l, 2, 0) + caret_x(&l, 2, s.len())) / 2.0; + assert!((mid - 306.0).abs() < 1.0, "centred: {mid}"); +} + +#[test] +fn rtl_list_label_sits_on_the_right() { + let mut d = doc_of(&[("مورد اول", true), ("مورد دوم", true)]); + let id = d.numbering.add_list(wordcraft_doc::ListKind::Numbered); + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(1, 0), &|p: &mut ParaProps| { + p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 }) + }) + .unwrap(); + let l = lay(&d); + let (pl, x) = first_line(&l, 0); + let lab = pl.label.as_ref().expect("numbered"); + let text_right = caret_x(&l, 0, 0); + assert!(x + lab.x >= text_right - 0.5, "label {} is right of the text start {text_right}", x + lab.x); + assert!(x + lab.x + lab.width <= 540.5); + // "1." reads right to left: the full stop is drawn left of the number. + let st = &pl.styles[lab.style as usize]; + let (one, dot) = (st.face.glyph_for('1'), st.face.glyph_for('.')); + let gx = |gid: u32| lab.glyphs.iter().find(|g| g.gid == gid).map(|g| g.dx).unwrap(); + assert!(gx(dot) < gx(one), "label shows .1"); +} + +#[test] +fn rtl_empty_and_trailing_lines_mirror() { + let d = doc_of(&[("", true)]); + let l = lay(&d); + assert!((caret_x(&l, 0, 0) - 540.0).abs() < 0.5, "empty right-to-left paragraph: caret at the right margin"); + // A paragraph ending in a line break gets an empty last line, also on the right. + let mut d = doc_of(&[("سطر", true)]); + let end = "سطر".len(); + d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().insert_text(end, "\n", &Default::default()).unwrap(); + let l = lay(&d); + assert!((caret_x(&l, 0, end + 1) - 540.0).abs() < 0.5, "{}", caret_x(&l, 0, end + 1)); +} + +#[test] +fn bidi_display_draws_every_glyph_inside_the_line() { + let s = "این یک متن آزمایشی با English و عدد ۱۲۳ است. ".repeat(12); + let d = doc_of(&[(&s, true)]); + let l = lay(&d); + let items = display::page_display(&d, &l.pages[0], &Default::default()); + let mut n = 0; + for it in &items { + if let display::Draw::Glyphs { glyphs, text, ranges, .. } = it { + for (k, (_, x, _)) in glyphs.iter().enumerate() { + // A wrapped line's trailing spaces hang past its end edge: the left margin here. + if ranges.get(k).and_then(|r| text.get(r.clone())).is_some_and(|t| t.trim().is_empty()) { + continue; + } + assert!(*x >= 72.0 - 1.0 && *x <= 540.0 + 1.0, "glyph at {x} outside the text column"); + n += 1; + } + } + } + assert!(n > 100); + // Lines wrap and every line but the last is filled to the right margin. + let (pl, x) = first_line(&l, 0); + assert!(pl.lines.len() > 3); + assert!((x + pl.lines[0].vis.iter().map(|v| v.x + v.w).fold(0.0f32, f32::max) - 540.0).abs() < 1.0); +} + +#[test] +fn hostile_bidi_text_never_panics() { + // Explicit embeddings, isolates, unmatched pops, marks with no base, lone surrogates' stand-ins. + let junk = [ + "\u{202B}\u{202B}\u{202C}\u{202C}\u{202C}abc\u{2067}سلام\u{2069}\u{2069}", + "\u{064E}\u{064E}\u{200C}\u{200D}", + "\u{200F}\u{200E}\t\u{200F}\n\u{200E}", + "(((((((((سلام]]]]]]]", + &"\u{202E}".repeat(200), + ]; + for s in junk { + for rtl in [false, true] { + let d = doc_of(&[(s, rtl)]); + let l = lay(&d); + let _ = display::page_display(&d, &l.pages[0], &Default::default()); + for off in 0..=s.len() { + if s.is_char_boundary(off) { + let _ = l.caret(&Pos::body(0, off)); + let _ = l.visual_step(&Pos::body(0, off), true, 0); + let _ = l.visual_step(&Pos::body(0, off), false, 0); + } + } + let _ = l.selection_rects(&d, &Pos::body(0, 0), &Pos::body(0, s.len()), 0); + } + } +} + +/// A face that has Arabic letters (installed system fonts), or `None` to skip. +fn arabic_face() -> Option { + wordcraft_fonts::FontDb::global().fallback_for('ب', 0).map(|f| wordcraft_fonts::FaceRef::of(&f)).filter(|f| f.covers('پ')) +} + +#[test] +fn persian_letters_join_and_brackets_mirror() { + let Some(face) = arabic_face() else { + eprintln!("skipped: no font with Persian letters installed"); + return; + }; + let isolated = wordcraft_fonts::shape_run(&face, "ب", &[], |c| c, true); + let joined = wordcraft_fonts::shape_run(&face, "ببب", &[], |c| c, true); + assert_eq!(joined.len(), 3); + assert!(joined.iter().all(|g| g.gid != 0), "the font has the letters"); + // Initial, medial and final forms differ from the isolated letter. + assert!(joined.iter().any(|g| g.gid != isolated[0].gid), "cursive joining picks contextual forms"); + // Clusters come back in logical order. + assert!(joined.windows(2).all(|w| w[0].cluster <= w[1].cluster)); + // ZWNJ (نیم‌فاصله) breaks the join: "می‌خواهم" keeps می separate. + let with_zwnj = wordcraft_fonts::shape_run(&face, "ب\u{200C}ب", &[], |c| c, true); + let first = with_zwnj.first().map(|g| g.gid); + assert_eq!(first, Some(isolated[0].gid), "a letter before ZWNJ takes its isolated form"); + // In right-to-left text, "(" is drawn with the mirrored glyph. + let open = wordcraft_fonts::shape_run(&face, "(", &[], |c| c, true); + let close = wordcraft_fonts::shape_run(&face, ")", &[], |c| c, false); + if let (Some(o), Some(c)) = (open.first(), close.first()) + && c.gid != 0 + { + assert_eq!(o.gid, c.gid, "bidi mirroring"); + } +} + +#[test] +fn persian_text_in_a_latin_font_stays_in_one_fallback_face() { + if arabic_face().is_none() { + eprintln!("skipped: no font with Persian letters installed"); + return; + } + // Calibri (substituted) has no Persian: the whole word must come from one fallback face so + // its letters join, including the Persian-only letters and a diacritic. + let s = "پژوهشگرِ گرامی"; + let d = doc_of(&[(s, true)]); + let l = lay(&d); + let (pl, _) = first_line(&l, 0); + let word: Vec = pl.clusters.iter().filter(|c| c.end <= at(s, 8)).map(|c| pl.styles[c.style as usize].face.id()).collect(); + assert!(word.windows(2).all(|w| w[0] == w[1]), "one face for the word: {word:?}"); + // No .notdef glyphs. + assert!(pl.glyphs.iter().all(|g| g.gid != 0)); +} + +#[test] +fn glyph_runs_carry_their_text_for_pdf_export() { + let s = "سلام WordCraft می‌خواهم"; + let d = doc_of(&[(s, true)]); + let l = lay(&d); + let mut all = String::new(); + for it in display::page_display(&d, &l.pages[0], &Default::default()) { + if let display::Draw::Glyphs { glyphs, text, ranges, .. } = it { + assert_eq!(ranges.len(), glyphs.len(), "one text range per glyph"); + for r in &ranges { + assert!(text.get(r.clone()).is_some_and(|t| !t.is_empty()), "{r:?} in {text:?}"); + } + // A ligature glyph (لا) shows both its letters (when a Persian font draws it). + for r in ranges.iter().filter(|_| arabic_face().is_some()) { + if text.get(r.clone()) == Some("ل") { + panic!("the lam of لا is drawn as one glyph with the alef: {text:?} {ranges:?}"); + } + } + all.push_str(&text); + } + } + assert_eq!(all.replace(' ', ""), s.replace(' ', ""), "every character is accounted for, in logical order"); +} + +#[test] +fn character_border_boxes_a_whole_rtl_word() { + let s = "کتاب خوب"; + let mut d = doc_of(&[(s, true)]); + let b = wordcraft_doc::props::Border { space: 0.0, ..wordcraft_doc::props::Border::single(0.5) }; + d.format_range(&Pos::body(0, 0), &Pos::body(0, at(s, 4)), &|c| c.border = Some(b)).unwrap(); + let l = lay(&d); + let (pl, x) = first_line(&l, 0); + let line = &pl.lines[0]; + let word: Vec = (line.c0..line.c1).filter(|&k| pl.clusters[k].end <= at(s, 4)).collect(); + let lo = word.iter().filter_map(|&k| line.cl_left(k)).fold(f32::MAX, f32::min) + x; + let hi = word.iter().filter_map(|&k| line.cl_right(k)).fold(f32::MIN, f32::max) + x; + // The box's top edge spans the whole word, which reads right to left. + let top = display::page_display(&d, &l.pages[0], &Default::default()) + .into_iter() + .filter_map(|it| match it { + display::Draw::Line { x0, y0, x1, y1, .. } if (y0 - y1).abs() < 0.01 && (x1 - x0).abs() > 1.0 => Some((x0.min(x1), x0.max(x1))), + _ => None, + }) + .next() + .expect("a border"); + assert!((top.0 - lo).abs() < 0.5 && (top.1 - hi).abs() < 0.5, "box {top:?} vs word {lo}..{hi}"); +} + +#[test] +fn inline_picture_in_rtl_text_sits_in_its_place() { + // "متن [picture] بیشتر": in a right-to-left paragraph the picture is drawn where its cluster + // is on the line (left of the first word), not at its logical x. + let s = "متن بیشتر"; + let mut d = doc_of(&[(s, true)]); + let off = at(s, 4); + let pic = + wordcraft_doc::InlineObject::Image { media: "m".into(), w: 30.0, h: 20.0, alt: String::new(), float: Default::default(), crop: [0.0; 4] }; + d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().insert_object(off, pic, &Default::default()).unwrap(); + let l = lay(&d); + let (pl, x) = first_line(&l, 0); + let line = &pl.lines[0]; + let k = (line.c0..line.c1).find(|&k| matches!(pl.clusters[k].kind, para::ClKind::Object(_))).unwrap(); + let (a, b) = (x + line.cl_left(k).unwrap(), x + line.cl_right(k).unwrap()); + let drawn: Vec = display::page_display(&d, &l.pages[0], &Default::default()) + .into_iter() + .filter_map(|it| if let display::Draw::Image { rect, .. } = it { Some(rect) } else { None }) + .collect(); + assert_eq!(drawn.len(), 1); + assert!((drawn[0].x - a).abs() < 0.5 && (drawn[0].x + drawn[0].w - b).abs() < 0.5, "picture {:?} vs its slot {a}..{b}", drawn[0]); + // The object placed for selection and dragging is at the same spot. + let placed: Vec = l.pages[0].items.iter().filter_map(|it| if let Placed::Object { rect, .. } = it { Some(*rect) } else { None }).collect(); + assert!(placed.iter().any(|r| (r.x - a).abs() < 0.5), "placed {placed:?} vs {a}"); + assert!(a < 540.0 - 20.0, "after the first word, so left of the right margin"); +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 8a718fd..23ea5c3 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -614,8 +614,8 @@ impl Exporter<'_> { fn draw(&mut self, s: &mut Surface, d: &Draw) { match d { - Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link } => { - self.glyphs(s, face, *size, glyphs, *color, *alpha, *synth_bold, *synth_italic, text); + Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges } => { + self.glyphs(s, face, *size, glyphs, *color, *alpha, *synth_bold, *synth_italic, text, ranges); if let Some(l) = link { self.link_rect(face, *size, glyphs, l); } @@ -822,6 +822,7 @@ impl Exporter<'_> { bold: bool, italic: bool, text: &str, + known: &[std::ops::Range], ) { if !size.is_finite() || size <= 0.0 || size > 5000.0 { return; @@ -835,7 +836,10 @@ impl Exporter<'_> { log::warn!("PDF: font {} {} could not be embedded", face.family, face.style); return; } - let (txt, ranges) = self.map_text(face, glyphs, text); + // Layout's glyph → text mapping when it has one (right-to-left text, contextual forms and + // ligatures can't be guessed back from the font's cmap), else a guess. + let valid = known.len() == glyphs.len() && known.iter().all(|r| r.start < r.end && text.get(r.clone()).is_some()); + let (txt, ranges) = if valid { (text.to_string(), known.to_vec()) } else { self.map_text(face, glyphs, text) }; let upem = face.upem.max(1.0) as f32; let kg: Vec = glyphs .iter() diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index aa7c92f..a8009c2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -783,9 +783,15 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo let col_x = page.body.x; if let Some(para) = app.session.doc.para_at(&app.session.sel.focus) { let rp = app.session.doc.styles.resolve_para(¶.props); - let first = x0 + (col_x + rp.indent_left + rp.indent_first) * scale; - let left = x0 + (col_x + rp.indent_left) * scale; - let right = x0 + (col_x + page.body.w - rp.indent_right) * scale; + // Indents and tabs are measured from the paragraph's start edge: the right margin of a + // right-to-left paragraph, where the start-indent markers sit. + let rtl = rp.bidi; + let w = page.body.w; + let sx = |s: f32| if rtl { x0 + (col_x + w - s) * scale } else { x0 + (col_x + s) * scale }; + let s_at = |x: f32| if rtl { col_x + w - (x - x0) / scale } else { (x - x0) / scale - col_x }; + let first = sx(rp.indent_left + rp.indent_first); + let left = sx(rp.indent_left); + let right = sx(w - rp.indent_right); let c = t.text_dim; hp.add(egui::Shape::convex_polygon( vec![pos2(first - 4.5, bar.min.y), pos2(first + 4.5, bar.min.y), pos2(first, bar.min.y + 5.0)], @@ -810,9 +816,10 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo Stroke::new(1.0, c), )); for tab in &rp.tabs { - let tx = x0 + (col_x + tab.pos) * scale; + let tx = sx(tab.pos); + let foot = if rtl { -4.0 } else { 4.0 }; hp.line_segment([pos2(tx, bar.max.y - 6.0), pos2(tx, bar.max.y - 1.0)], Stroke::new(1.5, t.text)); - hp.line_segment([pos2(tx, bar.max.y - 1.0), pos2(tx + 4.0, bar.max.y - 1.0)], Stroke::new(1.5, t.text)); + hp.line_segment([pos2(tx, bar.max.y - 1.0), pos2(tx + foot, bar.max.y - 1.0)], Stroke::new(1.5, t.text)); } // Dragging the left-indent marker. let id = ui.id().with("ruler_left"); @@ -821,7 +828,7 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo if r.dragged() && let Some(pp) = r.interact_pointer_pos() { - let pt = ((pp.x - x0) / scale - col_x).clamp(-col_x, page.body.w - 18.0); + let pt = s_at(pp.x).clamp(-col_x, page.body.w - 18.0); let snapped = (pt / 4.5).round() * 4.5; if !r.drag_started() { app.session.join_next_undo(); @@ -833,7 +840,7 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo if r.dragged() && let Some(pp) = r.interact_pointer_pos() { - let pt = (pp.x - x0) / scale - col_x - rp.indent_left; + let pt = s_at(pp.x) - rp.indent_left; if !r.drag_started() { app.session.join_next_undo(); } @@ -844,7 +851,7 @@ fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layo if r.dragged() && let Some(pp) = r.interact_pointer_pos() { - let pt = page.body.w - ((pp.x - x0) / scale - col_x); + let pt = page.body.w - s_at(pp.x); if !r.drag_started() { app.session.join_next_undo(); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2b7f3a1..c00e36e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -28,6 +28,10 @@ pub enum Dialog { spacing: f32, }, Paragraph { + /// Right-to-left reading order (Direction in Word's Paragraph dialog). + rtl: bool, + /// Alignment and indents as seen on the page: `left` is the left side whatever the + /// direction (the model stores them from the paragraph's start edge). align: String, left: f32, right: f32, @@ -166,10 +170,12 @@ impl Dialog { wordcraft_doc::props::LineSpacing::Multiple(m) => m, _ => 1.0, }; + let (vl, vr) = rp.visual_indents(); Dialog::Paragraph { - align: format!("{:?}", rp.align).to_lowercase(), - left: rp.indent_left / 72.0, - right: rp.indent_right / 72.0, + rtl: rp.bidi, + align: format!("{:?}", rp.align.visual(rp.bidi)).to_lowercase(), + left: vl / 72.0, + right: vr / 72.0, first: rp.indent_first / 72.0, before: rp.space_before, after: rp.space_after, @@ -420,8 +426,13 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } - Dialog::Paragraph { align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow } => { + Dialog::Paragraph { rtl, align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow } => { ui.label(egui::RichText::new(tl!("General")).font(semibold(12.5))); + ui.horizontal(|ui| { + ui.label(tl!("Direction:")); + ui.radio_value(rtl, true, tl!("Right-to-left")); + ui.radio_value(rtl, false, tl!("Left-to-right")); + }); egui::ComboBox::from_label(tl!("Alignment")).selected_text(align.clone()).show_ui(ui, |ui| { for a in ["left", "center", "right", "justify"] { ui.selectable_value(align, a.to_string(), a); @@ -456,15 +467,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.checkbox(page_break, tl!("Page break before")); let (ok, cancel) = buttons(ui, tl!("OK")); if ok { - let _ = app.run("para.align", json!({"value": align})); - let _ = app.run( - "para.set", - json!({"props": { - "indentLeft": *left * 72.0, "indentRight": *right * 72.0, "indentFirst": *first * 72.0, - "spaceBefore": *before, "spaceAfter": *after, "lineSpacing": {"rule": "multiple", "value": *line}, - "keepNext": *keep_next, "keepLines": *keep_lines, "pageBreakBefore": *page_break, "widowControl": *widow, - }}), - ); + apply_paragraph(app, *rtl, align, *left, *right, *first, *before, *after, *line, [*keep_next, *keep_lines, *page_break, *widow]); } ok || cancel } @@ -823,3 +826,104 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } } + +/// The Paragraph dialog's OK. `align`, `left` and `right` are as seen on the page; `flags` are +/// keep with next, keep lines together, page break before, widow/orphan control. +#[allow(clippy::too_many_arguments)] +fn apply_paragraph( + app: &mut WordApp, + rtl: bool, + align: &str, + left: f32, + right: f32, + first: f32, + before: f32, + after: f32, + line: f32, + flags: [bool; 4], +) { + let [keep_next, keep_lines, page_break, widow] = flags; + // Direction first: the alignment and indents are as seen on the page, and their logical + // values depend on it. + let _ = app.run(if rtl { "para.rtl" } else { "para.ltr" }, json!({})); + let _ = app.run("para.align", json!({"value": align})); + let (start, end) = if rtl { (right, left) } else { (left, right) }; + let _ = app.run( + "para.set", + json!({"props": { + "indentLeft": start * 72.0, "indentRight": end * 72.0, "indentFirst": first * 72.0, + "spaceBefore": before, "spaceAfter": after, "lineSpacing": {"rule": "multiple", "value": line}, + "keepNext": keep_next, "keepLines": keep_lines, "pageBreakBefore": page_break, "widowControl": widow, + }}), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use wordcraft_doc::props::Align; + use wordcraft_engine::Session; + + fn app_with(text: &str) -> WordApp { + let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + let _ = app.run("document.setText", json!({"text": text})); + app + } + + fn props(app: &WordApp) -> wordcraft_doc::props::ParaProps { + app.session.doc.para_at(&app.session.sel.focus).map(|p| p.props.clone()).unwrap_or_default() + } + + /// Open the Paragraph dialog and press OK without changing anything. + fn ok_unchanged(app: &mut WordApp) { + let Some(Dialog::Paragraph { rtl, align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow }) = + Dialog::open("paragraph", app) + else { + panic!("no paragraph dialog") + }; + apply_paragraph(app, rtl, &align, left, right, first, before, after, line, [keep_next, keep_lines, page_break, widow]); + } + + #[test] + fn paragraph_dialog_shows_direction_and_visual_alignment() { + let mut app = app_with("سلام دنیا"); + let _ = app.run("para.rtl", json!({})); + let _ = app.run("para.set", json!({"props": {"indentLeft": 36.0}})); + let Some(Dialog::Paragraph { rtl, align, left, right, .. }) = Dialog::open("paragraph", &mut app) else { panic!() }; + assert!(rtl); + assert_eq!(align, "right", "a start-aligned right-to-left paragraph shows as right aligned"); + assert_eq!((left, right), (0.0, 0.5), "the start indent is the right one"); + } + + #[test] + fn paragraph_dialog_ok_keeps_what_it_shows() { + for (rtl, align) in [(false, "para.alignCenter"), (false, "para.alignRight"), (true, "para.alignLeft"), (true, "para.alignRight")] { + let mut app = app_with("متن"); + let _ = app.run(if rtl { "para.rtl" } else { "para.ltr" }, json!({})); + let _ = app.run(align, json!({})); + let _ = app.run("para.set", json!({"props": {"indentLeft": 18.0, "indentRight": 9.0}})); + let before = props(&app); + ok_unchanged(&mut app); + let after = props(&app); + assert_eq!((after.bidi, after.align), (before.bidi, before.align), "{align} in rtl={rtl}"); + assert_eq!((after.indent_left, after.indent_right), (Some(18.0), Some(9.0)), "{align} in rtl={rtl}"); + } + } + + #[test] + fn paragraph_dialog_changes_direction() { + let mut app = app_with("Hello"); + let Some(Dialog::Paragraph { align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow, .. }) = + Dialog::open("paragraph", &mut app) + else { + panic!() + }; + // Right to left, aligned right on the page, 1" from the right edge. + apply_paragraph(&mut app, true, "right", left, right + 1.0, first, before, after, line, [keep_next, keep_lines, page_break, widow]); + let _ = align; + let p = props(&app); + assert_eq!(p.bidi, Some(true)); + assert_eq!(p.align, Some(Align::Left), "right on the page is the start edge"); + assert_eq!(p.indent_left, Some(72.0), "the right indent is the start indent"); + } +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2ec44f8..364982e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -572,6 +572,7 @@ Right Derecha Right Border Borde derecho Right-to-Left Text Direction Dirección de texto de derecha a izquierda + Left-to-Right Text Direction Dirección de texto de izquierda a derecha Right: Derecha: Rotate Girar Rounded Rectangle Rectángulo redondeado @@ -796,6 +797,9 @@ Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Muestra los documentos con los colores invertidos, como en Vista › Cambiar modos. Guardar, imprimir y los PDF no cambian. Only web (http, https) and email (mailto) links can be followed Solo se pueden seguir vínculos web (http, https) y de correo electrónico (mailto) Character Border Borde de carácter + Direction: Dirección: + Right-to-left De derecha a izquierda + Left-to-right De izquierda a derecha # Zotero integration Zotero Zotero Citations Citas diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index fad7722..2e0e0b3 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -572,6 +572,7 @@ Right 右 Right Border 右罫線 Right-to-Left Text Direction 右から左への文字方向 + Left-to-Right Text Direction 左から右への文字方向 Right: 右: Rotate 回転 Rounded Rectangle 角丸四角形 @@ -784,6 +785,9 @@ 📂 Browse… 📂 参照… {words} words {words} 語 {selected} of {words} words {selected}/{words} 語 + Direction: 方向: + Right-to-left 右から左 + Left-to-right 左から右 # Zotero integration Zotero Zotero Citations 引用文献 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5c4aabb..200b31c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -573,6 +573,7 @@ Right Direita Right Border Borda direita Right-to-Left Text Direction Direção do texto da direita para a esquerda + Left-to-Right Text Direction Direção do texto da esquerda para a direita Right: Direita: Rotate Girar Rounded Rectangle Retângulo arredondado @@ -787,6 +788,9 @@ Dark page (white text on black) Página escura (texto branco sobre preto) Show documents with their colours inverted, like View › Switch Modes. Saving, printing and PDFs are unchanged. Mostra os documentos com as cores invertidas, como em Exibir › Alternar modos. Salvar, imprimir e PDFs não mudam. Only web (http, https) and email (mailto) links can be followed Só é possível seguir links da web (http, https) e de e-mail (mailto) + Direction: Direção: + Right-to-left Da direita para a esquerda + Left-to-right Da esquerda para a direita Apply Styles Aplicar estilos Scroll ribbon left Rolar a faixa de opções para a esquerda Scroll ribbon right Rolar a faixa de opções para a direita diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index b7b7020..4f52294 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -573,6 +573,7 @@ Right Праворуч Right Border Права межа Right-to-Left Text Direction Напрямок тексту справа наліво + Left-to-Right Text Direction Напрямок тексту зліва направо Right: Праворуч: Rotate Повернути Rounded Rectangle Заокруглений прямокутник @@ -791,6 +792,9 @@ Scroll ribbon left Прокрутити стрічку ліворуч Scroll ribbon right Прокрутити стрічку праворуч Character Border Межа символу + Direction: Напрямок: + Right-to-left Справа наліво + Left-to-right Зліва направо # Zotero integration Zotero Zotero Citations Цитати diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0558ac3..2268855 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -583,6 +583,7 @@ Right 右 Right Border 右框线 Right-to-Left Text Direction 从右向左文字方向 + Left-to-Right Text Direction 从左向右文字方向 Right: 右: Rotate 旋转 Rounded Rectangle 圆角矩形 @@ -795,6 +796,9 @@ 📂 Browse… 📂 浏览… {words} words {words} 个字 {selected} of {words} words {selected}/{words} 个字 + Direction: 方向: + Right-to-left 从右向左 + Left-to-right 从左向右 # Zotero integration Zotero Zotero Citations 引文 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1b1dac3..70a2c54 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -573,6 +573,7 @@ Right 右 Right Border 右框線 Right-to-Left Text Direction 從右至左文字方向 + Left-to-Right Text Direction 從左至右文字方向 Right: 右: Rotate 旋轉 Rounded Rectangle 圓角矩形 @@ -785,6 +786,9 @@ 📂 Browse… 📂 瀏覽… {words} words {words} 個字 {selected} of {words} words {selected}/{words} 個字 + Direction: 方向: + Right-to-left 從右至左 + Left-to-right 從左至右 # Zotero integration Zotero Zotero Citations 引文 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index d0c3752..8ff68a9 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -205,6 +205,19 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { ().then_lines(&pen, 8.0, 17.0, &[7.0, 13.0]) } "justify" => pen.lines(3.0, 17.0, &[4.0, 7.0, 10.0, 13.0, 16.0]), + // Text direction: lines set from the start edge, an arrow in the reading direction. + "textLtr" => { + pen.lines(3.0, 14.0, &[4.0, 8.0]); + pen.lines(3.0, 10.0, &[12.0]); + pen.line_c(&[(3.0, 16.5), (16.5, 16.5)], a); + pen.line_c(&[(13.5, 14.0), (16.5, 16.5), (13.5, 19.0)], a); + } + "textRtl" => { + pen.lines(6.0, 17.0, &[4.0, 8.0]); + pen.lines(10.0, 17.0, &[12.0]); + pen.line_c(&[(17.0, 16.5), (3.5, 16.5)], a); + pen.line_c(&[(6.5, 14.0), (3.5, 16.5), (6.5, 19.0)], a); + } "lineSpacing" => { pen.lines(9.0, 17.0, &[5.0, 10.0, 15.0]); pen.line_c(&[(4.5, 3.0), (4.5, 17.0)], a); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 6d4d9fb..fcf9ded 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -343,7 +343,9 @@ fn home(app: &mut WordApp, ui: &mut Ui) { }); group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| { let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props)); - let align = rp.as_ref().map(|r| r.align).unwrap_or_default(); + // The buttons show alignment as seen on the page (Align Right is a right-to-left paragraph's start). + let align = rp.as_ref().map(|r| r.align.visual(r.bidi)).unwrap_or_default(); + let rtl = rp.as_ref().is_some_and(|r| r.bidi); stack(ui, |ui| { crate::widgets::row(ui, |ui| { split(ui, app, "bullets", "Bullets", "para.bullets", json!({}), false, None, |ui, app| { @@ -397,6 +399,9 @@ fn home(app: &mut WordApp, ui: &mut Ui) { small(ui, app, "alignRight", None, "Align Right", "para.alignRight", json!({}), align == A::Right); small(ui, app, "justify", None, "Justify", "para.justify", json!({}), align == A::Justify); ui.add_space(4.0); + small(ui, app, "textLtr", None, "Left-to-Right Text Direction", "para.ltr", json!({}), !rtl); + small(ui, app, "textRtl", None, "Right-to-Left Text Direction", "para.rtl", json!({}), rtl); + ui.add_space(4.0); menu_button(ui, app, "lineSpacing", None, "Line and Paragraph Spacing", false, |ui, app| { for v in [1.0, 1.15, 1.5, 2.0, 2.5, 3.0] { mi(ui, app, &format!("{v}"), "para.lineSpacing", json!({"value": v})); diff --git a/docs/parity.md b/docs/parity.md index e8389e7..afb6269 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,13 +2,13 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**377 of 427 catalog features have commands (88.3%).** +**378 of 428 catalog features have commands (88.3%).** | Tab | Live | Total | |---|---|---| | File | 20 | 20 | | Quick Access | 4 | 4 | -| Home | 91 | 100 | +| Home | 92 | 101 | | Insert | 42 | 53 | | Draw | 0 | 11 | | Design | 10 | 10 |