mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
M2: Type 0 fonts with /ToUnicode /Identity-H read codes as Unicode
WSCAD (and other CAD exporters) write `/ToUnicode /Identity-H` — a name, not a CMap stream — on composite fonts whose two-byte codes are the UTF-16 code units. Acrobat, MuPDF and pdf.js read each code as its Unicode value. hayro resolved the name to the Identity CMap, which maps codes to CIDs only, so such pages had no text at all: nothing could be selected, searched, copied or edited (147 of 147 pages of a WSCAD schematic). - vendored hayro-interpret patch 35: `Type0Font::identity_to_unicode` - crates/fonts: the same reading for Edit text, find and redaction (controls and lone surrogates map to nothing; simple fonts unaffected) Tests fail without the fix: render `identity_to_unicode_names_give_text`, fonts `identity_to_unicode_names_read_codes_as_unicode`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e397efa854
commit
96db0e3bb5
+63
-4
@@ -1206,14 +1206,42 @@ 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, MuPDF and pdf.js read it as code = Unicode, and so does
|
||||
// the renderer's text layer; without it the text could be seen but not edited or found.
|
||||
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 C0 controls. One `bfrange` per
|
||||
/// high byte, so no range crosses a last-byte boundary (§9.10.3).
|
||||
fn identity_to_unicode() -> Vec<u8> {
|
||||
let highs: Vec<u32> = (0u32..=0xFF).filter(|h| !(0xD8..=0xDF).contains(h)).collect();
|
||||
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 highs.chunks(100) {
|
||||
cmap.push_str(&format!("{} beginbfrange\n", chunk.len()));
|
||||
for h in chunk {
|
||||
let low = if *h == 0 { 0x20 } else { 0 };
|
||||
cmap.push_str(&format!("<{h:02X}{low:02X}> <{h:02X}FF> <{h:02X}{low:02X}>\n"));
|
||||
}
|
||||
cmap.push_str("endbfrange\n");
|
||||
}
|
||||
cmap.into_bytes()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pdfcraft_cos::{Dict, Document, Object, Stream};
|
||||
@@ -1489,6 +1517,37 @@ 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(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]
|
||||
|
||||
@@ -1703,6 +1703,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.
|
||||
#[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 <004400650063006B0062006C006100740074> Tj 1 0 0 1 20 200 Tm <005A00FC0072006900630068002020AC> 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 {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+15
@@ -45,6 +45,11 @@ 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, MuPDF
|
||||
/// and pdf.js read the code as the 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 {
|
||||
@@ -117,6 +122,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 +170,7 @@ impl Type0Font {
|
||||
fallback,
|
||||
to_unicode_is_cid_indexed,
|
||||
adobe_collection,
|
||||
identity_to_unicode,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -431,6 +440,12 @@ impl Type0Font {
|
||||
}
|
||||
|
||||
pub(crate) fn char_code_to_unicode(&self, code: u32) -> Option<BfString> {
|
||||
if self.identity_to_unicode {
|
||||
// Controls and lone surrogates are not text.
|
||||
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