Merge PR #3497: M2: Type 0 fonts with /ToUnicode /Identity-H read their codes as Unicode

contributors/people.toml: kept both new contributor entries.
This commit is contained in:
aiongg
2026-10-22 01:45:33 +00:00
7 changed files with 130 additions and 8 deletions
+3
View File
@@ -156,3 +156,6 @@ sync_github_name = false
[people.MatrixMagician]
sync_github_name = true
[people.Lcoding15]
real_name = "Livio Oswald"
+71 -5
View File
@@ -1143,7 +1143,8 @@ pub fn glyph_unicode(name: &str) -> Option<char> {
chars.next().filter(|_| chars.len() == 0)
}
/// Code → Unicode for a font: its ToUnicode CMap, else (simple fonts) its encoding.
/// Code → Unicode for a font: its ToUnicode CMap (for a composite font, also the name
/// `/Identity-H` or `/Identity-V`, read as code = Unicode), else (simple fonts) its encoding.
fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
let mut out = Unicode::default();
if !composite {
@@ -1206,14 +1207,44 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
}
}
}
if let Some(Object::Stream(s)) = font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref()
&& let Ok(data) = s.decoded()
{
parse_to_unicode(&data, &mut out);
match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() {
Some(Object::Stream(s)) => {
if let Ok(data) = s.decoded() {
parse_to_unicode(&data, &mut out);
}
}
// `/ToUnicode /Identity-H` is not a stream, as ISO 32000-2 §9.10.3 requires, but CAD
// exporters (WSCAD among them) write it for composite fonts whose two-byte codes are the
// UTF-16 code units. Acrobat shows them as code = Unicode, and so does the renderer's
// text layer; without it the text could be seen but not edited or found. The same rule
// (code = Unicode, no controls or lone surrogates) is in the vendored renderer,
// `Type0Font::char_code_to_unicode` in vendor/hayro-interpret/src/font/cid.rs: keep both alike.
Some(Object::Name(n)) if composite && matches!(n.as_slice(), b"Identity-H" | b"Identity-V") => {
parse_to_unicode(&identity_to_unicode(), &mut out);
}
_ => {}
}
out
}
/// A ToUnicode CMap mapping each two-byte code to the same UTF-16 code unit, leaving out the
/// surrogates (which are not characters on their own) and the controls (C0, DEL and C1, as the
/// renderer's text layer does). No range crosses a last-byte boundary (§9.10.3).
fn identity_to_unicode() -> Vec<u8> {
let mut ranges = vec![(0x20u32, 0x7Eu32), (0xA0, 0xFF)];
ranges.extend((0x01u32..=0xFF).filter(|h| !(0xD8..=0xDF).contains(h)).map(|h| (h << 8, (h << 8) | 0xFF)));
let mut cmap = String::from("1 begincodespacerange <0000> <FFFF> endcodespacerange\n");
// At most 100 entries per section (§9.7.5.2 limits for CMap files).
for chunk in ranges.chunks(100) {
cmap.push_str(&format!("{} beginbfrange\n", chunk.len()));
for (lo, hi) in chunk {
cmap.push_str(&format!("<{lo:04X}> <{hi:04X}> <{lo:04X}>\n"));
}
cmap.push_str("endbfrange\n");
}
cmap.into_bytes()
}
#[cfg(test)]
mod tests {
use pdfcraft_cos::{Dict, Document, Object, Stream};
@@ -1489,6 +1520,41 @@ mod tests {
assert_eq!(m.decode(&[0, 1, 0, 7, 0, 2]), "M á");
}
/// WSCAD writes `/ToUnicode /Identity-H` (a name, not a CMap stream) on composite fonts whose
/// codes are UTF-16 code units: the codes read as the same Unicode, as in Acrobat. Controls
/// and lone surrogates map to nothing, and a simple font's name is not a ToUnicode map.
#[test]
fn identity_to_unicode_names_read_codes_as_unicode() {
let doc = Document::new_empty();
for name in ["Identity-H", "Identity-V"] {
let f = font(
&mut Document::new_empty(),
vec![
("Subtype", Object::name("Type0")),
("BaseFont", Object::name("RobotoMono-Regular")),
("Encoding", Object::name("Identity-H")),
("DescendantFonts", Object::Array(vec![Object::Dict(Dict::new())])),
("ToUnicode", Object::name(name)),
],
);
let m = Metrics::from_dict(&doc, &f);
assert_eq!(m.decode(&[0, b'Z', 0, 0xFC, 0, b'r', 0x20, 0xAC, 0x4E, 0x2D]), "Zür€中", "{name}");
assert_eq!(m.text_of(0x0001), None);
assert_eq!(m.text_of(0x007F), None, "DEL is a control");
assert_eq!(m.text_of(0x0085), None, "a C1 control");
assert_eq!(m.text_of(0x00A0).as_deref(), Some("\u{A0}"), "the range resumes after C1");
assert_eq!(m.text_of(0x007E).as_deref(), Some("~"));
assert_eq!(m.text_of(0xD800), None);
assert_eq!(m.text_of(0xFFFD).as_deref(), Some("\u{FFFD}"));
}
let simple = font(
&mut Document::new_empty(),
vec![("Subtype", Object::name("TrueType")), ("BaseFont", Object::name("Arial")), ("ToUnicode", Object::name("Identity-H"))],
);
let m = Metrics::from_dict(&doc, &simple);
assert_eq!(m.text_of(0x41).as_deref(), Some("A"), "the encoding still applies");
}
/// Chrome's subsets leave the glyphs of `/DW` width out of `/W` (#1443): "s" (CID 0x56) is
/// still in the font, but nothing is when the font gives no default width to point at.
#[test]
+31
View File
@@ -1757,6 +1757,37 @@ mod tests {
}
}
/// WSCAD writes `/ToUnicode /Identity-H`, a name rather than a CMap stream, on Type 0 fonts
/// whose two-byte codes are UTF-16 code units. The codes read as that Unicode text (as in
/// Acrobat), so the page's text can be selected and found; before, it had none. Controls (here a
/// DEL and a C1 control at the line ends) are not text.
#[test]
fn identity_to_unicode_names_give_text() {
use lopdf::{Document, Object, Stream, dictionary};
for name in ["Identity-H", "Identity-V"] {
let mut doc = Document::with_version("1.4");
let cid = doc.add_object(dictionary! {
"Type" => "Font", "Subtype" => "CIDFontType2", "BaseFont" => "RobotoMono-Regular",
"CIDSystemInfo" => dictionary! { "Registry" => Object::string_literal("Adobe"), "Ordering" => Object::string_literal("Identity"), "Supplement" => 0 },
"DW" => 600, "CIDToGIDMap" => "Identity"
});
let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type0", "BaseFont" => "RobotoMono-Regular", "Encoding" => "Identity-H", "DescendantFonts" => vec![Object::Reference(cid)], "ToUnicode" => name });
let operators = "BT /F1 12 Tf 1 0 0 1 20 250 Tm <004400650063006B0062006C006100740074007F> Tj 1 0 0 1 20 200 Tm <005A00FC0072006900630068002020AC0085> Tj ET";
let content = doc.add_object(Stream::new(dictionary! {}, operators.as_bytes().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(), "Deckblatt\nZürich €", "{name}");
assert_eq!(text.find("zürich"), vec![9..15], "{name}: glyphs 9 to 14");
}
}
/// 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 {
+2
View File
@@ -1,5 +1,6 @@
# Where PdfCraft falls short of Acrobat Pro
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-19 · **Change:** minor (gap 2: composite fonts with `/ToUnicode /Identity-H` read as Unicode, hayro-interpret patch 44; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 12: reading order is scored, not just word F1) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (gap 14: Vietnamese catalog shipped; Hindi/Indonesian/Korean and Arabic RTL remain) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -382,6 +383,7 @@ update the numbers.
| Date | Change | Summary |
|---|---|---|
| 2026-10-19 | minor | Gap 2: a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (WSCAD) reads its codes as Unicode, so such pages have text to select, find and edit (hayro-interpret patch 44, `view.render-cid-fonts`). Estimates unchanged. |
| 2026-10-15 | minor | Gap 12: the text oracle scores reading order as well as word F1, which is a bag of words and cannot see order; the first measurement separated 18 improvements from 6 regressions in a change F1 called unchanged on 26 of 29 files. Estimates unchanged. |
| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
| 2026-10-15 | patch | Gap 24: record localization and installers as partial (unusable Chinese, Telugu and Arabic; unsigned Linux packages) |
+2 -2
View File
@@ -782,8 +782,8 @@ milestone = "M2"
status = "partial"
commands = []
tools = []
evidence = ["crates/render/src/raster.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster.rs::mincho_substitute_covers_half_width_katakana"]
notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; non-embedded GB1, CNS1 and Korea1 fonts still draw nothing (no allowed face)"
evidence = ["crates/render/src/raster.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster.rs::mincho_substitute_covers_half_width_katakana", "crates/render/src/text.rs::identity_to_unicode_names_give_text", "crates/fonts/src/pdf.rs::identity_to_unicode_names_read_codes_as_unicode"]
notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; non-embedded GB1, CNS1 and Korea1 fonts still draw nothing (no allowed face); a `/ToUnicode` given as the name `/Identity-H` or `/Identity-V` (WSCAD) reads codes as Unicode for selection, search and editing, without control characters"
[[feature]]
id = "view.render-image-codecs"
+1 -1
View File
File diff suppressed because one or more lines are too long
+20
View File
@@ -45,9 +45,16 @@ pub(crate) struct Type0Font {
/// 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,
/// PdfCraft patch: `/ToUnicode /Identity-H` (a name, not a CMap stream), which CAD exporters
/// such as WSCAD write for fonts whose two-byte codes are UTF-16 code units. Acrobat shows
/// such text as the code's Unicode value; the identity CMap the name resolves to maps codes
/// to CIDs only and gave no text at all.
identity_to_unicode: bool,
}
impl Type0Font {
/// Reads a Type 0 font from its dictionary: the descendant CID font, the encoding CMap and
/// the code-to-Unicode map (`/ToUnicode`, the `/Identity-H` name, or the UCS2 CMap).
pub(crate) fn new(
dict: &Dict<'_>,
font_resolver: &FontResolverFn,
@@ -117,6 +124,9 @@ impl Type0Font {
let cache_key = dict.cache_key();
let mut to_unicode = read_to_unicode(dict, cmap_resolver);
let identity_to_unicode = dict
.get::<Name<'_>>(TO_UNICODE)
.is_some_and(|n| matches!(n.as_ref(), b"Identity-H" | b"Identity-V"));
let mut to_unicode_is_cid_indexed = false;
// If there is no ToUnicode map, try to get the UCS2 CMap.
@@ -162,6 +172,7 @@ impl Type0Font {
fallback,
to_unicode_is_cid_indexed,
adobe_collection,
identity_to_unicode,
})
}
@@ -430,7 +441,16 @@ impl Type0Font {
}
}
/// The text a character code stands for, or `None` when the font gives none (or, for an
/// identity `/ToUnicode` name, when the code is a control or a lone surrogate).
pub(crate) fn char_code_to_unicode(&self, code: u32) -> Option<BfString> {
if self.identity_to_unicode {
// Controls and lone surrogates are not text. The same rule builds the editing
// map, `identity_to_unicode` in crates/fonts/src/pdf.rs: keep both alike.
return char::from_u32(code)
.filter(|c| !c.is_control())
.map(BfString::Char);
}
if let Some(to_unicode) = &self.to_unicode {
let key = if self.to_unicode_is_cid_indexed {
self.code_to_cid(code).unwrap_or(0)