use crate::{OcrOutput, Quad, TextOrder};
#[derive(Debug, Clone, Copy)]
struct BoxProps {
top: f32,
bottom: f32,
left: f32,
height: f32,
}
impl BoxProps {
fn merge(self, other: Self) -> Self {
let top = self.top.min(other.top);
let bottom = self.bottom.max(other.bottom);
Self {
top,
bottom,
left: self.left.min(other.left),
height: bottom - top,
}
}
}
fn consumed_by_tables(output: &OcrOutput) -> Vec<bool> {
let mut consumed = vec![false; output.regions.len()];
for region in &output.regions {
let Some(table) = region.table.as_ref() else {
continue;
};
for &id in &table.source_line_indices {
if let Some(slot) = consumed.get_mut(id) {
*slot = true;
}
}
}
consumed
}
fn table_markdown_blocks(output: &OcrOutput, order: TextOrder) -> Vec<String> {
output
.tables(order)
.into_iter()
.map(|(_, table)| table.markdown.clone())
.collect()
}
fn plain_text_without_consumed(output: &OcrOutput, consumed: &[bool]) -> String {
output
.reading_order()
.into_iter()
.filter(|id| !consumed.get(*id).copied().unwrap_or(false))
.filter_map(|id| output.regions.get(id)?.recognition.as_ref())
.map(|recognition| recognition.text.as_str())
.collect::<Vec<_>>()
.join("\n")
}
pub fn to_output_markdown(output: &OcrOutput) -> String {
let consumed = consumed_by_tables(output);
let polygonless_text = output
.regions
.iter()
.any(|region| region.recognition.is_some() && region.polygon.is_none());
if polygonless_text {
let mut sections: Vec<String> = Vec::new();
let text = plain_text_without_consumed(output, &consumed);
if !text.is_empty() {
sections.push(text);
}
for (_, latex) in output.formula_latex(TextOrder::Reading) {
let block = crate::formula::output::to_markdown_block(latex, false);
if !block.is_empty() {
sections.push(block);
}
}
for block in table_markdown_blocks(output, TextOrder::Reading) {
if !block.is_empty() {
sections.push(block);
}
}
return if sections.is_empty() {
"No text detected.".to_string()
} else {
sections.join("\n\n")
};
}
let mut blocks: Vec<String> = Vec::new();
let mut previous_props: Option<BoxProps> = None;
for group in output.reading_order_groups() {
let mut parts = Vec::new();
let mut group_props: Option<BoxProps> = None;
let mut group_has_block = false;
for id in group {
let Some(region) = output.regions.get(id) else {
continue;
};
let Some(polygon) = region.polygon else {
continue;
};
let props = get_box_properties(&polygon.points);
group_props = Some(match group_props {
Some(current) => current.merge(props),
None => props,
});
if let Some(table) = ®ion.table {
group_has_block = true;
parts.push(table.markdown.clone());
continue;
}
if let Some(formula) = ®ion.formula {
let block =
crate::formula::output::to_markdown_block(&formula.latex, formula.truncated);
group_has_block = true;
parts.push(block);
continue;
}
if consumed.get(id).copied().unwrap_or(false) {
continue;
}
let Some(recognition) = ®ion.recognition else {
continue;
};
parts.push(recognition.text.clone());
}
parts.retain(|part| !part.is_empty());
if parts.is_empty() {
continue;
}
if let (Some(previous), Some(current)) = (previous_props, group_props) {
let min_height = previous.height.min(current.height);
let vertical_gap = current.top - previous.bottom;
if vertical_gap > min_height * 0.7 {
blocks.push(String::new());
}
}
blocks.push(if group_has_block {
parts.join("\n")
} else {
parts.join(" ")
});
previous_props = group_props;
}
if blocks.is_empty() {
output.plain_text(TextOrder::Reading)
} else {
blocks.join("\n")
}
}
fn get_box_properties(box_: &Quad) -> BoxProps {
let mut top = f32::INFINITY;
let mut bottom = f32::NEG_INFINITY;
let mut left = f32::INFINITY;
for point in box_ {
top = top.min(point[1]);
bottom = bottom.max(point[1]);
left = left.min(point[0]);
}
let height = bottom - top;
BoxProps {
top,
bottom,
left,
height,
}
}
#[cfg(test)]
mod tests {
use crate::{
CoordinateSpace, EngineInfo, FormulaOutcome,
GenericProviderPreference as ProviderPreference, ImageInfo, ImageSize, InputTimings,
OcrOutput, OcrRegion, OcrTimings, Polygon, ProviderInfo, ProviderResolutionInfo,
RecognitionOutcome, RegionKind, RegionSource, ResolvedProvider, StageReport, StageReports,
StageState, TextOrder,
};
#[test]
fn markdown_empty_message() {
let output = output_with_regions(vec![OcrRegion::text(
RegionSource::Input,
None,
None,
None,
Some(RecognitionOutcome {
text: String::new(),
score: 0.0,
words: None,
}),
)]);
assert_eq!(super::to_output_markdown(&output), "No text detected.");
}
fn formula_region(id: usize, points: [[f32; 2]; 4], latex: &str, truncated: bool) -> OcrRegion {
OcrRegion {
source: RegionSource::Detected { detector_index: id },
kind: RegionKind::Formula,
polygon: Some(Polygon { points }),
detection: Some(crate::DetectionOutcome { score: 0.8 }),
classification: None,
recognition: None,
formula: Some(FormulaOutcome {
latex: latex.to_string(),
eos_index: if truncated { None } else { Some(1) },
truncated,
model_id: "pp_formulanet_plus_m".to_string(),
token_ids: None,
}),
table: None,
}
}
fn text_region(id: usize, points: [[f32; 2]; 4], text: &str) -> OcrRegion {
OcrRegion::text(
RegionSource::Detected { detector_index: id },
Some(Polygon { points }),
None,
None,
Some(RecognitionOutcome {
text: text.to_string(),
score: 0.99,
words: None,
}),
)
}
#[test]
fn formula_regions_render_as_display_math_in_reading_order() {
let output = output_with_regions(vec![
text_region(
0,
[[10.0, 10.0], [90.0, 10.0], [90.0, 30.0], [10.0, 30.0]],
"above",
),
formula_region(
1,
[[10.0, 40.0], [90.0, 40.0], [90.0, 70.0], [10.0, 70.0]],
"\\frac{a}{b}",
false,
),
text_region(
2,
[[10.0, 80.0], [90.0, 80.0], [90.0, 95.0], [10.0, 95.0]],
"below",
),
]);
let markdown = super::to_output_markdown(&output);
assert!(markdown.contains("above"), "markdown: {markdown}");
assert!(markdown.contains("$$"), "markdown: {markdown}");
assert!(markdown.contains("\\frac{a}{b}"), "markdown: {markdown}");
assert!(markdown.contains("below"), "markdown: {markdown}");
assert!(
markdown.find("$$").unwrap() > markdown.find("above").unwrap(),
"formula must keep reading order: {markdown}"
);
assert!(
markdown.find("below").unwrap() > markdown.find("$$").unwrap(),
"formula must keep reading order: {markdown}"
);
}
#[test]
fn truncated_formula_markdown_carries_the_invisible_marker() {
let output = output_with_regions(vec![formula_region(
0,
[[10.0, 10.0], [90.0, 10.0], [90.0, 40.0], [10.0, 40.0]],
"\\frac{1}{",
true,
)]);
let markdown = super::to_output_markdown(&output);
assert!(
markdown.contains(crate::formula::output::TRUNCATED_MARKER),
"markdown: {markdown}"
);
}
fn output_with_regions(regions: Vec<OcrRegion>) -> OcrOutput {
let resolution = ProviderResolutionInfo {
requested: ProviderPreference::Cpu,
selected_ep: ResolvedProvider::Cpu,
fallback_to_cpu: false,
};
OcrOutput {
schema_version: 1,
image: ImageInfo {
original_size: ImageSize {
width: 200,
height: 100,
},
processed_size: ImageSize {
width: 200,
height: 100,
},
coordinate_space: CoordinateSpace::Image,
},
stages: StageReports {
input: InputTimings::default(),
detector: StageReport {
state: StageState::Completed {
items: regions.len(),
},
timing: None,
},
classifier: StageReport {
state: StageState::Disabled,
timing: None,
},
recognizer: StageReport {
state: StageState::Completed {
items: regions.len(),
},
timing: None,
},
formula: StageReport::default(),
table: StageReport::default(),
},
regions,
timings: OcrTimings::default(),
engine: EngineInfo {
model_id: "test".to_string(),
provider: ProviderInfo {
detector: resolution,
classifier: None,
recognizer: resolution,
},
},
}
}
fn table_region(
id: usize,
points: [[f32; 2]; 4],
markdown: &str,
source_line_indices: Vec<usize>,
) -> OcrRegion {
OcrRegion {
source: RegionSource::Detected { detector_index: id },
kind: RegionKind::Table,
polygon: Some(Polygon { points }),
detection: Some(crate::DetectionOutcome { score: 0.7 }),
classification: None,
recognition: None,
formula: None,
table: Some(crate::TableOutcome {
html: "<html><body><table></table></body></html>".to_string(),
table_html: "<table></table>".to_string(),
markdown: markdown.to_string(),
rows: 1,
cols: 2,
confidence: crate::TableStructureConfidence::High,
degradations: Vec::new(),
holes: 0,
cells: Vec::new(),
source_line_indices,
model_id: "slanet-plus".to_string(),
}),
}
}
fn count_occurrences(haystack: &str, needle: &str) -> usize {
haystack.matches(needle).count()
}
fn page_with_a_consuming_table() -> OcrOutput {
output_with_regions(vec![
text_region(
0,
[[10.0, 10.0], [90.0, 10.0], [90.0, 30.0], [10.0, 30.0]],
"A",
),
text_region(
1,
[[10.0, 40.0], [90.0, 40.0], [90.0, 60.0], [10.0, 60.0]],
"B",
),
table_region(
2,
[[0.0, 0.0], [200.0, 0.0], [200.0, 100.0], [0.0, 100.0]],
"| h1 | h2 |\n| --- | --- |\n| A | B |",
vec![0, 1],
),
text_region(
3,
[[10.0, 150.0], [90.0, 150.0], [90.0, 170.0], [10.0, 170.0]],
"C",
),
])
}
#[test]
fn a_table_region_replaces_the_lines_it_consumed() {
let markdown = super::to_output_markdown(&page_with_a_consuming_table());
assert!(markdown.contains("| h1 | h2 |"), "markdown: {markdown}");
assert!(markdown.contains("| --- | --- |"), "markdown: {markdown}");
assert!(markdown.contains("| A | B |"), "markdown: {markdown}");
assert_eq!(count_occurrences(&markdown, "A"), 1, "markdown: {markdown}");
assert_eq!(count_occurrences(&markdown, "B"), 1, "markdown: {markdown}");
assert_eq!(count_occurrences(&markdown, "C"), 1, "markdown: {markdown}");
assert!(
markdown.lines().any(|line| line == "| A | B |"),
"the grid must keep its own line: {markdown}"
);
}
#[test]
fn plain_text_keeps_the_consumed_lines_and_ignores_the_table() {
let output = page_with_a_consuming_table();
let text = output.plain_text(TextOrder::Reading);
assert_eq!(text.lines().count(), 3, "plain_text: {text:?}");
for needle in ["A", "B", "C"] {
assert_eq!(count_occurrences(&text, needle), 1, "plain_text: {text:?}");
}
assert!(!text.contains('|'), "plain_text: {text:?}");
assert!(!text.contains("---"), "plain_text: {text:?}");
}
#[test]
fn the_span_note_survives_the_markdown_channel() {
let output = page_with_a_consuming_table();
let markdown = super::to_output_markdown(&output);
let _ = markdown;
let with_note = output_with_regions(vec![table_region(
0,
[[0.0, 0.0], [200.0, 0.0], [200.0, 100.0], [0.0, 100.0]],
"<!-- 表格含跨行/跨列 -->\n| a | |\n| --- | --- |\n| | c |\n<!-- 表格含跨行/跨列 -->",
vec![],
)]);
let markdown = super::to_output_markdown(&with_note);
assert!(
markdown.contains("<!-- 表格含跨行/跨列 -->"),
"markdown: {markdown}"
);
}
#[test]
fn a_page_without_tables_renders_exactly_as_before() {
let output = output_with_regions(vec![
text_region(
0,
[[10.0, 10.0], [60.0, 10.0], [60.0, 30.0], [10.0, 30.0]],
"L1",
),
text_region(
1,
[[10.0, 40.0], [60.0, 40.0], [60.0, 60.0], [10.0, 60.0]],
"L2",
),
]);
assert_eq!(super::to_output_markdown(&output), "L1\nL2");
}
#[derive(serde::Deserialize)]
struct RealLayoutFixture {
boxes: Vec<RealLayoutBox>,
expected: Vec<usize>,
}
#[derive(serde::Deserialize)]
struct RealLayoutBox {
x0: f32,
y0: f32,
x1: f32,
y1: f32,
text: String,
}
#[test]
fn output_markdown_uses_real_10_columns_order() {
let fixture: RealLayoutFixture = serde_json::from_str(include_str!(
"../../tests/fixtures/reading_order_10_columns.json"
))
.expect("real layout fixture should parse");
let regions = fixture
.boxes
.iter()
.enumerate()
.map(|(id, b)| {
text_region(
id,
[[b.x0, b.y0], [b.x1, b.y0], [b.x1, b.y1], [b.x0, b.y1]],
&b.text,
)
})
.collect();
let output = output_with_regions(regions);
let markdown = super::to_output_markdown(&output);
let lines: Vec<&str> = markdown
.lines()
.filter(|line| !line.trim().is_empty())
.collect();
let expected: Vec<&str> = fixture
.expected
.iter()
.map(|index| fixture.boxes[*index].text.as_str())
.collect();
assert_eq!(lines, expected);
}
#[test]
fn output_markdown_uses_reading_order_for_columns() {
let output = output_with_regions(vec![
text_region(
0,
[[10.0, 10.0], [60.0, 10.0], [60.0, 30.0], [10.0, 30.0]],
"L1",
),
text_region(
1,
[[10.0, 40.0], [60.0, 40.0], [60.0, 60.0], [10.0, 60.0]],
"L2",
),
text_region(
2,
[[100.0, 10.0], [150.0, 10.0], [150.0, 30.0], [100.0, 30.0]],
"R1",
),
text_region(
3,
[[100.0, 40.0], [150.0, 40.0], [150.0, 60.0], [100.0, 60.0]],
"R2",
),
]);
assert_eq!(super::to_output_markdown(&output), "L1\nL2\nR1\nR2");
}
}