From e71b4783f26c817235cf4e7f9eb31ac1be2c2089 Mon Sep 17 00:00:00 2001 From: z3r0pix3l Date: Sun, 8 Nov 2026 21:13:54 +0300 Subject: [PATCH 1/2] PDF import: Arabic ligatures keep their letters in order (#6072) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A PDF names a ligature's characters in logical order (lam then alef for the lam-alef glyph), while a horizontal line of type is gathered glyph by glyph in visual order and then put back in logical order character by character. The ligature's characters were turned round with the line: لا came in as ال, بلال as بالل and الله as هللا. Each glyph's characters now go into the line in visual order, so putting the line back restores them. --- crates/pdf/src/import/builder.rs | 16 +++++++++++++++ crates/pdf/src/tests/live_text.rs | 34 +++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 86a4a89a3..b74498926 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -842,6 +842,9 @@ impl<'p> Builder<'p> { let vx = self.fonts.get(&key).and_then(|f| f.vertical.as_ref()).map_or(500.0, |v| v.centre(glyph)); m * kurbo::Point::new(vx, 880.0) }); + // A horizontal line is gathered in visual order and put back in logical order as a whole: + // a ligature's characters (lam-alef, lam-lam-heh) go in visual order too (#6072). + let text = if top.is_none() { visual_glyph_text(text) } else { text }; let place = |line: &mut TextLine| match top { Some(top) => line.push_upright(&look, at, opacity, Upright { top }, &text), None => line.push(&look, at, opacity, advance, &text), @@ -871,3 +874,16 @@ impl<'p> Builder<'p> { true } } + +/// The characters of one glyph in the order they are drawn: a PDF names a ligature's characters +/// in logical order (lam then alef for لا), while a horizontal line of type is gathered glyph by +/// glyph in visual order and only then put back in logical order. Right-to-left characters are +/// turned round here so that putting the line back restores them. +fn visual_glyph_text(text: String) -> String { + if text.chars().nth(1).is_none() { + return text; + } + let Some((order, _)) = vectorcraft_text::logical_order(&text) else { return text }; + let chars: Vec = text.chars().collect(); + order.iter().filter_map(|&i| chars.get(i)).collect() +} diff --git a/crates/pdf/src/tests/live_text.rs b/crates/pdf/src/tests/live_text.rs index 4cb4417bc..2cc9f938c 100644 --- a/crates/pdf/src/tests/live_text.rs +++ b/crates/pdf/src/tests/live_text.rs @@ -477,3 +477,37 @@ fn hebrew_comes_back_in_logical_order() { assert_eq!(shown(&t[0]), shown(&original), "{text}"); } } + +/// The text objects a PDF of `text` set in `face` imports as. +fn imported_texts(face: &vectorcraft_text::FontFace, text: &str) -> Vec { + let mut pdf = krilla::Document::new(); + let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap()); + let mut s = page.surface(); + let font = Font::new(face.file_data().to_vec().into(), face.face_index()).unwrap(); + s.draw_text(KPoint::from_xy(20.0, 50.0), font, 24.0, text, false, TextDirection::Auto); + s.finish(); + page.finish(); + let r = import_with_report(&pdf.finish().unwrap(), &ImportOptions::default()).unwrap(); + texts(&r.document).iter().map(|t| t.plain_text()).collect() +} + +#[test] +fn arabic_ligatures_keep_their_letters_in_order() { + // A lam-alef ligature is one glyph whose text is lam then alef: it must not be turned round + // with the line it sits in (#6072). + let db = vectorcraft_text::FontDb::global(); + let preferred = ["DejaVu Sans", "Arial", "Times New Roman", "Noto Sans Arabic", "Noto Naskh Arabic"].map(String::from); + // An installed Arabic font whose glyphs come back as live type. + let Some(face) = preferred + .iter() + .chain(&db.families()) + .filter_map(|f| db.face(f, "Regular")) + .filter(|f| f.covers('\u{0644}') && f.covers('\u{0627}')) + .find(|f| !imported_texts(f, "بب").is_empty()) + else { + return; // No such font on this host. + }; + for text in ["لا", "بلال", "السلام عليكم", "لا إله إلا الله"] { + assert_eq!(imported_texts(&face, text), [text], "in {}", face.family); + } +} From e0336d861a63a2059c5d23bc11d4b4ae605090ef Mon Sep 17 00:00:00 2001 From: z3r0pix3l Date: Sun, 8 Nov 2026 22:52:47 +0300 Subject: [PATCH 2/2] Test: use a font that draws lam-alef as one glyph (#6072) --- crates/pdf/src/tests/live_text.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/pdf/src/tests/live_text.rs b/crates/pdf/src/tests/live_text.rs index 2cc9f938c..bddeb758d 100644 --- a/crates/pdf/src/tests/live_text.rs +++ b/crates/pdf/src/tests/live_text.rs @@ -497,12 +497,18 @@ fn arabic_ligatures_keep_their_letters_in_order() { // with the line it sits in (#6072). let db = vectorcraft_text::FontDb::global(); let preferred = ["DejaVu Sans", "Arial", "Times New Roman", "Noto Sans Arabic", "Noto Naskh Arabic"].map(String::from); - // An installed Arabic font whose glyphs come back as live type. + // Whether `face` draws lam-alef as one glyph, the ligature the fix is about. + let ligates = |face: &vectorcraft_text::FontFace| { + let style = vectorcraft_doc::CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), ..Default::default() }; + let t = TextObject::point(kurbo::Point::ORIGIN, "لا", style); + vectorcraft_text::layout(db, &t).glyphs.len() == 1 + }; + // An installed Arabic font with a lam-alef ligature whose glyphs come back as live type. let Some(face) = preferred .iter() .chain(&db.families()) .filter_map(|f| db.face(f, "Regular")) - .filter(|f| f.covers('\u{0644}') && f.covers('\u{0627}')) + .filter(|f| f.covers('\u{0644}') && f.covers('\u{0627}') && ligates(f)) .find(|f| !imported_texts(f, "بب").is_empty()) else { return; // No such font on this host.