document2graph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,3 @@
1
+ from .extractor import BaselineExtractor
2
+
3
+ __all__ = ["BaselineExtractor"]
@@ -0,0 +1,82 @@
1
+ import os
2
+ import uuid
3
+
4
+ from ..utils.hybrid_chunker_extractor import HybridChunkerExtractor
5
+ from tqdm import tqdm
6
+ import json
7
+ from ..models.Chunk import Chunk
8
+ from ..models.ExtractorConfig import ExtractorConfig
9
+ from typing import List
10
+
11
+ from ..utils.log import Log
12
+
13
+ _log = Log("BaselineExtractor")
14
+
15
+ class BaselineExtractor:
16
+ def __init__(self, config: ExtractorConfig):
17
+ self.logger = _log.logger
18
+ self.pdf_path = config.pdf_path
19
+ self.data_path = config.data_path
20
+ self.raw_text_save_dir = os.path.join(self.data_path, "raw_texts/")
21
+ self.pdfPipelineOptions = config.pdfPipelineOptions
22
+ self.save_json = config.save_json
23
+ self.document_type = config.document_type
24
+
25
+ def _get_clean_filename(self, filename: str) -> str:
26
+ return os.path.splitext(os.path.basename(filename))[0]
27
+
28
+ def extract_baseline_chunks(self, filename: str, baseline_description: str, enrich: bool = False) -> dict[str, List[Chunk]]:
29
+ """Extract baseline chunks from the raw text files for a given document."""
30
+ sample = os.path.join(self.pdf_path, filename)
31
+ clean_filename = self._get_clean_filename(sample)
32
+ baseline_extractor = HybridChunkerExtractor(
33
+ source=sample,
34
+ merge_peers=True,
35
+ pipeline_options=self.pdfPipelineOptions
36
+ )
37
+ chunks = []
38
+ enriched_chunks = []
39
+ for chunk in baseline_extractor.extract_and_chunk(save_dir=self.raw_text_save_dir, filename=clean_filename):
40
+ meta = chunk.meta.export_json_dict()
41
+
42
+ chunks.append(Chunk(
43
+ chunk_id=str(uuid.uuid4()),
44
+ document_id=filename,
45
+ text=chunk.text,
46
+ meta=meta,
47
+ baseline_description=baseline_description
48
+ ))
49
+ if enrich:
50
+ enriched_text = baseline_extractor.chunker.contextualize(chunk)
51
+ enriched_chunks.append(Chunk(
52
+ chunk_id=str(uuid.uuid4()),
53
+ document_id=filename,
54
+ text=enriched_text,
55
+ meta=meta,
56
+ baseline_description=f"Enriched; {baseline_description}"
57
+ ))
58
+ return {"baseline": chunks, "enriched": enriched_chunks}
59
+
60
+ def generate_baseline_chunks(self, return_chunks: bool = False) -> dict[str, List[Chunk]] | None:
61
+ """Generate baseline chunks for all documents in the pdf_path."""
62
+ all_chunks = {}
63
+ for filename in tqdm(os.listdir(self.pdf_path)):
64
+ clean_filename = self._get_clean_filename(filename)
65
+ if filename.endswith(".pdf"):
66
+ self.logger.info(f"Extracting baseline chunks for {filename}...")
67
+ baseline_description = f"Baseline chunks for {filename}"
68
+ chunks = self.extract_baseline_chunks(filename, baseline_description, enrich=True)
69
+ all_chunks[filename] = chunks
70
+
71
+ # by default, the chunks are saved as json
72
+ if self.save_json:
73
+ baseline_json = os.path.join(self.data_path, "baseline_chunks/")
74
+ os.makedirs(baseline_json, exist_ok=True)
75
+ with open(f"{self.raw_text_save_dir}/{clean_filename}_baseline_chunks.json", "w") as f:
76
+ json.dump([chunk.model_dump() for chunk in chunks["baseline"]], f, indent=4)
77
+ if len(chunks["enriched"]) > 0:
78
+ with open(f"{self.raw_text_save_dir}/{clean_filename}_enriched_chunks.json", "w") as f:
79
+ json.dump([chunk.model_dump() for chunk in chunks["enriched"]], f, indent=4)
80
+
81
+
82
+ return all_chunks if return_chunks else None
@@ -0,0 +1,3 @@
1
+ from .extractor import DocumentGraphExtractor
2
+
3
+ __all__ = ["DocumentGraphExtractor"]
@@ -0,0 +1,146 @@
1
+ import os
2
+
3
+ from .snippet_graph_constructor import SnippetGraphConstructor
4
+ from ..utils.base_extractor import Extractor
5
+ from tqdm import tqdm
6
+ import json
7
+ from docling_parse.pdf_parser import DoclingPdfParser, PdfDocument
8
+ from ..models.Snippet import Snippet
9
+ from ..models.ExtractorConfig import ExtractorConfig
10
+ from typing import Any, List
11
+ from ..utils.log import Log
12
+
13
+
14
+ class SnippetType:
15
+ TEXT = "text"
16
+ IMAGE = "image"
17
+ TABLE = "table"
18
+
19
+ class DocumentGraphExtractor:
20
+ def __init__(self, config: ExtractorConfig):
21
+ self.pdf_path = config.pdf_path
22
+ self.data_path = config.data_path
23
+ self.pdfPipelineOptions = config.pdfPipelineOptions
24
+ self.save_json = config.save_json
25
+ self.logger = Log("DocumentGraphExtractor").logger
26
+ self.document_type = config.document_type
27
+ self.metadata_config = config.metadata_config
28
+ self.edge_weights = config.edge_weights
29
+
30
+ def _run(self, filename: str) -> dict[str, Any]:
31
+ sample = os.path.join(self.pdf_path, filename)
32
+ clean_filename = os.path.splitext(os.path.basename(sample))[0]
33
+ raw_text_save_dir = os.path.join(self.data_path, "raw_texts/")
34
+ graph_save_dir = os.path.join(self.data_path, "nx_graphs/")
35
+ os.makedirs(raw_text_save_dir, exist_ok=True)
36
+ os.makedirs(graph_save_dir, exist_ok=True)
37
+
38
+ extractor = Extractor(
39
+ source=sample, pipeline_options=self.pdfPipelineOptions
40
+ )
41
+ extractor.extract(save_dir=raw_text_save_dir, filename=clean_filename)
42
+ parser = DoclingPdfParser()
43
+ pdf_doc: PdfDocument = parser.load(path_or_stream=sample)
44
+
45
+ snippet_to_graph = SnippetGraphConstructor(
46
+ pdf_doc,
47
+ extractor.docling_doc.document,
48
+ clean_filename,
49
+ document_type=self.document_type,
50
+ metadata_config=self.metadata_config,
51
+ edge_weights=self.edge_weights,
52
+ )
53
+ text_nodes, image_nodes, table_nodes, edges = snippet_to_graph.get_graph(
54
+ save_to=f"{graph_save_dir}/{clean_filename}.gexf"
55
+ )
56
+ doc_metadata = snippet_to_graph.document_metadata
57
+
58
+ return {
59
+ "text_nodes": text_nodes,
60
+ "image_nodes": image_nodes,
61
+ "table_nodes": table_nodes,
62
+ "edges": edges,
63
+ "document_metadata": doc_metadata,
64
+ "clean_filename": clean_filename
65
+ }
66
+
67
+ def run(self, filename: str) -> dict[str, Any]:
68
+ """Process a single PDF and return its graph components.
69
+
70
+ Returns a dict with keys: text_nodes, image_nodes, table_nodes,
71
+ edges (list of (parent_id, child_id, weight) tuples),
72
+ document_metadata, clean_filename.
73
+ """
74
+ return self._run(filename)
75
+
76
+
77
+ def _get_text_value_for_snippet_type(self, node, snippet_type:str) -> str:
78
+ if snippet_type == SnippetType.TEXT:
79
+ return node.text
80
+ elif snippet_type == SnippetType.IMAGE:
81
+ return node.caption_text
82
+ elif snippet_type == SnippetType.TABLE:
83
+ return node.markdown_serialization
84
+ else:
85
+ raise ValueError(f"Unknown snippet type: {snippet_type}")
86
+
87
+ def _node_to_snippet(
88
+ self, node, snippet_type: str
89
+ ) -> Snippet:
90
+ """Convert a node (text, image, or table) to a Document Snippet.
91
+
92
+ Args:
93
+ node: TextSnippetNode or ImageSnippetNode or TableSnippetNode
94
+ snippet_type: SnippetType.TEXT or SnippetType.IMAGE or SnippetType.TABLE
95
+ """
96
+ text_value = self._get_text_value_for_snippet_type(node, snippet_type)
97
+ snippet = Snippet(
98
+ snippet_id=node.snippet_id,
99
+ type=snippet_type,
100
+ document_id=str(node.document_id),
101
+ sequence_no=node.sequence_no,
102
+ label=node.label,
103
+ level=node.level,
104
+ level_label=node.level_label,
105
+ parent_id=node.parent_id,
106
+ is_grouped=node.is_grouped,
107
+ page_no=node.page_no,
108
+ bbox=node.bbox,
109
+ text=text_value,
110
+ docling_parent_ref=node.docling_parent_ref,
111
+ docling_self_ref=node.docling_self_ref
112
+ )
113
+ return snippet
114
+
115
+ def generate_snippets(self, return_snippets: bool = False) -> List[Snippet] | None:
116
+ """
117
+ Main method to run the entire extraction and graph construction pipeline for all documents.
118
+ """
119
+ all_snippets = []
120
+ for file in tqdm(os.listdir(self.pdf_path)):
121
+ result = self._run(file)
122
+
123
+ # Convert nodes to snippets
124
+ text_snippets = [
125
+ self._node_to_snippet(tn, SnippetType.TEXT)
126
+ for tn in result["text_nodes"]
127
+ ]
128
+ image_snippets = [
129
+ self._node_to_snippet(inode, SnippetType.IMAGE)
130
+ for inode in result["image_nodes"]
131
+ ]
132
+ table_snippets = [
133
+ self._node_to_snippet(tn, SnippetType.TABLE)
134
+ for tn in result["table_nodes"]
135
+ ]
136
+ snippets = text_snippets + image_snippets + table_snippets
137
+
138
+ # default config is to save the snippets as json, but optionally can skip this step and just return the snippets
139
+ if self.save_json:
140
+ snippets_json_dir = os.path.join(self.data_path, "snippets/")
141
+ os.makedirs(snippets_json_dir, exist_ok=True)
142
+ with open(os.path.join(snippets_json_dir, f"{result['clean_filename']}_snippets.json"), "w") as f:
143
+ json.dump([snippet.model_dump() for snippet in snippets], f, indent=4)
144
+ if return_snippets:
145
+ all_snippets.extend(snippets)
146
+ return all_snippets if return_snippets else None
@@ -0,0 +1,424 @@
1
+ from docling_core.types.doc.document import TextItem, SectionHeaderItem, PictureItem, TableItem, RefItem, DoclingDocument
2
+ from docling_parse.pdf_parser import PdfDocument
3
+ from docling_core.types.doc.page import TextCellUnit
4
+ import numpy as np
5
+ from collections import defaultdict
6
+ import regex as re
7
+ import networkx as nx
8
+ from ..models.TextSnippet import TextSnippet
9
+ from ..models.TextSnippetNode import TextSnippetNode
10
+ from ..models.ImageSnippetNode import ImageSnippetNode
11
+ from ..models.TableSnippetNode import TableSnippetNode
12
+ from ..models.Document import Document
13
+ from ..models.DocumentMetadata import DocumentMetadata, MetadataExtractionConfig
14
+ from ..models.EdgeWeightConfig import EdgeWeightConfig
15
+ import uuid
16
+ from typing import TypeVar
17
+
18
+ from ..utils.doc_to_tree_lib import get_heights, get_height_hist, get_text_body_height, compute_snippet_level
19
+
20
+ T = TypeVar('T', bound=ImageSnippetNode)
21
+
22
+ ROOT_NODE_ID = "#/document-root"
23
+
24
+ SnippetNode = TextSnippetNode | ImageSnippetNode | TableSnippetNode
25
+ WeightedEdge = tuple[str, str, float]
26
+
27
+ class SnippetGraphConstructor():
28
+
29
+ def __init__(self, pdf_doc: PdfDocument, docling_doc: DoclingDocument, filename: str, document_type: str, metadata_config: MetadataExtractionConfig | None = None, edge_weights: EdgeWeightConfig | None = None):
30
+ self.pdf_doc = pdf_doc
31
+ self.docling_doc = docling_doc
32
+ self.edge_weights = edge_weights or EdgeWeightConfig()
33
+ self.text_items = [TextSnippet(text_item=item, line_heights=self.add_line_heights(item)) for item in docling_doc.texts]
34
+ self.table_items = docling_doc.tables
35
+ self.image_items = docling_doc.pictures
36
+ self.heights = get_heights(self.text_items, skip_first_page=True)
37
+ self._document_metadata = self.extract_doc_metadata(filename, document_type, metadata_config or MetadataExtractionConfig())
38
+ self.section_header_levels, self.section_level_to_label, self.text_body_levels, self.text_level_to_label = self.compute_doc_levels() # compute document levels once and pass to methods that need it
39
+
40
+ # document metadata extraction methods can be added here
41
+ def __safe_check_substring(self, search_string: str, item: TextItem) -> bool:
42
+ try:
43
+ return search_string.lower() in item.text.lower()
44
+ except Exception as e:
45
+ print(f"Error checking item text: {e}")
46
+ return False
47
+
48
+ def __match_metadata_by_section_header(self, section_header: SectionHeaderItem | TextItem, texts: list[TextItem]) -> RefItem | None:
49
+ bottom = section_header.prov[0].bbox.b
50
+ left = section_header.prov[0].bbox.l
51
+
52
+ for text in texts:
53
+ text_top = text.prov[0].bbox.t
54
+ text_left = text.prov[0].bbox.l
55
+ if (abs(bottom - text_top) < 6) and (abs(left - text_left) < 6) and text.parent is not None and section_header.parent is not None:
56
+ if text.parent.get_ref() == section_header.parent.get_ref():
57
+ return text.get_ref()
58
+ if text.parent.get_ref() != section_header.parent.get_ref():
59
+ return text.parent.get_ref()
60
+ return None
61
+
62
+ @property
63
+ def document_metadata(self):
64
+ return self._document_metadata
65
+
66
+ def add_line_heights(self, snippet: TextItem) -> list[float]:
67
+ lines = self.pdf_doc.get_page(snippet.prov[0].page_no).get_cells_in_bbox(cell_unit=TextCellUnit.LINE, bbox=snippet.prov[0].bbox)
68
+ heights = []
69
+ for line in lines:
70
+ # check if line is in snippet bbox
71
+ line_bbox = line.rect
72
+ if line.text in snippet.text:
73
+ heights.append(
74
+ ((line_bbox.r_y3 - line_bbox.r_y0) + (line_bbox.r_y2 - line_bbox.r_y1)) / 2
75
+ )
76
+ return [round(h, 1) for h in heights]
77
+
78
+ def compute_section_header_levels(self) -> tuple[dict[int, int], dict[int, str]]:
79
+ all_heights = self.heights
80
+ if not all_heights.size:
81
+ return {}, {}
82
+ section_headers = [s for s in self.text_items if isinstance(s.text_item, SectionHeaderItem)]
83
+ section_header_height_hist = get_height_hist(get_heights(section_headers, skip_first_page=True))
84
+ unique_section_header_heights = sorted(section_header_height_hist.keys(), reverse=True)
85
+ text_body_height, iqr = get_text_body_height(all_heights)
86
+
87
+ # group levels that are very close to each other on text body level (within iqr)
88
+ level_dict = defaultdict(int)
89
+ level_to_label = defaultdict(str)
90
+ level = 0
91
+ for h in unique_section_header_heights:
92
+ if(h >= text_body_height - iqr and h <= text_body_height + iqr):
93
+ level_dict[int(h)] = level # body text headers, usually just bold printed text
94
+ level_to_label[level] = "Heading"
95
+ else:
96
+ level_dict[int(h)] = level
97
+ level_to_label[level] = "Heading"
98
+ level += 1
99
+
100
+ # check if largest height is unique -> title
101
+ if len(unique_section_header_heights) > 0 and (section_header_height_hist[unique_section_header_heights[0]] == 1):
102
+ level_to_label[0] = "Title"
103
+
104
+ return level_dict, level_to_label
105
+
106
+ def compute_text_body_levels(self, num_section_headers: int) -> tuple[dict[int, int], dict[int, str]]:
107
+ all_heights = self.heights
108
+ if not all_heights.size:
109
+ return {}, {}
110
+
111
+ texts = [s for s in self.text_items if not isinstance(s.text_item, SectionHeaderItem)]
112
+
113
+ text_body_heights = get_heights(texts, skip_first_page=True)
114
+ text_body_height_hist = get_height_hist(text_body_heights)
115
+ unique_text_body_heights = sorted(text_body_height_hist.keys(), reverse=True)
116
+ text_body_height, iqr = get_text_body_height(all_heights)
117
+ subtext_heights = [h for h in all_heights if h < text_body_height - iqr]
118
+ subtext_median = np.percentile(subtext_heights, 50) if subtext_heights else 0
119
+
120
+
121
+ level_dict = defaultdict(int)
122
+ level_to_label = defaultdict(str)
123
+ level = num_section_headers # continue after section header levels
124
+ subtexts = [h for h in unique_text_body_heights if h < text_body_height - iqr]
125
+
126
+ for h in unique_text_body_heights:
127
+ if(h >= text_body_height - iqr):
128
+ level_dict[int(h)] = level # body text
129
+ level_to_label[ level ] = "Body"
130
+ else:
131
+ level += 1
132
+ break
133
+
134
+ for h in subtexts:
135
+ if(h >= subtext_median - iqr and h <= subtext_median + iqr):
136
+ level_dict[int(h)] = level # subtext
137
+ level_to_label[ level ] = "Subtext"
138
+ else:
139
+ level += 1
140
+ level_dict[int(h)] = level # smaller subtext, footnotes, etc.
141
+ level_to_label[ level ] = "Subsubtext"
142
+
143
+ return level_dict, level_to_label
144
+
145
+ def compute_doc_levels(self) -> tuple[dict[int, int], dict[int, str], dict[int, int], dict[int, str]]:
146
+ # section headers
147
+ section_header_levels, section_level_to_label = self.compute_section_header_levels()
148
+
149
+ # text body levels
150
+ text_body_levels, text_level_to_label = self.compute_text_body_levels(num_section_headers=max(section_header_levels.values())+1)
151
+
152
+ return section_header_levels, section_level_to_label, text_body_levels, text_level_to_label
153
+
154
+ def compute_parent_id(self, node: TextSnippetNode, nodes: list[TextSnippetNode]) -> str | None:
155
+ if(node.is_grouped):
156
+ # first case: if node is grouped, parent is the adjacent non-grouped node
157
+ group = [n for n in nodes if n.docling_parent_ref.get_ref().cref == node.docling_parent_ref.get_ref().cref]
158
+ first_bullet = min(group, key=lambda n: n.sequence_no)
159
+ return nodes[first_bullet.sequence_no - 1].snippet_id if first_bullet.sequence_no > 0 else None
160
+
161
+ elif(node.docling_parent_ref.get_ref().cref != "#/body"):
162
+ # second case: if node has a meaningful parent_id from docling, use it
163
+ parent_ref = node.docling_parent_ref.get_ref().cref
164
+ return parent_ref
165
+ # default case for text items with inaccurate parent_id from docling: find the closest preceding node with a lower level
166
+ for prev_node in reversed(nodes[:node.sequence_no]):
167
+ if prev_node.level < node.level:
168
+ return prev_node.snippet_id
169
+ return None
170
+
171
+ def compute_text_nodes(self, snippets: list[TextSnippet]) -> list[TextSnippetNode]:
172
+ nodes = []
173
+ for i, snippet in enumerate(snippets):
174
+ if isinstance(snippet.text_item, SectionHeaderItem):
175
+ snippet_level = compute_snippet_level(snippet, self.section_header_levels)
176
+ snippet_level_label = self.section_level_to_label.get(snippet_level, "unknown")
177
+ else:
178
+ snippet_level = compute_snippet_level(snippet, self.text_body_levels)
179
+ snippet_level_label = self.text_level_to_label.get(snippet_level, "unkown")
180
+ assert snippet.text_item.parent is not None, f"Text item {snippet.text_item.text} has no parent reference."
181
+ node = TextSnippetNode(
182
+ snippet_id=snippet.text_item.self_ref,
183
+ document_id=self._document_metadata.document_id,
184
+ label=snippet.text_item.label,
185
+ sequence_no=i,
186
+ level=snippet_level,
187
+ level_label=snippet_level_label,
188
+ parent_id="placeholder", # compute later
189
+ docling_parent_ref=snippet.text_item.parent.get_ref(),
190
+ docling_self_ref=snippet.text_item.get_ref(),
191
+ is_grouped=True if "group" in snippet.text_item.parent.get_ref().cref else False,
192
+ text=snippet.text_item.text,
193
+ bbox=snippet.text_item.prov[0].bbox,
194
+ charspan=snippet.text_item.prov[0].charspan,
195
+ page_no=snippet.text_item.prov[0].page_no
196
+ )
197
+ nodes.append(node)
198
+ nodes = [node.model_copy(update={"parent_id": self.compute_parent_id(node, nodes)}) for node in nodes]
199
+ for node in nodes:
200
+ if node.parent_id is None:
201
+ print(f"Node {node.text} has no parent_id assigned.")
202
+ elif node.parent_id == "placeholder":
203
+ print(f"Node {node.text} has placeholder parent_id.")
204
+ return nodes
205
+
206
+ def rb_image_parent_matching(self, images: list[T], text_nodes: list[TextSnippetNode]) -> list[T]:
207
+ # find text node that contains reference to image or table using a rule-based approach
208
+ ret_images = images.copy()
209
+ for i, image in enumerate(ret_images):
210
+ matched=False
211
+ if image.caption_nodes is not None:
212
+ # get the full caption text
213
+ caption = " ".join([node.text for node in image.caption_nodes])
214
+ caption_node_ids = [node.snippet_id for node in image.caption_nodes]
215
+ rules = ["Figure", "Fig.", "Table", "Abbildung", "Tabelle", "Abb.", "Tab."]
216
+ for rule in rules:
217
+ if rule in caption:
218
+ regex_pattern = rf"{rule}\s*\d+"
219
+ match = re.search(regex_pattern, caption, re.IGNORECASE)
220
+ if match:
221
+ matched_pattern = rf"{match.group(0)}"
222
+ # make sure we do not match caption nodes
223
+ text_item = [node for node in text_nodes if re.search(matched_pattern, node.text, re.IGNORECASE) and node.snippet_id not in caption_node_ids]
224
+ if text_item and text_item[0].parent_id is not None:
225
+ # we assign the level and parent_id of the matched text item to the image
226
+ # the first matched text item is taken
227
+ ret_images[i] = image.model_copy(update={"level": text_item[0].level, "level_label": text_item[0].level_label, "parent_id": text_item[0].parent_id})
228
+ matched=True
229
+ break
230
+ if not matched:
231
+ # no caption or no match: assign to closest preceding heading
232
+ preceding_headers = [node for node in text_nodes if node.level in self.section_header_levels.values() and node.sequence_no < image.sequence_no]
233
+ if preceding_headers:
234
+ lowest_level_header = min(preceding_headers, key=lambda n: n.level)
235
+ ret_images[i] = image.model_copy(update={"level": lowest_level_header.level + 1, "level_label": "Unreferenced Image", "parent_id": lowest_level_header.snippet_id})
236
+
237
+ return ret_images
238
+
239
+ def compute_image_nodes(self, images: list[PictureItem], text_nodes: list[TextSnippetNode]) -> list[ImageSnippetNode]:
240
+ image_nodes = []
241
+ for i, image in enumerate(images):
242
+ caption_nodes = self.get_caption_nodes(image, text_nodes)
243
+ node = ImageSnippetNode(
244
+ snippet_id=image.self_ref,
245
+ document_id=self._document_metadata.document_id,
246
+ label=image.label,
247
+ sequence_no=i,
248
+ level=1000, # placeholder, compute later
249
+ level_label="",
250
+ parent_id="placeholder", # compute later
251
+ docling_parent_ref=image.parent.get_ref() if image.parent else None,
252
+ docling_self_ref=image.get_ref(),
253
+ is_grouped=True if image.parent is not None and "group" in image.parent.get_ref().cref else False,
254
+ caption_nodes=caption_nodes,
255
+ caption_text=" ".join([node.text for node in caption_nodes]) if len(caption_nodes) > 0 else "",
256
+ bbox=image.prov[0].bbox,
257
+ page_no=image.prov[0].page_no
258
+ )
259
+ image_nodes.append(node)
260
+ # map parents and levels using rule-based matching
261
+ ret_images = self.rb_image_parent_matching(image_nodes, text_nodes)
262
+ return ret_images
263
+
264
+ def compute_table_nodes(self, tables: list[TableItem], text_nodes: list[TextSnippetNode]) -> list[TableSnippetNode]:
265
+ # for now, treat tables similar to images in terms of parent matching
266
+ table_nodes = []
267
+ for i, table in enumerate(tables):
268
+ caption_nodes = self.get_caption_nodes(table, text_nodes)
269
+ node = TableSnippetNode(
270
+ snippet_id=table.self_ref,
271
+ document_id=self._document_metadata.document_id,
272
+ label=table.label,
273
+ sequence_no=i,
274
+ level=1000, # placeholder, compute later
275
+ level_label="",
276
+ parent_id="placeholder", # compute later
277
+ docling_parent_ref=table.parent.get_ref() if table.parent else None,
278
+ docling_self_ref=table.get_ref(),
279
+ is_grouped=True if table.parent is not None and "group" in table.parent.get_ref().cref else False,
280
+ caption_nodes=caption_nodes,
281
+ caption_text=" ".join([node.text for node in caption_nodes]) if len(caption_nodes) > 0 else "",
282
+ markdown_serialization=table.export_to_markdown(self.docling_doc),
283
+ bbox=table.prov[0].bbox,
284
+ page_no=table.prov[0].page_no
285
+ )
286
+ table_nodes.append(node)
287
+ # map parents and levels using rule-based matching
288
+ ret_tables = self.rb_image_parent_matching(table_nodes, text_nodes)
289
+ return ret_tables
290
+
291
+ def get_caption_nodes(self, image: PictureItem | TableItem, text_nodes: list[TextSnippetNode]) -> list[TextSnippetNode] | list:
292
+ # find text node that contains reference to image or table
293
+ caption_refs = image.captions if image.captions else []
294
+ caption_nodes = []
295
+ for caption_ref in caption_refs:
296
+ for text_node in text_nodes:
297
+ if caption_ref.get_ref().cref == text_node.docling_self_ref.get_ref().cref:
298
+ caption_nodes.append(text_node)
299
+ return caption_nodes
300
+
301
+ def get_node_by_id(self, node_id: str, nodes: list[TextSnippetNode]) -> TextSnippetNode | None:
302
+ for node in nodes:
303
+ if node.snippet_id == node_id:
304
+ return node
305
+ return None
306
+
307
+ def write_nx_graph(self, filename: str, text_nodes: list[TextSnippetNode], image_nodes: list[ImageSnippetNode], table_nodes: list[TableSnippetNode], edges: list[WeightedEdge]):
308
+ nx_graph = self.build_nx_graph(text_nodes, image_nodes, table_nodes, edges)
309
+ nx.write_gexf(nx_graph, filename, version="1.3")
310
+ return nx_graph
311
+
312
+ def build_nx_graph(self, text_nodes: list[TextSnippetNode], image_nodes: list[ImageSnippetNode], table_nodes: list[TableSnippetNode], edges: list[WeightedEdge]) -> nx.DiGraph:
313
+ nx_graph = nx.DiGraph()
314
+ nx_graph.add_node(ROOT_NODE_ID, label="document_root", level=-1, level_label="Root", text=self._document_metadata.title)
315
+ for node in text_nodes:
316
+ nx_graph.add_node(node.snippet_id, label=node.label, level=node.level, level_label=node.level_label, text=node.text)
317
+ for node in image_nodes + table_nodes:
318
+ nx_graph.add_node(node.snippet_id, label=node.label, level=node.level, level_label=node.level_label, text=node.caption_text)
319
+ for parent_id, child_id, weight in edges:
320
+ nx_graph.add_edge(parent_id, child_id, weight=weight)
321
+ return nx_graph
322
+
323
+ def construct_snippet_nodes(self) -> tuple[list[TextSnippetNode], list[ImageSnippetNode], list[TableSnippetNode]]:
324
+ # we ignore the first page
325
+ text_nodes = self.compute_text_nodes(self.text_items)
326
+ image_nodes = self.compute_image_nodes(self.image_items, text_nodes)
327
+ table_nodes = self.compute_table_nodes(self.table_items, text_nodes) # add markdownserialization to caption text for better matching
328
+ return text_nodes, image_nodes, table_nodes
329
+
330
+ def compute_edge_weight(self, parent: SnippetNode | None, child: SnippetNode) -> float:
331
+ weights = self.edge_weights
332
+ if isinstance(child, (ImageSnippetNode, TableSnippetNode)):
333
+ if child.level_label == "Unreferenced Image":
334
+ return weights.unreferenced_media
335
+ return weights.media
336
+ if child.is_grouped:
337
+ return weights.list_item
338
+ if child.level_label in ("Title", "Heading") and parent is not None and parent.level_label in ("Title", "Heading"):
339
+ return weights.section
340
+ return weights.text
341
+
342
+ def construct_snippet_edges(self, nodes: list[SnippetNode]) -> list[WeightedEdge]:
343
+ """Build weighted parent->child edges; nodes without a resolvable parent attach to the document root."""
344
+ node_by_id = {node.snippet_id: node for node in nodes}
345
+ edges = []
346
+ for node in nodes:
347
+ parent = node_by_id.get(node.parent_id) if node.parent_id else None
348
+ if parent is not None:
349
+ edges.append((parent.snippet_id, node.snippet_id, self.compute_edge_weight(parent, node)))
350
+ else:
351
+ # missing or dangling parent_id: attach to the synthetic document root
352
+ edges.append((ROOT_NODE_ID, node.snippet_id, self.edge_weights.root))
353
+ return edges
354
+
355
+ def connect_components(self, nodes: list[SnippetNode], edges: list[WeightedEdge]) -> list[WeightedEdge]:
356
+ """Attach every weakly connected component that does not reach the document root to it.
357
+
358
+ construct_snippet_edges already links orphan nodes to the root, but parent
359
+ cycles can still leave a component detached; this guarantees a single
360
+ connected graph per document.
361
+ """
362
+ graph = nx.DiGraph()
363
+ graph.add_node(ROOT_NODE_ID)
364
+ graph.add_nodes_from(node.snippet_id for node in nodes)
365
+ graph.add_edges_from((p, c) for p, c, _ in edges)
366
+ node_by_id = {node.snippet_id: node for node in nodes}
367
+ extra_edges = []
368
+ for component in nx.weakly_connected_components(graph):
369
+ if ROOT_NODE_ID in component:
370
+ continue
371
+ component_nodes = [node_by_id[n] for n in component if n in node_by_id]
372
+ if not component_nodes:
373
+ continue
374
+ # attach the structurally highest node of the component to the root
375
+ top = min(component_nodes, key=lambda n: (n.level, n.sequence_no))
376
+ extra_edges.append((ROOT_NODE_ID, top.snippet_id, self.edge_weights.root))
377
+ return extra_edges
378
+
379
+ def get_graph(self, save_to="") -> tuple[list[TextSnippetNode], list[ImageSnippetNode], list[TableSnippetNode], list[WeightedEdge]]:
380
+ text_nodes, image_nodes, table_nodes = self.construct_snippet_nodes()
381
+ all_nodes: list[SnippetNode] = text_nodes + image_nodes + table_nodes
382
+ edges = self.construct_snippet_edges(all_nodes)
383
+ edges += self.connect_components(all_nodes, edges)
384
+ if save_to != "":
385
+ self.write_nx_graph(save_to, text_nodes, image_nodes, table_nodes, edges)
386
+ return text_nodes, image_nodes, table_nodes, edges
387
+
388
+ def extract_title(self, page_two_snippets: list[TextSnippet]) -> str:
389
+ # condition: page 2 and largest font size and on top of page
390
+ title = max(page_two_snippets, key=lambda s: s.line_heights[0] if s.line_heights else 0)
391
+ for snippet in page_two_snippets:
392
+ if snippet.line_heights and snippet.line_heights[0] >= title.line_heights[0] and snippet.text_item.prov[0].bbox.t < title.text_item.prov[0].bbox.t:
393
+ title = snippet
394
+ return title.text_item.text if title else ""
395
+
396
+ def extract_doc_metadata(self, filename: str, document_type: str, config: MetadataExtractionConfig) -> Document:
397
+ metadata_values: dict[str, str] = {}
398
+ for field_name in ["version", "authors", "institutions", "bibliography", "correspondence"]:
399
+ field_cfg = getattr(config, field_name)
400
+ if field_cfg is None:
401
+ metadata_values[field_name] = ""
402
+ continue
403
+ page_items = [item.text_item for item in self.text_items
404
+ if field_cfg.pages[0] <= item.text_item.prov[0].page_no <= field_cfg.pages[1]]
405
+ matched_ref = None
406
+ for text_item in page_items:
407
+ if self.__safe_check_substring(field_cfg.label, text_item):
408
+ matched_ref = self.__match_metadata_by_section_header(text_item, page_items)
409
+ if matched_ref is None:
410
+ matched_ref = text_item.get_ref()
411
+ break
412
+ metadata_values[field_name] = matched_ref.cref if matched_ref else ""
413
+
414
+ title_page_snippets = [item for item in self.text_items
415
+ if item.text_item.prov[0].page_no == config.title_page]
416
+ title = self.extract_title(title_page_snippets) if title_page_snippets else ""
417
+
418
+ return Document(
419
+ document_id=str(uuid.uuid4()),
420
+ title=title,
421
+ document_type=document_type,
422
+ filename=filename,
423
+ metadata=DocumentMetadata(**metadata_values),
424
+ )
@@ -0,0 +1,10 @@
1
+ from pydantic import BaseModel
2
+ from typing import Any, Sequence
3
+
4
+ class Chunk(BaseModel):
5
+ chunk_id: str
6
+ document_id: str
7
+ meta: dict[str, Any] | None = None
8
+ text: str
9
+ embedding: Sequence[float] | None = None
10
+ baseline_description: str | None = None