1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
//! Table-related types for document extraction.
use super::extraction::BoundingBox;
use serde::{Deserialize, Serialize};
/// Extracted table structure.
///
/// Represents a table detected and extracted from a document (PDF, image, etc.).
/// Tables are converted to both structured cell data and Markdown format.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct Table {
/// Table cells as a 2D vector (rows × columns)
pub cells: Vec<Vec<String>>,
/// Markdown representation of the table
pub markdown: String,
/// Page number where the table was found (1-indexed)
pub page_number: u32,
/// Bounding box of the table's position. Only populated when position data is
/// available from the producing extractor.
///
/// The coordinate space depends on how the table was produced, and callers must
/// know which route produced a given `Table` before interpreting this field:
///
/// - Tables extracted from a PDF's native content, and tables recognized on a
/// scanned PDF page that went through xberg's OCR pipeline (`--force-ocr` /
/// `--ocr-scanned-pages`), are in **PDF points with a bottom-left origin**
/// (x0=left, y0=bottom, x1=right, y1=top; y increases upward). For the OCR
/// case, the pipeline rescales the backend's raw pixel output into this space
/// before it reaches `Table::bounding_box` — see
/// `rescale_ocr_bboxes_to_page_points` in `extractors::pdf::ocr`.
/// - Tables detected by OCR on a standalone image with no backing PDF page (for
/// example extracting a bare PNG/JPEG/TIFF) are in **raster pixel coordinates
/// with a top-left origin** (x0=left, y0=top, x1=right, y1=bottom; y increases
/// downward) — the same convention the OCR backend (Tesseract, PaddleOCR,
/// candle-based backends) or the layout detector reported them in. There is no
/// PDF page geometry to rescale into for this case, so the raw pixel box is
/// passed through unchanged.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub bounding_box: Option<BoundingBox>,
/// Stable identifier shared by every `tables[]` entry that represents a
/// fragment of the same physical table.
///
/// Assigned deterministically by the extraction pipeline (e.g. a
/// sequential `"table-N"` in document order); never derived from
/// randomness or wall-clock time, so the same input document always
/// produces the same ids. Consumers can use it to reconcile the markdown
/// blocks in `content` / `pages[].content` / `chunks[].content` with the
/// structured entries in `tables[]`. `None` when the extractor did not
/// assign one.
///
/// Today, same-page fragments of one physical table are already merged
/// into a single `tables[]` entry before ids are assigned (see PDF table
/// stitching), so in practice `table_id` is unique per entry rather than
/// shared across several. A table split across a page boundary is
/// intentionally *not* linked — its per-page pieces get separate ids.
/// Sharing one id across page-boundary fragments is a known possible
/// future extension, not implemented yet.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub table_id: Option<String>,
/// Header cells for this fragment, i.e. the first row of `cells`.
///
/// Populated even when this fragment's own header row was merged away or
/// physically lives in a sibling fragment (see `table_id`), so a single
/// fragment is interpretable in isolation. `None` when no header row
/// could be determined.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub columns: Option<Vec<String>>,
}
/// Individual table cell with content and optional styling.
///
/// Future extension point for rich table support with cell-level metadata.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct TableCell {
/// Cell content as text
pub content: String,
/// Row span (number of rows this cell spans)
#[serde(default = "default_span")]
pub row_span: u32,
/// Column span (number of columns this cell spans)
#[serde(default = "default_span")]
pub col_span: u32,
/// Whether this is a header cell
#[serde(default)]
pub is_header: bool,
}
fn default_span() -> u32 {
1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_table_with_bounding_box_serialization() {
let table = Table {
cells: vec![
vec!["A".to_string(), "B".to_string()],
vec!["C".to_string(), "D".to_string()],
],
markdown: "| A | B |\n|---|---|\n| C | D |".to_string(),
page_number: 1,
bounding_box: Some(BoundingBox {
x0: 50.0,
y0: 100.0,
x1: 500.0,
y1: 700.0,
}),
..Default::default()
};
let json = serde_json::to_string(&table).unwrap();
assert!(json.contains("\"bounding_box\""));
assert!(json.contains("\"x0\":50.0"));
assert!(json.contains("\"y1\":700.0"));
let deserialized: Table = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.page_number, 1);
assert!(deserialized.bounding_box.is_some());
let bbox = deserialized.bounding_box.unwrap();
assert_eq!(bbox.x0, 50.0);
assert_eq!(bbox.y0, 100.0);
assert_eq!(bbox.x1, 500.0);
assert_eq!(bbox.y1, 700.0);
}
#[test]
fn test_table_without_bounding_box_serialization() {
let table = Table {
cells: vec![vec!["X".to_string()]],
markdown: "| X |".to_string(),
page_number: 2,
bounding_box: None,
..Default::default()
};
let json = serde_json::to_string(&table).unwrap();
assert!(!json.contains("bounding_box"));
let deserialized: Table = serde_json::from_str(&json).unwrap();
assert!(deserialized.bounding_box.is_none());
}
#[test]
fn test_table_deserialization_without_bounding_box_field() {
let json = r#"{"cells":[["A","B"]],"markdown":"| A | B |","page_number":1}"#;
let table: Table = serde_json::from_str(json).unwrap();
assert!(table.bounding_box.is_none());
assert_eq!(table.page_number, 1);
}
#[test]
fn test_table_bounding_box_clone_and_debug() {
let table = Table {
cells: vec![],
markdown: String::new(),
page_number: 1,
bounding_box: Some(BoundingBox {
x0: 10.0,
y0: 20.0,
x1: 30.0,
y1: 40.0,
}),
..Default::default()
};
let cloned = table.clone();
assert_eq!(cloned.bounding_box, table.bounding_box);
let debug = format!("{:?}", table);
assert!(debug.contains("bounding_box"));
}
#[test]
fn test_table_bounding_box_values_preserved() {
let original = Table {
cells: vec![
vec!["Header1".to_string(), "Header2".to_string()],
vec!["Val1".to_string(), "Val2".to_string()],
],
markdown: "| Header1 | Header2 |\n|---|---|\n| Val1 | Val2 |".to_string(),
page_number: 3,
bounding_box: Some(BoundingBox {
x0: 72.0,
y0: 200.5,
x1: 540.0,
y1: 600.75,
}),
..Default::default()
};
let json_value = serde_json::to_value(&original).unwrap();
let deserialized: Table = serde_json::from_value(json_value).unwrap();
assert_eq!(deserialized.cells, original.cells);
assert_eq!(deserialized.markdown, original.markdown);
assert_eq!(deserialized.page_number, original.page_number);
assert_eq!(deserialized.bounding_box, original.bounding_box);
}
#[test]
fn test_table_id_and_columns_serialize_when_present() {
let table = Table {
cells: vec![
vec!["Name".to_string(), "Age".to_string()],
vec!["Alice".to_string(), "30".to_string()],
],
markdown: "| Name | Age |\n|---|---|\n| Alice | 30 |".to_string(),
page_number: 1,
table_id: Some("table-1".to_string()),
columns: Some(vec!["Name".to_string(), "Age".to_string()]),
..Default::default()
};
let json = serde_json::to_string(&table).unwrap();
assert!(json.contains("\"table_id\":\"table-1\""));
assert!(json.contains("\"columns\":[\"Name\",\"Age\"]"));
let deserialized: Table = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.table_id.as_deref(), Some("table-1"));
assert_eq!(deserialized.columns, Some(vec!["Name".to_string(), "Age".to_string()]));
}
#[test]
fn test_table_id_and_columns_omitted_when_absent() {
let table = Table {
cells: vec![vec!["X".to_string()]],
markdown: "| X |".to_string(),
page_number: 1,
..Default::default()
};
let json = serde_json::to_string(&table).unwrap();
assert!(!json.contains("table_id"));
assert!(!json.contains("columns"));
let deserialized: Table = serde_json::from_str(&json).unwrap();
assert!(deserialized.table_id.is_none());
assert!(deserialized.columns.is_none());
}
#[test]
fn test_table_deserialization_without_table_id_or_columns_fields() {
let json = r#"{"cells":[["A","B"]],"markdown":"| A | B |","page_number":1}"#;
let table: Table = serde_json::from_str(json).unwrap();
assert!(table.table_id.is_none());
assert!(table.columns.is_none());
}
}