` (directly or inside its first ``/``).
+fn task_checkbox(li: scraper::ElementRef<'_>) -> Option {
+ fn first_checkbox(el: scraper::ElementRef<'_>, nested: bool) -> Option {
+ for child in el.children() {
+ match child.value() {
+ HtmlNode::Text(t) if t.trim().is_empty() => continue,
+ HtmlNode::Element(e) => {
+ if e.name() == "input" {
+ return e
+ .attr("type")
+ .filter(|t| t.eq_ignore_ascii_case("checkbox"))
+ .map(|_| e.attr("checked").is_some());
+ }
+ if !nested && matches!(e.name(), "p" | "label") {
+ return first_checkbox(scraper::ElementRef::wrap(child)?, true);
+ }
+ return None;
+ }
+ HtmlNode::Comment(_) => continue,
+ _ => return None,
+ }
+ }
+ None
+ }
+ first_checkbox(li, false)
+}
+
+fn has_class(el: &scraper::ElementRef<'_>, class: &str) -> bool {
+ el.value()
+ .attr("class")
+ .is_some_and(|c| c.split_whitespace().any(|x| x == class))
+}
+
+fn is_footnote_ref(el: &scraper::ElementRef<'_>) -> bool {
+ let v = el.value();
+ v.attr("data-footnote-ref").is_some()
+ || v.attr("role") == Some("doc-noteref")
+ || has_class(el, "footnote-ref")
+}
+
+fn is_footnote_backref(el: &scraper::ElementRef<'_>) -> bool {
+ let v = el.value();
+ v.attr("data-footnote-backref").is_some()
+ || v.attr("role") == Some("doc-backlink")
+ || has_class(el, "footnote-back")
+ || has_class(el, "footnote-backref")
+}
+
+fn is_footnote_section(el: &scraper::ElementRef<'_>) -> bool {
+ let v = el.value();
+ matches!(v.name(), "section" | "div" | "aside" | "ol")
+ && (v.attr("data-footnotes").is_some()
+ || matches!(v.attr("role"), Some("doc-endnotes" | "doc-footnotes"))
+ || has_class(el, "footnotes"))
+}
+
/// Extract language identifier from a class like "language-rust" or "lang-python".
fn extract_language_from_class(class: &str) -> Option {
for cls in class.split_whitespace() {
@@ -472,13 +845,13 @@ fn collapse_whitespace(s: &str) -> String {
/// drop whitespace-only inline runs and wrap the remaining inline runs in a
/// `Paragraph` (edge whitespace trimmed). Pure-inline lists are returned as is.
fn normalize_blocks(nodes: Vec) -> Vec {
- if nodes.iter().all(is_inline) {
+ if nodes.iter().all(Node::is_inline) {
return nodes;
}
let mut out = Vec::with_capacity(nodes.len());
let mut run: Vec = Vec::new();
for node in nodes {
- if is_inline(&node) {
+ if node.is_inline() {
run.push(node);
} else {
flush_inline_run(&mut run, &mut out);
@@ -527,21 +900,6 @@ fn is_transparent_container(tag: &str) -> bool {
)
}
-/// Check if a node is inline content.
-fn is_inline(node: &Node) -> bool {
- matches!(
- node,
- Node::Text(_)
- | Node::Bold { .. }
- | Node::Italic { .. }
- | Node::Strikethrough { .. }
- | Node::InlineCode(_)
- | Node::Link { .. }
- | Node::Image { .. }
- | Node::LineBreak
- )
-}
-
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/markkit/src/ir.rs b/crates/markkit/src/ir.rs
index 5fefd68..fa6fce1 100644
--- a/crates/markkit/src/ir.rs
+++ b/crates/markkit/src/ir.rs
@@ -2,18 +2,47 @@
//!
//! All parsers convert their input into this IR, and all renderers
//! consume it. This decouples input formats from output formats.
+//!
+//! Decisions (IR v2, see `knowledge/foundations/ir.md`):
+//! - [`Document::ir_version`] is serialized so JSON consumers can detect shape
+//! changes; bump [`IR_VERSION`] on any breaking change to serialized form.
+//! - [`Node`] is `#[non_exhaustive]`: new node kinds are additive, so matches
+//! outside this crate need a wildcard arm. Helper structs are
+//! `#[non_exhaustive]` too and built through constructors.
+//! - Serde keeps the default externally tagged enum form; optional/default
+//! fields are omitted when unset so simple documents stay compact.
+//! - Table spans are stored as authored (clamped by parsers) and laid out by
+//! the renderer, which owns the grid-size budget.
use serde::{Deserialize, Serialize};
+/// Current IR schema version written by this crate.
+///
+/// v1: initial IR (implicit, no version field). v2: footnotes, definition
+/// lists, task lists, figures, table cells with alignment and spans.
+pub const IR_VERSION: u32 = 2;
+
/// A parsed document.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[non_exhaustive]
pub struct Document {
+ /// IR schema version ([`IR_VERSION`] for documents built by this crate;
+ /// `1` when missing from deserialized JSON).
+ #[serde(default = "v1")]
+ pub ir_version: u32,
pub nodes: Vec,
}
+fn v1() -> u32 {
+ 1
+}
+
impl Document {
pub fn new(nodes: Vec) -> Self {
- Self { nodes }
+ Self {
+ ir_version: IR_VERSION,
+ nodes,
+ }
}
pub fn is_empty(&self) -> bool {
@@ -23,6 +52,7 @@ impl Document {
/// A block-level or inline node in the document tree.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[non_exhaustive]
pub enum Node {
/// Heading with level 1–6.
Heading { level: u8, children: Vec },
@@ -43,11 +73,24 @@ pub enum Node {
},
/// Table with optional header row.
Table {
- headers: Vec>,
- rows: Vec>>,
+ /// Per-column alignment; missing entries mean [`Alignment::None`].
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ alignments: Vec,
+ headers: Vec,
+ rows: Vec>,
},
/// Horizontal rule / thematic break.
ThematicBreak,
+ /// Definition list (``): terms with one or more definitions.
+ DefinitionList { items: Vec },
+ /// Figure: block content with an optional inline caption.
+ Figure {
+ children: Vec,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ caption: Option>,
+ },
+ /// Footnote body, referenced by [`Node::FootnoteReference`] with the same label.
+ FootnoteDefinition { label: String, children: Vec },
/// Plain text.
Text(String),
/// Bold / strong emphasis.
@@ -68,17 +111,127 @@ pub enum Node {
Image { src: String, alt: String },
/// Hard line break.
LineBreak,
+ /// Footnote marker pointing at a [`Node::FootnoteDefinition`].
+ FootnoteReference { label: String },
+}
+
+impl Node {
+ /// True for block-level nodes (headings, paragraphs, lists, tables, ...).
+ pub fn is_block(&self) -> bool {
+ matches!(
+ self,
+ Node::Heading { .. }
+ | Node::Paragraph { .. }
+ | Node::List { .. }
+ | Node::BlockQuote { .. }
+ | Node::CodeBlock { .. }
+ | Node::Table { .. }
+ | Node::ThematicBreak
+ | Node::DefinitionList { .. }
+ | Node::Figure { .. }
+ | Node::FootnoteDefinition { .. }
+ )
+ }
+
+ /// True for inline nodes (text, emphasis, links, ...).
+ pub fn is_inline(&self) -> bool {
+ !self.is_block()
+ }
}
/// A single item in a list.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[non_exhaustive]
pub struct ListItem {
pub children: Vec,
+ /// Task-list state: `Some(true)` checked, `Some(false)` unchecked, `None` plain item.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub checked: Option,
}
impl ListItem {
pub fn new(children: Vec) -> Self {
- Self { children }
+ Self {
+ children,
+ checked: None,
+ }
+ }
+
+ /// Task-list item with a checkbox.
+ pub fn task(children: Vec, checked: bool) -> Self {
+ Self {
+ children,
+ checked: Some(checked),
+ }
+ }
+}
+
+/// Horizontal alignment of a table column.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[non_exhaustive]
+pub enum Alignment {
+ #[default]
+ None,
+ Left,
+ Center,
+ Right,
+}
+
+/// A table cell: inline content plus its span (both ≥ 1).
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[non_exhaustive]
+pub struct TableCell {
+ pub children: Vec,
+ #[serde(default = "one", skip_serializing_if = "is_one")]
+ pub colspan: u32,
+ #[serde(default = "one", skip_serializing_if = "is_one")]
+ pub rowspan: u32,
+}
+
+fn one() -> u32 {
+ 1
+}
+
+fn is_one(n: &u32) -> bool {
+ *n == 1
+}
+
+impl TableCell {
+ pub fn new(children: Vec) -> Self {
+ Self {
+ children,
+ colspan: 1,
+ rowspan: 1,
+ }
+ }
+
+ /// Set the span; zero is treated as one.
+ pub fn with_span(mut self, colspan: u32, rowspan: u32) -> Self {
+ self.colspan = colspan.max(1);
+ self.rowspan = rowspan.max(1);
+ self
+ }
+}
+
+impl From> for TableCell {
+ fn from(children: Vec) -> Self {
+ Self::new(children)
+ }
+}
+
+/// One term of a [`Node::DefinitionList`] with its definitions.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+#[non_exhaustive]
+pub struct DefinitionItem {
+ /// Inline content of the term.
+ pub term: Vec,
+ /// Each definition is a list of block (or inline) nodes.
+ pub definitions: Vec>,
+}
+
+impl DefinitionItem {
+ pub fn new(term: Vec, definitions: Vec>) -> Self {
+ Self { term, definitions }
}
}
@@ -106,11 +259,70 @@ mod tests {
children: vec![Node::Text("bold".into())],
},
Node::Text(" text".into()),
+ Node::FootnoteReference { label: "1".into() },
],
},
+ Node::List {
+ ordered: false,
+ start: None,
+ items: vec![
+ ListItem::task(vec![Node::Text("done".into())], true),
+ ListItem::new(vec![Node::Text("plain".into())]),
+ ],
+ },
+ Node::Table {
+ alignments: vec![Alignment::Left, Alignment::Right],
+ headers: vec![TableCell::new(vec![Node::Text("a".into())]).with_span(2, 1)],
+ rows: vec![vec![TableCell::new(vec![]), TableCell::new(vec![])]],
+ },
+ Node::DefinitionList {
+ items: vec![DefinitionItem::new(
+ vec![Node::Text("term".into())],
+ vec![vec![Node::Text("def".into())]],
+ )],
+ },
+ Node::Figure {
+ children: vec![Node::Image {
+ src: "a.png".into(),
+ alt: "A".into(),
+ }],
+ caption: Some(vec![Node::Text("cap".into())]),
+ },
+ Node::FootnoteDefinition {
+ label: "1".into(),
+ children: vec![Node::Paragraph {
+ children: vec![Node::Text("note".into())],
+ }],
+ },
]);
let json = serde_json::to_string(&doc).unwrap();
let parsed: Document = serde_json::from_str(&json).unwrap();
assert_eq!(doc, parsed);
+ assert_eq!(parsed.ir_version, IR_VERSION);
+ }
+
+ #[test]
+ fn json_omits_defaults() {
+ let doc = Document::new(vec![Node::Table {
+ alignments: vec![],
+ headers: vec![TableCell::new(vec![Node::Text("h".into())])],
+ rows: vec![],
+ }]);
+ let json = serde_json::to_string(&doc).unwrap();
+ assert!(json.starts_with(r#"{"ir_version":2,"#), "{json}");
+ assert!(!json.contains("colspan"), "{json}");
+ assert!(!json.contains("alignments"), "{json}");
+ }
+
+ #[test]
+ fn missing_version_reads_as_v1() {
+ let doc: Document = serde_json::from_str(r#"{"nodes":[{"Text":"x"}]}"#).unwrap();
+ assert_eq!(doc.ir_version, 1);
+ }
+
+ #[test]
+ fn zero_span_clamped() {
+ let c = TableCell::new(vec![]).with_span(0, 0);
+ assert_eq!((c.colspan, c.rowspan), (1, 1));
}
}
diff --git a/crates/markkit/src/render/markdown.rs b/crates/markkit/src/render/markdown.rs
index eec02a1..9d68347 100644
--- a/crates/markkit/src/render/markdown.rs
+++ b/crates/markkit/src/render/markdown.rs
@@ -1,6 +1,22 @@
//! IR → Markdown renderer.
-
-use crate::ir::{Document, ListItem, Node};
+//!
+//! Decisions:
+//! - GFM is the target. Constructs GFM lacks use the de facto extensions most
+//! Markdown tools read: footnotes `[^label]` / `[^label]: body`, definition
+//! lists `Term` + `: definition` (PHP Markdown Extra / Pandoc), task items
+//! `- [x]`. Figures render their content plus an italic caption paragraph.
+//! - Table spans are laid out on a grid (GFM has no spans): a spanned cell's
+//! content goes in its top-left slot, covered slots stay empty. Layout is
+//! bounded by `MAX_TABLE_GRID_CELLS`; a table whose spans would exceed it is
+//! laid out ignoring spans (threat model T2, invariant S13).
+
+use crate::ir::{Alignment, DefinitionItem, Document, ListItem, Node, TableCell};
+
+/// Grid budget for span layout (rows × columns).
+const MAX_TABLE_GRID_CELLS: usize = 1_000_000;
+/// Span clamps applied at layout time (HTML limits; IR may come from anywhere).
+const MAX_COLSPAN: usize = 1000;
+const MAX_ROWSPAN: usize = 65534;
/// Renders a [`Document`] IR into Markdown text.
pub struct MarkdownRenderer {
@@ -25,7 +41,7 @@ impl MarkdownRenderer {
/// Render a slice of nodes.
fn render_nodes(&mut self, nodes: &[Node], depth: usize) {
for (i, node) in nodes.iter().enumerate() {
- if i > 0 && is_block(node) && is_block(&nodes[i - 1]) {
+ if i > 0 && node.is_block() && nodes[i - 1].is_block() {
self.buf.push('\n');
}
self.render_node(node, depth);
@@ -89,14 +105,41 @@ impl MarkdownRenderer {
self.buf.push_str("```\n");
}
- Node::Table { headers, rows } => {
- self.render_table(headers, rows);
+ Node::Table {
+ alignments,
+ headers,
+ rows,
+ } => {
+ self.render_table(alignments, headers, rows);
}
Node::ThematicBreak => {
self.buf.push_str("---\n");
}
+ Node::DefinitionList { items } => self.render_definition_list(items, depth),
+
+ Node::Figure { children, caption } => {
+ self.render_nodes(children, depth);
+ if let Some(caption) = caption {
+ self.buf.push('\n');
+ let start = self.buf.len();
+ self.render_inline_node(&Node::Italic {
+ children: caption.clone(),
+ });
+ trim_trailing_inline(&mut self.buf, start);
+ self.buf.push('\n');
+ }
+ }
+
+ Node::FootnoteDefinition { label, children } => {
+ let inner = render_blocks(children, depth);
+ self.buf.push_str("[^");
+ self.buf.push_str(&footnote_label(label));
+ self.buf.push_str("]:");
+ push_hanging(&mut self.buf, &inner, " ", " ");
+ }
+
// Inline nodes rendered at block level get wrapped in a line
Node::Text(_)
| Node::Bold { .. }
@@ -105,12 +148,31 @@ impl MarkdownRenderer {
| Node::InlineCode(_)
| Node::Link { .. }
| Node::Image { .. }
- | Node::LineBreak => {
+ | Node::LineBreak
+ | Node::FootnoteReference { .. } => {
self.render_inline_node(node);
}
}
}
+ /// Render a definition list: `Term` line, then `: definition` per definition.
+ fn render_definition_list(&mut self, items: &[DefinitionItem], depth: usize) {
+ for (i, item) in items.iter().enumerate() {
+ if i > 0 {
+ self.buf.push('\n');
+ }
+ let start = self.buf.len();
+ self.render_inline(&item.term);
+ trim_trailing_inline(&mut self.buf, start);
+ self.buf.push('\n');
+ for def in &item.definitions {
+ let inner = render_blocks(def, depth);
+ self.buf.push(':');
+ push_hanging(&mut self.buf, &inner, " ", " ");
+ }
+ }
+ }
+
/// Render a list.
fn render_list(&mut self, ordered: bool, start: Option, items: &[ListItem], depth: usize) {
let indent = " ".repeat(depth);
@@ -123,9 +185,14 @@ impl MarkdownRenderer {
format!("{}- ", indent)
};
self.buf.push_str(&marker);
+ match item.checked {
+ Some(true) => self.buf.push_str("[x] "),
+ Some(false) => self.buf.push_str("[ ] "),
+ None => {}
+ }
// Render item children
- let has_block = item.children.iter().any(|n| !is_inline_node(n));
+ let has_block = item.children.iter().any(Node::is_block);
if has_block {
// Multi-line item: render sub-blocks indented
let mut first = true;
@@ -241,53 +308,162 @@ impl MarkdownRenderer {
Node::LineBreak => {
self.buf.push_str(" \n");
}
+ Node::FootnoteReference { label } => {
+ self.buf.push_str("[^");
+ self.buf.push_str(&footnote_label(label));
+ self.buf.push(']');
+ }
// Block nodes shouldn't appear here but handle gracefully
other => self.render_node(other, 0),
}
}
- /// Render a GFM table.
- fn render_table(&mut self, headers: &[Vec], rows: &[Vec>]) {
- // Determine column count
- let col_count = headers
- .len()
- .max(rows.iter().map(|r| r.len()).max().unwrap_or(0));
-
+ /// Render a GFM table, laying spans out on a grid.
+ fn render_table(
+ &mut self,
+ alignments: &[Alignment],
+ headers: &[TableCell],
+ rows: &[Vec],
+ ) {
+ let all_rows: Vec<&[TableCell]> = std::iter::once(headers)
+ .chain(rows.iter().map(Vec::as_slice))
+ .collect();
+ let grid = layout_table(&all_rows, true).unwrap_or_else(|| {
+ layout_table(&all_rows, false).expect("span-free layout is within budget")
+ });
+ let col_count = grid.iter().map(Vec::len).max().unwrap_or(0);
if col_count == 0 {
return;
}
- // Render header row
- self.buf.push('|');
- for i in 0..col_count {
- self.buf.push(' ');
- if let Some(cells) = headers.get(i) {
- self.render_inline(cells);
- }
- self.buf.push_str(" |");
- }
- self.buf.push('\n');
-
- // Separator
- self.buf.push('|');
- for _ in 0..col_count {
- self.buf.push_str(" --- |");
- }
- self.buf.push('\n');
-
- // Data rows
- for row in rows {
+ for (r, row) in grid.iter().enumerate() {
self.buf.push('|');
for i in 0..col_count {
self.buf.push(' ');
- if let Some(cells) = row.get(i) {
+ if let Some(Some(cells)) = row.get(i) {
self.render_inline(cells);
}
self.buf.push_str(" |");
}
self.buf.push('\n');
+
+ if r == 0 {
+ self.buf.push('|');
+ for i in 0..col_count {
+ self.buf
+ .push_str(match alignments.get(i).copied().unwrap_or_default() {
+ Alignment::Left => " :--- |",
+ Alignment::Center => " :---: |",
+ Alignment::Right => " ---: |",
+ _ => " --- |",
+ });
+ }
+ self.buf.push('\n');
+ }
+ }
+ }
+}
+
+/// Grid slot: `Some(content)` at a cell's top-left, `None` where covered or empty.
+type GridRow<'a> = Vec>;
+
+/// Place cells on a grid honouring `colspan`/`rowspan` when `spans` is set.
+///
+/// Returns `None` when the grid would exceed `MAX_TABLE_GRID_CELLS`; the
+/// caller then retries with `spans = false`, whose size is bounded by the
+/// input (one slot per cell, rows padded at render time).
+fn layout_table<'a>(rows: &[&'a [TableCell]], spans: bool) -> Option>> {
+ let mut grid: Vec> = Vec::with_capacity(rows.len());
+ // Remaining rows each column stays covered by a rowspan from above.
+ let mut pending: Vec = Vec::new();
+ let mut width = 0usize;
+ for row in rows {
+ let busy: Vec = pending.iter().map(|&p| p > 0).collect();
+ for p in pending.iter_mut() {
+ *p = p.saturating_sub(1);
+ }
+ let mut out: GridRow<'a> = Vec::new();
+ let mut col = 0usize;
+ for cell in row.iter() {
+ while busy.get(col).copied().unwrap_or(false) {
+ col += 1;
+ }
+ let (cs, rs) = if spans {
+ (
+ (cell.colspan as usize).clamp(1, MAX_COLSPAN),
+ (cell.rowspan as usize).clamp(1, MAX_ROWSPAN),
+ )
+ } else {
+ (1, 1)
+ };
+ let end = col + cs;
+ if spans && end.saturating_mul(grid.len() + 1) > MAX_TABLE_GRID_CELLS {
+ return None;
+ }
+ if out.len() < end {
+ out.resize(end, None);
+ }
+ out[col] = Some(cell.children.as_slice());
+ if rs > 1 {
+ if pending.len() < end {
+ pending.resize(end, 0);
+ }
+ for p in &mut pending[col..end] {
+ *p = rs - 1;
+ }
+ }
+ col = end;
}
+ width = width.max(out.len()).max(pending.len());
+ if spans && width.saturating_mul(grid.len() + 1) > MAX_TABLE_GRID_CELLS {
+ return None;
+ }
+ grid.push(out);
}
+ Some(grid)
+}
+
+/// Render block children to normalized Markdown in a fresh renderer.
+fn render_blocks(nodes: &[Node], depth: usize) -> String {
+ let mut sub = MarkdownRenderer::new();
+ sub.render_nodes(nodes, depth);
+ normalize_output(&sub.buf)
+}
+
+/// Append `inner` with `first` before its first line and `rest` before the
+/// other non-blank lines (hanging indent for footnotes / definitions).
+fn push_hanging(buf: &mut String, inner: &str, first: &str, rest: &str) {
+ let mut lines = inner.lines();
+ if let Some(line) = lines.next() {
+ buf.push_str(first);
+ buf.push_str(line);
+ }
+ buf.push('\n');
+ for line in lines {
+ if !line.is_empty() {
+ buf.push_str(rest);
+ buf.push_str(line);
+ }
+ buf.push('\n');
+ }
+}
+
+/// Footnote labels are emitted inside `[^...]`: keep `[A-Za-z0-9_.-]`, map
+/// everything else to `-` so a label cannot close the bracket or break the line.
+fn footnote_label(label: &str) -> String {
+ let out: String = label
+ .trim()
+ .chars()
+ .take(64)
+ .map(|c| {
+ if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
+ c
+ } else {
+ '-'
+ }
+ })
+ .collect();
+ if out.is_empty() { "note".into() } else { out }
}
impl Default for MarkdownRenderer {
@@ -324,24 +500,6 @@ pub(crate) fn escape_table_cell(s: &str) -> String {
out
}
-/// Check if a node is block-level.
-fn is_block(node: &Node) -> bool {
- matches!(
- node,
- Node::Heading { .. }
- | Node::Paragraph { .. }
- | Node::List { .. }
- | Node::BlockQuote { .. }
- | Node::CodeBlock { .. }
- | Node::Table { .. }
- | Node::ThematicBreak
- )
-}
-
-fn is_inline_node(node: &Node) -> bool {
- !is_block(node)
-}
-
/// Normalize output: collapse multiple blank lines, preserve hard breaks (two trailing spaces).
fn normalize_output(s: &str) -> String {
let mut result = String::with_capacity(s.len());
@@ -394,6 +552,13 @@ mod tests {
assert_eq!(normalize_output("a \nb\n"), "a \nb\n");
}
+ #[test]
+ fn test_footnote_label() {
+ assert_eq!(footnote_label("1"), "1");
+ assert_eq!(footnote_label("a]b\n[c"), "a-b--c");
+ assert_eq!(footnote_label(" "), "note");
+ }
+
#[cfg(any(feature = "docx", feature = "spreadsheet"))]
#[test]
fn test_escape_table_cell() {
diff --git a/crates/markkit/src/spreadsheet.rs b/crates/markkit/src/spreadsheet.rs
index 2ca280c..2b04212 100644
--- a/crates/markkit/src/spreadsheet.rs
+++ b/crates/markkit/src/spreadsheet.rs
@@ -28,7 +28,7 @@ use crate::archive::{ArchiveError, ZipGuard};
use crate::config::MarkkitBuilder;
use crate::error::Error;
use crate::format::Format;
-use crate::ir::{Document, Node};
+use crate::ir::{Document, Node, TableCell};
use crate::render::escape_table_cell;
pub(crate) struct SheetOutput {
@@ -376,17 +376,18 @@ impl<'c> Collector<'c> {
self.sheets += 1;
self.rows += rows.len();
let cell = |s: String| {
- if s.is_empty() {
+ TableCell::new(if s.is_empty() {
vec![]
} else {
vec![Node::Text(s)]
- }
+ })
};
self.nodes.push(Node::Heading {
level: 2,
children: vec![Node::Text(name.replace(['\r', '\n'], " "))],
});
self.nodes.push(Node::Table {
+ alignments: Vec::new(),
headers: header.into_iter().map(cell).collect(),
rows: rows
.into_iter()
diff --git a/crates/markkit/tests/html_integration.rs b/crates/markkit/tests/html_integration.rs
index afd18f6..e2402e3 100644
--- a/crates/markkit/tests/html_integration.rs
+++ b/crates/markkit/tests/html_integration.rs
@@ -739,3 +739,133 @@ fn loose_text_between_blocks_becomes_paragraph() {
fn inline_only_document_unchanged() {
assert_eq!(convert("Hello world "), "Hello **world**\n");
}
+
+// ─── IR v2: tasks, definition lists, figures, footnotes, tables ─────
+
+#[test]
+fn task_list_github_markup() {
+ let html = r#""#;
+ assert_eq!(convert(html), "- [x] Done\n- [ ] Todo\n- Plain\n");
+}
+
+#[test]
+fn task_list_checkbox_inside_paragraph() {
+ let html = r#" Ship it
"#;
+ assert_eq!(convert(html), "1. [x] Ship it\n");
+}
+
+#[test]
+fn checkbox_after_text_is_not_task() {
+ let html = r#""#;
+ assert_eq!(convert(html), "- Agree\n");
+}
+
+#[test]
+fn non_checkbox_input_is_not_task() {
+ let html = r#""#;
+ assert!(!convert(html).contains('['));
+}
+
+#[test]
+fn definition_list() {
+ let html = "Rust A language. Also a fungus. \
+ Go Another.
Second para.
";
+ assert_eq!(
+ convert(html),
+ "Rust\n: A language.\n: Also a fungus.\n\nGo\n: Another.\n\n Second para.\n"
+ );
+}
+
+#[test]
+fn definition_list_div_wrappers_and_shared_terms() {
+ let html = "
A B Both
";
+ assert_eq!(convert(html), "A \nB\n: Both\n");
+}
+
+#[test]
+fn empty_definition_list_elided() {
+ assert_eq!(convert("x
"), "x\n");
+}
+
+#[test]
+fn figure_with_caption() {
+ let html =
+ r#"A cat . "#;
+ assert_eq!(convert(html), "\n\n*A **cat**.*\n");
+}
+
+#[test]
+fn figure_without_caption_is_transparent() {
+ let html = r#" "#;
+ assert_eq!(convert(html), "\n");
+}
+
+#[test]
+fn footnotes_github_markup() {
+ let html = r##"Claim1 .
+ "##;
+ assert_eq!(convert(html), "Claim[^1].\n\n[^1]: Source.\n");
+}
+
+#[test]
+fn footnotes_pandoc_markup() {
+ let html = r##"Text
+ "##;
+ assert_eq!(convert(html), "Text[^a]\n\n[^a]: First.\n\n Second.\n");
+}
+
+#[test]
+fn footnote_label_cannot_break_out() {
+ let html = r##"x]](javascript:alert(1))
"##;
+ let md = convert(html);
+ assert!(!md.contains("]("), "{md}");
+ assert!(md.starts_with("x[^"), "{md}");
+}
+
+#[test]
+fn table_alignment() {
+ let html = r#""#;
+ assert_eq!(
+ convert(html),
+ "| L | C | R | N |\n| :--- | :---: | ---: | --- |\n| 1 | 2 | 3 | 4 |\n"
+ );
+}
+
+#[test]
+fn table_colspan_and_rowspan() {
+ let html = r#""#;
+ assert_eq!(
+ convert(html),
+ "| AB | | C |\n| --- | --- | --- |\n| x | 1 | 2 |\n| | 3 | 4 |\n"
+ );
+}
+
+#[test]
+fn table_row_header_cells_kept() {
+ let html = "";
+ assert_eq!(convert(html), "| K | V |\n| --- | --- |\n| a | 1 |\n");
+}
+
+#[test]
+fn ir_v2_json_has_version() {
+ let m = Markkit::builder().build();
+ let doc = m
+ .parse_html("")
+ .unwrap();
+ let json = serde_json::to_value(&doc).unwrap();
+ assert_eq!(json["ir_version"], markkit::ir::IR_VERSION);
+ assert_eq!(json["nodes"][0]["List"]["items"][0]["checked"], true);
+}
diff --git a/crates/markkit/tests/html_security.rs b/crates/markkit/tests/html_security.rs
index d09ba18..c51b2e5 100644
--- a/crates/markkit/tests/html_security.rs
+++ b/crates/markkit/tests/html_security.rs
@@ -204,3 +204,32 @@ fn depth_limit_exceeded() {
md
);
}
+
+// ─── Table spans (T2: span amplification) ────────────────────────────
+
+#[test]
+fn hostile_colspan_rowspan_bounded() {
+ // Huge spans on many rows must not blow up the layout grid.
+ let mut html = String::from("");
+ for _ in 0..5_000 {
+ html.push_str(r#"a b "#);
+ }
+ html.push_str("
");
+ let start = std::time::Instant::now();
+ let md = convert(&html);
+ assert!(start.elapsed().as_secs() < 10);
+ // Output stays proportional to input (spans dropped past the grid budget).
+ assert!(md.len() < html.len() * 4, "output {} bytes", md.len());
+}
+
+#[test]
+fn non_numeric_spans_ignored() {
+ let md = convert(r#""#);
+ assert!(md.contains("| a | b |"), "{md}");
+}
+
+#[test]
+fn deep_definition_lists_bounded() {
+ let html = "".repeat(500) + "x" + &" ".repeat(500);
+ let _ = convert(&html);
+}
diff --git a/knowledge/formats/html-extraction.md b/knowledge/formats/html-extraction.md
index c1bcc1a..9588f57 100644
--- a/knowledge/formats/html-extraction.md
+++ b/knowledge/formats/html-extraction.md
@@ -34,7 +34,11 @@ HTML from the web: malformed markup, complex layouts, embedded scripts, etc.
| `/` | `- item` / `1. item` |
| `` | `> text` |
| `` | Fenced code block |
-| `` | GFM table |
+| `` | GFM table (alignment, spans laid out) |
+| `` | `Term` / `: definition` |
+| ` ` | `- [x] item` |
+| `` + `` | content + `*caption*` |
+| footnote refs / sections | `[^1]` / `[^1]: body` |
| `/` | `*text*` |
| `/` | `**text**` |
| `/` | `~~text~~` |
@@ -49,12 +53,15 @@ HTML from the web: malformed markup, complex layouts, embedded scripts, etc.
| ` ` | `Paragraph { children }` | Empty paragraph -> elided. Whitespace-only -> elided. |
| `
` | `List { ordered: false }` | Only `` direct children collected. Empty list -> elided. |
| `` | `List { ordered: true, start }` | `start` attr parsed as `u64`. Missing/invalid -> `None` (defaults to 1 in renderer). |
-| `` | `ListItem { children }` | Can contain mixed block+inline. Empty items skipped. |
+| ` ` | `ListItem { children, checked }` | Can contain mixed block+inline. Empty items skipped. A leading ` ` (directly or in the first ``/``) sets `checked` from the `checked` attribute. |
| `` | `BlockQuote { children }` | Nested blockquotes supported. Empty -> elided. |
| `` | `CodeBlock { language, code }` | If contains ``, language extracted from `class`. Otherwise raw text. |
| `` | `CodeBlock { language, code }` | Language from `language-*` or `lang-*` class prefix. |
| `` (not in pre) | `InlineCode(String)` | Inline context only. |
-| `` | `Table { headers, rows }` | `` or first `` row becomes headers. Empty table -> elided. |
+| `` | `Table { alignments, headers, rows }` | First `` row, or a leading all-`` row, becomes headers. Cells are direct ` `/` ` children of a row (row-header ` ` kept). `colspan` clamped 1-1000, `rowspan` 1-65534 (`0` = 65534). Alignment per grid column from header `style="text-align:…"` or `align`. Empty table -> elided. |
+| `` | `DefinitionList { items }` | `` starts an item (consecutive ``s join with a line break); each ` ` adds a definition. `` wrappers walked. Empty -> elided. |
+| `
` | `Figure { children, caption }` | Only when a direct non-empty `` exists; otherwise transparent. Inline-only content wrapped in `Paragraph`. |
+| footnote section | `FootnoteDefinition { label, children }` per `` | `section`/`div`/`aside`/`ol` with `data-footnotes`, `role=doc-endnotes|doc-footnotes`, or class `footnotes` (GitHub, Pandoc). Label from the matching reference (by ` `), else 1-based position. Headings/` ` in the section dropped. |
| ` ` | `ThematicBreak` | Always rendered as `---`. |
| ` ` | `LineBreak` | Renders as two trailing spaces + newline. |
@@ -67,6 +74,7 @@ HTML from the web: malformed markup, complex layouts, embedded scripts, etc.
| ``, ``, `` | `Strikethrough { children }` | Empty -> elided. |
| `` | `Link { url, title, children }` | When `preserve_links=false`, unwrapped to children. Empty -> elided. Missing href -> empty string. |
| ` ` | `Image { src, alt }` | When `preserve_images=false`, elided entirely. Missing attrs -> empty strings. |
+| footnote ` ` | `FootnoteReference { label }` | ` ` with `data-footnote-ref`, `role=doc-noteref`, or class `footnote-ref`. Label = link text without `[]` (else target id). Back-links (`data-footnote-backref`, `role=doc-backlink`, `.footnote-back[ref]`) dropped. |
### Transparent Container Elements
@@ -80,7 +88,7 @@ as if the container were not present.
| `` | Same as div. |
| `` | Same as div. |
| `` | Same as div. |
-| `` | Same as div. |
+| `` | Same as div when it has no `` (see `Figure` above). |
| `` | Same as div. |
### Non-Content Elements (Always Stripped)
diff --git a/knowledge/foundations/ir.md b/knowledge/foundations/ir.md
index f861201..433a98f 100644
--- a/knowledge/foundations/ir.md
+++ b/knowledge/foundations/ir.md
@@ -21,15 +21,31 @@ Pipeline: **parse -> normalize -> render**
### Document
-Top-level container. Holds ordered `Vec`.
+Top-level container.
```rust
+#[non_exhaustive]
struct Document {
+ ir_version: u32, // IR_VERSION (2) when built by markkit; 1 if absent in JSON
nodes: Vec,
}
```
-Methods: `new(Vec)`, `is_empty() -> bool`
+Methods: `new(Vec)` (sets `ir_version = IR_VERSION`), `is_empty() -> bool`.
+
+### Versioning and extensibility
+
+- `markkit::ir::IR_VERSION` is the serialized schema version. v1 was the
+ initial, unversioned IR. v2 adds footnotes, definition lists, task lists,
+ figures and table cells with alignment and spans. Bump it on any breaking
+ change to serialized form.
+- `Node`, `ListItem`, `TableCell`, `DefinitionItem`, `Alignment` and
+ `Document` are `#[non_exhaustive]`: new kinds/fields are additive; external
+ matches need a `_` arm and helper structs are built via constructors
+ (`ListItem::new` / `::task`, `TableCell::new(..).with_span(c, r)`,
+ `DefinitionItem::new`).
+- `Node::is_block()` / `Node::is_inline()` classify nodes; parsers and the
+ renderer use them instead of local lists.
### Node
@@ -44,8 +60,11 @@ Tagged enum. Variants are block-level or inline.
| `List` | `ordered: bool`, `start: Option`, `items: Vec` | Ordered or unordered list. `start` only meaningful when `ordered=true`. |
| `BlockQuote` | `children: Vec` | Nested block content. Children can be any node type. |
| `CodeBlock` | `language: Option`, `code: String` | Fenced code block. Language extracted from `language-*` or `lang-*` CSS class. |
-| `Table` | `headers: Vec>`, `rows: Vec>>` | GFM table. Each cell is `Vec` (inline content). Headers may be empty. |
+| `Table` | `alignments: Vec`, `headers: Vec`, `rows: Vec>` | GFM table. Cells hold inline content plus `colspan`/`rowspan` (≥1). `alignments` is per grid column; missing = `None`. Headers may be empty. |
| `ThematicBreak` | (unit) | Horizontal rule. Renders as `---`. |
+| `DefinitionList` | `items: Vec` | Terms with one or more definitions. |
+| `Figure` | `children: Vec`, `caption: Option>` | Block content with inline caption. |
+| `FootnoteDefinition` | `label: String`, `children: Vec` | Footnote body (blocks). |
#### Inline Variants
@@ -59,17 +78,16 @@ Tagged enum. Variants are block-level or inline.
| `Link` | `url: String`, `title: Option`, `children: Vec` | Hyperlink. Children are link text (inline). |
| `Image` | `src: String`, `alt: String` | Image reference. Leaf node (no children). |
| `LineBreak` | (unit) | Hard line break. Renders as two trailing spaces + newline. |
+| `FootnoteReference` | `label: String` | Marker pointing at the `FootnoteDefinition` with the same label. |
-### ListItem
-
-```rust
-struct ListItem {
- children: Vec,
-}
-```
+### Helper structs
-Children can be mixed block/inline. If all inline, rendered on single line.
-If contains block nodes, sub-blocks are indented.
+| Type | Fields | Notes |
+|------|--------|-------|
+| `ListItem` | `children: Vec`, `checked: Option` | `checked` set for task-list items. Children mixed block/inline; all-inline renders on one line, blocks are indented. |
+| `TableCell` | `children: Vec`, `colspan: u32`, `rowspan: u32` | Spans default 1, stored as authored (parsers clamp). |
+| `DefinitionItem` | `term: Vec`, `definitions: Vec>` | Term is inline; each definition is a block list. |
+| `Alignment` | `None`, `Left`, `Center`, `Right` | Table column alignment. |
## Pipeline Stages
@@ -101,7 +119,16 @@ At render time, the `normalize_output` function:
- Inline content concatenated directly
- Lists indented with 2-space increments per depth level
- BlockQuote lines prefixed with `> `
-- Tables rendered as GFM pipe tables with `---` separator
+- Tables rendered as GFM pipe tables; separator carries alignment
+ (`:---`, `:---:`, `---:`). Spans are laid out on a grid: content in the
+ top-left slot, covered slots empty. Layout is capped at
+ `MAX_TABLE_GRID_CELLS` (1M); over budget, spans are ignored (threat T2e)
+- Task items render `- [x] ` / `- [ ] `
+- Footnotes: `[^label]` and `[^label]: body` (continuation indented 4);
+ labels sanitized to `[A-Za-z0-9_.-]` (max 64 chars, `note` if empty)
+- Definition lists: term line, then `: definition` per definition
+ (continuation indented 4), items separated by blank lines
+- Figures: children, then the caption as an italic paragraph
## IR Invariants
@@ -114,13 +141,18 @@ At render time, the `normalize_output` function:
| Depth bounded at 200 | `MAX_DEPTH` constant; at limit, subtree flattened to text |
| No executable content in IR | Script, style, iframe, etc. stripped by parser |
| `List.start` is `None` when `ordered=false` | Parser only reads `start` attr for `` |
+| Table spans ≥ 1 | `TableCell::with_span` clamps 0 to 1; HTML parser clamps to 1000 / 65534 |
+| Figure / footnote children are blocks | HTML parser wraps all-inline content in `Paragraph` |
## Serialization
-JSON AST via serde. Tagged enum representation (default serde).
+JSON AST via serde. Tagged enum representation (default serde). Optional
+and default-valued fields (`alignments`, `colspan`/`rowspan` = 1, `checked`,
+`caption`) are omitted when unset.
```json
{
+ "ir_version": 2,
"nodes": [
{
"Heading": {
diff --git a/knowledge/log.md b/knowledge/log.md
index 1b2ff07..ea6e8bb 100644
--- a/knowledge/log.md
+++ b/knowledge/log.md
@@ -2,6 +2,8 @@
## 2026-10-26
+* **IR v2**: footnotes, definition lists, task lists, figures, table alignment and spans; `Document.ir_version` (`IR_VERSION = 2`), `#[non_exhaustive]` IR types, `Node::is_block`/`is_inline`. HTML maps GitHub/Pandoc footnotes, ``, checkbox items, captioned figures, `colspan`/`rowspan`/alignment and row-header ``. New threat T2e and invariant S13 (span grid budget, footnote label sanitizing). See [IR Specification](foundations/ir.md), [HTML Extraction](formats/html-extraction.md), [Threat Model](security/threat-model.md).
+
* **URL fetch hardening**: T3 SSRF is mitigated. `extract_url` blocks non-public addresses by default (URL, resolver and redirect layers), enforces scheme, allowlist, redirect, body and time limits; new invariant S12 and `url_security` tests. Proxy-resolved names are the documented residual. See [Threat Model](security/threat-model.md) and [Security Model](security/security-model.md).
* **Dependency trim**: unused dependencies removed (lockfile shrinks by ~460 lines; the `instant`/`paste` unmaintained advisories and their `deny.toml` ignores are gone with the stub bench deps). quick-xml moves to 0.42, one version shared with calamine; markkit's XML walkers now read `&str` names and values. See [Threat Model](security/threat-model.md) T6d and [Maintenance](operations/maintenance.md).
* **Inter-block whitespace**: indented HTML lost blank lines between blocks and leaked a leading space into every following line, because whitespace-only text between blocks stayed in the block list as `Text(" ")`. The parser now normalizes block-context lists; golden fixtures, which had recorded the broken output, are regenerated. See [HTML Extraction](formats/html-extraction.md) and [IR Specification](foundations/ir.md).
diff --git a/knowledge/security/security-model.md b/knowledge/security/security-model.md
index a5086a6..7268562 100644
--- a/knowledge/security/security-model.md
+++ b/knowledge/security/security-model.md
@@ -22,6 +22,7 @@ Markkit processes untrusted input from the internet. Key threats:
- **Mitigation**: Processing time limits
- **Mitigation**: Output size limits
- **Mitigation**: Nesting depth limits
+- **Mitigation**: Table span layout bounded by a grid budget (spans dropped past it); see [Threat Model](threat-model.md) T2e
### TM-SSRF: Server-side request forgery (URL fetching)
- **Mitigation**: Non-public addresses refused by default, after DNS resolution and on every redirect (opt in with `allow_private_networks`)
diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md
index 49d78f1..d97296e 100644
--- a/knowledge/security/threat-model.md
+++ b/knowledge/security/threat-model.md
@@ -73,6 +73,12 @@ tags:
- `max_processing_secs` configurable (default: 60s)
- Tested with 1000 paragraph elements
+#### T2e: Table Span Amplification
+- `colspan`/`rowspan` clamped by the HTML parser (1000 / 65534) and again at layout
+- Renderer lays spans out on a grid capped at 1 000 000 slots, checked incrementally
+ before allocating; a table over budget is laid out ignoring spans (one slot per cell)
+- Tested with 5 000 rows of `colspan=999999999 rowspan=0`
+
#### T2d: Billion Laughs / Entity Expansion
- html5ever handles entity expansion internally
- No custom entity processing
@@ -84,6 +90,8 @@ tags:
- `html_security::many_elements`
- `html_security::resource_limit_bytes`
- `html_integration::resource_limit_enforced`
+- `html_security::hostile_colspan_rowspan_bounded`
+- `html_security::deep_definition_lists_bounded`
- `html_proptest::resource_limit_enforced`
### T3: Server-Side Request Forgery (SSRF)
@@ -240,6 +248,7 @@ process, or smuggling content past a human reviewer.
| Redirects (`extract_url`) | 5 | `max_redirects(usize)` | Every redirect hop |
| Fetched body (`extract_url`) | = input size | `max_input_size(usize)` | `Content-Length` and while streaming |
| Nesting depth | 200 | Hardcoded `MAX_DEPTH` | During HTML tree walk |
+| Table span grid | 1 000 000 slots | Hardcoded `MAX_TABLE_GRID_CELLS` | Renderer table layout (spans dropped past it) |
| XML nesting depth (DOCX) | 256 | Hardcoded `MAX_XML_DEPTH` | While building the XML tree |
| ZIP entries | 10 000 | `max_archive_entries(usize)` | On archive open |
| ZIP entry size (decompressed) | 64 MiB | `max_archive_entry_size(u64)` | While decompressing |
@@ -285,6 +294,7 @@ process, or smuggling content past a human reviewer.
| S10 | Spreadsheet parsing never allocates a grid beyond `max_spreadsheet_cells` | `document_security::xls_*_rejected`, `ods_repeated_rows_and_columns_rejected` |
| S11 | Unsupported formats fail explicitly (no empty success) | `document_detection::pdf_is_explicitly_unsupported_not_empty` |
| S12 | Default `extract_url` never connects to a non-public address | `url_security::*_blocked*`, `fetch::tests::non_public_addresses_forbidden` |
+| S13 | Table spans never grow the render grid past `MAX_TABLE_GRID_CELLS`; footnote labels cannot leave `[^...]` | `html_security::hostile_colspan_rowspan_bounded`, `html_integration::footnote_label_cannot_break_out` |
## See also