use crate::{
error::{RapidOcrError, Result},
table::decode::{CellSlot, GridSize, StructureNode, StructureResult},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextPolicy {
Raw,
Escape,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StructureConfidence {
High,
Low,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StructureDegradation {
GridHoles {
holes: usize,
},
Truncated,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RenderedTable {
pub rendered: String,
pub confidence: StructureConfidence,
pub degradations: Vec<StructureDegradation>,
}
pub fn emit_html(
result: &StructureResult,
cell_texts: &[String],
policy: TextPolicy,
) -> Result<RenderedTable> {
validate_cells(result, cell_texts.len())?;
let mut out = String::with_capacity(64 + cell_texts.iter().map(String::len).sum::<usize>());
out.push_str("<html><body>");
emit_node(&result.root, result, cell_texts, policy, &mut out)?;
out.push_str("</body></html>");
Ok(rendered(result, out))
}
pub fn emit_table_html(
result: &StructureResult,
cell_texts: &[String],
policy: TextPolicy,
) -> Result<RenderedTable> {
validate_cells(result, cell_texts.len())?;
let mut out = String::with_capacity(cell_texts.iter().map(String::len).sum::<usize>() + 64);
emit_node(&result.root, result, cell_texts, policy, &mut out)?;
Ok(rendered(result, out))
}
const MARKDOWN_SPAN_NOTE: &str = "<!-- 表格含跨行/跨列,Markdown 无法表达;已按逻辑网格展开,跨过的位置为空位。无损表示见 HTML 字段。 -->";
pub fn emit_markdown(result: &StructureResult, cell_texts: &[String]) -> Result<RenderedTable> {
validate_cells(result, cell_texts.len())?;
let grid = result.grid;
if grid.rows == 0 || grid.cols == 0 {
return Ok(rendered(result, String::new()));
}
let mut matrix = vec![vec![String::new(); grid.cols]; grid.rows];
for cell in &result.cells {
let text = text_for(cell.cell_index, cell_texts)?;
matrix[cell.logic.row_start][cell.logic.col_start] = escape_markdown_cell(text);
}
let has_span = result
.cells
.iter()
.any(|cell| cell.rowspan > 1 || cell.colspan > 1);
let mut out = String::new();
if has_span {
out.push_str(MARKDOWN_SPAN_NOTE);
out.push('\n');
}
for (row_index, row) in matrix.iter().enumerate() {
out.push('|');
for value in row {
out.push(' ');
out.push_str(value);
out.push_str(" |");
}
out.push('\n');
if row_index == 0 {
out.push('|');
for _ in row {
out.push_str(" --- |");
}
out.push('\n');
}
}
if has_span {
out.push_str(MARKDOWN_SPAN_NOTE);
out.push('\n');
}
Ok(rendered(result, out))
}
pub fn structure_degradations(result: &StructureResult) -> Vec<StructureDegradation> {
let mut out = Vec::new();
if !result.is_grid_complete() {
out.push(StructureDegradation::GridHoles {
holes: result.coverage.holes(),
});
}
if result.is_truncated() {
out.push(StructureDegradation::Truncated);
}
out
}
fn rendered(result: &StructureResult, rendered: String) -> RenderedTable {
let degradations = structure_degradations(result);
let confidence = if degradations.is_empty() {
StructureConfidence::High
} else {
StructureConfidence::Low
};
RenderedTable {
rendered,
confidence,
degradations,
}
}
fn emit_node(
node: &StructureNode,
result: &StructureResult,
cell_texts: &[String],
policy: TextPolicy,
out: &mut String,
) -> Result<()> {
if let Some(index) = node.cell_index {
let cell = result.cells.get(index).ok_or_else(|| {
RapidOcrError::Decode(format!(
"table structure node refers to cell {index}, but the result only has {} cell(s)",
result.cells.len()
))
})?;
check_node_matches_cell(node, cell)?;
out.push_str(&cell_open_tag(cell, result.grid)?);
out.push_str(&encode_text(text_for(index, cell_texts)?, policy));
out.push_str("</td>");
return Ok(());
}
let closing = closing_tag(&node.tag)?;
out.push_str(&node.tag);
for child in &node.children {
emit_node(child, result, cell_texts, policy, out)?;
}
out.push_str(&closing);
Ok(())
}
fn cell_open_tag(cell: &CellSlot, grid: GridSize) -> Result<String> {
if cell.rowspan == 0 || cell.colspan == 0 {
return Err(RapidOcrError::Decode(format!(
"table cell {} declares rowspan {} / colspan {}; spans are positive integers",
cell.cell_index, cell.rowspan, cell.colspan
)));
}
if cell.logic.row_start + cell.rowspan > grid.rows
|| cell.logic.col_start + cell.colspan > grid.cols
{
return Err(RapidOcrError::Decode(format!(
"table cell {} spans {}x{} from row {} / column {}, which runs past the {}x{} logical \
grid",
cell.cell_index,
cell.rowspan,
cell.colspan,
cell.logic.row_start,
cell.logic.col_start,
grid.rows,
grid.cols
)));
}
let mut tag = String::from("<td");
if cell.colspan > 1 {
tag.push_str(&format!(" colspan=\"{}\"", cell.colspan));
}
if cell.rowspan > 1 {
tag.push_str(&format!(" rowspan=\"{}\"", cell.rowspan));
}
tag.push('>');
Ok(tag)
}
fn check_node_matches_cell(node: &StructureNode, cell: &CellSlot) -> Result<()> {
let agrees = |declared: Option<usize>, resolved: usize| match declared {
Some(value) => value == resolved,
None => resolved <= 1,
};
if !agrees(node.rowspan, cell.rowspan) || !agrees(node.colspan, cell.colspan) {
return Err(RapidOcrError::Decode(format!(
"table structure cell {} has rowspan {:?} / colspan {:?} in the tree but {} / {} in \
the cell table; the tree and the cell table disagree",
cell.cell_index, node.rowspan, node.colspan, cell.rowspan, cell.colspan
)));
}
Ok(())
}
fn validate_cells(result: &StructureResult, text_count: usize) -> Result<()> {
if text_count != result.cells.len() {
return Err(RapidOcrError::Decode(format!(
"table structure has {} cell(s) but {} text(s) were supplied; the emitter cannot \
guess which cell a text belongs to",
result.cells.len(),
text_count
)));
}
let grid = result.grid;
let mut claimed = vec![false; grid.cell_count()];
for cell in &result.cells {
cell_open_tag(cell, grid)?;
let start = cell.logic.row_start * grid.cols + cell.logic.col_start;
if claimed[start] {
return Err(RapidOcrError::Decode(format!(
"table cell {} starts at row {} / column {}, where another cell already starts; \
the logical grid cannot place both",
cell.cell_index, cell.logic.row_start, cell.logic.col_start
)));
}
claimed[start] = true;
}
Ok(())
}
fn text_for(index: usize, cell_texts: &[String]) -> Result<&str> {
cell_texts.get(index).map(String::as_str).ok_or_else(|| {
RapidOcrError::Decode(format!(
"table structure cell {index} is outside the supplied text slice (len {})",
cell_texts.len()
))
})
}
fn encode_text(text: &str, policy: TextPolicy) -> String {
match policy {
TextPolicy::Raw => text.to_string(),
TextPolicy::Escape => crate::output::html::escape_html(text).replace('\n', "<br>"),
}
}
fn escape_markdown_cell(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'|' => out.push_str("\\|"),
'\n' => out.push_str("<br>"),
'\r' => {}
_ => out.push(ch),
}
}
out
}
fn closing_tag(tag: &str) -> Result<String> {
let inner = tag
.strip_prefix('<')
.and_then(|rest| rest.strip_suffix('>'))
.ok_or_else(|| {
RapidOcrError::Decode(format!(
"table structure container tag `{tag}` is not of the form `<name>`"
))
})?;
let name = inner.split_whitespace().next().unwrap_or(inner);
if name.is_empty() || name.starts_with('/') {
return Err(RapidOcrError::Decode(format!(
"table structure container tag `{tag}` has no element name"
)));
}
Ok(format!("</{name}>"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::table::decode::{GridCoverage, GridSize, LogicCell, StructureTermination};
fn container(tag: &str, children: Vec<StructureNode>) -> StructureNode {
StructureNode {
tag: tag.to_string(),
rowspan: None,
colspan: None,
cell_index: None,
bbox: None,
children,
}
}
fn cell_tag(rowspan: usize, colspan: usize) -> String {
let mut tag = String::from("<td");
if colspan > 1 {
tag.push_str(&format!(" colspan=\"{colspan}\""));
}
if rowspan > 1 {
tag.push_str(&format!(" rowspan=\"{rowspan}\""));
}
tag.push('>');
tag
}
fn spanned(
index: usize,
row: usize,
col: usize,
rowspan: usize,
colspan: usize,
) -> (StructureNode, CellSlot) {
let node = StructureNode {
tag: cell_tag(rowspan, colspan),
rowspan: (rowspan > 1).then_some(rowspan),
colspan: (colspan > 1).then_some(colspan),
cell_index: Some(index),
bbox: None,
children: Vec::new(),
};
let cell = CellSlot {
cell_index: index,
tag: cell_tag(rowspan, colspan),
rowspan,
colspan,
score: 1.0,
bbox: None,
logic: LogicCell {
row_start: row,
row_end: row + rowspan - 1,
col_start: col,
col_end: col + colspan - 1,
},
};
(node, cell)
}
fn sample() -> StructureResult {
let (n0, c0) = spanned(0, 0, 0, 1, 1);
let (n1, c1) = spanned(1, 0, 1, 1, 1);
let (n2, c2) = spanned(2, 1, 0, 1, 1);
let (n3, c3) = spanned(3, 1, 1, 2, 1);
let (n4, c4) = spanned(4, 2, 0, 1, 1);
StructureResult {
root: container(
"<table>",
vec![
container("<thead>", vec![container("<tr>", vec![n0, n1])]),
container(
"<tbody>",
vec![container("<tr>", vec![n2, n3]), container("<tr>", vec![n4])],
),
],
),
cells: vec![c0, c1, c2, c3, c4],
grid: GridSize { rows: 3, cols: 2 },
coverage: GridCoverage {
cells: 6,
covered: 6,
},
termination: StructureTermination::Eos,
confidence: 0.9,
}
}
fn five_texts(values: [&str; 5]) -> Vec<String> {
values.map(str::to_string).to_vec()
}
fn four_texts(values: [&str; 4]) -> Vec<String> {
values.map(str::to_string).to_vec()
}
fn flat_two_by_two() -> StructureResult {
let (n0, c0) = spanned(0, 0, 0, 1, 1);
let (n1, c1) = spanned(1, 0, 1, 1, 1);
let (n2, c2) = spanned(2, 1, 0, 1, 1);
let (n3, c3) = spanned(3, 1, 1, 1, 1);
StructureResult {
root: container(
"<table>",
vec![
container("<tr>", vec![n0, n1]),
container("<tr>", vec![n2, n3]),
],
),
cells: vec![c0, c1, c2, c3],
grid: GridSize { rows: 2, cols: 2 },
coverage: GridCoverage {
cells: 4,
covered: 4,
},
termination: StructureTermination::Eos,
confidence: 1.0,
}
}
fn blanks() -> Vec<String> {
five_texts(["", "", "", "", ""])
}
fn raw(result: &StructureResult, texts: &[String]) -> RenderedTable {
emit_html(result, texts, TextPolicy::Raw).expect("must emit")
}
#[test]
fn an_empty_cell_round_trips_to_the_self_closing_form() {
let out = raw(&sample(), &blanks());
assert_eq!(
out.rendered,
"<html><body><table><thead><tr><td></td><td></td></tr></thead>\
<tbody><tr><td></td><td rowspan=\"2\"></td></tr><tr><td></td></tr></tbody>\
</table></body></html>"
);
}
#[test]
fn cell_texts_land_in_their_own_cells() {
let out = raw(&sample(), &five_texts(["a", "b", "c", "d", "e"]));
assert!(out.rendered.contains("<td>a</td>"));
assert!(out.rendered.contains("<td>b</td>"));
assert!(out.rendered.contains("<td>c</td>"));
assert!(out.rendered.contains("<td rowspan=\"2\">d</td>"));
assert!(out.rendered.contains("<td>e</td>"));
}
#[test]
fn a_missing_text_slice_is_rejected_rather_than_padded() {
let error = emit_html(&sample(), &[], TextPolicy::Raw).expect_err("must reject");
assert!(
error.to_string().contains("5 cell(s) but 0 text(s)"),
"{error}"
);
}
#[test]
fn inline_markup_in_cell_text_is_not_escaped_under_the_raw_policy() {
let out = raw(&sample(), &five_texts(["SiO<sub>2</sub>", "", "", "", ""]));
assert!(
out.rendered.contains("<td>SiO<sub>2</sub></td>"),
"the raw policy must preserve markup so TEDS stays comparable: {}",
out.rendered
);
}
#[test]
fn the_escape_policy_neutralizes_markup_in_cell_text() {
let out = emit_html(
&sample(),
&five_texts(["<script>alert(1)</script>", "", "", "", ""]),
TextPolicy::Escape,
)
.expect("must emit");
assert!(
out.rendered
.contains("<td><script>alert(1)</script></td>"),
"the escape policy must neutralize injected markup: {}",
out.rendered
);
assert!(!out.rendered.contains("<script>"));
}
#[test]
fn the_escape_policy_covers_the_five_xml_entities() {
let out = emit_html(
&sample(),
&five_texts(["&<>\"'", "", "", "", ""]),
TextPolicy::Escape,
)
.expect("must emit");
assert!(
out.rendered.contains("<td>&<>"'</td>"),
"{}",
out.rendered
);
}
#[test]
fn section_tags_are_kept_because_the_ground_truth_has_them() {
let out = raw(&sample(), &blanks());
assert!(out.rendered.contains("<thead>"));
assert!(out.rendered.contains("<tbody>"));
}
#[test]
fn the_table_only_variant_drops_the_page_shell() {
let out = emit_table_html(&sample(), &blanks(), TextPolicy::Raw).expect("must emit");
assert!(out.rendered.starts_with("<table>"));
assert!(out.rendered.ends_with("</table>"));
assert!(!out.rendered.contains("<html>"));
}
#[test]
fn a_malformed_container_tag_is_rejected() {
assert!(closing_tag("<tr").is_err());
assert!(closing_tag("<>").is_err());
}
#[test]
fn closing_tag_ignores_attributes() {
assert_eq!(closing_tag("<tr class=\"x\">").unwrap(), "</tr>");
assert_eq!(closing_tag("<tr>").unwrap(), "</tr>");
}
#[test]
fn a_cell_index_outside_the_cell_table_is_rejected() {
let mut result = sample();
result.root = container("<table>", vec![container("<tr>", vec![cell_node(99)])]);
let error =
emit_html(&result, &blanks(), TextPolicy::Raw).expect_err("cell 99 does not exist");
assert!(error.to_string().contains("cell 99"), "{error}");
}
fn cell_node(index: usize) -> StructureNode {
StructureNode {
tag: "<td>".to_string(),
rowspan: None,
colspan: None,
cell_index: Some(index),
bbox: None,
children: Vec::new(),
}
}
#[test]
fn both_attributes_are_emitted_with_colspan_first() {
let (node, cell) = spanned(0, 0, 0, 2, 3);
let result = StructureResult {
root: container("<table>", vec![container("<tr>", vec![node])]),
cells: vec![cell],
grid: GridSize { rows: 2, cols: 3 },
coverage: GridCoverage {
cells: 6,
covered: 6,
},
termination: StructureTermination::Eos,
confidence: 1.0,
};
let out = emit_table_html(&result, &[String::new()], TextPolicy::Raw).expect("must emit");
assert_eq!(
out.rendered,
"<table><tr><td colspan=\"3\" rowspan=\"2\"></td></tr></table>"
);
}
#[test]
fn a_tree_that_disagrees_with_the_cell_table_is_rejected() {
let mut result = sample();
result.cells[1].colspan = 2;
result.cells[1].logic.col_end = 2;
result.grid.cols = 3;
result.coverage = GridCoverage {
cells: 9,
covered: 7,
};
let error = emit_html(&result, &blanks(), TextPolicy::Raw)
.expect_err("the tree and the cell table disagree");
assert!(error.to_string().contains("disagree"), "{error}");
}
#[test]
fn a_span_that_runs_past_the_grid_is_rejected() {
let mut result = sample();
result.cells[3].rowspan = 99;
let error = emit_html(&result, &blanks(), TextPolicy::Raw)
.expect_err("a span past the grid must not be emitted");
assert!(error.to_string().contains("runs past"), "{error}");
}
#[test]
fn a_zero_span_is_rejected() {
let mut result = sample();
result.cells[0].colspan = 0;
let error = emit_html(&result, &blanks(), TextPolicy::Raw).expect_err("spans are 1-based");
assert!(error.to_string().contains("positive integers"), "{error}");
}
#[test]
fn a_complete_grid_is_high_confidence() {
let out = raw(&sample(), &blanks());
assert_eq!(out.confidence, StructureConfidence::High);
assert!(out.degradations.is_empty());
}
#[test]
fn grid_holes_lower_the_confidence_instead_of_emitting_a_plausible_table() {
let mut result = sample();
result.coverage = GridCoverage {
cells: 6,
covered: 5,
};
let out = raw(&result, &blanks());
assert_eq!(out.confidence, StructureConfidence::Low);
assert_eq!(
out.degradations,
vec![StructureDegradation::GridHoles { holes: 1 }]
);
assert!(out.rendered.contains("<table>"));
}
#[test]
fn truncation_lowers_the_confidence_even_when_the_grid_is_full() {
let mut result = sample();
result.termination = StructureTermination::Exhausted;
let out = raw(&result, &blanks());
assert_eq!(out.confidence, StructureConfidence::Low);
assert_eq!(out.degradations, vec![StructureDegradation::Truncated]);
}
#[test]
fn both_degradations_are_reported_together() {
let mut result = sample();
result.coverage = GridCoverage {
cells: 6,
covered: 4,
};
result.termination = StructureTermination::Exhausted;
let out = raw(&result, &blanks());
assert_eq!(
out.degradations,
vec![
StructureDegradation::GridHoles { holes: 2 },
StructureDegradation::Truncated
]
);
}
#[test]
fn an_empty_result_is_high_confidence_rather_than_degraded() {
let mut result = sample();
result.cells.clear();
result.root = container("<table>", Vec::new());
result.grid = GridSize { rows: 0, cols: 0 };
result.coverage = GridCoverage {
cells: 0,
covered: 0,
};
let out = raw(&result, &[]);
assert_eq!(out.confidence, StructureConfidence::High);
assert!(out.degradations.is_empty());
}
#[test]
fn markdown_places_cells_into_the_logical_grid() {
let out = emit_markdown(&flat_two_by_two(), &four_texts(["a", "b", "c", "d"]))
.expect("must emit");
assert_eq!(out.rendered, "| a | b |\n| --- | --- |\n| c | d |\n");
}
#[test]
fn markdown_warns_explicitly_that_spans_are_lost() {
let out =
emit_markdown(&sample(), &five_texts(["a", "b", "c", "d", "e"])).expect("must emit");
assert_eq!(out.confidence, StructureConfidence::High);
assert!(
out.degradations.is_empty(),
"a spanned table can still be perfectly well-formed: {:?}",
out.degradations
);
let lines: Vec<&str> = out.rendered.lines().collect();
assert!(lines[0].starts_with("<!--"), "lines: {lines:?}");
assert!(lines[0].contains("跨行/跨列"), "lines: {lines:?}");
assert_eq!(
lines[lines.len() - 1],
lines[0],
"the note must close the table too, not only open it"
);
assert_eq!(lines[1], "| a | b |", "the note must not break the table");
}
#[test]
fn markdown_says_nothing_extra_when_nothing_is_lost() {
let out = emit_markdown(&flat_two_by_two(), &four_texts(["a", "b", "c", "d"]))
.expect("must emit");
assert!(!out.rendered.contains("<!--"), "{}", out.rendered);
assert_eq!(out.rendered.lines().count(), 3);
}
#[test]
fn markdown_leaves_spanned_over_positions_empty() {
let (n0, c0) = spanned(0, 0, 0, 2, 1);
let (n1, c1) = spanned(1, 0, 1, 1, 2);
let (n2, c2) = spanned(2, 1, 1, 1, 2);
let result = StructureResult {
root: container(
"<table>",
vec![container("<tr>", vec![n0, n1]), container("<tr>", vec![n2])],
),
cells: vec![c0, c1, c2],
grid: GridSize { rows: 2, cols: 3 },
coverage: GridCoverage {
cells: 6,
covered: 6,
},
termination: StructureTermination::Eos,
confidence: 1.0,
};
let texts = ["a", "b", "c"].map(str::to_string).to_vec();
let out = emit_markdown(&result, &texts).expect("must emit");
assert_eq!(
out.rendered,
format!(
"{note}\n| a | b | |\n| --- | --- | --- |\n| | c | |\n{note}\n",
note = super::MARKDOWN_SPAN_NOTE
)
);
}
#[test]
fn markdown_escapes_pipes_and_newlines_so_the_table_keeps_its_shape() {
let out = emit_markdown(
&flat_two_by_two(),
&four_texts(["a|b", "line\nbreak", "back\\slash", ""]),
)
.expect("must emit");
let first = out.rendered.lines().next().unwrap();
assert_eq!(first, "| a\\|b | line<br>break |");
assert!(out.rendered.contains("back\\\\slash"));
assert_eq!(out.rendered.lines().nth(1).unwrap(), "| --- | --- |");
}
#[test]
fn escaped_html_preserves_cell_line_breaks() {
let result = flat_two_by_two();
let rendered = emit_html(
&result,
&four_texts(["first\nsecond", "", "", ""]),
TextPolicy::Escape,
)
.expect("escaped HTML");
assert!(rendered.rendered.contains("first<br>second"));
}
#[test]
fn markdown_of_an_empty_grid_is_empty() {
let mut result = sample();
result.cells.clear();
result.grid = GridSize { rows: 0, cols: 0 };
result.coverage = GridCoverage {
cells: 0,
covered: 0,
};
let out = emit_markdown(&result, &[]).expect("must emit");
assert_eq!(out.rendered, "");
assert_eq!(out.confidence, StructureConfidence::High);
}
#[test]
fn markdown_rejects_a_text_count_that_does_not_match_the_cells() {
let error = emit_markdown(&sample(), &[]).expect_err("must reject");
assert!(
error.to_string().contains("5 cell(s) but 0 text(s)"),
"{error}"
);
}
#[test]
fn markdown_rejects_two_cells_claiming_the_same_start() {
let mut result = sample();
result.cells[4].logic = result.cells[2].logic;
let error = emit_markdown(&result, &blanks()).expect_err("two cells cannot share a start");
assert!(error.to_string().contains("already starts"), "{error}");
}
#[test]
fn html_and_markdown_agree_on_what_they_reject() {
let mut result = sample();
result.cells[0].colspan = 0;
let html_error = emit_html(&result, &blanks(), TextPolicy::Raw).unwrap_err();
let md_error = emit_markdown(&result, &blanks()).unwrap_err();
assert_eq!(html_error.to_string(), md_error.to_string());
}
}