Skip to main content

zpdf_document/
xmp.rs

1//! XMP document metadata (ISO 32000-1 §14.3.2 / Adobe XMP). The catalog's
2//! `/Metadata` entry is a stream carrying an XMP packet — RDF/XML describing the
3//! document with Dublin Core (`dc:`), XMP Basic (`xmp:`), and PDF-schema (`pdf:`)
4//! properties. PDF 2.0 deprecates the `/Info` dictionary in favour of this, so
5//! XMP is increasingly the only place some metadata lives.
6//!
7//! **This reads the common properties with a bounded tag/attribute *scrape*, not
8//! a full XML parser.** That is deliberate: an XML engine that resolves general
9//! entities is vulnerable to "billion laughs" entity-expansion bombs, and a DOM
10//! builder can blow the stack on deeply-nested input. Here no general entity is
11//! ever resolved (only the five predefined XML entities and numeric character
12//! references, each of which maps to exactly one character), every scan is
13//! linear over the byte string, and every field and array is length-capped. Like
14//! the other navigation/metadata readers it runs only when explicitly called —
15//! never during `open` or rendering.
16//!
17//! The trade-off: a producer that binds the schema namespaces to non-standard
18//! prefixes (not `dc`/`xmp`/`pdf`) is not recognized. In practice these prefixes
19//! are universal.
20
21use std::borrow::Cow;
22
23use zpdf_core::PdfObject;
24use zpdf_parser::PdfFile;
25
26use crate::obj_util::catalog_dict;
27
28/// Upper bound on XMP packet bytes scanned. XMP packets are typically far under
29/// 100 KiB; this caps a pathological `/Metadata` stream.
30const MAX_XMP_BYTES: usize = 8 * 1024 * 1024;
31/// Per-field character cap (after entity decoding). Bounds an adversarial value.
32const MAX_FIELD_LEN: usize = 8192;
33/// Cap on `rdf:li` items collected from one array/alt property.
34const MAX_LI: usize = 1024;
35
36/// Common document metadata read from the XMP packet. Every field is optional;
37/// producers populate an arbitrary subset, and a field may be present in XMP but
38/// not in `/Info` (or vice-versa).
39#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct XmpMetadata {
41    /// `dc:title` — the document title (the `x-default` language alternative).
42    pub title: Option<String>,
43    /// `dc:creator` — the author(s), in order.
44    pub creators: Vec<String>,
45    /// `dc:description` — a description/abstract (the `x-default` alternative).
46    pub description: Option<String>,
47    /// `dc:subject` — subject keywords/phrases.
48    pub subjects: Vec<String>,
49    /// `pdf:Keywords` — the keyword string (the PDF-schema simple property).
50    pub keywords: Option<String>,
51    /// `pdf:Producer` — the application that produced the PDF.
52    pub producer: Option<String>,
53    /// `xmp:CreatorTool` — the application that authored the original document.
54    pub creator_tool: Option<String>,
55    /// `xmp:CreateDate` — creation timestamp (raw XMP/ISO-8601 date string).
56    pub create_date: Option<String>,
57    /// `xmp:ModifyDate` — last-modification timestamp (raw date string).
58    pub modify_date: Option<String>,
59}
60
61impl XmpMetadata {
62    /// Whether every field is absent.
63    pub fn is_empty(&self) -> bool {
64        self.title.is_none()
65            && self.creators.is_empty()
66            && self.description.is_none()
67            && self.subjects.is_empty()
68            && self.keywords.is_none()
69            && self.producer.is_none()
70            && self.creator_tool.is_none()
71            && self.create_date.is_none()
72            && self.modify_date.is_none()
73    }
74}
75
76/// Decode and return the raw bytes of the catalog's `/Metadata` XMP stream, or
77/// `None` when the document carries none. Routes through the parser's filter
78/// pipeline, so it respects `ParseLimits`.
79pub fn metadata_bytes(file: &PdfFile) -> Option<Vec<u8>> {
80    let root = catalog_dict(file)?;
81    // /Metadata is an indirect reference to a stream (streams are always indirect
82    // objects in PDF), so a Ref is the only valid shape.
83    let id = match root.get("Metadata")? {
84        PdfObject::Ref(r) => *r,
85        _ => return None,
86    };
87    file.resolve_stream_data(id).ok()
88}
89
90/// Parse the catalog's XMP `/Metadata` packet into [`XmpMetadata`]. Returns
91/// `None` when the document carries no `/Metadata`, it cannot be decoded, or it
92/// holds none of the recognized properties.
93pub fn parse_xmp(file: &PdfFile) -> Option<XmpMetadata> {
94    let bytes = metadata_bytes(file)?;
95    let xml = decode_text(&bytes);
96    let meta = scrape(&xml);
97    if meta.is_empty() {
98        None
99    } else {
100        Some(meta)
101    }
102}
103
104/// Decode the XMP packet bytes to text. XMP is UTF-8 by convention but may carry
105/// a UTF-8 or UTF-16 byte-order mark; honour the BOM, else assume UTF-8. The
106/// input is capped to [`MAX_XMP_BYTES`] first.
107fn decode_text(bytes: &[u8]) -> String {
108    let bytes = &bytes[..bytes.len().min(MAX_XMP_BYTES)];
109    if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
110        decode_utf16(rest, true)
111    } else if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
112        decode_utf16(rest, false)
113    } else {
114        let rest = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes);
115        String::from_utf8_lossy(rest).into_owned()
116    }
117}
118
119/// Decode UTF-16 (big- or little-endian) bytes leniently.
120fn decode_utf16(bytes: &[u8], be: bool) -> String {
121    let units: Vec<u16> = bytes
122        .chunks_exact(2)
123        .map(|c| {
124            if be {
125                u16::from_be_bytes([c[0], c[1]])
126            } else {
127                u16::from_le_bytes([c[0], c[1]])
128            }
129        })
130        .collect();
131    String::from_utf16_lossy(&units)
132}
133
134/// Scrape the recognized properties out of the XMP text. Comments are stripped
135/// first, so a property commented out before the real one is not matched.
136fn scrape(xml: &str) -> XmpMetadata {
137    let xml = strip_comments(xml);
138    XmpMetadata {
139        title: alt_property(&xml, "dc:title"),
140        creators: array_property(&xml, "dc:creator"),
141        description: alt_property(&xml, "dc:description"),
142        subjects: array_property(&xml, "dc:subject"),
143        keywords: simple_property(&xml, "pdf:Keywords"),
144        producer: simple_property(&xml, "pdf:Producer"),
145        creator_tool: simple_property(&xml, "xmp:CreatorTool"),
146        create_date: simple_property(&xml, "xmp:CreateDate"),
147        modify_date: simple_property(&xml, "xmp:ModifyDate"),
148    }
149}
150
151/// Remove `<!-- … -->` comment spans so a commented-out property can't be
152/// matched as the real value. Linear and bounded; an unterminated comment is
153/// dropped to the end of the input. Borrows unchanged input when there are no
154/// comments (the common case).
155fn strip_comments(xml: &str) -> Cow<'_, str> {
156    if !xml.contains("<!--") {
157        return Cow::Borrowed(xml);
158    }
159    let mut out = String::with_capacity(xml.len());
160    let mut rest = xml;
161    while let Some(start) = rest.find("<!--") {
162        out.push_str(&rest[..start]);
163        let after = &rest[start + 4..];
164        match after.find("-->") {
165            Some(end) => rest = &after[end + 3..],
166            None => {
167                rest = "";
168                break;
169            }
170        }
171    }
172    out.push_str(rest);
173    Cow::Owned(out)
174}
175
176/// A simple (text) property: the element's text, else the RDF attribute form.
177fn simple_property(xml: &str, qname: &str) -> Option<String> {
178    element_inner(xml, qname)
179        .and_then(simple_text)
180        .or_else(|| attribute_value(xml, qname))
181}
182
183/// A language-alternative property (`rdf:Alt`): the `x-default` item, else the
184/// first item, else (degenerate) the element's text or the attribute form.
185fn alt_property(xml: &str, qname: &str) -> Option<String> {
186    if let Some(inner) = element_inner(xml, qname) {
187        let lis = scan_li(inner);
188        if let Some(pick) = lis.iter().find(|l| l.x_default).or_else(|| lis.first()) {
189            return Some(pick.value.clone());
190        }
191        if let Some(t) = simple_text(inner) {
192            return Some(t);
193        }
194    }
195    attribute_value(xml, qname)
196}
197
198/// An ordered/unordered array property (`rdf:Seq` / `rdf:Bag`): the `rdf:li`
199/// items, else a single value from the element text or attribute form.
200fn array_property(xml: &str, qname: &str) -> Vec<String> {
201    if let Some(inner) = element_inner(xml, qname) {
202        let lis = scan_li(inner);
203        if !lis.is_empty() {
204            return lis.into_iter().map(|l| l.value).collect();
205        }
206        if let Some(t) = simple_text(inner) {
207            return vec![t];
208        }
209    }
210    attribute_value(xml, qname).into_iter().collect()
211}
212
213/// One `rdf:li` item with whether it is the `x-default` language alternative.
214struct Li {
215    x_default: bool,
216    value: String,
217}
218
219/// Collect the `rdf:li` items inside an array/alt property's content, bounded by
220/// [`MAX_LI`]. Each item's text is entity-decoded and trimmed; empty items are
221/// dropped.
222fn scan_li(inner: &str) -> Vec<Li> {
223    const CLOSE: &str = "</rdf:li>";
224    let mut out = Vec::new();
225    let mut rest = inner;
226    while out.len() < MAX_LI {
227        let Some(start) = find_open_tag(rest, "rdf:li") else {
228            break;
229        };
230        let after = &rest[start..];
231        let Some(gt) = after.find('>') else {
232            break;
233        };
234        let open = &after[..gt]; // open-tag text (attributes)
235        let x_default = open.contains("x-default");
236        if open.ends_with('/') {
237            // A self-closing (empty) <rdf:li/>; skip past it.
238            rest = &after[gt + 1..];
239            continue;
240        }
241        let content_start = gt + 1;
242        let Some(close_rel) = after[content_start..].find(CLOSE) else {
243            break;
244        };
245        let value = decode_entities(after[content_start..content_start + close_rel].trim());
246        if !value.is_empty() {
247            out.push(Li { x_default, value });
248        }
249        rest = &after[content_start + close_rel + CLOSE.len()..];
250    }
251    out
252}
253
254/// The text content (entity-decoded) of an element's inner span, or `None` when
255/// it is empty *or* it is structured (contains child elements). The structured
256/// check matters for the array/alt fallback: an empty or unrecognized container
257/// like `<rdf:Alt></rdf:Alt>` or `<rdf:Bag/>` must yield `None`, not leak its raw
258/// markup as the value. A literal `<` only appears in real text as the `&lt;`
259/// entity, so a bare `<` reliably marks markup.
260fn simple_text(inner: &str) -> Option<String> {
261    let trimmed = inner.trim();
262    if trimmed.is_empty() || trimmed.contains('<') {
263        return None;
264    }
265    let t = decode_entities(trimmed);
266    (!t.is_empty()).then_some(t)
267}
268
269/// The inner content of the first `<qname …>…</qname>` element, or `None`. A
270/// self-closing `<qname …/>` yields `Some("")`. No same-name nesting is assumed
271/// (XMP properties do not nest a property inside itself).
272fn element_inner<'a>(xml: &'a str, qname: &str) -> Option<&'a str> {
273    let open = find_open_tag(xml, qname)?;
274    let after = &xml[open..];
275    let gt = after.find('>')?;
276    if after[..gt].ends_with('/') {
277        return Some("");
278    }
279    let content_start = gt + 1;
280    let close = format!("</{qname}>");
281    let rel = after[content_start..].find(&close)?;
282    Some(&after[content_start..content_start + rel])
283}
284
285/// Find the byte offset of the first `<qname` opening tag — requiring the next
286/// character to be a tag delimiter, so `<dc:title` does not match `<dc:titlebar`.
287fn find_open_tag(xml: &str, qname: &str) -> Option<usize> {
288    let needle_buf = format!("<{qname}");
289    let needle = needle_buf.as_str();
290    let mut from = 0;
291    while let Some(rel) = xml[from..].find(needle) {
292        let pos = from + rel;
293        let after_idx = pos + needle.len();
294        match xml.as_bytes().get(after_idx) {
295            Some(b' ' | b'\t' | b'\r' | b'\n' | b'>' | b'/') => return Some(pos),
296            None => return None,
297            // A longer name that merely starts with `qname`; keep scanning.
298            _ => from = after_idx,
299        }
300    }
301    None
302}
303
304/// The value of an RDF-shorthand attribute `qname="…"` (or `qname='…'`) on an
305/// element such as `rdf:Description`. The match must begin at an attribute
306/// boundary so `pdf:Producer` is not found inside a longer attribute name.
307fn attribute_value(xml: &str, qname: &str) -> Option<String> {
308    let mut from = 0;
309    while let Some(rel) = xml[from..].find(qname) {
310        let pos = from + rel;
311        let prev_ok = pos == 0
312            || matches!(
313                xml.as_bytes()[pos - 1],
314                b' ' | b'\t' | b'\r' | b'\n' | b'<' | b'"' | b'\''
315            );
316        let after = pos + qname.len();
317        if prev_ok {
318            let rest = xml[after..].trim_start();
319            if let Some(rest) = rest.strip_prefix('=') {
320                let rest = rest.trim_start();
321                let mut chars = rest.chars();
322                if let Some(q @ ('"' | '\'')) = chars.next() {
323                    let body = &rest[q.len_utf8()..];
324                    if let Some(end) = body.find(q) {
325                        return Some(decode_entities(&body[..end]));
326                    }
327                }
328            }
329        }
330        from = after;
331    }
332    None
333}
334
335/// Decode the five predefined XML entities and numeric character references in a
336/// scraped value, capping its length. **No general (DTD-defined) entity is
337/// resolved**, so an entity-expansion bomb cannot inflate the output — an
338/// unknown `&name;` is left verbatim. Output length ≤ input length ≤
339/// [`MAX_FIELD_LEN`].
340fn decode_entities(s: &str) -> String {
341    let s = cap_len(s);
342    if !s.contains('&') {
343        return s.to_string();
344    }
345    let mut out = String::with_capacity(s.len());
346    let mut rest = s;
347    while let Some(amp) = rest.find('&') {
348        out.push_str(&rest[..amp]);
349        let tail = &rest[amp..];
350        // A real entity reference is short; only look a little way for the ';'.
351        // Snap the window to a char boundary so a multibyte char straddling the
352        // cutoff cannot panic the slice (the value text is arbitrary UTF-8).
353        let mut wend = tail.len().min(12);
354        while wend > 0 && !tail.is_char_boundary(wend) {
355            wend -= 1;
356        }
357        let window = &tail[..wend];
358        if let Some(semi) = window.find(';') {
359            if let Some(ch) = decode_one_entity(&tail[1..semi]) {
360                out.push(ch);
361                rest = &tail[semi + 1..];
362                continue;
363            }
364        }
365        // Not a recognized entity — keep the '&' literally and move on.
366        out.push('&');
367        rest = &tail[1..];
368    }
369    out.push_str(rest);
370    out
371}
372
373/// Decode one entity body (the text between `&` and `;`) to a single character,
374/// or `None` if unrecognized. Numeric references map to exactly one code point.
375/// A numeric reference to a code point that XML 1.0 forbids (NUL, the C0/C1
376/// control range except tab/newline/return, and the noncharacters U+FFFE/U+FFFF)
377/// is rejected, so `&#0;` cannot inject a NUL or control character into a
378/// scraped metadata value.
379fn decode_one_entity(body: &str) -> Option<char> {
380    match body {
381        "lt" => Some('<'),
382        "gt" => Some('>'),
383        "amp" => Some('&'),
384        "quot" => Some('"'),
385        "apos" => Some('\''),
386        _ => {
387            let num = body.strip_prefix('#')?;
388            let code = match num.strip_prefix(['x', 'X']) {
389                Some(hex) => u32::from_str_radix(hex, 16).ok()?,
390                None => num.parse::<u32>().ok()?,
391            };
392            let ch = char::from_u32(code)?;
393            is_xml_char(ch).then_some(ch)
394        }
395    }
396}
397
398/// Whether a character is permitted by XML 1.0's `Char` production — excludes
399/// NUL and the C0/C1 control codes (bar tab, line feed, carriage return) and the
400/// noncharacters U+FFFE/U+FFFF. Used to reject a numeric character reference to a
401/// disallowed code point rather than emit it into a metadata string.
402fn is_xml_char(ch: char) -> bool {
403    matches!(ch,
404        '\u{09}' | '\u{0A}' | '\u{0D}'
405        | '\u{20}'..='\u{7E}'
406        | '\u{85}'
407        | '\u{00A0}'..='\u{D7FF}'
408        | '\u{E000}'..='\u{FFFD}'
409        | '\u{10000}'..='\u{10FFFF}'
410    )
411}
412
413/// Truncate a string to [`MAX_FIELD_LEN`] bytes on a `char` boundary.
414fn cap_len(s: &str) -> &str {
415    if s.len() <= MAX_FIELD_LEN {
416        return s;
417    }
418    let mut end = MAX_FIELD_LEN;
419    while end > 0 && !s.is_char_boundary(end) {
420        end -= 1;
421    }
422    &s[..end]
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    const DC_RDF: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
430<x:xmpmeta xmlns:x="adobe:ns:meta/">
431 <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
432  <rdf:Description rdf:about=""
433      xmlns:dc="http://purl.org/dc/elements/1.1/"
434      xmlns:xmp="http://ns.adobe.com/xap/1.0/"
435      xmlns:pdf="http://ns.adobe.com/pdf/1.3/"
436      pdf:Producer="Acrobat 7.0">
437   <dc:title><rdf:Alt><rdf:li xml:lang="x-default">Annual &amp; Report</rdf:li></rdf:Alt></dc:title>
438   <dc:creator><rdf:Seq><rdf:li>Jane Doe</rdf:li><rdf:li>John Roe</rdf:li></rdf:Seq></dc:creator>
439   <dc:subject><rdf:Bag><rdf:li>finance</rdf:li><rdf:li>q4</rdf:li></rdf:Bag></dc:subject>
440   <xmp:CreatorTool>LibreOffice</xmp:CreatorTool>
441   <xmp:CreateDate>2024-01-01T12:00:00Z</xmp:CreateDate>
442  </rdf:Description>
443 </rdf:RDF>
444</x:xmpmeta>
445<?xpacket end="w"?>"#;
446
447    #[test]
448    fn scrapes_standard_packet() {
449        let m = scrape(DC_RDF);
450        assert_eq!(m.title.as_deref(), Some("Annual & Report")); // entity decoded
451        assert_eq!(m.creators, vec!["Jane Doe", "John Roe"]);
452        assert_eq!(m.subjects, vec!["finance", "q4"]);
453        assert_eq!(m.creator_tool.as_deref(), Some("LibreOffice"));
454        assert_eq!(m.create_date.as_deref(), Some("2024-01-01T12:00:00Z"));
455        // pdf:Producer is given in the RDF attribute shorthand.
456        assert_eq!(m.producer.as_deref(), Some("Acrobat 7.0"));
457        assert!(!m.is_empty());
458    }
459
460    #[test]
461    fn x_default_language_preferred() {
462        let xml = r#"<dc:title><rdf:Alt>
463            <rdf:li xml:lang="fr">Bonjour</rdf:li>
464            <rdf:li xml:lang="x-default">Hello</rdf:li>
465        </rdf:Alt></dc:title>"#;
466        assert_eq!(alt_property(xml, "dc:title").as_deref(), Some("Hello"));
467    }
468
469    #[test]
470    fn first_li_when_no_x_default() {
471        let xml =
472            r#"<dc:title><rdf:Alt><rdf:li xml:lang="fr">Bonjour</rdf:li></rdf:Alt></dc:title>"#;
473        assert_eq!(alt_property(xml, "dc:title").as_deref(), Some("Bonjour"));
474    }
475
476    #[test]
477    fn simple_element_property() {
478        let xml = "<pdf:Producer>A &amp; B &lt;v2&gt;</pdf:Producer>";
479        assert_eq!(
480            simple_property(xml, "pdf:Producer").as_deref(),
481            Some("A & B <v2>")
482        );
483    }
484
485    #[test]
486    fn numeric_character_reference_decoded() {
487        // &#169; = '©', &#x2122; = '™'.
488        let xml = "<pdf:Producer>Acme &#169; &#x2122;</pdf:Producer>";
489        assert_eq!(
490            simple_property(xml, "pdf:Producer").as_deref(),
491            Some("Acme © ™")
492        );
493    }
494
495    #[test]
496    fn open_tag_requires_delimiter() {
497        // <dc:titlebar> must not satisfy a search for <dc:title>.
498        let xml = "<dc:titlebar>nope</dc:titlebar><dc:title>yes</dc:title>";
499        assert_eq!(simple_property(xml, "dc:title").as_deref(), Some("yes"));
500    }
501
502    #[test]
503    fn unknown_entity_is_not_expanded() {
504        // A DTD-defined entity reference must be left verbatim — never resolved —
505        // so an entity-expansion bomb cannot inflate the output. The scrape just
506        // returns the literal text and terminates.
507        let xml = "<pdf:Producer>&lol9; tail</pdf:Producer>";
508        let v = simple_property(xml, "pdf:Producer").expect("value");
509        assert_eq!(v, "&lol9; tail");
510    }
511
512    #[test]
513    fn long_value_is_length_capped() {
514        let big = "x".repeat(MAX_FIELD_LEN * 4);
515        let xml = format!("<pdf:Producer>{big}</pdf:Producer>");
516        let v = simple_property(&xml, "pdf:Producer").expect("value");
517        assert!(v.len() <= MAX_FIELD_LEN, "value must be capped");
518    }
519
520    #[test]
521    fn missing_property_is_none() {
522        assert!(simple_property(DC_RDF, "pdf:Keywords").is_none());
523        assert!(scrape("<x>no xmp here</x>").is_empty());
524    }
525
526    #[test]
527    fn numeric_ref_to_control_char_is_rejected() {
528        // &#0; (NUL) and &#x1; (a C0 control) must not be injected into the value;
529        // the entity is left verbatim. A valid &#169; still decodes.
530        let xml = "<pdf:Producer>a&#0;b&#x1;c &#169;</pdf:Producer>";
531        let v = simple_property(xml, "pdf:Producer").expect("value");
532        assert!(!v.contains('\u{0}'), "NUL must not be injected");
533        assert!(!v.contains('\u{1}'), "control char must not be injected");
534        assert!(v.contains('\u{A9}'), "valid char ref still decodes");
535    }
536
537    #[test]
538    fn commented_out_property_is_ignored() {
539        // A property commented out before the real one must not be matched.
540        let xml = "<rdf:Description>\
541            <!-- <pdf:Producer>FAKE</pdf:Producer> -->\
542            <pdf:Producer>REAL</pdf:Producer></rdf:Description>";
543        assert_eq!(scrape(xml).producer.as_deref(), Some("REAL"));
544    }
545
546    #[test]
547    fn empty_container_does_not_leak_markup() {
548        // An rdf:Alt/Seq/Bag with no rdf:li must yield no value — not the raw
549        // markup of the (empty or foreign) container.
550        assert!(alt_property("<dc:title><rdf:Alt></rdf:Alt></dc:title>", "dc:title").is_none());
551        assert!(array_property("<dc:creator><rdf:Seq/></dc:creator>", "dc:creator").is_empty());
552        // A genuinely simple text value still resolves.
553        assert_eq!(
554            alt_property("<dc:title>Plain</dc:title>", "dc:title").as_deref(),
555            Some("Plain")
556        );
557    }
558
559    #[test]
560    fn multibyte_near_entity_window_does_not_panic() {
561        // An '&' (with no ';') followed by a multibyte char straddling the
562        // 12-byte entity-lookahead window must not panic the slice. Here '€'
563        // occupies bytes 11..14 of `tail`, so a naive `tail[..12]` would split it.
564        let xml = "<pdf:Producer>&xxxxxxxxxx\u{20AC}more</pdf:Producer>";
565        let v = simple_property(xml, "pdf:Producer").expect("value");
566        assert!(v.contains("more")); // decoded without panic; '&' kept literally
567    }
568
569    #[test]
570    fn attribute_value_with_multibyte_is_decoded() {
571        // A multibyte char inside a quoted attribute value must be returned
572        // intact (no panic at the rest[q.len_utf8()..] / body.find(q) boundaries).
573        let xml = "<rdf:Description pdf:Producer=\"\u{20AC}x\">";
574        assert_eq!(
575            attribute_value(xml, "pdf:Producer").as_deref(),
576            Some("\u{20AC}x")
577        );
578    }
579
580    #[test]
581    fn utf16be_bom_decodes() {
582        // "<pdf:Producer>Hi</pdf:Producer>" as UTF-16BE with a BOM.
583        let s = "<pdf:Producer>Hi</pdf:Producer>";
584        let mut bytes = vec![0xFE, 0xFF];
585        for u in s.encode_utf16() {
586            bytes.extend_from_slice(&u.to_be_bytes());
587        }
588        let xml = decode_text(&bytes);
589        assert_eq!(simple_property(&xml, "pdf:Producer").as_deref(), Some("Hi"));
590    }
591}