Skip to main content

xberg_native_pdf/
outline.rs

1//! PDF document outline (bookmarks) support.
2//!
3//! Provides access to the PDF document outline, also known as bookmarks,
4//! which allow users to navigate a PDF document.
5
6use crate::document::PdfDocument;
7use crate::error::{Error, Result};
8use crate::object::Object;
9
10/// A single outline item (bookmark) in the document hierarchy.
11#[derive(Debug, Clone)]
12pub struct OutlineItem {
13    /// The title of this bookmark
14    pub title: String,
15
16    /// The destination (page number or named destination)
17    /// None if destination cannot be determined
18    pub dest: Option<Destination>,
19
20    /// Child bookmarks under this item
21    pub children: Vec<OutlineItem>,
22}
23
24/// Destination in the PDF
25#[derive(Debug, Clone)]
26pub enum Destination {
27    /// Direct page reference (page index, 0-based)
28    PageIndex(usize),
29
30    /// Named destination (string identifier)
31    Named(String),
32}
33
34impl PdfDocument {
35    /// Get the document outline (bookmarks) if present.
36    ///
37    /// Returns a hierarchical structure of bookmarks that can be used
38    /// for document navigation.
39    ///
40    /// # Returns
41    ///
42    /// - `Ok(Some(Vec<OutlineItem>))` - Bookmarks found and parsed
43    /// - `Ok(None)` - No bookmarks in document
44    /// - `Err` - Error parsing bookmarks
45    ///
46    /// # Example
47    ///
48    /// ```no_run
49    /// use xberg_native_pdf::PdfDocument;
50    ///
51    /// let mut doc = PdfDocument::open("sample.pdf")?;
52    /// if let Some(outline) = doc.get_outline()? {
53    ///     for item in outline {
54    ///         println!("Bookmark: {}", item.title);
55    ///     }
56    /// }
57    /// # Ok::<(), xberg_native_pdf::error::Error>(())
58    /// ```
59    pub fn get_outline(&self) -> Result<Option<Vec<OutlineItem>>> {
60        let catalog = self.catalog()?;
61
62        let outlines_ref = match catalog.as_dict() {
63            Some(dict) => match dict.get("Outlines") {
64                Some(Object::Reference(obj_ref)) => *obj_ref,
65                _ => return Ok(None),
66            },
67            None => return Ok(None),
68        };
69
70        let outlines_dict = self.load_object(outlines_ref)?;
71
72        let first_ref = match outlines_dict.as_dict() {
73            Some(dict) => match dict.get("First") {
74                Some(Object::Reference(obj_ref)) => *obj_ref,
75                _ => return Ok(None),
76            },
77            None => return Ok(None),
78        };
79
80        let mut items = Vec::new();
81        let mut current_ref = Some(first_ref);
82
83        while let Some(item_ref) = current_ref {
84            if let Ok(item) = self.parse_outline_item(item_ref) {
85                items.push(item);
86            }
87
88            let item_obj = self.load_object(item_ref)?;
89            current_ref = match item_obj.as_dict() {
90                Some(dict) => match dict.get("Next") {
91                    Some(Object::Reference(obj_ref)) => Some(*obj_ref),
92                    _ => None,
93                },
94                None => None,
95            };
96        }
97
98        if items.is_empty() { Ok(None) } else { Ok(Some(items)) }
99    }
100
101    /// Parse a single outline item and its children recursively.
102    fn parse_outline_item(&self, item_ref: crate::object::ObjectRef) -> Result<OutlineItem> {
103        let item_obj = self.load_object(item_ref)?;
104
105        // Extract title. `/Title` is a PDF text string (ISO 32000-1
106        // §7.9.2.2): UTF-16BE/LE prefixed with a BOM, or PDFDocEncoding
107        // for the BOM-less form. hyperref emits UTF-16BE-with-BOM by
108        // default, so a bare `String::from_utf8_lossy` mangles it —
109        // route every case through the shared text-string decoder. The
110        // value may also be given as an indirect reference. ~keep
111        let title = match item_obj.as_dict().and_then(|dict| dict.get("Title")) {
112            Some(Object::String(s)) => crate::optional_content::decode_pdf_text_string(s),
113            Some(Object::Reference(r)) => self
114                .load_object(*r)
115                .ok()
116                .as_ref()
117                .and_then(Object::as_string)
118                .map(crate::optional_content::decode_pdf_text_string)
119                .unwrap_or_else(|| "(No Title)".to_string()),
120            _ => "(No Title)".to_string(),
121        };
122
123        let dest = self.parse_outline_destination(&item_obj)?;
124
125        let mut children = Vec::new();
126        if let Some(dict) = item_obj.as_dict()
127            && let Some(Object::Reference(first_child_ref)) = dict.get("First")
128        {
129            let mut child_ref = Some(*first_child_ref);
130
131            while let Some(c_ref) = child_ref {
132                if let Ok(child) = self.parse_outline_item(c_ref) {
133                    children.push(child);
134                }
135
136                let child_obj = self.load_object(c_ref)?;
137                child_ref = match child_obj.as_dict() {
138                    Some(dict) => match dict.get("Next") {
139                        Some(Object::Reference(obj_ref)) => Some(*obj_ref),
140                        _ => None,
141                    },
142                    None => None,
143                };
144            }
145        }
146
147        Ok(OutlineItem { title, dest, children })
148    }
149
150    /// Parse destination from an outline item.
151    fn parse_outline_destination(&self, item: &Object) -> Result<Option<Destination>> {
152        let dict = match item.as_dict() {
153            Some(d) => d,
154            None => return Ok(None),
155        };
156
157        if let Some(dest_obj) = dict.get("Dest") {
158            return self.resolve_destination(dest_obj);
159        }
160
161        let mut action = dict.get("A");
162
163        let obj;
164        if let Some(Object::Reference(obj_ref)) = action {
165            obj = self.load_object(*obj_ref)?;
166            action = Some(&obj);
167        }
168
169        if let Some(Object::Dictionary(action)) = action
170            && let Some(dest_obj) = action.get("D")
171        {
172            return self.resolve_destination(dest_obj);
173        }
174
175        Ok(None)
176    }
177
178    /// Resolve a *named* destination (PDF 1.1 catalog `/Dests` dict, or
179    /// PDF 1.2+ `/Names` → `/Dests` name tree, ISO 32000-1 §12.3.2.3 /
180    /// §7.9.6) to a 0-based page index. `Ok(None)` when the name is
181    /// genuinely unresolvable (caller keeps [`Destination::Named`]).
182    ///
183    /// Named destinations are referenced by string from Link/GoTo actions and
184    /// outline items; a consumer that follows those references needs to turn a
185    /// name into the page it targets.
186    ///
187    /// # Example
188    ///
189    /// ```no_run
190    /// use xberg_native_pdf::PdfDocument;
191    ///
192    /// let doc = PdfDocument::open("doc.pdf")?;
193    /// if let Some(page) = doc.resolve_named_destination("section.1")? {
194    ///     println!("named destination -> page {page}");
195    /// }
196    /// # Ok::<(), xberg_native_pdf::error::Error>(())
197    /// ```
198    pub fn resolve_named_destination(&self, name: &str) -> Result<Option<usize>> {
199        self.resolve_named_destination_bytes(name.as_bytes())
200    }
201
202    /// Byte-based counterpart of [`resolve_named_destination`]. A
203    /// `/Dest` name-tree key is a byte string ordered and compared *by
204    /// byte* (ISO 32000-1 §7.9.6) — it is not a text string. A caller
205    /// that already holds the raw key bytes (e.g. from an
206    /// `Object::String`, which may be UTF-16BE-with-BOM — what Adobe
207    /// Distiller emits) must look up with those bytes directly rather
208    /// than decoding to `&str` first: for a byte sequence that is not
209    /// valid UTF-8, `resolve_named_destination`'s lossy decode-then-
210    /// re-encode mangles the key (e.g. the `FE FF` BOM becomes two
211    /// U+FFFD replacement characters) and the lookup can never match. ~keep
212    fn resolve_named_destination_bytes(&self, name: &[u8]) -> Result<Option<usize>> {
213        let catalog = self.catalog()?;
214        let Some(cat) = catalog.as_dict() else {
215            return Ok(None);
216        };
217        let resolve = |r: crate::object::ObjectRef| self.load_object(r).ok();
218        let Some(dest) = lookup_named_dest(cat, name, &resolve, 0) else {
219            return Ok(None);
220        };
221        // The found value is a dest array (or was already normalised
222        // from a `<< /D [...] >>` wrapper). Reuse the array path. ~keep
223        match self.resolve_destination(&dest)? {
224            Some(Destination::PageIndex(i)) => Ok(Some(i)),
225            _ => Ok(None),
226        }
227    }
228
229    /// Resolve a destination object to a Destination enum.
230    fn resolve_destination(&self, dest_obj: &Object) -> Result<Option<Destination>> {
231        match dest_obj {
232            // Named destination — a byte string (`/Dest (name)`) or a
233            // name object (`/Dest /name`). Resolve via the catalog
234            // /Dests dict / /Names name tree; fall back to the
235            // unresolved name for backward compatibility (the
236            // `bookmarks` JSON still prints names when unresolvable).
237            // The name-tree key is looked up by its RAW bytes — it is
238            // not a text string (ISO 32000-1 §7.9.6) — so a UTF-16BE-
239            // with-BOM key (Adobe Distiller's default) still resolves;
240            // only the *display* fallback goes through a lossy decode. ~keep
241            Object::String(name) => match self.resolve_named_destination_bytes(name)? {
242                Some(idx) => Ok(Some(Destination::PageIndex(idx))),
243                None => Ok(Some(Destination::Named(String::from_utf8_lossy(name).to_string()))),
244            },
245            Object::Name(name) => match self.resolve_named_destination(name)? {
246                Some(idx) => Ok(Some(Destination::PageIndex(idx))),
247                None => Ok(Some(Destination::Named(name.clone()))),
248            },
249
250            Object::Array(arr) if !arr.is_empty() => match &arr[0] {
251                Object::Reference(page_ref) => {
252                    if let Ok(page_index) = self.find_page_index(*page_ref) {
253                        Ok(Some(Destination::PageIndex(page_index)))
254                    } else {
255                        Ok(None)
256                    }
257                }
258                _ => Ok(None),
259            },
260
261            Object::Reference(dest_ref) => {
262                let resolved = self.load_object(*dest_ref)?;
263                self.resolve_destination(&resolved)
264            }
265
266            _ => Ok(None),
267        }
268    }
269
270    /// Find the page index for a given page object reference.
271    fn find_page_index(&self, page_ref: crate::object::ObjectRef) -> Result<usize> {
272        // Single tree walk; otherwise per-call get_page_ref(i) is O(n) and the
273        // outer loop becomes O(n²) — outlines with many bookmarks turn into n³. ~keep
274        let refs = self.all_page_refs()?;
275        refs.iter()
276            .position(|r| *r == page_ref)
277            .ok_or_else(|| Error::InvalidPdf(format!("Page reference {:?} not found", page_ref)))
278    }
279}
280
281/// Max name-tree descent — guards malformed/cyclic `/Kids` trees from
282/// unbounded recursion (foundation §6.3 untrusted-input limits).
283const NAME_TREE_MAX_DEPTH: u8 = 32;
284
285/// Follow one level of indirection. Pure given `resolve`.
286fn deref_obj(o: &Object, resolve: &dyn Fn(crate::object::ObjectRef) -> Option<Object>) -> Option<Object> {
287    match o {
288        Object::Reference(r) => resolve(*r),
289        other => Some(other.clone()),
290    }
291}
292
293/// A name-tree / `/Dests` value is either the destination **array**
294/// directly, or a dictionary with a `/D` entry holding it
295/// (ISO 32000-1 §12.3.2.3). Normalise to the array object.
296fn normalize_dest_value(v: &Object, resolve: &dyn Fn(crate::object::ObjectRef) -> Option<Object>) -> Option<Object> {
297    let v = deref_obj(v, resolve)?;
298    if let Some(d) = v.as_dict()
299        && let Some(inner) = d.get("D")
300    {
301        return deref_obj(inner, resolve);
302    }
303    if v.as_array().is_some() {
304        return Some(v);
305    }
306    None
307}
308
309/// Walk a name-tree node (`/Names` leaf or `/Kids` intermediate),
310/// `/Limits`-guided per ISO 32000-1 §7.9.6. Pure given `resolve`.
311fn walk_name_tree(
312    node: &Object,
313    target: &[u8],
314    resolve: &dyn Fn(crate::object::ObjectRef) -> Option<Object>,
315    depth: u8,
316) -> Option<Object> {
317    if depth > NAME_TREE_MAX_DEPTH {
318        return None;
319    }
320    let node = deref_obj(node, resolve)?;
321    let dict = node.as_dict()?;
322
323    if let Some(names) = dict
324        .get("Names")
325        .and_then(|n| deref_obj(n, resolve))
326        .filter(|d| d.as_array().is_some())
327    {
328        let arr = names.as_array().expect("checked array above");
329        let mut i = 0;
330        while i + 1 < arr.len() {
331            if arr[i].as_string() == Some(target) {
332                return normalize_dest_value(&arr[i + 1], resolve);
333            }
334            i += 2;
335        }
336        // A pure-leaf node with no match is terminal. ~keep
337        if !dict.contains_key("Kids") {
338            return None;
339        }
340    }
341
342    if let Some(kids) = dict
343        .get("Kids")
344        .and_then(|k| deref_obj(k, resolve))
345        .filter(|k| k.as_array().is_some())
346    {
347        for kid in kids.as_array().expect("checked array above") {
348            let Some(kid_node) = deref_obj(kid, resolve) else {
349                continue;
350            };
351            let in_range = kid_limits_admit(&kid_node, target, resolve);
352            if in_range && let Some(found) = walk_name_tree(&kid_node, target, resolve, depth + 1) {
353                return Some(found);
354            }
355        }
356    }
357    None
358}
359
360/// Whether `target` can lie under this name-tree kid, per its `/Limits`
361/// entry. A missing, unresolvable or malformed `/Limits` admits the kid, so
362/// the search stays robust against damaged trees. ~keep
363fn kid_limits_admit(
364    kid_node: &Object,
365    target: &[u8],
366    resolve: &dyn Fn(crate::object::ObjectRef) -> Option<Object>,
367) -> bool {
368    let Some(lim) = kid_node.as_dict().and_then(|d| d.get("Limits")) else {
369        return true;
370    };
371    let Some(limits) = deref_obj(lim, resolve) else {
372        return true;
373    };
374    match limits.as_array() {
375        Some(l) if l.len() == 2 => match (l[0].as_string(), l[1].as_string()) {
376            (Some(lo), Some(hi)) => lo <= target && target <= hi,
377            _ => true,
378        },
379        _ => true,
380    }
381}
382
383/// Resolve `target` to its destination object via the catalog
384/// `/Dests` dictionary (PDF 1.1) then the `/Names` → `/Dests` name
385/// tree (PDF 1.2+). Pure given `resolve`; bounded; returns the
386/// normalised destination array object, or `None`.
387fn lookup_named_dest(
388    catalog: &std::collections::HashMap<String, Object>,
389    target: &[u8],
390    resolve: &dyn Fn(crate::object::ObjectRef) -> Option<Object>,
391    depth: u8,
392) -> Option<Object> {
393    if let Some(dests) = catalog.get("Dests").and_then(|d| deref_obj(d, resolve))
394        && let Some(dd) = dests.as_dict()
395    {
396        let key = String::from_utf8_lossy(target);
397        if let Some(v) = dd.get(key.as_ref())
398            && let Some(found) = normalize_dest_value(v, resolve)
399        {
400            return Some(found);
401        }
402    }
403    let names = catalog.get("Names").and_then(|n| deref_obj(n, resolve))?;
404    let root = names.as_dict()?.get("Dests").and_then(|d| deref_obj(d, resolve))?;
405    walk_name_tree(&root, target, resolve, depth)
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use std::collections::HashMap;
412
413    fn s(b: &str) -> Object {
414        Object::String(b.as_bytes().to_vec())
415    }
416    fn arr_dest(page_obj: u32) -> Object {
417        Object::Array(vec![
418            Object::Reference(crate::object::ObjectRef::new(page_obj, 0)),
419            Object::Name("Fit".to_string()),
420        ])
421    }
422    fn no_resolve() -> impl Fn(crate::object::ObjectRef) -> Option<Object> {
423        |_r| None
424    }
425
426    #[test]
427    fn test_outline_item_creation() {
428        let item = OutlineItem {
429            title: "Chapter 1".to_string(),
430            dest: Some(Destination::PageIndex(0)),
431            children: vec![],
432        };
433
434        assert_eq!(item.title, "Chapter 1");
435        assert!(matches!(item.dest, Some(Destination::PageIndex(0))));
436        assert!(item.children.is_empty());
437    }
438
439    #[test]
440    fn test_outline_hierarchy() {
441        let child = OutlineItem {
442            title: "Section 1.1".to_string(),
443            dest: Some(Destination::PageIndex(1)),
444            children: vec![],
445        };
446
447        let parent = OutlineItem {
448            title: "Chapter 1".to_string(),
449            dest: Some(Destination::PageIndex(0)),
450            children: vec![child],
451        };
452
453        assert_eq!(parent.children.len(), 1);
454        assert_eq!(parent.children[0].title, "Section 1.1");
455    }
456
457    /// Catalog `/Dests` dict, value is the destination array directly.
458    #[test]
459    fn dests_dict_direct_array() {
460        let mut dests = HashMap::new();
461        dests.insert("chap1".to_string(), arr_dest(7));
462        let mut cat = HashMap::new();
463        cat.insert("Dests".to_string(), Object::Dictionary(dests));
464        let r = no_resolve();
465        let got = lookup_named_dest(&cat, b"chap1", &r, 0).expect("found");
466        assert_eq!(got.as_array().unwrap()[0].as_reference().unwrap().id, 7);
467        assert!(lookup_named_dest(&cat, b"missing", &r, 0).is_none());
468    }
469
470    /// `/Dests` value is a `<< /D [...] >>` wrapper (must unwrap /D).
471    #[test]
472    fn dests_dict_d_wrapper() {
473        let mut inner = HashMap::new();
474        inner.insert("D".to_string(), arr_dest(9));
475        let mut dests = HashMap::new();
476        dests.insert("c".to_string(), Object::Dictionary(inner));
477        let mut cat = HashMap::new();
478        cat.insert("Dests".to_string(), Object::Dictionary(dests));
479        let got = lookup_named_dest(&cat, b"c", &no_resolve(), 0).expect("found");
480        assert_eq!(got.as_array().unwrap()[0].as_reference().unwrap().id, 9);
481    }
482
483    /// `/Names` → `/Dests` flat-`/Names`-array leaf.
484    #[test]
485    fn names_tree_flat_leaf() {
486        let dests_root = Object::Dictionary(HashMap::from([(
487            "Names".to_string(),
488            Object::Array(vec![s("a"), arr_dest(1), s("b"), arr_dest(4), s("c"), arr_dest(6)]),
489        )]));
490        let names = Object::Dictionary(HashMap::from([("Dests".to_string(), dests_root)]));
491        let cat = HashMap::from([("Names".to_string(), names)]);
492        let r = no_resolve();
493        assert_eq!(
494            lookup_named_dest(&cat, b"b", &r, 0).unwrap().as_array().unwrap()[0]
495                .as_reference()
496                .unwrap()
497                .id,
498            4
499        );
500        assert!(lookup_named_dest(&cat, b"z", &r, 0).is_none());
501    }
502
503    /// `/Kids` intermediate node with `/Limits`-guided descent, the
504    /// child nodes reached through indirect references (exercises the
505    /// injected `resolve`).
506    #[test]
507    fn names_tree_kids_with_limits_and_indirection() {
508        use crate::object::ObjectRef;
509        let leaf_l = Object::Dictionary(HashMap::from([
510            ("Limits".to_string(), Object::Array(vec![s("a"), s("b")])),
511            (
512                "Names".to_string(),
513                Object::Array(vec![s("a"), arr_dest(1), s("b"), arr_dest(2)]),
514            ),
515        ]));
516        let leaf_r = Object::Dictionary(HashMap::from([
517            ("Limits".to_string(), Object::Array(vec![s("m"), s("z")])),
518            (
519                "Names".to_string(),
520                Object::Array(vec![s("m"), arr_dest(3), s("z"), arr_dest(4)]),
521            ),
522        ]));
523        let resolve = move |rf: ObjectRef| match rf.id {
524            100 => Some(leaf_l.clone()),
525            101 => Some(leaf_r.clone()),
526            _ => None,
527        };
528        let root = Object::Dictionary(HashMap::from([(
529            "Kids".to_string(),
530            Object::Array(vec![
531                Object::Reference(ObjectRef::new(100, 0)),
532                Object::Reference(ObjectRef::new(101, 0)),
533            ]),
534        )]));
535        let names = Object::Dictionary(HashMap::from([("Dests".to_string(), root)]));
536        let cat = HashMap::from([("Names".to_string(), names)]);
537        assert_eq!(
538            lookup_named_dest(&cat, b"z", &resolve, 0).unwrap().as_array().unwrap()[0]
539                .as_reference()
540                .unwrap()
541                .id,
542            4
543        );
544        assert_eq!(
545            lookup_named_dest(&cat, b"a", &resolve, 0).unwrap().as_array().unwrap()[0]
546                .as_reference()
547                .unwrap()
548                .id,
549            1
550        );
551        assert!(lookup_named_dest(&cat, b"c", &resolve, 0).is_none());
552    }
553
554    /// Cyclic `/Kids` must terminate via the depth guard (no panic /
555    /// stack overflow) — adversarial-input safety (foundation §6.3).
556    #[test]
557    fn names_tree_cyclic_kids_is_bounded() {
558        use crate::object::ObjectRef;
559        let resolve = |rf: ObjectRef| {
560            if rf.id == 200 {
561                Some(Object::Dictionary(HashMap::from([(
562                    "Kids".to_string(),
563                    Object::Array(vec![Object::Reference(ObjectRef::new(200, 0))]),
564                )])))
565            } else {
566                None
567            }
568        };
569        let names = Object::Dictionary(HashMap::from([(
570            "Dests".to_string(),
571            Object::Reference(ObjectRef::new(200, 0)),
572        )]));
573        let cat = HashMap::from([("Names".to_string(), names)]);
574        assert!(lookup_named_dest(&cat, b"x", &resolve, 0).is_none());
575    }
576
577    /// No /Dests and no /Names → cleanly None.
578    #[test]
579    fn no_dests_no_names_is_none() {
580        let cat = HashMap::from([("Type".to_string(), Object::Name("Catalog".to_string()))]);
581        assert!(lookup_named_dest(&cat, b"anything", &no_resolve(), 0).is_none());
582    }
583
584    /// GH #1589 root cause: a name-tree key is a byte string ordered
585    /// and compared *by byte* (ISO 32000-1 §7.9.6), not a text
586    /// string. A UTF-16BE-with-BOM key (what Adobe Distiller emits)
587    /// resolves fine when looked up by its raw bytes — `lookup_named_dest`
588    /// / `walk_name_tree` were never at fault — but the OLD
589    /// `resolve_destination` decoded the `/Dest` byte string lossily to
590    /// a `String` *before* calling into the lookup, then re-encoded
591    /// that `String` back to bytes for the search. For a BOM-prefixed
592    /// UTF-16BE key that round trip mangles `FE FF` into two U+FFFD
593    /// replacement characters (`EF BF BD` each in UTF-8) and the
594    /// lookup can never match — every named destination in such a
595    /// document silently resolved to `None`. ~keep
596    #[test]
597    fn utf16be_key_requires_raw_byte_lookup_not_lossy_roundtrip() {
598        // UTF-16BE-with-BOM encoding of the two-character name "A3".
599        let utf16_key: &[u8] = &[0xFE, 0xFF, 0x00, 0x41, 0x00, 0x33];
600        let dests_root = Object::Dictionary(HashMap::from([(
601            "Names".to_string(),
602            Object::Array(vec![Object::String(utf16_key.to_vec()), arr_dest(3)]),
603        )]));
604        let names = Object::Dictionary(HashMap::from([("Dests".to_string(), dests_root)]));
605        let cat = HashMap::from([("Names".to_string(), names)]);
606        let r = no_resolve();
607
608        // Correct: raw bytes find the entry (proves walk_name_tree /
609        // lookup_named_dest are byte-correct, as the reporter claimed).
610        let found = lookup_named_dest(&cat, utf16_key, &r, 0).expect("raw bytes must resolve");
611        assert_eq!(found.as_array().unwrap()[0].as_reference().unwrap().id, 3);
612
613        // What the pre-fix `resolve_destination` effectively looked up:
614        // the name-tree key round-tripped through a lossy UTF-8 decode.
615        let lossy_roundtrip = String::from_utf8_lossy(utf16_key).to_string().into_bytes();
616        assert_eq!(
617            lossy_roundtrip,
618            vec![0xEF, 0xBF, 0xBD, 0xEF, 0xBF, 0xBD, 0x00, 0x41, 0x00, 0x33],
619            "sanity: the lossy round-trip must mangle the BOM exactly as reported"
620        );
621        assert!(lookup_named_dest(&cat, &lossy_roundtrip, &r, 0).is_none());
622    }
623}