1#[cfg(feature = "semantic")]
8use serde::{Deserialize, Serialize};
9#[cfg(feature = "semantic")]
10use std::collections::BTreeMap;
11
12use crate::pipeline::element::{Element, ElementBBox};
13use crate::pipeline::hybrid_chunking::split_into_sentences;
14
15pub(crate) struct Aggregates {
17 pub dominant_font: Option<String>,
18 pub dominant_font_size: Option<f64>,
19 pub is_bold: bool,
20 pub is_italic: bool,
21 pub min_confidence: f32,
22}
23
24impl Aggregates {
25 pub(crate) fn from_elements(elements: &[Element]) -> Self {
26 let mut font_weight: Vec<(String, usize)> = Vec::new();
27 let mut size_weight: Vec<(f64, usize)> = Vec::new();
28 let mut bold_chars = 0usize;
29 let mut italic_chars = 0usize;
30 let mut total_chars = 0usize;
31 let mut min_conf = 1.0f32;
32
33 for e in elements {
34 let w = e.text().chars().count();
35 total_chars += w;
36 let meta = e.metadata();
37 if let Some(f) = &meta.font_name {
38 match font_weight.iter_mut().find(|(name, _)| name == f) {
39 Some((_, c)) => *c += w,
40 None => font_weight.push((f.clone(), w)),
41 }
42 }
43 if let Some(s) = meta.font_size {
44 match size_weight.iter_mut().find(|(sz, _)| (*sz - s).abs() < 0.1) {
45 Some((_, c)) => *c += w,
46 None => size_weight.push((s, w)),
47 }
48 }
49 if meta.is_bold {
50 bold_chars += w;
51 }
52 if meta.is_italic {
53 italic_chars += w;
54 }
55 min_conf = min_conf.min(meta.confidence as f32);
56 }
57
58 let dominant_font = font_weight
59 .into_iter()
60 .max_by_key(|(_, c)| *c)
61 .map(|(name, _)| name);
62 let dominant_font_size = size_weight
63 .into_iter()
64 .max_by_key(|(_, c)| *c)
65 .map(|(sz, _)| sz);
66
67 Self {
68 dominant_font,
69 dominant_font_size,
70 is_bold: total_chars > 0 && bold_chars * 2 > total_chars,
71 is_italic: total_chars > 0 && italic_chars * 2 > total_chars,
72 min_confidence: if elements.is_empty() { 0.0 } else { min_conf },
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
86#[cfg_attr(feature = "semantic", derive(Serialize, Deserialize))]
87#[non_exhaustive]
88pub struct ContentTypeFlags {
89 pub has_table: bool,
91 pub has_list: bool,
93 pub has_code: bool,
95 pub heading_only: bool,
97}
98
99#[derive(Debug, Clone, Default, PartialEq, Eq)]
101#[cfg_attr(feature = "semantic", derive(Serialize, Deserialize))]
102#[non_exhaustive]
103pub struct DocumentSource {
104 pub title: Option<String>,
106 pub author: Option<String>,
108 pub creation_date: Option<String>,
110 pub filename: Option<String>,
112 pub doc_hash: Option<String>,
114 pub total_pages: Option<u32>,
116}
117
118impl DocumentSource {
119 pub fn with_file(filename: Option<String>, doc_hash: Option<String>) -> Self {
125 Self {
126 filename,
127 doc_hash,
128 ..Default::default()
129 }
130 }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq)]
137#[cfg_attr(feature = "semantic", derive(Serialize, Deserialize))]
138#[non_exhaustive]
139pub struct PageRegion {
140 pub page: u32,
142 pub bbox: ElementBBox,
144}
145
146#[derive(Debug, Clone, Default, PartialEq)]
148#[cfg_attr(feature = "semantic", derive(Serialize, Deserialize))]
149#[non_exhaustive]
150pub struct ChunkMetadata {
151 pub heading_path: Vec<String>,
153 pub dominant_font: Option<String>,
155 pub dominant_font_size: Option<f64>,
157 pub is_bold: bool,
159 pub is_italic: bool,
161 pub min_confidence: f32,
163 pub content_types: ContentTypeFlags,
165 pub char_count: usize,
167 pub word_count: usize,
169 pub sentence_count: usize,
171 pub language: Option<String>,
174 pub language_confidence: Option<f32>,
177 pub language_reliable: Option<bool>,
181 pub chunk_id: String,
183 pub prev_chunk_id: Option<String>,
185 pub next_chunk_id: Option<String>,
187 pub source: Option<DocumentSource>,
189 pub page_span: Option<(u32, u32)>,
192 pub page_regions: Vec<PageRegion>,
195 pub table_rows: Option<usize>,
198 pub table_cols: Option<usize>,
201 #[cfg(feature = "semantic")]
205 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
206 pub extra: BTreeMap<String, serde_json::Value>,
207}
208
209use sha2::{Digest, Sha256};
210
211impl ChunkMetadata {
212 pub(crate) fn from_elements(
217 elements: &[Element],
218 text: &str,
219 full_text: &str,
220 chunk_index: usize,
221 doc_hash: Option<&str>,
222 ) -> Self {
223 let agg = Aggregates::from_elements(elements);
224 let heading_path = elements
225 .first()
226 .map(|e| e.metadata().heading_path.clone())
227 .unwrap_or_default();
228 let (page_span, page_regions) = page_anchor(elements);
229 let (table_rows, table_cols) = table_dims(elements);
230 #[cfg(feature = "language-detection")]
232 let (language, language_confidence, language_reliable) = match detect_language_full(text) {
233 Some((code, conf, reliable)) => (Some(code), Some(conf), Some(reliable)),
234 None => (None, None, None),
235 };
236 #[cfg(not(feature = "language-detection"))]
237 let (language, language_confidence, language_reliable): (
238 Option<String>,
239 Option<f32>,
240 Option<bool>,
241 ) = (None, None, None);
242 ChunkMetadata {
243 heading_path,
244 dominant_font: agg.dominant_font,
245 dominant_font_size: agg.dominant_font_size,
246 is_bold: agg.is_bold,
247 is_italic: agg.is_italic,
248 min_confidence: agg.min_confidence,
249 content_types: content_type_flags(elements),
250 char_count: char_count(text),
251 word_count: word_count(text),
252 sentence_count: sentence_count(text),
253 language,
254 language_confidence,
255 language_reliable,
256 chunk_id: content_chunk_id(doc_hash, chunk_index, full_text),
257 prev_chunk_id: None,
258 next_chunk_id: None,
259 source: None,
260 page_span,
261 page_regions,
262 table_rows,
263 table_cols,
264 #[cfg(feature = "semantic")]
265 extra: BTreeMap::new(),
266 }
267 }
268}
269
270fn table_dims(elements: &[Element]) -> (Option<usize>, Option<usize>) {
273 elements
274 .iter()
275 .filter_map(|e| match e {
276 Element::Table(t) => Some(match &t.structure {
277 Some(st) => (st.num_rows, st.num_cols),
278 None => (
279 t.rows.len(),
280 t.rows.iter().map(|r| r.len()).max().unwrap_or(0),
281 ),
282 }),
283 _ => None,
284 })
285 .max_by_key(|(rows, _)| *rows)
286 .map(|(r, c)| (Some(r), Some(c)))
287 .unwrap_or((None, None))
288}
289
290fn union_bbox(a: ElementBBox, b: ElementBBox) -> ElementBBox {
292 let x = a.x.min(b.x);
293 let y = a.y.min(b.y);
294 let right = a.right().max(b.right());
295 let top = a.top().max(b.top());
296 ElementBBox::new(x, y, right - x, top - y)
297}
298
299fn page_anchor(elements: &[Element]) -> (Option<(u32, u32)>, Vec<PageRegion>) {
303 let mut by_page: Vec<(u32, ElementBBox)> = Vec::new();
304 for e in elements {
305 let page = e.metadata().page;
306 let bbox = *e.bbox();
307 match by_page.iter_mut().find(|(p, _)| *p == page) {
308 Some(slot) => slot.1 = union_bbox(slot.1, bbox),
309 None => by_page.push((page, bbox)),
310 }
311 }
312 if by_page.is_empty() {
313 return (None, Vec::new());
314 }
315 by_page.sort_by_key(|(p, _)| *p);
316 let span = (by_page.first().unwrap().0, by_page.last().unwrap().0);
317 let regions = by_page
318 .into_iter()
319 .map(|(page, bbox)| PageRegion { page, bbox })
320 .collect();
321 (Some(span), regions)
322}
323
324pub(crate) fn link_chunks(chunks: &mut [crate::pipeline::RagChunk]) {
326 let ids: Vec<String> = chunks.iter().map(|c| c.metadata.chunk_id.clone()).collect();
327 for (i, c) in chunks.iter_mut().enumerate() {
328 c.metadata.prev_chunk_id = if i > 0 {
329 Some(ids[i - 1].clone())
330 } else {
331 None
332 };
333 c.metadata.next_chunk_id = ids.get(i + 1).cloned();
334 }
335}
336
337#[cfg(feature = "language-detection")]
344pub fn detect_language(text: &str) -> Option<String> {
345 detect_language_full(text).map(|(code, _, _)| code)
346}
347
348#[cfg(feature = "language-detection")]
353pub(crate) fn detect_language_full(text: &str) -> Option<(String, f32, bool)> {
354 if text.trim().is_empty() {
355 return None;
356 }
357 whatlang::detect(text).map(|info| {
358 (
359 info.lang().code().to_string(),
360 info.confidence() as f32,
361 info.is_reliable(),
362 )
363 })
364}
365
366pub(crate) fn content_chunk_id(doc_hash: Option<&str>, index: usize, full_text: &str) -> String {
369 let doc_id = match doc_hash {
370 Some(h) => h.to_string(),
371 None => {
372 let mut hasher = Sha256::new();
373 hasher.update(full_text.as_bytes());
374 let digest = hasher.finalize();
375 digest[..8]
376 .iter()
377 .map(|b| format!("{b:02x}"))
378 .collect::<String>()
379 }
380 };
381 format!("{doc_id}:{index}")
382}
383
384pub(crate) fn content_type_flags(elements: &[Element]) -> ContentTypeFlags {
385 let mut flags = ContentTypeFlags::default();
386 let mut all_titles = !elements.is_empty();
387 for e in elements {
388 match e {
389 Element::Table(_) => flags.has_table = true,
390 Element::ListItem(_) => flags.has_list = true,
391 Element::CodeBlock(_) => flags.has_code = true,
392 _ => {}
393 }
394 if !matches!(e, Element::Title(_)) {
395 all_titles = false;
396 }
397 }
398 flags.heading_only = all_titles;
399 flags
400}
401
402pub(crate) fn char_count(text: &str) -> usize {
403 text.chars().count()
404}
405
406pub(crate) fn word_count(text: &str) -> usize {
407 text.split_whitespace().count()
408}
409
410pub(crate) fn sentence_count(text: &str) -> usize {
411 if text.trim().is_empty() {
412 return 0;
413 }
414 split_into_sentences(text).len()
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use crate::pipeline::element::{Element, ElementData, ElementMetadata, TableStructure};
421
422 fn table_el() -> Element {
423 Element::Table(crate::pipeline::element::TableElementData::new(
424 vec![],
425 crate::pipeline::element::ElementMetadata::default(),
426 ))
427 }
428
429 #[test]
430 fn content_types_and_counts() {
431 let els = vec![
432 para("Hello world. Second sentence!", "F", 10.0, false, 1.0),
433 table_el(),
434 ];
435 let flags = content_type_flags(&els);
436 assert!(flags.has_table);
437 assert!(!flags.has_list);
438 assert!(!flags.heading_only);
439
440 let text = "Hello world. Second sentence!";
441 assert_eq!(char_count(text), text.chars().count());
442 assert_eq!(word_count(text), 4);
443 assert_eq!(sentence_count(text), 2);
444 }
445
446 #[test]
447 fn heading_only_when_all_titles() {
448 let d = crate::pipeline::element::ElementData {
449 text: "Title".to_string(),
450 metadata: crate::pipeline::element::ElementMetadata::default(),
451 };
452 let els = vec![Element::Title(d)];
453 assert!(content_type_flags(&els).heading_only);
454 }
455
456 fn para(text: &str, font: &str, size: f64, bold: bool, conf: f64) -> Element {
457 let metadata = ElementMetadata {
458 font_name: Some(font.to_string()),
459 font_size: Some(size),
460 is_bold: bold,
461 confidence: conf,
462 ..ElementMetadata::default()
463 };
464 Element::Paragraph(ElementData {
465 text: text.to_string(),
466 metadata,
467 })
468 }
469
470 #[test]
471 fn aggregate_picks_char_weighted_dominant_font_and_min_confidence() {
472 let els = vec![
474 para("aaaa", "Helvetica", 12.0, true, 0.9),
475 para("bb", "Times", 10.0, false, 0.5),
476 ];
477 let agg = Aggregates::from_elements(&els);
478 assert_eq!(agg.dominant_font.as_deref(), Some("Helvetica"));
479 assert_eq!(agg.dominant_font_size, Some(12.0));
480 assert!(agg.is_bold, "4 bold chars vs 2 non-bold → bold majority");
481 assert!((agg.min_confidence - 0.5).abs() < 1e-6);
482 }
483
484 #[test]
485 fn chunk_id_is_deterministic_and_prefixed() {
486 let a = content_chunk_id(None, 0, "the quick brown fox");
487 let b = content_chunk_id(None, 0, "the quick brown fox");
488 assert_eq!(a, b, "same text + index → same id");
489 assert!(a.ends_with(":0"));
490 assert_eq!(
493 a.split(':').next().unwrap().len(),
494 16,
495 "hashless chunk_id prefix must be 16 hex chars (8 bytes)"
496 );
497
498 let with_hash = content_chunk_id(Some("dochash123"), 7, "ignored when hash present");
499 assert_eq!(with_hash, "dochash123:7");
500
501 let other = content_chunk_id(None, 0, "different text");
502 assert_ne!(a, other);
503 }
504
505 #[test]
506 fn chunk_metadata_default_is_empty() {
507 let m = ChunkMetadata::default();
508 assert!(m.heading_path.is_empty());
509 assert_eq!(m.dominant_font, None);
510 assert!(!m.is_bold);
511 assert_eq!(m.min_confidence, 0.0);
512 assert!(!m.content_types.has_table);
513 assert_eq!(m.char_count, 0);
514 assert_eq!(m.language, None);
515 assert_eq!(m.language_confidence, None);
516 assert_eq!(m.language_reliable, None);
517 assert_eq!(m.chunk_id, "");
518 assert!(m.source.is_none());
519 assert_eq!(m.page_span, None);
520 assert!(m.page_regions.is_empty());
521 assert_eq!(m.table_rows, None);
522 assert_eq!(m.table_cols, None);
523 }
524
525 #[test]
526 fn document_source_with_file_sets_only_supplied_fields() {
527 let s = DocumentSource::with_file(Some("doc.pdf".to_string()), Some("h7".to_string()));
528 assert_eq!(s.filename.as_deref(), Some("doc.pdf"));
529 assert_eq!(s.doc_hash.as_deref(), Some("h7"));
530 assert_eq!(s.title, None);
533 assert_eq!(s.author, None);
534 assert_eq!(s.creation_date, None);
535 assert_eq!(s.total_pages, None);
536
537 let empty = DocumentSource::with_file(None, None);
538 assert_eq!(empty, DocumentSource::default());
539 }
540
541 #[test]
542 fn build_metadata_from_chunk_elements() {
543 let els = vec![
544 para("aaaa", "Helvetica", 12.0, true, 0.8),
545 para("bb. cc.", "Helvetica", 12.0, false, 0.6),
546 ];
547 let text = "aaaa\nbb. cc.";
548 let m = ChunkMetadata::from_elements(&els, text, text, 3, None);
549 assert_eq!(m.dominant_font.as_deref(), Some("Helvetica"));
550 assert!((m.min_confidence - 0.6).abs() < 1e-6);
551 assert_eq!(m.char_count, text.chars().count());
552 assert_eq!(m.chunk_id, content_chunk_id(None, 3, text));
553 assert!(m.source.is_none());
554 #[cfg(not(feature = "language-detection"))]
557 assert_eq!(m.language, None);
558 }
559
560 fn el_at(text: &str, page: u32, x: f64, y: f64, w: f64, h: f64) -> Element {
561 Element::Paragraph(ElementData {
562 text: text.to_string(),
563 metadata: ElementMetadata {
564 page,
565 bbox: crate::pipeline::element::ElementBBox::new(x, y, w, h),
566 ..ElementMetadata::default()
567 },
568 })
569 }
570
571 #[test]
572 fn citation_anchor_page_span_and_per_page_union_bbox() {
573 let els = vec![
574 el_at("a", 1, 10.0, 700.0, 100.0, 20.0), el_at("b", 1, 50.0, 600.0, 200.0, 10.0), el_at("c", 2, 30.0, 500.0, 40.0, 40.0), ];
578 let text = "a\nb\nc";
579 let m = ChunkMetadata::from_elements(&els, text, text, 0, None);
580
581 assert_eq!(m.page_span, Some((1, 2)));
582 assert_eq!(m.page_regions.len(), 2);
583 assert_eq!(m.page_regions[0].page, 1);
585 assert_eq!(m.page_regions[1].page, 2);
586
587 let p1 = &m.page_regions[0].bbox;
589 assert_eq!(p1.x, 10.0);
590 assert_eq!(p1.y, 600.0);
591 assert_eq!(p1.right(), 250.0);
592 assert_eq!(p1.top(), 720.0);
593
594 let p2 = &m.page_regions[1].bbox;
596 assert_eq!(p2.x, 30.0);
597 assert_eq!(p2.right(), 70.0);
598 assert_eq!(p2.top(), 540.0);
599 }
600
601 #[test]
602 fn citation_anchor_empty_for_no_elements() {
603 let m = ChunkMetadata::from_elements(&[], "", "", 0, None);
604 assert_eq!(m.page_span, None);
605 assert!(m.page_regions.is_empty());
606 }
607
608 #[cfg(feature = "language-detection")]
609 #[test]
610 fn language_reliability_populated_alongside_code() {
611 let els = vec![para("x", "F", 10.0, false, 1.0)];
612 let text =
613 "The annual report summarizes the financial performance of the company over the year.";
614 let m = ChunkMetadata::from_elements(&els, text, text, 0, None);
615 assert_eq!(m.language.as_deref(), Some("eng"));
616 let conf = m
617 .language_confidence
618 .expect("confidence present when a language is detected");
619 assert!(
620 conf > 0.0 && conf <= 1.0,
621 "confidence must be in (0, 1], got {conf}"
622 );
623 assert_eq!(
624 m.language_reliable,
625 Some(true),
626 "a full English sentence must be a reliable detection"
627 );
628 }
629
630 #[cfg(feature = "language-detection")]
631 #[test]
632 fn language_reliability_none_for_empty_text() {
633 let m = ChunkMetadata::from_elements(&[], "", "", 0, None);
634 assert_eq!(m.language, None);
635 assert_eq!(m.language_confidence, None);
636 assert_eq!(m.language_reliable, None);
637 }
638
639 fn table_with(rows: Vec<Vec<&str>>) -> Element {
640 Element::Table(crate::pipeline::element::TableElementData::new(
641 rows.into_iter()
642 .map(|r| r.into_iter().map(String::from).collect())
643 .collect(),
644 ElementMetadata::default(),
645 ))
646 }
647
648 #[test]
649 fn table_dims_from_largest_table() {
650 let small = table_with(vec![vec!["a", "b"]]); let big = table_with(vec![vec!["a"], vec!["b"], vec!["c"]]); let els = vec![para("x", "F", 10.0, false, 1.0), small, big];
653 let text = "x";
654 let m = ChunkMetadata::from_elements(&els, text, text, 0, None);
655 assert_eq!(m.table_rows, Some(3));
657 assert_eq!(m.table_cols, Some(1));
658 }
659
660 #[test]
661 fn table_cols_uses_widest_row() {
662 let ragged = table_with(vec![vec!["a", "b"], vec!["c", "d", "e", "f"]]);
663 let m = ChunkMetadata::from_elements(&[ragged], "t", "t", 0, None);
664 assert_eq!(m.table_rows, Some(2));
665 assert_eq!(m.table_cols, Some(4));
666 }
667
668 #[test]
669 fn table_dims_none_without_table() {
670 let els = vec![para("just prose", "F", 10.0, false, 1.0)];
671 let m = ChunkMetadata::from_elements(&els, "just prose", "just prose", 0, None);
672 assert_eq!(m.table_rows, None);
673 assert_eq!(m.table_cols, None);
674 }
675
676 #[test]
677 fn table_dims_prefers_rich_structure() {
678 let el = Element::Table(crate::pipeline::element::TableElementData {
688 rows: vec![vec!["x".to_string()]],
689 structure: Some(TableStructure {
690 cells: vec![],
691 num_rows: 3,
692 num_cols: 4,
693 header_rows: 1,
694 }),
695 metadata: ElementMetadata::default(),
696 });
697 assert_eq!(table_dims(&[el]), (Some(3), Some(4)));
698 }
699
700 #[cfg(feature = "semantic")]
701 #[test]
702 fn extra_bag_defaults_empty_and_roundtrips() {
703 let mut m = ChunkMetadata::default();
704 assert!(m.extra.is_empty(), "extra defaults to empty");
705
706 let json_empty = serde_json::to_string(&m).unwrap();
708 assert!(
709 !json_empty.contains("\"extra\""),
710 "empty extra must be skipped in JSON"
711 );
712
713 m.extra
715 .insert("legal.clause_number".to_string(), serde_json::json!("3.2"));
716 m.extra.insert(
717 "legal.defined_terms".to_string(),
718 serde_json::json!(["Party", "Agreement"]),
719 );
720 let json = serde_json::to_string(&m).unwrap();
721 assert!(json.contains("\"extra\""));
722 let back: ChunkMetadata = serde_json::from_str(&json).unwrap();
723 assert_eq!(back.extra, m.extra, "extra survives round-trip");
724 assert_eq!(
725 back.extra.get("legal.clause_number").unwrap(),
726 &serde_json::json!("3.2")
727 );
728 }
729}