mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
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:
@@ -156,3 +156,6 @@ sync_github_name = false
|
||||
|
||||
[people.MatrixMagician]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Lcoding15]
|
||||
real_name = "Livio Oswald"
|
||||
|
||||
+71
-5
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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"
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+20
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user