mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
T: kashida justification for Arabic
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ee55acd858
commit
2827574a47
4 files changed
+106
-2
No files matched your search
+1
-1
@@ -96,7 +96,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
||||
- Split Window / New Window (`window.split`, `window.newWindow`, Window ▸ Arrange): two side-by-side views of the document with their own zoom, scroll and render cache. New Window puts the second view in its own OS window (an egui viewport; a floating window on the web).
|
||||
- Transparency flattener (`file.exportPdf {flatten: high|medium|low|ppi}`, Edit ▸ Transparency Flattener Presets): each object involving transparency becomes an opaque image of its area over what's beneath it, so everything else stays vector (spreads with transparent parent items are rasterised whole).
|
||||
- Video and sound (place .mp4/.mov/.webm/.mp3/.wav…, `media.options/get`, Media panel): media frames show a poster or placeholder on screen, in print and in PDF, and export as HTML5 `<video>`/`<audio>` in EPUB. No in-app playback or PDF rich media.
|
||||
- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashida justification and caret movement in visual order aren't there yet.
|
||||
- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel for right-to-left text; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Caret movement in visual order isn't there yet.
|
||||
- Interface language (Edit ▸ Interface Language, `app.language`): German, French, Spanish and Japanese menu titles, common menu items and panel names (our own translations); dialogs and the rest of the UI stay English.
|
||||
- Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update.
|
||||
- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with our own Generic CMYK ICC profile (lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result.
|
||||
|
||||
@@ -1480,7 +1480,7 @@ fn layout_line(
|
||||
measure += hl + hr;
|
||||
}
|
||||
let natural: f64 = line.iter().map(|g| g.adv).sum();
|
||||
let extra = measure - natural;
|
||||
let mut extra = measure - natural;
|
||||
let spaces: Vec<usize> = line.iter().enumerate().filter(|(_, g)| g.is_space() && !g.no_break).map(|(i, _)| i).collect();
|
||||
let align = match pp.align {
|
||||
Align::TowardsSpine => {
|
||||
@@ -1506,6 +1506,20 @@ fn layout_line(
|
||||
let mut add = vec![0.0; line.len()];
|
||||
let mut scale = vec![1.0; line.len()];
|
||||
let mut offset = 0.0;
|
||||
// Kashidas: in justified Arabic, the joins of words take the extra length first.
|
||||
let mut kashidas: Vec<(usize, f64)> = Vec::new();
|
||||
if justify_this && extra > 0.0 && pp.kashidas {
|
||||
let points = kashida_points(&line);
|
||||
if !points.is_empty() {
|
||||
let total = extra.min(points.iter().map(|&i| line[i].size * 1.5).sum());
|
||||
let per = total / points.len() as f64;
|
||||
kashidas = points.into_iter().map(|i| (i, per)).collect();
|
||||
extra -= total;
|
||||
for &(i, l) in &kashidas {
|
||||
add[i] += l;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (justify_this || squeeze_last) && !spaces.is_empty() {
|
||||
distribute(&line, &spaces, extra, sp, &mut add, &mut scale);
|
||||
} else if justify_this && spaces.is_empty() && line.len() > 1 && !last {
|
||||
@@ -1566,6 +1580,10 @@ fn layout_line(
|
||||
p.adv *= scale[i];
|
||||
}
|
||||
p.adv += add[i];
|
||||
if let Some((_, l)) = kashidas.iter().find(|k| k.0 == i) {
|
||||
// Right to left: the stretch is on the glyph's left, toward the letter it joins.
|
||||
p.x += l;
|
||||
}
|
||||
let leader = leaders.iter().find(|(k, _)| *k == i).map(|(_, l)| l.as_str());
|
||||
let (tab_x, tab_w) = (x, p.adv);
|
||||
x += p.adv;
|
||||
@@ -1578,9 +1596,66 @@ fn layout_line(
|
||||
pens.push(x);
|
||||
reorder_visual(&line, &mut out, &pens, x0 + offset, rtl_para);
|
||||
}
|
||||
for (i, l) in kashidas {
|
||||
let (g, at) = (&line[i], out[i].x - l);
|
||||
let gid = designcraft_fonts::first_glyph(&g.face, &['\u{0640}']);
|
||||
let w = g.face.advance(gid) * g.sx;
|
||||
if gid == 0 || w <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// One tatweel stretched over the gap (overlapping its neighbours a little).
|
||||
let k = (l + 0.4) / w;
|
||||
out.push(PlacedGlyph { gid, x: at - 0.2, y: -g.shift, adv: 0.0, sx: g.sx * k, len: 0, upright: false, tcy: None, ..place(g, at) });
|
||||
}
|
||||
(out, x, ratio)
|
||||
}
|
||||
|
||||
/// Arabic letters that join the following letter (dual-joining).
|
||||
fn joins_next(c: char) -> bool {
|
||||
matches!(c as u32,
|
||||
0x0626 | 0x0628 | 0x062A..=0x062E | 0x0633..=0x063F | 0x0641..=0x0647 | 0x0649 | 0x064A | 0x066E | 0x066F
|
||||
| 0x0678..=0x0687 | 0x069A..=0x06BF | 0x06C1 | 0x06CC | 0x06CE | 0x06D0 | 0x06D1 | 0x06FA..=0x06FC)
|
||||
}
|
||||
|
||||
/// Arabic letters that join the preceding one (dual- or right-joining).
|
||||
fn joins_prev(c: char) -> bool {
|
||||
joins_next(c)
|
||||
|| matches!(c as u32,
|
||||
0x0622..=0x0625 | 0x0627 | 0x0629 | 0x062F..=0x0632 | 0x0648 | 0x0671..=0x0673 | 0x0675..=0x0677 | 0x0688..=0x0699
|
||||
| 0x06C0 | 0x06C3..=0x06CB | 0x06CD | 0x06CF | 0x06D2 | 0x06D3)
|
||||
}
|
||||
|
||||
/// Where kashidas go on a line: one join per word, after a seen or sad when there is one, else the
|
||||
/// word's last join (indices of the glyph before the join).
|
||||
fn kashida_points(line: &[Glyph]) -> Vec<usize> {
|
||||
let is_mark = |g: &Glyph| matches!(g.ch as u32, 0x064B..=0x065F | 0x0670 | 0x06D6..=0x06ED);
|
||||
let mut out = Vec::new();
|
||||
let mut best: Option<(bool, usize)> = None;
|
||||
for i in 0..line.len() {
|
||||
let g = &line[i];
|
||||
if g.is_space() || g.len == 0 {
|
||||
if let Some((_, k)) = best.take() {
|
||||
out.push(k);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !joins_next(g.ch) || line.get(i + 1).is_some_and(|n| n.byte == g.byte) {
|
||||
continue;
|
||||
}
|
||||
// The next letter (past any marks) must join back, and lam–alef stays a ligature.
|
||||
let Some(n) = line[i + 1..].iter().find(|n| !is_mark(n)) else { continue };
|
||||
if !joins_prev(n.ch) || n.len == 0 || (g.ch == '\u{0644}' && matches!(n.ch, '\u{0622}' | '\u{0623}' | '\u{0625}' | '\u{0627}')) {
|
||||
continue;
|
||||
}
|
||||
let seen = matches!(g.ch as u32, 0x0633..=0x0636);
|
||||
if best.is_none_or(|(s, _)| !s || seen) {
|
||||
best = Some((seen, i));
|
||||
}
|
||||
}
|
||||
out.extend(best.map(|b| b.1));
|
||||
out
|
||||
}
|
||||
|
||||
/// Bidi: put a laid-out line (glyphs in text order, `pens` their pen positions and then the end
|
||||
/// position) into visual order from `start`, cluster by cluster (Unicode Bidirectional
|
||||
/// Algorithm, rule L2).
|
||||
|
||||
@@ -841,6 +841,33 @@ fn bidi_matches_the_reference_order() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kashidas_stretch_justified_arabic_before_spaces() {
|
||||
let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
|
||||
let lid = d.default_layer();
|
||||
let text = "بسم الله الرحمن الرحيم الحمد لله رب العالمين الرحمن الرحيم مالك يوم الدين اياك نعبد واياك نستعين";
|
||||
let pf = ParaFormat {
|
||||
para: ParaAttrs { align: Some(Align::RightJustified), direction: Some(designcraft_doc::TextDirection::RightToLeft), ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(100.0, 100.0, 260.0, 400.0), lid, text, pf).unwrap();
|
||||
let first = |d: &Document| compose_story(d, sid, &ComposeOptions::default()).frames[0].lines[0].clone();
|
||||
let with = first(&d);
|
||||
assert!(compose_story(&d, sid, &ComposeOptions::default()).frames[0].lines.len() > 1);
|
||||
// Spaces keep (about) their natural width; the joins took the extra length.
|
||||
let space_w = |l: &Line| l.glyphs.iter().filter(|g| g.len > 0 && text[g.byte..].starts_with(' ')).map(|g| g.adv).fold(0.0, f64::max);
|
||||
d.story_mut(sid).unwrap().paras[0].para.kashidas = Some(false);
|
||||
let without = first(&d);
|
||||
assert!(space_w(&with) < space_w(&without) - 0.5, "{} {}", space_w(&with), space_w(&without));
|
||||
// Both fill the measure: the line's (visually last) glyph reaches the left edge either way.
|
||||
let left = |l: &Line| l.glyphs.iter().filter(|g| g.len > 0).map(|g| g.x).fold(f64::MAX, f64::min);
|
||||
assert!((left(&with) - left(&without)).abs() < 1.0, "{} {}", left(&with), left(&without));
|
||||
// Tatweels are drawn in the gaps when the font has one.
|
||||
if with.glyphs.iter().any(|g| g.len == 0 && g.gid != 0) {
|
||||
assert!(with.glyphs.len() > without.glyphs.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digits_option_draws_figures_in_another_script() {
|
||||
let mut d = Document::new(&designcraft_doc::build::NewDocument::default());
|
||||
|
||||
@@ -592,6 +592,8 @@ attr_set! {
|
||||
glyph_scale_max: f64 = 1.0,
|
||||
auto_leading: f64 = 1.2,
|
||||
single_word_justify: Align = Align::FullyJustified,
|
||||
/// Insert Kashidas (World-Ready): justified Arabic lines stretch at joins before spaces.
|
||||
kashidas: bool = true,
|
||||
// Keeps
|
||||
keep_with_next: u32 = 0,
|
||||
keep_lines_together: bool = false,
|
||||
|
||||
Reference in new issue
Block a user