Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
`<figure>`/`<figcaption>` and table spans/alignment. Row-header `<th>` cells in table bodies are
no longer dropped.
- `Document.ir_version` (`markkit::ir::IR_VERSION = 2`) is serialized with the IR JSON.
- Math: `$tex$` and `$$` blocks from MathML (`application/x-tex` annotation), KaTeX and MathJax output.
- JSON Schema for the IR at `schema/ir.schema.json`; `json-schema` feature adds `markkit::ir::json_schema()`.
- Per-block provenance: `MarkkitBuilder::track_sources(true)` records a `Source` on each IR block
(HTML element XPath, DOCX body block index, spreadsheet sheet and A1 range) for citations.
- `Markkit::parse_document` / `parse_document_sync` return the IR plus metadata (`ParsedDocument`)
Expand Down
63 changes: 63 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls", "j
# HTML parsing
scraper = "0.29"
ego-tree = "0.11"
schemars = "1"
html5ever = "0.40"

# Document formats
Expand Down
3 changes: 3 additions & 0 deletions crates/markkit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ http_client = ["dep:reqwest", "tokio/net"]
logging = ["dep:tracing"]
# LLM tool contract (`markkit::tool`): Everruns toolkit-library shape.
tool = ["dep:tower", "dep:futures-core", "dep:base64"]
# JSON Schema for the IR (`markkit::ir::json_schema()`)
json-schema = ["dep:schemars"]
all_formats = ["html", "pdf", "docx", "spreadsheet"]

[dependencies]
Expand All @@ -35,6 +37,7 @@ thiserror.workspace = true
# Serialization
serde.workspace = true
serde_json.workspace = true
schemars = { workspace = true, optional = true }

# HTML (optional)
scraper = { workspace = true, optional = true }
Expand Down
59 changes: 59 additions & 0 deletions crates/markkit/src/html/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
//! - Provenance (`track_sources`): blocks get the absolute XPath of the
//! element they came from. Sibling indexes are computed once per parent and
//! cached, so cost is O(depth) per block, not O(siblings).
//! - Math: MathML `<math>` (also inside KaTeX `.katex` / `.katex-display`
//! and MathJax `<mjx-container>`) maps to `Math` / `MathBlock` using the
//! `application/x-tex` annotation; without one, the text content is kept as
//! plain text. KaTeX's visual HTML duplicate is dropped.
//! - `<figure>` becomes `Figure` only when it has a non-empty `<figcaption>`;
//! otherwise it stays a transparent container as before.

Expand Down Expand Up @@ -193,6 +197,11 @@ impl<'a> HtmlParser<'a> {
out.push(fig);
continue;
}
if let Some(mut m) = self.math_wrapper(el) {
self.mark_source(&mut m, el);
out.push(m);
continue;
}
// Transparent containers: recurse directly into their children
if is_transparent_container(name) {
self.convert_children(el, exclude, out, depth + 1);
Expand Down Expand Up @@ -223,6 +232,10 @@ impl<'a> HtmlParser<'a> {
return Some(Node::Text(trimmed.to_string()));
}

if let Some(m) = self.math_wrapper(el) {
return Some(m);
}

let tag = el.value().name();

match tag {
Expand Down Expand Up @@ -424,6 +437,45 @@ impl<'a> HtmlParser<'a> {
}
}

/// MathML or a KaTeX / MathJax wrapper around it.
fn math_wrapper(&self, el: scraper::ElementRef<'_>) -> Option<Node> {
let v = el.value();
let (display, math) = match v.name() {
"math" => (v.attr("display") == Some("block"), el),
"mjx-container" => (v.attr("display") == Some("true"), find_math(el)?),
_ if has_class(&el, "katex-display") => (true, find_math(el)?),
_ if has_class(&el, "katex") => (false, find_math(el)?),
_ => return None,
};
let display = display || math.value().attr("display") == Some("block");
let annotation = math
.descendants()
.filter_map(scraper::ElementRef::wrap)
.find(|e| {
e.value().name() == "annotation"
&& e.value()
.attr("encoding")
.is_some_and(|enc| enc.eq_ignore_ascii_case("application/x-tex"))
});
match annotation {
Some(a) => {
let tex = a.text().collect::<String>().trim().to_string();
if tex.is_empty() {
None
} else if display {
Some(Node::MathBlock { tex, source: None })
} else {
Some(Node::Math { tex })
}
}
None => {
let text = collapse_whitespace(&math.text().collect::<String>());
let text = text.trim();
(!text.is_empty()).then(|| Node::Text(text.to_string()))
}
}
}

/// Generic container (`div`, `span`, ...) in inline/element context.
fn convert_container(
&self,
Expand Down Expand Up @@ -873,6 +925,13 @@ fn task_checkbox(li: scraper::ElementRef<'_>) -> Option<bool> {
first_checkbox(li, false)
}

/// First `<math>` element inside a wrapper.
fn find_math(el: scraper::ElementRef<'_>) -> Option<scraper::ElementRef<'_>> {
el.descendants()
.filter_map(scraper::ElementRef::wrap)
.find(|e| e.value().name() == "math")
}

fn has_class(el: &scraper::ElementRef<'_>, class: &str) -> bool {
el.value()
.attr("class")
Expand Down
Loading
Loading