From 8b83460d7d6f23a46ca1ded975847f87c394ca77 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:59:10 +0000 Subject: [PATCH 01/29] feat: add standalone HTML support --- Cargo.lock | 313 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + README.md | 1 + node/cli.js | 2 +- node/html.test.mjs | 10 ++ node/index.d.ts | 1 + node/src/lib.rs | 3 + python/anydoc/_anydoc.pyi | 2 +- python/src/lib.rs | 3 +- python/tests/test_html.py | 15 ++ src/formats/detect.rs | 21 +++ src/formats/html.rs | 239 +++++++++++++++++++++++++++++ src/formats/mod.rs | 2 + src/lib.rs | 3 + tests/html.rs | 53 +++++++ wasm/src/lib.rs | 3 + 16 files changed, 669 insertions(+), 3 deletions(-) create mode 100644 node/html.test.mjs create mode 100644 python/tests/test_html.py create mode 100644 src/formats/html.rs create mode 100644 tests/html.rs diff --git a/Cargo.lock b/Cargo.lock index 6bb48e2a..24767b2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,6 +99,7 @@ dependencies = [ "log", "pdf-inspector", "quick-xml", + "scraper", "sha2 0.11.0", "zip", ] @@ -361,6 +362,29 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "csv" version = "1.4.0" @@ -425,6 +449,27 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + [[package]] name = "digest" version = "0.10.7" @@ -446,6 +491,21 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "ecb" version = "0.1.2" @@ -455,6 +515,12 @@ dependencies = [ "cipher", ] +[[package]] +name = "ego-tree" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" + [[package]] name = "either" version = "1.17.0" @@ -668,6 +734,16 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + [[package]] name = "hybrid-array" version = "0.4.14" @@ -850,6 +926,15 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.33" @@ -887,6 +972,17 @@ dependencies = [ "weezl", ] +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + [[package]] name = "md-5" version = "0.10.6" @@ -970,6 +1066,12 @@ dependencies = [ "libloading", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -1012,6 +1114,29 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "pdf-inspector" version = "1.14.2" @@ -1030,6 +1155,59 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1057,6 +1235,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1190,6 +1374,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "regex" version = "1.13.1" @@ -1225,6 +1418,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1250,6 +1452,45 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scraper" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" +dependencies = [ + "cssparser", + "ego-tree", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + [[package]] name = "semver" version = "1.0.28" @@ -1307,6 +1548,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1347,12 +1597,54 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -1405,6 +1697,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + [[package]] name = "thiserror" version = "2.0.19" @@ -1598,6 +1899,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "weezl" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index 2a334b72..b8b1f3f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ encoding_rs = "0.8.35" log = "0.4" pdf-inspector = "1.14.2" quick-xml = "0.41.0" +scraper = { version = "0.27.0", default-features = false } zip = { version = "8.6.0", default-features = false, features = ["deflate"] } [profile.release] diff --git a/README.md b/README.md index 760d3f18..c6b20be5 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ Only documents that need OCR leave the machine, and the whole document goes, sin | OpenDocument | `.odt`, `.ods`, `.odp` | | Rich Text Format | `.rtf` | | EPUB | `.epub` | +| HTML | `.html`, `.htm` | | CSV | `.csv` | | PDF | `.pdf` | diff --git a/node/cli.js b/node/cli.js index 5d7cd34c..d11a40a4 100644 --- a/node/cli.js +++ b/node/cli.js @@ -3,7 +3,7 @@ const { readFile, writeFile } = require('node:fs/promises') -const FORMATS = 'doc, docx, odt, pdf, ppt, pptx, rtf, epub, xlsx, ods, odp, csv' +const FORMATS = 'doc, docx, odt, pdf, ppt, pptx, rtf, epub, html, xlsx, ods, odp, csv' const HELP = `anydoc: convert documents to GitHub-Flavored Markdown diff --git a/node/html.test.mjs b/node/html.test.mjs new file mode 100644 index 00000000..ab00326d --- /dev/null +++ b/node/html.test.mjs @@ -0,0 +1,10 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { formatFromBytes, formatFromExtension, toMarkdownBytes } from './index.js' + +test('standalone HTML is exposed through the Node binding', async () => { + const input = Buffer.from('

Hello

world

') + assert.equal(formatFromExtension('html'), 'html') + assert.equal(formatFromBytes(input), 'html') + assert.equal(await toMarkdownBytes(input), '# Hello\n\n**world**\n') +}) diff --git a/node/index.d.ts b/node/index.d.ts index e3e4fd72..cedb7691 100644 --- a/node/index.d.ts +++ b/node/index.d.ts @@ -128,6 +128,7 @@ export declare const enum Format { pptx = 'pptx', rtf = 'rtf', epub = 'epub', + html = 'html', xlsx = 'xlsx', ods = 'ods', odp = 'odp', diff --git a/node/src/lib.rs b/node/src/lib.rs index c32bd79e..bb5229fa 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -26,6 +26,7 @@ pub enum Format { pptx, rtf, epub, + html, xlsx, ods, odp, @@ -43,6 +44,7 @@ impl From for anydoc::Format { Format::pptx => anydoc::Format::Pptx, Format::rtf => anydoc::Format::Rtf, Format::epub => anydoc::Format::Epub, + Format::html => anydoc::Format::Html, Format::xlsx => anydoc::Format::Excel, Format::ods => anydoc::Format::Ods, Format::odp => anydoc::Format::Odp, @@ -62,6 +64,7 @@ impl From for Format { anydoc::Format::Pptx => Format::pptx, anydoc::Format::Rtf => Format::rtf, anydoc::Format::Epub => Format::epub, + anydoc::Format::Html => Format::html, anydoc::Format::Excel => Format::xlsx, anydoc::Format::Ods => Format::ods, anydoc::Format::Odp => Format::odp, diff --git a/python/anydoc/_anydoc.pyi b/python/anydoc/_anydoc.pyi index ca45a408..8fc322c0 100644 --- a/python/anydoc/_anydoc.pyi +++ b/python/anydoc/_anydoc.pyi @@ -4,7 +4,7 @@ import os from typing import Literal, final Format = Literal[ - "doc", "docx", "odt", "pdf", "ppt", "pptx", "rtf", "epub", "xlsx", "ods", "odp", "csv" + "doc", "docx", "odt", "pdf", "ppt", "pptx", "rtf", "epub", "html", "xlsx", "ods", "odp", "csv" ] class ConvertError(Exception): diff --git a/python/src/lib.rs b/python/src/lib.rs index a38a09de..6c396a10 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -67,7 +67,7 @@ create_exception!( /// Format names, as the extension that identifies each format. Container /// variants that share a parser (`.docm`, `.xlsm`, `.ppsx`, ...) map onto /// these via `format_from_bytes` or `format_from_extension`. -const FORMATS: [(&str, anydoc::Format); 12] = [ +const FORMATS: [(&str, anydoc::Format); 13] = [ ("doc", anydoc::Format::Doc), ("docx", anydoc::Format::Docx), ("odt", anydoc::Format::Odt), @@ -76,6 +76,7 @@ const FORMATS: [(&str, anydoc::Format); 12] = [ ("pptx", anydoc::Format::Pptx), ("rtf", anydoc::Format::Rtf), ("epub", anydoc::Format::Epub), + ("html", anydoc::Format::Html), ("xlsx", anydoc::Format::Excel), ("ods", anydoc::Format::Ods), ("odp", anydoc::Format::Odp), diff --git a/python/tests/test_html.py b/python/tests/test_html.py new file mode 100644 index 00000000..d71e098e --- /dev/null +++ b/python/tests/test_html.py @@ -0,0 +1,15 @@ +import unittest + +import anydoc + + +class HtmlBindingTests(unittest.TestCase): + def test_standalone_html_is_exposed(self): + data = b'

Hello

world

' + self.assertEqual(anydoc.format_from_extension('html'), 'html') + self.assertEqual(anydoc.format_from_bytes(data), 'html') + self.assertEqual(anydoc.to_markdown_bytes(data), '# Hello\n\n**world**\n') + + +if __name__ == '__main__': + unittest.main() diff --git a/src/formats/detect.rs b/src/formats/detect.rs index 727a69f6..6aa670cf 100644 --- a/src/formats/detect.rs +++ b/src/formats/detect.rs @@ -44,9 +44,28 @@ pub(crate) fn from_bytes(bytes: &[u8]) -> Option { if bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") { return Some(Format::Pdf); } + if looks_like_html(bytes) { + return Some(Format::Html); + } None } +fn looks_like_html(bytes: &[u8]) -> bool { + let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes); + let bytes = bytes.trim_ascii_start(); + html_prefix(bytes, b" bool { + let Some(head) = bytes.get(..prefix.len()) else { + return false; + }; + head.eq_ignore_ascii_case(prefix) + && bytes + .get(prefix.len()) + .is_none_or(|b| b.is_ascii_whitespace() || matches!(b, b'>' | b'/')) +} + /// Classify an OLE compound file by its mandated content stream. Encrypted /// OOXML packages (`EncryptedPackage`) stay `None`: the inner format is /// unknowable, and the frontend reports `Encrypted` precisely. @@ -236,6 +255,8 @@ mod tests { junk.extend_from_slice(b"%PDF-1.4"); assert_eq!(from_bytes(&junk), Some(Format::Pdf)); assert_eq!(from_bytes(b"{\\rtf1\\ansi hi}"), Some(Format::Rtf)); + assert_eq!(from_bytes(b""), Some(Format::Html)); + assert_eq!(from_bytes(b"\xEF\xBB\xBF x"), Some(Format::Html)); assert_eq!(from_bytes(b"a,b,c\n1,2,3\n"), None); assert_eq!(from_bytes(b""), None); } diff --git a/src/formats/html.rs b/src/formats/html.rs new file mode 100644 index 00000000..65624827 --- /dev/null +++ b/src/formats/html.rs @@ -0,0 +1,239 @@ +//! Standalone HTML: browser-grade HTML5 parsing followed by the shared +//! semantic HTML -> document-model frontend used by EPUB. + +use crate::error::ConvertError; +use crate::model::{AnchorId, Document, ImageSource, LinkTarget}; +use crate::package::limits; +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 scraper::{ElementRef, Html, Node as HtmlNode}; +use std::rc::Rc; + +/// Parse a standalone HTML document into anydoc's document model. +/// +/// HTML5 tree construction is delegated to `scraper`/`html5ever`; the +/// resulting DOM is adapted into the small namespace-aware tree already used +/// by `shared::html`, so HTML and EPUB keep one semantic conversion path. +pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() as u64 > limits::MAX_ENTRY_BYTES { + return Err(ConvertError::ResourceLimit { + limit: "max_entry_bytes", + detail: format!( + "HTML input is {} bytes; maximum is {}", + bytes.len(), + limits::MAX_ENTRY_BYTES + ), + }); + } + + let text = decode_html(bytes); + let parsed = Html::parse_document(&text); + let root = parsed.root_element(); + + let mut css = Stylesheet::default(); + for style in root.descendent_elements().filter(|e| e.value().name() == "style") { + css.add(&style.text().collect::()); + } + + let body = root + .descendent_elements() + .find(|e| e.value().name() == "body") + .ok_or_else(|| ConvertError::malformed("HTML parser produced no body element"))?; + + let mut node_count = 0usize; + let body = adapt_element(body, 1, &mut node_count)?; + let blocks = crate::shared::html::to_blocks(&body, &css, &StandaloneCtx)?; + + Ok(Document { blocks, ..Document::default() }) +} + +fn decode_html(bytes: &[u8]) -> String { + if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) { + return String::from_utf8_lossy(rest).into_owned(); + } + if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) { + return UTF_16LE.decode(rest).0.into_owned(); + } + if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) { + return UTF_16BE.decode(rest).0.into_owned(); + } + if let Some(encoding) = sniff_meta_charset(bytes) { + return encoding.decode(bytes).0.into_owned(); + } + match std::str::from_utf8(bytes) { + Ok(text) => text.to_owned(), + Err(_) => WINDOWS_1252.decode(bytes).0.into_owned(), + } +} + +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"charset") { + let mut pos = offset + found + b"charset".len(); + while prefix.get(pos).is_some_and(u8::is_ascii_whitespace) { + pos += 1; + } + if prefix.get(pos) != Some(&b'=') { + offset = offset + found + 1; + continue; + } + pos += 1; + while prefix.get(pos).is_some_and(u8::is_ascii_whitespace) { + pos += 1; + } + let quote = match prefix.get(pos) { + Some(b'\'') | Some(b'"') => { + let q = prefix[pos]; + pos += 1; + Some(q) + } + _ => None, + }; + let start = pos; + while let Some(&byte) = prefix.get(pos) { + let stop = quote.map_or_else( + || byte.is_ascii_whitespace() || matches!(byte, b';' | b'>' | b'/' | b'\'' | b'"'), + |q| byte == q, + ); + if stop { + break; + } + pos += 1; + } + if pos > start + && let Some(encoding) = Encoding::for_label(&prefix[start..pos]) + { + return Some(encoding); + } + offset = offset + found + 1; + } + None +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|window| window == needle) +} + +fn adapt_element( + source: ElementRef<'_>, + depth: usize, + node_count: &mut usize, +) -> Result { + if depth > limits::MAX_XML_DEPTH { + return Err(ConvertError::ResourceLimit { + limit: "max_xml_depth", + detail: format!("HTML element nesting depth {depth} exceeds {}", limits::MAX_XML_DEPTH), + }); + } + bump_node_count(node_count)?; + + let value = source.value(); + let attrs = value + .attrs + .iter() + .map(|(name, value)| Attr { + ns: optional_namespace(name.ns.as_ref()), + local: name.local.as_ref().to_owned(), + value: value.to_string(), + }) + .collect(); + + let mut children = Vec::new(); + for child in source.children() { + match child.value() { + HtmlNode::Element(_) => { + if let Some(element) = ElementRef::wrap(child) { + children.push(Node::Elem(adapt_element(element, depth + 1, node_count)?)); + } + } + HtmlNode::Text(text) => { + bump_node_count(node_count)?; + children.push(Node::Text(text.text.to_string())); + } + HtmlNode::Comment(_) | HtmlNode::Doctype(_) | HtmlNode::ProcessingInstruction(_) => { + bump_node_count(node_count)?; + } + HtmlNode::Document | HtmlNode::Fragment => {} + } + } + + Ok(Element { + ns: optional_namespace(value.name.ns.as_ref()), + local: value.name.local.as_ref().to_owned(), + attrs, + children, + }) +} + +fn optional_namespace(namespace: &str) -> Option> { + (!namespace.is_empty()).then(|| Rc::::from(namespace)) +} + +fn bump_node_count(node_count: &mut usize) -> Result<(), ConvertError> { + *node_count = node_count.saturating_add(1); + if *node_count > limits::MAX_XML_NODES { + return Err(ConvertError::ResourceLimit { + limit: "max_xml_nodes", + detail: format!("HTML tree has more than {} nodes", limits::MAX_XML_NODES), + }); + } + Ok(()) +} + +struct StandaloneCtx; + +impl HtmlCtx for StandaloneCtx { + fn link_target(&self, href: &str) -> Option { + let href = href.trim(); + if href.is_empty() { + return None; + } + if let Some(fragment) = href.strip_prefix('#') { + let fragment = crate::package::path::decode_fragment(fragment); + return Some(LinkTarget::Anchor(fragment)); + } + Some(if is_absolute_uri(href) { + LinkTarget::External(href.to_owned()) + } else { + LinkTarget::Relative(href.to_owned()) + }) + } + + fn image_source(&self, src: &str) -> Result, ConvertError> { + let src = src.trim(); + if src.is_empty() { + return Ok(None); + } + Ok(is_absolute_uri(src).then(|| ImageSource::External(src.to_owned()))) + } + + fn anchor_id(&self, raw: &str) -> AnchorId { + raw.to_owned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn charset_sniff_accepts_meta_and_http_equiv_forms() { + assert_eq!( + sniff_meta_charset(b"").map(Encoding::name), + Some("windows-1252") + ); + assert_eq!( + sniff_meta_charset( + b"" + ) + .map(Encoding::name), + Some("windows-1252") + ); + } +} diff --git a/src/formats/mod.rs b/src/formats/mod.rs index d570b264..5be9c8d2 100644 --- a/src/formats/mod.rs +++ b/src/formats/mod.rs @@ -5,6 +5,7 @@ pub mod detect; mod doc; mod docx; mod epub; +mod html; mod odf; pub mod pdf; mod ppt; @@ -24,6 +25,7 @@ pub fn parse(bytes: &[u8], format: Format) -> Result { Format::Odt | Format::Ods | Format::Odp => odf::parse(bytes), Format::Pptx => pptx::parse(bytes), Format::Epub => epub::parse(bytes), + Format::Html => html::parse(bytes), Format::Rtf => rtf::parse(bytes), // RTF files wearing a .doc extension are common in the wild. Format::Doc if bytes.starts_with(b"{\\rtf") => rtf::parse(bytes), diff --git a/src/lib.rs b/src/lib.rs index 4ca94c58..c08907f6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,8 @@ pub enum Format { Rtf, /// EPUB 2 and 3 (`.epub`). Epub, + /// Standalone HTML5 (`.html`, `.htm`). JavaScript is not executed. + Html, /// Excel workbooks: `.xlsx`, `.xlsm`, binary `.xlsb`, and legacy /// OLE-based `.xls`. Excel, @@ -80,6 +82,7 @@ impl Format { "ppt" | "pps" | "pot" => Format::Ppt, "rtf" => Format::Rtf, "epub" => Format::Epub, + "html" | "htm" => Format::Html, "xlsx" | "xlsm" | "xlsb" | "xls" => Format::Excel, "ods" => Format::Ods, "odp" => Format::Odp, diff --git a/tests/html.rs b/tests/html.rs new file mode 100644 index 00000000..4b02c628 --- /dev/null +++ b/tests/html.rs @@ -0,0 +1,53 @@ +use anydoc::{Format, to_markdown_bytes}; + +#[test] +fn html_extensions_are_named() { + assert_eq!(Format::from_extension("html"), Some(Format::Html)); + assert_eq!(Format::from_extension("HTM"), Some(Format::Html)); +} + +#[test] +fn html_doctype_is_detected_from_content() { + let html = b"\xEF\xBB\xBF

Hello

"; + assert_eq!(Format::from_bytes(html), Some(Format::Html)); +} + +#[test] +fn malformed_html5_is_repaired_before_conversion() { + let html = br#"

Hello

first

second"#; + let markdown = to_markdown_bytes(html, None).unwrap(); + assert_eq!(markdown, "# Hello\n\nfirst\n\n**second**\n"); +} + +#[test] +fn html5_table_inserts_implicit_structure() { + let html = br#"
AB
12
"#; + let markdown = to_markdown_bytes(html, None).unwrap(); + assert_eq!(markdown, "| A | B |\n| --- | --- |\n| 1 | 2 |\n"); +} + +#[test] +fn style_blocks_feed_the_existing_semantic_css_subset() { + let html = br#"

keep me

"#; + let markdown = to_markdown_bytes(html, None).unwrap(); + assert_eq!(markdown, "**keep me**\n"); +} + +#[test] +fn meta_charset_decodes_legacy_html() { + let mut html = b"

caf".to_vec(); + html.push(0xE9); + html.extend_from_slice(b"

"); + let markdown = to_markdown_bytes(&html, None).unwrap(); + assert_eq!(markdown, "caf\u{e9}\n"); +} + +#[test] +fn scripts_are_not_document_content() { + let html = br#"

before

after

"#; + let markdown = to_markdown_bytes(html, None).unwrap(); + assert_eq!(markdown, "before\n\nafter\n"); +} diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs index 6898bb6a..c27d8fe2 100644 --- a/wasm/src/lib.rs +++ b/wasm/src/lib.rs @@ -28,6 +28,7 @@ pub enum Format { Pptx = "pptx", Rtf = "rtf", Epub = "epub", + Html = "html", Xlsx = "xlsx", Ods = "ods", Odp = "odp", @@ -45,6 +46,7 @@ impl From for anydoc::Format { Format::Pptx => anydoc::Format::Pptx, Format::Rtf => anydoc::Format::Rtf, Format::Epub => anydoc::Format::Epub, + Format::Html => anydoc::Format::Html, Format::Xlsx => anydoc::Format::Excel, Format::Ods => anydoc::Format::Ods, Format::Odp => anydoc::Format::Odp, @@ -65,6 +67,7 @@ impl From for Format { anydoc::Format::Pptx => Format::Pptx, anydoc::Format::Rtf => Format::Rtf, anydoc::Format::Epub => Format::Epub, + anydoc::Format::Html => Format::Html, anydoc::Format::Excel => Format::Xlsx, anydoc::Format::Ods => Format::Ods, anydoc::Format::Odp => Format::Odp, From b20d2d9f8d19a8dc2a1d960aabf6f9b01637d1d8 Mon Sep 17 00:00:00 2001 From: marcellmanfrin Date: Fri, 28 Aug 2026 11:13:29 -0300 Subject: [PATCH 02/29] fix: address HTML charset review feedback --- src/formats/html.rs | 168 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 141 insertions(+), 27 deletions(-) diff --git a/src/formats/html.rs b/src/formats/html.rs index 65624827..58bc3429 100644 --- a/src/formats/html.rs +++ b/src/formats/html.rs @@ -74,48 +74,157 @@ fn sniff_meta_charset(bytes: &[u8]) -> Option<&'static Encoding> { prefix.make_ascii_lowercase(); let mut offset = 0usize; - while let Some(found) = find_bytes(&prefix[offset..], b"charset") { - let mut pos = offset + found + b"charset".len(); - while prefix.get(pos).is_some_and(u8::is_ascii_whitespace) { + 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]; + + if let Some(label) = html_attr(attrs, b"charset") + && let Some(encoding) = Encoding::for_label(label) + { + 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")); + if is_content_type + && let Some(content) = html_attr(attrs, b"content") + && let Some(label) = content_type_charset(content) + && let Some(encoding) = Encoding::for_label(label) + { + 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, _) => {} + } + } + 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 prefix.get(pos) != Some(&b'=') { - offset = offset + found + 1; + if pos >= attrs.len() { + break; + } + + let name_start = pos; + while attrs + .get(pos) + .is_some_and(|b| !b.is_ascii_whitespace() && !matches!(b, b'=' | b'/' | b'>')) + { + pos += 1; + } + if pos == name_start { + pos += 1; continue; } - pos += 1; - while prefix.get(pos).is_some_and(u8::is_ascii_whitespace) { + let name = &attrs[name_start..pos]; + + while attrs.get(pos).is_some_and(u8::is_ascii_whitespace) { pos += 1; } - let quote = match prefix.get(pos) { - Some(b'\'') | Some(b'"') => { - let q = prefix[pos]; + + 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; - Some(q) } - _ => None, - }; - let start = pos; - while let Some(&byte) = prefix.get(pos) { - let stop = quote.map_or_else( - || byte.is_ascii_whitespace() || matches!(byte, b';' | b'>' | b'/' | b'\'' | b'"'), - |q| byte == q, - ); - if stop { - break; + + 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; + } + value = &attrs[value_start..pos]; + if attrs.get(pos) == Some("e) { + pos += 1; + } + } else { + let value_start = pos; + while attrs + .get(pos) + .is_some_and(|b| !b.is_ascii_whitespace() && !matches!(b, b'/' | b'>')) + { + pos += 1; + } + value = &attrs[value_start..pos]; } - pos += 1; } - if pos > start - && let Some(encoding) = Encoding::for_label(&prefix[start..pos]) - { - return Some(encoding); + + if name.eq_ignore_ascii_case(wanted) { + return Some(value); } - offset = offset + found + 1; } 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; + } + + 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; + } + pos += 1; + } + (pos > start).then_some(&content[start..pos]) +} + fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { haystack.windows(needle.len()).position(|window| window == needle) } @@ -236,4 +345,9 @@ mod tests { Some("windows-1252") ); } + + #[test] + fn charset_sniff_ignores_non_meta_text_and_attributes() { + assert_eq!(sniff_meta_charset(b"

utf-8

"), None); + } } From 8dfa216d506fe6647ecf7dadf53600bdbc7ab165 Mon Sep 17 00:00:00 2001 From: marcellmanfrin Date: Fri, 28 Aug 2026 11:14:10 -0300 Subject: [PATCH 03/29] fix: harden HTML content detection --- src/formats/detect.rs | 57 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/src/formats/detect.rs b/src/formats/detect.rs index 6aa670cf..4f647492 100644 --- a/src/formats/detect.rs +++ b/src/formats/detect.rs @@ -1,7 +1,9 @@ //! Content-based format detection. //! -//! Identifies the format from what each specification designates as the -//! container's identity, never from heuristics over document content: +//! Identifies the format primarily from what each specification designates as +//! the container's identity. Standalone HTML is the one intentional content +//! heuristic: after an optional BOM/leading whitespace, a leading HTML5 +//! `` or `` marker identifies `Format::Html`. //! //! - PDF: the `%PDF-` header (ISO 32000; implementations accept leading //! junk, bounded here at 1024 bytes). @@ -41,19 +43,62 @@ pub(crate) fn from_bytes(bytes: &[u8]) -> Option { if bytes.starts_with(b"PK\x03\x04") { return detect_zip(bytes); } - if bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") { - return Some(Format::Pdf); - } if looks_like_html(bytes) { return Some(Format::Html); } + if bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") { + return Some(Format::Pdf); + } None } fn looks_like_html(bytes: &[u8]) -> bool { + if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) { + return looks_like_utf16_html(rest, true); + } + if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) { + return looks_like_utf16_html(rest, false); + } let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes); + looks_like_ascii_html(bytes) +} + +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); + looks_like_ascii_html(decoded.as_bytes()) +} + +fn looks_like_ascii_html(bytes: &[u8]) -> bool { let bytes = bytes.trim_ascii_start(); - html_prefix(bytes, b" bool { + const DOCTYPE: &[u8] = b" bool { From 341be1b1f7f1b3deac09500dad3c3890d33443ca Mon Sep 17 00:00:00 2001 From: marcellmanfrin 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#"

pixel

"#; + 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(""); + for _ in 0..300 { + html.push_str(""); + } + html.push_str("

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(""); + for _ in 0..300 { + html.push_str("
"); + } + html.push_str(""); + + 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(""); From c21198bf90d9892e2c520bb66abe81775a2dcc9a Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Barbacena Date: Sat, 29 Aug 2026 06:32:16 -0300 Subject: [PATCH 10/29] test: add HTML corpus integration regressions --- tests/html_corpus.rs | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/html_corpus.rs diff --git a/tests/html_corpus.rs b/tests/html_corpus.rs new file mode 100644 index 00000000..0225a107 --- /dev/null +++ b/tests/html_corpus.rs @@ -0,0 +1,37 @@ +mod common; + +use anydoc::{Format, to_markdown, to_markdown_bytes}; +use common::fixture_root; +use std::fs; +use std::path::PathBuf; + +fn html_fixture(name: &str) -> PathBuf { + fixture_root().join("html").join(name) +} + +#[test] +fn controlled_document_preserves_rich_text_links_and_unicode() { + let bytes = fs::read(html_fixture("controlled-document.html")).unwrap(); + let markdown = to_markdown_bytes(&bytes, Some(Format::Html)).unwrap(); + assert_eq!( + markdown, + "# Fixture Document\n\nPlain paragraph with **bold**, *italic*, and [example](https://example.com/page).\n\n## Unicode\n\ncafé Ł music 𝄞 family 👨‍👩‍👧.\n\n**Styled bold paragraph.**\n" + ); +} + +#[test] +fn controlled_nested_lists_preserve_numbering_and_structure() { + let bytes = fs::read(html_fixture("controlled-lists.html")).unwrap(); + let markdown = to_markdown_bytes(&bytes, Some(Format::Html)).unwrap(); + assert_eq!( + markdown, + "## Lists\n\n3. Third\n\n4. Fourth\n\n - Nested A\n\n - Nested B\n" + ); +} + +#[test] +fn controlled_spanned_table_matches_existing_xlsx_semantics() { + let html = to_markdown(html_fixture("controlled-merged-table.html")).unwrap(); + let source = to_markdown(fixture_root().join("xlsx").join("handmade-merged.xlsx")).unwrap(); + assert_eq!(html, source); +} From 0c4c81c0dbaf8c6ec4f75ef3c38d35281b062f72 Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Barbacena Date: Sat, 29 Aug 2026 06:34:19 -0300 Subject: [PATCH 11/29] test: add controlled HTML corpus fixtures --- tests/fixtures/html/controlled-document.html | 18 ++++++++++++++++++ tests/fixtures/html/controlled-lists.html | 16 ++++++++++++++++ .../fixtures/html/controlled-merged-table.html | 11 +++++++++++ 3 files changed, 45 insertions(+) create mode 100644 tests/fixtures/html/controlled-document.html create mode 100644 tests/fixtures/html/controlled-lists.html create mode 100644 tests/fixtures/html/controlled-merged-table.html diff --git a/tests/fixtures/html/controlled-document.html b/tests/fixtures/html/controlled-document.html new file mode 100644 index 00000000..88fa4c65 --- /dev/null +++ b/tests/fixtures/html/controlled-document.html @@ -0,0 +1,18 @@ + + + + + + + +

Fixture Document

+

Plain paragraph with bold, italic, and example.

+

Unicode

+

café Ł music 𝄞 family 👨‍👩‍👧.

+

Styled bold paragraph.

+ + + diff --git a/tests/fixtures/html/controlled-lists.html b/tests/fixtures/html/controlled-lists.html new file mode 100644 index 00000000..8cd514e0 --- /dev/null +++ b/tests/fixtures/html/controlled-lists.html @@ -0,0 +1,16 @@ + + + + +

Lists

+
    +
  1. Third
  2. +
  3. Fourth +
      +
    • Nested A
    • +
    • Nested B
    • +
    +
  4. +
+ + diff --git a/tests/fixtures/html/controlled-merged-table.html b/tests/fixtures/html/controlled-merged-table.html new file mode 100644 index 00000000..15ff1adb --- /dev/null +++ b/tests/fixtures/html/controlled-merged-table.html @@ -0,0 +1,11 @@ + + + + + + + + +
Merged acrosspadded
tallb23.5
b3
+ + From adaf98499893d6bdb13446b3262fdeffa9e32e46 Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Barbacena Date: Sat, 29 Aug 2026 06:37:22 -0300 Subject: [PATCH 12/29] test: add LibreOffice HTML corpus regressions --- tests/html_corpus.rs | 64 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/tests/html_corpus.rs b/tests/html_corpus.rs index 0225a107..e148bdc1 100644 --- a/tests/html_corpus.rs +++ b/tests/html_corpus.rs @@ -9,6 +9,13 @@ fn html_fixture(name: &str) -> PathBuf { fixture_root().join("html").join(name) } +fn assert_both_contain(source: &str, html: &str, needles: &[&str]) { + for needle in needles { + assert!(source.contains(needle), "source Markdown missing {needle:?}"); + assert!(html.contains(needle), "HTML Markdown missing {needle:?}"); + } +} + #[test] fn controlled_document_preserves_rich_text_links_and_unicode() { let bytes = fs::read(html_fixture("controlled-document.html")).unwrap(); @@ -25,7 +32,7 @@ fn controlled_nested_lists_preserve_numbering_and_structure() { let markdown = to_markdown_bytes(&bytes, Some(Format::Html)).unwrap(); assert_eq!( markdown, - "## Lists\n\n3. Third\n\n4. Fourth\n\n - Nested A\n\n - Nested B\n" + "## Lists\n\n3. Third\n\n4. Fourth\n\n - Nested A\n - Nested B\n" ); } @@ -35,3 +42,58 @@ fn controlled_spanned_table_matches_existing_xlsx_semantics() { let source = to_markdown(fixture_root().join("xlsx").join("handmade-merged.xlsx")).unwrap(); assert_eq!(html, source); } + +#[test] +fn libreoffice_docx_text_preserves_core_document_semantics() { + let source = to_markdown(fixture_root().join("docx").join("text.docx")).unwrap(); + let html = to_markdown(html_fixture("libreoffice-docx-text.html")).unwrap(); + + assert_both_contain( + &source, + &html, + &[ + "# Fixture Document", + "Plain paragraph with **bold**, *italic*", + "## Lists", + "First numbered", + "## Table", + "Wide head", + "Music clef 𝄞", + "Persian with ZWNJ", + "Family emoji", + "[example](https://example.com/page)", + ], + ); +} + +#[test] +fn libreoffice_docx_numbering_preserves_list_content_and_emphasis() { + let source = + to_markdown(fixture_root().join("docx").join("handmade-numbering.docx")).unwrap(); + let html = to_markdown(html_fixture("libreoffice-docx-numbering.html")).unwrap(); + + assert_both_contain( + &source, + &html, + &[ + "One-one", + "One-two", + "One-two-a roman", + "Deep bullet", + "Interruption paragraph.", + "One-four continues the count", + "Ten-start via override", + "Bold here, and style-false keeps it bold", + "direct on makes this italic", + ], + ); + assert!(html.contains("1. One-one")); + assert!(html.contains("- Deep bullet")); +} + +#[test] +fn libreoffice_xlsx_merged_table_matches_source_markdown() { + let source = to_markdown(fixture_root().join("xlsx").join("handmade-merged.xlsx")).unwrap(); + let html = to_markdown(html_fixture("libreoffice-xlsx-merged.html")).unwrap(); + assert_eq!(html, source); +} From 529aaa2906c55b26d433257a41c60a4a28fee2c8 Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Barbacena Date: Sat, 29 Aug 2026 06:38:53 -0300 Subject: [PATCH 13/29] test: add LibreOffice HTML corpus fixtures --- .../html/libreoffice-docx-numbering.html | 99 ++++++++++ .../fixtures/html/libreoffice-docx-text.html | 185 ++++++++++++++++++ .../html/libreoffice-xlsx-merged.html | 41 ++++ 3 files changed, 325 insertions(+) create mode 100644 tests/fixtures/html/libreoffice-docx-numbering.html create mode 100644 tests/fixtures/html/libreoffice-docx-text.html create mode 100644 tests/fixtures/html/libreoffice-xlsx-merged.html diff --git a/tests/fixtures/html/libreoffice-docx-numbering.html b/tests/fixtures/html/libreoffice-docx-numbering.html new file mode 100644 index 00000000..79242746 --- /dev/null +++ b/tests/fixtures/html/libreoffice-docx-numbering.html @@ -0,0 +1,99 @@ + + + + + + + + + + + +
  1. One-one

  2. +
  3. One-two

    +
      +
    1. One-two-a + roman

    2. +
    3. One-two-b + roman

      +
        +
      • Deep bullet

      • +
      +
    +
  4. One-three

  5. +
+

Interruption +paragraph.

+
    +
  1. One-four + continues the count

  2. +
+
    +
  1. Two-one + independent counter

  2. +
+
    +
  1. Ten-start + via override

  2. +
+
    +
  1. Letter-A via + numStyleLink

  2. +
+
    +
  1. Level + replaced and start overridden together

  2. +
+

Suppressed numbering +paragraph

+
    +
  1. Style-numbered + paragraph

  2. +
+
    +
      +
        +
          +
            +
              +
            1. pStyle-bound + level one

            2. +
            +
          +
        +
      +
    +
+

pStyle-bound level +two

+
    +
      +
        +
          +
            +
              +
            1. pStyle-bound + level one again

            2. +
            +
          +
        +
      +
    +
+

Inherited +subheading

+

Bold here, and +style-false keeps it bold

+

toggled +off by a true in the character style

+

double toggle +cancels to plain

+

direct +off wins absolutely

+

direct on makes +this italic

+ + \ No newline at end of file diff --git a/tests/fixtures/html/libreoffice-docx-text.html b/tests/fixtures/html/libreoffice-docx-text.html new file mode 100644 index 00000000..d86cd5c8 --- /dev/null +++ b/tests/fixtures/html/libreoffice-docx-text.html @@ -0,0 +1,185 @@ + + + + + + + + + + + + +
  1. Fixture Document

  2. +
+

+Plain paragraph with bold, italic, and struck +runs.

+

Style-bold +paragraph with a NotBold-styled +span inside.

+
    +
      +
    1. Lists

    2. +
    +
+
    +
  1. + First numbered

  2. +
  3. + Second numbered

    +
      +
    1. + Alpha sub one

    2. +
    3. + Alpha sub two

      +
        +
      1. + Roman sub sub

      2. +
      +
    +
  4. + Third numbered

  5. +
+

+Interrupting paragraph between lists.

+
    +
  1. + Fourth, continuing the count

  2. +
+
    +
  1. + Roman starting at four

  2. +
+
    +
  1. + Roman five

  2. +
+
    +
  • + Bullet one

  • +
  • + Bullet two

    +
      +
    • + Nested bullet

    • +
    +
+
    +
      +
    1. Table

    2. + + + + + + + + + + + + + + + + + + +

      + Wide head

      +

      + End

      +

      + Tall

      +

      + B2

      +

      + C2

      +

      + B3

      +

      + C3

      +
      +
    3. Notes and special text

    4. +
    +
+

+Music clef 𝄞 appears before this footnote1 +reference.

+

+An endnote follows herei.

+

+Persian with ZWNJ: می‌خواهم. +Family emoji: 👨‍👩‍👧.

+

+Markdown specials: *stars* _under_ [bracket] `tick` #hash 1. dotted | +pipe.

+
    +
      +
    1. Links and anchors

    2. +
    +
+

+External link to example.

+

+Relative link to a +sibling file.

+

+This plain paragraph carries a bookmark.

+

+Jump to the bookmarked +paragraph.

+
    +
      +
    1. Objects

    2. +
    +
+

+Inline image: + + +done.

+

+Text box: after the box.

+ Inside the text box.

+Shape1 +

+
    +
      +
    1. Quote and code

    2. +
    +
+

+Value below one millionth: 0.0000004 should survive.

+

1Footnote + after an astral character.

+
+

iEndnote + body text.

+
+ + \ No newline at end of file diff --git a/tests/fixtures/html/libreoffice-xlsx-merged.html b/tests/fixtures/html/libreoffice-xlsx-merged.html new file mode 100644 index 00000000..fcb0f498 --- /dev/null +++ b/tests/fixtures/html/libreoffice-xlsx-merged.html @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Merged across padded
tallb23.5
b3
+ + + + From 2bca8eeeed75730e40ed52bdb9e4aabb51c3ab01 Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Barbacena Date: Sat, 29 Aug 2026 06:45:08 -0300 Subject: [PATCH 14/29] test: cover structural children in nested list wrappers --- tests/html.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/html.rs b/tests/html.rs index bf489309..1099d129 100644 --- a/tests/html.rs +++ b/tests/html.rs @@ -288,3 +288,15 @@ fn html_inside_svg_foreign_object_still_counts_self_closing_non_void_depth() { to_markdown_bytes(html.as_bytes(), Some(Format::Html)).unwrap_err(), ); } + +#[test] +fn nested_list_wrappers_preserve_structural_children() { + let html = br#" +
      +
    1. Nested heading

    2. +
      A
      +
"#; + let markdown = to_markdown_bytes(html, Some(Format::Html)).unwrap(); + assert!(markdown.contains("Nested heading"), "{markdown:?}"); + assert!(markdown.contains("| A |"), "{markdown:?}"); +} From ff5820f4103353ab92879f8ec63b3f0179da0e3a Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Barbacena Date: Sat, 29 Aug 2026 06:47:47 -0300 Subject: [PATCH 15/29] fix: preserve structural children in HTML lists --- src/shared/html.rs | 106 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 81 insertions(+), 25 deletions(-) diff --git a/src/shared/html.rs b/src/shared/html.rs index 3a7590b6..ccb4b18c 100644 --- a/src/shared/html.rs +++ b/src/shared/html.rs @@ -297,6 +297,25 @@ impl Builder<'_> { Ok(b.finish()) } + /// Render one element in a fresh block context, preserving the element's + /// own block semantics. This is used for malformed-but-repairable list + /// markup where structural elements appear directly under `ul`/`ol`. + fn sub_element_blocks( + &mut self, + elem: &Element, + delta: StyleDelta, + ) -> Result, ConvertError> { + let mut b = Builder { + blocks: Vec::new(), + inlines: Vec::new(), + css: self.css, + ctx: self.ctx, + start_boundary: true, + }; + b.walk_elem(elem, delta)?; + Ok(b.finish()) + } + /// Element-level props: matching stylesheet rules and the inline `style` /// attribute merged in one cascade order — normal rules, inline style, /// `!important` rules, `!important` inline style. @@ -539,19 +558,40 @@ impl Builder<'_> { delta: StyleDelta, ) -> Result, ConvertError> { let ordered = elem.local == "ol"; - let items: Vec<&Element> = elem.child_elems().filter(|e| e.local == "li").collect(); - if items.is_empty() { - return Ok(Vec::new()); - } + let children: Vec<&Element> = elem.child_elems().collect(); + let items: Vec<&Element> = + children.iter().copied().filter(|e| e.local == "li").collect(); + if !ordered { - let mut list_items = Vec::with_capacity(items.len()); - for li in &items { - list_items - .push(ListItem { blocks: self.sub_blocks(li, delta)?, marker_label: None }); + let mut out = Vec::new(); + let mut list_items = Vec::new(); + for child in children { + if child.local == "li" { + list_items.push(ListItem { + blocks: self.sub_blocks(child, delta)?, + marker_label: None, + }); + continue; + } + if !list_items.is_empty() { + out.push(Block::List(List { + marker: MarkerKind::Bullet, + start: 1, + items: std::mem::take(&mut list_items), + })); + } + out.extend(self.sub_element_blocks(child, delta)?); + } + if !list_items.is_empty() { + out.push(Block::List(List { + marker: MarkerKind::Bullet, + start: 1, + items: list_items, + })); } - let list = List { marker: MarkerKind::Bullet, start: 1, items: list_items }; - return Ok(vec![Block::List(list)]); + return Ok(out); } + let marker = match elem.attr_any("type") { Some("a") => MarkerKind::LowerAlpha, Some("A") => MarkerKind::UpperAlpha, @@ -575,24 +615,40 @@ impl Builder<'_> { // step must not overflow. next = if reversed { next.saturating_sub(1) } else { next.saturating_add(1) }; } - // Zero/negative numbers are valid ordered-list values but cannot be - // a `start` for the renderer's start+index numbering; such lists - // carry every number as an explicit literal marker instead. - if numbers.iter().any(|&n| n < 1) { - let mut list_items = Vec::with_capacity(items.len()); - for (li, &n) in items.iter().zip(&numbers) { - list_items.push(ListItem { - blocks: self.sub_blocks(li, delta)?, - marker_label: Some(format!("{n}.")), - }); - } - return Ok(vec![Block::List(List { marker, start: 1, items: list_items })]); - } + + // Zero/negative numbers are valid but cannot be represented by the + // renderer's positive `start` field, so those items carry explicit + // literal marker labels. Structural siblings still stay in order. + let explicit_markers = numbers.iter().any(|&n| n < 1); let mut out: Vec = Vec::new(); let mut current: Option = None; let mut last_number = 0i64; - for (li, &number) in items.iter().zip(&numbers) { - let item = ListItem { blocks: self.sub_blocks(li, delta)?, marker_label: None }; + let mut item_index = 0usize; + + for child in children { + if child.local != "li" { + if let Some(list) = current.take() { + out.push(Block::List(list)); + } + out.extend(self.sub_element_blocks(child, delta)?); + continue; + } + + let number = numbers[item_index]; + item_index += 1; + let item = ListItem { + blocks: self.sub_blocks(child, delta)?, + marker_label: explicit_markers.then(|| format!("{number}.")), + }; + + if explicit_markers { + if current.is_none() { + current = Some(List { marker, start: 1, items: Vec::new() }); + } + current.as_mut().unwrap().items.push(item); + continue; + } + let contiguous = current.is_some() && last_number.checked_add(1) == Some(number); if !contiguous { if let Some(list) = current.take() { From 6372cf0846bed82c4457553135c0da5cb4d030c3 Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Barbacena Date: Sat, 29 Aug 2026 06:49:35 -0300 Subject: [PATCH 16/29] style: format HTML corpus changes --- src/shared/html.rs | 3 +-- tests/html_corpus.rs | 8 ++------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/shared/html.rs b/src/shared/html.rs index ccb4b18c..a5192d7a 100644 --- a/src/shared/html.rs +++ b/src/shared/html.rs @@ -559,8 +559,7 @@ impl Builder<'_> { ) -> Result, ConvertError> { let ordered = elem.local == "ol"; let children: Vec<&Element> = elem.child_elems().collect(); - let items: Vec<&Element> = - children.iter().copied().filter(|e| e.local == "li").collect(); + let items: Vec<&Element> = children.iter().copied().filter(|e| e.local == "li").collect(); if !ordered { let mut out = Vec::new(); diff --git a/tests/html_corpus.rs b/tests/html_corpus.rs index e148bdc1..a62b2b12 100644 --- a/tests/html_corpus.rs +++ b/tests/html_corpus.rs @@ -30,10 +30,7 @@ fn controlled_document_preserves_rich_text_links_and_unicode() { fn controlled_nested_lists_preserve_numbering_and_structure() { let bytes = fs::read(html_fixture("controlled-lists.html")).unwrap(); let markdown = to_markdown_bytes(&bytes, Some(Format::Html)).unwrap(); - assert_eq!( - markdown, - "## Lists\n\n3. Third\n\n4. Fourth\n\n - Nested A\n - Nested B\n" - ); + assert_eq!(markdown, "## Lists\n\n3. Third\n\n4. Fourth\n\n - Nested A\n - Nested B\n"); } #[test] @@ -68,8 +65,7 @@ fn libreoffice_docx_text_preserves_core_document_semantics() { #[test] fn libreoffice_docx_numbering_preserves_list_content_and_emphasis() { - let source = - to_markdown(fixture_root().join("docx").join("handmade-numbering.docx")).unwrap(); + let source = to_markdown(fixture_root().join("docx").join("handmade-numbering.docx")).unwrap(); let html = to_markdown(html_fixture("libreoffice-docx-numbering.html")).unwrap(); assert_both_contain( From bbff29ab56a736266d957b6e9b26b9b492dcb523 Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Barbacena Date: Sat, 29 Aug 2026 06:53:23 -0300 Subject: [PATCH 17/29] test: refine LibreOffice numbering invariants --- tests/html_corpus.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/html_corpus.rs b/tests/html_corpus.rs index a62b2b12..b345bb8c 100644 --- a/tests/html_corpus.rs +++ b/tests/html_corpus.rs @@ -79,7 +79,11 @@ fn libreoffice_docx_numbering_preserves_list_content_and_emphasis() { "Interruption paragraph.", "One-four continues the count", "Ten-start via override", - "Bold here, and style-false keeps it bold", + "pStyle-bound level one", + "pStyle-bound level two", + "pStyle-bound level one again", + "Bold here,", + "style-false keeps it bold", "direct on makes this italic", ], ); From fe53a0a4adca72e83ca37bafe5c41d5c19fe2e2a Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Barbacena Date: Sat, 29 Aug 2026 06:55:08 -0300 Subject: [PATCH 18/29] test: include HTML fixtures in corpus detection --- tests/snapshots.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/snapshots.rs b/tests/snapshots.rs index 25f997c2..9b77eee6 100644 --- a/tests/snapshots.rs +++ b/tests/snapshots.rs @@ -85,6 +85,7 @@ fn fixtures_detect_from_content() { ("doc", Some(Format::Doc)), ("docx", Some(Format::Docx)), ("epub", Some(Format::Epub)), + ("html", Some(Format::Html)), ("odp", Some(Format::Odp)), ("ods", Some(Format::Ods)), ("odt", Some(Format::Odt)), From b6c08895f8b16351629ed6614786589b03ac4756 Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Barbacena Date: Sat, 29 Aug 2026 07:02:21 -0300 Subject: [PATCH 19/29] test: add HTML corpus snapshots --- ...shots__html__controlled-document.html.snap | 13 +++ ...napshots__html__controlled-lists.html.snap | 12 +++ ...s__html__controlled-merged-table.html.snap | 9 ++ ...html__libreoffice-docx-numbering.html.snap | 49 +++++++++++ ...ots__html__libreoffice-docx-text.html.snap | 83 +++++++++++++++++++ ...s__html__libreoffice-xlsx-merged.html.snap | 9 ++ 6 files changed, 175 insertions(+) create mode 100644 tests/snapshots/snapshots__html__controlled-document.html.snap create mode 100644 tests/snapshots/snapshots__html__controlled-lists.html.snap create mode 100644 tests/snapshots/snapshots__html__controlled-merged-table.html.snap create mode 100644 tests/snapshots/snapshots__html__libreoffice-docx-numbering.html.snap create mode 100644 tests/snapshots/snapshots__html__libreoffice-docx-text.html.snap create mode 100644 tests/snapshots/snapshots__html__libreoffice-xlsx-merged.html.snap diff --git a/tests/snapshots/snapshots__html__controlled-document.html.snap b/tests/snapshots/snapshots__html__controlled-document.html.snap new file mode 100644 index 00000000..0e3964eb --- /dev/null +++ b/tests/snapshots/snapshots__html__controlled-document.html.snap @@ -0,0 +1,13 @@ +--- +source: tests/snapshots.rs +expression: output +--- +# Fixture Document + +Plain paragraph with **bold**, *italic*, and [example](https://example.com/page). + +## Unicode + +café Ł music 𝄞 family 👨‍👩‍👧. + +**Styled bold paragraph.** diff --git a/tests/snapshots/snapshots__html__controlled-lists.html.snap b/tests/snapshots/snapshots__html__controlled-lists.html.snap new file mode 100644 index 00000000..f52b3598 --- /dev/null +++ b/tests/snapshots/snapshots__html__controlled-lists.html.snap @@ -0,0 +1,12 @@ +--- +source: tests/snapshots.rs +expression: output +--- +## Lists + +3. Third + +4. Fourth + + - Nested A + - Nested B diff --git a/tests/snapshots/snapshots__html__controlled-merged-table.html.snap b/tests/snapshots/snapshots__html__controlled-merged-table.html.snap new file mode 100644 index 00000000..d32b3294 --- /dev/null +++ b/tests/snapshots/snapshots__html__controlled-merged-table.html.snap @@ -0,0 +1,9 @@ +--- +source: tests/snapshots.rs +expression: output +--- +| | | | +| --- | --- | --- | +| Merged across | | padded | +| tall | b2 | 3.5 | +| | b3 | | diff --git a/tests/snapshots/snapshots__html__libreoffice-docx-numbering.html.snap b/tests/snapshots/snapshots__html__libreoffice-docx-numbering.html.snap new file mode 100644 index 00000000..ebd4ae55 --- /dev/null +++ b/tests/snapshots/snapshots__html__libreoffice-docx-numbering.html.snap @@ -0,0 +1,49 @@ +--- +source: tests/snapshots.rs +expression: output +--- +1. One-one + +2. One-two + + - i. One-two-a roman + + - ii. One-two-b roman + + - Deep bullet + +3. One-three + +Interruption paragraph. + +4. One-four continues the count + +1. Two-one independent counter + +10. Ten-start via override + +- A. Letter-A via numStyleLink + +- G. Level replaced and start overridden together + +Suppressed numbering paragraph + +8. Style-numbered paragraph + +1. pStyle-bound level one + +pStyle-bound level two + +2. pStyle-bound level one again + +**Inherited subheading** + +**Bold here,** and style-false keeps it bold + +toggled off by a true in the character style + +**double toggle cancels to plain** + +direct off wins absolutely + +*direct on makes this italic* diff --git a/tests/snapshots/snapshots__html__libreoffice-docx-text.html.snap b/tests/snapshots/snapshots__html__libreoffice-docx-text.html.snap new file mode 100644 index 00000000..36dbbb5f --- /dev/null +++ b/tests/snapshots/snapshots__html__libreoffice-docx-text.html.snap @@ -0,0 +1,83 @@ +--- +source: tests/snapshots.rs +expression: output +--- +1. # Fixture Document + +Plain paragraph with **bold**, *italic*, and ~~struck~~ runs. + +**Style-bold paragraph with a** NotBold-styled span **inside.** + +1. ## Lists + +1. First numbered + +2. Second numbered + + - a. Alpha sub one + + - b. Alpha sub two + + - i. Roman sub sub + +3. Third numbered + +Interrupting paragraph between lists. + +4. Fourth, continuing the count + +- IV. Roman starting at four + +- I. Roman five + +- Bullet one + +- Bullet two + + - Nested bullet + +2. ## Table + +| | | | | +| --- | --- | --- | --- | +| Wide head | | End | | +| Tall | B2 | | C2 | +| | B3 | | C3 | + +3. ## Notes and special text + +Music clef 𝄞 appears before this footnote[1](#sdfootnote1sym) reference. + +An endnote follows here[i](#sdendnote1sym). + +Persian with ZWNJ: می‌خواهم. Family emoji: 👨‍👩‍👧. + +Markdown specials: \*stars* \_under_ \[bracket] \`tick` #hash 1. dotted | pipe. + +4. ## Links and anchors + +External link to [example](https://example.com/page). + +Relative link to [a sibling file](../../tests/fixture-src/sibling.odt). + +This plain paragraph carries a bookmark. + +Jump to [the bookmarked paragraph](#plainmark). + +5. ## Objects + +Inline image: done. + +Text box: after the box. + +Inside the text box. + +Shape1 + +6. ## Quote and code + +Value below one millionth: 0.0000004 should survive. + +[1](#sdfootnote1anc)Footnote after an astral character. + +[i](#sdendnote1anc)Endnote body text. diff --git a/tests/snapshots/snapshots__html__libreoffice-xlsx-merged.html.snap b/tests/snapshots/snapshots__html__libreoffice-xlsx-merged.html.snap new file mode 100644 index 00000000..d32b3294 --- /dev/null +++ b/tests/snapshots/snapshots__html__libreoffice-xlsx-merged.html.snap @@ -0,0 +1,9 @@ +--- +source: tests/snapshots.rs +expression: output +--- +| | | | +| --- | --- | --- | +| Merged across | | padded | +| tall | b2 | 3.5 | +| | b3 | | From 5bd85ba674f5cdd3550c130e486cfc7de90a87e6 Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Barbacena Date: Sat, 29 Aug 2026 07:08:09 -0300 Subject: [PATCH 20/29] test: keep corpus walk helper local --- tests/common/mod.rs | 13 ------------- tests/snapshots.rs | 18 ++++++++++++++++-- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index cb693101..3ba22488 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -5,16 +5,3 @@ use std::path::{Path, PathBuf}; pub fn fixture_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests").join("fixtures") } - -/// Recursively collect every file under `dir`, sorted for determinism. -pub fn walk(dir: &Path, out: &mut Vec) { - let mut entries: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().path()).collect(); - entries.sort(); - for path in entries { - if path.is_dir() { - walk(&path, out); - } else { - out.push(path); - } - } -} diff --git a/tests/snapshots.rs b/tests/snapshots.rs index 9b77eee6..687d378f 100644 --- a/tests/snapshots.rs +++ b/tests/snapshots.rs @@ -9,9 +9,23 @@ mod common; -use common::{fixture_root, walk}; +use common::fixture_root; use std::fmt::Write as _; -use std::path::Path; +use std::path::{Path, PathBuf}; + + +/// Recursively collect every file under `dir`, sorted for determinism. +fn walk(dir: &Path, out: &mut Vec) { + let mut entries: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().path()).collect(); + entries.sort(); + for path in entries { + if path.is_dir() { + walk(&path, out); + } else { + out.push(path); + } + } +} /// Convert one file, capturing panics so a bad parser records a baseline /// instead of aborting the whole harness. From 6c78e2ce49067e4ed730fa748d3d6d70e6e91c82 Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Barbacena Date: Sat, 29 Aug 2026 07:09:22 -0300 Subject: [PATCH 21/29] style: format corpus walk helper --- tests/snapshots.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/snapshots.rs b/tests/snapshots.rs index 687d378f..1129a2f8 100644 --- a/tests/snapshots.rs +++ b/tests/snapshots.rs @@ -13,7 +13,6 @@ use common::fixture_root; use std::fmt::Write as _; use std::path::{Path, PathBuf}; - /// Recursively collect every file under `dir`, sorted for determinism. fn walk(dir: &Path, out: &mut Vec) { let mut entries: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().path()).collect(); From ab653fb9cfb0a36c24a93f261a7a766bb119d86b Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Barbacena Date: Sat, 29 Aug 2026 07:11:52 -0300 Subject: [PATCH 22/29] test: isolate HTML corpus fixture helper --- tests/common/mod.rs | 13 +++++++++++++ tests/html_corpus.rs | 7 ++++--- tests/snapshots.rs | 17 ++--------------- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 3ba22488..cb693101 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -5,3 +5,16 @@ use std::path::{Path, PathBuf}; pub fn fixture_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests").join("fixtures") } + +/// Recursively collect every file under `dir`, sorted for determinism. +pub fn walk(dir: &Path, out: &mut Vec) { + let mut entries: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().path()).collect(); + entries.sort(); + for path in entries { + if path.is_dir() { + walk(&path, out); + } else { + out.push(path); + } + } +} diff --git a/tests/html_corpus.rs b/tests/html_corpus.rs index b345bb8c..8d1a552a 100644 --- a/tests/html_corpus.rs +++ b/tests/html_corpus.rs @@ -1,10 +1,11 @@ -mod common; - use anydoc::{Format, to_markdown, to_markdown_bytes}; -use common::fixture_root; use std::fs; use std::path::PathBuf; +fn fixture_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("fixtures") +} + fn html_fixture(name: &str) -> PathBuf { fixture_root().join("html").join(name) } diff --git a/tests/snapshots.rs b/tests/snapshots.rs index 1129a2f8..9b77eee6 100644 --- a/tests/snapshots.rs +++ b/tests/snapshots.rs @@ -9,22 +9,9 @@ mod common; -use common::fixture_root; +use common::{fixture_root, walk}; use std::fmt::Write as _; -use std::path::{Path, PathBuf}; - -/// Recursively collect every file under `dir`, sorted for determinism. -fn walk(dir: &Path, out: &mut Vec) { - let mut entries: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().path()).collect(); - entries.sort(); - for path in entries { - if path.is_dir() { - walk(&path, out); - } else { - out.push(path); - } - } -} +use std::path::Path; /// Convert one file, capturing panics so a bad parser records a baseline /// instead of aborting the whole harness. From 6c706695aa055584e370f4887a87ece1b2ba3ee6 Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Barbacena Date: Sat, 29 Aug 2026 07:37:25 -0300 Subject: [PATCH 23/29] test: cover non-rendering children inside lists --- tests/html_list.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/html_list.rs diff --git a/tests/html_list.rs b/tests/html_list.rs new file mode 100644 index 00000000..e6ed851c --- /dev/null +++ b/tests/html_list.rs @@ -0,0 +1,27 @@ +use anydoc::{Format, to_markdown_bytes}; + +#[test] +fn unordered_list_ignores_non_rendering_children_without_splitting() { + let html = br#" +
    +
  • one
  • + +
  • two
  • +
"#; + + let markdown = to_markdown_bytes(html, Some(Format::Html)).unwrap(); + assert_eq!(markdown, "- one\n- two\n"); +} + +#[test] +fn ordered_list_ignores_non_rendering_children_without_splitting() { + let html = br#" +
    +
  1. one
  2. + +
  3. two
  4. +
"#; + + let markdown = to_markdown_bytes(html, Some(Format::Html)).unwrap(); + assert_eq!(markdown, "1. one\n2. two\n"); +} From 75cc2d272e1db7ec73a7e0ecfbc90bc244b69fbb Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Barbacena Date: Sat, 29 Aug 2026 07:40:59 -0300 Subject: [PATCH 24/29] fix: ignore non-rendering children when grouping lists --- src/shared/html.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/shared/html.rs b/src/shared/html.rs index a5192d7a..e35473b4 100644 --- a/src/shared/html.rs +++ b/src/shared/html.rs @@ -572,6 +572,10 @@ impl Builder<'_> { }); continue; } + let child_blocks = self.sub_element_blocks(child, delta)?; + if child_blocks.is_empty() { + continue; + } if !list_items.is_empty() { out.push(Block::List(List { marker: MarkerKind::Bullet, @@ -579,7 +583,7 @@ impl Builder<'_> { items: std::mem::take(&mut list_items), })); } - out.extend(self.sub_element_blocks(child, delta)?); + out.extend(child_blocks); } if !list_items.is_empty() { out.push(Block::List(List { @@ -626,10 +630,14 @@ impl Builder<'_> { for child in children { if child.local != "li" { + let child_blocks = self.sub_element_blocks(child, delta)?; + if child_blocks.is_empty() { + continue; + } if let Some(list) = current.take() { out.push(Block::List(list)); } - out.extend(self.sub_element_blocks(child, delta)?); + out.extend(child_blocks); continue; } From c6b7bb18608d4bde426a71d9a5300070d8f653fc Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Barbacena Date: Sat, 29 Aug 2026 21:22:23 -0300 Subject: [PATCH 25/29] fix: preserve structural depth across anchor recovery --- src/formats/html.rs | 6 ++++++ tests/html.rs | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/formats/html.rs b/src/formats/html.rs index b66c423b..e55eb345 100644 --- a/src/formats/html.rs +++ b/src/formats/html.rs @@ -212,6 +212,12 @@ impl TokenSink for HtmlComplexitySink { } fn close_implied_before_start(open: &mut Vec, name: &str) { + if name == "a" + && let Some(position) = open.iter().rposition(|candidate| candidate.as_ref() == "a") + { + open.remove(position); + } + if is_heading_element(name) && open.last().is_some_and(|candidate| is_heading_element(candidate.as_ref())) { diff --git a/tests/html.rs b/tests/html.rs index 1099d129..57ab7eaa 100644 --- a/tests/html.rs +++ b/tests/html.rs @@ -300,3 +300,27 @@ fn nested_list_wrappers_preserve_structural_children() { assert!(markdown.contains("Nested heading"), "{markdown:?}"); assert!(markdown.contains("| A |"), "{markdown:?}"); } + +#[test] +fn repeated_unclosed_anchors_do_not_trigger_depth_limit() { + let mut html = String::from(""); + for index in 0..300 { + html.push_str(&format!("link")); + } + let result = to_markdown_bytes(html.as_bytes(), Some(Format::Html)); + assert!( + !matches!(result, Err(ConvertError::ResourceLimit { limit: "max_xml_depth", .. })), + "HTML5 repairs repeated anchors; preflight must not reject them as excessive depth" + ); +} + +#[test] +fn intervening_blocks_between_unclosed_anchors_still_hit_preflight_depth_limit() { + let mut html = String::from(""); + for index in 0..300 { + html.push_str(&format!("
")); + } + + let error = to_markdown_bytes(html.as_bytes(), Some(Format::Html)).unwrap_err(); + assert_preflight_depth_limit(error); +} From ee58d6a1aaafc64155077858d9a431de4839cb08 Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Barbacena Date: Sun, 30 Aug 2026 08:02:05 +0000 Subject: [PATCH 26/29] fix: handle framesets and relative HTML images --- src/formats/html.rs | 16 ++++++++++------ tests/html.rs | 20 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/formats/html.rs b/src/formats/html.rs index e55eb345..effeacb1 100644 --- a/src/formats/html.rs +++ b/src/formats/html.rs @@ -46,10 +46,15 @@ pub fn parse(bytes: &[u8]) -> Result { css.add(&style.text().collect::()); } - let body = root - .descendent_elements() - .find(|e| e.value().name() == "body") - .ok_or_else(|| ConvertError::malformed("HTML parser produced no body element"))?; + let body = match root.descendent_elements().find(|e| e.value().name() == "body") { + Some(body) => body, + None if root.descendent_elements().any(|e| e.value().name() == "frameset") => { + return Ok(Document::default()); + } + None => { + return Err(ConvertError::malformed("HTML parser produced no body element")); + } + }; let mut node_count = 0usize; let body = adapt_element(body, 1, &mut node_count)?; @@ -466,8 +471,7 @@ impl HtmlCtx for StandaloneCtx { if src.is_empty() { return Ok(None); } - Ok((is_absolute_uri(src) || src.starts_with("//")) - .then(|| ImageSource::External(src.to_owned()))) + Ok(Some(ImageSource::External(src.to_owned()))) } fn anchor_id(&self, raw: &str) -> AnchorId { diff --git a/tests/html.rs b/tests/html.rs index 57ab7eaa..cf65d76a 100644 --- a/tests/html.rs +++ b/tests/html.rs @@ -324,3 +324,23 @@ fn intervening_blocks_between_unclosed_anchors_still_hit_preflight_depth_limit() let error = to_markdown_bytes(html.as_bytes(), Some(Format::Html)).unwrap_err(); assert_preflight_depth_limit(error); } + +#[test] +fn frameset_html_converts_without_body_error() { + let html = br#"frames"#; + assert_eq!(to_markdown_bytes(html, Some(Format::Html)).unwrap(), ""); +} + +#[test] +fn relative_image_is_preserved_without_fetching() { + 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 == "images/pixel.png" + )), + other => panic!("expected paragraph, got {other:?}"), + } +} From ce7948287c8b4a62666f851a5845beabb091ef38 Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Barbacena Date: Sun, 30 Aug 2026 08:06:01 +0000 Subject: [PATCH 27/29] test: update HTML image snapshots --- .../snapshots__html__libreoffice-docx-text.html.snap | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/snapshots/snapshots__html__libreoffice-docx-text.html.snap b/tests/snapshots/snapshots__html__libreoffice-docx-text.html.snap index 36dbbb5f..566b5585 100644 --- a/tests/snapshots/snapshots__html__libreoffice-docx-text.html.snap +++ b/tests/snapshots/snapshots__html__libreoffice-docx-text.html.snap @@ -66,13 +66,13 @@ Jump to [the bookmarked paragraph](#plainmark). 5. ## Objects -Inline image: done. +Inline image: ![](text_html_c4ee90a9.png) done. Text box: after the box. Inside the text box. -Shape1 +![Shape1](text_html_48696f23.gif) 6. ## Quote and code From b7a119b7a964a9827bb470be2fe7a4e813b97c2d Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Date: Mon, 31 Aug 2026 22:29:24 -0300 Subject: [PATCH 28/29] fix: close implied paragraphs and preserve bare-hash links in HTML Cubic findings (PR 149 review run ed78d83a, shared HTML frontend): 1. The complexity preflight did not model HTML5's implicit

closure on block-level start tags, so repeated

pairs accumulated phantom nesting depth: documents html5ever repairs into shallow trees were rejected and the depth accounting diverged from the DOM the limits are meant to model. Close an innermost open

when a block-level start tag arrives; deeper arrangements remain over-counted, keeping the preflight fail-closed. 2. href="#" produced LinkTarget::Anchor(""), which the Markdown renderer cannot resolve, so the link was dropped to plain text. Preserve the empty fragment as a relative "#" URL instead. Regressions: 200

pairs now convert (depth 200 < limit), 300 pairs are still rejected before DOM construction, and a bare-hash link renders as [top](#). --- src/formats/html.rs | 56 +++++++++++++++++++++++++++++++++++++++++++++ tests/html.rs | 30 ++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/src/formats/html.rs b/src/formats/html.rs index effeacb1..490ce8a4 100644 --- a/src/formats/html.rs +++ b/src/formats/html.rs @@ -223,6 +223,17 @@ fn close_implied_before_start(open: &mut Vec, name: &str) { open.remove(position); } + // HTML5 implicitly closes an open

when a block-level start tag + // arrives. Model the case where the

is the innermost open element; + // deeper arrangements stay over-counted, keeping the preflight + // fail-closed. (hr also closes

in HTML5 but is void, so it never + // reaches this hook; the leftover

only over-counts.) + if is_paragraph_closing_element(name) + && open.last().is_some_and(|candidate| candidate.as_ref() == "p") + { + open.pop(); + } + if is_heading_element(name) && open.last().is_some_and(|candidate| is_heading_element(candidate.as_ref())) { @@ -250,6 +261,45 @@ fn is_heading_element(name: &str) -> bool { matches!(name, "h1" | "h2" | "h3" | "h4" | "h5" | "h6") } +/// Block-level start tags that make HTML5 implicitly close an open

. +fn is_paragraph_closing_element(name: &str) -> bool { + matches!( + name, + "address" + | "article" + | "aside" + | "blockquote" + | "details" + | "dialog" + | "div" + | "dl" + | "fieldset" + | "figcaption" + | "figure" + | "footer" + | "form" + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6" + | "header" + | "hgroup" + | "hr" + | "main" + | "menu" + | "nav" + | "ol" + | "p" + | "pre" + | "section" + | "summary" + | "table" + | "ul" + ) +} + fn html5_self_closing_is_honored(open: &[LocalName], name: &str) -> bool { if matches!(name, "svg" | "math") { return true; @@ -456,6 +506,12 @@ impl HtmlCtx for StandaloneCtx { return None; } if let Some(fragment) = href.strip_prefix('#') { + if fragment.is_empty() { + // A bare "#" points at the document itself. Keep it as a + // relative URL so the link survives rendering instead of + // becoming an unresolvable empty anchor. + return Some(LinkTarget::Relative(href.to_owned())); + } let fragment = crate::package::path::decode_fragment(fragment); return Some(LinkTarget::Anchor(fragment)); } diff --git a/tests/html.rs b/tests/html.rs index cf65d76a..caf7744d 100644 --- a/tests/html.rs +++ b/tests/html.rs @@ -344,3 +344,33 @@ fn relative_image_is_preserved_without_fetching() { other => panic!("expected paragraph, got {other:?}"), } } + +#[test] +fn paragraph_div_pairs_do_not_accumulate_paragraph_depth() { + // html5ever implicitly closes each

when the

arrives, so the + // real DOM nests 200 divs - well under max_xml_depth. + let mut html = String::from(""); + for _ in 0..200 { + html.push_str("

text

"); + } + html.push_str(""); + assert!(to_markdown_bytes(html.as_bytes(), Some(Format::Html)).is_ok()); +} + +#[test] +fn deeply_nested_paragraph_div_pairs_are_rejected_before_dom() { + let mut html = String::from(""); + for _ in 0..300 { + html.push_str("

text

"); + } + html.push_str(""); + let error = to_markdown_bytes(html.as_bytes(), Some(Format::Html)).unwrap_err(); + assert!(matches!(error, ConvertError::ResourceLimit { limit: "max_xml_depth", .. })); +} + +#[test] +fn bare_hash_link_is_preserved_as_relative_url() { + let html = br##"

top

"##; + let markdown = to_markdown_bytes(html, Some(Format::Html)).unwrap(); + assert_eq!(markdown, "[top](#)\n"); +} From 41a30a51544def72ddca777c201c45c16cc930f6 Mon Sep 17 00:00:00 2001 From: Marcell Manfrin Date: Mon, 31 Aug 2026 23:24:58 -0300 Subject: [PATCH 29/29] fix: complete the paragraph-closing start tag set in the HTML preflight Cubic (PR 149, src/formats/html.rs:308) claimed dialog and summary do not implicitly close an open

; html5ever's in-body mode (tree_builder/rules.rs) proves they do. Keep both and complete the preflight list to the parser's full close_p_element_in_button_scope set: center, dir, listing, plaintext, and search were missing. table is retained: html5ever closes

for outside quirks mode, and the preflight assumes standards mode. --- src/formats/html.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/formats/html.rs b/src/formats/html.rs index 490ce8a4..f9680ca6 100644 --- a/src/formats/html.rs +++ b/src/formats/html.rs @@ -261,7 +261,9 @@ fn is_heading_element(name: &str) -> bool { matches!(name, "h1" | "h2" | "h3" | "h4" | "h5" | "h6") } -/// Block-level start tags that make HTML5 implicitly close an open

. +/// Block-level start tags that make HTML5 implicitly close an open

+/// (html5ever's in-body close_p_element_in_button_scope arms; table closes +///

only outside quirks mode, and the preflight assumes standards mode). fn is_paragraph_closing_element(name: &str) -> bool { matches!( name, @@ -269,8 +271,10 @@ fn is_paragraph_closing_element(name: &str) -> bool { | "article" | "aside" | "blockquote" + | "center" | "details" | "dialog" + | "dir" | "div" | "dl" | "fieldset" @@ -287,12 +291,15 @@ fn is_paragraph_closing_element(name: &str) -> bool { | "header" | "hgroup" | "hr" + | "listing" | "main" | "menu" | "nav" | "ol" | "p" + | "plaintext" | "pre" + | "search" | "section" | "summary" | "table"