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:
dotCooCoo
2026-10-21 16:46:22 +07:00
committed by bflatastic
parent 3677a16474
commit 1cea163b11
34 changed files with 671 additions and 14 deletions
+38
View File
@@ -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,
}
}
}
+16 -1
View File
@@ -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;
}
+1
View File
@@ -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);
}
+37
View File
@@ -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
+185
View File
@@ -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);
}
}
+49 -1
View File
@@ -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
View File
@@ -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 })
}
+168
View File
@@ -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]
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1573
1574
1575
1576
1577
1578
1579
1580
1581
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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.
+3
View File
@@ -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
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
2127
2128
2129
2130
2131
2132
2133
2134
2135
+3
View File
@@ -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.
+3
View File
@@ -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.
+53 -5
View File
@@ -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, &para);
// 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
View File
@@ -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`):
+1
View File
@@ -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.