diff --git a/crates/engine/src/cmd/fileio/tests_pdffidelity.rs b/crates/engine/src/cmd/fileio/tests_pdffidelity.rs index f4829ff3c..4844bd518 100644 --- a/crates/engine/src/cmd/fileio/tests_pdffidelity.rs +++ b/crates/engine/src/cmd/fileio/tests_pdffidelity.rs @@ -59,6 +59,23 @@ fn layers_option_keeps_or_flattens_optional_content() { assert!(open(&mut s, &bytes, json!({"layers": "no"})).unwrap_err().to_string().contains("layers must be true or false")); } +/// A PDF-compatible `.ai` without the editor's private data opens its layers from its optional +/// content groups, whose names without a byte order mark are PDFDocEncoding. +#[test] +fn ai_layer_names_are_read_as_pdf_doc_encoding() { + let (a, b) = (first_extra(1), first_extra(1) + 1); + let catalog = format!("/OCProperties << /OCGs [{a} 0 R {b} 0 R] /D << >> >> "); + let page = PdfPage { + resources: format!("/Properties << /A {a} 0 R /B {b} 0 R >>"), + ..PdfPage::new(100.0, 100.0, "/OC /A BDC 0 g 0 0 10 10 re f EMC /OC /B BDC 0 g 20 20 10 10 re f EMC") + }; + let bytes = pdf_with_catalog(&[page], &[r"<< /Type /OCG /Name (Art\205final) >>", "<< /Type /OCG /Name (Notes) >>"], &catalog, None); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "art.ai", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + let names: Vec = s.active().unwrap().doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect(); + assert_eq!(names, ["Art\u{2013}final", "Notes"]); +} + #[test] fn load_options_read_text_and_layers() { let o = LoadOptions::from_params("x", &json!({"textAs": "Outlines", "layers": false})).unwrap(); diff --git a/crates/pdf/src/file_info.rs b/crates/pdf/src/file_info.rs index a64414c96..4e858701c 100644 --- a/crates/pdf/src/file_info.rs +++ b/crates/pdf/src/file_info.rs @@ -52,7 +52,7 @@ pub fn file_info(bytes: &[u8], password: Option<&str>) -> Option { pub(crate) fn file_info_in(pdf: &Pdf) -> FileInfo { let mut f = packet(pdf).and_then(|p| xmp(&p)).unwrap_or_default(); let info = pdf.metadata(); - let text = |b: &Option>| b.as_deref().map(info_string).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + let text = |b: &Option>| b.as_deref().map(text_string).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); f.author = f.author.or_else(|| text(&info.author)); f.description = f.description.or_else(|| text(&info.subject)); f.keywords = f.keywords.or_else(|| text(&info.keywords).map(|k| split(&k)).filter(|k| !k.is_empty())); @@ -61,33 +61,6 @@ pub(crate) fn file_info_in(pdf: &Pdf) -> FileInfo { f } -/// An information dictionary string: UTF-16 or UTF-8 with its byte order mark, as [`text_string`] -/// reads it, else PDFDocEncoding (ISO 32000-1 Annex D, Table D.2). -fn info_string(b: &[u8]) -> String { - if b.starts_with(&[0xFE, 0xFF]) || b.starts_with(&[0xEF, 0xBB, 0xBF]) { - return text_string(b); - } - b.iter().map(|&c| pdf_doc_char(c)).collect() -} - -/// Byte `c` of a PDFDocEncoding string. It matches Latin-1 except at 0x18 to 0x1F (accents) and -/// 0x80 to 0xA0 (punctuation, ligatures, letters and the euro sign); 0x9F has no character and -/// stays as it is. -fn pdf_doc_char(c: u8) -> char { - const ACCENTS: [char; 8] = ['\u{2D8}', '\u{2C7}', '\u{2C6}', '\u{2D9}', '\u{2DD}', '\u{2DB}', '\u{2DA}', '\u{2DC}']; - const HIGH: [char; 33] = [ - '\u{2022}', '\u{2020}', '\u{2021}', '\u{2026}', '\u{2014}', '\u{2013}', '\u{192}', '\u{2044}', '\u{2039}', '\u{203A}', '\u{2212}', - '\u{2030}', '\u{201E}', '\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}', '\u{201A}', '\u{2122}', '\u{FB01}', '\u{FB02}', '\u{141}', '\u{152}', - '\u{160}', '\u{178}', '\u{17D}', '\u{131}', '\u{142}', '\u{153}', '\u{161}', '\u{17E}', '\u{9F}', '\u{20AC}', - ]; - let mapped = match c { - 0x18..=0x1F => ACCENTS.get(usize::from(c - 0x18)), - 0x80..=0xA0 => HIGH.get(usize::from(c - 0x80)), - _ => None, - }; - mapped.copied().unwrap_or(char::from(c)) -} - /// The catalog's metadata stream, decoded; `None` past [`MAX_XMP`] bytes. fn packet(pdf: &Pdf) -> Option> { let xref = pdf.xref(); diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs index 110a1a664..95ca8932e 100644 --- a/crates/pdf/src/import_scan.rs +++ b/crates/pdf/src/import_scan.rs @@ -211,7 +211,7 @@ fn usage(d: &Dict<'_>, category: &[u8], key: &[u8]) -> Option { } /// A PDF text string: UTF-16 (with its byte order mark), UTF-8 (with one) or PDFDocEncoding -/// (read as Latin-1, which it matches for letters). +/// (ISO 32000-1 Annex D, Table D.2). pub(crate) fn text_string(b: &[u8]) -> String { if let Some(rest) = b.strip_prefix(&[0xFE, 0xFF]) { let units: Vec = rest.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect(); @@ -220,7 +220,25 @@ pub(crate) fn text_string(b: &[u8]) -> String { if let Some(rest) = b.strip_prefix(&[0xEF, 0xBB, 0xBF]) { return String::from_utf8_lossy(rest).into_owned(); } - b.iter().map(|&c| c as char).collect() + b.iter().map(|&c| pdf_doc_char(c)).collect() +} + +/// Byte `c` of a PDFDocEncoding string. It matches Latin-1 except at 0x18 to 0x1F (accents) and +/// 0x80 to 0xA0 (punctuation, ligatures, letters and the euro sign); 0x9F has no character and +/// stays as it is. +fn pdf_doc_char(c: u8) -> char { + const ACCENTS: [char; 8] = ['\u{2D8}', '\u{2C7}', '\u{2C6}', '\u{2D9}', '\u{2DD}', '\u{2DB}', '\u{2DA}', '\u{2DC}']; + const HIGH: [char; 33] = [ + '\u{2022}', '\u{2020}', '\u{2021}', '\u{2026}', '\u{2014}', '\u{2013}', '\u{192}', '\u{2044}', '\u{2039}', '\u{203A}', '\u{2212}', + '\u{2030}', '\u{201E}', '\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}', '\u{201A}', '\u{2122}', '\u{FB01}', '\u{FB02}', '\u{141}', '\u{152}', + '\u{160}', '\u{178}', '\u{17D}', '\u{131}', '\u{142}', '\u{153}', '\u{161}', '\u{17E}', '\u{9F}', '\u{20AC}', + ]; + let mapped = match c { + 0x18..=0x1F => ACCENTS.get(usize::from(c - 0x18)), + 0x80..=0xA0 => HIGH.get(usize::from(c - 0x80)), + _ => None, + }; + mapped.copied().unwrap_or(char::from(c)) } /// One page as the interpreter walks it. diff --git a/crates/pdf/src/tests_import_layers.rs b/crates/pdf/src/tests_import_layers.rs index 7c520fcde..a30f6ba1c 100644 --- a/crates/pdf/src/tests_import_layers.rs +++ b/crates/pdf/src/tests_import_layers.rs @@ -230,6 +230,14 @@ fn visibility(layers: &[std::sync::Arc]) -> Vec { layers.iter().map(|l| l.visible).collect() } +/// A group name without a byte order mark is PDFDocEncoding (ISO 32000-1 Annex D, Table D.2), +/// which differs from Latin-1 at 0x18 to 0x1F and 0x80 to 0xA0. +#[test] +fn layer_names_without_a_byte_order_mark_are_pdf_doc_encoding() { + let bytes = three_groups([&ocg(r"Art\205final", ""), &ocg(r"\215Caf\351\216 \240", ""), &ocg("Plain", "")], &[], ""); + assert_eq!(names(&import(&bytes).unwrap()), ["Art\u{2013}final", "\u{201C}Caf\u{E9}\u{201D} \u{20AC}", "Plain"]); +} + #[test] fn a_view_state_that_is_off_hides_the_layer_with_its_art() { let off = ocg("Off", "/Usage << /View << /ViewState /OFF >> >>");