mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Paragraph: Insert Kashidas justifies Arabic lines with kashidas
The reference app justifies Arabic text with kashidas, set per paragraph with the Paragraph panel's Insert Kashidas list (None, Short, Medium, Long, Stylistic). VectorCraft had no kashidas, so a justified Arabic line was spaced out at its word spaces like a Latin one. The Paragraph panel, with the Indic options shown, now has Insert Kashidas. On a justified horizontal area type line that is shorter than its measure, each Arabic word with joined letters gets one kashida between its last pair of them, drawn as tatweels (U+0640) in the font of the letter after it. 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); a Stylistic kashida is a row of the font's own tatweels, up to an em. The word spaces take the room the kashidas leave, so the line keeps its width, and line breaks do not change. The letter after a kashida advances over it, so the caret before that letter, a selection of it and a click on it take the kashida in. None, the default, leaves lines as they were. In horizontal type that is not on a path, the caret in a letter drawn with several glyphs (a letter with a vowel mark) is now placed by the whole letter, as hit testing already measured it, instead of by its first glyph. text.setFormat takes kashida. The setting is saved with the paragraph only when it is not None, paragraph styles carry it, and Reset Panel sets it back to None. Docs: docs/mcp.md, docs/target-app-parity.md.
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