mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
PDF import: PDF and .ai layer names without a byte order mark are read as PDFDocEncoding
The layers of PDF and PDF-compatible .ai files come from their optional content groups, whose /Name is a PDF text string. The import read a name without a byte order mark as Latin-1. Such a string is PDFDocEncoding (ISO 32000-1 Annex D, Table D.2), which differs from Latin-1 at 0x18 to 0x1F and 0x80 to 0xA0. A group named (Art\205final) opened as a layer named "Art", U+0085, "final" instead of "Art–final", and curly double quotes (0x8D, 0x8E) and the euro sign (0xA0) became control characters and a no-break space. text_string now reads such a string with the PDFDocEncoding table that File Info already used for the document information dictionary. The table moves from file_info.rs to import_scan.rs, and File Info reads the information dictionary's strings with text_string.
This commit is contained in:
@@ -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<String> = 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();
|
||||
|
||||
@@ -52,7 +52,7 @@ pub fn file_info(bytes: &[u8], password: Option<&str>) -> Option<FileInfo> {
|
||||
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<Vec<u8>>| b.as_deref().map(info_string).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
|
||||
let text = |b: &Option<Vec<u8>>| 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<Vec<u8>> {
|
||||
let xref = pdf.xref();
|
||||
|
||||
@@ -211,7 +211,7 @@ fn usage(d: &Dict<'_>, category: &[u8], key: &[u8]) -> Option<bool> {
|
||||
}
|
||||
|
||||
/// 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<u16> = 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.
|
||||
|
||||
@@ -230,6 +230,14 @@ fn visibility(layers: &[std::sync::Arc<Node>]) -> Vec<bool> {
|
||||
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 >> >>");
|
||||
|
||||
Reference in New Issue
Block a user