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.
- document2graph/__init__.py +0 -0
- document2graph/baseline_extractor/__init__.py +3 -0
- document2graph/baseline_extractor/extractor.py +82 -0
- document2graph/document2graph_extractor/__init__.py +3 -0
- document2graph/document2graph_extractor/extractor.py +146 -0
- document2graph/document2graph_extractor/snippet_graph_constructor.py +424 -0
- document2graph/models/Chunk.py +10 -0
- document2graph/models/Document.py +10 -0
- document2graph/models/DocumentMetadata.py +23 -0
- document2graph/models/EdgeWeightConfig.py +18 -0
- document2graph/models/ExtractorConfig.py +14 -0
- document2graph/models/ImageSnippetNode.py +21 -0
- document2graph/models/Snippet.py +19 -0
- document2graph/models/TableSnippetNode.py +6 -0
- document2graph/models/TextSnippet.py +6 -0
- document2graph/models/TextSnippetNode.py +19 -0
- document2graph/models/__init__.py +25 -0
- document2graph/utils/__init__.py +0 -0
- document2graph/utils/base_extractor.py +127 -0
- document2graph/utils/doc_to_tree_lib.py +52 -0
- document2graph/utils/hybrid_chunker_extractor.py +26 -0
- document2graph/utils/log.py +40 -0
- document2graph-0.1.0.dist-info/METADATA +243 -0
- document2graph-0.1.0.dist-info/RECORD +27 -0
- document2graph-0.1.0.dist-info/WHEEL +5 -0
- document2graph-0.1.0.dist-info/licenses/LICENSE +21 -0
- document2graph-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MetadataFieldConfig(BaseModel):
|
|
5
|
+
label: str # substring to search for in the document text
|
|
6
|
+
pages: tuple[int, int] # inclusive 1-based page range to search on
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MetadataExtractionConfig(BaseModel):
|
|
10
|
+
title_page: int = 2
|
|
11
|
+
version: MetadataFieldConfig | None = MetadataFieldConfig(label="version", pages=(1, 1))
|
|
12
|
+
authors: MetadataFieldConfig | None = MetadataFieldConfig(label="korrespondenzadresse", pages=(2, 2))
|
|
13
|
+
institutions: MetadataFieldConfig | None = MetadataFieldConfig(label="institute", pages=(2, 2))
|
|
14
|
+
bibliography: MetadataFieldConfig | None = MetadataFieldConfig(label="bibliografie", pages=(2, 2))
|
|
15
|
+
correspondence: MetadataFieldConfig | None = MetadataFieldConfig(label="korrespondenzadresse", pages=(2, 2))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DocumentMetadata(BaseModel):
|
|
19
|
+
version: str = ""
|
|
20
|
+
authors: str = ""
|
|
21
|
+
institutions: str = ""
|
|
22
|
+
bibliography: str = ""
|
|
23
|
+
correspondence: str = ""
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EdgeWeightConfig(BaseModel):
|
|
5
|
+
"""Weights assigned to graph edges by structural category.
|
|
6
|
+
|
|
7
|
+
Higher values indicate a stronger structural connection between
|
|
8
|
+
parent and child node. All weights are stored on the edge as the
|
|
9
|
+
``weight`` attribute (both in the returned edge list and in the
|
|
10
|
+
exported GEXF graph).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
section: float = 1.0 # heading -> subheading
|
|
14
|
+
text: float = 0.8 # heading/body -> body text, subtext, footnotes
|
|
15
|
+
list_item: float = 0.6 # anchor text -> grouped list item (bullets)
|
|
16
|
+
media: float = 0.9 # referencing text -> image/table (matched via caption)
|
|
17
|
+
unreferenced_media: float = 0.3 # fallback heading -> image/table without a caption match
|
|
18
|
+
root: float = 0.1 # synthetic document root -> otherwise disconnected node
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
from docling.datamodel.pipeline_options import PdfPipelineOptions
|
|
3
|
+
from .DocumentMetadata import MetadataExtractionConfig
|
|
4
|
+
from .EdgeWeightConfig import EdgeWeightConfig
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ExtractorConfig(BaseModel):
|
|
8
|
+
pdf_path: str
|
|
9
|
+
data_path: str
|
|
10
|
+
pdfPipelineOptions: PdfPipelineOptions = PdfPipelineOptions()
|
|
11
|
+
document_type: str = ""
|
|
12
|
+
save_json: bool = True
|
|
13
|
+
metadata_config: MetadataExtractionConfig = Field(default_factory=MetadataExtractionConfig)
|
|
14
|
+
edge_weights: EdgeWeightConfig = Field(default_factory=EdgeWeightConfig)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from docling_core.types.doc.document import RefItem
|
|
3
|
+
from docling_core.types.doc.base import BoundingBox
|
|
4
|
+
from .TextSnippetNode import TextSnippetNode
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ImageSnippetNode(BaseModel):
|
|
8
|
+
snippet_id: str
|
|
9
|
+
document_id: str
|
|
10
|
+
label: str
|
|
11
|
+
sequence_no: int
|
|
12
|
+
level: int
|
|
13
|
+
level_label: str
|
|
14
|
+
parent_id: str
|
|
15
|
+
docling_parent_ref: RefItem | None
|
|
16
|
+
docling_self_ref: RefItem | None
|
|
17
|
+
is_grouped: bool = False
|
|
18
|
+
caption_nodes: list[TextSnippetNode] | list = []
|
|
19
|
+
caption_text: str
|
|
20
|
+
bbox: BoundingBox
|
|
21
|
+
page_no: int
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from docling_core.types.doc.document import RefItem
|
|
3
|
+
from docling_core.types.doc.base import BoundingBox
|
|
4
|
+
|
|
5
|
+
class Snippet(BaseModel):
|
|
6
|
+
snippet_id: str
|
|
7
|
+
type: str
|
|
8
|
+
document_id: str
|
|
9
|
+
sequence_no: int
|
|
10
|
+
label: str
|
|
11
|
+
level: int
|
|
12
|
+
level_label: str
|
|
13
|
+
parent_id: str | None
|
|
14
|
+
is_grouped: bool = False
|
|
15
|
+
page_no: int
|
|
16
|
+
bbox: BoundingBox
|
|
17
|
+
text: str
|
|
18
|
+
docling_parent_ref: RefItem | None
|
|
19
|
+
docling_self_ref: RefItem | None
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from docling_core.types.doc.document import RefItem
|
|
3
|
+
from docling_core.types.doc.base import BoundingBox
|
|
4
|
+
|
|
5
|
+
class TextSnippetNode(BaseModel):
|
|
6
|
+
snippet_id: str
|
|
7
|
+
document_id: str
|
|
8
|
+
label: str
|
|
9
|
+
sequence_no: int
|
|
10
|
+
level: int
|
|
11
|
+
level_label: str
|
|
12
|
+
parent_id: str | None
|
|
13
|
+
docling_parent_ref: RefItem
|
|
14
|
+
docling_self_ref: RefItem
|
|
15
|
+
is_grouped: bool = False
|
|
16
|
+
text: str
|
|
17
|
+
bbox: BoundingBox
|
|
18
|
+
charspan: tuple[int, int]
|
|
19
|
+
page_no: int
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .Chunk import Chunk
|
|
2
|
+
from .EdgeWeightConfig import EdgeWeightConfig
|
|
3
|
+
from .ExtractorConfig import ExtractorConfig
|
|
4
|
+
from .Snippet import Snippet
|
|
5
|
+
from .Document import Document
|
|
6
|
+
from .DocumentMetadata import DocumentMetadata, MetadataExtractionConfig, MetadataFieldConfig
|
|
7
|
+
from .ImageSnippetNode import ImageSnippetNode
|
|
8
|
+
from .TableSnippetNode import TableSnippetNode
|
|
9
|
+
from .TextSnippetNode import TextSnippetNode
|
|
10
|
+
from .TextSnippet import TextSnippet
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Chunk",
|
|
14
|
+
"EdgeWeightConfig",
|
|
15
|
+
"ExtractorConfig",
|
|
16
|
+
"Snippet",
|
|
17
|
+
"Document",
|
|
18
|
+
"DocumentMetadata",
|
|
19
|
+
"MetadataExtractionConfig",
|
|
20
|
+
"MetadataFieldConfig",
|
|
21
|
+
"ImageSnippetNode",
|
|
22
|
+
"TableSnippetNode",
|
|
23
|
+
"TextSnippetNode",
|
|
24
|
+
"TextSnippet",
|
|
25
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from docling.document_converter import DocumentConverter, PdfFormatOption
|
|
2
|
+
from docling.datamodel.base_models import InputFormat
|
|
3
|
+
from docling_core.types.doc.document import DoclingDocument, TableItem, TextItem, PictureItem
|
|
4
|
+
from docling.datamodel.pipeline_options import PdfPipelineOptions
|
|
5
|
+
from docling_core.types.doc.document import GroupItem, RefItem
|
|
6
|
+
import os
|
|
7
|
+
import pickle
|
|
8
|
+
from .log import Log
|
|
9
|
+
|
|
10
|
+
class Extractor:
|
|
11
|
+
def __init__(self, source: str, pipeline_options: PdfPipelineOptions = PdfPipelineOptions()):
|
|
12
|
+
self.source = source
|
|
13
|
+
self.converter = DocumentConverter(format_options={
|
|
14
|
+
InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options)
|
|
15
|
+
})
|
|
16
|
+
self.docling_doc = self.converter.convert(source)
|
|
17
|
+
self.doc = self.docling_doc.document
|
|
18
|
+
|
|
19
|
+
self.logger = Log("Extractor").logger
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_furniture(self, item):
|
|
23
|
+
"""
|
|
24
|
+
Check if the item is furniture (e.g., title page, table of contents).
|
|
25
|
+
"""
|
|
26
|
+
return item.content_layer.value == "furniture"
|
|
27
|
+
|
|
28
|
+
def is_text(self, item):
|
|
29
|
+
"""
|
|
30
|
+
Check if the item is a text item.
|
|
31
|
+
"""
|
|
32
|
+
return isinstance(item, TextItem)
|
|
33
|
+
|
|
34
|
+
def is_picture(self, item):
|
|
35
|
+
"""
|
|
36
|
+
Check if the item is a picture item.
|
|
37
|
+
"""
|
|
38
|
+
return isinstance(item, PictureItem)
|
|
39
|
+
|
|
40
|
+
def is_table(self, item):
|
|
41
|
+
"""
|
|
42
|
+
Check if the item is a table item.
|
|
43
|
+
"""
|
|
44
|
+
return isinstance(item, TableItem)
|
|
45
|
+
|
|
46
|
+
def is_group(self, item):
|
|
47
|
+
"""
|
|
48
|
+
Check if the item is a group item.
|
|
49
|
+
"""
|
|
50
|
+
return isinstance(item, GroupItem)
|
|
51
|
+
|
|
52
|
+
def serialize_ref_items(
|
|
53
|
+
self, ref_items: list[RefItem], doc: DoclingDocument
|
|
54
|
+
) -> tuple[list[str], list[str]]:
|
|
55
|
+
"""
|
|
56
|
+
Serialize the reference items to a string representation.
|
|
57
|
+
This function is necessary to handle also string representations of groups, tables and images.
|
|
58
|
+
Only for annotation of extracted doc necessary.
|
|
59
|
+
"""
|
|
60
|
+
serialized_items = []
|
|
61
|
+
serialized_labels = []
|
|
62
|
+
for refItem in ref_items:
|
|
63
|
+
item = refItem.resolve(doc)
|
|
64
|
+
if self.is_furniture(item):
|
|
65
|
+
continue
|
|
66
|
+
if self.is_text(item):
|
|
67
|
+
serialized_items.append(item.text)
|
|
68
|
+
serialized_labels.append(item.label.value)
|
|
69
|
+
elif self.is_picture(item):
|
|
70
|
+
serialized_items.append(item.caption_text(doc))
|
|
71
|
+
serialized_labels.append(item.label.value)
|
|
72
|
+
elif self.is_table(item):
|
|
73
|
+
serialized_items.append(item.export_to_markdown(doc))
|
|
74
|
+
serialized_labels.append(item.label.value)
|
|
75
|
+
elif self.is_group(item):
|
|
76
|
+
# Recursively serialize group items
|
|
77
|
+
group_items, group_labels = self.serialize_ref_items(item.children, doc)
|
|
78
|
+
serialized_items.append(group_items)
|
|
79
|
+
serialized_labels.append(group_labels)
|
|
80
|
+
else:
|
|
81
|
+
serialized_items.append(item.text)
|
|
82
|
+
serialized_labels.append(item.label.value)
|
|
83
|
+
return serialized_items, serialized_labels
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def serialize_doc(self, doc: DoclingDocument) -> dict:
|
|
87
|
+
"""
|
|
88
|
+
Serialize the document to a string representation.
|
|
89
|
+
Leave furniture out. Bring text in order, to see if we can manually label the paragraphs.
|
|
90
|
+
"""
|
|
91
|
+
full_text, full_text_labels = self.serialize_ref_items(
|
|
92
|
+
doc.body.children, doc
|
|
93
|
+
)
|
|
94
|
+
return {
|
|
95
|
+
"full_text": full_text,
|
|
96
|
+
"full_text_labels": full_text_labels,
|
|
97
|
+
"pictures": doc.pictures,
|
|
98
|
+
"tables": doc.tables,
|
|
99
|
+
"texts": doc.texts,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
def extract(self, save_dir: str, filename: str) -> dict:
|
|
103
|
+
"""
|
|
104
|
+
Extract and serialize the document, saving intermediate results.
|
|
105
|
+
"""
|
|
106
|
+
self.logger.info(f"Saving parsed pdf as {filename}.json")
|
|
107
|
+
|
|
108
|
+
os.makedirs(save_dir, exist_ok=True)
|
|
109
|
+
|
|
110
|
+
self.docling_doc.document.save_as_json(
|
|
111
|
+
f"{save_dir}/{filename}_docling_doc.json"
|
|
112
|
+
)
|
|
113
|
+
self.logger.info(f"Start extracting {filename}...")
|
|
114
|
+
result = self.serialize_doc(self.doc)
|
|
115
|
+
with open(f"{save_dir}/{filename}_serialized_doc.pkl", "wb") as f:
|
|
116
|
+
pickle.dump(result, f)
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main():
|
|
121
|
+
# only for quick testing
|
|
122
|
+
source = "../pdf/ddg_pdf/ddg_praxisempfehlungen/DuS_2024_S02_Praxisempfehlungen_Aberle_Adipositas-und-Diabetes.pdf"
|
|
123
|
+
extractor = Extractor(source)
|
|
124
|
+
_ = extractor.extract("test", "test")
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
main()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
# schemas
|
|
4
|
+
from ..models.TextSnippet import TextSnippet
|
|
5
|
+
|
|
6
|
+
def get_heights(text_items: list[TextSnippet], skip_first_page: bool = False) -> np.ndarray:
|
|
7
|
+
if skip_first_page:
|
|
8
|
+
heights = [height for snippet in text_items for height in snippet.line_heights if snippet.text_item.prov[0].page_no > 1]
|
|
9
|
+
else:
|
|
10
|
+
heights = [height for snippet in text_items for height in snippet.line_heights]
|
|
11
|
+
return np.multiply(np.round(np.array(heights), 1), 10)
|
|
12
|
+
|
|
13
|
+
def get_height_hist(heights=np.array, plot=False) -> dict[int, int]:
|
|
14
|
+
if not heights.size:
|
|
15
|
+
print("No heights found, returning empty histogram.")
|
|
16
|
+
return {}
|
|
17
|
+
unique_heights, counts = np.unique(heights, return_counts=True)
|
|
18
|
+
height_hist = dict(zip(unique_heights, counts))
|
|
19
|
+
if plot:
|
|
20
|
+
# matplotlib is only needed for this debug plot, not a package dependency
|
|
21
|
+
import matplotlib.pyplot as plt
|
|
22
|
+
plt.bar(height_hist.keys(), height_hist.values()) # type: ignore
|
|
23
|
+
xticks = unique_heights[::max(1, len(unique_heights)//20)]
|
|
24
|
+
plt.xticks(xticks, rotation=90, size=6)
|
|
25
|
+
plt.xlabel("Font size")
|
|
26
|
+
plt.ylabel("Count")
|
|
27
|
+
plt.show()
|
|
28
|
+
|
|
29
|
+
return height_hist
|
|
30
|
+
|
|
31
|
+
def get_text_body_height(heights: np.ndarray) -> tuple[float, float]:
|
|
32
|
+
assert heights.size > 0, "No heights provided to compute text body height."
|
|
33
|
+
# text body level range: between 25th and 75th percentile
|
|
34
|
+
text_body_height = np.percentile(heights, 50) # median
|
|
35
|
+
iqr = np.percentile(heights, 75) - np.percentile(heights, 25)
|
|
36
|
+
return float(text_body_height), float(iqr)
|
|
37
|
+
|
|
38
|
+
def line_height_to_key(heights: list[float]) -> np.ndarray:
|
|
39
|
+
return np.multiply(heights, 10).astype(int) # multiply by 10 to avoid float precision issues
|
|
40
|
+
|
|
41
|
+
def compute_snippet_level(snippet: TextSnippet, levels: dict[int, int]) -> int:
|
|
42
|
+
heights = line_height_to_key(snippet.line_heights)
|
|
43
|
+
if not heights.size:
|
|
44
|
+
return 1000 # unknown level
|
|
45
|
+
# get median height in case there are multiple lines with different heights
|
|
46
|
+
median_height = np.median(heights)
|
|
47
|
+
if median_height in levels:
|
|
48
|
+
return levels[int(median_height)]
|
|
49
|
+
else:
|
|
50
|
+
# find closest height in levels
|
|
51
|
+
closest_height = min(levels.keys(), key=lambda h: abs(h - median_height))
|
|
52
|
+
return levels[closest_height]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from .base_extractor import Extractor
|
|
3
|
+
from docling.datamodel.pipeline_options import PdfPipelineOptions
|
|
4
|
+
from docling_core.transforms.chunker.hybrid_chunker import HybridChunker
|
|
5
|
+
from docling_core.transforms.chunker.tokenizer.huggingface import HuggingFaceTokenizer
|
|
6
|
+
|
|
7
|
+
class HybridChunkerExtractor(Extractor):
|
|
8
|
+
def __init__(self, source: str, merge_peers: bool, pipeline_options: PdfPipelineOptions = PdfPipelineOptions()):
|
|
9
|
+
super().__init__(source, pipeline_options)
|
|
10
|
+
self.merge_peers = merge_peers
|
|
11
|
+
self.chunker = HybridChunker(
|
|
12
|
+
tokenizer=HuggingFaceTokenizer.from_pretrained("google/gemma-3-4b-it", max_tokens=131072),
|
|
13
|
+
merge_peers=merge_peers)
|
|
14
|
+
|
|
15
|
+
def extract_and_chunk(self, save_dir: str, filename: str):
|
|
16
|
+
# First, we extract the document using the base extractor logic
|
|
17
|
+
docling_doc = self.converter.convert(self.source).document
|
|
18
|
+
# Then, we apply the hybrid chunker to the extracted document
|
|
19
|
+
self.logger.info(f"Saving parsed pdf as {filename}.json")
|
|
20
|
+
|
|
21
|
+
os.makedirs(save_dir, exist_ok=True)
|
|
22
|
+
|
|
23
|
+
docling_doc.save_as_json(
|
|
24
|
+
f"{save_dir}/{filename}_baseline_docling_doc.json"
|
|
25
|
+
)
|
|
26
|
+
yield from self.chunker.chunk(docling_doc)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import coloredlogs
|
|
3
|
+
import logging
|
|
4
|
+
from pprint import pprint
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger()
|
|
7
|
+
coloredlogs.install(level='INFO', logger=logger, isatty=True)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Log:
|
|
11
|
+
def __init__(self, name):
|
|
12
|
+
self.logger = logging.getLogger(name)
|
|
13
|
+
|
|
14
|
+
def log(self, s):
|
|
15
|
+
self.logger.info(s)
|
|
16
|
+
sys.stdout.flush()
|
|
17
|
+
|
|
18
|
+
def plog(self, s):
|
|
19
|
+
plog(s)
|
|
20
|
+
|
|
21
|
+
def info(self, s):
|
|
22
|
+
self.logger.info(s)
|
|
23
|
+
sys.stdout.flush()
|
|
24
|
+
|
|
25
|
+
def debug(self, s):
|
|
26
|
+
pprint(s)
|
|
27
|
+
sys.stdout.flush()
|
|
28
|
+
|
|
29
|
+
def error(self, s):
|
|
30
|
+
self.logger.error(s)
|
|
31
|
+
sys.stdout.flush()
|
|
32
|
+
|
|
33
|
+
def warning(self, s):
|
|
34
|
+
self.logger.warning(s)
|
|
35
|
+
sys.stdout.flush()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def plog(s):
|
|
39
|
+
pprint(s)
|
|
40
|
+
sys.stdout.flush()
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: document2graph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Extract hierarchical document graphs and baseline chunks from PDF
|
|
5
|
+
Author-email: Marina Walther <marina.walther@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mwalther10/document2graph
|
|
8
|
+
Project-URL: Issues, https://github.com/mwalther10/document2graph/issues
|
|
9
|
+
Keywords: pdf,document2graph,docling,networkx,chunking,retrieval
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Text Processing :: Indexing
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: docling
|
|
22
|
+
Requires-Dist: docling-core
|
|
23
|
+
Requires-Dist: docling-parse
|
|
24
|
+
Requires-Dist: numpy
|
|
25
|
+
Requires-Dist: pydantic>=2
|
|
26
|
+
Requires-Dist: regex
|
|
27
|
+
Requires-Dist: networkx
|
|
28
|
+
Requires-Dist: coloredlogs
|
|
29
|
+
Requires-Dist: tqdm
|
|
30
|
+
Requires-Dist: transformers
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: pytest; extra == "dev"
|
|
33
|
+
Requires-Dist: httpx; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# document2graph
|
|
37
|
+
|
|
38
|
+
Extract hierarchical document graphs and baseline chunks from PDF files. The package parses PDFs using [Docling](https://github.com/DS4SD/docling) and produces two complementary representations:
|
|
39
|
+
|
|
40
|
+
- **Document graph** — text, image, and table nodes connected by weighted structural edges, capturing the layout hierarchy of the document. Every graph is guaranteed to be connected: nodes without a resolvable parent are attached to a synthetic document root node.
|
|
41
|
+
- **Baseline chunks** — flat, semantically merged chunks suitable for retrieval pipelines, with optional context enrichment.
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install document2graph
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or from source:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install -e .
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
For development dependencies (pytest):
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install -e ".[dev]"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Requires Python 3.11+.
|
|
62
|
+
|
|
63
|
+
## Quick start
|
|
64
|
+
|
|
65
|
+
### Configuration
|
|
66
|
+
|
|
67
|
+
Both extractors share the same `ExtractorConfig`:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from document2graph.models import ExtractorConfig
|
|
71
|
+
from docling.datamodel.pipeline_options import PdfPipelineOptions
|
|
72
|
+
|
|
73
|
+
config = ExtractorConfig(
|
|
74
|
+
pdf_path="data/pdfs/", # directory containing input PDFs
|
|
75
|
+
data_path="data/output/", # root directory for all output files
|
|
76
|
+
save_json=True, # write results to disk as JSON (default: True)
|
|
77
|
+
document_type="report", # arbitrary label stored in document metadata
|
|
78
|
+
pdfPipelineOptions=PdfPipelineOptions(), # optional Docling pipeline config
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
#### Metadata extraction
|
|
83
|
+
|
|
84
|
+
The `metadata_config` field controls how document metadata (title, authors, version, etc.) is
|
|
85
|
+
located inside each PDF. Each field is described by a search string and an inclusive page range
|
|
86
|
+
to search on. Fields set to `None` are skipped.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from document2graph.models import ExtractorConfig, MetadataExtractionConfig, MetadataFieldConfig
|
|
90
|
+
|
|
91
|
+
config = ExtractorConfig(
|
|
92
|
+
pdf_path="data/pdfs/",
|
|
93
|
+
data_path="data/output/",
|
|
94
|
+
metadata_config=MetadataExtractionConfig(
|
|
95
|
+
title_page=1,
|
|
96
|
+
version=MetadataFieldConfig(label="edition", pages=(1, 1)),
|
|
97
|
+
authors=MetadataFieldConfig(label="authors", pages=(1, 2)),
|
|
98
|
+
institutions=MetadataFieldConfig(label="affiliations", pages=(1, 2)),
|
|
99
|
+
bibliography=None, # not present in this document type
|
|
100
|
+
correspondence=MetadataFieldConfig(label="contact", pages=(2, 3)),
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The default `MetadataExtractionConfig()` targets German-language Praxisempfehlungen PDFs
|
|
106
|
+
(version on page 1, authors/institutions/bibliography/correspondence on page 2).
|
|
107
|
+
|
|
108
|
+
#### Edge weights
|
|
109
|
+
|
|
110
|
+
Every edge in the document graph carries a `weight` attribute reflecting the strength of the
|
|
111
|
+
structural connection. The defaults can be overridden per category via `edge_weights`:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from document2graph.models import ExtractorConfig, EdgeWeightConfig
|
|
115
|
+
|
|
116
|
+
config = ExtractorConfig(
|
|
117
|
+
pdf_path="data/pdfs/",
|
|
118
|
+
data_path="data/output/",
|
|
119
|
+
edge_weights=EdgeWeightConfig(
|
|
120
|
+
section=1.0, # heading -> subheading
|
|
121
|
+
text=0.8, # heading/body -> body text, subtext, footnotes
|
|
122
|
+
list_item=0.6, # anchor text -> grouped list item (bullets)
|
|
123
|
+
media=0.9, # referencing text -> image/table (matched via caption)
|
|
124
|
+
unreferenced_media=0.3, # fallback heading -> image/table without a caption match
|
|
125
|
+
root=0.1, # document root -> otherwise disconnected node
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
#### Connectivity guarantee
|
|
131
|
+
|
|
132
|
+
Each graph contains a synthetic root node (`#/document-root`, labelled with the document
|
|
133
|
+
title). Any node whose parent cannot be resolved — as well as any component that would
|
|
134
|
+
otherwise be disconnected — is attached to this root with the `root` edge weight, so every
|
|
135
|
+
document graph is a single (weakly) connected component.
|
|
136
|
+
|
|
137
|
+
Extracted metadata is available on the `Document` object under the `metadata` sub-object:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
result = extractor.run("my_document.pdf")
|
|
141
|
+
doc = result["document_metadata"]
|
|
142
|
+
|
|
143
|
+
print(doc.title)
|
|
144
|
+
print(doc.metadata.authors)
|
|
145
|
+
print(doc.metadata.version)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Document graph extraction
|
|
149
|
+
|
|
150
|
+
Processes every PDF in `pdf_path` and builds a NetworkX graph per document.
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from document2graph.document2graph_extractor import DocumentGraphExtractor
|
|
154
|
+
|
|
155
|
+
extractor = DocumentGraphExtractor(config)
|
|
156
|
+
|
|
157
|
+
# Process all PDFs and save graphs + snippets to disk
|
|
158
|
+
extractor.generate_snippets()
|
|
159
|
+
|
|
160
|
+
# Or collect Snippet objects in memory as well
|
|
161
|
+
snippets = extractor.generate_snippets(return_snippets=True)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
To process a single file and get the raw graph components:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
result = extractor.run("my_document.pdf")
|
|
168
|
+
|
|
169
|
+
text_nodes = result["text_nodes"]
|
|
170
|
+
image_nodes = result["image_nodes"]
|
|
171
|
+
table_nodes = result["table_nodes"]
|
|
172
|
+
edges = result["edges"] # list of (parent_id, child_id, weight) tuples
|
|
173
|
+
metadata = result["document_metadata"]
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Graphs are saved as `.gexf` files under `<data_path>/nx_graphs/` and can be loaded with NetworkX:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
import networkx as nx
|
|
180
|
+
G = nx.read_gexf("data/output/nx_graphs/my_document.gexf")
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Baseline chunk extraction
|
|
184
|
+
|
|
185
|
+
Produces flat, retrieval-ready chunks using Docling's hybrid chunker.
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from document2graph.baseline_extractor import BaselineExtractor
|
|
189
|
+
|
|
190
|
+
extractor = BaselineExtractor(config)
|
|
191
|
+
|
|
192
|
+
# Process all PDFs; saves JSON to disk
|
|
193
|
+
extractor.generate_baseline_chunks()
|
|
194
|
+
|
|
195
|
+
# Or return chunks in memory
|
|
196
|
+
all_chunks = extractor.generate_baseline_chunks(return_chunks=True)
|
|
197
|
+
# all_chunks["my_document.pdf"]["baseline"] -> List[Chunk]
|
|
198
|
+
# all_chunks["my_document.pdf"]["enriched"] -> List[Chunk] (context-enriched)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
To process a single file:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
chunks = extractor.extract_baseline_chunks(
|
|
205
|
+
filename="my_document.pdf",
|
|
206
|
+
baseline_description="Q1 report chunks",
|
|
207
|
+
enrich=True, # also produce context-enriched variants
|
|
208
|
+
)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Output structure
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
<data_path>/
|
|
215
|
+
├── raw_texts/ # intermediate Docling extraction output
|
|
216
|
+
├── nx_graphs/ # .gexf graph files (one per document)
|
|
217
|
+
├── snippets/ # <filename>_snippets.json (document graph output)
|
|
218
|
+
└── baseline_chunks/ # <filename>_baseline_chunks.json
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Data models
|
|
222
|
+
|
|
223
|
+
| Model | Key fields |
|
|
224
|
+
|---|---|
|
|
225
|
+
| `Snippet` | `snippet_id`, `type` (text/image/table), `document_id`, `sequence_no`, `label`, `level`, `page_no`, `bbox`, `text` |
|
|
226
|
+
| `Chunk` | `chunk_id`, `document_id`, `text`, `meta`, `baseline_description`, `embedding` |
|
|
227
|
+
| `Document` | `document_id`, `document_type`, `filename`, `title`, `metadata: DocumentMetadata` |
|
|
228
|
+
| `DocumentMetadata` | `version`, `authors`, `institutions`, `bibliography`, `correspondence` |
|
|
229
|
+
| `MetadataExtractionConfig` | `title_page`, `version`, `authors`, `institutions`, `bibliography`, `correspondence` (each a `MetadataFieldConfig`) |
|
|
230
|
+
| `MetadataFieldConfig` | `label` (search string), `pages` (inclusive 1-based page range, e.g. `(1, 3)`) |
|
|
231
|
+
| `EdgeWeightConfig` | `section`, `text`, `list_item`, `media`, `unreferenced_media`, `root` (edge weights by category) |
|
|
232
|
+
|
|
233
|
+
## Running tests
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
pytest
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Skip slow tests that download models:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
pytest -m "not slow"
|
|
243
|
+
```
|