Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
8b83460
feat: add standalone HTML support
github-actions[bot] Aug 28, 2026
b20d2d9
fix: address HTML charset review feedback
marcellmanfrin Aug 28, 2026
8dfa216
fix: harden HTML content detection
marcellmanfrin Aug 28, 2026
341be1b
test: cover HTML detection review regressions
marcellmanfrin Aug 28, 2026
50b9629
fix: address HTML review regressions
marcellmanfrin Aug 28, 2026
9920d30
fix: address second HTML review regressions
marcellmanfrin Aug 28, 2026
df61b31
fix: match HTML5 preflight depth semantics
marcellmanfrin Aug 28, 2026
076e77d
fix: address final HTML review findings
marcellmanfrin Aug 28, 2026
fbe5339
test: consolidate HTML review regressions
marcellmanfrin Aug 29, 2026
c21198b
test: add HTML corpus integration regressions
marcellmanfrin Aug 29, 2026
0c4c81c
test: add controlled HTML corpus fixtures
marcellmanfrin Aug 29, 2026
adaf984
test: add LibreOffice HTML corpus regressions
marcellmanfrin Aug 29, 2026
529aaa2
test: add LibreOffice HTML corpus fixtures
marcellmanfrin Aug 29, 2026
2bca8ee
test: cover structural children in nested list wrappers
marcellmanfrin Aug 29, 2026
ff5820f
fix: preserve structural children in HTML lists
marcellmanfrin Aug 29, 2026
6372cf0
style: format HTML corpus changes
marcellmanfrin Aug 29, 2026
bbff29a
test: refine LibreOffice numbering invariants
marcellmanfrin Aug 29, 2026
fe53a0a
test: include HTML fixtures in corpus detection
marcellmanfrin Aug 29, 2026
b6c0889
test: add HTML corpus snapshots
marcellmanfrin Aug 29, 2026
5bd85ba
test: keep corpus walk helper local
marcellmanfrin Aug 29, 2026
6c78e2c
style: format corpus walk helper
marcellmanfrin Aug 29, 2026
ab653fb
test: isolate HTML corpus fixture helper
marcellmanfrin Aug 29, 2026
6c70669
test: cover non-rendering children inside lists
marcellmanfrin Aug 29, 2026
75cc2d2
fix: ignore non-rendering children when grouping lists
marcellmanfrin Aug 29, 2026
c6b7bb1
fix: preserve structural depth across anchor recovery
marcellmanfrin Aug 30, 2026
ee58d6a
fix: handle framesets and relative HTML images
marcellmanfrin Aug 30, 2026
ce79482
test: update HTML image snapshots
marcellmanfrin Aug 30, 2026
b7a119b
fix: close implied paragraphs and preserve bare-hash links in HTML
Sep 1, 2026
41a30a5
fix: complete the paragraph-closing start tag set in the HTML preflight
Sep 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
314 changes: 314 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
2 changes: 1 addition & 1 deletion node/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions node/html.test.mjs
Original file line number Diff line number Diff line change
@@ -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('<!doctype html><h1>Hello</h1><p><b>world</b></p>')
assert.equal(formatFromExtension('html'), 'html')
assert.equal(formatFromBytes(input), 'html')
assert.equal(await toMarkdownBytes(input), '# Hello\n\n**world**\n')
})
1 change: 1 addition & 0 deletions node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export declare const enum Format {
pptx = 'pptx',
rtf = 'rtf',
epub = 'epub',
html = 'html',
xlsx = 'xlsx',
ods = 'ods',
odp = 'odp',
Expand Down
3 changes: 3 additions & 0 deletions node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub enum Format {
pptx,
rtf,
epub,
html,
xlsx,
ods,
odp,
Expand All @@ -43,6 +44,7 @@ impl From<Format> 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,
Expand All @@ -62,6 +64,7 @@ impl From<anydoc::Format> 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,
Expand Down
2 changes: 1 addition & 1 deletion python/anydoc/_anydoc.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
3 changes: 2 additions & 1 deletion python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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),
Expand Down
15 changes: 15 additions & 0 deletions python/tests/test_html.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import unittest

import anydoc


class HtmlBindingTests(unittest.TestCase):
def test_standalone_html_is_exposed(self):
data = b'<!doctype html><h1>Hello</h1><p><b>world</b></p>'
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()
113 changes: 111 additions & 2 deletions src/formats/detect.rs
Original file line number Diff line number Diff line change
@@ -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
//! `<!DOCTYPE ... html>` or `<html>` marker identifies `Format::Html`.
//!
//! - PDF: the `%PDF-` header (ISO 32000; implementations accept leading
//! junk, bounded here at 1024 bytes).
Expand Down Expand Up @@ -41,12 +43,117 @@ pub(crate) fn from_bytes(bytes: &[u8]) -> Option<Format> {
if bytes.starts_with(b"PK\x03\x04") {
return detect_zip(bytes);
}
if looks_like_html(bytes) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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"<html") {
return true;
}

const DOCTYPE: &[u8] = b"<!doctype";
if !utf16_prefix_eq_ignore_ascii_case(pairs, index, little_endian, DOCTYPE) {
return false;
}
index += DOCTYPE.len();
if !pairs.get(index).is_some_and(|pair| {
utf16_ascii_unit(*pair, little_endian).is_some_and(|b| b.is_ascii_whitespace())
}) {
return false;
}
while pairs.get(index).is_some_and(|pair| {
utf16_ascii_unit(*pair, little_endian).is_some_and(|b| b.is_ascii_whitespace())
}) {
index += 1;
}
utf16_html_prefix(pairs, index, little_endian, b"html")
}

fn utf16_ascii_unit(pair: [u8; 2], little_endian: bool) -> Option<u8> {
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"<html")
}

fn html_doctype_prefix(bytes: &[u8]) -> bool {
const DOCTYPE: &[u8] = b"<!doctype";
let Some(head) = bytes.get(..DOCTYPE.len()) else {
return false;
};
if !head.eq_ignore_ascii_case(DOCTYPE) {
return false;
}
let Some(rest) = bytes.get(DOCTYPE.len()..) else {
return false;
};
if !rest.first().is_some_and(u8::is_ascii_whitespace) {
return false;
}
html_prefix(rest.trim_ascii_start(), b"html")
}

fn html_prefix(bytes: &[u8], prefix: &[u8]) -> 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.
Expand Down Expand Up @@ -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"<!DOCTYPE html><html></html>"), Some(Format::Html));
assert_eq!(from_bytes(b"\xEF\xBB\xBF <HTML><body>x</body></HTML>"), Some(Format::Html));
assert_eq!(from_bytes(b"a,b,c\n1,2,3\n"), None);
assert_eq!(from_bytes(b""), None);
}
Expand Down
Loading