Skip to main content

quarb_html/
lib.rs

1//! HTML adapter for Quarb.
2//!
3//! Maps an HTML document onto the arbor model, parsing with
4//! html5ever (via `scraper`) so malformed markup is handled per the
5//! HTML5 standard.
6//!
7//! - Elements are the nodes; text and comment nodes are not navigated
8//!   (text is exposed as a projection instead).
9//! - A node's *name* is its tag (`html`, `body`, `div`, `h1`, `a`),
10//!   so `/html/body//p` navigates by element type. The document root
11//!   is unnamed; the `<html>` element is its single child.
12//! - A node's *traits* are structural classes: `<block>` or
13//!   `<inline>`, plus `<heading>` for `h1`–`h6` and `<link>` for an
14//!   anchor with an `href`.
15//! - Attributes are properties: `::href`, `::class`, `::id`. The
16//!   default projection (`::`) is the element's text content;
17//!   `:::tag`, `:::classes`, `:::attrs`, and `:::n-attrs`
18//!   expose adapter metadata.
19//! - An anchor's fragment `href` resolves: `::href-->` follows
20//!   `#section` to the element with `id="section"`.
21
22use quarb::names::Names;
23use quarb::{AstAdapter, NodeId, Value};
24mod sink;
25use std::collections::HashMap;
26
27struct Node {
28    /// The tag name, in the document's name table; `None` for the
29    /// document root.
30    tag: Option<u32>,
31    /// The attributes: the name in the table, the value.
32    attrs: Vec<(u32, String)>,
33    /// The element's content in document order: its own text
34    /// children as written, its element children by ordinal in
35    /// `children`. The subtree text (the bare projection) is
36    /// concatenated on first use and kept — storing it for every
37    /// element up front cost the depth of the tree per element.
38    pieces: Vec<Piece>,
39    text: std::cell::OnceCell<String>,
40    parent: Option<NodeId>,
41    children: Vec<NodeId>,
42}
43
44/// One piece of an element's content.
45enum Piece {
46    Text(String),
47    Child(usize),
48}
49
50impl Node {
51    fn attr(&self, names: &Names, name: &str) -> Option<&str> {
52        let k = names.key(name)?;
53        self.attrs
54            .iter()
55            .find(|(a, _)| *a == k)
56            .map(|(_, v)| v.as_str())
57    }
58}
59
60/// The encoding an HTML document declares in its head — a `<meta
61/// charset=…>` or a `<meta http-equiv="content-type"
62/// content="…; charset=…">` within the first 1024 bytes, as the
63/// HTML standard's prescan reads them — reported for the engine's
64/// source boundary. The adapter decodes nothing. An XML prologue
65/// on an XHTML page counts too (the XML adapter's rule, applied
66/// here so a page served as text/html still declares). A
67/// `charset=` inside any other attribute's value (a description)
68/// declares nothing.
69pub fn declared_encoding(bytes: &[u8]) -> Option<String> {
70    if let Some(label) = xml_prologue_encoding(bytes) {
71        return Some(label);
72    }
73    let head = &bytes[..bytes.len().min(1024)];
74    let lower: Vec<u8> = head.iter().map(|b| b.to_ascii_lowercase()).collect();
75    let mut at = 0;
76    while let Some(i) = find(&lower[at..], b"<meta") {
77        let start = at + i + 5;
78        let end = start + find(&lower[start..], b">").unwrap_or(lower.len() - start);
79        let attrs = meta_attributes(&lower[start..end]);
80        let get = |name: &str| {
81            attrs
82                .iter()
83                .find(|(k, _)| k == name)
84                .map(|(_, v)| v.as_str())
85        };
86        if let Some(label) = get("charset").filter(|l| !l.is_empty()) {
87            return Some(label.to_string());
88        }
89        if get("http-equiv") == Some("content-type")
90            && let Some(content) = get("content")
91            && let Some(p) = content.find("charset=")
92        {
93            let label = content[p + 8..]
94                .trim_matches(|c: char| c == '"' || c == '\'' || c.is_ascii_whitespace())
95                .split([';', ' ', '"', '\''])
96                .next()
97                .unwrap_or("");
98            if !label.is_empty() {
99                return Some(label.to_string());
100            }
101        }
102        at = end;
103    }
104    None
105}
106
107/// `<?xml … encoding="…"?>` at the very start — the one XML rule
108/// the HTML reporter shares, kept local so the crates stay apart.
109/// The target must be `xml` itself (`<?xml-stylesheet …?>` is
110/// another instruction and declares nothing).
111fn xml_prologue_encoding(bytes: &[u8]) -> Option<String> {
112    let head = bytes.strip_prefix(b"<?xml")?;
113    if !head.first().is_some_and(|b| b.is_ascii_whitespace()) {
114        return None;
115    }
116    let end = find(head, b"?>")?;
117    let prologue = std::str::from_utf8(&head[..end]).ok()?;
118    let rest = prologue
119        .split_once("encoding")?
120        .1
121        .trim_start()
122        .strip_prefix('=')?
123        .trim_start();
124    let quote = rest.chars().next()?;
125    (quote == '"' || quote == '\'')
126        .then(|| {
127            rest[1..]
128                .split(quote)
129                .next()
130                .unwrap_or("")
131                .trim()
132                .to_string()
133        })
134        .filter(|l| !l.is_empty())
135}
136
137fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
138    hay.windows(needle.len()).position(|w| w == needle)
139}
140
141/// The attributes of a lowercased tag body, as (name, value): a
142/// value quoted or bare, a name without one given the empty value.
143/// Quoted values are skipped as wholes, so nothing inside one is
144/// taken for an attribute.
145fn meta_attributes(tag: &[u8]) -> Vec<(String, String)> {
146    let mut out = Vec::new();
147    let mut i = 0;
148    let n = tag.len();
149    while i < n {
150        while i < n && (tag[i].is_ascii_whitespace() || tag[i] == b'/') {
151            i += 1;
152        }
153        let name_start = i;
154        while i < n && !tag[i].is_ascii_whitespace() && tag[i] != b'=' && tag[i] != b'/' {
155            i += 1;
156        }
157        if i == name_start {
158            break;
159        }
160        let name = String::from_utf8_lossy(&tag[name_start..i]).into_owned();
161        while i < n && tag[i].is_ascii_whitespace() {
162            i += 1;
163        }
164        let mut value = String::new();
165        if i < n && tag[i] == b'=' {
166            i += 1;
167            while i < n && tag[i].is_ascii_whitespace() {
168                i += 1;
169            }
170            if i < n && (tag[i] == b'"' || tag[i] == b'\'') {
171                let q = tag[i];
172                i += 1;
173                let v = i;
174                while i < n && tag[i] != q {
175                    i += 1;
176                }
177                value = String::from_utf8_lossy(&tag[v..i]).trim().to_string();
178                i = (i + 1).min(n);
179            } else {
180                let v = i;
181                while i < n && !tag[i].is_ascii_whitespace() {
182                    i += 1;
183                }
184                value = String::from_utf8_lossy(&tag[v..i]).to_string();
185            }
186        }
187        out.push((name, value));
188    }
189    out
190}
191
192/// A Quarb adapter over a parsed HTML document.
193pub struct HtmlAdapter {
194    nodes: Vec<Node>,
195    /// The document's tag and attribute names, each once.
196    names: Names,
197    /// `id` attribute value → the element carrying it.
198    ids: HashMap<String, NodeId>,
199    root: NodeId,
200    /// The document's own URL, when the mount knows it — the base
201    /// a relative `href` joins against for `::href-->` across
202    /// documents. `None` for pasted or fileless html: only
203    /// absolute hrefs reference out then.
204    document_url: Option<url::Url>,
205}
206
207impl HtmlAdapter {
208    /// Does `node`'s `rel` admit the relation `hint`? No hint and
209    /// the `*` wildcard admit everything; a named hint needs the
210    /// space-separated `rel` list to contain it (`rel="nofollow
211    /// noopener"` answers to either word). An element without a
212    /// `rel` answers only the wildcard forms — the spec's
213    /// `-->stylesheet` reads "only the stylesheet relation".
214    fn rel_ok(&self, node: NodeId, hint: Option<&str>) -> bool {
215        match hint {
216            None | Some("*") => true,
217            Some(h) => self.nodes[node.0 as usize]
218                .attr(&self.names, "rel")
219                .is_some_and(|rel| rel.split_whitespace().any(|w| w.eq_ignore_ascii_case(h))),
220        }
221    }
222
223    /// Declare the document's own URL — the base a relative `href`
224    /// joins against when `::href-->` reaches across documents.
225    pub fn set_document_url(&mut self, url: &str) {
226        self.document_url = url::Url::parse(url).ok();
227    }
228
229    /// The subtree text of `node` — every descendant text node in
230    /// document order — concatenated on first use and kept. An
231    /// explicit stack, so a document nested twenty thousand
232    /// elements deep does not recurse.
233    fn text_of(&self, node: NodeId) -> &str {
234        self.nodes[node.0 as usize].text.get_or_init(|| {
235            let mut out = String::new();
236            // (element, next piece, where its text starts in `out`):
237            // an element whose pieces are spent keeps its own text,
238            // so a filter over every element of a deep tree reads
239            // each subtree once.
240            let mut stack: Vec<(usize, usize, usize)> = vec![(node.0 as usize, 0, 0)];
241            while let Some(top) = stack.last_mut() {
242                let (cur, at, start) = *top;
243                let n = &self.nodes[cur];
244                if at >= n.pieces.len() {
245                    if cur != node.0 as usize {
246                        let _ = n.text.set(out[start..].to_string());
247                    }
248                    stack.pop();
249                    continue;
250                }
251                top.1 += 1;
252                match &n.pieces[at] {
253                    Piece::Text(t) => out.push_str(t),
254                    Piece::Child(k) => {
255                        let c = n.children[*k].0 as usize;
256                        match self.nodes[c].text.get() {
257                            Some(t) => out.push_str(t),
258                            None => stack.push((c, 0, out.len())),
259                        }
260                    }
261                }
262            }
263            out
264        })
265    }
266
267    /// Parse `html` and build the adapter.
268    pub fn parse(html: &str) -> Self {
269        use html5ever::tendril::TendrilSink;
270        let sink =
271            html5ever::driver::parse_document(sink::Sink::new(), Default::default()).one(html);
272        let mut nodes = vec![Node {
273            tag: None,
274            attrs: Vec::new(),
275            pieces: vec![Piece::Child(0)],
276            text: std::cell::OnceCell::new(),
277            parent: None,
278            children: Vec::new(),
279        }];
280        let mut ids = HashMap::new();
281        let root = NodeId(0);
282        let sink::Sink {
283            nodes: mut sunk,
284            mut names,
285        } = sink;
286        // The document element: the first element child of the
287        // sink's document.
288        let html_el = sunk[0].kids.iter().find_map(|k| match k {
289            sink::Kid::Node(n) if !sunk[*n].skip => Some(*n),
290            _ => None,
291        });
292        if let Some(html_el) = html_el {
293            let html_node = build(
294                html_el,
295                Some(root),
296                &mut sunk,
297                &mut nodes,
298                &mut names,
299                &mut ids,
300            );
301            nodes[0].children = vec![html_node];
302        }
303
304        HtmlAdapter {
305            nodes,
306            names,
307            ids,
308            root,
309            document_url: None,
310        }
311    }
312
313    /// A locator path to `node`, like `/html/body/div[2]/p`, for
314    /// rendering. A `[n]` index is added only to disambiguate same-tag
315    /// siblings.
316    pub fn locator(&self, node: NodeId) -> String {
317        let mut segments = Vec::new();
318        let mut cur = Some(node);
319        while let Some(id) = cur {
320            let n = &self.nodes[id.0 as usize];
321            if let Some(tag) = n.tag {
322                segments.push(self.segment(id, tag));
323            }
324            cur = n.parent;
325        }
326        segments.reverse();
327        format!("/{}", segments.join("/"))
328    }
329
330    fn segment(&self, node: NodeId, tag: u32) -> String {
331        let name = self.names.get(tag);
332        let Some(parent) = self.nodes[node.0 as usize].parent else {
333            return name.to_string();
334        };
335        let siblings = &self.nodes[parent.0 as usize].children;
336        let same_tag: Vec<NodeId> = siblings
337            .iter()
338            .copied()
339            .filter(|&s| self.nodes[s.0 as usize].tag == Some(tag))
340            .collect();
341        if same_tag.len() > 1 {
342            let n = same_tag.iter().position(|&s| s == node).unwrap() + 1;
343            format!("{name}[{n}]")
344        } else {
345            name.to_string()
346        }
347    }
348}
349
350/// Intern the sink's element `el` and its subtree into the arena:
351/// text pieces and element children in order, the strings moved
352/// out of the sink. Iterative (an explicit work stack) so deep
353/// markup cannot overflow the call stack; elements are interned in
354/// document (pre-order) order, so node ids, child ordering and the
355/// first-occurrence `ids` map follow the document.
356fn build(
357    el: usize,
358    parent: Option<NodeId>,
359    sunk: &mut [sink::SinkNode],
360    nodes: &mut Vec<Node>,
361    names: &mut Names,
362    ids: &mut HashMap<String, NodeId>,
363) -> NodeId {
364    let root_this = NodeId(nodes.len() as u64);
365    let mut stack = vec![(el, parent)];
366    while let Some((el, parent)) = stack.pop() {
367        let this = NodeId(nodes.len() as u64);
368        let s = &mut sunk[el];
369        let tag = names.intern(&s.name.as_ref().expect("an element").local);
370        let attrs = std::mem::take(&mut s.attrs);
371        let kids = std::mem::take(&mut s.kids);
372        let mut pieces = Vec::new();
373        let mut children: Vec<usize> = Vec::new();
374        let mut ordinal = 0;
375        for k in kids {
376            match k {
377                sink::Kid::Text(t) => pieces.push(Piece::Text(t)),
378                sink::Kid::Node(n) => {
379                    if sunk[n].skip {
380                        continue;
381                    }
382                    pieces.push(Piece::Child(ordinal));
383                    ordinal += 1;
384                    children.push(n);
385                }
386            }
387        }
388        if let Some(k) = names.key("id")
389            && let Some((_, id)) = attrs.iter().find(|(a, _)| *a == k)
390        {
391            ids.entry(id.clone()).or_insert(this);
392        }
393        nodes.push(Node {
394            tag: Some(tag),
395            attrs,
396            pieces,
397            text: std::cell::OnceCell::new(),
398            parent,
399            children: Vec::new(),
400        });
401        if let Some(p) = parent {
402            nodes[p.0 as usize].children.push(this);
403        }
404        for child in children.into_iter().rev() {
405            stack.push((child, Some(this)));
406        }
407    }
408    root_this
409}
410
411/// Block-level HTML element tags.
412const BLOCK: &[&str] = &[
413    "address",
414    "article",
415    "aside",
416    "blockquote",
417    "body",
418    "details",
419    "dialog",
420    "dd",
421    "div",
422    "dl",
423    "dt",
424    "fieldset",
425    "figcaption",
426    "figure",
427    "footer",
428    "form",
429    "h1",
430    "h2",
431    "h3",
432    "h4",
433    "h5",
434    "h6",
435    "header",
436    "hgroup",
437    "hr",
438    "html",
439    "li",
440    "main",
441    "nav",
442    "ol",
443    "p",
444    "pre",
445    "section",
446    "table",
447    "ul",
448];
449
450fn is_heading(tag: &str) -> bool {
451    matches!(tag, "h1" | "h2" | "h3" | "h4" | "h5" | "h6")
452}
453
454impl AstAdapter for HtmlAdapter {
455    /// The arena knows how many nodes a step reaches; nothing is read.
456    fn estimate(&self, scope: &[&str], plan: &quarb::StepPlan<'_>) -> Option<quarb::Estimate> {
457        quarb::adapter::arena_estimate(self, scope, plan, quarb::Latency::Memory, None)
458    }
459
460    fn root(&self) -> NodeId {
461        self.root
462    }
463
464    /// In memory: an unscoped reverse resolution or value match may
465    /// walk the arbor.
466    fn walks_for_references(&self) -> bool {
467        true
468    }
469
470    fn children(&self, node: NodeId) -> Vec<NodeId> {
471        self.nodes[node.0 as usize].children.clone()
472    }
473
474    fn name(&self, node: NodeId) -> Option<String> {
475        self.nodes[node.0 as usize]
476            .tag
477            .map(|t| self.names.get(t).to_string())
478    }
479
480    fn parent(&self, node: NodeId) -> Option<NodeId> {
481        self.nodes[node.0 as usize].parent
482    }
483
484    /// The kind first (spec: Traits) — the element's tag, the
485    /// document's `document` — then the structural traits:
486    /// `<block>` or `<inline>`, plus `<heading>` for `h1`–`h6` and
487    /// `<link>` for an anchor with an `href`.
488    fn traits(&self, node: NodeId) -> Vec<String> {
489        let n = &self.nodes[node.0 as usize];
490        let Some(tag) = n.tag.map(|t| self.names.get(t)) else {
491            return vec!["document".to_string()];
492        };
493        let mut out = vec![tag.to_string()];
494        if BLOCK.contains(&tag) {
495            out.push("block".to_string());
496        } else {
497            out.push("inline".to_string());
498        }
499        if is_heading(tag) {
500            out.push("heading".to_string());
501        }
502        if tag == "a" && n.attr(&self.names, "href").is_some() {
503            out.push("link".to_string());
504        }
505        // A table in every substrate carries `tabula` (spec: The
506        // Table): `//*<tabula>` finds it, `| #` reads it.
507        if tag == "table" {
508            out.push("tabula".to_string());
509        }
510        out
511    }
512
513    /// Attributes, by name (`::href`). Element text is the bare
514    /// projection (`::`) — no attribute name is shadowed.
515    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
516        self.nodes[node.0 as usize]
517            .attr(&self.names, name)
518            .map(|v| Value::Str(v.to_string()))
519    }
520
521    /// The default projection of an element is its text content.
522    fn default_value(&self, node: NodeId) -> Option<Value> {
523        Some(Value::Str(self.text_of(node).to_string()))
524    }
525
526    /// `:::tag`, `:::classes` (the class attribute, split),
527    /// `:::attrs` (the attribute names, sorted — the parser does
528    /// not preserve source order), and `:::n-attrs` — facts about
529    /// the element, never its data: attribute values are properties
530    /// (`::href`), text is the bare projection. `attrs` is what
531    /// makes a renamed attribute discoverable: enumerate the names,
532    /// probe the values by shape.
533    /// The `:::` keys this adapter answers (`:meta` lists the ones
534    /// a node answers).
535    fn metadata_catalogue(&self) -> &'static [&'static str] {
536        &["tag", "classes", "attrs", "n-attrs"]
537    }
538
539    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
540        let n = &self.nodes[node.0 as usize];
541        match key {
542            "tag" => n.tag.map(|t| Value::Str(self.names.get(t).to_string())),
543            "classes" => n.attr(&self.names, "class").map(|c| {
544                Value::list(
545                    c.split_whitespace()
546                        .map(|s| Value::Str(s.to_string()))
547                        .collect(),
548                )
549            }),
550            "attrs" => {
551                let mut names: Vec<&str> =
552                    n.attrs.iter().map(|(k, _)| self.names.get(*k)).collect();
553                names.sort_unstable();
554                Some(Value::list(
555                    names
556                        .into_iter()
557                        .map(|k| Value::Str(k.to_string()))
558                        .collect(),
559                ))
560            }
561            "n-attrs" => Some(Value::Int(n.attrs.len() as i64)),
562            _ => None,
563        }
564    }
565
566    /// Follow an attribute that is a fragment reference (`#section`) to
567    /// the element with that `id`. Used by an anchor's `::href-->`.
568    fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
569        if property == "href" && !self.rel_ok(node, hint) {
570            return None;
571        }
572        let value = self.nodes[node.0 as usize].attr(&self.names, property)?;
573        let fragment = value.strip_prefix('#')?;
574        self.ids.get(fragment).copied()
575    }
576
577    /// The external reference an `href`-like attribute holds: the
578    /// absolute URL, relative values joined against the document's
579    /// own URL (when the mount declared one). A bare `#fragment`
580    /// stays in-document (that is `resolve`'s job); a fragment on
581    /// a cross-document URL rides along — it is the crossref
582    /// within the target, landed by `resolve_fragment` once that
583    /// document is mounted. Non-web schemes reference nothing
584    /// followable. Feeds `::href-->` across documents — the
585    /// acquisition arrow.
586    fn external_ref(
587        &self,
588        node: NodeId,
589        property: &str,
590        hint: Option<&str>,
591    ) -> Option<quarb::ExternalRef> {
592        if property == "href" && !self.rel_ok(node, hint) {
593            return None;
594        }
595        let value = self.nodes[node.0 as usize].attr(&self.names, property)?;
596        let t = value.trim();
597        if t.is_empty() || t.starts_with('#') {
598            return None;
599        }
600        let u = match url::Url::parse(t) {
601            Ok(u) => u,
602            // A relative reference: joined against the document's
603            // URL where one is known; else handed over as written,
604            // for the engine to make absolute against the tree the
605            // page stands in (a file beside it in a directory).
606            Err(url::ParseError::RelativeUrlWithoutBase) => match &self.document_url {
607                Some(base) => base.join(t).ok()?,
608                None => return Some(quarb::ExternalRef::uri(t)),
609            },
610            Err(_) => return None,
611        };
612        if u.scheme() != "http" && u.scheme() != "https" {
613            return None;
614        }
615        Some(quarb::ExternalRef::uri(u.as_str()))
616    }
617
618    /// html's fragment landing: the element whose `id` is
619    /// `fragment`, anywhere in this document.
620    fn resolve_fragment(&self, _node: NodeId, fragment: &str) -> Option<NodeId> {
621        self.ids.get(fragment).copied()
622    }
623
624    /// The bare arrow's property: an anchor-family element's
625    /// `href`, a frame's `src`.
626    /// An element's properties are its attributes.
627    fn property_names(&self, node: NodeId) -> Vec<String> {
628        self.nodes[node.0 as usize]
629            .attrs
630            .iter()
631            .map(|(k, _)| self.names.get(*k).to_string())
632            .collect()
633    }
634
635    /// Any attribute may hold a reference (`#id`, an IDREF) to any
636    /// element of the document: the format declares no narrower one.
637    fn can_refer_to(&self, _source: NodeId, _target: NodeId) -> Option<bool> {
638        Some(true)
639    }
640
641    fn ref_property(&self, node: NodeId) -> Option<String> {
642        let n = &self.nodes[node.0 as usize];
643        let (prop, tags): (&str, &[&str]) = match n.tag.map(|t| self.names.get(t)) {
644            Some(t) if ["a", "link", "area"].contains(&t) => ("href", &["a", "link", "area"]),
645            Some(t) if ["iframe", "frame"].contains(&t) => ("src", &["iframe", "frame"]),
646            _ => return None,
647        };
648        let _ = tags;
649        n.attr(&self.names, prop).map(|_| prop.to_string())
650    }
651
652    /// The relation an href edge carries: the `rel` attribute —
653    /// `<link rel="stylesheet">`, `<a rel="nofollow">`. The spec's
654    /// `-->stylesheet` hint filters on it; absent, the edge label
655    /// falls back to the property name.
656    fn ref_label(&self, node: NodeId, property: &str) -> Option<String> {
657        if property != "href" {
658            return None;
659        }
660        let rel = self.nodes[node.0 as usize].attr(&self.names, "rel")?;
661        let rel = rel.trim();
662        (!rel.is_empty()).then(|| rel.to_string())
663    }
664}
665
666#[cfg(test)]
667mod declared_tests {
668    use super::declared_encoding;
669
670    #[test]
671    fn meta_charset_forms() {
672        assert_eq!(
673            declared_encoding(
674                b"<!DOCTYPE html><html><head><meta charset=\"windows-1251\"><title>x</title>"
675            ),
676            Some("windows-1251".into())
677        );
678        assert_eq!(
679            declared_encoding(b"<html><head><META http-equiv=\"Content-Type\" content=\"text/html; charset=koi8-r\">"),
680            Some("koi8-r".into())
681        );
682        assert_eq!(
683            declared_encoding(b"<html><head><meta charset=utf-8></head>"),
684            Some("utf-8".into())
685        );
686        assert_eq!(
687            declared_encoding(b"<html><head><meta name=\"charset-note\" content=\"x\"></head>"),
688            None
689        );
690        assert_eq!(
691            declared_encoding(b"<?xml version=\"1.0\" encoding=\"ISO-8859-5\"?><html/>"),
692            Some("ISO-8859-5".into())
693        );
694        // a charset= inside another attribute's value declares nothing
695        assert_eq!(
696            declared_encoding(
697                b"<meta name=\"description\" content=\"why charset=koi8-r was used\"><title>x</title>"
698            ),
699            None
700        );
701        // another processing instruction is no prologue
702        assert_eq!(
703            declared_encoding(b"<?xml-stylesheet href=\"a.xsl\" encoding=\"koi8-r\"?><html/>"),
704            None
705        );
706        let mut late = vec![b' '; 1100];
707        late.extend_from_slice(b"<meta charset=\"koi8-r\">");
708        assert_eq!(declared_encoding(&late), None);
709    }
710}