Right-to-left and Persian text support (bidi, shaping, RTL paragraphs, DOCX) (#207)
* feat(doc,docx): complex-script run properties and RTL OOXML round-trip Model the formatting Word applies to Persian, Arabic and Hebrew text: cs, font_cs, size_cs, bold_cs, italic_cs and lang_bidi on CharProps, resolved with Word's fallbacks (ResolvedChar::complex). Size, bold and italic pair with their complex-script values so a run's size or bold also covers its Persian characters. Add doc::bidi script helpers, and keep ZWNJ and Arabic marks inside words for word selection. DOCX: read and write w:rFonts/@w:cs, w:szCs, w:bCs, w:iCs, w:cs and w:lang/@w:bidi in schema order; stop using the cs font as the Latin font. Tests round-trip Persian text with ZWNJ, digits and mixed Latin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(fonts): directional shaping and Persian font fallback Add shape_run to shape a run in a known bidi direction (Arabic joining, mirrored brackets). Prefer Persian-capable fonts (Vazirmatn, Noto Naskh/ Sans Arabic…) for right-to-left fallback, pick bold/italic fallback faces, and map common Persian document fonts (B Nazanin, B Lotus, B Titr, IRANSans…) to open substitutes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(layout): Unicode Bidirectional Algorithm and right-to-left paragraphs Run UAX #9 per paragraph (unicode-bidi), shape each level run in its direction, keep marks and ZWNJ with their letter and a word in one fallback face. After line breaking, apply rules L1/L2 per line and give each cluster a visual box; right-to-left paragraphs are laid out from their start edge and mirrored, so indents, tabs, alignment, list labels, drop caps, floats and borders sit on the right. Caret, hit testing and selection use visual geometry (split selections, caret at bidi boundaries, DocLayout::visual_step for arrow keys). Display items carry glyph-to-text ranges, which the PDF writer now uses. Plain left-to-right lines are unchanged. No hyphenation of RTL words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(engine,ui): paragraph direction commands and RTL editing Add para.ltr / para.rtl (Word's text direction buttons) that change only the reading order, and make the alignment buttons visual in right-to-left paragraphs (para.align also takes start/end). Arrow keys move visually in bidirectional text, Ctrl+arrows follow reading order, and Bold/Italic/ Size/Font cover complex-script text. Ribbon buttons with code-drawn icons, a mirrored ruler for RTL paragraphs, translations, catalog, parity and roadmap updates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(contributors): add Saeed Azarehgar to people.toml Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(engine): para.align with an explicit value sets it, without toggling Only the alignment buttons toggle back to the start edge when pressed again. para.align (used by the Paragraph dialog, scripts and agents) now sets the requested alignment, as seen on the page, in each selected paragraph's own direction. Previously pressing OK in the dialog with Center or Right selected reset the paragraph to the start edge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(ui): Direction option in the Paragraph dialog Add Word's Direction (Right-to-left / Left-to-right) to the Paragraph dialog's General section. The dialog now shows alignment and Left/Right indents as seen on the page, mapping them to the paragraph's logical start/end values on OK, so an unchanged OK no longer flips a right-to-left paragraph. OK logic moved to apply_paragraph, with tests; ja/zh translations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(layout): allow hanging trailing spaces in the RTL display test A wrapped right-to-left line's trailing spaces hang past its end edge (the left margin), as left-to-right lines hang them past the right. The test failed whenever the loaded fallback fonts made a line end there; it now checks only visible glyphs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a9eb233a9d
commit
5d86dec250
|
|
@ -4984,6 +4984,7 @@ dependencies = [
|
|||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"unicode-bidi",
|
||||
"unicode-linebreak",
|
||||
"unicode-segmentation",
|
||||
"wordcraft-doc",
|
||||
|
|
|
|||
14
ROADMAP.md
14
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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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('،'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -286,8 +286,26 @@ pub struct CharProps {
|
|||
pub lang: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub no_proof: Option<bool>,
|
||||
/// 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<bool>,
|
||||
/// Format the whole run with the complex-script properties below (OOXML `w:cs`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cs: Option<bool>,
|
||||
/// Complex-script font (Arabic, Persian, Hebrew…; OOXML `w:rFonts/@w:cs`). `None` = `font`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub font_cs: Option<String>,
|
||||
/// 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<f32>,
|
||||
/// 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<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub italic_cs: Option<bool>,
|
||||
/// 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<String>,
|
||||
/// Hyperlink target: a URL, or `#bookmark` for an internal link.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<String>,
|
||||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -41,6 +41,15 @@ pub struct ResolvedChar {
|
|||
pub del: Option<u32>,
|
||||
pub no_proof: bool,
|
||||
pub lang: Option<String>,
|
||||
/// 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<f32>| v.unwrap_or(0.0).clamp(-MAX_INDENT, MAX_INDENT);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
||||
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<u8> {
|
||||
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let doc = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
||||
);
|
||||
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("<w:bidi/>"), "paragraph direction: {xml}");
|
||||
assert!(xml.contains(r#"w:cs="B Nazanin""#), "complex-script font");
|
||||
assert!(xml.contains(r#"<w:szCs w:val="28"/>"#), "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("<w:rFonts") < pos("<w:b/>"));
|
||||
assert!(pos("<w:b/>") < pos("<w:bCs/>") && pos("<w:bCs/>") < pos("<w:i/>"));
|
||||
assert!(pos("<w:i/>") < pos(r#"<w:iCs w:val="0"/>"#));
|
||||
assert!(pos(r#"<w:sz w:val="24"/>"#) < pos("<w:szCs"));
|
||||
assert!(pos("<w:rtl/>") < pos("<w:lang"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_word_style_bidi_markup() {
|
||||
// What a right-to-left paragraph looks like in a Word document: w:bidi, logical
|
||||
// alignment and indents (left = start), runs with w:rtl and the complex-script values.
|
||||
let body = r#"
|
||||
<w:p>
|
||||
<w:pPr><w:bidi/><w:ind w:left="720" w:right="0"/><w:jc w:val="left"/></w:pPr>
|
||||
<w:r><w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:cs="B Lotus"/><w:b/><w:bCs/><w:sz w:val="24"/><w:szCs w:val="32"/><w:rtl/><w:lang w:val="en-US" w:bidi="fa-IR"/></w:rPr><w:t>سلام دنیا</w:t></w:r>
|
||||
<w:r><w:rPr><w:rtl/></w:rPr><w:t xml:space="preserve"> </w:t></w:r>
|
||||
<w:r><w:t>Word 2025</w:t></w:r>
|
||||
</w:p>
|
||||
<w:p><w:pPr><w:bidi w:val="0"/></w:pPr><w:r><w:rPr><w:cs/><w:iCs/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
|
||||
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#"<w:p><w:pPr><w:bidi w:val="banana"/></w:pPr><w:r><w:rPr><w:rFonts w:cs="{long}"/><w:szCs w:val="-5"/><w:szCs w:val="99999999"/><w:lang w:bidi="{long}"/><w:rtl w:val="maybe"/></w:rPr><w:t>ok</w:t></w:r></w:p>"#
|
||||
);
|
||||
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)));
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,8 +14,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -23,11 +23,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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<bool>, 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -353,13 +353,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
})
|
||||
})
|
||||
.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");
|
||||
|
|
|
|||
|
|
@ -156,4 +156,6 @@ pub mod p {
|
|||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_bidi;
|
||||
#[cfg(test)]
|
||||
mod tests_typing;
|
||||
|
|
|
|||
|
|
@ -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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -242,6 +242,26 @@ struct SysFallback {
|
|||
misses: std::collections::HashSet<char>,
|
||||
}
|
||||
|
||||
/// 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<Arc<FontFace>> {
|
||||
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<Arc<FontFace>> {
|
||||
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<Arc<FontFace>> {
|
||||
let faces = self.read_faces();
|
||||
let mut order: Vec<&Arc<FontFace>> = 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<String> = 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) {
|
||||
|
|
|
|||
|
|
@ -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<ShapedGlyph>> = 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<ShapedGlyph> {
|
||||
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<ShapedGlyph>> = 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<ShapedGlyph> {
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
let shaped = face.hb().map(|hb| {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ pub enum Draw {
|
|||
/// The text these glyphs show (for PDF text extraction), and the hyperlink.
|
||||
text: String,
|
||||
link: Option<String>,
|
||||
/// 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<std::ops::Range<usize>>,
|
||||
},
|
||||
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<std::ops::Range<usize>> = 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<usize> = 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 {
|
||||
|
|
|
|||
|
|
@ -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<VisualStep> {
|
||||
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<crate::math::MathLayout>)> {
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Placed>, story: StoryRef, path: &[u32], pl: &Arc<Pa
|
|||
return;
|
||||
};
|
||||
let h = last.top + last.height - first.top;
|
||||
let left = x + pl.rp.indent_left.min(pl.rp.indent_left + pl.rp.indent_first);
|
||||
let right = x + width - pl.rp.indent_right;
|
||||
// Indents are logical: a right-to-left paragraph's start indent (and hanging indent) is on the right.
|
||||
let start = pl.rp.indent_left.min(pl.rp.indent_left + pl.rp.indent_first);
|
||||
let (li, ri) = if pl.rp.bidi { (pl.rp.indent_right, start) } else { (start, pl.rp.indent_right) };
|
||||
let left = x + li;
|
||||
let right = x + width - ri;
|
||||
if let Some(c) = pl.rp.shading {
|
||||
let pad = pl.rp.borders.as_ref().map(|b| b.left.map(|l| l.space).unwrap_or(4.0)).unwrap_or(0.0);
|
||||
items.push(Placed::Fill { rect: Rect::new(left - pad, y, right - left + pad * 2.0, h), color: c });
|
||||
|
|
@ -1114,7 +1118,7 @@ fn place_objects(
|
|||
let rect = if floating {
|
||||
floats.get(&oi).copied().unwrap_or_else(|| float_rect(at.page, at.col, at.para_y, *w, *h, float))
|
||||
} else {
|
||||
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);
|
||||
display::inline_rect(Some(obj), cx, y + (line.baseline - fl.top), c.adv, c.obj_h)
|
||||
};
|
||||
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
|
||||
|
|
@ -1628,3 +1632,5 @@ pub fn now_ms() -> f64 {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_bidi;
|
||||
|
|
|
|||
|
|
@ -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<VisCl>,
|
||||
/// 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<f32> {
|
||||
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<f32> {
|
||||
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<u32>,
|
||||
/// 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<u8>,
|
||||
/// 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<crate::math::MathLayout>)>,
|
||||
/// Display equations (on lines of their own): cluster index and justification.
|
||||
|
|
@ -204,10 +299,79 @@ struct Builder<'a> {
|
|||
style_index: std::collections::HashMap<u64, Vec<(u16, bool)>>,
|
||||
glyphs: Vec<Glyph>,
|
||||
clusters: Vec<Cluster>,
|
||||
/// Bidi level per byte of the paragraph text (empty: all left to right).
|
||||
levels: Vec<u8>,
|
||||
/// 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<FaceRef>,
|
||||
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<u8> {
|
||||
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<usize> {
|
||||
let mut order: Vec<usize> = (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<ResolvedChar>, face_override: Option<FaceRef>, 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<ResolvedChar>, kind_override: Option<ClKind>) {
|
||||
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<ResolvedChar>, kind_override: Option<ClKind>, 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<Arc<ResolvedChar>> = 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<FaceRef>, bool)> = None;
|
||||
let mut segs: Vec<(usize, usize, Option<FaceRef>, bool)> = Vec::new();
|
||||
let mut cur: Option<SegKey> = 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<ResolvedChar>) {
|
||||
let before = self.clusters.len();
|
||||
self.shape(text, start, rc, None);
|
||||
self.shape_with(text, start, rc, None, false);
|
||||
let added: Vec<Cluster> = 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<ResolvedChar> { 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<Cluster> = b.clusters.drain(before_c..).collect();
|
||||
b.shape_with(&text, 0, &rc, None, false);
|
||||
let mut cl: Vec<Cluster> = 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<u8> = 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<Exclusion>;
|
||||
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<f32> = None;
|
||||
let mut hcache: Vec<(u16, u32, f32)> = Vec::new();
|
||||
let mut lines: Vec<Line> = 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<u8> = (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<f32> = (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<usize>, 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<usize> = 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<f32> {
|
||||
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<f32> {
|
||||
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<usize> = (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<usize> {
|
||||
let Some(l) = self.lines.get(li) else { return Vec::new() };
|
||||
let mut v: Vec<usize> = (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 {
|
||||
|
|
|
|||
|
|
@ -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::<usize>::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<f32> = (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<f32> = 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::FaceRef> {
|
||||
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<u32> = 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<usize> = (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<Rect> = 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<Rect> = 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");
|
||||
}
|
||||
|
|
@ -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<usize>],
|
||||
) {
|
||||
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<KrillaGlyph> = glyphs
|
||||
.iter()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -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 引用文献
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 3.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -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 Цитати
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
|
|
@ -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 引文
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -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 引文
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 4.
|
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}));
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue