diff --git a/Cargo.lock b/Cargo.lock index 6bb48e2a..03039ba6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,10 +95,12 @@ dependencies = [ "csv", "encoding_rs", "flate2", + "html5ever", "insta", "log", "pdf-inspector", "quick-xml", + "scraper", "sha2 0.11.0", "zip", ] @@ -361,6 +363,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 +450,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 +492,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 +516,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 +735,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 +927,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 +973,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 +1067,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 +1115,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 +1156,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 +1236,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 +1375,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 +1419,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 +1453,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 +1549,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 +1598,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 +1698,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 +1900,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..0ed2ba91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,9 +26,11 @@ 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" +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('
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 Fromworld
' + 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..522e513b 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,12 +43,117 @@ pub(crate) fn from_bytes(bytes: &[u8]) -> Optionwhen 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())) + { + open.pop(); + } + + 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_heading_element(name: &str) -> bool { + matches!(name, "h1" | "h2" | "h3" | "h4" | "h5" | "h6") +} + +/// 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,
+ "address"
+ | "article"
+ | "aside"
+ | "blockquote"
+ | "center"
+ | "details"
+ | "dialog"
+ | "dir"
+ | "div"
+ | "dl"
+ | "fieldset"
+ | "figcaption"
+ | "figure"
+ | "footer"
+ | "form"
+ | "h1"
+ | "h2"
+ | "h3"
+ | "h4"
+ | "h5"
+ | "h6"
+ | "header"
+ | "hgroup"
+ | "hr"
+ | "listing"
+ | "main"
+ | "menu"
+ | "nav"
+ | "ol"
+ | "p"
+ | "plaintext"
+ | "pre"
+ | "search"
+ | "section"
+ | "summary"
+ | "table"
+ | "ul"
+ )
+}
+
+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,
+ "area"
+ | "base"
+ | "br"
+ | "col"
+ | "embed"
+ | "hr"
+ | "img"
+ | "input"
+ | "link"
+ | "meta"
+ | "param"
+ | "source"
+ | "track"
+ | "wbr"
+ )
+}
+
+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;
+ }
+ input.push_back(StrTendril::from(&text[offset..end]));
+ let _ = tokenizer.feed(&input);
+ check_preflight_limits(&tokenizer.sink)?;
+ offset = end;
+ }
+
+ tokenizer.end();
+ check_preflight_limits(&tokenizer.sink)
+}
+
+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(
+ source: ElementRef<'_>,
+ depth: usize,
+ node_count: &mut usize,
+) -> Result