Skip to main content

exiftool_rs/formats/
postscript.rs

1//! PostScript/EPS/AI file format reader.
2//!
3//! Parses DSC (Document Structuring Convention) comments for metadata.
4//! Mirrors ExifTool's PostScript.pm.
5
6use crate::error::{Error, Result};
7use crate::metadata::XmpReader;
8use crate::tag::{Tag, TagGroup, TagId};
9use crate::value::Value;
10
11pub fn read_postscript(data: &[u8]) -> Result<Vec<Tag>> {
12    let mut tags = Vec::new();
13    let mut offset = 0;
14
15    // DOS EPS binary header: C5 D0 D3 C6
16    if data.len() >= 30 && data.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) {
17        let ps_offset = u32::from_le_bytes([data[4], data[5], data[6], data[7]]) as usize;
18        let ps_length = u32::from_le_bytes([data[8], data[9], data[10], data[11]]) as usize;
19
20        if ps_offset + ps_length <= data.len() {
21            offset = ps_offset;
22        }
23        tags.push(mk("EPSFormat", "EPS Format", Value::String("DOS Binary".into())));
24    }
25
26    // Check for PS magic
27    if offset + 4 > data.len() || (!data[offset..].starts_with(b"%!PS") && !data[offset..].starts_with(b"%!Ad")) {
28        return Err(Error::InvalidData("not a PostScript file".into()));
29    }
30
31    // Parse DSC comments line by line (handle \r, \n, and \r\n)
32    let text = String::from_utf8_lossy(&data[offset..data.len().min(offset + 65536)]);
33    let text = text.replace('\r', "\n");
34
35    for line in text.lines() {
36        if !line.starts_with("%%") && !line.starts_with("%!") {
37            // Stop at first non-comment non-DSC line (after header section)
38            if !line.starts_with('%') && !line.is_empty() {
39                break;
40            }
41            continue;
42        }
43
44        let line = line.trim();
45
46        if let Some(rest) = line.strip_prefix("%%Title:") {
47            tags.push(mk("Title", "Title", Value::String(rest.trim().trim_matches('(').trim_matches(')').to_string())));
48        } else if let Some(rest) = line.strip_prefix("%%Creator:") {
49            tags.push(mk("Creator", "Creator", Value::String(rest.trim().trim_matches('(').trim_matches(')').to_string())));
50        } else if let Some(rest) = line.strip_prefix("%%CreationDate:") {
51            tags.push(mk("CreateDate", "Create Date", Value::String(rest.trim().trim_matches('(').trim_matches(')').to_string())));
52        } else if let Some(rest) = line.strip_prefix("%%For:") {
53            tags.push(mk("Author", "Author", Value::String(rest.trim().trim_matches('(').trim_matches(')').to_string())));
54        } else if let Some(rest) = line.strip_prefix("%%BoundingBox:") {
55            tags.push(mk("BoundingBox", "Bounding Box", Value::String(rest.trim().to_string())));
56        } else if let Some(rest) = line.strip_prefix("%%HiResBoundingBox:") {
57            tags.push(mk("HiResBoundingBox", "HiRes Bounding Box", Value::String(rest.trim().to_string())));
58        } else if let Some(rest) = line.strip_prefix("%%Pages:") {
59            tags.push(mk("Pages", "Pages", Value::String(rest.trim().to_string())));
60        } else if let Some(rest) = line.strip_prefix("%%LanguageLevel:") {
61            tags.push(mk("LanguageLevel", "Language Level", Value::String(rest.trim().to_string())));
62        } else if let Some(rest) = line.strip_prefix("%%DocumentData:") {
63            tags.push(mk("DocumentData", "Document Data", Value::String(rest.trim().to_string())));
64        } else if line.starts_with("%!PS-Adobe-") {
65            let version = line.strip_prefix("%!PS-Adobe-").unwrap_or("").trim();
66            tags.push(mk("PSVersion", "PostScript Version", Value::String(version.to_string())));
67            // Check for EPS
68            if version.contains("EPSF") {
69                tags.push(mk("EPSVersion", "EPS Version", Value::String(version.to_string())));
70            }
71        }
72    }
73
74    // Look for embedded XMP
75    if let Some(xmp_start) = find_bytes(&data[offset..], b"<?xpacket begin") {
76        let xmp_data = &data[offset + xmp_start..];
77        if let Some(xmp_end) = find_bytes(xmp_data, b"<?xpacket end") {
78            let end = xmp_end + 20; // Include the end tag
79            if let Ok(xmp_tags) = XmpReader::read(&xmp_data[..end.min(xmp_data.len())]) {
80                tags.extend(xmp_tags);
81            }
82        }
83    }
84
85    Ok(tags)
86}
87
88fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> {
89    haystack.windows(needle.len()).position(|w| w == needle)
90}
91
92fn mk(name: &str, description: &str, value: Value) -> Tag {
93    let pv = value.to_display_string();
94    Tag {
95        id: TagId::Text(name.to_string()),
96        name: name.to_string(),
97        description: description.to_string(),
98        group: TagGroup {
99            family0: "PostScript".into(),
100            family1: "PostScript".into(),
101            family2: "Document".into(),
102        },
103        raw_value: value,
104        print_value: pv,
105        priority: 0,
106    }
107}