diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index d98e8d4c7..91940f4eb 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -598,6 +598,35 @@ impl Default for JustificationOptions { } } +/// Insert Kashidas (Paragraph panel): how long the kashidas are that lengthen the Arabic words of +/// a justified paragraph's lines. A kashida is a tatweel (U+0640) drawn between a word's last pair +/// of joined letters, at most one in each word. Short, Medium and Long kashidas are at most a quarter +/// of an em, half an em and an em long; a Stylistic kashida is a row of whole tatweels as the font +/// draws them, at most an em long. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Kashida { + /// No kashidas: justified lines widen their word spaces. + #[default] + None, + Short, + Medium, + Long, + Stylistic, +} + +impl Kashida { + /// The longest kashida, in ems of the letter that follows it in the text; 0 for None. + pub fn max_em(self) -> f64 { + match self { + Kashida::None => 0.0, + Kashida::Short => 0.25, + Kashida::Medium => 0.5, + Kashida::Long | Kashida::Stylistic => 1.0, + } + } +} + /// Paragraph attributes (the Paragraph panel). /// /// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before @@ -645,6 +674,9 @@ pub struct ParaStyle { /// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses, /// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari. pub roman_hanging: bool, + /// Insert Kashidas (Paragraph panel): justified lines lengthen their Arabic words with + /// kashidas before they widen their word spaces. + pub kashida: Kashida, } impl ParaStyle { @@ -706,6 +738,8 @@ struct ParaStyleFile { kinsoku: Kinsoku, #[serde(default, skip_serializing_if = "crate::skip::is_default")] roman_hanging: bool, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + kashida: Kashida, } impl From for ParaStyleFile { @@ -734,6 +768,7 @@ impl From for ParaStyleFile { burasagari, kinsoku, roman_hanging, + kashida, .. } = p; Self { @@ -756,6 +791,7 @@ impl From for ParaStyleFile { burasagari, kinsoku, roman_hanging, + kashida, } } } @@ -782,6 +818,7 @@ impl From for ParaStyle { burasagari, kinsoku, roman_hanging, + kashida, .. } = f; Self { @@ -803,6 +840,7 @@ impl From for ParaStyle { burasagari, kinsoku, roman_hanging, + kashida, } } } diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index c46b5478d..13dc8057d 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -312,6 +312,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "fontAxes", "burasagari", "romanHangingPunctuation", + "kashida", "composer", ]; if !keys.iter().any(|k| p.get(*k).is_some()) { @@ -372,6 +373,17 @@ fn set_format(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(C, "`burasagari` must be \"none\", \"standard\" or \"forced\"")), }), }; + let kashida = match p.get("kashida") { + None => None, + Some(v) => Some(match v.as_str() { + Some("none") => vectorcraft_doc::text::Kashida::None, + Some("short") => vectorcraft_doc::text::Kashida::Short, + Some("medium") => vectorcraft_doc::text::Kashida::Medium, + Some("long") => vectorcraft_doc::text::Kashida::Long, + Some("stylistic") => vectorcraft_doc::text::Kashida::Stylistic, + _ => return Err(bad(C, "`kashida` must be \"none\", \"short\", \"medium\", \"long\" or \"stylistic\"")), + }), + }; let spacing = |k: &str| spacing_param(p, k, C); let (word_spacing, letter_spacing, glyph_scaling) = (spacing("wordSpacing")?, spacing("letterSpacing")?, spacing("glyphScaling")?); let single_word = match p.get("singleWordJustification") { @@ -521,6 +533,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = flag("romanHangingPunctuation") { para.roman_hanging = v; } + if let Some(v) = kashida { + para.kashida = v; + } if let Some(v) = composer { para.composer = v; } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index f841aa25d..38aaed283 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -179,6 +179,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map { ("burasagari", json!(v.burasagari)), ("kinsoku", json!(v.kinsoku)), ("roman_hanging", json!(v.roman_hanging)), + ("kashida", json!(v.kashida)), ] { m.insert(k.into(), x); } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index f95a52663..fa9441150 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -1098,6 +1098,43 @@ mod area_tests { assert!(text(&s).para_at(1).roman_hanging); } + /// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one + /// undo step, the paragraph is saved with it only when it is not None and opens with it, an + /// unknown value changes nothing, and paragraph styles carry it. + #[test] + fn insert_kashidas_is_set_per_paragraph_and_saved_only_when_set() { + use vectorcraft_doc::text::Kashida; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s + .execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "كتب محمد\nعلي", "area": {"width": 300, "height": 200}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + assert_eq!(text(&s).para_at(0).kashida, Kashida::None); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("kashida").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + // Only the first paragraph (the caret's range). + s.execute("text.setFormat", &json!({"kashida": "medium", "start": 0, "end": 0})).unwrap(); + assert_eq!((text(&s).para_at(0).kashida, text(&s).para_at(1).kashida), (Kashida::Medium, Kashida::None)); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["kashida"], "medium"); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.para_at(0).kashida, back.para_at(1).kashida), (Kashida::Medium, Kashida::None)); + assert!(s.execute("text.setFormat", &json!({"kashida": "wide"})).is_err()); + assert_eq!(text(&s).para_at(0).kashida, Kashida::Medium, "an unknown value changes nothing"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s).para_at(0).kashida, Kashida::None); + // A paragraph style carries it. + s.execute("paraStyle.new", &json!({"name": "Stretched", "attrs": {"kashida": "stylistic"}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Stretched", "id": id})).unwrap(); + assert_eq!(text(&s).para_at(1).kashida, Kashida::Stylistic); + } + /// Paragraph panel menu › Justification…: `text.setFormat` sets each paragraph's word and /// letter spacing, glyph scaling, Auto Leading and Single Word Justification, clamped to their /// ranges, in one undo step; Minimum ≤ Desired ≤ Maximum must hold or nothing changes. They diff --git a/crates/text/src/kashida.rs b/crates/text/src/kashida.rs new file mode 100644 index 000000000..7d4cde0c9 --- /dev/null +++ b/crates/text/src/kashida.rs @@ -0,0 +1,185 @@ +//! Paragraph panel › Insert Kashidas: justified lines lengthen their Arabic words with kashidas. +//! +//! [`plan`] picks, in each Arabic word of a justified line, the place between its last pair of +//! joined letters, and shares the line's room among those places, each kashida up to its +//! [`Kashida::max_em`]. The layout draws each kashida as tatweels (U+0640) in the face of the +//! letter after it, and the word spaces take the room the kashidas leave. +//! +//! Behavior reference (public documentation): +//! +//! and . + +use std::ops::Range; + +use vectorcraft_doc::text::Kashida; + +use crate::shape::SGlyph; + +/// The tatweel (kashida) character. +pub(crate) const TATWEEL: char = '\u{0640}'; + +/// How a character joins its neighbors in cursive Arabic, from the Unicode Character Database's +/// `ArabicShaping.txt` for the Arabic block (U+0600 to U+06FF). Other characters do not join, +/// except the zero width joiner, and the nonspacing and enclosing marks and format characters +/// that the file does not list, which joining goes across. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Joining { + /// Joins the letters on both sides. + Dual, + /// Joins the letter before it only. + Right, + /// Joins both sides without changing its own form (the tatweel, the zero width joiner). + Causing, + /// Skipped over: joining goes on across it (nonspacing and enclosing marks, format + /// characters). + Transparent, + None, +} + +fn joining(c: char) -> Joining { + use unicode_general_category::{GeneralCategory, get_general_category}; + match c { + '\u{0620}' + | '\u{0626}' + | '\u{0628}' + | '\u{062A}'..='\u{062E}' + | '\u{0633}'..='\u{063F}' + | '\u{0641}'..='\u{0647}' + | '\u{0649}' + | '\u{064A}' => Joining::Dual, + '\u{066E}' | '\u{066F}' | '\u{0678}'..='\u{0687}' | '\u{069A}'..='\u{06BF}' | '\u{06C1}' | '\u{06C2}' | '\u{06CC}' | '\u{06CE}' => { + Joining::Dual + } + '\u{06D0}' | '\u{06D1}' | '\u{06FA}'..='\u{06FC}' | '\u{06FF}' => Joining::Dual, + '\u{0622}'..='\u{0625}' + | '\u{0627}' + | '\u{0629}' + | '\u{062F}'..='\u{0632}' + | '\u{0648}' + | '\u{0671}'..='\u{0673}' + | '\u{0675}'..='\u{0677}' => Joining::Right, + '\u{0688}'..='\u{0699}' + | '\u{06C0}' + | '\u{06C3}'..='\u{06CB}' + | '\u{06CD}' + | '\u{06CF}' + | '\u{06D2}' + | '\u{06D3}' + | '\u{06D5}' + | '\u{06EE}' + | '\u{06EF}' => Joining::Right, + TATWEEL | '\u{200D}' => Joining::Causing, + '\u{0600}'..='\u{0605}' | '\u{06DD}' | '\u{200C}' => Joining::None, + _ if matches!(get_general_category(c), GeneralCategory::NonspacingMark | GeneralCategory::EnclosingMark | GeneralCategory::Format) => { + Joining::Transparent + } + _ => Joining::None, + } +} + +/// Is the cluster of glyph `a` joined to the cluster of glyph `b` that follows it in `text`: both +/// right to left in one face, `a`'s last character other than a mark joining the letter after it, +/// and `b` starting with a letter that joins the one before it? +fn joined(text: &str, a: &SGlyph, b: &SGlyph) -> bool { + if a.face.id() != b.face.id() || a.level != b.level || !a.level.is_rtl() || a.byte + a.len != b.byte { + return false; + } + let last = text.get(a.byte..a.byte + a.len).and_then(|s| s.chars().rev().find(|&c| joining(c) != Joining::Transparent)); + let first = text.get(b.byte..b.byte + b.len).and_then(|s| s.chars().next()); + matches!(last.map(joining), Some(Joining::Dual | Joining::Causing)) + && matches!(first.map(joining), Some(Joining::Dual | Joining::Right | Joining::Causing)) +} + +/// The tatweel of `g`'s face and its advance in points at `g`'s scale; None when the face has +/// no tatweel. +pub(crate) fn tatweel(g: &SGlyph) -> Option<(u32, f64)> { + let gid = g.face.glyph_for(TATWEEL); + let advance = g.face.advance(gid) * g.sx; + (gid != 0 && advance.is_finite() && advance > 0.0).then_some((gid, advance)) +} + +/// The most tatweels one Stylistic kashida is drawn with. +pub(crate) const MAX_TATWEELS: f64 = 64.0; + +/// The kashidas of the glyphs `line` (indices into `sg`, a paragraph's glyphs in logical order; +/// `text` is the plain text) of a justified line with `room` points to fill: for each Arabic +/// word with joined letters, at its last pair of them, the byte offset of the cluster after the kashida and +/// the kashida's length, in text order. Words end at spaces, inline graphics, and characters other than letters +/// and marks (punctuation, digits, symbols). Every kashida takes an equal share of `room`, up to +/// `kind`'s longest in ems of the letter after it (as wide as its horizontal scale draws it); a +/// Stylistic kashida is a whole number of tatweels, at most [`MAX_TATWEELS`], and a word whose +/// share is shorter than one tatweel gets none. A line of words without joined letters, or whose +/// font has no tatweel, gets none. +pub(crate) fn plan(text: &str, sg: &[SGlyph], line: Range, kind: Kashida, room: f64) -> Vec<(usize, f64)> { + if kind == Kashida::None || !room.is_finite() || room <= 0.0 { + return vec![]; + } + // The first glyph of the cluster after each word's last joint. + let mut picks: Vec = Vec::new(); + let mut word: Option = None; + let mut prev: Option = None; + let mut j = line.start; + while j < line.end { + let Some(g) = sg.get(j) else { break }; + let end = (j + 1..line.end).find(|&e| sg.get(e).is_none_or(|n| n.byte != g.byte)).unwrap_or(line.end); + let first = text.get(g.byte..g.byte + g.len).and_then(|s| s.chars().next()); + let not_a_letter = first.is_some_and(|c| !c.is_alphabetic() && joining(c) != Joining::Transparent); + if g.is_space() || g.inline.is_some() || not_a_letter { + picks.extend(word.take()); + prev = None; + } else { + if prev.and_then(|p| sg.get(p)).is_some_and(|a| joined(text, a, g)) && tatweel(g).is_some() { + word = Some(j); + } + prev = Some(j); + } + j = end; + } + picks.extend(word); + if picks.is_empty() { + return vec![]; + } + let share = room / picks.len() as f64; + let mut out = Vec::with_capacity(picks.len()); + for g in picks.iter().filter_map(|&k| sg.get(k)) { + let Some((_, t)) = tatweel(g) else { continue }; + let longest = kind.max_em() * g.face.units_per_em() * g.sx; + let mut len = share.min(longest); + if kind == Kashida::Stylistic { + len = (len / t).floor().min(MAX_TATWEELS) * t; + } + if len.is_finite() && len > 1e-6 { + out.push((g.byte, len)); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Beh joins on both sides, alef and waw only to the letter before them, hamza to neither; + /// marks and the format characters the file does not list are skipped, the number signs and + /// the end of ayah it lists join neither side, and the tatweel and the zero width joiner join + /// both sides. + #[test] + fn arabic_letters_join_as_arabic_shaping_lists_them() { + assert_eq!(joining('ب'), Joining::Dual); + assert_eq!(joining('ي'), Joining::Dual); + assert_eq!(joining('ک'), Joining::Dual); + assert_eq!(joining('ا'), Joining::Right); + assert_eq!(joining('و'), Joining::Right); + assert_eq!(joining('ة'), Joining::Right); + assert_eq!(joining('ء'), Joining::None); + assert_eq!(joining('\u{064E}'), Joining::Transparent); + assert_eq!(joining('\u{061C}'), Joining::Transparent); + assert_eq!(joining('\u{0600}'), Joining::None); + assert_eq!(joining('\u{0605}'), Joining::None); + assert_eq!(joining('\u{06DD}'), Joining::None); + assert_eq!(joining(TATWEEL), Joining::Causing); + assert_eq!(joining('\u{200D}'), Joining::Causing); + assert_eq!(joining('\u{200C}'), Joining::None); + assert_eq!(joining('a'), Joining::None); + } +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 8637ea1cc..fd444fdfe 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -4,6 +4,7 @@ use std::ops::Range; use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape, Vec2}; use unicode_bidi::{BidiInfo, Level}; +use vectorcraft_doc::text::Kashida; use vectorcraft_doc::{ Burasagari, CharStyle, HyphenationOptions, InlineArt, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, SingleWordJustify, TextKind, TextObject, @@ -14,6 +15,7 @@ use crate::composer::{Breakpoint, Params, compose}; use crate::fontdb::FontDb; use crate::hyphen::hyphen_points_with; use crate::justify::{Flex, LineFit, apply_desired, fit_line, is_word_space}; +use crate::kashida; use crate::shape::{ Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, mark_word_ends, no_line_end, no_line_start, punct, shape_range, soft_line_start, style_metrics, @@ -140,6 +142,34 @@ impl Ctx<'_> { scale: 1.0, }); } + + /// Draw a kashida `len` points long from `at` (the pen on the baseline) on line `line`, as + /// tatweels in the face, size and style of glyph `g`. Short, Medium and Long kashidas are one + /// tatweel stretched along the baseline; a Stylistic one is tatweels side by side. Each tatweel + /// is a glyph at `g`'s byte offset that stands for no character (length 0) and does not + /// advance: `g`'s advance takes the kashida in. + fn emit_kashida(&mut self, g: &SGlyph, kind: Kashida, len: f64, at: Point, line: usize) { + let Some((gid, t)) = kashida::tatweel(g) else { return }; + let mut k = g.clone(); + k.gid = gid; + k.adv = t; + k.dx = 0.0; + k.dy = 0.0; + k.len = 0; + k.lead = 0.0; + k.ch = kashida::TATWEEL; + k.inline = None; + k.tcy = None; + if kind == Kashida::Stylistic { + let n = (len / t).round().clamp(1.0, kashida::MAX_TATWEELS) as usize; + for m in 0..n { + let x = at.x + m as f64 * t; + self.emit(&k, Affine::translate((x, at.y)), Point::new(x, at.y), 0.0, 0.0, line); + } + return; + } + self.emit(&k, Affine::translate((at.x, at.y)) * Affine::scale_non_uniform(len / t, 1.0), at, 0.0, 0.0, line); + } } /// Lay out a text object into text-space glyph outlines (default [`LayoutOptions`]). @@ -1934,6 +1964,11 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio (true, SingleWordJustify::AlignCenter) => (1, false), (true, SingleWordJustify::AlignRight) => (2, false), }; + // Insert Kashidas: the Arabic words of a horizontal line take its room on kashidas, + // and the word spaces and gaps take what is left. + let kashidas = + if justify && !cx.vertical && room > w + EPS { kashida::plan(cx.text, &sg, i..body, para.kashida, room - w) } else { vec![] }; + let w = w + kashidas.iter().map(|k| k.1).sum::(); if justify && (room - w).abs() > EPS { let line_flex = |j: usize| if held.contains(&j) { Flex::default() } else { flex.get(j).copied().unwrap_or_default() }; if cjk_gaps > 0 && room > w { @@ -1989,7 +2024,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio let leading_space_width: f64 = order.iter().take_while(|&&offset| i + offset >= trimmed).filter_map(|&offset| sg.get(i + offset)).map(|g| g.adv).sum(); let mut x = start_x - leading_space_width; - for offset in order { + for (oi, &offset) in order.iter().enumerate() { let j = i + offset; let Some(g) = sg.get(j) else { continue }; let mut adv = g.adv; @@ -2020,6 +2055,16 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio adv += per_gap; } } + // The kashida before a cluster in the text is drawn at the cluster's right, between + // it and the letter before it, and the cluster's last glyph advances over it: the + // caret, selection and hit testing take it as part of the cluster. + let kashida = kashidas + .binary_search_by_key(&g.byte, |k| k.0) + .ok() + .and_then(|at| kashidas.get(at)) + .filter(|_| order.get(oi + 1).and_then(|&o| sg.get(i + o)).is_none_or(|next| next.byte != g.byte)) + .map(|k| k.1); + adv += kashida.unwrap_or(0.0); let y = baseline - align_shift(g, cx.style_at(g.byte).char_align, line_em, cx.vertical); let place = if scale != 1.0 { Affine::translate((x, y)) * Affine::scale_non_uniform(scale, 1.0) } else { Affine::translate((x, y)) }; cx.emit(g, place, Point::new(x, y), 0.0, adv, li); @@ -2034,6 +2079,9 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio tab.outline.extend(dots); } } + if let Some(len) = kashida { + cx.emit_kashida(g, para.kashida, len, Point::new(x + adv - len, y), li); + } x += adv; if j < trimmed { x_end = x; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index f7701b3a1..b3d1294a3 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -19,6 +19,7 @@ mod fontconfig; mod fontdb; pub mod hyphen; mod justify; +mod kashida; mod layout; mod shape; #[cfg(not(target_arch = "wasm32"))] @@ -286,15 +287,36 @@ fn dir(angle: f64) -> Vec2 { Vec2::new(angle.cos(), angle.sin()) } -/// Caret for byte offset `byte`, as a (top, bottom) segment in text space. +/// The left and right x of the cluster of `glyphs[i]` (glyphs of one line of horizontal type +/// that is not on a path): the glyphs next to it with its byte offset and length, and the +/// tatweels of a kashida after it (length 0, the same byte offset). None for vertical type and +/// type on a path. +fn cluster_extent(layout: &TextLayout, glyphs: &[PositionedGlyph], i: usize) -> Option<(f64, f64)> { + let g = glyphs.get(i).filter(|_| !layout.vertical && !layout.on_path)?; + let same = |o: &PositionedGlyph| o.byte == g.byte && (o.len == g.len || o.len == 0); + let start = glyphs.get(..i)?.iter().rposition(|o| !same(o)).map_or(0, |p| p + 1); + let end = glyphs.get(i..)?.iter().position(|o| !same(o)).map_or(glyphs.len(), |p| i + p); + let span = glyphs.get(start..end)?; + let left = span.iter().map(|o| o.origin.x).fold(f64::INFINITY, f64::min); + let right = span.iter().map(|o| o.origin.x + o.advance).fold(f64::NEG_INFINITY, f64::max); + (left.is_finite() && right.is_finite()).then_some((left, right)) +} + +/// Caret for byte offset `byte`, as a (top, bottom) segment in text space. In horizontal type, a +/// cluster drawn with several glyphs is measured whole ([`cluster_extent`]). pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) { let Some(line) = layout.lines.get(layout.line_of(byte)) else { return (Point::ZERO, Point::ZERO); }; let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; - let (pos, angle) = if let Some(g) = glyphs.iter().find(|g| g.byte <= byte && g.byte + g.len > byte) { + let (pos, angle) = if let Some(i) = glyphs.iter().position(|g| g.byte <= byte && g.byte + g.len > byte) { + let g = &glyphs[i]; let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 }; - (g.origin + dir(g.angle) * (g.advance * if g.rtl { 1.0 - frac } else { frac }), g.angle) + let (origin, advance) = match cluster_extent(layout, glyphs, i) { + Some((left, right)) => (Point::new(left, g.origin.y), right - left), + None => (g.origin, g.advance), + }; + (origin + dir(g.angle) * (advance * if g.rtl { 1.0 - frac } else { frac }), g.angle) } else if let Some(g) = glyphs.iter().max_by_key(|g| g.byte + g.len) { (g.origin + dir(g.angle) * if g.rtl { 0.0 } else { g.advance }, g.angle) } else { @@ -359,13 +381,15 @@ pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize { let Some(line) = layout.lines.get(li) else { return 0 }; let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; // A cluster drawn with several adjacent glyphs (a Devanagari pre-base matra and its consonant, - // a consonant and its reph) has one caret stop at each end of its glyphs taken together. + // a consonant and its reph) has one caret stop at each end of its glyphs taken together. The + // tatweels of a kashida have no source length and come after the glyphs of the letter after + // the kashida in the text; they belong to that letter's cluster. let mut clusters: Vec<(f64, f64, &PositionedGlyph)> = Vec::with_capacity(glyphs.len()); for g in glyphs { let left = layout.logical_point(g.origin).x; let right = left + g.advance; match clusters.last_mut() { - Some((l, r, c)) if c.byte == g.byte && c.len == g.len => (*l, *r) = (l.min(left), r.max(right)), + Some((l, r, c)) if c.byte == g.byte && (c.len == g.len || g.len == 0) => (*l, *r) = (l.min(left), r.max(right)), _ => clusters.push((left, right, g)), } } @@ -402,9 +426,11 @@ pub fn line_end_of(layout: &TextLayout, byte: usize) -> usize { fn x_in_line(layout: &TextLayout, li: usize, byte: usize) -> f64 { let line = &layout.lines[li]; let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; - if let Some(g) = glyphs.iter().find(|g| g.byte <= byte && g.byte + g.len > byte) { + if let Some(i) = glyphs.iter().position(|g| g.byte <= byte && g.byte + g.len > byte) { + let g = &glyphs[i]; let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 }; - return layout.logical_point(g.origin).x + g.advance * if g.rtl { 1.0 - frac } else { frac }; + let (left, advance) = cluster_extent(layout, glyphs, i).map_or((layout.logical_point(g.origin).x, g.advance), |(l, r)| (l, r - l)); + return left + advance * if g.rtl { 1.0 - frac } else { frac }; } glyphs.iter().max_by_key(|g| g.byte + g.len).map_or(line.x0, |g| layout.logical_point(g.origin).x + if g.rtl { 0.0 } else { g.advance }) } diff --git a/crates/text/src/tests_justify.rs b/crates/text/src/tests_justify.rs index 2c2c92d1c..709bf1b4e 100644 --- a/crates/text/src/tests_justify.rs +++ b/crates/text/src/tests_justify.rs @@ -3,6 +3,7 @@ use super::*; use kurbo::{Affine, Shape}; +use vectorcraft_doc::text::Kashida; use vectorcraft_doc::{CharStyle, JustificationOptions, Justify, SingleWordJustify, TextKind}; use vectorcraft_geom::PathData; @@ -136,6 +137,173 @@ fn single_word_lines_follow_single_word_justification() { assert!((c0 - 15.0).abs() < 1e-3 && (c1 - (w - 15.0)).abs() < 1e-3, "centred: {c0}..{c1}"); } +/// An OpenType (GSUB) Arabic face with a tatweel: craft-fonts' Noto Sans Arabic when the build has +/// craft-fonts (`CRAFT_FONTS_DIR`), else an installed one. None skips the test. +fn arabic_face(test: &str) -> Option> { + let opentype = |face: &FontFace| { + use skrifa::raw::TableProvider; + face.skrifa().is_some_and(|f| f.gsub().is_ok()) + }; + let face = ["Noto Sans Arabic", "Arial", "Geeza Pro"] + .into_iter() + .filter_map(|name| db().face(name, "Regular")) + .find(|face| face.covers('ب') && face.covers('د') && face.covers('\u{0640}') && opentype(face)); + match &face { + Some(face) => eprintln!("ASSERTIONS RUN: {test}; Arabic font {} {}", face.family, face.style), + None => eprintln!("SKIPPED: {test}; no font covers Arabic and the tatweel"), + } + face +} + +/// Arabic type at 20 pt in `face`, in a frame 300 pt wide, aligned as `justify` with `kashida`. +fn arabic_area(face: &FontFace, text: &str, justify: Justify, kashida: Kashida) -> TextObject { + let st = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, ..CharStyle::default() }; + let mut t = TextObject::point(Point::ZERO, text, st); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 300.0, 1000.0).to_path(0.1)) }; + t.xf = Affine::IDENTITY; + t.para.justify = justify; + t.para.kashida = kashida; + t +} + +/// The kashida glyphs of a layout: tatweels that stand for no character. +fn kashidas<'a>(l: &'a TextLayout, face: &FontFace) -> Vec<&'a PositionedGlyph> { + let tatweel = face.glyph_for('\u{0640}'); + l.glyphs.iter().filter(|g| g.len == 0 && g.gid == tatweel).collect() +} + +/// Insert Kashidas: a justified Arabic line lengthens each word with one kashida between its last +/// two joined letters, up to a quarter, half or whole em (Stylistic: whole tatweels up to an em), +/// before it widens the word space. The line keeps its width, the kashida fills the gap between +/// the letters it joins, and None leaves the line as it was. +#[test] +fn justified_arabic_lines_take_their_room_on_kashidas() { + let Some(face) = arabic_face("justified_arabic_lines_take_their_room_on_kashidas") else { return }; + // Two words whose last letters join (teh and beh, meem and dal) and one whose letters (waw, reh, + // dal) join none after them. + let text = "كتب ورد محمد"; + let spaces: Vec = text.match_indices(' ').map(|(b, _)| b).collect(); + let off = layout(db(), &arabic_area(&face, text, Justify::JustifyAll, Kashida::None)); + assert!(off.glyphs.iter().all(|g| g.len > 0), "no kashidas without Insert Kashidas"); + let measure = |l: &TextLayout| l.lines[0].x1 - l.lines[0].x0; + assert!((measure(&off) - 300.0).abs() < 1e-6, "the line fills the frame: {}", measure(&off)); + let space = at(&off, spaces[0]).advance; + let tatweel = face.advance(face.glyph_for('\u{0640}')) * 20.0 / face.units_per_em(); + for (kind, longest) in [(Kashida::Short, 5.0), (Kashida::Medium, 10.0), (Kashida::Long, 20.0), (Kashida::Stylistic, 20.0)] { + let on = layout(db(), &arabic_area(&face, text, Justify::JustifyAll, kind)); + let k = kashidas(&on, &face); + let mut bytes: Vec = k.iter().map(|g| g.byte).collect(); + bytes.sort_unstable(); + bytes.dedup(); + assert_eq!(bytes, ["كتب".len() - 'ب'.len_utf8(), text.len() - 'د'.len_utf8()], "{kind:?}: before the last letter of each word"); + let each = if kind == Kashida::Stylistic { (longest / tatweel).floor() * tatweel } else { longest }; + let mut total = 0.0; + for &b in &bytes { + let (start, end, count) = kashida_span(&on, &face, b); + assert!((end - start - each).abs() < 1e-6, "{kind:?}: {start}..{end}"); + total += end - start; + if kind == Kashida::Stylistic { + assert!((count as f64 * tatweel - each).abs() < 1e-6, "{kind:?}: whole tatweels"); + } + // The letter after the kashida in the text advances over it to the letter before it, + // so the caret before that letter and a selection of it reach the letter before it. + let after = on.glyphs.iter().filter(|o| o.byte == b && o.len > 0); + let len = after.clone().map(|o| o.len).max().unwrap(); + let ink = after.clone().map(|o| o.origin.x).fold(f64::MAX, f64::min); + assert!(ink < start && (after.map(|o| o.origin.x + o.advance).fold(f64::MIN, f64::max) - end).abs() < 1e-6, "{kind:?}"); + let caret = caret_position(&on, b).0.x; + assert!((caret - end).abs() < 1e-6, "{kind:?}: the caret before the letter: {caret} {end}"); + let x_max = selection_quads(&on, b, b + len).iter().flatten().map(|p| p.x).fold(f64::MIN, f64::max); + assert!((x_max - end).abs() < 1e-6, "{kind:?}: the selection takes the kashida in: {x_max} {end}"); + } + assert!((measure(&on) - measure(&off)).abs() < 1e-6, "{kind:?}: same width"); + for &b in &spaces { + assert!((at(&on, b).advance - (space - total / 2.0)).abs() < 1e-6, "{kind:?}: the spaces give up the kashidas' length"); + } + } + // A click on a letter and its kashida goes to the nearer edge of the two taken together: dal is + // narrower than a Long kashida. + let on = layout(db(), &arabic_area(&face, text, Justify::JustifyAll, Kashida::Long)); + let dal = text.len() - 'د'.len_utf8(); + let (start, end, _) = kashida_span(&on, &face, dal); + let ink = on.glyphs.iter().filter(|o| o.byte == dal && o.len > 0).map(|o| o.origin.x).fold(f64::MAX, f64::min); + let middle = (ink + end) / 2.0; + assert!(start < middle, "the kashida is longer than the letter: {ink} {start} {end}"); + assert_eq!(byte_in_line(&on, 0, (start + middle) / 2.0), text.len(), "left of the middle: the letter's left edge"); + assert_eq!(byte_in_line(&on, 0, (middle + end) / 2.0), dal, "right of the middle: its right edge"); + // A letter with a vowel mark is a cluster of two glyphs: the caret before it and a selection of + // it still reach the letter before the kashida. + let marked = "كتبَ ورد"; + let on = layout(db(), &arabic_area(&face, marked, Justify::JustifyAll, Kashida::Medium)); + let beh = "كت".len(); + let (_, end, _) = kashida_span(&on, &face, beh); + assert!(on.glyphs.iter().filter(|o| o.byte == beh && o.len > 0).count() >= 2, "beh and fatha"); + assert!((caret_position(&on, beh).0.x - end).abs() < 1e-6, "{} {end}", caret_position(&on, beh).0.x); + let x_max = selection_quads(&on, beh, "كتبَ".len()).iter().flatten().map(|p| p.x).fold(f64::MIN, f64::max); + assert!((x_max - end).abs() < 1e-6, "{x_max} {end}"); + // Punctuation ends a word as a space does. + let text = "كتب/محمد"; + let on = layout(db(), &arabic_area(&face, text, Justify::JustifyAll, Kashida::Medium)); + let mut bytes: Vec = kashidas(&on, &face).iter().map(|g| g.byte).collect(); + bytes.sort_unstable(); + assert_eq!(bytes, ["كتب".len() - 'ب'.len_utf8(), text.len() - 'د'.len_utf8()]); + // At 50% horizontal scale an em is half as wide; the line still fills the frame. + for kind in [Kashida::Long, Kashida::Stylistic] { + let text = "كتب ورد محمد"; + let mut t = arabic_area(&face, text, Justify::JustifyAll, kind); + for r in &mut t.runs { + r.style.h_scale = 50.0; + } + let half = layout(db(), &t); + let each = if kind == Kashida::Stylistic { (10.0 / (tatweel * 0.5)).floor() * tatweel * 0.5 } else { 10.0 }; + for b in ["كتب".len() - 'ب'.len_utf8(), text.len() - 'د'.len_utf8()] { + let (start, end, _) = kashida_span(&half, &face, b); + assert!((end - start - each).abs() < 1e-6, "{kind:?} at 50%: {start}..{end}"); + } + assert!((measure(&half) - 300.0).abs() < 1e-6, "{kind:?} at 50%: {}", measure(&half)); + } +} + +/// Where the kashida before the cluster at byte `b` starts, where the letter before it in the text +/// starts (the kashida's end), and how many tatweels draw it. +fn kashida_span(l: &TextLayout, face: &FontFace, b: usize) -> (f64, f64, usize) { + let k: Vec<_> = kashidas(l, face).into_iter().filter(|g| g.byte == b).collect(); + let start = k.iter().map(|g| g.origin.x).fold(f64::MAX, f64::min); + let end = l.glyphs.iter().filter(|o| o.byte + o.len == b && o.len > 0).map(|o| o.origin.x).fold(f64::MAX, f64::min); + (start, end, k.len()) +} + +/// Insert Kashidas changes only justified horizontal lines of Arabic: right-aligned and Auto +/// Arabic paragraphs, a justified Latin one and justified vertical Arabic type are set as with +/// None. +#[test] +fn kashidas_leave_unjustified_and_latin_lines_as_they_were() { + let same = |a: &TextLayout, b: &TextLayout| { + assert_eq!(a.glyphs.len(), b.glyphs.len()); + for (x, y) in a.glyphs.iter().zip(&b.glyphs) { + assert_eq!((x.gid, x.byte, x.len, x.origin, x.advance), (y.gid, y.byte, y.len, y.origin, y.advance)); + } + }; + let latin = |k: Kashida| { + let mut t = area("ab cd", 300.0, Justify::JustifyAll, JustificationOptions::default()); + t.para.kashida = k; + layout(db(), &t) + }; + same(&latin(Kashida::Long), &latin(Kashida::None)); + let Some(face) = arabic_face("kashidas_leave_unjustified_and_latin_lines_as_they_were") else { return }; + let arabic = |j: Justify, k: Kashida| layout(db(), &arabic_area(&face, "كتب محمد", j, k)); + same(&arabic(Justify::Right, Kashida::Long), &arabic(Justify::Right, Kashida::None)); + same(&arabic(Justify::Auto, Kashida::Medium), &arabic(Justify::Auto, Kashida::None)); + assert!(!kashidas(&arabic(Justify::JustifyAll, Kashida::Medium), &face).is_empty()); + // Vertical type is set as with None too. + let vertical = |k: Kashida| { + let mut t = arabic_area(&face, "كتب محمد", Justify::JustifyAll, k); + t.vertical = true; + layout(db(), &t) + }; + same(&vertical(Kashida::Medium), &vertical(Kashida::None)); +} + /// Auto Leading: characters whose leading is Auto are set at the paragraph's share of their size; /// an explicit leading is kept. #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 324a3654f..135ef76ab 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1789,6 +1789,9 @@ Modified: Модифицирано: Modify Модифициране Mojikumi Моджикуми + Insert Kashidas Вмъкване на кашиди + Long Дълго + Stylistic Стилистично Mojikumi Set Набор Mojikumi Kinsoku Set Набор Kinsoku Hard Твърдо diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 0ef1e6b56..03b915463 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1756,6 +1756,9 @@ lighting Light Světlo Left Indent Odsazení zleva Left-to-Right Paragraph Direction Směr odstavce zleva doprava Line-end Punctuation Half Width Interpunkce na konci řádku poloviční šířky + Insert Kashidas Vložit kašídy + Long Dlouhý + Stylistic Stylistický Mojikumi Set Sada Mojikumi Kinsoku Set Sada Kinsoku Hard Pevné diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 9b0403845..0ec4cb936 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1573,6 +1573,9 @@ lighting Light Lys Left Indent Venstre indrykning Left-to-Right Paragraph Direction Afsnitsretning fra venstre mod højre Line-end Punctuation Half Width Skilletegn i halv bredde ved linjeslut + Insert Kashidas Indsæt kashidaer + Long Lang + Stylistic Stilistisk Mojikumi Set Mojikumi-sæt Kinsoku Set Kinsoku-sæt Hard Hård diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 098f42215..f27da2e10 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1838,6 +1838,9 @@ file-filter Templates Vorlagen Modified: Geändert: Modify Ändern Mojikumi Mojikumi + Insert Kashidas Kashidas einfügen + Long Lang + Stylistic Stilistisch Mojikumi Set Mojikumi-Satz Kinsoku Set Kinsoku-Satz Hard Hart diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 3f97c9809..70fa0461a 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2549,6 +2549,9 @@ theme Appearance Εμφάνιση Modified since it was read: {path} Τροποποιήθηκε μετά την ανάγνωση: {path} Modified: Τροποποιήθηκε: Mojikumi Mojikumi + Insert Kashidas Εισαγωγή κασίντα + Long Μακρύ + Stylistic Στιλιστικό Mojikumi Set Σύνολο Mojikumi Kinsoku Set Σύνολο Kinsoku Mono Μονοφωνικό diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ba9e17619..0fec9d8dd 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1830,6 +1830,9 @@ Modified: Modificado: Modify Modificar Mojikumi Mojikumi + Insert Kashidas Insertar kashidas + Long Larga + Stylistic Estilística Mojikumi Set Conjunto de mojikumi Kinsoku Set Conjunto de kinsoku Hard Fuerte diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 85435572f..4ea604801 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1719,6 +1719,9 @@ lighting Light Valo Left Indent Vasen sisennys Left-to-Right Paragraph Direction Kappaleen suunta vasemmalta oikealle Line-end Punctuation Half Width Rivinlopun välimerkit puolileveinä + Insert Kashidas Lisää kashidat + Long Pitkä + Stylistic Tyylitelty Mojikumi Set Mojikumi-joukko Kinsoku Set Kinsoku-joukko Hard Tiukka diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 56781916e..d78900e8a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1829,6 +1829,9 @@ Modified: Modifié : Modify Modifier Mojikumi Mojikumi + Insert Kashidas Insérer des kashidas + Long Longue + Stylistic Stylistique Mojikumi Set Jeu de mojikumi Kinsoku Set Jeu de kinsoku Hard Fort diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 430a39c51..d1188b811 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2316,6 +2316,9 @@ file-filter Templates Sablonok Modified: Módosítva: Modify Módosítás Mojikumi Mojikumi + Insert Kashidas Kasidák beszúrása + Long Hosszú + Stylistic Stilisztikai Mojikumi Set Mojikumi-készlet Monitor RGB Monitor-RGB Mono Egyszínű diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index ee6078b95..b12036dae 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1573,6 +1573,9 @@ lighting Light Cahaya Left Indent Indentasi kiri Left-to-Right Paragraph Direction Arah paragraf kiri ke kanan Line-end Punctuation Half Width Tanda baca akhir baris lebar setengah + Insert Kashidas Sisipkan kashida + Long Panjang + Stylistic Stilistik Mojikumi Set Set Mojikumi Kinsoku Set Set Kinsoku Hard Keras diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 552047213..4cd46733c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1856,6 +1856,9 @@ file-filter Templates Modelli Modified: Modificato: Modify Modifica Mojikumi Mojikumi + Insert Kashidas Inserisci kashida + Long Lunga + Stylistic Stilistica Mojikumi Set Set mojikumi Kinsoku Set Set kinsoku Hard Forte diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d3bf43095..6a660a101 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -48,6 +48,9 @@ Japanese 日本語 Join Our Discord Discord に参加 Line-end Punctuation Half Width 行末約物半角 + Insert Kashidas カシーダを挿入 + Long 長い + Stylistic スタイリスティック Mojikumi Set 文字組み Kinsoku Set 禁則処理 Hard 強い禁則 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 686304370..4334f70e7 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1475,6 +1475,9 @@ lighting Light სინათლე Left Indent მარცხენა შეწევა Left-to-Right Paragraph Direction აბზაცის მიმართულება მარცხნიდან მარჯვნივ Line-end Punctuation Half Width სტრიქონის ბოლოს პუნქტუაცია ნახევარი სიგანით + Insert Kashidas ქაშიდების ჩასმა + Long გრძელი + Stylistic სტილისტური Mojikumi Set Mojikumi-ის ნაკრები Kinsoku Set Kinsoku-ის ნაკრები Hard მკაცრი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index efafe83e2..5c235cf35 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -44,6 +44,9 @@ Japanese 일본어 Join Our Discord Discord 참여하기 Line-end Punctuation Half Width 줄 끝 문장 부호 반각 + Insert Kashidas 카시다 삽입 + Long 길게 + Stylistic 스타일 Mojikumi Set 모지쿠미 세트 Kinsoku Set 긴소쿠 세트 Hard 강하게 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 08dba4456..d766b8d49 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1587,6 +1587,9 @@ lighting Light Licht Left Indent Links inspringen Left-to-Right Paragraph Direction Alinearichting van links naar rechts Line-end Punctuation Half Width Leestekens op regeleinde op halve breedte + Insert Kashidas Kashida's invoegen + Long Lang + Stylistic Stilistisch Mojikumi Set Mojikumi-set Kinsoku Set Kinsoku-set Hard Hard diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 0ed051320..03138b413 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1676,6 +1676,9 @@ lighting Light Światło Left Indent Wcięcie z lewej Left-to-Right Paragraph Direction Kierunek akapitu od lewej do prawej Line-end Punctuation Half Width Interpunkcja na końcu wiersza o połowie szerokości + Insert Kashidas Wstaw kaszidy + Long Długi + Stylistic Stylistyczny Mojikumi Set Zestaw mojikumi Kinsoku Set Zestaw kinsoku Hard Ścisły diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 2924d9771..eaad4d8dc 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3136,6 +3136,9 @@ lighting Light Luz Lock Others Bloquear outros Merge Selected Mesclar selecionados Model Modelo + Insert Kashidas Inserir Kashidas + Long Longo + Stylistic Estilístico Mojikumi Set Conjunto Mojikumi Kinsoku Set Conjunto Kinsoku Hard Duro diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7e08d1469..438c78bf1 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1854,6 +1854,9 @@ file-filter Templates Шаблоны Modified: Изменено: Modify Изменить Mojikumi Mojikumi + Insert Kashidas Вставлять кашиды + Long Длинные + Stylistic Стилистические Mojikumi Set Набор mojikumi Kinsoku Set Набор kinsoku Hard Строгий diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 5680628b7..7b985dd6d 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1789,6 +1789,9 @@ Modified: Ändrad: Modify Ändra Mojikumi Mojikumi + Insert Kashidas Infoga kashidor + Long Lång + Stylistic Stilistisk Mojikumi Set Mojikumi-uppsättning Kinsoku Set Kinsoku-uppsättning Hard Hård diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4f34c5e1f..dd2f27283 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1850,6 +1850,9 @@ file-filter Templates Шаблони Modified: Змінено: Modify Змінити Mojikumi Mojikumi + Insert Kashidas Вставляти кашиди + Long Довгі + Stylistic Стилістичні Mojikumi Set Набір mojikumi Kinsoku Set Набір kinsoku Hard Суворий diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 3bd3ab519..270d012bf 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2127,6 +2127,9 @@ file-filter Templates Mẫu Modified: Đã sửa đổi: Modify Sửa đổi Mojikumi Mojikumi + Insert Kashidas Chèn kashida + Long Dài + Stylistic Kiểu cách Mojikumi Set Bộ Mojikumi Monitor RGB RGB màn hình Mono Đơn sắc diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e2e0f94f8..c90275bef 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4599,6 +4599,9 @@ imageTrace Outline 轮廓 Left-to-Right Paragraph Direction 从左到右段落方向 Right-to-Left Paragraph Direction 从右到左段落方向 Line-end Punctuation Half Width 行尾标点半角 + Insert Kashidas 插入卡希达 + Long 长 + Stylistic 风格化 Mojikumi Set 标点挤压集 Kinsoku Set Kinsoku 集 Hard 强 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 407c1825c..201aa2142 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2583,6 +2583,9 @@ file-filter Templates 範本 Modified since it was read: {path} 讀取後已被修改:{path} Modified: 修改時間: Modify 修改 + Insert Kashidas 插入卡希達 + Long 長 + Stylistic 風格化 Mojikumi Set 標點間距 Kinsoku Set Kinsoku 集 Hard 強 diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 3a3f33468..fec1f19ff 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -1,7 +1,7 @@ //! Paragraph panel: seven alignment buttons, Paragraph Direction (with the Indic options), indents, -//! space before/after, Hyphenate, Mojikumi Set and Kinsoku Set (with the East Asian options); the panel menu -//! turns Roman Hanging Punctuation on and off, opens Justification… and Hyphenation… and picks the -//! Single-line or Every-line Composer. +//! space before/after, Hyphenate, Insert Kashidas (with the Indic options), Mojikumi Set and Kinsoku +//! Set (with the East Asian options); the panel menu turns Roman Hanging Punctuation on and off, +//! opens Justification… and Hyphenation… and picks the Single-line or Every-line Composer. use egui::{Ui, vec2}; use serde_json::{Value, json}; @@ -247,6 +247,28 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { return; } hyphenate_check(app, ui, ¶); + // How long the kashidas are that justify Arabic lines, with the Middle Eastern (Indic) options. + if app.session.prefs.show_indic_options { + ui.horizontal(|ui| { + use vectorcraft_doc::text::Kashida; + widgets::dim_label(ui, tl!("Insert Kashidas")); + let kinds = [ + (tl!("None"), Kashida::None, "none"), + (tl!("Short"), Kashida::Short, "short"), + (tl!("Medium"), Kashida::Medium, "medium"), + (tl!("Long"), Kashida::Long, "long"), + (tl!("Stylistic"), Kashida::Stylistic, "stylistic"), + ]; + let names = kinds.map(|k| k.0); + let current = kinds.iter().find(|k| k.1 == para.kashida).map_or(names[0], |k| k.0); + // Already translated: shown as they are. + if let Some(i) = widgets::dropdown_names(ui, "pa-kashida", current, &names, ui.available_width() - 4.0) + && let Some((_, _, key)) = kinds.get(i) + { + format(app, json!({"kashida": key})); + } + }); + } // Japanese composition: how punctuation is spaced (JLREQ 3.1), with the East Asian options. if app.session.prefs.show_east_asian_options { ui.horizontal(|ui| { @@ -345,7 +367,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { /// Reset Panel: the paragraphs the panel acts on ([`format`]) take Auto alignment, no indents or /// paragraph spacing, Hyphenate off with the default Hyphenation and Justification options, Auto /// direction, Bottom-to-Bottom Leading, Regular burasagari, the Hard kinsoku set, the Every-line -/// Composer and Roman Hanging Punctuation off. +/// Composer, Roman Hanging Punctuation off and no kashidas. fn reset_panel(app: &mut VectorcraftApp) { let h = HyphenationOptions::default(); let j = vectorcraft_doc::JustificationOptions::default(); @@ -355,7 +377,7 @@ fn reset_panel(app: &mut VectorcraftApp) { json!({ "leftIndent": 0, "rightIndent": 0, "firstLineIndent": 0, "spaceBefore": 0, "spaceAfter": 0, "hyphenate": false, "wordsLongerThan": h.words_longer_than, "afterFirst": h.after_first, "beforeLast": h.before_last, - "hyphenLimit": h.hyphen_limit, "hyphenationZone": h.zone, "hyphenateCapitalized": h.capitalized, + "hyphenLimit": h.hyphen_limit, "hyphenationZone": h.zone, "hyphenateCapitalized": h.capitalized, "kashida": "none", "wordSpacing": j.word_spacing, "letterSpacing": j.letter_spacing, "glyphScaling": j.glyph_scaling, "autoLeading": j.auto_leading, "singleWordJustification": "fullJustify", "direction": "auto", "leadingModel": "romanBaseline", "burasagari": "standard", "kinsoku": "hard", "composer": "everyLine", @@ -587,6 +609,32 @@ mod tests { assert!(tips(&mut app) > without, "two more buttons"); } + /// Insert Kashidas is in the panel with the Middle Eastern (Indic) options only, shows the + /// paragraph's setting, and Reset Panel sets it back to None. + #[test] + fn insert_kashidas_shows_with_the_indic_options_and_reset_panel_turns_it_off() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "كتب محمد"})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + crate::i18n::set_current(crate::i18n::Lang::EN); + let painted = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, show); + assert!(!painted(&mut app).contains("Insert Kashidas")); + app.session.prefs.show_indic_options = true; + let text = painted(&mut app); + assert!(text.contains("Insert Kashidas") && text.contains("None"), "{text}"); + app.run("text.setFormat", json!({"kashida": "medium"})).unwrap(); + assert!(painted(&mut app).contains("Medium")); + let kashida = |app: &VectorcraftApp| { + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id.as_u64().unwrap())).unwrap(); + let NodeKind::Text(text) = &node.kind else { panic!("text") }; + text.para_at(0).kashida + }; + assert_eq!(kashida(&app), vectorcraft_doc::text::Kashida::Medium); + reset_panel(&mut app); + assert_eq!(kashida(&app), vectorcraft_doc::text::Kashida::None); + } + /// Reset Panel turns Hyphenate and Roman Hanging Punctuation off and sets the Hyphenation and /// Justification options back to their defaults in every paragraph it acts on, so the paragraphs /// save neither. diff --git a/docs/mcp.md b/docs/mcp.md index edf3d6a1d..2560766f1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3792,6 +3792,27 @@ values the Paragraph panel shows, with Preview; OK sends the values that changed ```json {"name":"run_command","arguments":{"command":"text.setFormat","params":{"wordSpacing":[80,100,150],"letterSpacing":{"max":5},"singleWordJustification":"alignLeft"}}} ``` + +## Insert Kashidas + +`text.setFormat {kashida}` (Paragraph panel › Insert Kashidas, shown with the Indic options) sets how the justified +lines of the paragraphs of the selected type (or `ids`, or the paragraphs a `start` / `end` range touches) lengthen +their Arabic words: `"none"` (the default), `"short"`, `"medium"`, `"long"` or `"stylistic"`. On a justified line of +horizontal area type that is shorter than its measure, each Arabic word with joined letters gets one kashida between its +last pair of them: a tatweel (U+0640) in the font of the letter after it. Words end at spaces, inline graphics and +characters other than letters and marks, such as punctuation, digits and symbols. The kashidas share the line's room +equally, each at most a quarter of an em (short), half an em (medium) or an em (long) of that letter's size at its +horizontal scale; a stylistic kashida is a row of the font's own tatweels, as many whole ones as fit in its share and in +an em. The word spaces and the rest of justification take the room the kashidas leave, so the line keeps its width. +Line breaks are the same as with none. Lines that are not justified, words without joined letters and fonts without a +tatweel get no kashidas. Each tatweel is a glyph of the layout that stands for no character and does not advance; the +letter after the kashida advances over it, so the caret before that letter, a selection of it and a click on it take +the kashida in. The setting is saved with the paragraph (`kashida` in `para`) only when it is not none, paragraph +styles carry it, and Reset Panel sets it back to none. The change is one undo step. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"kashida":"medium"}}} +``` ## Kinsoku Set `text.setFormat {kinsoku: "hard"|"soft"|"none"}` sets the Paragraph panel's Kinsoku Set of the selected type (or `ids`): diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3d51ba493..b3f9c7e52 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -657,6 +657,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. + The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting. In vertical type, the Tabs panel names its alignment buttons Top-Justified Tab, Center-Justified Tab, Bottom-Justified Tab and Decimal-Justified Tab, in their tool tips and for screen readers, and labels the position field Y. In horizontal type the buttons keep the names Left-Justified Tab and Right-Justified Tab, and the field keeps the label X. The Tabs panel's ruler is numbered in the General unit with the canvas rulers' label steps and a tick every tenth of a step. With the panel menu's Snap to Unit on, or Shift held, a tab stop placed by a click on the ruler or dragged on it lands on the nearest tick (`text.tabs.set`). When a drag on the Tabs panel's ruler or a value typed in its X field moves a tab stop past another stop, the moved stop stays selected.