diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index b052ff7c3..61acbfc8e 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -865,6 +865,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), @@ -894,3 +897,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..bddeb758d 100644 --- a/crates/pdf/src/tests/live_text.rs +++ b/crates/pdf/src/tests/live_text.rs @@ -477,3 +477,43 @@ 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); + // 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}') && ligates(f)) + .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); + } +}