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    /// Structural traits: `<block>` or `<inline>`, plus `<heading>`
485    /// for `h1`–`h6` and `<link>` for an anchor with an `href`.
486    fn traits(&self, node: NodeId) -> Vec<String> {
487        let n = &self.nodes[node.0 as usize];
488        let Some(tag) = n.tag.map(|t| self.names.get(t)) else {
489            return Vec::new();
490        };
491        let mut out = Vec::new();
492        if BLOCK.contains(&tag) {
493            out.push("block".to_string());
494        } else {
495            out.push("inline".to_string());
496        }
497        if is_heading(tag) {
498            out.push("heading".to_string());
499        }
500        if tag == "a" && n.attr(&self.names, "href").is_some() {
501            out.push("link".to_string());
502        }
503        // A table in every substrate carries `tabula` (spec: The
504        // Table): `//*<tabula>` finds it, `| #` reads it.
505        if tag == "table" {
506            out.push("tabula".to_string());
507        }
508        out
509    }
510
511    /// Attributes, by name (`::href`). Element text is the bare
512    /// projection (`::`) — no attribute name is shadowed.
513    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
514        self.nodes[node.0 as usize]
515            .attr(&self.names, name)
516            .map(|v| Value::Str(v.to_string()))
517    }
518
519    /// The default projection of an element is its text content.
520    fn default_value(&self, node: NodeId) -> Option<Value> {
521        Some(Value::Str(self.text_of(node).to_string()))
522    }
523
524    /// `::::tag`, `::::classes` (the class attribute, split),
525    /// `::::attrs` (the attribute names, sorted — the parser does
526    /// not preserve source order), and `::::n-attrs` — facts about
527    /// the element, never its data: attribute values are properties
528    /// (`::href`), text is the bare projection. `attrs` is what
529    /// makes a renamed attribute discoverable: enumerate the names,
530    /// probe the values by shape.
531    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
532        let n = &self.nodes[node.0 as usize];
533        match key {
534            "tag" => n.tag.map(|t| Value::Str(self.names.get(t).to_string())),
535            "classes" => n.attr(&self.names, "class").map(|c| {
536                Value::list(
537                    c.split_whitespace()
538                        .map(|s| Value::Str(s.to_string()))
539                        .collect(),
540                )
541            }),
542            "attrs" => {
543                let mut names: Vec<&str> =
544                    n.attrs.iter().map(|(k, _)| self.names.get(*k)).collect();
545                names.sort_unstable();
546                Some(Value::list(
547                    names
548                        .into_iter()
549                        .map(|k| Value::Str(k.to_string()))
550                        .collect(),
551                ))
552            }
553            "n-attrs" => Some(Value::Int(n.attrs.len() as i64)),
554            _ => None,
555        }
556    }
557
558    /// Follow an attribute that is a fragment reference (`#section`) to
559    /// the element with that `id`. Used by an anchor's `::href-->`.
560    fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
561        if property == "href" && !self.rel_ok(node, hint) {
562            return None;
563        }
564        let value = self.nodes[node.0 as usize].attr(&self.names, property)?;
565        let fragment = value.strip_prefix('#')?;
566        self.ids.get(fragment).copied()
567    }
568
569    /// The external reference an `href`-like attribute holds: the
570    /// absolute URL, relative values joined against the document's
571    /// own URL (when the mount declared one). A bare `#fragment`
572    /// stays in-document (that is `resolve`'s job); a fragment on
573    /// a cross-document URL rides along — it is the crossref
574    /// within the target, landed by `resolve_fragment` once that
575    /// document is mounted. Non-web schemes reference nothing
576    /// followable. Feeds `::href-->` across documents — the
577    /// acquisition arrow.
578    fn external_ref(
579        &self,
580        node: NodeId,
581        property: &str,
582        hint: Option<&str>,
583    ) -> Option<quarb::ExternalRef> {
584        if property == "href" && !self.rel_ok(node, hint) {
585            return None;
586        }
587        let value = self.nodes[node.0 as usize].attr(&self.names, property)?;
588        let t = value.trim();
589        if t.is_empty() || t.starts_with('#') {
590            return None;
591        }
592        let u = match url::Url::parse(t) {
593            Ok(u) => u,
594            // A relative reference: joined against the document's
595            // URL where one is known; else handed over as written,
596            // for the engine to make absolute against the tree the
597            // page stands in (a file beside it in a directory).
598            Err(url::ParseError::RelativeUrlWithoutBase) => match &self.document_url {
599                Some(base) => base.join(t).ok()?,
600                None => return Some(quarb::ExternalRef::uri(t)),
601            },
602            Err(_) => return None,
603        };
604        if u.scheme() != "http" && u.scheme() != "https" {
605            return None;
606        }
607        Some(quarb::ExternalRef::uri(u.as_str()))
608    }
609
610    /// html's fragment landing: the element whose `id` is
611    /// `fragment`, anywhere in this document.
612    fn resolve_fragment(&self, _node: NodeId, fragment: &str) -> Option<NodeId> {
613        self.ids.get(fragment).copied()
614    }
615
616    /// The bare arrow's property: an anchor-family element's
617    /// `href`, a frame's `src`.
618    /// An element's properties are its attributes.
619    fn property_names(&self, node: NodeId) -> Vec<String> {
620        self.nodes[node.0 as usize]
621            .attrs
622            .iter()
623            .map(|(k, _)| self.names.get(*k).to_string())
624            .collect()
625    }
626
627    /// Any attribute may hold a reference (`#id`, an IDREF) to any
628    /// element of the document: the format declares no narrower one.
629    fn can_refer_to(&self, _source: NodeId, _target: NodeId) -> Option<bool> {
630        Some(true)
631    }
632
633    fn ref_property(&self, node: NodeId) -> Option<String> {
634        let n = &self.nodes[node.0 as usize];
635        let (prop, tags): (&str, &[&str]) = match n.tag.map(|t| self.names.get(t)) {
636            Some(t) if ["a", "link", "area"].contains(&t) => ("href", &["a", "link", "area"]),
637            Some(t) if ["iframe", "frame"].contains(&t) => ("src", &["iframe", "frame"]),
638            _ => return None,
639        };
640        let _ = tags;
641        n.attr(&self.names, prop).map(|_| prop.to_string())
642    }
643
644    /// The relation an href edge carries: the `rel` attribute —
645    /// `<link rel="stylesheet">`, `<a rel="nofollow">`. The spec's
646    /// `-->stylesheet` hint filters on it; absent, the edge label
647    /// falls back to the property name.
648    fn ref_label(&self, node: NodeId, property: &str) -> Option<String> {
649        if property != "href" {
650            return None;
651        }
652        let rel = self.nodes[node.0 as usize].attr(&self.names, "rel")?;
653        let rel = rel.trim();
654        (!rel.is_empty()).then(|| rel.to_string())
655    }
656}
657
658#[cfg(test)]
659mod declared_tests {
660    use super::declared_encoding;
661
662    #[test]
663    fn meta_charset_forms() {
664        assert_eq!(
665            declared_encoding(
666                b"<!DOCTYPE html><html><head><meta charset=\"windows-1251\"><title>x</title>"
667            ),
668            Some("windows-1251".into())
669        );
670        assert_eq!(
671            declared_encoding(b"<html><head><META http-equiv=\"Content-Type\" content=\"text/html; charset=koi8-r\">"),
672            Some("koi8-r".into())
673        );
674        assert_eq!(
675            declared_encoding(b"<html><head><meta charset=utf-8></head>"),
676            Some("utf-8".into())
677        );
678        assert_eq!(
679            declared_encoding(b"<html><head><meta name=\"charset-note\" content=\"x\"></head>"),
680            None
681        );
682        assert_eq!(
683            declared_encoding(b"<?xml version=\"1.0\" encoding=\"ISO-8859-5\"?><html/>"),
684            Some("ISO-8859-5".into())
685        );
686        // a charset= inside another attribute's value declares nothing
687        assert_eq!(
688            declared_encoding(
689                b"<meta name=\"description\" content=\"why charset=koi8-r was used\"><title>x</title>"
690            ),
691            None
692        );
693        // another processing instruction is no prologue
694        assert_eq!(
695            declared_encoding(b"<?xml-stylesheet href=\"a.xsl\" encoding=\"koi8-r\"?><html/>"),
696            None
697        );
698        let mut late = vec![b' '; 1100];
699        late.extend_from_slice(b"<meta charset=\"koi8-r\">");
700        assert_eq!(declared_encoding(&late), None);
701    }
702}