M2: a ligature stays one glyph in the text layer, in every writing direction (#660)

A glyph whose ToUnicode is several characters ("ffi", Arabic lam-alef, a
letter with its marks) was split into one glyph per character, with the
glyph box cut into equal parts along view x. Turned text and vertical
columns put the parts across the line, so "office" at 90° read
"o ce\nf\nf\ni"; narrow ligatures put two "f"s close enough to be dropped
as fake bold ("of ice"); right-to-left runs reversed the characters inside
the ligature ("سالم" for "سلام").

The glyph now keeps its text and its own box, as PDFium reports every
character of a ligature with the whole glyph box.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
cyfung1031 2026-10-10 11:04:11 +09:00 committed by GitHub
parent a99b39396f
commit d67e79dd15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 101 additions and 7 deletions

View File

@ -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> <FF> 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::<Vec<_>>(), ["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> <FFFF> 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];