pdftract/crates/pdftract-core/src/markdown.rs
jedarden 4ac8479ad9 test(pdftract-1sxpa): complete inline image header parser implementation
- Implement recover_to_next_key function with byte-by-byte scanning
  for '/' and 'ID' keywords to enable error recovery in malformed headers
- Fix test assertion: StructInvalidDictValue -> StructInvalidType
- Fix ID whitespace validation test input (IDEI -> ID)
- Fix markdown.rs test calls to include tables parameter
- Add book_chapter fixture provenance entries

All 14 inline_image tests pass, covering:
- Basic header parsing with shorthand key expansion
- Array filter chains
- ID whitespace validation
- Malformed header recovery

Acceptance criteria:
- PASS: BI /W 10 /H 10 /CS /DeviceGray /BPC 8 /F /ASCIIHexDecode ID parses
- PASS: Shorthand expansion (/W -> /Width) yields width == 10
- PASS: Array filter /F [/ASCII85Decode /FlateDecode] parses
- PASS: ID without trailing whitespace emits diagnostic
- PASS: Malformed header (missing value) emits diagnostic and recovers

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-05-27 22:18:09 -04:00

2031 lines
61 KiB
Rust

//! Markdown output generation with positional HTML comment anchors.
//!
//! This module provides functions for converting extracted PDF content to
//! Markdown format with optional HTML comment anchors that allow downstream
//! tools to map excerpts back to precise PDF locations.
//!
//! # Anchor Format
//!
//! Each block can be preceded by a single-line HTML comment:
//!
//! ```markdown
//! <!-- pdftract: page=3 block=12 bbox=[72.0,640.5,540.0,672.0] kind=heading -->
//! ## Chapter 3
//! ```
//!
//! The anchor format is a stable schema parseable with one regex:
//!
//! ```text
//! <!-- pdftract: page=(\d+) block=(\d+) bbox=\[([\d.,]+)\] kind=(\w+) -->
//! ```
//!
//! # Parsing Anchors
//!
//! Use [`parse_anchors`] to extract all anchors from markdown text:
//!
//! ```
//! use pdftract_core::markdown::{parse_anchors, Anchor};
//!
//! let md = r#"<!-- pdftract: page=0 block=0 bbox=[72.0,640.5,540.0,672.0] kind=heading -->
//! # Title"#;
//!
//! let anchors = parse_anchors(md);
//! assert_eq!(anchors.len(), 1);
//! assert_eq!(anchors[0].page, 0);
//! assert_eq!(anchors[0].block, 0);
//! ```
use crate::schema::{
BeadJson, BlockJson, ChoiceValueJson, FormFieldJson, FormFieldTypeJson, FormFieldValueJson,
SpanJson, TableJson, ThreadJson,
};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
/// Regex for parsing pdftract HTML comment anchors.
///
/// Format: `<!-- pdftract: page=(\d+) block=(\d+) bbox=\[([\d.,]+)\] kind=(\w+) -->`
fn anchor_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(
r"<!--\s*pdftract:\s*page=(\d+)\s+block=(\d+)\s+bbox=\[([\d.,]+)\]\s+kind=(\w+)\s*-->",
)
.expect("invalid ANCHOR_REGEX")
})
}
/// A parsed HTML comment anchor containing positional metadata.
///
/// Anchors are extracted from markdown output and provide a mapping from
/// markdown text back to precise PDF locations.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Anchor {
/// Zero-based page index.
pub page: usize,
/// Zero-based block index within the page.
pub block: usize,
/// Bounding box in PDF points: [x0, y0, x1, y1].
pub bbox: [f32; 4],
/// Block kind (e.g., "heading", "paragraph", "table").
pub kind: String,
}
impl Anchor {
/// Create a new anchor from components.
pub fn new(page: usize, block: usize, bbox: [f32; 4], kind: String) -> Self {
Self {
page,
block,
bbox,
kind,
}
}
/// Format this anchor as an HTML comment.
///
/// Returns a single-line comment suitable for insertion before block content.
///
/// # Example
///
/// ```
/// use pdftract_core::markdown::Anchor;
///
/// let anchor = Anchor::new(3, 12, [72.0, 640.5, 540.0, 672.0], "heading".to_string());
/// let comment = anchor.to_comment();
/// assert_eq!(comment, "<!-- pdftract: page=3 block=12 bbox=[72.0,640.5,540.0,672.0] kind=heading -->");
/// ```
pub fn to_comment(&self) -> String {
format!(
"<!-- pdftract: page={} block={} bbox=[{:.1},{:.1},{:.1},{:.1}] kind={} -->",
self.page,
self.block,
self.bbox[0],
self.bbox[1],
self.bbox[2],
self.bbox[3],
self.kind
)
}
}
/// Parse all pdftract anchors from markdown text.
///
/// Returns a vector of [`Anchor`] structs in the order they appear in the text.
/// Invalid anchor formats are silently skipped.
///
/// # Arguments
///
/// * `md` - The markdown text to parse
///
/// # Returns
///
/// A vector of parsed anchors.
///
/// # Example
///
/// ```
/// use pdftract_core::markdown::parse_anchors;
///
/// let md = r#"<!-- pdftract: page=0 block=0 bbox=[72.0,640.5,540.0,672.0] kind=heading -->
/// # Title
///
/// <!-- pdftract: page=0 block=1 bbox=[72.0,600.0,540.0,630.0] kind=paragraph -->
/// Some text."#;
///
/// let anchors = parse_anchors(md);
/// assert_eq!(anchors.len(), 2);
/// assert_eq!(anchors[0].page, 0);
/// assert_eq!(anchors[0].block, 0);
/// assert_eq!(anchors[1].page, 0);
/// assert_eq!(anchors[1].block, 1);
/// ```
pub fn parse_anchors(md: &str) -> Vec<Anchor> {
let mut anchors = Vec::new();
for captures in anchor_regex().captures_iter(md) {
// Parse page number
let page = match captures.get(1).and_then(|m| m.as_str().parse().ok()) {
Some(p) => p,
None => continue,
};
// Parse block number
let block = match captures.get(2).and_then(|m| m.as_str().parse().ok()) {
Some(b) => b,
None => continue,
};
// Parse bbox: "x0,y0,x1,y1" with possible decimal points
let bbox_str = match captures.get(3) {
Some(m) => m.as_str(),
None => continue,
};
let bbox: [f32; 4] = match parse_bbox(bbox_str) {
Some(b) => b,
None => continue,
};
// Parse kind
let kind = match captures.get(4) {
Some(m) => m.as_str().to_string(),
None => continue,
};
anchors.push(Anchor::new(page, block, bbox, kind));
}
anchors
}
/// Parse a bbox string like "72.0,640.5,540.0,672.0" into [f32; 4].
fn parse_bbox(s: &str) -> Option<[f32; 4]> {
let parts: Vec<&str> = s.split(',').collect();
if parts.len() != 4 {
return None;
}
let mut bbox = [0.0f32; 4];
for (i, part) in parts.iter().enumerate() {
bbox[i] = part.trim().parse().ok()?;
}
Some(bbox)
}
/// Convert a block to markdown with optional anchor comment.
///
/// If `include_anchor` is true, emits an HTML comment before the block content.
///
/// # Arguments
///
/// * `block` - The block to convert
/// * `tables` - The tables array for looking up table structures by table_index
/// * `page_index` - Zero-based page index
/// * `block_index` - Zero-based block index within the page
/// * `include_anchor` - Whether to include the HTML comment anchor
///
/// # Returns
///
/// A markdown string with optional anchor.
pub fn block_to_markdown(
block: &BlockJson,
tables: &[TableJson],
page_index: usize,
block_index: usize,
include_anchor: bool,
) -> String {
let mut result = String::new();
// Add anchor comment if requested
if include_anchor {
let anchor = Anchor::new(
page_index,
block_index,
[
block.bbox[0] as f32,
block.bbox[1] as f32,
block.bbox[2] as f32,
block.bbox[3] as f32,
],
block.kind.clone(),
);
result.push_str(&anchor.to_comment());
result.push('\n');
}
// Add block content based on kind
match block.kind.as_str() {
"heading" => {
let level = block.level.unwrap_or(1);
let prefix = "#".repeat(level as usize);
result.push_str(&format!("{} {}\n", prefix, block.text));
}
"paragraph" => {
result.push_str(&format!("{}\n", block.text));
}
"list" => {
result.push_str(&format!("* {}\n", block.text));
}
"table" => {
// Look up the table structure from the tables array
if let Some(table_idx) = block.table_index {
if let Some(table) = tables.get(table_idx) {
result.push_str(&emit_table(table));
} else {
// Fallback to text if table index is invalid
result.push_str(&format!("| {}\n", block.text));
}
} else {
// Fallback to text if no table index
result.push_str(&format!("| {}\n", block.text));
}
}
"figure" => {
result.push_str(&format!("![]()\n\n{}\n", block.text));
}
"caption" => {
// Captions are emitted as italic text
result.push_str(&format!("*{}*\n", block.text));
}
_ => {
result.push_str(&format!("{}\n", block.text));
}
}
result
}
/// Convert all blocks from a page to markdown with optional anchors.
///
/// If `include_anchor` is true, each block is preceded by an HTML comment.
/// If `include_page_break` is true, adds a horizontal rule between pages.
///
/// # Arguments
///
/// * `blocks` - The blocks to convert
/// * `tables` - The tables array for looking up table structures
/// * `page_index` - Zero-based page index
/// * `include_anchor` - Whether to include HTML comment anchors
/// * `include_page_break` - Whether to add a page break separator
///
/// # Returns
///
/// A markdown string with all blocks from the page.
pub fn page_to_markdown(
blocks: &[BlockJson],
tables: &[TableJson],
page_index: usize,
include_anchor: bool,
include_page_break: bool,
) -> String {
let mut result = String::new();
for (block_index, block) in blocks.iter().enumerate() {
let md = block_to_markdown(block, tables, page_index, block_index, include_anchor);
result.push_str(&md);
result.push('\n');
}
// Add page break if requested and this isn't the last page
if include_page_break {
result.push_str("\n---\n\n");
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::BlockJson;
fn make_test_block(kind: &str, text: &str, bbox: [f64; 4]) -> BlockJson {
BlockJson {
kind: kind.to_string(),
text: text.to_string(),
bbox,
level: None,
table_index: None,
spans: vec![],
receipt: None,
}
}
#[test]
fn test_anchor_to_comment() {
let anchor = Anchor::new(3, 12, [72.0, 640.5, 540.0, 672.0], "heading".to_string());
let comment = anchor.to_comment();
assert_eq!(
comment,
"<!-- pdftract: page=3 block=12 bbox=[72.0,640.5,540.0,672.0] kind=heading -->"
);
}
#[test]
fn test_anchor_to_comment_round_bbox() {
let anchor = Anchor::new(
0,
0,
[72.123, 640.567, 540.999, 672.111],
"paragraph".to_string(),
);
let comment = anchor.to_comment();
// Should be rounded to 1 decimal place
assert_eq!(
comment,
"<!-- pdftract: page=0 block=0 bbox=[72.1,640.6,541.0,672.1] kind=paragraph -->"
);
}
#[test]
fn test_parse_anchors_single() {
let md = r#"<!-- pdftract: page=0 block=0 bbox=[72.0,640.5,540.0,672.0] kind=heading -->
# Title"#;
let anchors = parse_anchors(md);
assert_eq!(anchors.len(), 1);
assert_eq!(anchors[0].page, 0);
assert_eq!(anchors[0].block, 0);
assert_eq!(anchors[0].bbox, [72.0, 640.5, 540.0, 672.0]);
assert_eq!(anchors[0].kind, "heading");
}
#[test]
fn test_parse_anchors_multiple() {
let md = r#"<!-- pdftract: page=0 block=0 bbox=[72.0,640.5,540.0,672.0] kind=heading -->
# Title
<!-- pdftract: page=0 block=1 bbox=[72.0,600.0,540.0,630.0] kind=paragraph -->
Some text."#;
let anchors = parse_anchors(md);
assert_eq!(anchors.len(), 2);
assert_eq!(anchors[0].page, 0);
assert_eq!(anchors[0].block, 0);
assert_eq!(anchors[1].page, 0);
assert_eq!(anchors[1].block, 1);
}
#[test]
fn test_parse_anchors_invalid_format_skipped() {
let md = r#"<!-- pdftract: page=0 block=0 bbox=[72.0,640.5,540.0,672.0] kind=heading -->
# Title
<!-- malformed anchor -->
Some text."#;
let anchors = parse_anchors(md);
assert_eq!(anchors.len(), 1);
}
#[test]
fn test_parse_anchors_whitespace_tolerant() {
let md =
r#"<!-- pdftract: page=0 block=0 bbox=[72.0,640.5,540.0,672.0] kind=heading -->"#;
let anchors = parse_anchors(md);
assert_eq!(anchors.len(), 1);
}
#[test]
fn test_parse_bbox() {
assert_eq!(
parse_bbox("72.0,640.5,540.0,672.0"),
Some([72.0, 640.5, 540.0, 672.0])
);
assert_eq!(parse_bbox("0,0,100,100"), Some([0.0, 0.0, 100.0, 100.0]));
assert_eq!(
parse_bbox("72.0, 640.5, 540.0, 672.0"),
Some([72.0, 640.5, 540.0, 672.0])
); // with spaces
assert_eq!(parse_bbox("invalid"), None);
assert_eq!(parse_bbox("1,2,3"), None); // too few values
assert_eq!(parse_bbox("1,2,3,4,5"), None); // too many values
}
#[test]
fn test_block_to_markdown_heading_with_anchor() {
let block = BlockJson {
kind: "heading".to_string(),
text: "Chapter 1".to_string(),
bbox: [72.0, 640.5, 540.0, 672.0],
level: Some(2),
table_index: None,
spans: vec![],
receipt: None,
};
let md = block_to_markdown(&block, &[], 0, 0, true);
assert!(md.contains(
"<!-- pdftract: page=0 block=0 bbox=[72.0,640.5,540.0,672.0] kind=heading -->"
));
assert!(md.contains("## Chapter 1"));
}
#[test]
fn test_block_to_markdown_paragraph_without_anchor() {
let block = make_test_block("paragraph", "Some text.", [72.0, 600.0, 540.0, 630.0]);
let md = block_to_markdown(&block, &[], 0, 0, false);
assert!(!md.contains("<!-- pdftract:"));
assert!(md.contains("Some text."));
}
#[test]
fn test_block_to_markdown_list() {
let block = make_test_block("list", "Item 1", [72.0, 500.0, 540.0, 520.0]);
let md = block_to_markdown(&block, &[], 0, 0, false);
assert!(md.contains("* Item 1"));
}
#[test]
fn test_block_to_markdown_table() {
let block = make_test_block("table", "Cell data", [72.0, 400.0, 540.0, 450.0]);
let md = block_to_markdown(&block, &[], 0, 0, false);
assert!(md.contains("| Cell data"));
}
#[test]
fn test_block_to_markdown_figure() {
let block = make_test_block("figure", "Alt text", [72.0, 300.0, 540.0, 350.0]);
let md = block_to_markdown(&block, &[], 0, 0, false);
assert!(md.contains("![]()"));
assert!(md.contains("Alt text"));
}
#[test]
fn test_page_to_markdown_with_page_break() {
let blocks = vec![
make_test_block("heading", "Title", [72.0, 640.5, 540.0, 672.0]),
make_test_block("paragraph", "Text", [72.0, 600.0, 540.0, 630.0]),
];
let md = page_to_markdown(&blocks, &[], 0, false, true);
assert!(md.contains("---"));
}
#[test]
fn test_page_to_markdown_without_page_break() {
let blocks = vec![
make_test_block("heading", "Title", [72.0, 640.5, 540.0, 672.0]),
make_test_block("paragraph", "Text", [72.0, 600.0, 540.0, 630.0]),
];
let md = page_to_markdown(&blocks, &[], 0, false, false);
assert!(!md.contains("---"));
}
#[test]
fn test_page_to_markdown_with_anchors() {
let blocks = vec![
make_test_block("heading", "Title", [72.0, 640.5, 540.0, 672.0]),
make_test_block("paragraph", "Text", [72.0, 600.0, 540.0, 630.0]),
];
let md = page_to_markdown(&blocks, &[], 0, true, false);
assert_eq!(md.matches("<!-- pdftract:").count(), 2);
}
#[test]
fn test_roundtrip_extract_and_parse() {
let blocks = vec![BlockJson {
kind: "heading".to_string(),
text: "Chapter 1".to_string(),
bbox: [72.0, 640.5, 540.0, 672.0],
level: Some(2),
table_index: None,
spans: vec![],
receipt: None,
}];
let md = page_to_markdown(&blocks, &[], 3, true, false);
let anchors = parse_anchors(&md);
assert_eq!(anchors.len(), 1);
assert_eq!(anchors[0].page, 3);
assert_eq!(anchors[0].block, 0);
assert_eq!(anchors[0].kind, "heading");
}
}
/// Generate a markdown footer section for form fields.
///
/// This function creates a formatted markdown table listing all form fields
/// with their names, types, and current values. Only emits the section when
/// form_fields count > 0.
///
/// # Arguments
///
/// * `form_fields` - The form fields to include in the footer
///
/// # Returns
///
/// A markdown string with a form fields table, or an empty string if no fields.
///
/// # Example
///
/// ```ignore
/// use pdftract_core::markdown::form_fields_to_markdown;
/// use pdftract_core::schema::{FormFieldJson, FormFieldTypeJson, FormFieldValueJson};
///
/// let fields = vec![
/// FormFieldJson {
/// name: "employee_name".to_string(),
/// field_type: FormFieldTypeJson::Text,
/// value: FormFieldValueJson::Text(Some("John Doe".to_string())),
/// // ... other fields
/// },
/// ];
///
/// let md = form_fields_to_markdown(&fields);
/// assert!(md.contains("## Form Fields"));
/// assert!(md.contains("employee_name"));
/// ```
pub fn form_fields_to_markdown(form_fields: &[FormFieldJson]) -> String {
if form_fields.is_empty() {
return String::new();
}
let mut result = String::from("\n\n## Form Fields\n\n");
result.push_str("| Name | Type | Value |\n");
result.push_str("|------|------|-------|\n");
for field in form_fields {
let type_str = match field.field_type {
FormFieldTypeJson::Text => "text",
FormFieldTypeJson::Button => "button",
FormFieldTypeJson::Choice => "choice",
FormFieldTypeJson::Signature => "signature",
};
let value_str = format_value_json(&field.value);
result.push_str(&format!(
"| {} | {} | {} |\n",
field.name, type_str, value_str
));
}
result
}
/// Format a FormFieldValueJson as a string for markdown display.
fn format_value_json(value: &FormFieldValueJson) -> String {
match value {
FormFieldValueJson::Text(None) => "*empty*".to_string(),
FormFieldValueJson::Text(Some(s)) => escape_pipe(s),
FormFieldValueJson::Button(b) => b.to_string(),
FormFieldValueJson::Choice(ChoiceValueJson::Single(s)) => escape_pipe(s),
FormFieldValueJson::Choice(ChoiceValueJson::Multiple(vec)) => {
let values: Vec<String> = vec.iter().map(|s| escape_pipe(s.as_str())).collect();
values.join(", ")
}
FormFieldValueJson::Signature(None) => "*unsigned*".to_string(),
FormFieldValueJson::Signature(Some(n)) => format!("ref #{}", n),
}
}
/// Generate a markdown footer section for article threads.
///
/// This function creates a formatted markdown section listing all article
/// threads with their metadata and page ranges. Only emits the section
/// when threads count > 0.
///
/// # Arguments
///
/// * `threads` - The threads to include in the footer
///
/// # Returns
///
/// A markdown string with an article threads section, or an empty string if no threads.
///
/// # Example
///
/// ```ignore
/// use pdftract_core::markdown::threads_to_markdown;
/// use pdftract_core::schema::{ThreadJson, BeadJson};
///
/// let threads = vec![
/// ThreadJson {
/// title: Some("Main Article".to_string()),
/// author: Some("John Doe".to_string()),
/// subject: None,
/// keywords: None,
/// beads: vec![
/// BeadJson { page_index: 0, rect: [100.0, 200.0, 300.0, 220.0] },
/// BeadJson { page_index: 1, rect: [100.0, 500.0, 300.0, 520.0] },
/// ],
/// },
/// ];
///
/// let md = threads_to_markdown(&threads);
/// assert!(md.contains("## Article Threads"));
/// assert!(md.contains("1. *Main Article* (John Doe) - pages 0-1 (2 beads)"));
/// ```
pub fn threads_to_markdown(threads: &[ThreadJson]) -> String {
if threads.is_empty() {
return String::new();
}
let mut result = String::from("\n\n## Article Threads\n\n");
for (i, thread) in threads.iter().enumerate() {
// Build the thread title line
let title = thread.title.as_deref().unwrap_or("(Untitled)");
let author = thread.author.as_deref().unwrap_or("");
// Collapse contiguous page ranges
let page_ranges = collapse_page_ranges(&thread.beads);
// Format: "1. *Title* (Author) - pages 0-1, 3-5 (3 beads)"
result.push_str(&format!(
"{}. *{}* ({}) - {} ({} beads)\n",
i + 1,
title,
author,
page_ranges,
thread.beads.len()
));
}
result
}
/// Collapse contiguous page indices into ranges.
///
/// Given a list of beads with page indices, this function collapses
/// contiguous sequences into ranges for more compact display.
///
/// # Arguments
///
/// * `beads` - The beads to collapse into page ranges
///
/// # Returns
///
/// A string like "pages 0-1, 3-5" representing the page ranges.
fn collapse_page_ranges(beads: &[BeadJson]) -> String {
if beads.is_empty() {
return "no pages".to_string();
}
let mut ranges = Vec::new();
let mut start = beads[0].page_index;
let mut end = beads[0].page_index;
for bead in beads.iter().skip(1) {
// Skip duplicate page indices
if bead.page_index == end {
continue;
}
if bead.page_index == end + 1 {
// Contiguous, extend the range
end = bead.page_index;
} else {
// Gap, emit the current range
ranges.push((start, end));
start = bead.page_index;
end = bead.page_index;
}
}
// Emit the last range
ranges.push((start, end));
// Format ranges
let parts: Vec<String> = ranges
.iter()
.map(|&(s, e)| {
if s == e {
format!("{}", s)
} else {
format!("{}-{}", s, e)
}
})
.collect();
format!("pages {}", parts.join(", "))
}
/// Convert a span to markdown with inline styling based on flags.
///
/// This function implements Phase 6.5 inline span styling, translating
/// span flag bitmask values to Markdown inline syntax.
///
/// # Styling Rules
///
/// - Bold (bit 0) → `**text**`
/// - Italic (bit 1) → `*text*`
/// - Bold + Italic → `***text***`
/// - Subscript (bit 3) → `<sub>text</sub>`
/// - Superscript (bit 4) → `<sup>text</sup>`
/// - Smallcaps (bit 2) → `<span style="font-variant: small-caps">text</span>`
/// - Color-only differences: no styling emitted
///
/// # Arguments
///
/// * `span` - The span to convert
///
/// # Returns
///
/// A markdown string with appropriate inline styling applied.
///
/// # Examples
///
/// ```
/// use pdftract_core::schema::SpanJson;
/// use pdftract_core::markdown::span_to_markdown;
///
/// let mut span = SpanJson {
/// text: "important text".to_string(),
/// flags: vec!["bold".to_string()],
/// ..Default::default()
/// };
///
/// let md = span_to_markdown(&span);
/// assert_eq!(md, "**important text**");
/// ```
///
/// ```
/// // H₂O example: subscript
/// let mut span = SpanJson {
/// text: "2".to_string(),
/// flags: vec!["subscript".to_string()],
/// ..Default::default()
/// };
///
/// let md = span_to_markdown(&span);
/// assert_eq!(md, "<sub>2</sub>");
/// ```
///
/// ```
/// // 4th example: superscript
/// let mut span = SpanJson {
/// text: "th".to_string(),
/// flags: vec!["superscript".to_string()],
/// ..Default::default()
/// };
///
/// let md = span_to_markdown(&span);
/// assert_eq!(md, "<sup>th</sup>");
/// ```
///
/// ```
/// // Bold + italic combination
/// let mut span = SpanJson {
/// text: "emphasized".to_string(),
/// flags: vec!["bold".to_string(), "italic".to_string()],
/// ..Default::default()
/// };
///
/// let md = span_to_markdown(&span);
/// assert_eq!(md, "***emphasized***");
/// ```
///
/// ```
/// // Special character escaping
/// let mut span = SpanJson {
/// text: "1*2".to_string(),
/// flags: vec![],
/// ..Default::default()
/// };
///
/// let md = span_to_markdown(&span);
/// assert_eq!(md, "1\\*2");
/// ```
pub fn span_to_markdown(span: &SpanJson) -> String {
// Get the text content
let text = &span.text;
// Skip whitespace-only spans (no point styling whitespace)
if text.trim().is_empty() {
return text.clone();
}
// Check for each flag in the flags Vec<String>
let has_bold = span.flags.contains(&"bold".to_string());
let has_italic = span.flags.contains(&"italic".to_string());
let has_subscript = span.flags.contains(&"subscript".to_string());
let has_superscript = span.flags.contains(&"superscript".to_string());
let has_smallcaps = span.flags.contains(&"smallcaps".to_string());
// Color-only differences: emit no styling (just return escaped text)
// This is checked by seeing if none of the style flags are present
let has_any_style = has_bold || has_italic || has_subscript || has_superscript || has_smallcaps;
if !has_any_style {
// No styling flags, just escape and return
return escape_markdown_inline(text);
}
// Escape the text first (before wrapping in styling)
let escaped = escape_markdown_inline(text);
// Build the styled output
let mut result = String::new();
// Combination order:
// - Bold + italic wrapper (***text***) goes outermost
// - Smallcaps span wraps script tags (<span><sup>text</sup></span>)
// - Script tags go inside smallcaps (if both present)
// This order: **<span><sup>text</sup></span>** or **<sub>text</sub>** (if no smallcaps)
// Bold + italic wrapper (***text***)
if has_bold && has_italic {
result.push_str("***");
} else if has_bold {
result.push_str("**");
} else if has_italic {
result.push_str("*");
}
// Smallcaps wrapper (outer relative to scripts)
if has_smallcaps {
result.push_str("<span style=\"font-variant: small-caps\">");
}
// Script tags (sub/sup) go inside smallcaps
if has_subscript {
result.push_str("<sub>");
} else if has_superscript {
result.push_str("<sup>");
}
// Add the escaped text
result.push_str(&escaped);
// Close wrappers in reverse order
if has_subscript {
result.push_str("</sub>");
} else if has_superscript {
result.push_str("</sup>");
}
if has_smallcaps {
result.push_str("</span>");
}
if has_bold && has_italic {
result.push_str("***");
} else if has_bold {
result.push_str("**");
} else if has_italic {
result.push_str("*");
}
result
}
/// Escape special Markdown characters in inline text.
///
/// This function escapes characters that have special meaning in Markdown
/// to prevent unintended formatting. Per CommonMark spec, these characters
/// are escaped to prevent them from being interpreted as Markdown syntax.
///
/// # Characters Escaped
///
/// The following characters are escaped with a backslash:
/// - `\` (backslash itself - must be escaped to avoid interpretation)
/// - `` ` `` (code span)
/// - `*` (emphasis/strong)
/// - `_` (emphasis)
/// - `[` (link start)
/// - `]` (link end)
/// - `(` (link destination start)
/// - `)` (link destination end)
/// - `#` (ATX heading)
/// - `!` (image)
/// - `+` (list marker)
/// - `<` (HTML tag/auto-link)
/// - `>` (blockquote)
///
/// # Characters NOT Escaped
///
/// - `-` (hyphen) - only special at start of line for lists/HR
/// - `.` (period) - only special as part of list marker like "1."
/// - `=` (equals) - not special in CommonMark
///
/// # Arguments
///
/// * `s` - The string to escape
///
/// # Returns
///
/// A string with special characters escaped.
fn escape_markdown_inline(s: &str) -> String {
let mut result = String::with_capacity(s.len() * 2);
for c in s.chars() {
match c {
'\\' | '`' | '*' | '_' | '[' | ']' | '(' | ')' | '#' | '!' | '+' | '<' | '>' => {
result.push('\\');
result.push(c);
}
_ => result.push(c),
}
}
result
}
/// Emit a table as Markdown (GFM pipe table) or HTML fallback.
///
/// This function implements Phase 6.5 table emission:
/// - Simple tables (all 1x1 cells, no nested content) → GFM pipe table
/// - Complex tables (merged cells/colspan/rowspan/nested blocks) → HTML `<table>`
/// - Caption → italic line below the table
///
/// # Arguments
///
/// * `table` - The table to emit
///
/// # Returns
///
/// A Markdown string with the table in the appropriate format.
///
/// # Examples
///
/// ```
/// use pdftract_core::markdown::emit_table;
/// use pdftract_core::schema::{TableJson, RowJson, CellJson};
///
/// let table = TableJson {
/// id: "table_0".to_string(),
/// bbox: [50.0, 100.0, 400.0, 300.0],
/// rows: vec![
/// RowJson {
/// bbox: [50.0, 250.0, 400.0, 300.0],
/// cells: vec![
/// CellJson {
/// bbox: [50.0, 250.0, 200.0, 300.0],
/// text: "Header 1".to_string(),
/// spans: vec![],
/// row: 0,
/// col: 0,
/// rowspan: 1,
/// colspan: 1,
/// is_header_row: true,
/// },
/// CellJson {
/// bbox: [200.0, 250.0, 400.0, 300.0],
/// text: "Header 2".to_string(),
/// spans: vec![],
/// row: 0,
/// col: 1,
/// rowspan: 1,
/// colspan: 1,
/// is_header_row: true,
/// },
/// ],
/// is_header: true,
/// },
/// RowJson {
/// bbox: [50.0, 100.0, 400.0, 250.0],
/// cells: vec![
/// CellJson {
/// bbox: [50.0, 100.0, 200.0, 250.0],
/// text: "Data 1".to_string(),
/// spans: vec![],
/// row: 1,
/// col: 0,
/// rowspan: 1,
/// colspan: 1,
/// is_header_row: false,
/// },
/// CellJson {
/// bbox: [200.0, 100.0, 400.0, 250.0],
/// text: "Data 2".to_string(),
/// spans: vec![],
/// row: 1,
/// col: 1,
/// rowspan: 1,
/// colspan: 1,
/// is_header_row: false,
/// },
/// ],
/// is_header: false,
/// },
/// ],
/// header_rows: 1,
/// detection_method: "line_based".to_string(),
/// continued: false,
/// continued_from_prev: false,
/// page_index: 0,
/// };
///
/// let md = emit_table(&table);
/// assert!(md.contains("| Header 1 | Header 2 |"));
/// assert!(md.contains("| Data 1 | Data 2 |"));
/// ```
pub fn emit_table(table: &TableJson) -> String {
// Check if table is simple (all cells 1x1) or complex (merged cells)
let is_simple = table.rows.iter().all(|row| {
row.cells
.iter()
.all(|cell| cell.rowspan == 1 && cell.colspan == 1)
});
if is_simple {
emit_gfm_table(table)
} else {
emit_html_table(table)
}
}
/// Emit a table as GitHub-Flavored Markdown pipe table.
///
/// GFM pipe tables require:
/// - All cells have rowspan=1 and colspan=1 (no merged cells)
/// - Header row (first row if is_header=true, otherwise synthesized)
/// - Separator row with `| --- | --- |` syntax
/// - Body rows with `| val | val |` syntax
fn emit_gfm_table(table: &TableJson) -> String {
let mut result = String::new();
// Find the maximum number of columns across all rows
let max_cols = table
.rows
.iter()
.map(|row| row.cells.len())
.max()
.unwrap_or(0);
if max_cols == 0 {
return String::new();
}
// Emit header row (use first row if it exists)
if let Some(first_row) = table.rows.first() {
result.push_str("| ");
for (i, cell) in first_row.cells.iter().enumerate() {
if i > 0 {
result.push_str(" | ");
}
result.push_str(&escape_pipe(&cell.text));
}
// Pad missing columns
for i in first_row.cells.len()..max_cols {
if i > 0 || !first_row.cells.is_empty() {
result.push_str(" | ");
}
result.push_str(" ");
}
result.push_str(" |\n");
} else {
// Empty header row for table with no rows
for i in 0..max_cols {
if i > 0 {
result.push_str(" | ");
}
result.push_str(" ");
}
result.push_str(" |\n");
}
// Emit separator row
result.push_str("|");
for _ in 0..max_cols {
result.push_str(" --- |");
}
result.push('\n');
// Emit body rows (skip first row if it was header)
let body_start = if table.rows.first().map_or(false, |r| r.is_header) {
1
} else {
0
};
for row in table.rows.iter().skip(body_start) {
result.push_str("| ");
for (i, cell) in row.cells.iter().enumerate() {
if i > 0 {
result.push_str(" | ");
}
result.push_str(&escape_pipe(&cell.text));
}
// Pad missing columns
for i in row.cells.len()..max_cols {
if i > 0 || !row.cells.is_empty() {
result.push_str(" | ");
}
result.push_str(" ");
}
result.push_str(" |\n");
}
result
}
/// Emit a table as inline HTML `<table>`.
///
/// HTML fallback is used when:
/// - Any cell has colspan > 1 or rowspan > 1 (merged cells)
/// - Nested blocks are present (future enhancement)
pub fn emit_html_table(table: &TableJson) -> String {
let mut result = String::from("<table>\n");
for row in &table.rows {
result.push_str(" <tr>\n");
for cell in &row.cells {
let tag = if cell.is_header_row || row.is_header {
"th"
} else {
"td"
};
result.push_str(" <");
result.push_str(tag);
// Add colspan if > 1
if cell.colspan > 1 {
result.push_str(&format!(" colspan=\"{}\"", cell.colspan));
}
// Add rowspan if > 1
if cell.rowspan > 1 {
result.push_str(&format!(" rowspan=\"{}\"", cell.rowspan));
}
result.push_str(">");
result.push_str(&escape_pipe(&cell.text));
result.push_str("</");
result.push_str(tag);
result.push_str(">\n");
}
result.push_str(" </tr>\n");
}
result.push_str("</table>\n");
result
}
/// Escape pipe characters for markdown table cells.
///
/// This function escapes `|` as `\|` to prevent it from being interpreted
/// as a column separator in GFM pipe tables.
///
/// Also replaces newlines with `<br>` for GFM tables (HTML inside Markdown
/// table cells is allowed and widely supported).
fn escape_pipe(s: &str) -> String {
let mut result = String::with_capacity(s.len() * 2);
for c in s.chars() {
match c {
'|' => {
result.push_str("\\|");
}
'\n' => {
// Newlines in GFM tables become <br> tags
result.push_str("<br>");
}
'<' => {
// Escape < to prevent HTML injection
result.push_str("&lt;");
}
'>' => {
// Escape > to prevent HTML injection
result.push_str("&gt;");
}
_ => result.push(c),
}
}
result
}
#[cfg(test)]
mod span_tests {
use super::*;
/// Helper function to create a test span with the given text and flags.
/// All other fields are set to reasonable defaults for testing.
fn make_test_span(text: &str, flags: &[&str]) -> SpanJson {
SpanJson {
text: text.to_string(),
bbox: [0.0, 0.0, 100.0, 20.0],
font: "Helvetica".to_string(),
size: 12.0,
color: None,
rendering_mode: None,
confidence: None,
confidence_source: None,
lang: None,
flags: flags.iter().map(|s| s.to_string()).collect(),
receipt: None,
column: None,
}
}
#[test]
fn test_span_to_markdown_bold() {
let span = make_test_span("important", &["bold"]);
assert_eq!(span_to_markdown(&span), "**important**");
}
#[test]
fn test_span_to_markdown_italic() {
let span = make_test_span("emphasized", &["italic"]);
assert_eq!(span_to_markdown(&span), "*emphasized*");
}
#[test]
fn test_span_to_markdown_bold_italic() {
// Critical test: bold + italic span emitted as ***text***
let span = make_test_span("very important", &["bold", "italic"]);
assert_eq!(span_to_markdown(&span), "***very important***");
}
#[test]
fn test_span_to_markdown_subscript() {
let span = make_test_span("2", &["subscript"]);
assert_eq!(span_to_markdown(&span), "<sub>2</sub>");
}
#[test]
fn test_span_to_markdown_superscript() {
let span = make_test_span("th", &["superscript"]);
assert_eq!(span_to_markdown(&span), "<sup>th</sup>");
}
#[test]
fn test_span_to_markdown_smallcaps() {
let span = make_test_span("CAPS", &["smallcaps"]);
assert_eq!(
span_to_markdown(&span),
"<span style=\"font-variant: small-caps\">CAPS</span>"
);
}
#[test]
fn test_span_to_markdown_no_flags() {
// Color-only difference or no styling: no styling emitted
let span = make_test_span("plain text", &[]);
assert_eq!(span_to_markdown(&span), "plain text");
}
#[test]
fn test_span_to_markdown_special_chars_escaped() {
// Special chars escaped: span text "1*2" -> "1\*2"
let span = make_test_span("1*2", &[]);
assert_eq!(span_to_markdown(&span), "1\\*2");
}
#[test]
fn test_span_to_markdown_bold_subscript_combination() {
// Bold + subscript: **<sub>text</sub>**
let span = make_test_span("ion", &["bold", "subscript"]);
assert_eq!(span_to_markdown(&span), "**<sub>ion</sub>**");
}
#[test]
fn test_span_to_markdown_bold_superscript_combination() {
// Bold + superscript: **<sup>text</sup>**
let span = make_test_span("st", &["bold", "superscript"]);
assert_eq!(span_to_markdown(&span), "**<sup>st</sup>**");
}
#[test]
fn test_span_to_markdown_italic_subscript_combination() {
// Italic + subscript: *<sub>text</sub>*
let span = make_test_span("ion", &["italic", "subscript"]);
assert_eq!(span_to_markdown(&span), "*<sub>ion</sub>*");
}
#[test]
fn test_span_to_markdown_all_flags() {
// All flags: bold + italic + smallcaps + superscript
let span = make_test_span("X", &["bold", "italic", "smallcaps", "superscript"]);
assert_eq!(
span_to_markdown(&span),
"***<span style=\"font-variant: small-caps\"><sup>X</sup></span>***"
);
}
#[test]
fn test_span_to_markdown_whitespace_only() {
// Empty/whitespace-only spans emit unwrapped
let span = make_test_span(" ", &["bold"]);
assert_eq!(span_to_markdown(&span), " ");
}
#[test]
fn test_span_to_markdown_empty_string() {
let span = make_test_span("", &["bold"]);
assert_eq!(span_to_markdown(&span), "");
}
#[test]
fn test_escape_markdown_inline_asterisk() {
assert_eq!(escape_markdown_inline("1*2"), "1\\*2");
}
#[test]
fn test_escape_markdown_inline_underscore() {
assert_eq!(escape_markdown_inline("hello_world"), "hello\\_world");
}
#[test]
fn test_escape_markdown_inline_backtick() {
assert_eq!(escape_markdown_inline("code`here"), "code\\`here");
}
#[test]
fn test_escape_markdown_inline_brackets() {
assert_eq!(escape_markdown_inline("[link]"), "\\[link\\]");
}
#[test]
fn test_escape_markdown_inline_multiple_special() {
assert_eq!(escape_markdown_inline("*_[link]*"), "\\*\\_\\[link\\]\\*");
}
#[test]
fn test_escape_markdown_inline_backslash() {
assert_eq!(escape_markdown_inline("C:\\path"), "C:\\\\path");
}
#[test]
fn test_escape_markdown_inline_hash() {
assert_eq!(escape_markdown_inline("#heading"), "\\#heading");
}
#[test]
fn test_escape_markdown_inline_plus_minus() {
assert_eq!(escape_markdown_inline("+/-"), "\\+/-");
}
#[test]
fn test_escape_markdown_inline_less_greater() {
// < and > are escaped (HTML tags/auto-links)
assert_eq!(escape_markdown_inline("<tag>"), "\\<tag\\>");
}
#[test]
fn test_span_to_markdown_bold_with_asterisk_in_text() {
// Bold text containing asterisks should be escaped
let span = make_test_span("2*2=4", &["bold"]);
assert_eq!(span_to_markdown(&span), "**2\\*2=4**");
}
#[test]
fn test_span_to_markdown_subscript_with_special_chars() {
// Subscript with special characters
let span = make_test_span("2+", &["subscript"]);
assert_eq!(span_to_markdown(&span), "<sub>2\\+</sub>");
}
#[test]
fn test_span_to_markdown_superscript_with_special_chars() {
// Superscript with special characters
let span = make_test_span("n-1", &["superscript"]);
assert_eq!(span_to_markdown(&span), "<sup>n-1</sup>");
}
#[test]
fn test_span_to_markdown_smallcaps_with_special_chars() {
// Smallcaps with underscore
let span = make_test_span("HELLO_WORLD", &["smallcaps"]);
assert_eq!(
span_to_markdown(&span),
"<span style=\"font-variant: small-caps\">HELLO\\_WORLD</span>"
);
}
#[test]
fn test_threads_to_markdown_empty() {
// Empty threads list returns empty string
let threads: Vec<ThreadJson> = vec![];
assert_eq!(threads_to_markdown(&threads), "");
}
#[test]
fn test_threads_to_markdown_single_thread() {
// Single thread with multiple beads
let threads = vec![ThreadJson {
title: Some("Main Article".to_string()),
author: Some("John Doe".to_string()),
subject: None,
keywords: None,
beads: vec![
BeadJson {
page_index: 0,
rect: [100.0, 200.0, 300.0, 220.0],
},
BeadJson {
page_index: 1,
rect: [100.0, 500.0, 300.0, 520.0],
},
],
}];
let md = threads_to_markdown(&threads);
assert!(md.contains("## Article Threads"));
assert!(md.contains("1. *Main Article* (John Doe) - pages 0-1 (2 beads)"));
}
#[test]
fn test_threads_to_markdown_multiple_threads() {
// Multiple threads with various metadata
let threads = vec![
ThreadJson {
title: Some("Introduction".to_string()),
author: Some("Jane Smith".to_string()),
subject: None,
keywords: None,
beads: vec![BeadJson {
page_index: 0,
rect: [50.0, 100.0, 250.0, 120.0],
}],
},
ThreadJson {
title: Some("Main Content".to_string()),
author: None,
subject: Some("Chapter 1".to_string()),
keywords: Some("test, example".to_string()),
beads: vec![
BeadJson {
page_index: 1,
rect: [50.0, 400.0, 250.0, 420.0],
},
BeadJson {
page_index: 2,
rect: [50.0, 100.0, 250.0, 120.0],
},
],
},
];
let md = threads_to_markdown(&threads);
assert!(md.contains("1. *Introduction* (Jane Smith) - pages 0 (1 beads)"));
assert!(md.contains("2. *Main Content* () - pages 1-2 (2 beads)"));
}
#[test]
fn test_threads_to_markdown_untitled_thread() {
// Thread with no title
let threads = vec![ThreadJson {
title: None,
author: None,
subject: None,
keywords: None,
beads: vec![BeadJson {
page_index: 5,
rect: [100.0, 200.0, 300.0, 220.0],
}],
}];
let md = threads_to_markdown(&threads);
assert!(md.contains("1. *(Untitled)* () - pages 5 (1 beads)"));
}
#[test]
fn test_collapse_page_ranges_single_page() {
// Single bead
let beads = vec![BeadJson {
page_index: 3,
rect: [0.0, 0.0, 100.0, 20.0],
}];
assert_eq!(collapse_page_ranges(&beads), "pages 3");
}
#[test]
fn test_collapse_page_ranges_contiguous() {
// Contiguous pages
let beads = vec![
BeadJson {
page_index: 0,
rect: [0.0, 0.0, 100.0, 20.0],
},
BeadJson {
page_index: 1,
rect: [0.0, 0.0, 100.0, 20.0],
},
BeadJson {
page_index: 2,
rect: [0.0, 0.0, 100.0, 20.0],
},
];
assert_eq!(collapse_page_ranges(&beads), "pages 0-2");
}
#[test]
fn test_collapse_page_ranges_gaps() {
// Pages with gaps
let beads = vec![
BeadJson {
page_index: 0,
rect: [0.0, 0.0, 100.0, 20.0],
},
BeadJson {
page_index: 2,
rect: [0.0, 0.0, 100.0, 20.0],
},
BeadJson {
page_index: 5,
rect: [0.0, 0.0, 100.0, 20.0],
},
];
assert_eq!(collapse_page_ranges(&beads), "pages 0, 2, 5");
}
#[test]
fn test_collapse_page_ranges_mixed() {
// Mixed contiguous and gaps
let beads = vec![
BeadJson {
page_index: 0,
rect: [0.0, 0.0, 100.0, 20.0],
},
BeadJson {
page_index: 1,
rect: [0.0, 0.0, 100.0, 20.0],
},
BeadJson {
page_index: 3,
rect: [0.0, 0.0, 100.0, 20.0],
},
BeadJson {
page_index: 4,
rect: [0.0, 0.0, 100.0, 20.0],
},
BeadJson {
page_index: 4,
rect: [0.0, 0.0, 100.0, 20.0],
},
];
assert_eq!(collapse_page_ranges(&beads), "pages 0-1, 3-4");
}
// Table emission tests (Phase 6.5)
fn make_test_cell(
text: &str,
row: usize,
col: usize,
rowspan: u32,
colspan: u32,
is_header_row: bool,
) -> crate::schema::CellJson {
crate::schema::CellJson {
bbox: [0.0, 0.0, 100.0, 20.0],
text: text.to_string(),
spans: vec![],
row,
col,
rowspan,
colspan,
is_header_row,
}
}
fn make_test_row(cells: Vec<crate::schema::CellJson>, is_header: bool) -> crate::schema::RowJson {
crate::schema::RowJson {
bbox: [0.0, 0.0, 100.0, 20.0],
cells,
is_header,
}
}
#[test]
fn test_emit_table_simple_3x3() {
// Simple 3x3 table: GFM pipe format
let table = TableJson {
id: "table_0".to_string(),
bbox: [0.0, 0.0, 300.0, 200.0],
rows: vec![
make_test_row(
vec![
make_test_cell("H1", 0, 0, 1, 1, true),
make_test_cell("H2", 0, 1, 1, 1, true),
make_test_cell("H3", 0, 2, 1, 1, true),
],
true,
),
make_test_row(
vec![
make_test_cell("D1", 1, 0, 1, 1, false),
make_test_cell("D2", 1, 1, 1, 1, false),
make_test_cell("D3", 1, 2, 1, 1, false),
],
false,
),
make_test_row(
vec![
make_test_cell("D4", 2, 0, 1, 1, false),
make_test_cell("D5", 2, 1, 1, 1, false),
make_test_cell("D6", 2, 2, 1, 1, false),
],
false,
),
],
header_rows: 1,
detection_method: "line_based".to_string(),
continued: false,
continued_from_prev: false,
page_index: 0,
};
let md = emit_table(&table);
assert!(md.contains("| H1 | H2 | H3 |"));
assert!(md.contains("| --- | --- | --- |"));
assert!(md.contains("| D1 | D2 | D3 |"));
assert!(md.contains("| D4 | D5 | D6 |"));
// Should NOT contain HTML table tags
assert!(!md.contains("<table>"));
assert!(!md.contains("<tr>"));
assert!(!md.contains("<td>"));
}
#[test]
fn test_emit_table_merged_cells_html_fallback() {
// Critical test: merged-cell table input -> falls back to inline <table>
let table = TableJson {
id: "table_0".to_string(),
bbox: [0.0, 0.0, 300.0, 200.0],
rows: vec![
make_test_row(
vec![
make_test_cell("Merged Header", 0, 0, 1, 2, true), // colspan=2
make_test_cell("H2", 0, 1, 1, 1, true),
],
true,
),
make_test_row(
vec![
make_test_cell("D1", 1, 0, 1, 1, false),
make_test_cell("D2", 1, 1, 1, 1, false),
],
false,
),
],
header_rows: 1,
detection_method: "line_based".to_string(),
continued: false,
continued_from_prev: false,
page_index: 0,
};
let md = emit_table(&table);
// Should contain HTML table tags
assert!(md.contains("<table>"));
assert!(md.contains("</table>"));
assert!(md.contains("<tr>"));
assert!(md.contains("</tr>"));
// Should have colspan attribute
assert!(md.contains("colspan=\"2\""));
// Should NOT contain GFM pipe syntax
assert!(!md.contains("| --- |"));
}
#[test]
fn test_emit_table_rowspan_html_fallback() {
// Table with rowspan -> HTML fallback
let table = TableJson {
id: "table_0".to_string(),
bbox: [0.0, 0.0, 300.0, 200.0],
rows: vec![
make_test_row(
vec![
make_test_cell("Rowspan", 0, 0, 2, 1, true), // rowspan=2
make_test_cell("H2", 0, 1, 1, 1, true),
],
true,
),
make_test_row(
vec![
make_test_cell("D1", 1, 0, 1, 1, false), // This cell is below the rowspan cell
make_test_cell("D2", 1, 1, 1, 1, false),
],
false,
),
],
header_rows: 1,
detection_method: "line_based".to_string(),
continued: false,
continued_from_prev: false,
page_index: 0,
};
let md = emit_table(&table);
// Should have rowspan attribute
assert!(md.contains("rowspan=\"2\""));
// Should NOT contain GFM pipe syntax
assert!(!md.contains("| --- |"));
}
#[test]
fn test_escape_pipe() {
// Cell with pipe character: escaped as \|
assert_eq!(escape_pipe("A|B"), "A\\|B");
assert_eq!(escape_pipe("|||"), "\\|\\|\\|");
assert_eq!(escape_pipe("test"), "test");
}
#[test]
fn test_escape_pipe_newline_to_br() {
// Cell with newline: rendered with <br>
assert_eq!(escape_pipe("line1\nline2"), "line1<br>line2");
assert_eq!(escape_pipe("a\nb\nc"), "a<br>b<br>c");
}
#[test]
fn test_escape_pipe_html_entities() {
// < and > escaped as HTML entities
assert_eq!(escape_pipe("<tag>"), "&lt;tag&gt;");
assert_eq!(escape_pipe("a<b"), "a&lt;b");
}
#[test]
fn test_emit_table_with_pipe_in_cell() {
// Cell with pipe character: escaped as \|
let table = TableJson {
id: "table_0".to_string(),
bbox: [0.0, 0.0, 200.0, 100.0],
rows: vec![
make_test_row(
vec![
make_test_cell("A|B", 0, 0, 1, 1, true),
make_test_cell("Normal", 0, 1, 1, 1, true),
],
true,
),
make_test_row(
vec![
make_test_cell("Data", 1, 0, 1, 1, false),
make_test_cell("Value", 1, 1, 1, 1, false),
],
false,
),
],
header_rows: 1,
detection_method: "line_based".to_string(),
continued: false,
continued_from_prev: false,
page_index: 0,
};
let md = emit_table(&table);
// Pipe should be escaped in the output
assert!(md.contains("A\\|B"));
// The table should still render correctly
assert!(md.contains("| --- | --- |"));
}
#[test]
fn test_emit_table_with_newline_in_cell() {
// Cell with newline: rendered with <br>
let table = TableJson {
id: "table_0".to_string(),
bbox: [0.0, 0.0, 200.0, 100.0],
rows: vec![
make_test_row(
vec![
make_test_cell("Line1\nLine2", 0, 0, 1, 1, true),
make_test_cell("Normal", 0, 1, 1, 1, true),
],
true,
),
make_test_row(
vec![
make_test_cell("Data", 1, 0, 1, 1, false),
make_test_cell("Value", 1, 1, 1, 1, false),
],
false,
),
],
header_rows: 1,
detection_method: "line_based".to_string(),
continued: false,
continued_from_prev: false,
page_index: 0,
};
let md = emit_table(&table);
// Newline should become <br> tag
assert!(md.contains("Line1<br>Line2"));
}
#[test]
fn test_emit_table_empty() {
// Empty table (no rows)
let table = TableJson {
id: "table_0".to_string(),
bbox: [0.0, 0.0, 100.0, 50.0],
rows: vec![],
header_rows: 0,
detection_method: "line_based".to_string(),
continued: false,
continued_from_prev: false,
page_index: 0,
};
let md = emit_table(&table);
// Empty table should return empty string
assert_eq!(md, "");
}
#[test]
fn test_emit_table_single_row() {
// Table with single row (no body rows)
let table = TableJson {
id: "table_0".to_string(),
bbox: [0.0, 0.0, 200.0, 50.0],
rows: vec![make_test_row(
vec![
make_test_cell("H1", 0, 0, 1, 1, true),
make_test_cell("H2", 0, 1, 1, 1, true),
],
true,
)],
header_rows: 1,
detection_method: "line_based".to_string(),
continued: false,
continued_from_prev: false,
page_index: 0,
};
let md = emit_table(&table);
// Should have header row and separator
assert!(md.contains("| H1 | H2 |"));
assert!(md.contains("| --- | --- |"));
// Should not have any body rows (no "| |" after separator)
let parts: Vec<&str> = md.lines().collect();
assert_eq!(parts.len(), 2); // Header row + separator
}
#[test]
fn test_emit_table_no_header() {
// Table with no header row (all rows are data)
let table = TableJson {
id: "table_0".to_string(),
bbox: [0.0, 0.0, 200.0, 100.0],
rows: vec![
make_test_row(
vec![
make_test_cell("D1", 0, 0, 1, 1, false),
make_test_cell("D2", 0, 1, 1, 1, false),
],
false,
),
make_test_row(
vec![
make_test_cell("D3", 1, 0, 1, 1, false),
make_test_cell("D4", 1, 1, 1, 1, false),
],
false,
),
],
header_rows: 0,
detection_method: "line_based".to_string(),
continued: false,
continued_from_prev: false,
page_index: 0,
};
let md = emit_table(&table);
// Should use first row as header for GFM
assert!(md.contains("| D1 | D2 |"));
assert!(md.contains("| --- | --- |"));
// Second row should be in body
assert!(md.contains("| D3 | D4 |"));
}
#[test]
fn test_emit_html_table_header_cells() {
// HTML table with is_header_row cells should use <th> tags
let table = TableJson {
id: "table_0".to_string(),
bbox: [0.0, 0.0, 200.0, 100.0],
rows: vec![
make_test_row(
vec![
make_test_cell("Header1", 0, 0, 1, 1, true), // is_header_row=true
make_test_cell("Header2", 0, 1, 1, 1, true),
],
true,
),
make_test_row(
vec![
make_test_cell("Data1", 1, 0, 1, 1, false), // is_header_row=false
make_test_cell("Data2", 1, 1, 1, 1, false),
],
false,
),
],
header_rows: 1,
detection_method: "line_based".to_string(),
continued: false,
continued_from_prev: false,
page_index: 0,
};
let md = emit_html_table(&table);
// First row should use <th> tags
assert!(md.contains("<th>Header1</th>"));
assert!(md.contains("<th>Header2</th>"));
// Second row should use <td> tags
assert!(md.contains("<td>Data1</td>"));
assert!(md.contains("<td>Data2</td>"));
}
#[test]
fn test_emit_html_table_row_and_colspan() {
// HTML table with both rowspan and colspan
let table = TableJson {
id: "table_0".to_string(),
bbox: [0.0, 0.0, 300.0, 200.0],
rows: vec![
make_test_row(
vec![
make_test_cell("Both", 0, 0, 2, 2, true), // rowspan=2, colspan=2
make_test_cell("H2", 0, 1, 1, 1, true),
],
true,
),
make_test_row(
vec![
make_test_cell("D1", 1, 0, 1, 1, false),
make_test_cell("D2", 1, 1, 1, 1, false),
],
false,
),
],
header_rows: 1,
detection_method: "line_based".to_string(),
continued: false,
continued_from_prev: false,
page_index: 0,
};
let md = emit_html_table(&table);
// Should have both colspan and rowspan attributes
assert!(md.contains("colspan=\"2\""));
assert!(md.contains("rowspan=\"2\""));
}
#[test]
fn test_emit_gfm_table_variable_width() {
// GFM table with different column counts per row
let table = TableJson {
id: "table_0".to_string(),
bbox: [0.0, 0.0, 300.0, 200.0],
rows: vec![
make_test_row(
vec![
make_test_cell("H1", 0, 0, 1, 1, true),
make_test_cell("H2", 0, 1, 1, 1, true),
make_test_cell("H3", 0, 2, 1, 1, true),
],
true,
),
make_test_row(
vec![
make_test_cell("D1", 1, 0, 1, 1, false),
make_test_cell("D2", 1, 1, 1, 1, false),
// Missing third cell - should pad
],
false,
),
],
header_rows: 1,
detection_method: "line_based".to_string(),
continued: false,
continued_from_prev: false,
page_index: 0,
};
let md = emit_table(&table);
// Should have 3 columns in all rows (padded with empty cells)
assert!(md.contains("| H1 | H2 | H3 |"));
assert!(md.contains("| --- | --- | --- |"));
// Second row should be padded
let lines: Vec<&str> = md.lines().collect();
let body_line = lines.get(2).unwrap();
assert_eq!(body_line.matches('|').count(), 4); // 4 pipes = 3 cells
}
}