1use quarb::{AstAdapter, NodeId, Value};
23use scraper::{ElementRef, Html};
24use std::collections::HashMap;
25
26struct Node {
27 tag: Option<String>,
29 attrs: Vec<(String, String)>,
30 text: String,
32 parent: Option<NodeId>,
33 children: Vec<NodeId>,
34}
35
36impl Node {
37 fn attr(&self, name: &str) -> Option<&str> {
38 self.attrs
39 .iter()
40 .find(|(k, _)| k == name)
41 .map(|(_, v)| v.as_str())
42 }
43}
44
45pub fn declared_encoding(bytes: &[u8]) -> Option<String> {
55 if let Some(label) = xml_prologue_encoding(bytes) {
56 return Some(label);
57 }
58 let head = &bytes[..bytes.len().min(1024)];
59 let lower: Vec<u8> = head.iter().map(|b| b.to_ascii_lowercase()).collect();
60 let mut at = 0;
61 while let Some(i) = find(&lower[at..], b"<meta") {
62 let start = at + i + 5;
63 let end = start + find(&lower[start..], b">").unwrap_or(lower.len() - start);
64 let attrs = meta_attributes(&lower[start..end]);
65 let get = |name: &str| {
66 attrs
67 .iter()
68 .find(|(k, _)| k == name)
69 .map(|(_, v)| v.as_str())
70 };
71 if let Some(label) = get("charset").filter(|l| !l.is_empty()) {
72 return Some(label.to_string());
73 }
74 if get("http-equiv") == Some("content-type")
75 && let Some(content) = get("content")
76 && let Some(p) = content.find("charset=")
77 {
78 let label = content[p + 8..]
79 .trim_matches(|c: char| c == '"' || c == '\'' || c.is_ascii_whitespace())
80 .split([';', ' ', '"', '\''])
81 .next()
82 .unwrap_or("");
83 if !label.is_empty() {
84 return Some(label.to_string());
85 }
86 }
87 at = end;
88 }
89 None
90}
91
92fn xml_prologue_encoding(bytes: &[u8]) -> Option<String> {
97 let head = bytes.strip_prefix(b"<?xml")?;
98 if !head.first().is_some_and(|b| b.is_ascii_whitespace()) {
99 return None;
100 }
101 let end = find(head, b"?>")?;
102 let prologue = std::str::from_utf8(&head[..end]).ok()?;
103 let rest = prologue
104 .split_once("encoding")?
105 .1
106 .trim_start()
107 .strip_prefix('=')?
108 .trim_start();
109 let quote = rest.chars().next()?;
110 (quote == '"' || quote == '\'')
111 .then(|| {
112 rest[1..]
113 .split(quote)
114 .next()
115 .unwrap_or("")
116 .trim()
117 .to_string()
118 })
119 .filter(|l| !l.is_empty())
120}
121
122fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
123 hay.windows(needle.len()).position(|w| w == needle)
124}
125
126fn meta_attributes(tag: &[u8]) -> Vec<(String, String)> {
131 let mut out = Vec::new();
132 let mut i = 0;
133 let n = tag.len();
134 while i < n {
135 while i < n && (tag[i].is_ascii_whitespace() || tag[i] == b'/') {
136 i += 1;
137 }
138 let name_start = i;
139 while i < n && !tag[i].is_ascii_whitespace() && tag[i] != b'=' && tag[i] != b'/' {
140 i += 1;
141 }
142 if i == name_start {
143 break;
144 }
145 let name = String::from_utf8_lossy(&tag[name_start..i]).into_owned();
146 while i < n && tag[i].is_ascii_whitespace() {
147 i += 1;
148 }
149 let mut value = String::new();
150 if i < n && tag[i] == b'=' {
151 i += 1;
152 while i < n && tag[i].is_ascii_whitespace() {
153 i += 1;
154 }
155 if i < n && (tag[i] == b'"' || tag[i] == b'\'') {
156 let q = tag[i];
157 i += 1;
158 let v = i;
159 while i < n && tag[i] != q {
160 i += 1;
161 }
162 value = String::from_utf8_lossy(&tag[v..i]).trim().to_string();
163 i = (i + 1).min(n);
164 } else {
165 let v = i;
166 while i < n && !tag[i].is_ascii_whitespace() {
167 i += 1;
168 }
169 value = String::from_utf8_lossy(&tag[v..i]).to_string();
170 }
171 }
172 out.push((name, value));
173 }
174 out
175}
176
177pub struct HtmlAdapter {
179 nodes: Vec<Node>,
180 ids: HashMap<String, NodeId>,
182 root: NodeId,
183 document_url: Option<url::Url>,
188}
189
190impl HtmlAdapter {
191 fn rel_ok(&self, node: NodeId, hint: Option<&str>) -> bool {
198 match hint {
199 None | Some("*") => true,
200 Some(h) => self.nodes[node.0 as usize]
201 .attr("rel")
202 .is_some_and(|rel| rel.split_whitespace().any(|w| w.eq_ignore_ascii_case(h))),
203 }
204 }
205
206 pub fn set_document_url(&mut self, url: &str) {
209 self.document_url = url::Url::parse(url).ok();
210 }
211
212 pub fn parse(html: &str) -> Self {
214 let document = Html::parse_document(html);
215 let mut nodes = vec![Node {
216 tag: None,
217 attrs: Vec::new(),
218 text: String::new(),
219 parent: None,
220 children: Vec::new(),
221 }];
222 let mut ids = HashMap::new();
223 let root = NodeId(0);
224
225 let html_node = build(document.root_element(), Some(root), &mut nodes, &mut ids);
226 nodes[0].text = nodes[html_node.0 as usize].text.clone();
227 nodes[0].children = vec![html_node];
228
229 HtmlAdapter {
230 nodes,
231 ids,
232 root,
233 document_url: None,
234 }
235 }
236
237 pub fn locator(&self, node: NodeId) -> String {
241 let mut segments = Vec::new();
242 let mut cur = Some(node);
243 while let Some(id) = cur {
244 let n = &self.nodes[id.0 as usize];
245 if let Some(tag) = &n.tag {
246 segments.push(self.segment(id, tag));
247 }
248 cur = n.parent;
249 }
250 segments.reverse();
251 format!("/{}", segments.join("/"))
252 }
253
254 fn segment(&self, node: NodeId, tag: &str) -> String {
255 let Some(parent) = self.nodes[node.0 as usize].parent else {
256 return tag.to_string();
257 };
258 let siblings = &self.nodes[parent.0 as usize].children;
259 let same_tag: Vec<NodeId> = siblings
260 .iter()
261 .copied()
262 .filter(|&s| self.nodes[s.0 as usize].tag.as_deref() == Some(tag))
263 .collect();
264 if same_tag.len() > 1 {
265 let n = same_tag.iter().position(|&s| s == node).unwrap() + 1;
266 format!("{tag}[{n}]")
267 } else {
268 tag.to_string()
269 }
270 }
271}
272
273fn build(
284 el: ElementRef,
285 parent: Option<NodeId>,
286 nodes: &mut Vec<Node>,
287 ids: &mut HashMap<String, NodeId>,
288) -> NodeId {
289 let root_this = NodeId(nodes.len() as u64);
290 let mut stack = vec![(el, parent)];
292
293 while let Some((el, parent)) = stack.pop() {
294 let this = NodeId(nodes.len() as u64);
295
296 let tag = el.value().name().to_string();
297 let attrs: Vec<(String, String)> = el
298 .value()
299 .attrs()
300 .map(|(k, v)| (k.to_string(), v.to_string()))
301 .collect();
302 let text: String = el.text().collect();
303
304 if let Some(id) = el.value().id() {
305 ids.entry(id.to_string()).or_insert(this);
306 }
307
308 nodes.push(Node {
309 tag: Some(tag),
310 attrs,
311 text,
312 parent,
313 children: Vec::new(),
314 });
315
316 if let Some(p) = parent {
320 nodes[p.0 as usize].children.push(this);
321 }
322
323 let children: Vec<ElementRef> = el.children().filter_map(ElementRef::wrap).collect();
326 for child in children.into_iter().rev() {
327 stack.push((child, Some(this)));
328 }
329 }
330
331 root_this
332}
333
334const BLOCK: &[&str] = &[
336 "address",
337 "article",
338 "aside",
339 "blockquote",
340 "body",
341 "details",
342 "dialog",
343 "dd",
344 "div",
345 "dl",
346 "dt",
347 "fieldset",
348 "figcaption",
349 "figure",
350 "footer",
351 "form",
352 "h1",
353 "h2",
354 "h3",
355 "h4",
356 "h5",
357 "h6",
358 "header",
359 "hgroup",
360 "hr",
361 "html",
362 "li",
363 "main",
364 "nav",
365 "ol",
366 "p",
367 "pre",
368 "section",
369 "table",
370 "ul",
371];
372
373fn is_heading(tag: &str) -> bool {
374 matches!(tag, "h1" | "h2" | "h3" | "h4" | "h5" | "h6")
375}
376
377impl AstAdapter for HtmlAdapter {
378 fn root(&self) -> NodeId {
379 self.root
380 }
381
382 fn walks_for_references(&self) -> bool {
385 true
386 }
387
388 fn children(&self, node: NodeId) -> Vec<NodeId> {
389 self.nodes[node.0 as usize].children.clone()
390 }
391
392 fn name(&self, node: NodeId) -> Option<String> {
393 self.nodes[node.0 as usize].tag.clone()
394 }
395
396 fn parent(&self, node: NodeId) -> Option<NodeId> {
397 self.nodes[node.0 as usize].parent
398 }
399
400 fn traits(&self, node: NodeId) -> Vec<String> {
403 let n = &self.nodes[node.0 as usize];
404 let Some(tag) = &n.tag else {
405 return Vec::new();
406 };
407 let mut out = Vec::new();
408 if BLOCK.contains(&tag.as_str()) {
409 out.push("block".to_string());
410 } else {
411 out.push("inline".to_string());
412 }
413 if is_heading(tag) {
414 out.push("heading".to_string());
415 }
416 if tag == "a" && n.attr("href").is_some() {
417 out.push("link".to_string());
418 }
419 out
420 }
421
422 fn property(&self, node: NodeId, name: &str) -> Option<Value> {
425 self.nodes[node.0 as usize]
426 .attr(name)
427 .map(|v| Value::Str(v.to_string()))
428 }
429
430 fn default_value(&self, node: NodeId) -> Option<Value> {
432 Some(Value::Str(self.nodes[node.0 as usize].text.clone()))
433 }
434
435 fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
443 let n = &self.nodes[node.0 as usize];
444 match key {
445 "tag" => n.tag.clone().map(Value::Str),
446 "classes" => n.attr("class").map(|c| {
447 Value::list(
448 c.split_whitespace()
449 .map(|s| Value::Str(s.to_string()))
450 .collect(),
451 )
452 }),
453 "attrs" => {
454 let mut names: Vec<&str> = n.attrs.iter().map(|(k, _)| k.as_str()).collect();
455 names.sort_unstable();
456 Some(Value::list(
457 names
458 .into_iter()
459 .map(|k| Value::Str(k.to_string()))
460 .collect(),
461 ))
462 }
463 "n-attrs" => Some(Value::Int(n.attrs.len() as i64)),
464 _ => None,
465 }
466 }
467
468 fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
471 if property == "href" && !self.rel_ok(node, hint) {
472 return None;
473 }
474 let value = self.nodes[node.0 as usize].attr(property)?;
475 let fragment = value.strip_prefix('#')?;
476 self.ids.get(fragment).copied()
477 }
478
479 fn external_ref(
489 &self,
490 node: NodeId,
491 property: &str,
492 hint: Option<&str>,
493 ) -> Option<quarb::ExternalRef> {
494 if property == "href" && !self.rel_ok(node, hint) {
495 return None;
496 }
497 let value = self.nodes[node.0 as usize].attr(property)?;
498 let t = value.trim();
499 if t.is_empty() || t.starts_with('#') {
500 return None;
501 }
502 let u = match url::Url::parse(t) {
503 Ok(u) => u,
504 Err(url::ParseError::RelativeUrlWithoutBase) => match &self.document_url {
509 Some(base) => base.join(t).ok()?,
510 None => return Some(quarb::ExternalRef::uri(t)),
511 },
512 Err(_) => return None,
513 };
514 if u.scheme() != "http" && u.scheme() != "https" {
515 return None;
516 }
517 Some(quarb::ExternalRef::uri(u.as_str()))
518 }
519
520 fn resolve_fragment(&self, _node: NodeId, fragment: &str) -> Option<NodeId> {
523 self.ids.get(fragment).copied()
524 }
525
526 fn property_names(&self, node: NodeId) -> Vec<String> {
530 self.nodes[node.0 as usize]
531 .attrs
532 .iter()
533 .map(|(k, _)| k.clone())
534 .collect()
535 }
536
537 fn can_refer_to(&self, _source: NodeId, _target: NodeId) -> Option<bool> {
540 Some(true)
541 }
542
543 fn ref_property(&self, node: NodeId) -> Option<String> {
544 let n = &self.nodes[node.0 as usize];
545 let (prop, tags): (&str, &[&str]) = match n.tag.as_deref() {
546 Some(t) if ["a", "link", "area"].contains(&t) => ("href", &["a", "link", "area"]),
547 Some(t) if ["iframe", "frame"].contains(&t) => ("src", &["iframe", "frame"]),
548 _ => return None,
549 };
550 let _ = tags;
551 n.attr(prop).map(|_| prop.to_string())
552 }
553
554 fn ref_label(&self, node: NodeId, property: &str) -> Option<String> {
559 if property != "href" {
560 return None;
561 }
562 let rel = self.nodes[node.0 as usize].attr("rel")?;
563 let rel = rel.trim();
564 (!rel.is_empty()).then(|| rel.to_string())
565 }
566}
567
568#[cfg(test)]
569mod declared_tests {
570 use super::declared_encoding;
571
572 #[test]
573 fn meta_charset_forms() {
574 assert_eq!(
575 declared_encoding(
576 b"<!DOCTYPE html><html><head><meta charset=\"windows-1251\"><title>x</title>"
577 ),
578 Some("windows-1251".into())
579 );
580 assert_eq!(
581 declared_encoding(b"<html><head><META http-equiv=\"Content-Type\" content=\"text/html; charset=koi8-r\">"),
582 Some("koi8-r".into())
583 );
584 assert_eq!(
585 declared_encoding(b"<html><head><meta charset=utf-8></head>"),
586 Some("utf-8".into())
587 );
588 assert_eq!(
589 declared_encoding(b"<html><head><meta name=\"charset-note\" content=\"x\"></head>"),
590 None
591 );
592 assert_eq!(
593 declared_encoding(b"<?xml version=\"1.0\" encoding=\"ISO-8859-5\"?><html/>"),
594 Some("ISO-8859-5".into())
595 );
596 assert_eq!(
598 declared_encoding(
599 b"<meta name=\"description\" content=\"why charset=koi8-r was used\"><title>x</title>"
600 ),
601 None
602 );
603 assert_eq!(
605 declared_encoding(b"<?xml-stylesheet href=\"a.xsl\" encoding=\"koi8-r\"?><html/>"),
606 None
607 );
608 let mut late = vec![b' '; 1100];
609 late.extend_from_slice(b"<meta charset=\"koi8-r\">");
610 assert_eq!(declared_encoding(&late), None);
611 }
612}