- 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>
2031 lines
61 KiB
Rust
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("<");
|
|
}
|
|
'>' => {
|
|
// Escape > to prevent HTML injection
|
|
result.push_str(">");
|
|
}
|
|
_ => 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>"), "<tag>");
|
|
assert_eq!(escape_pipe("a<b"), "a<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
|
|
}
|
|
}
|