use crate::blocks::{Block, BlockKind};
#[cfg(test)]
use crate::table_trace::TableOrigin;
use crate::text::TextSpan;
pub(crate) const INDENT: &str = " ";
pub(crate) fn number(value: f64) -> String {
if value.is_finite() {
format!("{value}")
} else {
"null".to_string()
}
}
pub(crate) fn string(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 2);
out.push('"');
for c in text.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\u{08}' => out.push_str("\\b"),
'\u{0c}' => out.push_str("\\f"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
fn kind_name(kind: &BlockKind) -> &'static str {
match kind {
BlockKind::Paragraph => "paragraph",
BlockKind::Heading { .. } => "heading",
BlockKind::ListItem { .. } => "listItem",
BlockKind::TableCell { .. } => "tableCell",
BlockKind::Header => "header",
BlockKind::Footer => "footer",
BlockKind::Caption => "caption",
BlockKind::Figure => "figure",
}
}
fn kind_fields(kind: &BlockKind) -> Vec<(&'static str, String)> {
match *kind {
BlockKind::Heading { level } => vec![("level", level.to_string())],
BlockKind::ListItem { level, ordinal } => vec![
("level", level.to_string()),
(
"ordinal",
ordinal.map_or_else(|| "null".to_string(), |n| n.to_string()),
),
],
BlockKind::TableCell {
row,
column,
row_span,
column_span,
header,
} => vec![
("row", row.to_string()),
("column", column.to_string()),
("rowSpan", row_span.to_string()),
("columnSpan", column_span.to_string()),
("header", header.to_string()),
],
_ => Vec::new(),
}
}
fn span(span: &TextSpan, depth: usize) -> String {
let pad = INDENT.repeat(depth);
let mut out = format!(
"{pad}{{ \"text\": {}, \"x\": {}, \"y\": {}, \"width\": {}, \"height\": {}, \"fontSize\": {}",
string(&span.text),
number(span.x),
number(span.y),
number(span.width),
number(span.height),
number(span.font_size),
);
if let Some(name) = &span.font_name {
out.push_str(&format!(", \"fontName\": {}", string(name)));
}
out.push_str(&format!(
", \"isBold\": {}, \"isItalic\": {} }}",
span.is_bold, span.is_italic
));
out
}
pub(crate) fn block(block: &Block, depth: usize) -> String {
let pad = INDENT.repeat(depth);
let inner = INDENT.repeat(depth + 1);
let mut lines: Vec<String> = Vec::new();
lines.push(format!("{inner}\"index\": {}", block.index));
lines.push(format!(
"{inner}\"kind\": {}",
string(kind_name(&block.kind))
));
for (key, value) in kind_fields(&block.kind) {
lines.push(format!("{inner}\"{key}\": {value}"));
}
lines.push(format!("{inner}\"page\": {}", block.page));
let bbox: Vec<String> = block.bbox.iter().map(|v| number(*v)).collect();
lines.push(format!("{inner}\"bbox\": [{}]", bbox.join(", ")));
lines.push(format!("{inner}\"text\": {}", string(&block.text)));
lines.push(format!(
"{inner}\"continuesPrevious\": {}",
block.continues_previous
));
if let Some(confidence) = block.confidence {
lines.push(format!("{inner}\"confidence\": {}", number(confidence)));
}
if let Some(grid) = block.grid {
let facts = [
format!("\"thinRows\": {}", grid.thin_rows),
format!("\"fill\": {}", number(grid.fill)),
format!("\"retriedFrom\": {}", grid.retried_from),
format!("\"fullRows\": {}", grid.full_rows),
format!("\"columns\": {}", grid.columns),
];
lines.push(format!("{inner}\"grid\": {{{}}}", facts.join(", ")));
}
let spans: Vec<String> = block.spans.iter().map(|s| span(s, depth + 2)).collect();
if spans.is_empty() {
lines.push(format!("{inner}\"spans\": []"));
} else {
lines.push(format!(
"{inner}\"spans\": [\n{}\n{inner}]",
spans.join(",\n")
));
}
format!("{pad}{{\n{}\n{pad}}}", lines.join(",\n"))
}
pub fn to_json(blocks: &[Block]) -> String {
if blocks.is_empty() {
return "{\n \"blocks\": []\n}\n".to_string();
}
let written: Vec<String> = blocks.iter().map(|b| block(b, 2)).collect();
format!(
"{{\n{INDENT}\"blocks\": [\n{}\n{INDENT}]\n}}\n",
written.join(",\n")
)
}
#[cfg(test)]
mod tests {
use super::*;
fn block_of(kind: BlockKind, text: &str) -> Block {
Block {
kind,
page: 0,
bbox: [1.0, 2.0, 3.0, 4.0],
text: text.to_string(),
spans: Vec::new(),
index: 0,
continues_previous: false,
confidence: None,
grid: None,
table_origin: TableOrigin::Other,
trace_learned: None,
}
}
#[test]
fn nothing_in_is_an_empty_document_out() {
assert_eq!(to_json(&[]), "{\n \"blocks\": []\n}\n");
}
#[test]
fn a_caption_is_named_a_caption() {
let json = to_json(&[block_of(BlockKind::Caption, "Figure 1. The plant.")]);
assert!(json.contains("\"kind\": \"caption\""), "{json}");
}
#[test]
fn a_heading_carries_its_level_beside_its_kind() {
let json = to_json(&[block_of(BlockKind::Heading { level: 2 }, "Terms")]);
assert!(json.contains("\"kind\": \"heading\""), "{json}");
assert!(json.contains("\"level\": 2"), "{json}");
assert!(json.contains("\"text\": \"Terms\""), "{json}");
assert!(json.contains("\"bbox\": [1, 2, 3, 4]"), "{json}");
}
#[test]
fn a_bulleted_item_says_it_has_no_number() {
let json = to_json(&[block_of(
BlockKind::ListItem {
level: 0,
ordinal: None,
},
"first",
)]);
assert!(json.contains("\"ordinal\": null"), "{json}");
}
#[test]
fn a_non_finite_coordinate_is_null_and_not_a_number_no_parser_reads() {
let mut block = block_of(BlockKind::Paragraph, "x");
block.bbox = [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1.5];
let json = to_json(&[block]);
assert!(json.contains("\"bbox\": [null, null, null, 1.5]"), "{json}");
assert!(!json.contains("NaN") && !json.contains("inf"), "{json}");
}
#[test]
fn a_quote_and_a_control_character_leave_as_escapes() {
let json = to_json(&[block_of(
BlockKind::Paragraph,
"he said \"go\"\tand\u{1}stopped",
)]);
let want = concat!(
"\"text\": \"he said ",
"\\\"go\\\"",
"\\tand",
"\\u0001",
"stopped\""
);
assert!(json.contains(want), "{json}");
}
#[test]
fn the_same_blocks_write_the_same_bytes() {
let blocks = vec![
block_of(BlockKind::Heading { level: 1 }, "Scope"),
block_of(BlockKind::Paragraph, "Body."),
];
assert_eq!(to_json(&blocks), to_json(&blocks));
}
}