diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 536a35e5..dea26142 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -18,6 +18,7 @@ use kurbo::{Affine, BezPath, Rect, Shape}; /// One glyph on the page. #[derive(Clone, Debug, PartialEq)] pub struct TextGlyph { + /// The glyph's Unicode text: one character, or several for a ligature. pub text: String, /// Box in page view space (points, y down): [x0, y0, x1, y1]. pub rect: [f32; 4], @@ -260,13 +261,9 @@ impl<'a> Device<'a> for TextDevice { if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) || b.width() > 10_000.0 || b.height() > 10_000.0 { return; } - for (i, ch) in text.chars().enumerate() { - // Ligatures (e.g. "ffi") share the glyph box, split evenly. - let n = text.chars().count().max(1) as f64; - let w = b.width() / n; - let x0 = b.x0 + w * i as f64; - self.glyphs[dir].push(TextGlyph { text: ch.to_string(), rect: [x0 as f32, b.y0 as f32, (x0 + w) as f32, b.y1 as f32] }); - } + // A glyph whose Unicode is several characters (a ligature such as "ffi") stays one glyph + // with its own box: the PDF gives no position for the characters inside it. + self.glyphs[dir].push(TextGlyph { text, rect: [b.x0 as f32, b.y0 as f32, b.x1 as f32, b.y1 as f32] }); } fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} fn pop_clip_path(&mut self) {} @@ -608,6 +605,103 @@ mod tests { } } + /// One page per text matrix, each showing `codes` in Helvetica whose `/ToUnicode` maps the + /// codes in `unicode` (a `bfchar` body) to several characters. + fn ligature_pages(unicode: &str, codes: &str, matrices: &[&str]) -> Pdf { + use lopdf::{Document, Object, Stream, dictionary}; + let mut doc = Document::with_version("1.7"); + let cmap = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /TestLigatures def /CMapType 2 def 1 begincodespacerange <00> endcodespacerange {unicode} endcmap CMapName currentdict /CMap defineresource pop end end" + ); + let to_unicode = doc.add_object(Stream::new(dictionary! {}, cmap.into_bytes())); + let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica", "ToUnicode" => to_unicode }); + let pages = doc.new_object_id(); + let mut kids = Vec::new(); + for m in matrices { + let content = doc.add_object(Stream::new(dictionary! {}, format!("BT /F1 20 Tf {m} Tm ({codes}) Tj ET").into_bytes())); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(300), Object::Integer(300)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content }); + kids.push(Object::Reference(page)); + } + let count = kids.len() as i64; + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => kids, "Count" => count}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + Pdf::new(std::sync::Arc::new(bytes)).unwrap() + } + + #[test] + fn a_ligature_is_one_glyph_in_every_writing_direction() { + // "o", one glyph for "ffi", "c", "e": "office", turned 0°, 90°, 180° and 270°. The same + // codes without the ligature mapping ("oWce") are the control for the glyph box. + let turns = ["1 0 0 1 100 150", "0 1 -1 0 150 100", "-1 0 0 -1 200 150", "0 -1 1 0 150 200"]; + let ligature = ligature_pages("1 beginbfchar <57> <006600660069> endbfchar", "oWce", &turns); + let control = ligature_pages("", "oWce", &turns); + for (page, turn) in turns.iter().enumerate() { + let text = extract_page(&ligature, page, &InterpreterSettings::default()).unwrap(); + let plain = extract_page(&control, page, &InterpreterSettings::default()).unwrap(); + assert_eq!(plain.plain_text(), "oWce", "Tm {turn}"); + assert_eq!(text.plain_text(), "office", "Tm {turn}"); + assert_eq!(text.glyphs.iter().map(|g| g.text.as_str()).collect::>(), ["o", "ffi", "c", "e"], "Tm {turn}"); + assert_eq!(text.glyphs[1].rect, plain.glyphs[1].rect, "the painted glyph's box, Tm {turn}"); + assert_eq!(text.find("ffi"), vec![1..2], "Tm {turn}"); + assert_eq!(text.find("of"), vec![0..2], "a match ending inside the ligature covers it, Tm {turn}"); + assert_eq!(text.line_rects(0..4).len(), 1, "one line, Tm {turn}"); + } + } + + #[test] + fn narrow_ligatures_keep_every_letter() { + // Split evenly, this "ffi" put its two "f"s so close that the second was dropped as a + // fake-bold copy of the first. + let pdf = ligature_pages("1 beginbfchar <62> <006600660069> endbfchar", "obce", &["1 0 0 1 100 150"]); + let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap(); + assert_eq!(text.plain_text(), "office"); + } + + #[test] + fn right_to_left_ligatures_keep_their_logical_order() { + // "سلام" drawn left to right as meem, alef, lam, seen; then with lam-alef as one glyph. + let letters = ligature_pages("4 beginbfchar <61> <0645> <62> <0644> <63> <0633> <64> <0627> endbfchar", "adbc", &["1 0 0 1 100 150"]); + let ligature = ligature_pages("3 beginbfchar <61> <0645> <62> <06440627> <63> <0633> endbfchar", "abc", &["1 0 0 1 100 150"]); + for pdf in [letters, ligature] { + assert_eq!(extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text(), "سلام"); + } + // "بَاب" with beh and its fatha as one glyph, as Add text writes shaped Arabic: the mark + // stays after its letter. + let marked = ligature_pages("3 beginbfchar <61> <0628064E> <62> <0627> <63> <0628> endbfchar", "cba", &["1 0 0 1 100 150"]); + assert_eq!(extract_page(&marked, 0, &InterpreterSettings::default()).unwrap().plain_text(), "بَاب"); + } + + #[test] + fn vertical_ligatures_stay_in_their_column() { + use lopdf::{Document, Object, Stream, dictionary}; + let mut doc = Document::with_version("1.7"); + // CID 2 is one glyph for "株式". + let cmap = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /TestUnicode def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange 3 beginbfchar <0001> <65E5> <0002> <682A5F0F> <0003> <8A9E> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end"; + let unicode = doc.add_object(Stream::new(dictionary! {}, cmap.to_vec())); + let cid = doc.add_object(dictionary! { + "Type" => "Font", "Subtype" => "CIDFontType2", "BaseFont" => "TestVertical", + "CIDSystemInfo" => dictionary! { "Registry" => Object::string_literal("Adobe"), "Ordering" => Object::string_literal("Identity"), "Supplement" => 0 }, + "DW" => 1000, "DW2" => vec![Object::Integer(880), Object::Integer(-1000)], "CIDToGIDMap" => "Identity" + }); + let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type0", "BaseFont" => "TestVertical", "Encoding" => "Identity-V", "DescendantFonts" => vec![Object::Reference(cid)], "ToUnicode" => unicode }); + let content = doc.add_object(Stream::new(dictionary! {}, b"BT /F1 20 Tf 1 0 0 1 200 250 Tm <000100020003> Tj ET".to_vec())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { "Type" => "Page", "Parent" => pages, "MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(300), Object::Integer(300)], "Resources" => dictionary! {"Font" => dictionary! {"F1" => font}}, "Contents" => content }); + doc.objects.insert(pages, dictionary! {"Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1}.into()); + let catalog = doc.add_object(dictionary! {"Type" => "Catalog", "Pages" => pages}); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap(); + assert_eq!(text.plain_text(), "日株式語"); + assert_eq!(text.glyphs.len(), 3); + assert_eq!(text.line_rects(0..3).len(), 1, "one column"); + } + #[test] fn upright_mapping_round_trips() { let r = [10.0, 20.0, 30.0, 25.0];