fix(text): read a TrueType font without an encoding as WinAnsi (#1477)

* fix(text): read a TrueType font without an encoding as WinAnsi (fixes #1468)

* fix(text): describe the WinAnsi reading by pdftotext's output

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Marco Arnulfo
2026-10-13 02:04:39 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent 0dd5fdd5bd
commit 685fd7eeea
3 changed files with 55 additions and 2 deletions
+39
View File
@@ -2534,4 +2534,43 @@ mod tests {
let all = t.glyphs_in([0.0, 0.0, 60.0, 40.0]);
assert_eq!(t.column_text(&all), "日本\n語文");
}
/// An embedded TrueType font with no `/Encoding`, flagged `flags`, its `/ToUnicode` (if
/// any) mapping only printable ASCII, as OCR engines write the text layer of a scan.
fn ocr_layer_text(flags: i64, ascii_to_unicode: bool) -> String {
use lopdf::{Document, Object, Stream, dictionary};
let mut doc = Document::with_version("1.7");
let program = doc.add_object(Stream::new(dictionary! {}, include_bytes!("../../../assets/fonts/Inter-Regular.ttf").to_vec()));
let descriptor =
doc.add_object(dictionary! { "Type" => "FontDescriptor", "FontName" => "Inter-Regular", "Flags" => flags, "FontFile2" => program });
let mut font = dictionary! { "Type" => "Font", "Subtype" => "TrueType", "BaseFont" => "Inter-Regular", "FontDescriptor" => descriptor };
if ascii_to_unicode {
let cmap = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Ascii def /CMapType 2 def 1 begincodespacerange <00> <FF> endcodespacerange 1 beginbfrange <20> <7E> <0020> endbfrange endcmap CMapName currentdict /CMap defineresource pop end end";
font.set("ToUnicode", doc.add_object(Stream::new(dictionary! {}, cmap.to_vec())));
}
let font = doc.add_object(font);
// WinAnsi bytes: "Rückfragen für Überhitzungsschutz: können, weiß, Café".
let mut ops = b"BT /F1 14 Tf 1 0 0 1 20 200 Tm (".to_vec();
ops.extend_from_slice(b"R\xfcckfragen f\xfcr \xdcberhitzungsschutz: k\xf6nnen, wei\xdf, Caf\xe9");
ops.extend_from_slice(b") Tj ET");
let content = doc.add_object(Stream::new(dictionary! {}, ops));
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(400), Object::Integer(400)], "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();
extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text()
}
#[test]
fn truetype_without_an_encoding_reads_its_codes_as_win_ansi() {
// Café: 0xE9 is /Oslash in StandardEncoding, so reading the codes as Standard first and
// WinAnsi only for the gaps would still give "CafØ".
let expected = "Rückfragen für Überhitzungsschutz: können, weiß, Café";
assert_eq!(ocr_layer_text(4, false), expected, "symbolic, no /ToUnicode");
assert_eq!(ocr_layer_text(32, true), expected, "nonsymbolic, /ToUnicode for ASCII only");
}
}
+1
View File
File diff suppressed because one or more lines are too long
+15 -2
View File
@@ -1,5 +1,5 @@
use crate::font::blob::{CffFontBlob, OpenTypeFontBlob};
use crate::font::generated::{glyph_names, mac_os_roman, mac_roman, standard};
use crate::font::generated::{glyph_names, mac_os_roman, mac_roman, standard, win_ansi};
use crate::font::standard_font::StandardKind;
use crate::font::{
Encoding, FallbackFontQuery, FontFlags, glyph_name_to_unicode, read_to_unicode,
@@ -191,7 +191,7 @@ impl TrueTypeFont {
match &self.kind {
Kind::Embedded(e) => e
.code_to_name(code as u8)
.code_to_unicode_name(code as u8)
.and_then(glyph_name_to_unicode)
.map(BfString::Char),
Kind::Standard(s) => s.char_code_to_unicode(code as u8).map(BfString::Char),
@@ -277,6 +277,19 @@ impl EmbeddedKind {
.or_else(|| standard::get(code))
}
/// PdfCraft patch: the glyph name text extraction reads a code as. Without a base encoding
/// a TrueType font's codes are read as WinAnsi, matching pdftotext's output, not as
/// Standard: OCR engines write the text layer of a scan with WinAnsi codes and no
/// `/Encoding`, and Standard has no name for ü, ö or ß and gives é (0xE9) as Ø. Glyph
/// selection (`code_to_name`) is unchanged.
fn code_to_unicode_name(&self, code: u8) -> Option<&str> {
self.differences
.get(&code)
.map(|s| s.as_str())
.or_else(|| self.encoding.map_code(code))
.or_else(|| win_ansi::get(code))
}
fn outline_glyph(&self, glyph: GlyphId) -> BezPath {
self.base_font.outline_glyph(glyph)
}