mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 18:10:01 -06:00
Merge pull request #3288 from dotCooCoo/feat/kashida
Paragraph: Insert Kashidas justifies Arabic lines with kashidas
This commit is contained in:
@@ -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<ParaStyle> for ParaStyleFile {
|
||||
@@ -734,6 +768,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
..
|
||||
} = p;
|
||||
Self {
|
||||
@@ -756,6 +791,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -782,6 +818,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
..
|
||||
} = f;
|
||||
Self {
|
||||
@@ -803,6 +840,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<Value> {
|
||||
"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<Value> {
|
||||
_ => 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<Value> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
|
||||
("burasagari", json!(v.burasagari)),
|
||||
("kinsoku", json!(v.kinsoku)),
|
||||
("roman_hanging", json!(v.roman_hanging)),
|
||||
("kashida", json!(v.kashida)),
|
||||
] {
|
||||
m.insert(k.into(), x);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
//! <https://web.archive.org/web/20260906105741/https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/justify-text-using-kashida-and-hyphenation.html>
|
||||
//! and <https://web.archive.org/web/20250816144754/https://helpx.adobe.com/illustrator/using/arabic-hebrew.html>.
|
||||
|
||||
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<usize>, 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<usize> = Vec::new();
|
||||
let mut word: Option<usize> = None;
|
||||
let mut prev: Option<usize> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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<usize>], 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::<f64>();
|
||||
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<usize>], 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<usize>], 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<usize>], 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;
|
||||
|
||||
+33
-7
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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<std::sync::Arc<FontFace>> {
|
||||
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<usize> = 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<usize> = 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<usize> = 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]
|
||||
|
||||
@@ -1789,6 +1789,9 @@
|
||||
Modified: Модифицирано:
|
||||
Modify Модифициране
|
||||
Mojikumi Моджикуми
|
||||
Insert Kashidas Вмъкване на кашиди
|
||||
Long Дълго
|
||||
Stylistic Стилистично
|
||||
Mojikumi Set Набор Mojikumi
|
||||
Kinsoku Set Набор Kinsoku
|
||||
Hard Твърдо
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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é
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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 Μονοφωνικό
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2021 and column 19.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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ű
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -48,6 +48,9 @@
|
||||
Japanese 日本語
|
||||
Join Our Discord Discord に参加
|
||||
Line-end Punctuation Half Width 行末約物半角
|
||||
Insert Kashidas カシーダを挿入
|
||||
Long 長い
|
||||
Stylistic スタイリスティック
|
||||
Mojikumi Set 文字組み
|
||||
Kinsoku Set 禁則処理
|
||||
Hard 強い禁則
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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 მკაცრი
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -44,6 +44,9 @@
|
||||
Japanese 일본어
|
||||
Join Our Discord Discord 참여하기
|
||||
Line-end Punctuation Half Width 줄 끝 문장 부호 반각
|
||||
Insert Kashidas 카시다 삽입
|
||||
Long 길게
|
||||
Stylistic 스타일
|
||||
Mojikumi Set 모지쿠미 세트
|
||||
Kinsoku Set 긴소쿠 세트
|
||||
Hard 강하게
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1854,6 +1854,9 @@ file-filter Templates Шаблоны
|
||||
Modified: Изменено:
|
||||
Modify Изменить
|
||||
Mojikumi Mojikumi
|
||||
Insert Kashidas Вставлять кашиды
|
||||
Long Длинные
|
||||
Stylistic Стилистические
|
||||
Mojikumi Set Набор mojikumi
|
||||
Kinsoku Set Набор kinsoku
|
||||
Hard Строгий
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -1850,6 +1850,9 @@ file-filter Templates Шаблони
|
||||
Modified: Змінено:
|
||||
Modify Змінити
|
||||
Mojikumi Mojikumi
|
||||
Insert Kashidas Вставляти кашиди
|
||||
Long Довгі
|
||||
Stylistic Стилістичні
|
||||
Mojikumi Set Набір mojikumi
|
||||
Kinsoku Set Набір kinsoku
|
||||
Hard Суворий
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 726 and column 40.
|
@@ -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
|
||||
|
||||
|
@@ -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 强
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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 強
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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.
|
||||
|
||||
+21
@@ -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`):
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user