1#![allow(clippy::unwrap_used, clippy::expect_used)]
3use std::collections::BTreeMap;
9
10use serde_json::json;
11
12use super::types::{ExtractedTable, TableQuality, TableSummary};
13use crate::VecEmbedder;
14use crate::error::{MemvidError, Result};
15use crate::memvid::Memvid;
16use crate::types::embedding_identity::{
17 EmbeddingIdentity, MEMVID_EMBEDDING_DIMENSION_KEY, MEMVID_EMBEDDING_MODEL_KEY,
18 MEMVID_EMBEDDING_NORMALIZED_KEY, MEMVID_EMBEDDING_PROVIDER_KEY,
19};
20use crate::types::{FrameId, PutOptions};
21
22pub const TABLE_TRACK: &str = "tables";
24
25pub const TABLE_META_KIND: &str = "table_meta";
27
28pub const TABLE_ROW_KIND: &str = "table_row";
30
31pub fn store_table(
45 mem: &mut Memvid,
46 table: &ExtractedTable,
47 embed_rows: bool,
48) -> Result<(FrameId, Vec<FrameId>)> {
49 store_table_impl(mem, table, embed_rows, None, None)
50}
51
52pub fn store_table_with_embedder(
58 mem: &mut Memvid,
59 table: &ExtractedTable,
60 embed_rows: bool,
61 embedder: Option<&dyn VecEmbedder>,
62 embedding_identity: Option<&EmbeddingIdentity>,
63) -> Result<(FrameId, Vec<FrameId>)> {
64 store_table_impl(mem, table, embed_rows, embedder, embedding_identity)
65}
66
67fn store_table_impl(
68 mem: &mut Memvid,
69 table: &ExtractedTable,
70 embed_rows: bool,
71 embedder: Option<&dyn VecEmbedder>,
72 embedding_identity: Option<&EmbeddingIdentity>,
73) -> Result<(FrameId, Vec<FrameId>)> {
74 let table_id = &table.table_id;
75
76 let meta_payload = serde_json::to_vec(&json!({
78 "table_id": table_id,
79 "source_file": table.source_file,
80 "source_uri": table.source_uri,
81 "page_start": table.page_start,
82 "page_end": table.page_end,
83 "headers": table.headers,
84 "n_rows": table.n_rows,
85 "n_cols": table.n_cols,
86 "quality": table.quality.to_string(),
87 "detection_mode": table.detection_mode.to_string(),
88 "confidence_score": table.confidence_score,
89 "warnings": table.warnings,
90 "extraction_ms": table.extraction_ms,
91 }))
92 .map_err(|e| MemvidError::TableExtraction {
93 reason: format!("failed to serialize table metadata: {e}"),
94 })?;
95
96 let mut meta_extra: BTreeMap<String, String> = BTreeMap::new();
97 meta_extra.insert("table_id".to_string(), table_id.clone());
98 meta_extra.insert("n_rows".to_string(), table.n_rows.to_string());
99 meta_extra.insert("n_cols".to_string(), table.n_cols.to_string());
100 meta_extra.insert("page_start".to_string(), table.page_start.to_string());
101 meta_extra.insert("page_end".to_string(), table.page_end.to_string());
102 meta_extra.insert("quality".to_string(), table.quality.to_string());
103 meta_extra.insert(
104 "detection_mode".to_string(),
105 table.detection_mode.to_string(),
106 );
107
108 if let Ok(headers_json) = serde_json::to_string(&table.headers) {
110 meta_extra.insert("headers_json".to_string(), headers_json);
111 }
112
113 let meta_options = PutOptions {
114 timestamp: None,
115 track: Some(TABLE_TRACK.to_string()),
116 kind: Some(TABLE_META_KIND.to_string()),
117 uri: Some(format!("mv2://tables/{table_id}")),
118 title: Some(format!(
119 "Table from {} (pages {}-{})",
120 table.source_file, table.page_start, table.page_end
121 )),
122 metadata: None,
123 search_text: Some(table.to_search_text()),
124 tags: vec![
125 "table".to_string(),
126 table.source_file.clone(),
127 format!("{}_quality", table.quality),
128 ],
129 labels: vec![format!("{}_detected", table.detection_mode)],
130 extra_metadata: meta_extra,
131 enable_embedding: false, auto_tag: false,
133 extract_dates: false,
134 extract_triplets: false, parent_id: None,
136 role: crate::FrameRole::default(),
137 no_raw: false,
138 source_path: None,
139 dedup: false,
140 instant_index: false, extraction_budget_ms: 0, };
143
144 let meta_frame_id = mem.next_frame_id();
145 mem.put_bytes_with_options(&meta_payload, meta_options)?;
146
147 let mut row_frame_ids = Vec::with_capacity(table.rows.len());
149
150 for row in &table.rows {
151 if row.is_header_row {
153 continue;
154 }
155
156 let cell_map: serde_json::Map<String, serde_json::Value> = table
158 .headers
159 .iter()
160 .enumerate()
161 .filter_map(|(i, header)| {
162 row.cells
163 .get(i)
164 .map(|cell| (header.clone(), serde_json::Value::String(cell.text.clone())))
165 })
166 .collect();
167
168 let row_payload = serde_json::to_vec(&json!({
169 "table_id": table_id,
170 "row_index": row.row_index,
171 "page": row.page,
172 "cells": cell_map,
173 }))
174 .map_err(|e| MemvidError::TableExtraction {
175 reason: format!("failed to serialize row data: {e}"),
176 })?;
177
178 let search_text: String = row
180 .cells
181 .iter()
182 .map(|c| c.text.as_str())
183 .collect::<Vec<_>>()
184 .join(" ");
185
186 let mut row_extra: BTreeMap<String, String> = BTreeMap::new();
187 row_extra.insert("table_id".to_string(), table_id.clone());
188 row_extra.insert("row_index".to_string(), row.row_index.to_string());
189 row_extra.insert("page".to_string(), row.page.to_string());
190 row_extra.insert("parent_frame".to_string(), meta_frame_id.to_string());
191
192 let mut row_options = PutOptions {
193 timestamp: None,
194 track: Some(TABLE_TRACK.to_string()),
195 kind: Some(TABLE_ROW_KIND.to_string()),
196 uri: Some(format!("mv2://tables/{}/row/{}", table_id, row.row_index)),
197 title: None,
198 metadata: None,
199 search_text: Some(search_text),
200 tags: vec!["table_row".to_string(), table_id.clone()],
201 labels: Vec::new(),
202 extra_metadata: row_extra,
203 enable_embedding: embed_rows,
204 auto_tag: false,
205 extract_dates: true, extract_triplets: false, parent_id: None,
208 role: crate::FrameRole::default(),
209 no_raw: false,
210 source_path: None,
211 dedup: false,
212 instant_index: false, extraction_budget_ms: 0, };
215
216 let should_embed = embed_rows && embedder.is_some();
217 if should_embed {
218 let embedder = embedder.expect("checked above");
219 let text = row_options.search_text.as_deref().unwrap_or_default();
220 let embedding = embedder.embed_query(text)?;
221
222 if let Some(identity) = embedding_identity {
223 if let Some(provider) = identity.provider.as_deref() {
224 row_options.extra_metadata.insert(
225 MEMVID_EMBEDDING_PROVIDER_KEY.to_string(),
226 provider.to_string(),
227 );
228 }
229 if let Some(model) = identity.model.as_deref() {
230 row_options
231 .extra_metadata
232 .insert(MEMVID_EMBEDDING_MODEL_KEY.to_string(), model.to_string());
233 }
234 if let Some(dimension) = identity.dimension {
235 row_options.extra_metadata.insert(
236 MEMVID_EMBEDDING_DIMENSION_KEY.to_string(),
237 dimension.to_string(),
238 );
239 } else {
240 row_options.extra_metadata.insert(
241 MEMVID_EMBEDDING_DIMENSION_KEY.to_string(),
242 embedding.len().to_string(),
243 );
244 }
245 if let Some(normalized) = identity.normalized {
246 row_options.extra_metadata.insert(
247 MEMVID_EMBEDDING_NORMALIZED_KEY.to_string(),
248 normalized.to_string(),
249 );
250 }
251 } else {
252 row_options.extra_metadata.insert(
253 MEMVID_EMBEDDING_DIMENSION_KEY.to_string(),
254 embedding.len().to_string(),
255 );
256 }
257
258 let row_frame_id = mem.next_frame_id();
259 mem.put_with_embedding_and_options(&row_payload, embedding, row_options)?;
260 row_frame_ids.push(row_frame_id);
261 } else {
262 let row_frame_id = mem.next_frame_id();
263 mem.put_bytes_with_options(&row_payload, row_options)?;
264 row_frame_ids.push(row_frame_id);
265 }
266 }
267
268 Ok((meta_frame_id, row_frame_ids))
269}
270
271pub fn list_tables(mem: &mut Memvid) -> Result<Vec<TableSummary>> {
279 let meta_frame_ids: Vec<FrameId> = mem
281 .toc
282 .frames
283 .iter()
284 .enumerate()
285 .filter(|(_, frame)| frame.kind.as_deref() == Some(TABLE_META_KIND))
286 .map(|(id, _)| id as FrameId)
287 .collect();
288
289 let mut summaries = Vec::new();
290
291 for frame_id in meta_frame_ids {
293 let payload_bytes = mem.frame_canonical_payload(frame_id)?;
295 let payload = String::from_utf8_lossy(&payload_bytes);
296 let meta: serde_json::Value =
297 serde_json::from_str(&payload).map_err(|e| MemvidError::TableExtraction {
298 reason: format!("failed to parse table metadata: {e}"),
299 })?;
300
301 let table_id = meta["table_id"].as_str().unwrap_or("unknown").to_string();
302 let source_file = meta["source_file"]
303 .as_str()
304 .unwrap_or("unknown")
305 .to_string();
306 let page_start = meta["page_start"].as_u64().unwrap_or(0);
307 let page_end = meta["page_end"].as_u64().unwrap_or(0);
308 #[allow(clippy::cast_possible_truncation)]
310 let n_rows = meta["n_rows"].as_u64().unwrap_or(0) as usize;
311 #[allow(clippy::cast_possible_truncation)]
312 let n_cols = meta["n_cols"].as_u64().unwrap_or(0) as usize;
313 let quality = meta["quality"].as_str().unwrap_or("unknown").to_string();
314 let headers = meta["headers"]
315 .as_array()
316 .map(|arr| {
317 arr.iter()
318 .filter_map(|v| v.as_str())
319 .map(String::from)
320 .collect()
321 })
322 .unwrap_or_default();
323
324 summaries.push(TableSummary {
325 table_id,
326 source_file,
327 page_start: u32::try_from(page_start).unwrap_or(0),
328 page_end: u32::try_from(page_end).unwrap_or(0),
329 n_rows,
330 n_cols,
331 quality: quality.parse().unwrap_or(TableQuality::Medium),
332 headers,
333 frame_id,
334 });
335 }
336
337 Ok(summaries)
338}
339
340pub fn get_table(mem: &mut Memvid, table_id: &str) -> Result<Option<ExtractedTable>> {
349 let meta_frame_id: Option<FrameId> = mem
351 .toc
352 .frames
353 .iter()
354 .enumerate()
355 .find(|(_, f)| {
356 f.kind.as_deref() == Some(TABLE_META_KIND)
357 && f.extra_metadata
358 .get("table_id")
359 .is_some_and(|id| id == table_id)
360 })
361 .map(|(id, _)| id as FrameId);
362
363 let meta_frame_id = match meta_frame_id {
364 Some(id) => id,
365 None => return Ok(None),
366 };
367
368 let payload_bytes = mem.frame_canonical_payload(meta_frame_id)?;
370 let payload = String::from_utf8_lossy(&payload_bytes);
371 let meta: serde_json::Value =
372 serde_json::from_str(&payload).map_err(|e| MemvidError::TableExtraction {
373 reason: format!("failed to parse table metadata: {e}"),
374 })?;
375
376 let mut table = ExtractedTable::new(
378 meta["table_id"].as_str().unwrap_or(""),
379 meta["source_file"].as_str().unwrap_or(""),
380 );
381
382 table.source_uri = meta["source_uri"].as_str().map(String::from);
383 table.page_start = u32::try_from(meta["page_start"].as_u64().unwrap_or(1)).unwrap_or(1);
384 #[allow(clippy::cast_possible_truncation)]
385 {
386 table.page_end = meta["page_end"].as_u64().unwrap_or(1) as u32;
387 }
388 #[allow(clippy::cast_possible_truncation)]
389 {
390 table.n_cols = meta["n_cols"].as_u64().unwrap_or(0) as usize;
391 table.n_rows = meta["n_rows"].as_u64().unwrap_or(0) as usize;
392 }
393 #[allow(clippy::cast_possible_truncation)]
394 {
395 table.confidence_score = meta["confidence_score"].as_f64().unwrap_or(0.5) as f32;
396 }
397 table.extraction_ms = meta["extraction_ms"].as_u64().unwrap_or(0);
398
399 table.headers = meta["headers"]
400 .as_array()
401 .map(|arr| {
402 arr.iter()
403 .filter_map(|v| v.as_str())
404 .map(String::from)
405 .collect()
406 })
407 .unwrap_or_default();
408
409 table.warnings = meta["warnings"]
410 .as_array()
411 .map(|arr| {
412 arr.iter()
413 .filter_map(|v| v.as_str())
414 .map(String::from)
415 .collect()
416 })
417 .unwrap_or_default();
418
419 table.quality = meta["quality"]
420 .as_str()
421 .and_then(|s| s.parse().ok())
422 .unwrap_or(TableQuality::Medium);
423
424 let mut row_frame_ids: Vec<(FrameId, usize)> = mem
426 .toc
427 .frames
428 .iter()
429 .enumerate()
430 .filter(|(_, f)| {
431 f.kind.as_deref() == Some(TABLE_ROW_KIND)
432 && f.extra_metadata
433 .get("table_id")
434 .is_some_and(|id| id == table_id)
435 })
436 .map(|(id, f)| {
437 let row_index = f
438 .extra_metadata
439 .get("row_index")
440 .and_then(|s| s.parse::<usize>().ok())
441 .unwrap_or(0);
442 (id as FrameId, row_index)
443 })
444 .collect();
445
446 row_frame_ids.sort_by_key(|(_, row_index)| *row_index);
448
449 for (frame_id, _) in row_frame_ids {
451 let row_payload_bytes = mem.frame_canonical_payload(frame_id)?;
452 let row_payload = String::from_utf8_lossy(&row_payload_bytes);
453 let row_data: serde_json::Value =
454 serde_json::from_str(&row_payload).map_err(|e| MemvidError::TableExtraction {
455 reason: format!("failed to parse row data: {e}"),
456 })?;
457
458 #[allow(clippy::cast_possible_truncation)]
459 let row_index = row_data["row_index"].as_u64().unwrap_or(0) as usize;
460 #[allow(clippy::cast_possible_truncation)]
461 let page = row_data["page"].as_u64().unwrap_or(1) as u32;
462
463 let cells: Vec<super::types::TableCell> =
464 if let Some(cell_map) = row_data["cells"].as_object() {
465 table
466 .headers
467 .iter()
468 .enumerate()
469 .map(|(col_idx, header)| {
470 let text = cell_map
471 .get(header)
472 .and_then(|v| v.as_str())
473 .unwrap_or("")
474 .to_string();
475 super::types::TableCell::new(text, col_idx)
476 })
477 .collect()
478 } else {
479 Vec::new()
480 };
481
482 table
483 .rows
484 .push(super::types::TableRow::new(row_index, page, cells));
485 }
486
487 Ok(Some(table))
488}
489
490#[must_use]
498pub fn export_to_csv(table: &ExtractedTable) -> String {
499 let mut output = String::new();
500
501 if !table.headers.is_empty() {
503 let header_line: Vec<String> = table.headers.iter().map(|h| escape_csv_field(h)).collect();
504 output.push_str(&header_line.join(","));
505 output.push('\n');
506 }
507
508 for row in &table.rows {
510 if row.is_header_row {
511 continue;
512 }
513
514 let row_line: Vec<String> = row
515 .cells
516 .iter()
517 .map(|c| escape_csv_field(&c.text))
518 .collect();
519 output.push_str(&row_line.join(","));
520 output.push('\n');
521 }
522
523 output
524}
525
526fn escape_csv_field(field: &str) -> String {
528 if field.contains(',') || field.contains('"') || field.contains('\n') {
529 format!("\"{}\"", field.replace('"', "\"\""))
530 } else {
531 field.to_string()
532 }
533}
534
535pub fn export_to_json(table: &ExtractedTable, as_records: bool) -> Result<String> {
544 if as_records {
545 let records: Vec<serde_json::Value> = table
547 .data_rows()
548 .iter()
549 .map(|row| {
550 let mut obj = serde_json::Map::new();
551 for (i, header) in table.headers.iter().enumerate() {
552 let value = row.cells.get(i).map(|c| c.text.clone()).unwrap_or_default();
553 obj.insert(header.clone(), serde_json::Value::String(value));
554 }
555 serde_json::Value::Object(obj)
556 })
557 .collect();
558
559 serde_json::to_string_pretty(&records).map_err(|e| MemvidError::TableExtraction {
560 reason: format!("failed to serialize to JSON: {e}"),
561 })
562 } else {
563 serde_json::to_string_pretty(table).map_err(|e| MemvidError::TableExtraction {
565 reason: format!("failed to serialize to JSON: {e}"),
566 })
567 }
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573 use crate::table::types::{DetectionMode, TableCell, TableRow};
574
575 fn make_test_table() -> ExtractedTable {
576 let mut table = ExtractedTable::new("test_001", "test.pdf");
577 table.headers = vec!["Name".to_string(), "Age".to_string(), "City".to_string()];
578 table.n_cols = 3;
579 table.page_start = 1;
580 table.page_end = 1;
581 table.detection_mode = DetectionMode::Lattice;
582 table.quality = TableQuality::High;
583
584 let header_cells = vec![
586 TableCell::new("Name", 0).as_header(),
587 TableCell::new("Age", 1).as_header(),
588 TableCell::new("City", 2).as_header(),
589 ];
590 table
591 .rows
592 .push(TableRow::new(0, 1, header_cells).as_header());
593
594 table.rows.push(TableRow::new(
596 1,
597 1,
598 vec![
599 TableCell::new("Alice", 0),
600 TableCell::new("30", 1),
601 TableCell::new("New York", 2),
602 ],
603 ));
604 table.rows.push(TableRow::new(
605 2,
606 1,
607 vec![
608 TableCell::new("Bob", 0),
609 TableCell::new("25", 1),
610 TableCell::new("Los Angeles", 2),
611 ],
612 ));
613
614 table.n_rows = 2;
615 table
616 }
617
618 #[test]
619 fn test_export_to_csv() {
620 let table = make_test_table();
621 let csv = export_to_csv(&table);
622
623 assert!(csv.contains("Name,Age,City"));
624 assert!(csv.contains("Alice,30,New York"));
625 assert!(csv.contains("Bob,25,Los Angeles"));
626 }
627
628 #[test]
629 fn test_csv_escaping() {
630 assert_eq!(escape_csv_field("simple"), "simple");
631 assert_eq!(escape_csv_field("with,comma"), "\"with,comma\"");
632 assert_eq!(escape_csv_field("with\"quote"), "\"with\"\"quote\"");
633 assert_eq!(escape_csv_field("with\nnewline"), "\"with\nnewline\"");
634 }
635
636 #[test]
637 fn test_export_to_json_records() {
638 let table = make_test_table();
639 let json = export_to_json(&table, true).unwrap();
640
641 let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
642 assert_eq!(parsed.len(), 2);
643 assert_eq!(parsed[0]["Name"], "Alice");
644 assert_eq!(parsed[0]["Age"], "30");
645 }
646
647 #[test]
648 fn test_export_to_json_full() {
649 let table = make_test_table();
650 let json = export_to_json(&table, false).unwrap();
651
652 let parsed: ExtractedTable = serde_json::from_str(&json).unwrap();
653 assert_eq!(parsed.table_id, "test_001");
654 assert_eq!(parsed.headers.len(), 3);
655 }
656}