1use sim_kernel::Expr;
5
6use crate::{
7 BackendId, Inline, MarkupBackend, MarkupBlock, MarkupDecodeOptions, MarkupDoc,
8 MarkupEncodeOptions, MarkupError, MarkupFidelity, SourceDoc, Span, SpanState,
9};
10
11#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct HtmlDecodeOptions {
14 pub max_input_bytes: usize,
16 pub max_nodes: usize,
18 pub max_depth: usize,
20 pub max_text_bytes: usize,
22 pub http_charset: Option<String>,
24}
25
26impl Default for HtmlDecodeOptions {
27 fn default() -> Self {
28 Self {
29 max_input_bytes: 2 * 1024 * 1024,
30 max_nodes: 100_000,
31 max_depth: 256,
32 max_text_bytes: 1024 * 1024,
33 http_charset: None,
34 }
35 }
36}
37
38#[derive(Clone, Debug, Default)]
40pub struct HtmlBackend;
41
42impl MarkupBackend for HtmlBackend {
43 fn id(&self) -> BackendId {
44 BackendId::new("html")
45 }
46 fn decode(
47 &self,
48 input: &str,
49 opts: &MarkupDecodeOptions,
50 ) -> Result<(MarkupDoc, MarkupFidelity), MarkupError> {
51 decode_html_text(
52 input,
53 opts.preserve_source,
54 HtmlDecodeOptions::default(),
55 Vec::new(),
56 )
57 }
58 fn encode(
59 &self,
60 doc: &MarkupDoc,
61 _opts: &MarkupEncodeOptions,
62 ) -> Result<(String, MarkupFidelity), MarkupError> {
63 if let Some(source) = &doc.source
64 && source.backend.as_str() == "html"
65 {
66 return Ok((source.text.clone(), MarkupFidelity::exact(self.id())));
67 }
68 Err(MarkupError::Encode(
69 "HTML is an extraction backend; encoding requires preserved HTML source".into(),
70 ))
71 }
72}
73
74pub fn decode_html_bytes(
76 input: &[u8],
77 opts: &HtmlDecodeOptions,
78) -> Result<(MarkupDoc, MarkupFidelity), MarkupError> {
79 if input.len() > opts.max_input_bytes {
80 return Err(MarkupError::Decode("HTML input byte limit exceeded".into()));
81 }
82 let declared = opts.http_charset.clone().or_else(|| sniff_charset(input));
83 let mut warnings = Vec::new();
84 let text = match declared.as_deref().map(|v| v.to_ascii_lowercase()) {
85 Some(label) if label == "iso-8859-1" || label == "windows-1252" => {
86 input.iter().map(|&b| char::from(b)).collect()
87 }
88 Some(label) if label != "utf-8" && label != "utf8" => {
89 warnings.push(format!("unsupported charset {label}; decoded as UTF-8"));
90 String::from_utf8_lossy(input).into_owned()
91 }
92 _ => String::from_utf8_lossy(input).into_owned(),
93 };
94 if std::str::from_utf8(input).is_err()
95 && !matches!(declared.as_deref(), Some("iso-8859-1" | "windows-1252"))
96 {
97 warnings.push("invalid UTF-8 replaced during decode".into());
98 }
99 decode_html_text(&text, true, opts.clone(), warnings)
100}
101
102fn sniff_charset(input: &[u8]) -> Option<String> {
103 let head = String::from_utf8_lossy(&input[..input.len().min(4096)]).to_ascii_lowercase();
104 let at = head.find("charset=")? + 8;
105 Some(
106 head[at..]
107 .trim_start_matches(['\'', '"'])
108 .split(|c: char| c == '\'' || c == '"' || c == ';' || c.is_whitespace() || c == '>')
109 .next()?
110 .to_owned(),
111 )
112}
113
114fn decode_html_text(
115 input: &str,
116 preserve_source: bool,
117 limits: HtmlDecodeOptions,
118 warnings: Vec<String>,
119) -> Result<(MarkupDoc, MarkupFidelity), MarkupError> {
120 if input.len() > limits.max_input_bytes {
121 return Err(MarkupError::Decode("HTML input byte limit exceeded".into()));
122 }
123 let mut p = Parser {
124 source: input,
125 pos: 0,
126 nodes: 0,
127 depth: 0,
128 text_bytes: 0,
129 limits,
130 blocks: Vec::new(),
131 stack: Vec::new(),
132 title: None,
133 attrs: Default::default(),
134 warnings,
135 suppressed: 0,
136 };
137 p.parse()?;
138 p.extract_structures();
139 let mut fidelity = MarkupFidelity::exact(BackendId::new("html"));
140 fidelity.warnings = p.warnings;
141 let doc = MarkupDoc {
142 title: p.title,
143 blocks: p.blocks,
144 attrs: p.attrs,
145 source: preserve_source.then(|| SourceDoc {
146 backend: BackendId::new("html"),
147 text: input.to_owned(),
148 }),
149 };
150 Ok((doc, fidelity))
151}
152
153struct Frame {
154 tag: String,
155 start: usize,
156 text: String,
157 href: Option<String>,
158 lang: Option<String>,
159}
160struct Parser<'a> {
161 source: &'a str,
162 pos: usize,
163 nodes: usize,
164 depth: usize,
165 text_bytes: usize,
166 limits: HtmlDecodeOptions,
167 blocks: Vec<MarkupBlock>,
168 stack: Vec<Frame>,
169 title: Option<String>,
170 attrs: std::collections::BTreeMap<String, Expr>,
171 warnings: Vec<String>,
172 suppressed: usize,
173}
174impl Parser<'_> {
175 fn extract_structures(&mut self) {
176 for (tag, ordered) in [("ul", false), ("ol", true)] {
177 for list in html_elements(self.source, tag) {
178 let items = html_elements(list, "li")
179 .into_iter()
180 .map(|item| {
181 vec![MarkupBlock::Paragraph {
182 content: vec![Inline::Text(normalize(&strip_tags(item)))],
183 span: None,
184 }]
185 })
186 .collect::<Vec<_>>();
187 if !items.is_empty() {
188 self.blocks.push(MarkupBlock::List {
189 ordered,
190 items,
191 span: None,
192 });
193 }
194 }
195 }
196 for table in html_elements(self.source, "table") {
197 let mut rows = html_elements(table, "tr")
198 .into_iter()
199 .map(|row| {
200 let mut cells = html_elements(row, "th");
201 if cells.is_empty() {
202 cells = html_elements(row, "td");
203 }
204 cells
205 .into_iter()
206 .map(|cell| vec![Inline::Text(normalize(&strip_tags(cell)))])
207 .collect::<Vec<_>>()
208 })
209 .filter(|r| !r.is_empty())
210 .collect::<Vec<_>>();
211 if !rows.is_empty() {
212 let header = rows.remove(0);
213 self.blocks.push(MarkupBlock::Table {
214 header,
215 rows,
216 span: None,
217 });
218 }
219 }
220 let readable = self
221 .blocks
222 .iter()
223 .filter_map(|b| match b {
224 MarkupBlock::Heading { text, .. }
225 | MarkupBlock::Paragraph { content: text, .. } => Some(
226 text.iter()
227 .filter_map(|i| {
228 if let Inline::Text(v) = i {
229 Some(v.as_str())
230 } else {
231 None
232 }
233 })
234 .collect::<Vec<_>>()
235 .join(" "),
236 ),
237 _ => None,
238 })
239 .collect::<Vec<_>>()
240 .join("\n");
241 self.attrs
242 .insert("readable-text".into(), Expr::String(readable));
243 }
244 fn parse(&mut self) -> Result<(), MarkupError> {
245 while self.pos < self.source.len() {
246 if self.source.as_bytes()[self.pos] == b'<' {
247 self.tag()?;
248 } else {
249 self.text()?;
250 }
251 }
252 while let Some(frame) = self.stack.pop() {
253 self.finish(frame, self.source.len());
254 }
255 Ok(())
256 }
257 fn tag(&mut self) -> Result<(), MarkupError> {
258 let start = self.pos;
259 let Some(rel) = self.source[start..].find('>') else {
260 self.pos = self.source.len();
261 return Ok(());
262 };
263 let end = start + rel + 1;
264 self.nodes += 1;
265 if self.nodes > self.limits.max_nodes {
266 return Err(MarkupError::Decode("HTML node limit exceeded".into()));
267 }
268 let raw = &self.source[start + 1..end - 1];
269 self.pos = end;
270 if raw.starts_with('!') || raw.starts_with('?') {
271 return Ok(());
272 }
273 let closing = raw.trim_start().starts_with('/');
274 let body = raw.trim().trim_start_matches('/').trim();
275 let name = body
276 .split_whitespace()
277 .next()
278 .unwrap_or("")
279 .trim_end_matches('/')
280 .to_ascii_lowercase();
281 if name.is_empty() {
282 return Ok(());
283 }
284 if closing {
285 if let Some(ix) = self.stack.iter().rposition(|f| f.tag == name) {
286 while self.stack.len() > ix {
287 let f = self.stack.pop().unwrap();
288 self.finish(f, end);
289 }
290 }
291 return Ok(());
292 }
293 if name == "meta"
294 && let Some(v) = attr(body, "name").zip(attr(body, "content"))
295 {
296 self.attrs.insert(
297 format!("meta:{}", v.0.to_ascii_lowercase()),
298 Expr::String(v.1),
299 );
300 }
301 if name == "link"
302 && attr(body, "rel").is_some_and(|v| v.eq_ignore_ascii_case("canonical"))
303 && let Some(v) = attr(body, "href")
304 {
305 self.attrs.insert("canonical-link".into(), Expr::String(v));
306 }
307 if name == "html"
308 && let Some(v) = attr(body, "lang")
309 {
310 self.attrs.insert("language".into(), Expr::String(v));
311 }
312 let active = matches!(
313 name.as_str(),
314 "script" | "style" | "form" | "object" | "embed" | "iframe"
315 );
316 if active {
317 self.suppressed += 1;
318 self.warnings
319 .push(format!("active or embedded <{name}> content omitted"));
320 }
321 if body.contains("on")
322 && body
323 .split_whitespace()
324 .any(|a| a.to_ascii_lowercase().starts_with("on") && a.contains('='))
325 {
326 self.warnings
327 .push(format!("event handler stripped from <{name}>"));
328 }
329 if !body.ends_with('/')
330 && !matches!(
331 name.as_str(),
332 "meta" | "link" | "img" | "br" | "hr" | "input" | "source"
333 )
334 {
335 self.depth += 1;
336 if self.depth > self.limits.max_depth {
337 return Err(MarkupError::Decode("HTML depth limit exceeded".into()));
338 }
339 self.stack.push(Frame {
340 tag: name,
341 start,
342 text: String::new(),
343 href: attr(body, "href"),
344 lang: attr(body, "class")
345 .and_then(|v| v.strip_prefix("language-").map(str::to_owned)),
346 });
347 }
348 Ok(())
349 }
350 fn text(&mut self) -> Result<(), MarkupError> {
351 let end = self.source[self.pos..]
352 .find('<')
353 .map_or(self.source.len(), |v| self.pos + v);
354 let raw = &self.source[self.pos..end];
355 self.pos = end;
356 if self.suppressed == 0 {
357 let decoded = entities(raw);
358 self.text_bytes += decoded.len();
359 if self.text_bytes > self.limits.max_text_bytes {
360 return Err(MarkupError::Decode("HTML text limit exceeded".into()));
361 }
362 for f in &mut self.stack {
363 f.text.push_str(&decoded);
364 }
365 }
366 Ok(())
367 }
368 fn finish(&mut self, f: Frame, end: usize) {
369 self.depth = self.depth.saturating_sub(1);
370 if matches!(
371 f.tag.as_str(),
372 "script" | "style" | "form" | "object" | "embed" | "iframe"
373 ) {
374 self.suppressed = self.suppressed.saturating_sub(1);
375 return;
376 }
377 let text = normalize(&f.text);
378 if text.is_empty() {
379 return;
380 }
381 let span = Some(Span {
382 start: f.start,
383 end,
384 state: SpanState::Preserved,
385 });
386 let inline = || {
387 vec![if let Some(target) = &f.href {
388 Inline::Link {
389 label: vec![Inline::Text(text.clone())],
390 target: target.clone(),
391 }
392 } else {
393 Inline::Text(text.clone())
394 }]
395 };
396 match f.tag.as_str() {
397 "title" => self.title = Some(text),
398 "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => self.blocks.push(MarkupBlock::Heading {
399 level: f.tag[1..].parse().unwrap_or(1),
400 text: inline(),
401 id: None,
402 span,
403 }),
404 "pre" => self.blocks.push(MarkupBlock::CodeBlock {
405 lang: f.lang,
406 code: text,
407 span,
408 }),
409 "blockquote" => self.blocks.push(MarkupBlock::Quote {
410 blocks: vec![MarkupBlock::Paragraph {
411 content: inline(),
412 span: span.clone(),
413 }],
414 span,
415 }),
416 "p" | "li" | "td" | "th" => self.blocks.push(MarkupBlock::Paragraph {
417 content: inline(),
418 span,
419 }),
420 _ => {}
421 }
422 }
423}
424fn attr(body: &str, wanted: &str) -> Option<String> {
425 for token in body.split_whitespace().skip(1) {
426 let (k, v) = token.split_once('=')?;
427 if k.eq_ignore_ascii_case(wanted) {
428 return Some(v.trim_matches(['\'', '"', '>']).to_owned());
429 }
430 }
431 None
432}
433fn normalize(s: &str) -> String {
434 s.split_whitespace().collect::<Vec<_>>().join(" ")
435}
436fn entities(s: &str) -> String {
437 s.replace("&", "&")
438 .replace("<", "<")
439 .replace(">", ">")
440 .replace(""", "\"")
441 .replace("'", "'")
442 .replace(" ", " ")
443}
444fn html_elements<'a>(s: &'a str, tag: &str) -> Vec<&'a str> {
445 let mut out = Vec::new();
446 let open = format!("<{tag}");
447 let close = format!("</{tag}>");
448 let mut rest = s;
449 while let Some(a) = rest.to_ascii_lowercase().find(&open) {
450 let x = &rest[a..];
451 let Some(gt) = x.find('>') else { break };
452 let Some(b) = x[gt + 1..].to_ascii_lowercase().find(&close) else {
453 break;
454 };
455 out.push(&x[gt + 1..gt + 1 + b]);
456 rest = &x[gt + 1 + b + close.len()..];
457 }
458 out
459}
460fn strip_tags(s: &str) -> String {
461 let mut out = String::new();
462 let mut inside = false;
463 for c in s.chars() {
464 match c {
465 '<' => inside = true,
466 '>' => inside = false,
467 _ if !inside => out.push(c),
468 _ => {}
469 }
470 }
471 entities(&out)
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 #[test]
478 fn inert_and_chunk_equivalent() {
479 let html=b"<html lang='en'><head><link rel='canonical' href='https://e/x'><script>panic()</script></head><body><h1>Hello & hi</h1><p>Safe <a href='/x'>link</a></p></body></html>";
480 let (a, f) = decode_html_bytes(html, &Default::default()).unwrap();
481 let joined = [&html[..31], &html[31..]].concat();
482 let (b, _) = decode_html_bytes(&joined, &Default::default()).unwrap();
483 assert_eq!(a, b);
484 assert!(!format!("{:?}", a.blocks).contains("panic"));
485 assert!(f.warnings.iter().any(|w| w.contains("omitted")));
486 }
487}
488