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('

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..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]) -> Option { if bytes.starts_with(b"PK\x03\x04") { return detect_zip(bytes); } + 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 (pairs, _) = bytes.as_chunks::<2>(); + 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 { + let bytes = bytes.trim_ascii_start(); + html_doctype_prefix(bytes) || html_prefix(bytes, b" bool { + const DOCTYPE: &[u8] = 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 +343,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..f9680ca6 --- /dev/null +++ b/src/formats/html.rs @@ -0,0 +1,573 @@ +//! 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 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. +/// +/// 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); + preflight_html_complexity(&text)?; + + 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 = 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)?; + 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 = 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(); + + 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 = meta + .value() + .attr("http-equiv") + .is_some_and(|value| value.trim().eq_ignore_ascii_case("content-type")); + if is_content_type + && let Some(content) = meta.value().attr("content") + && let Some(label) = content_type_charset(content) + && let Some(encoding) = Encoding::for_label(label.as_bytes()) + { + return Some(encoding); + } + } + None +} + +fn content_type_charset(content: &str) -> Option<&str> { + 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(); + } + _ => {} + } + } + 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)] +struct HtmlComplexitySink { + node_count: Cell, + open_elements: RefCell>, + node_limit_exceeded: Cell, + depth_limit_exceeded: Cell, +} + +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); + } + } + + 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); + } + } + + 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); + } + } +} + +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(); + 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 { + "title" | "textarea" => TokenSinkResult::RawData(Rcdata), + "style" | "xmp" | "iframe" | "noembed" | "noframes" => { + TokenSinkResult::RawData(Rawtext) + } + "script" => TokenSinkResult::RawData(ScriptData), + "plaintext" => TokenSinkResult::Plaintext, + _ => TokenSinkResult::Continue, + } + } + EndTag => { + self.close_element(&tag.name); + TokenSinkResult::Continue + } + }, + Token::CharacterTokens(text) => { + if !text.is_empty() { + self.bump_node(); + } + TokenSinkResult::Continue + } + Token::CommentToken(_) | Token::DoctypeToken(_) | Token::NullCharacterToken => { + self.bump_node(); + TokenSinkResult::Continue + } + Token::EOFToken | Token::ParseError(_) => TokenSinkResult::Continue, + } + } +} + +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); + } + + // 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())) + { + 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 { + 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('#') { + 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)); + } + 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(Some(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") + ); + } + + #[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/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/src/shared/html.rs b/src/shared/html.rs index 3a7590b6..e35473b4 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,43 @@ 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; + } + 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, + start: 1, + items: std::mem::take(&mut list_items), + })); + } + out.extend(child_blocks); + } + 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 +618,44 @@ 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" { + 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(child_blocks); + 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() { 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
+ + 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
+ + + + diff --git a/tests/html.rs b/tests/html.rs new file mode 100644 index 00000000..caf7744d --- /dev/null +++ b/tests/html.rs @@ -0,0 +1,376 @@ +use anydoc::model::{Block, ImageSource, Inline}; +use anydoc::{ConvertError, Format, to_document, 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 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 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"; + + 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 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 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(); + let markdown = to_markdown_bytes(html, None).unwrap(); + 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"#; + 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"); +} + +#[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:?}"), + } +} + +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 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(""); + 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(), + ); +} + +#[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:?}"); +} + +#[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); +} + +#[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:?}"), + } +} + +#[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"); +} diff --git a/tests/html_corpus.rs b/tests/html_corpus.rs new file mode 100644 index 00000000..8d1a552a --- /dev/null +++ b/tests/html_corpus.rs @@ -0,0 +1,100 @@ +use anydoc::{Format, to_markdown, to_markdown_bytes}; +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) +} + +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(); + 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 - 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); +} + +#[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", + "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", + ], + ); + 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); +} 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"); +} 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)), 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..566b5585 --- /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: ![](text_html_c4ee90a9.png) done. + +Text box: after the box. + +Inside the text box. + +![Shape1](text_html_48696f23.gif) + +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 | | 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,