fix(render): an embedded CJK font without a usable ToUnicode reads its text through its collection's UCS2 CMap (hayro-interpret patch 84)

ISO 32000-2 §9.10.2: a Type 0 font of the Adobe-GB1, -CNS1, -Japan1 or -Korea1 collection maps
code to CID to Unicode through the collection's UCS2 CMap. Only fonts that are not embedded took
that route; an embedded one without /ToUnicode, or with one that maps nothing for a code, gave no
text. /ToUnicode still comes first; the UCS2 CMap answers the codes it gives nothing for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
mtlcl410
2026-11-06 06:40:33 +08:00
co-authored by Claude Opus 5.5
parent 250c2623d1
commit fc28e85fb8
3 changed files with 85 additions and 13 deletions
+58
View File
@@ -1965,3 +1965,61 @@ fn text_outside_the_crop_box_is_clipped() {
assert!(text.is_empty(), "expected no glyphs on {crop:?}, got {:?}", text.plain_text());
}
}
/// A one-page document showing `codes` in a Type 0 font with `encoding` over an embedded
/// `descendant` (`CIDFontType0` takes the bundled CFF program, `CIDFontType2` a TrueType one)
/// of the Adobe-Japan1 collection, with `to_unicode` as its `/ToUnicode` CMap when given.
fn japan1_page(encoding: &str, descendant: &str, to_unicode: Option<&[u8]>, codes: &str) -> String {
use lopdf::{Document, Object, Stream, dictionary};
let mut doc = Document::with_version("1.7");
let (key, program): (&str, &[u8]) = if descendant == "CIDFontType0" {
("FontFile3", include_bytes!("../../../../vendor/hayro-interpret/assets/FoxitFixed.pfb"))
} else {
("FontFile2", include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"))
};
let mut file = dictionary! {};
if descendant == "CIDFontType0" {
file.set("Subtype", "CIDFontType0C");
}
let program = doc.add_object(Stream::new(file, program.to_vec()));
let descriptor = doc.add_object(dictionary! { "Type" => "FontDescriptor", "FontName" => "TestJapan1", "Flags" => 4, key => program });
let cid = doc.add_object(dictionary! {
"Type" => "Font", "Subtype" => descendant, "BaseFont" => "TestJapan1",
"CIDSystemInfo" => dictionary! { "Registry" => Object::string_literal("Adobe"), "Ordering" => Object::string_literal("Japan1"), "Supplement" => 6 },
"FontDescriptor" => descriptor, "DW" => 1000, "CIDToGIDMap" => "Identity"
});
let mut font = dictionary! { "Type" => "Font", "Subtype" => "Type0", "BaseFont" => "TestJapan1", "Encoding" => encoding, "DescendantFonts" => vec![Object::Reference(cid)] };
if let Some(cmap) = to_unicode {
font.set("ToUnicode", doc.add_object(Stream::new(dictionary! {}, cmap.to_vec())));
}
let font = doc.add_object(font);
let content = doc.add_object(Stream::new(dictionary! {}, format!("BT /F1 20 Tf 1 0 0 1 20 250 Tm <{codes}> Tj ET").into_bytes()));
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();
extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text()
}
/// An embedded font of an Adobe character collection reads as text through the collection's
/// UCS2 CMap, code to CID to Unicode (ISO 32000-2 §9.10.2), where its `/ToUnicode` is missing or
/// has nothing for a code: with Identity-H or a predefined CMap such as `/H`, CFF or TrueType.
/// Only fonts that are not embedded took that route, so Japanese and Chinese pages whose fonts
/// are embedded without a `/ToUnicode` had no text to select, find or copy. `/ToUnicode` still
/// comes first where it maps a code.
#[test]
fn an_embedded_adobe_collection_font_reads_through_its_ucs2_cmap() {
// Adobe-Japan1 CIDs 34, 1125 and 843: A, 亜 and あ.
assert_eq!(japan1_page("Identity-H", "CIDFontType0", None, "00220465034B"), "A亜あ", "Identity-H, CFF, no /ToUnicode");
assert_eq!(japan1_page("Identity-H", "CIDFontType2", None, "00220465034B"), "A亜あ", "Identity-H, TrueType, no /ToUnicode");
// `/H` maps JIS X 0208 codes to CIDs: 3021 亜, 2422 あ, 2341 A.
assert_eq!(japan1_page("H", "CIDFontType2", None, "302124222341"), "亜あA", "predefined /H");
let empty = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Empty def /CMapType 2 def 1 begincodespacerange <0000> <FFFF> endcodespacerange endcmap CMapName currentdict /CMap defineresource pop end end";
assert_eq!(japan1_page("Identity-H", "CIDFontType2", Some(empty), "00220465034B"), "A亜あ", "a /ToUnicode with no mappings");
let one = b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /One def /CMapType 2 def 1 begincodespacerange <0000> <FFFF> endcodespacerange 1 beginbfchar <0022> <005A> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end";
assert_eq!(japan1_page("Identity-H", "CIDFontType0", Some(one), "00220465034B"), "Z亜あ", "/ToUnicode first, the UCS2 CMap for the rest");
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+26 -12
View File
@@ -42,6 +42,9 @@ pub(crate) struct Type0Font {
/// Whether the `to_unicode` map is a UCS2 `CMap` (CID-indexed) rather than
/// a `ToUnicode` `CMap` (code-indexed).
to_unicode_is_cid_indexed: bool,
/// PdfCraft patch (84): the character collection's UCS2 CMap (by CID), for the codes the
/// font's own `/ToUnicode` gives nothing for, whether or not its program is embedded.
ucs2: Option<CMap>,
/// PdfCraft patch: whether the CIDs belong to an Adobe character collection (Japan1, GB1,
/// CNS1, Korea1), whose glyph numbering no substitute other than a CID-keyed CFF font shares.
adobe_collection: bool,
@@ -131,18 +134,20 @@ impl Type0Font {
.is_some_and(|n| matches!(n.as_ref(), b"Identity-H" | b"Identity-V"));
let mut to_unicode_is_cid_indexed = false;
// PdfCraft patch (84): the character collection's UCS2 CMap (ISO 32000-2 §9.10.2), CID
// to Unicode, for a font of an Adobe collection whether or not its program is embedded.
let mut ucs2 = character_collection
.as_ref()
.and_then(|cc| cc.family.ucs2_cmap())
.and_then(|name| (cmap_resolver)(name))
.and_then(|data| {
let resolver = cmap_resolver.clone();
CMap::parse(data, move |n| (resolver)(n))
});
// If there is no ToUnicode map, try to get the UCS2 CMap.
if fallback
&& to_unicode.is_none()
&& let Some(cc) = character_collection.as_ref()
&& let Some(ucs2_name) = cc.family.ucs2_cmap()
&& let Some(data) = (cmap_resolver)(ucs2_name)
{
let resolver = cmap_resolver.clone();
if let Some(ucs2_cmap) = CMap::parse(data, move |n| (resolver)(n)) {
to_unicode = Some(ucs2_cmap);
to_unicode_is_cid_indexed = true;
}
if fallback && to_unicode.is_none() && ucs2.is_some() {
to_unicode = ucs2.take();
to_unicode_is_cid_indexed = true;
}
let adobe_collection = character_collection
@@ -173,6 +178,7 @@ impl Type0Font {
font_flags,
fallback,
to_unicode_is_cid_indexed,
ucs2,
adobe_collection,
identity_to_unicode,
})
@@ -466,7 +472,15 @@ impl Type0Font {
} else {
code
};
return to_unicode.lookup_bf_string(key);
if let Some(s) = to_unicode.lookup_bf_string(key) {
return Some(s);
}
}
// PdfCraft patch (84): then the collection's UCS2 CMap, by the code's CID.
if let Some(ucs2) = &self.ucs2
&& let Some(s) = self.code_to_cid(code).and_then(|cid| ucs2.lookup_bf_string(cid))
{
return Some(s);
}
None