Date: Fri, 28 Aug 2026 11:14:25 -0300
Subject: [PATCH 04/29] test: cover HTML detection review regressions
---
tests/html.rs | 36 ++++++++++++++++++++++++++++++++++++
1 file changed, 36 insertions(+)
diff --git a/tests/html.rs b/tests/html.rs
index 4b02c628..44b88e58 100644
--- a/tests/html.rs
+++ b/tests/html.rs
@@ -12,6 +12,42 @@ fn html_doctype_is_detected_from_content() {
assert_eq!(Format::from_bytes(html), Some(Format::Html));
}
+#[test]
+fn html_doctype_allows_html5_ascii_whitespace() {
+ assert_eq!(Format::from_bytes(b""), Some(Format::Html));
+ assert_eq!(Format::from_bytes(b""), Some(Format::Html));
+}
+
+#[test]
+fn html_prefix_wins_over_embedded_pdf_marker() {
+ let html = b"%PDF-1.7 is text here";
+ assert_eq!(Format::from_bytes(html), Some(Format::Html));
+}
+
+#[test]
+fn utf16_html_is_detected_from_content() {
+ let source = "hello";
+
+ let mut le = vec![0xFF, 0xFE];
+ for unit in source.encode_utf16() {
+ le.extend_from_slice(&unit.to_le_bytes());
+ }
+ assert_eq!(Format::from_bytes(&le), Some(Format::Html));
+
+ let mut be = vec![0xFE, 0xFF];
+ for unit in source.encode_utf16() {
+ be.extend_from_slice(&unit.to_be_bytes());
+ }
+ assert_eq!(Format::from_bytes(&be), Some(Format::Html));
+}
+
+#[test]
+fn unrelated_charset_attribute_does_not_change_decoding() {
+ let html = "café
".as_bytes();
+ let markdown = to_markdown_bytes(html, None).unwrap();
+ assert_eq!(markdown, "café\n");
+}
+
#[test]
fn malformed_html5_is_repaired_before_conversion() {
let html = br#"Hello
first
second"#;
From 50b9629952fb718d7716fa38988c6f17bfde2dc3 Mon Sep 17 00:00:00 2001
From: Marcell Manfrin Barbacena
Date: Fri, 28 Aug 2026 18:14:35 +0000
Subject: [PATCH 05/29] fix: address HTML review regressions
---
Cargo.lock | 1 +
Cargo.toml | 3 +-
src/formats/detect.rs | 27 +++--
src/formats/html.rs | 269 +++++++++++++++++++++++-------------------
tests/html.rs | 47 +++++++-
5 files changed, 214 insertions(+), 133 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 24767b2c..03039ba6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -95,6 +95,7 @@ dependencies = [
"csv",
"encoding_rs",
"flate2",
+ "html5ever",
"insta",
"log",
"pdf-inspector",
diff --git a/Cargo.toml b/Cargo.toml
index b8b1f3f2..e24a9208 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -26,6 +26,7 @@ cfb = "0.14.0"
csv = "1.4.0"
flate2 = "1"
encoding_rs = "0.8.35"
+html5ever = "0.39.0"
log = "0.4"
pdf-inspector = "1.14.2"
quick-xml = "0.41.0"
@@ -34,4 +35,4 @@ zip = { version = "8.6.0", default-features = false, features = ["deflate"] }
[profile.release]
lto = "thin"
-strip = "symbols"
+strip = "symbols"
\ No newline at end of file
diff --git a/src/formats/detect.rs b/src/formats/detect.rs
index 4f647492..04bc849b 100644
--- a/src/formats/detect.rs
+++ b/src/formats/detect.rs
@@ -64,18 +64,21 @@ fn looks_like_html(bytes: &[u8]) -> bool {
}
fn looks_like_utf16_html(bytes: &[u8], little_endian: bool) -> bool {
- let units: Vec = bytes
- .chunks_exact(2)
- .take(256)
- .map(|pair| {
- if little_endian {
- u16::from_le_bytes([pair[0], pair[1]])
- } else {
- u16::from_be_bytes([pair[0], pair[1]])
- }
- })
- .collect();
- let decoded = String::from_utf16_lossy(&units);
+ let mut units = bytes.chunks_exact(2).map(|pair| {
+ if little_endian {
+ u16::from_le_bytes([pair[0], pair[1]])
+ } else {
+ u16::from_be_bytes([pair[0], pair[1]])
+ }
+ });
+ let mut prefix = Vec::with_capacity(64);
+ if let Some(first) =
+ units.find(|unit| !matches!(*unit, 0x0009 | 0x000A | 0x000C | 0x000D | 0x0020))
+ {
+ prefix.push(first);
+ prefix.extend(units.take(63));
+ }
+ let decoded = String::from_utf16_lossy(&prefix);
looks_like_ascii_html(decoded.as_bytes())
}
diff --git a/src/formats/html.rs b/src/formats/html.rs
index 58bc3429..721d3d34 100644
--- a/src/formats/html.rs
+++ b/src/formats/html.rs
@@ -8,7 +8,14 @@ use crate::package::xml::{Attr, Element, Node};
use crate::shared::html::{HtmlCtx, Stylesheet};
use crate::shared::uri::is_absolute_uri;
use encoding_rs::{Encoding, UTF_16BE, UTF_16LE, WINDOWS_1252};
+use html5ever::LocalName;
+use html5ever::tendril::StrTendril;
+use html5ever::tokenizer::states::{Rawtext, Rcdata, ScriptData};
+use html5ever::tokenizer::{
+ BufferQueue, EndTag, StartTag, TagToken, Token, TokenSink, TokenSinkResult, Tokenizer,
+};
use scraper::{ElementRef, Html, Node as HtmlNode};
+use std::cell::{Cell, RefCell};
use std::rc::Rc;
/// Parse a standalone HTML document into anydoc's document model.
@@ -29,6 +36,8 @@ pub fn parse(bytes: &[u8]) -> Result {
}
let text = decode_html(bytes);
+ preflight_html_complexity(&text)?;
+
let parsed = Html::parse_document(&text);
let root = parsed.root_element();
@@ -70,163 +79,180 @@ fn decode_html(bytes: &[u8]) -> String {
fn sniff_meta_charset(bytes: &[u8]) -> Option<&'static Encoding> {
const SNIFF_BYTES: usize = 1024;
- let mut prefix = bytes[..bytes.len().min(SNIFF_BYTES)].to_vec();
- prefix.make_ascii_lowercase();
-
- let mut offset = 0usize;
- while let Some(found) = find_bytes(&prefix[offset..], b"'))
- {
- offset = start + 1;
- continue;
- }
-
- let Some(end_rel) = find_tag_end(&prefix[attrs_start..]) else {
- break;
- };
- let end = attrs_start + end_rel;
- let attrs = &prefix[attrs_start..end];
+ let prefix = String::from_utf8_lossy(&bytes[..bytes.len().min(SNIFF_BYTES)]);
+ let parsed = Html::parse_document(prefix.as_ref());
+ let root = parsed.root_element();
- if let Some(label) = html_attr(attrs, b"charset")
- && let Some(encoding) = Encoding::for_label(label)
+ for meta in root.descendent_elements().filter(|element| element.value().name() == "meta") {
+ if let Some(label) = meta.value().attr("charset")
+ && let Some(encoding) = Encoding::for_label(label.trim().as_bytes())
{
return Some(encoding);
}
- let is_content_type = html_attr(attrs, b"http-equiv")
- .is_some_and(|value| value.eq_ignore_ascii_case(b"content-type"));
+ let is_content_type = meta
+ .value()
+ .attr("http-equiv")
+ .is_some_and(|value| value.trim().eq_ignore_ascii_case("content-type"));
if is_content_type
- && let Some(content) = html_attr(attrs, b"content")
+ && let Some(content) = meta.value().attr("content")
&& let Some(label) = content_type_charset(content)
- && let Some(encoding) = Encoding::for_label(label)
+ && let Some(encoding) = Encoding::for_label(label.as_bytes())
{
return Some(encoding);
}
-
- offset = end.saturating_add(1);
}
None
}
-fn find_tag_end(bytes: &[u8]) -> Option {
- let mut quote = None;
- for (index, &byte) in bytes.iter().enumerate() {
- match (quote, byte) {
- (Some(q), b) if b == q => quote = None,
- (Some(_), _) => {}
- (None, b'\'' | b'"') => quote = Some(byte),
- (None, b'>') => return Some(index),
- (None, _) => {}
+fn content_type_charset(content: &str) -> Option<&str> {
+ for parameter in content.split(';') {
+ let Some((name, value)) = parameter.split_once('=') else {
+ continue;
+ };
+ if name.trim().eq_ignore_ascii_case("charset") {
+ let label = value.trim().trim_matches(|c| c == '\'' || c == '"').trim();
+ if !label.is_empty() {
+ return Some(label);
+ }
}
}
None
}
-fn html_attr<'a>(attrs: &'a [u8], wanted: &[u8]) -> Option<&'a [u8]> {
- let mut pos = 0usize;
- while pos < attrs.len() {
- while attrs.get(pos).is_some_and(|b| b.is_ascii_whitespace() || *b == b'/') {
- pos += 1;
- }
- if pos >= attrs.len() {
- break;
- }
+#[derive(Default)]
+struct HtmlComplexitySink {
+ node_count: Cell,
+ open_elements: RefCell>,
+ node_limit_exceeded: Cell,
+ depth_limit_exceeded: Cell,
+}
- let name_start = pos;
- while attrs
- .get(pos)
- .is_some_and(|b| !b.is_ascii_whitespace() && !matches!(b, b'=' | b'/' | b'>'))
- {
- pos += 1;
+impl HtmlComplexitySink {
+ fn bump_node(&self) {
+ let count = self.node_count.get().saturating_add(1);
+ self.node_count.set(count);
+ if count > limits::MAX_XML_NODES {
+ self.node_limit_exceeded.set(true);
}
- if pos == name_start {
- pos += 1;
- continue;
- }
- let name = &attrs[name_start..pos];
+ }
- while attrs.get(pos).is_some_and(u8::is_ascii_whitespace) {
- pos += 1;
+ fn push_element(&self, name: &LocalName) {
+ let mut open = self.open_elements.borrow_mut();
+ open.push(name.clone());
+ if open.len() > limits::MAX_XML_DEPTH {
+ self.depth_limit_exceeded.set(true);
}
+ }
- let mut value = &attrs[pos..pos];
- if attrs.get(pos) == Some(&b'=') {
- pos += 1;
- while attrs.get(pos).is_some_and(u8::is_ascii_whitespace) {
- pos += 1;
- }
+ fn close_element(&self, name: &LocalName) {
+ let mut open = self.open_elements.borrow_mut();
+ if let Some(position) = open.iter().rposition(|candidate| candidate == name) {
+ open.truncate(position);
+ }
+ }
+}
- if let Some("e @ (b'\'' | b'"')) = attrs.get(pos) {
- pos += 1;
- let value_start = pos;
- while attrs.get(pos).is_some_and(|b| *b != quote) {
- pos += 1;
+impl TokenSink for HtmlComplexitySink {
+ type Handle = ();
+
+ fn process_token(&self, token: Token, _line_number: u64) -> TokenSinkResult {
+ match token {
+ TagToken(tag) => match tag.kind {
+ StartTag => {
+ self.bump_node();
+ let name = tag.name.as_ref();
+ if !tag.self_closing && !is_void_html_element(name) {
+ self.push_element(&tag.name);
+ }
+ match name {
+ "title" | "textarea" => TokenSinkResult::RawData(Rcdata),
+ "style" | "xmp" | "iframe" | "noembed" | "noframes" => {
+ TokenSinkResult::RawData(Rawtext)
+ }
+ "script" => TokenSinkResult::RawData(ScriptData),
+ "plaintext" => TokenSinkResult::Plaintext,
+ _ => TokenSinkResult::Continue,
+ }
}
- value = &attrs[value_start..pos];
- if attrs.get(pos) == Some("e) {
- pos += 1;
+ EndTag => {
+ self.close_element(&tag.name);
+ TokenSinkResult::Continue
}
- } else {
- let value_start = pos;
- while attrs
- .get(pos)
- .is_some_and(|b| !b.is_ascii_whitespace() && !matches!(b, b'/' | b'>'))
- {
- pos += 1;
+ },
+ Token::CharacterTokens(text) => {
+ if !text.is_empty() {
+ self.bump_node();
}
- value = &attrs[value_start..pos];
+ TokenSinkResult::Continue
}
- }
-
- if name.eq_ignore_ascii_case(wanted) {
- return Some(value);
+ Token::CommentToken(_) | Token::DoctypeToken(_) | Token::NullCharacterToken => {
+ self.bump_node();
+ TokenSinkResult::Continue
+ }
+ Token::EOFToken | Token::ParseError(_) => TokenSinkResult::Continue,
}
}
- None
}
-fn content_type_charset(content: &[u8]) -> Option<&[u8]> {
- let found = find_bytes(content, b"charset")?;
- let mut pos = found + b"charset".len();
- while content.get(pos).is_some_and(u8::is_ascii_whitespace) {
- pos += 1;
- }
- if content.get(pos) != Some(&b'=') {
- return None;
- }
- pos += 1;
- while content.get(pos).is_some_and(u8::is_ascii_whitespace) {
- pos += 1;
- }
+fn is_void_html_element(name: &str) -> bool {
+ matches!(
+ name,
+ "area"
+ | "base"
+ | "br"
+ | "col"
+ | "embed"
+ | "hr"
+ | "img"
+ | "input"
+ | "link"
+ | "meta"
+ | "param"
+ | "source"
+ | "track"
+ | "wbr"
+ )
+}
- let quote = match content.get(pos) {
- Some(b'\'') | Some(b'"') => {
- let quote = content[pos];
- pos += 1;
- Some(quote)
- }
- _ => None,
- };
- let start = pos;
- while let Some(&byte) = content.get(pos) {
- let stop = quote.map_or_else(
- || byte.is_ascii_whitespace() || matches!(byte, b';' | b'\'' | b'"'),
- |q| byte == q,
- );
- if stop {
- break;
+fn preflight_html_complexity(text: &str) -> Result<(), ConvertError> {
+ const CHUNK_BYTES: usize = 64 * 1024;
+ let tokenizer = Tokenizer::new(HtmlComplexitySink::default(), Default::default());
+ let input = BufferQueue::default();
+ let mut offset = 0usize;
+
+ while offset < text.len() {
+ let mut end = offset.saturating_add(CHUNK_BYTES).min(text.len());
+ while end > offset && !text.is_char_boundary(end) {
+ end -= 1;
}
- pos += 1;
+ input.push_back(StrTendril::from(&text[offset..end]));
+ let _ = tokenizer.feed(&input);
+ check_preflight_limits(&tokenizer.sink)?;
+ offset = end;
}
- (pos > start).then_some(&content[start..pos])
+
+ tokenizer.end();
+ check_preflight_limits(&tokenizer.sink)
}
-fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option {
- haystack.windows(needle.len()).position(|window| window == needle)
+fn check_preflight_limits(sink: &HtmlComplexitySink) -> Result<(), ConvertError> {
+ if sink.node_limit_exceeded.get() {
+ return Err(ConvertError::ResourceLimit {
+ limit: "max_xml_nodes",
+ detail: format!("HTML token stream has more than {} nodes", limits::MAX_XML_NODES),
+ });
+ }
+ if sink.depth_limit_exceeded.get() {
+ return Err(ConvertError::ResourceLimit {
+ limit: "max_xml_depth",
+ detail: format!(
+ "HTML source nesting depth exceeds {} before DOM construction",
+ limits::MAX_XML_DEPTH
+ ),
+ });
+ }
+ Ok(())
}
fn adapt_element(
@@ -349,5 +375,10 @@ mod tests {
#[test]
fn charset_sniff_ignores_non_meta_text_and_attributes() {
assert_eq!(sniff_meta_charset(b"utf-8
"), None);
+ assert_eq!(sniff_meta_charset(b"utf-8
"), None);
+ assert_eq!(
+ sniff_meta_charset(b""),
+ None
+ );
}
}
diff --git a/tests/html.rs b/tests/html.rs
index 44b88e58..de27cc91 100644
--- a/tests/html.rs
+++ b/tests/html.rs
@@ -1,4 +1,4 @@
-use anydoc::{Format, to_markdown_bytes};
+use anydoc::{ConvertError, Format, to_markdown_bytes};
#[test]
fn html_extensions_are_named() {
@@ -41,6 +41,23 @@ fn utf16_html_is_detected_from_content() {
assert_eq!(Format::from_bytes(&be), Some(Format::Html));
}
+#[test]
+fn utf16_html_detection_allows_long_leading_whitespace() {
+ let source = format!("{}hello", " ".repeat(300));
+
+ let mut le = vec![0xFF, 0xFE];
+ for unit in source.encode_utf16() {
+ le.extend_from_slice(&unit.to_le_bytes());
+ }
+ assert_eq!(Format::from_bytes(&le), Some(Format::Html));
+
+ let mut be = vec![0xFE, 0xFF];
+ for unit in source.encode_utf16() {
+ be.extend_from_slice(&unit.to_be_bytes());
+ }
+ assert_eq!(Format::from_bytes(&be), Some(Format::Html));
+}
+
#[test]
fn unrelated_charset_attribute_does_not_change_decoding() {
let html = "café
".as_bytes();
@@ -48,6 +65,34 @@ fn unrelated_charset_attribute_does_not_change_decoding() {
assert_eq!(markdown, "café\n");
}
+#[test]
+fn meta_looking_text_in_comment_does_not_change_decoding() {
+ let html = "café
".as_bytes();
+ let markdown = to_markdown_bytes(html, None).unwrap();
+ assert_eq!(markdown, "café\n");
+}
+
+#[test]
+fn meta_looking_text_in_script_does_not_change_decoding() {
+ let html =
+ "café
"
+ .as_bytes();
+ let markdown = to_markdown_bytes(html, None).unwrap();
+ assert_eq!(markdown, "café\n");
+}
+
+#[test]
+fn html_node_limit_covers_nodes_outside_body_before_dom_materialization() {
+ let mut html = String::from("");
+ for _ in 0..2_000_001 {
+ html.push_str("");
+ }
+ html.push_str("ok");
+
+ let error = to_markdown_bytes(html.as_bytes(), Some(Format::Html)).unwrap_err();
+ assert!(matches!(error, ConvertError::ResourceLimit { limit: "max_xml_nodes", .. }));
+}
+
#[test]
fn malformed_html5_is_repaired_before_conversion() {
let html = br#"Hello
first
second"#;
From 9920d30ba6466b682fe1eb226239b1f5360edac9 Mon Sep 17 00:00:00 2001
From: Marcell Manfrin Barbacena
Date: Fri, 28 Aug 2026 18:42:10 -0300
Subject: [PATCH 06/29] fix: address second HTML review regressions
---
src/formats/detect.rs | 9 +++----
src/formats/html.rs | 55 ++++++++++++++++++++++++++++++++--------
tests/html.rs | 58 ++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 104 insertions(+), 18 deletions(-)
diff --git a/src/formats/detect.rs b/src/formats/detect.rs
index 04bc849b..77072f45 100644
--- a/src/formats/detect.rs
+++ b/src/formats/detect.rs
@@ -64,12 +64,9 @@ fn looks_like_html(bytes: &[u8]) -> bool {
}
fn looks_like_utf16_html(bytes: &[u8], little_endian: bool) -> bool {
- let mut units = bytes.chunks_exact(2).map(|pair| {
- if little_endian {
- u16::from_le_bytes([pair[0], pair[1]])
- } else {
- u16::from_be_bytes([pair[0], pair[1]])
- }
+ let (pairs, _) = bytes.as_chunks::<2>();
+ let mut units = pairs.iter().map(|pair| {
+ if little_endian { u16::from_le_bytes(*pair) } else { u16::from_be_bytes(*pair) }
});
let mut prefix = Vec::with_capacity(64);
if let Some(first) =
diff --git a/src/formats/html.rs b/src/formats/html.rs
index 721d3d34..bef02a07 100644
--- a/src/formats/html.rs
+++ b/src/formats/html.rs
@@ -78,7 +78,7 @@ fn decode_html(bytes: &[u8]) -> String {
}
fn sniff_meta_charset(bytes: &[u8]) -> Option<&'static Encoding> {
- const SNIFF_BYTES: usize = 1024;
+ const SNIFF_BYTES: usize = 64 * 1024;
let prefix = String::from_utf8_lossy(&bytes[..bytes.len().min(SNIFF_BYTES)]);
let parsed = Html::parse_document(prefix.as_ref());
let root = parsed.root_element();
@@ -106,18 +106,31 @@ fn sniff_meta_charset(bytes: &[u8]) -> Option<&'static Encoding> {
}
fn content_type_charset(content: &str) -> Option<&str> {
- for parameter in content.split(';') {
- let Some((name, value)) = parameter.split_once('=') else {
- continue;
- };
- if name.trim().eq_ignore_ascii_case("charset") {
- let label = value.trim().trim_matches(|c| c == '\'' || c == '"').trim();
- if !label.is_empty() {
- return Some(label);
+ let mut start = 0usize;
+ let mut quote = None;
+ for (index, ch) in content.char_indices() {
+ match (quote, ch) {
+ (Some(active), current) if current == active => quote = None,
+ (None, '\'' | '"') => quote = Some(ch),
+ (None, ';') => {
+ if let Some(label) = charset_parameter(&content[start..index]) {
+ return Some(label);
+ }
+ start = index + ch.len_utf8();
}
+ _ => {}
}
}
- None
+ charset_parameter(&content[start..])
+}
+
+fn charset_parameter(parameter: &str) -> Option<&str> {
+ let (name, value) = parameter.split_once('=')?;
+ if !name.trim().eq_ignore_ascii_case("charset") {
+ return None;
+ }
+ let label = value.trim().trim_matches(|c| c == '\'' || c == '"').trim();
+ (!label.is_empty()).then_some(label)
}
#[derive(Default)]
@@ -139,6 +152,7 @@ impl HtmlComplexitySink {
fn push_element(&self, name: &LocalName) {
let mut open = self.open_elements.borrow_mut();
+ close_implied_before_start(&mut open, name.as_ref());
open.push(name.clone());
if open.len() > limits::MAX_XML_DEPTH {
self.depth_limit_exceeded.set(true);
@@ -195,6 +209,24 @@ impl TokenSink for HtmlComplexitySink {
}
}
+fn close_implied_before_start(open: &mut Vec, name: &str) {
+ let implied = match name {
+ "li" => &["li"][..],
+ "p" => &["p"][..],
+ "dt" | "dd" => &["dt", "dd"][..],
+ "rt" | "rp" => &["rt", "rp"][..],
+ "option" => &["option"][..],
+ "optgroup" => &["option", "optgroup"][..],
+ "tr" => &["tr"][..],
+ "td" | "th" => &["td", "th"][..],
+ _ => return,
+ };
+ if let Some(position) = open.iter().rposition(|candidate| implied.contains(&candidate.as_ref()))
+ {
+ open.truncate(position);
+ }
+}
+
fn is_void_html_element(name: &str) -> bool {
matches!(
name,
@@ -345,7 +377,8 @@ impl HtmlCtx for StandaloneCtx {
if src.is_empty() {
return Ok(None);
}
- Ok(is_absolute_uri(src).then(|| ImageSource::External(src.to_owned())))
+ Ok((is_absolute_uri(src) || src.starts_with("//"))
+ .then(|| ImageSource::External(src.to_owned())))
}
fn anchor_id(&self, raw: &str) -> AnchorId {
diff --git a/tests/html.rs b/tests/html.rs
index de27cc91..f4ee48ce 100644
--- a/tests/html.rs
+++ b/tests/html.rs
@@ -1,4 +1,5 @@
-use anydoc::{ConvertError, Format, to_markdown_bytes};
+use anydoc::model::{Block, ImageSource, Inline};
+use anydoc::{ConvertError, Format, to_document, to_markdown_bytes};
#[test]
fn html_extensions_are_named() {
@@ -132,3 +133,58 @@ fn scripts_are_not_document_content() {
let markdown = to_markdown_bytes(html, None).unwrap();
assert_eq!(markdown, "before\n\nafter\n");
}
+
+#[test]
+fn quoted_mime_parameter_semicolon_does_not_fake_charset() {
+ let mut html = br#""#.to_vec();
+ html.push(0x80);
+ html.extend_from_slice(b"
");
+ let markdown = to_markdown_bytes(&html, Some(Format::Html)).unwrap();
+ assert_eq!(markdown, "€\n");
+}
+
+#[test]
+fn optional_li_end_tags_do_not_count_as_nested_depth() {
+ let mut html = String::from("");
+ for i in 0..300 {
+ html.push_str(&format!("- item {i}"));
+ }
+ html.push_str("
");
+ let markdown = to_markdown_bytes(html.as_bytes(), Some(Format::Html)).unwrap();
+ assert!(markdown.contains("item 299"));
+}
+
+#[test]
+fn optional_p_end_tags_do_not_count_as_nested_depth() {
+ let mut html = String::from("");
+ for i in 0..300 {
+ html.push_str(&format!("paragraph {i}"));
+ }
+ let markdown = to_markdown_bytes(html.as_bytes(), Some(Format::Html)).unwrap();
+ assert!(markdown.contains("paragraph 299"));
+}
+
+#[test]
+fn meta_charset_after_first_kib_is_still_honored() {
+ let mut html = b"
");
+ html.push(0xA3);
+ html.extend_from_slice(b"
");
+ let markdown = to_markdown_bytes(&html, Some(Format::Html)).unwrap();
+ assert_eq!(markdown, "Ł\n");
+}
+
+#[test]
+fn protocol_relative_image_is_preserved_as_external() {
+ let html = br#"
"#;
+ let document = to_document(html, Some(Format::Html)).unwrap();
+ match &document.blocks[0] {
+ Block::Paragraph(inlines) => assert!(matches!(
+ &inlines[0],
+ Inline::Image { source: ImageSource::External(url), .. }
+ if url == "//cdn.example.test/image.png"
+ )),
+ other => panic!("expected paragraph, got {other:?}"),
+ }
+}
From df61b31d7dad49715a3745fea5829f0c6a846df3 Mon Sep 17 00:00:00 2001
From: Marcell Manfrin Barbacena
Date: Fri, 28 Aug 2026 20:27:30 -0300
Subject: [PATCH 07/29] fix: match HTML5 preflight depth semantics
---
src/formats/html.rs | 85 ++++++++++++++++++++++++++++++++++++++++++++-
tests/html.rs | 61 ++++++++++++++++++++++++++++++++
2 files changed, 145 insertions(+), 1 deletion(-)
diff --git a/src/formats/html.rs b/src/formats/html.rs
index bef02a07..b66c423b 100644
--- a/src/formats/html.rs
+++ b/src/formats/html.rs
@@ -176,7 +176,9 @@ impl TokenSink for HtmlComplexitySink {
StartTag => {
self.bump_node();
let name = tag.name.as_ref();
- if !tag.self_closing && !is_void_html_element(name) {
+ let honor_self_closing = tag.self_closing
+ && html5_self_closing_is_honored(&self.open_elements.borrow(), name);
+ if !is_void_html_element(name) && !honor_self_closing {
self.push_element(&tag.name);
}
match name {
@@ -210,6 +212,12 @@ impl TokenSink for HtmlComplexitySink {
}
fn close_implied_before_start(open: &mut Vec, name: &str) {
+ if is_heading_element(name)
+ && open.last().is_some_and(|candidate| is_heading_element(candidate.as_ref()))
+ {
+ open.pop();
+ }
+
let implied = match name {
"li" => &["li"][..],
"p" => &["p"][..],
@@ -227,6 +235,81 @@ fn close_implied_before_start(open: &mut Vec, name: &str) {
}
}
+fn is_heading_element(name: &str) -> bool {
+ matches!(name, "h1" | "h2" | "h3" | "h4" | "h5" | "h6")
+}
+
+fn html5_self_closing_is_honored(open: &[LocalName], name: &str) -> bool {
+ if matches!(name, "svg" | "math") {
+ return true;
+ }
+
+ let mut in_foreign_content = false;
+ for candidate in open.iter().rev() {
+ match candidate.as_ref() {
+ "foreignobject" | "desc" | "title" | "mi" | "mo" | "mn" | "ms" | "mtext"
+ | "annotation-xml" => return false,
+ "svg" | "math" => {
+ in_foreign_content = true;
+ break;
+ }
+ _ => {}
+ }
+ }
+
+ in_foreign_content && !is_foreign_content_html_breakout(name)
+}
+
+fn is_foreign_content_html_breakout(name: &str) -> bool {
+ matches!(
+ name,
+ "b" | "big"
+ | "blockquote"
+ | "body"
+ | "br"
+ | "center"
+ | "code"
+ | "dd"
+ | "div"
+ | "dl"
+ | "dt"
+ | "em"
+ | "embed"
+ | "font"
+ | "h1"
+ | "h2"
+ | "h3"
+ | "h4"
+ | "h5"
+ | "h6"
+ | "head"
+ | "hr"
+ | "i"
+ | "img"
+ | "li"
+ | "listing"
+ | "menu"
+ | "meta"
+ | "nobr"
+ | "ol"
+ | "p"
+ | "pre"
+ | "ruby"
+ | "s"
+ | "small"
+ | "span"
+ | "strike"
+ | "strong"
+ | "sub"
+ | "sup"
+ | "table"
+ | "tt"
+ | "u"
+ | "ul"
+ | "var"
+ )
+}
+
fn is_void_html_element(name: &str) -> bool {
matches!(
name,
diff --git a/tests/html.rs b/tests/html.rs
index f4ee48ce..b9b82421 100644
--- a/tests/html.rs
+++ b/tests/html.rs
@@ -188,3 +188,64 @@ fn protocol_relative_image_is_preserved_as_external() {
other => panic!("expected paragraph, got {other:?}"),
}
}
+
+fn assert_preflight_depth_limit(error: ConvertError) {
+ match error {
+ ConvertError::ResourceLimit { limit, detail } => {
+ assert_eq!(limit, "max_xml_depth");
+ assert!(
+ detail.contains("before DOM construction"),
+ "expected preflight depth rejection, got: {detail}"
+ );
+ }
+ other => panic!("expected max_xml_depth resource limit, got {other:?}"),
+ }
+}
+
+#[test]
+fn non_void_self_closing_html_tags_still_count_toward_preflight_depth() {
+ let mut html = String::from("");
+ for _ in 0..300 {
+ html.push_str("");
+ }
+
+ assert_preflight_depth_limit(
+ to_markdown_bytes(html.as_bytes(), Some(Format::Html)).unwrap_err(),
+ );
+}
+
+#[test]
+fn successive_headings_are_implicitly_closed_before_preflight_depth_counting() {
+ let mut html = String::from("");
+ for i in 0..300 {
+ html.push_str(&format!("heading {i}"));
+ }
+
+ let markdown = to_markdown_bytes(html.as_bytes(), Some(Format::Html)).unwrap();
+ assert!(markdown.contains("# heading 299"));
+}
+
+#[test]
+fn foreign_self_closing_svg_elements_do_not_accumulate_html_depth() {
+ let mut html = String::from("
ok
");
+
+ let markdown = to_markdown_bytes(html.as_bytes(), Some(Format::Html)).unwrap();
+ assert!(markdown.contains("ok"));
+}
+
+#[test]
+fn html_inside_svg_foreign_object_still_counts_self_closing_non_void_depth() {
+ let mut html = String::from("");
+
+ assert_preflight_depth_limit(
+ to_markdown_bytes(html.as_bytes(), Some(Format::Html)).unwrap_err(),
+ );
+}
From 076e77d233a2075a658811a81ffaf78d427f954f Mon Sep 17 00:00:00 2001
From: Marcell Manfrin Barbacena
Date: Fri, 28 Aug 2026 20:58:35 -0300
Subject: [PATCH 08/29] fix: address final HTML review findings
---
Cargo.toml | 2 +-
src/formats/detect.rs | 65 +++++++++++++++++++++++++++++++++++--------
tests/html.rs | 20 +++++++++++++
3 files changed, 75 insertions(+), 12 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index e24a9208..0ed2ba91 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -35,4 +35,4 @@ zip = { version = "8.6.0", default-features = false, features = ["deflate"] }
[profile.release]
lto = "thin"
-strip = "symbols"
\ No newline at end of file
+strip = "symbols"
diff --git a/src/formats/detect.rs b/src/formats/detect.rs
index 77072f45..522e513b 100644
--- a/src/formats/detect.rs
+++ b/src/formats/detect.rs
@@ -65,18 +65,61 @@ fn looks_like_html(bytes: &[u8]) -> bool {
fn looks_like_utf16_html(bytes: &[u8], little_endian: bool) -> bool {
let (pairs, _) = bytes.as_chunks::<2>();
- let mut units = pairs.iter().map(|pair| {
- if little_endian { u16::from_le_bytes(*pair) } else { u16::from_be_bytes(*pair) }
- });
- let mut prefix = Vec::with_capacity(64);
- if let Some(first) =
- units.find(|unit| !matches!(*unit, 0x0009 | 0x000A | 0x000C | 0x000D | 0x0020))
- {
- prefix.push(first);
- prefix.extend(units.take(63));
+ let mut index = 0;
+ while pairs.get(index).is_some_and(|pair| {
+ utf16_ascii_unit(*pair, little_endian).is_some_and(|b| b.is_ascii_whitespace())
+ }) {
+ index += 1;
+ }
+
+ if utf16_html_prefix(pairs, index, little_endian, b" Option {
+ let unit = if little_endian { u16::from_le_bytes(pair) } else { u16::from_be_bytes(pair) };
+ (unit <= 0x7F).then_some(unit as u8)
+}
+
+fn utf16_prefix_eq_ignore_ascii_case(
+ pairs: &[[u8; 2]],
+ start: usize,
+ little_endian: bool,
+ prefix: &[u8],
+) -> bool {
+ let Some(slice) = pairs.get(start..start + prefix.len()) else {
+ return false;
+ };
+ slice.iter().zip(prefix).all(|(pair, expected)| {
+ utf16_ascii_unit(*pair, little_endian)
+ .is_some_and(|byte| byte.eq_ignore_ascii_case(expected))
+ })
+}
+
+fn utf16_html_prefix(pairs: &[[u8; 2]], start: usize, little_endian: bool, prefix: &[u8]) -> bool {
+ utf16_prefix_eq_ignore_ascii_case(pairs, start, little_endian, prefix)
+ && pairs.get(start + prefix.len()).is_none_or(|pair| {
+ utf16_ascii_unit(*pair, little_endian)
+ .is_some_and(|b| b.is_ascii_whitespace() || matches!(b, b'>' | b'/'))
+ })
}
fn looks_like_ascii_html(bytes: &[u8]) -> bool {
diff --git a/tests/html.rs b/tests/html.rs
index b9b82421..1718431b 100644
--- a/tests/html.rs
+++ b/tests/html.rs
@@ -59,6 +59,26 @@ fn utf16_html_detection_allows_long_leading_whitespace() {
assert_eq!(Format::from_bytes(&be), Some(Format::Html));
}
+#[test]
+fn utf16le_doctype_allows_long_whitespace_between_keyword_and_name() {
+ let source = format!("", " ".repeat(80));
+ let mut bytes = vec![0xFF, 0xFE];
+ for unit in source.encode_utf16() {
+ bytes.extend_from_slice(&unit.to_le_bytes());
+ }
+ assert_eq!(Format::from_bytes(&bytes), Some(Format::Html));
+}
+
+#[test]
+fn utf16be_doctype_allows_long_whitespace_between_keyword_and_name() {
+ let source = format!("", " ".repeat(80));
+ let mut bytes = vec![0xFE, 0xFF];
+ for unit in source.encode_utf16() {
+ bytes.extend_from_slice(&unit.to_be_bytes());
+ }
+ assert_eq!(Format::from_bytes(&bytes), Some(Format::Html));
+}
+
#[test]
fn unrelated_charset_attribute_does_not_change_decoding() {
let html = "café
".as_bytes();
From fbe5339f2eb23a64f84007bd75c873c70cc9b032 Mon Sep 17 00:00:00 2001
From: Marcell Manfrin Barbacena
Date: Fri, 28 Aug 2026 22:26:42 -0300
Subject: [PATCH 09/29] test: consolidate HTML review regressions
---
tests/html.rs | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/tests/html.rs b/tests/html.rs
index 1718431b..bf489309 100644
--- a/tests/html.rs
+++ b/tests/html.rs
@@ -25,6 +25,12 @@ fn html_prefix_wins_over_embedded_pdf_marker() {
assert_eq!(Format::from_bytes(html), Some(Format::Html));
}
+#[test]
+fn pdf_header_before_html_marker_remains_pdf() {
+ let bytes = b" %PDF-1.7\nnot an HTML root";
+ assert_eq!(Format::from_bytes(bytes), Some(Format::Pdf));
+}
+
#[test]
fn utf16_html_is_detected_from_content() {
let source = "hello";
@@ -245,6 +251,19 @@ fn successive_headings_are_implicitly_closed_before_preflight_depth_counting() {
assert!(markdown.contains("# heading 299"));
}
+#[test]
+fn alternating_headings_are_implicitly_closed_before_preflight_depth_counting() {
+ const HEADINGS: [&str; 6] = ["h1", "h2", "h3", "h4", "h5", "h6"];
+ let mut html = String::from("");
+ for i in 0..300 {
+ let heading = HEADINGS[i % HEADINGS.len()];
+ html.push_str(&format!("<{heading}>heading {i}"));
+ }
+
+ let markdown = to_markdown_bytes(html.as_bytes(), Some(Format::Html)).unwrap();
+ assert!(markdown.contains("heading 299"));
+}
+
#[test]
fn foreign_self_closing_svg_elements_do_not_accumulate_html_depth() {
let mut html = String::from("