dcmspec 0.2.1__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.
- dcmspec/__init__.py +0 -0
- dcmspec/apps/__init__.py +1 -0
- dcmspec/apps/cli/__init__.py +0 -0
- dcmspec/apps/cli/dataelements.py +89 -0
- dcmspec/apps/cli/iodattributes.py +125 -0
- dcmspec/apps/cli/iodmodules.py +92 -0
- dcmspec/apps/cli/modattributes.py +265 -0
- dcmspec/apps/cli/tdwiicontent.py +365 -0
- dcmspec/apps/cli/uidvalues.py +84 -0
- dcmspec/apps/cli/upsdimseattributes.py +109 -0
- dcmspec/apps/cli/upsioddimseattributes.py +330 -0
- dcmspec/apps/ui/iod_explorer/README.md +33 -0
- dcmspec/apps/ui/iod_explorer/__init__.py +9 -0
- dcmspec/apps/ui/iod_explorer/config/README.md +171 -0
- dcmspec/apps/ui/iod_explorer/config/iod_explorer_config.json +4 -0
- dcmspec/apps/ui/iod_explorer/config/iod_explorer_config_debug.json +4 -0
- dcmspec/apps/ui/iod_explorer/config/iod_explorer_config_example.json +4 -0
- dcmspec/apps/ui/iod_explorer/config/iod_explorer_config_minimal_logging.json +4 -0
- dcmspec/apps/ui/iod_explorer/iod_explorer.py +989 -0
- dcmspec/config.py +90 -0
- dcmspec/csv_table_spec_parser.py +85 -0
- dcmspec/doc_handler.py +214 -0
- dcmspec/dom_table_spec_parser.py +831 -0
- dcmspec/dom_utils.py +116 -0
- dcmspec/iod_spec_builder.py +444 -0
- dcmspec/iod_spec_printer.py +59 -0
- dcmspec/json_spec_store.py +110 -0
- dcmspec/module_registry.py +51 -0
- dcmspec/pdf_doc_handler.py +451 -0
- dcmspec/progress.py +232 -0
- dcmspec/service_attribute_defaults.py +124 -0
- dcmspec/service_attribute_model.py +231 -0
- dcmspec/spec_factory.py +451 -0
- dcmspec/spec_merger.py +536 -0
- dcmspec/spec_model.py +461 -0
- dcmspec/spec_parser.py +37 -0
- dcmspec/spec_printer.py +131 -0
- dcmspec/spec_store.py +50 -0
- dcmspec/ups_xhtml_doc_handler.py +117 -0
- dcmspec/xhtml_doc_handler.py +182 -0
- dcmspec-0.2.1.dist-info/METADATA +139 -0
- dcmspec-0.2.1.dist-info/RECORD +45 -0
- dcmspec-0.2.1.dist-info/WHEEL +4 -0
- dcmspec-0.2.1.dist-info/entry_points.txt +11 -0
- dcmspec-0.2.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""JSON model store class for DICOM specification storage in dcmspec.
|
|
2
|
+
|
|
3
|
+
Provides the JSONSpecStore class for loading and saving DICOM specification models
|
|
4
|
+
to and from JSON files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from anytree import Node
|
|
10
|
+
from anytree.importer import JsonImporter
|
|
11
|
+
from anytree.exporter import JsonExporter
|
|
12
|
+
|
|
13
|
+
from dcmspec.spec_store import SpecStore
|
|
14
|
+
from dcmspec.spec_model import SpecModel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class JSONSpecStore(SpecStore):
|
|
20
|
+
"""Model store class for DICOM specification models storage in JSON format.
|
|
21
|
+
|
|
22
|
+
Provides methods to load and save DICOM specification models to and from JSON files.
|
|
23
|
+
Inherits logging from SpecStore.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def load(self, path: str) -> SpecModel:
|
|
27
|
+
"""Load a specification model from a JSON file.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
path (str): The path to the JSON file to load.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
SpecModel: The specification model containing both metadata and content nodes.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
RuntimeError: If the file cannot be read, parsed, or has an invalid structure.
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
importer = JsonImporter()
|
|
41
|
+
with open(path, "r", encoding="utf-8") as json_file:
|
|
42
|
+
root = importer.read(json_file)
|
|
43
|
+
|
|
44
|
+
# Check that the root node is named "dcmspec"
|
|
45
|
+
if root.name != "dcmspec":
|
|
46
|
+
raise RuntimeError(f"Invalid model structure in JSON file {path}: root node must be 'dcmspec'.")
|
|
47
|
+
|
|
48
|
+
# Search for metadata and content nodes directly under the root
|
|
49
|
+
metadata = next((node for node in root.children if node.name == "metadata"), None)
|
|
50
|
+
content = next((node for node in root.children if node.name == "content"), None)
|
|
51
|
+
|
|
52
|
+
if metadata is None or content is None:
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
f"Invalid model structure in JSON file {path}: "
|
|
55
|
+
f"Both 'metadata' and 'content' nodes must be present as children of 'dcmspec'."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Detach the model nodes from the file root node
|
|
59
|
+
metadata.parent = None
|
|
60
|
+
content.parent = None
|
|
61
|
+
|
|
62
|
+
# Convert keys of column_to_attr back to integers if present in metadata
|
|
63
|
+
if "column_to_attr" in metadata.__dict__:
|
|
64
|
+
metadata.column_to_attr = {int(k): v for k, v in metadata.column_to_attr.items()}
|
|
65
|
+
|
|
66
|
+
return SpecModel(metadata=metadata, content=content)
|
|
67
|
+
except OSError as e:
|
|
68
|
+
raise RuntimeError(f"Failed to read model data from JSON file {path}: {e}") from e
|
|
69
|
+
except json.JSONDecodeError as e:
|
|
70
|
+
raise RuntimeError(f"Failed to parse JSON file {path}: {e}") from e
|
|
71
|
+
|
|
72
|
+
def save(self, model: SpecModel, path: str) -> None:
|
|
73
|
+
"""Save a specification model to a JSON file.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
model (SpecModel): The model object (an instance of SpecModel or a derived class)
|
|
77
|
+
containing metadata and content nodes to save.
|
|
78
|
+
path (str): The path to the JSON file to write.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
None
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
RuntimeError: If the file cannot be written.
|
|
85
|
+
|
|
86
|
+
"""
|
|
87
|
+
# Create the destination folder if it does not exist
|
|
88
|
+
dir_name = os.path.dirname(path)
|
|
89
|
+
if dir_name:
|
|
90
|
+
os.makedirs(dir_name, exist_ok=True)
|
|
91
|
+
|
|
92
|
+
# Create a new root node "dcmspec"
|
|
93
|
+
root_node = Node("dcmspec")
|
|
94
|
+
|
|
95
|
+
# Temporarily add the model's metadata and content as children of the new root node
|
|
96
|
+
model.metadata.parent = root_node
|
|
97
|
+
model.content.parent = root_node
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
exporter = JsonExporter(indent=4, sort_keys=False)
|
|
101
|
+
with open(path, "w", encoding="utf-8") as json_file:
|
|
102
|
+
exporter.write(root_node, json_file)
|
|
103
|
+
self.logger.info(f"Attribute model saved as JSON to {path}")
|
|
104
|
+
|
|
105
|
+
except OSError as e:
|
|
106
|
+
raise RuntimeError(f"Failed to write JSON file {path}: {e}") from e
|
|
107
|
+
|
|
108
|
+
# Detach the temporary children to leave the model unchanged
|
|
109
|
+
model.metadata.parent = None
|
|
110
|
+
model.content.parent = None
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Module registry class for sharing module models across IODs in dcmspec.
|
|
2
|
+
|
|
3
|
+
Provides the ModuleRegistry class, which manages a mapping from table_id to module SpecModel.
|
|
4
|
+
This enables memory-efficient sharing of module models when building many IODs.
|
|
5
|
+
|
|
6
|
+
A table_id is a string identifier for a DICOM table, typically extracted from the HTML anchor tag,
|
|
7
|
+
for example: <a id="table_C.7-1" shape="rect"></a> yields table_id="table_C.7-1".
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections import UserDict
|
|
11
|
+
|
|
12
|
+
class ModuleRegistry(UserDict):
|
|
13
|
+
"""Registry for sharing module models by table_id across IODs.
|
|
14
|
+
|
|
15
|
+
This class manages a mapping from table_id (str) to SpecModel.
|
|
16
|
+
The table_id is typically a string like "table_C.7-1", as found in the HTML anchor tag:
|
|
17
|
+
<a id="table_C.7-1" shape="rect"></a>
|
|
18
|
+
It is used to avoid duplicating module models in memory when building many IODs.
|
|
19
|
+
|
|
20
|
+
Access patterns:
|
|
21
|
+
- registry[table_id] -> SpecModel
|
|
22
|
+
- registry.get(table_id) -> SpecModel or None
|
|
23
|
+
- for table_id, model in registry.items(): ...
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
```python
|
|
27
|
+
registry = ModuleRegistry()
|
|
28
|
+
# When building IODs, pass registry to IODSpecBuilder(module_registry=registry)
|
|
29
|
+
|
|
30
|
+
# Setting a module model:
|
|
31
|
+
registry["table_C.7-1"] = module_model # module_model is a SpecModel
|
|
32
|
+
|
|
33
|
+
# Getting a module model (returns SpecModel):
|
|
34
|
+
model = registry["table_C.7-1"]
|
|
35
|
+
|
|
36
|
+
# Safe get (returns SpecModel or None):
|
|
37
|
+
model = registry.get("table_C.7-1")
|
|
38
|
+
|
|
39
|
+
# Checking if a module is present:
|
|
40
|
+
if "table_C.7-1" in registry:
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
# Iterating over all table_ids and models:
|
|
44
|
+
for table_id, model in registry.items():
|
|
45
|
+
# table_id: str, model: SpecModel
|
|
46
|
+
...
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
All values in the registry are instances of SpecModel.
|
|
50
|
+
|
|
51
|
+
"""
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""PDF document handler for IHE Technical Frameworks or Supplements processing in dcmspec.
|
|
2
|
+
|
|
3
|
+
Provides the PDFDocHandler class for downloading, caching, and parsing PDF documents
|
|
4
|
+
from IHE Technical Frameworks or Supplements, returning CSV data from tables in PDF files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Optional, List
|
|
10
|
+
# BEGIN LEGACY SUPPORT: Remove for int progress callback deprecation
|
|
11
|
+
from dcmspec.progress import handle_legacy_callback
|
|
12
|
+
from typing import Callable
|
|
13
|
+
# END LEGACY SUPPORT
|
|
14
|
+
|
|
15
|
+
import pdfplumber
|
|
16
|
+
import camelot
|
|
17
|
+
|
|
18
|
+
from dcmspec.config import Config
|
|
19
|
+
from dcmspec.doc_handler import DocHandler
|
|
20
|
+
from dcmspec.progress import ProgressObserver
|
|
21
|
+
|
|
22
|
+
class PDFDocHandler(DocHandler):
|
|
23
|
+
"""Handler class for extracting tables from PDF documents.
|
|
24
|
+
|
|
25
|
+
Provides methods to download, cache, and extract tables as CSV data from PDF files.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
config: Optional[Config] = None,
|
|
31
|
+
logger: Optional[logging.Logger] = None,
|
|
32
|
+
extractor: str = "pdfplumber"
|
|
33
|
+
):
|
|
34
|
+
"""Initialize the PDF document handler.
|
|
35
|
+
|
|
36
|
+
Sets up the handler with an optional configuration and logger.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
config (Optional[Config]): Configuration object for cache and other settings.
|
|
40
|
+
logger (Optional[logging.Logger]): Logger instance to use. If None, a default logger is created.
|
|
41
|
+
extractor (str): Table extraction library to use.
|
|
42
|
+
`pdfplumber` (default) uses pdfplumber for extraction.
|
|
43
|
+
`camelot` uses Camelot (lattice flavor) for extraction.
|
|
44
|
+
`pdfplumber` detects tables by analyzing lines and whitespace in the PDF's vector content,
|
|
45
|
+
while `camelot` detects tables by processing the rendered page image to find drawn lines.
|
|
46
|
+
|
|
47
|
+
"""
|
|
48
|
+
super().__init__(config=config, logger=logger)
|
|
49
|
+
self.extractor = extractor
|
|
50
|
+
self.logger.debug(f"PDFDocHandler initialized with extractor {self.extractor} and logger {self.logger.name} "
|
|
51
|
+
f"at level {logging.getLevelName(self.logger.level)}")
|
|
52
|
+
|
|
53
|
+
self.cache_file_name = None
|
|
54
|
+
|
|
55
|
+
def load_document(
|
|
56
|
+
self,
|
|
57
|
+
cache_file_name: str,
|
|
58
|
+
url: Optional[str] = None,
|
|
59
|
+
force_download: bool = False,
|
|
60
|
+
progress_observer: 'Optional[ProgressObserver]' = None,
|
|
61
|
+
# BEGIN LEGACY SUPPORT: Remove for int progress callback deprecation
|
|
62
|
+
progress_callback: 'Optional[Callable[[int], None]]' = None,
|
|
63
|
+
# END LEGACY SUPPORT
|
|
64
|
+
page_numbers: Optional[list] = None,
|
|
65
|
+
table_indices: Optional[list] = None,
|
|
66
|
+
table_header_rowspan: Optional[dict] = None,
|
|
67
|
+
table_id: Optional[str] = None,
|
|
68
|
+
) -> dict:
|
|
69
|
+
"""Download, cache, and extract the logical CSV table from the PDF.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
cache_file_name (str): Path to the local cached PDF file.
|
|
73
|
+
url (str, optional): URL to download the file from if not cached or if force_download is True.
|
|
74
|
+
force_download (bool): If True, do not use cache and download the file from the URL.
|
|
75
|
+
progress_observer (Optional[ProgressObserver]): Optional observer to report download progress.
|
|
76
|
+
progress_callback (Optional[Callable[[int], None]]): [LEGACY, Deprecated] Optional callback to
|
|
77
|
+
report progress as an integer percent (0-100, or -1 if indeterminate). Use progress_observer
|
|
78
|
+
instead. Will be removed in a future release.
|
|
79
|
+
page_numbers (list, optional): List of page numbers to extract tables from.
|
|
80
|
+
table_indices (list, optional): List of (page, index) tuples specifying which tables to concatenate.
|
|
81
|
+
table_header_rowspan (dict, optional): Number of header rows (rowspan) for each table in table_indices.
|
|
82
|
+
table_id (str, optional): An identifier for the concatenated table.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
dict: The specification table dict with keys 'header', 'data', and optionally 'table_id'.
|
|
86
|
+
|
|
87
|
+
Example:
|
|
88
|
+
```python
|
|
89
|
+
handler = PDFDocHandler()
|
|
90
|
+
spec_table = handler.load_document(
|
|
91
|
+
cache_file_name="myfile.pdf",
|
|
92
|
+
url="https://example.com/myfile.pdf",
|
|
93
|
+
page_numbers=[10, 11],
|
|
94
|
+
table_indices=[(10, 0), (11, 1)],
|
|
95
|
+
table_header_rowspan={
|
|
96
|
+
(10, 0): 2, # Table starts on page 10, index 0 and has 2 header rows
|
|
97
|
+
(11, 1): 2, # Table ends on page 11, index 1 and has 2 header rows
|
|
98
|
+
},
|
|
99
|
+
table_id="my_spec_table"
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
"""
|
|
104
|
+
# BEGIN LEGACY SUPPORT: Remove for int progress callback deprecation
|
|
105
|
+
progress_observer = handle_legacy_callback(progress_observer, progress_callback)
|
|
106
|
+
# END LEGACY SUPPORT
|
|
107
|
+
self.cache_file_name = cache_file_name
|
|
108
|
+
cache_file_path = os.path.join(self.config.get_param("cache_dir"), "standard", cache_file_name)
|
|
109
|
+
need_download = force_download or (not os.path.exists(cache_file_path))
|
|
110
|
+
if need_download:
|
|
111
|
+
if not url:
|
|
112
|
+
self.logger.error("URL must be provided to download the file.")
|
|
113
|
+
raise ValueError("URL must be provided to download the file.")
|
|
114
|
+
self.logger.info(f"Downloading PDF from {url} to {cache_file_path}")
|
|
115
|
+
cache_file_path = self.download(url, cache_file_name, progress_observer=progress_observer)
|
|
116
|
+
else:
|
|
117
|
+
self.logger.info(f"Loading PDF from cache file {cache_file_path}")
|
|
118
|
+
|
|
119
|
+
if page_numbers is None or table_indices is None:
|
|
120
|
+
self.logger.error("page_numbers and table_indices must be provided to extract the logical table.")
|
|
121
|
+
raise ValueError("page_numbers and table_indices must be provided to extract the logical table.")
|
|
122
|
+
|
|
123
|
+
self.logger.debug(f"Extracting tables from pages: {page_numbers}")
|
|
124
|
+
if self.extractor == "pdfplumber":
|
|
125
|
+
pdf = pdfplumber.open(cache_file_path)
|
|
126
|
+
all_tables = self.extract_tables_pdfplumber(pdf, page_numbers)
|
|
127
|
+
self.logger.debug(f"Extracted {len(all_tables)} tables from PDF using pdfplumber.")
|
|
128
|
+
pdf.close()
|
|
129
|
+
elif self.extractor == "camelot":
|
|
130
|
+
all_tables = self.extract_tables_camelot(cache_file_path, page_numbers)
|
|
131
|
+
self.logger.debug(f"Extracted {len(all_tables)} tables from PDF using Camelot.")
|
|
132
|
+
else:
|
|
133
|
+
raise ValueError(f"Unknown extractor: {self.extractor}")
|
|
134
|
+
|
|
135
|
+
if self.logger.isEnabledFor(logging.DEBUG):
|
|
136
|
+
for idx, table in enumerate(all_tables):
|
|
137
|
+
self.logger.debug(f"\nTable {idx}:")
|
|
138
|
+
for row in table["data"]:
|
|
139
|
+
self.logger.debug(row)
|
|
140
|
+
|
|
141
|
+
self.logger.debug(f"Selecting tables with indices: {table_indices}")
|
|
142
|
+
selected_tables = self.select_tables(all_tables, table_indices, table_header_rowspan)
|
|
143
|
+
self.logger.debug(f"Concatenating selected tables with table_id: {table_id}")
|
|
144
|
+
spec_table = self.concat_tables(selected_tables, table_id=table_id)
|
|
145
|
+
self.logger.debug(f"Returning spec_table with header: {spec_table.get('header', [])}")
|
|
146
|
+
self.logger.debug(f"Returning spec_table with data: {spec_table.get('data', [])}")
|
|
147
|
+
|
|
148
|
+
return spec_table
|
|
149
|
+
|
|
150
|
+
def download(self,
|
|
151
|
+
url: str,
|
|
152
|
+
cache_file_name: str,
|
|
153
|
+
progress_observer: 'Optional[ProgressObserver]' = None,
|
|
154
|
+
# BEGIN LEGACY SUPPORT: Remove for int progress callback deprecation
|
|
155
|
+
progress_callback: 'Optional[Callable[[int], None]]' = None
|
|
156
|
+
# END LEGACY SUPPORT
|
|
157
|
+
) -> str:
|
|
158
|
+
"""Download and cache a PDF file from a URL using the base class download method.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
url: The URL of the PDF document to download.
|
|
162
|
+
cache_file_name: The filename of the cached document.
|
|
163
|
+
progress_observer (Optional[ProgressObserver]): Optional observer to report download progress.
|
|
164
|
+
progress_callback (Optional[Callable[[int], None]]): [LEGACY, Deprecated] Optional callback to
|
|
165
|
+
report progress as an integer percent (0-100, or -1 if indeterminate). Use progress_observer
|
|
166
|
+
instead. Will be removed in a future release.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
The file path where the document was saved.
|
|
170
|
+
|
|
171
|
+
Raises:
|
|
172
|
+
RuntimeError: If the download or save fails.
|
|
173
|
+
|
|
174
|
+
"""
|
|
175
|
+
# BEGIN LEGACY SUPPORT: Remove for int progress callback deprecation
|
|
176
|
+
progress_observer = handle_legacy_callback(progress_observer, progress_callback)
|
|
177
|
+
# END LEGACY SUPPORT
|
|
178
|
+
file_path = os.path.join(self.config.get_param("cache_dir"), "standard", cache_file_name)
|
|
179
|
+
return super().download(url, file_path, binary=True, progress_observer=progress_observer)
|
|
180
|
+
|
|
181
|
+
def extract_tables_pdfplumber(self, pdf: pdfplumber.PDF, page_numbers: List[int]) -> List[dict]:
|
|
182
|
+
"""Extract and return all tables from the specified PDF pages using pdfplumber.
|
|
183
|
+
|
|
184
|
+
Uses pdfplumber to extract tables from the PDF by analyzing lines and whitespace in the PDF's vector content.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
pdf (pdfplumber.PDF): The PDF object.
|
|
188
|
+
page_numbers (List[int]): List of page numbers (1-indexed) to extract tables from.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
List[dict]: List of dicts, each with keys 'page', 'index', and 'data' (table as list of rows).
|
|
192
|
+
|
|
193
|
+
Raises:
|
|
194
|
+
IndexError: If a page number is out of range for the PDF.
|
|
195
|
+
|
|
196
|
+
"""
|
|
197
|
+
all_tables = []
|
|
198
|
+
num_pages = len(pdf.pages)
|
|
199
|
+
for page_num in page_numbers:
|
|
200
|
+
if not (1 <= page_num <= num_pages):
|
|
201
|
+
raise IndexError(
|
|
202
|
+
f"Page number {page_num} is out of range for this PDF (valid range: 1 to {num_pages})"
|
|
203
|
+
)
|
|
204
|
+
page = pdf.pages[page_num - 1]
|
|
205
|
+
tables = page.extract_tables(
|
|
206
|
+
table_settings={
|
|
207
|
+
"vertical_strategy": "lines",
|
|
208
|
+
"horizontal_strategy": "lines",
|
|
209
|
+
"snap_tolerance": 8,
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
if not tables:
|
|
214
|
+
continue
|
|
215
|
+
all_tables.extend(
|
|
216
|
+
{
|
|
217
|
+
"page": page_num,
|
|
218
|
+
"index": idx,
|
|
219
|
+
"data": table,
|
|
220
|
+
}
|
|
221
|
+
for idx, table in enumerate(tables)
|
|
222
|
+
)
|
|
223
|
+
return all_tables
|
|
224
|
+
|
|
225
|
+
def extract_tables_camelot(self, file_path: str, page_numbers: List[int]) -> List[dict]:
|
|
226
|
+
"""Extract and return all tables from the specified PDF pages using Camelot.
|
|
227
|
+
|
|
228
|
+
Uses Camelot in "lattice" mode, which detects tables by analyzing the rendered page image for drawn lines.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
file_path (str): Path to the PDF file.
|
|
232
|
+
page_numbers (List[int]): List of page numbers (1-indexed) to extract tables from.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
List[dict]: List of dicts, each with keys 'page', 'index', and 'data' (table as list of rows).
|
|
236
|
+
|
|
237
|
+
"""
|
|
238
|
+
all_tables = []
|
|
239
|
+
for page_num in page_numbers:
|
|
240
|
+
tables = camelot.read_pdf(
|
|
241
|
+
file_path,
|
|
242
|
+
pages=str(page_num),
|
|
243
|
+
flavor="lattice",
|
|
244
|
+
line_scale=40
|
|
245
|
+
)
|
|
246
|
+
all_tables.extend(
|
|
247
|
+
{
|
|
248
|
+
"page": page_num,
|
|
249
|
+
"index": idx,
|
|
250
|
+
"data": table.df.values.tolist(),
|
|
251
|
+
}
|
|
252
|
+
for idx, table in enumerate(tables)
|
|
253
|
+
)
|
|
254
|
+
return all_tables
|
|
255
|
+
|
|
256
|
+
def select_tables(
|
|
257
|
+
self,
|
|
258
|
+
tables: List[dict],
|
|
259
|
+
table_indices: List[tuple],
|
|
260
|
+
table_header_rowspan: Optional[dict] = None,
|
|
261
|
+
) -> List[dict]:
|
|
262
|
+
"""Select tables referenced by table_indices and split each table into header and data.
|
|
263
|
+
|
|
264
|
+
This method processes a list of extracted tables, selects those specified by table_indices,
|
|
265
|
+
and splits each selected table into a header and data rows. If a table has multiple header rows
|
|
266
|
+
(as specified by table_header_rowspan), these rows are merged column-wise to form a single header row.
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
tables (List[dict]): List of table dicts, each with 'page', 'index', and 'data' (raw table rows).
|
|
271
|
+
table_indices (List[tuple]): List of (page, index) tuples specifying which tables to select and process.
|
|
272
|
+
table_header_rowspan (dict, optional): Number of header rows (rowspan) for each table in table_indices,
|
|
273
|
+
keyed by (page, index). If not specified, defaults to 1 header row per table.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
List[dict]: List of dicts, each with keys:
|
|
277
|
+
- 'page': page number of the table
|
|
278
|
+
- 'index': index of the table on the page
|
|
279
|
+
- 'header': merged header row (list of column names)
|
|
280
|
+
- 'data': list of data rows (list of cell values)
|
|
281
|
+
|
|
282
|
+
Example:
|
|
283
|
+
```python
|
|
284
|
+
selected_tables = handler.select_tables(
|
|
285
|
+
tables,
|
|
286
|
+
table_indices=[(10, 0), (11, 1)],
|
|
287
|
+
table_header_rowspan={(10, 0): 2, (11, 1): 2}
|
|
288
|
+
)
|
|
289
|
+
for table in selected_tables:
|
|
290
|
+
print(table["header"], table["data"])
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
"""
|
|
294
|
+
def merge_multirow_header(header_rows):
|
|
295
|
+
n_cols = max(len(row) for row in header_rows)
|
|
296
|
+
merged = []
|
|
297
|
+
for col in range(n_cols):
|
|
298
|
+
merged_cell = " ".join(
|
|
299
|
+
str(row[col]).strip() for row in header_rows
|
|
300
|
+
if col < len(row) and row[col] not in (None, "")
|
|
301
|
+
).strip()
|
|
302
|
+
merged.append(merged_cell)
|
|
303
|
+
return merged
|
|
304
|
+
|
|
305
|
+
selected_tables = []
|
|
306
|
+
for page, idx in table_indices:
|
|
307
|
+
for table in tables:
|
|
308
|
+
if table["page"] == page and table["index"] == idx:
|
|
309
|
+
table_rows = table["data"]
|
|
310
|
+
n_header_rows = 1
|
|
311
|
+
if table_header_rowspan and (page, idx) in table_header_rowspan:
|
|
312
|
+
n_header_rows = table_header_rowspan[(page, idx)]
|
|
313
|
+
header_rows = table_rows[:n_header_rows]
|
|
314
|
+
data_rows = table_rows[n_header_rows:]
|
|
315
|
+
# Merge header rows if needed
|
|
316
|
+
if len(header_rows) == 1:
|
|
317
|
+
header_ = header_rows[0]
|
|
318
|
+
else:
|
|
319
|
+
header_ = merge_multirow_header(header_rows)
|
|
320
|
+
selected_tables.append({
|
|
321
|
+
"page": page,
|
|
322
|
+
"index": idx,
|
|
323
|
+
"header": header_,
|
|
324
|
+
"data": data_rows,
|
|
325
|
+
})
|
|
326
|
+
return selected_tables
|
|
327
|
+
|
|
328
|
+
def concat_tables(
|
|
329
|
+
self,
|
|
330
|
+
tables: List[dict],
|
|
331
|
+
table_id: str = None,
|
|
332
|
+
) -> dict:
|
|
333
|
+
"""Concatenate selected tables (across pages or by specification) into a single logical table.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
tables (List[dict]): List of table dicts, each with 'page', 'index', 'header', and 'data'.
|
|
337
|
+
table_id (str, optional): An identifier for the concatenated table.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
dict: A dict with keys 'table_id' (if provided), 'header' (from the first table),
|
|
341
|
+
and 'data' (the concatenated table as a list of rows).
|
|
342
|
+
|
|
343
|
+
"""
|
|
344
|
+
grouped_table = []
|
|
345
|
+
header = []
|
|
346
|
+
first = True
|
|
347
|
+
for table in tables:
|
|
348
|
+
header_ = table.get("header", [])
|
|
349
|
+
if first:
|
|
350
|
+
header = header_
|
|
351
|
+
first = False
|
|
352
|
+
elif header and header_ != header:
|
|
353
|
+
self.logger.warning(
|
|
354
|
+
f"Header mismatch in concatenated tables: {header} != {header_} "
|
|
355
|
+
f"(page {table['page']}, index {table['index']})"
|
|
356
|
+
)
|
|
357
|
+
n_columns = len(header)
|
|
358
|
+
for row in table["data"]:
|
|
359
|
+
# Always pad/truncate to header length
|
|
360
|
+
row = (row + [""] * (n_columns - len(row)))[:n_columns]
|
|
361
|
+
grouped_table.append(row)
|
|
362
|
+
|
|
363
|
+
# Realign columns: shift non-empty header cells and data cells left to fill gaps ---
|
|
364
|
+
def shift_row_left(row):
|
|
365
|
+
new_row = [cell for cell in row if cell not in (None, "")]
|
|
366
|
+
new_row += [""] * (len(row) - len(new_row))
|
|
367
|
+
return new_row
|
|
368
|
+
|
|
369
|
+
header = shift_row_left(header)
|
|
370
|
+
data = [shift_row_left(row) for row in grouped_table]
|
|
371
|
+
|
|
372
|
+
# Remove columns where the header is empty
|
|
373
|
+
columns_to_keep = [i for i, cell in enumerate(header) if cell not in (None, "")]
|
|
374
|
+
header = [header[i] for i in columns_to_keep]
|
|
375
|
+
data = [[row[i] for i in columns_to_keep] for row in data]
|
|
376
|
+
|
|
377
|
+
result = {"header": header, "data": data}
|
|
378
|
+
if table_id is not None:
|
|
379
|
+
result["table_id"] = table_id
|
|
380
|
+
return result
|
|
381
|
+
|
|
382
|
+
def extract_notes(
|
|
383
|
+
self,
|
|
384
|
+
pdf: pdfplumber.PDF,
|
|
385
|
+
page_numbers: List[int],
|
|
386
|
+
table_id: str = None,
|
|
387
|
+
note_pattern: str = r"^\d*\s*Note\s\d+:",
|
|
388
|
+
header_footer_pattern: str = r"^\s*(IHE|_{3,}|Rev\.|Copyright|Template|Page\s\d+|\(TDW-II\))",
|
|
389
|
+
line_number_pattern: str = r"^\d+\s",
|
|
390
|
+
end_note_pattern: str = r".*7\.5\.1\.1\.2",
|
|
391
|
+
) -> dict:
|
|
392
|
+
"""Extract notes referenced in tables from the specified PDF pages.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
pdf (pdfplumber.PDF): The PDF object.
|
|
396
|
+
page_numbers (List[int]): List of page numbers (1-indexed) to extract notes from.
|
|
397
|
+
table_id (str, optional): The table_id to associate with these notes.
|
|
398
|
+
note_pattern (str): Regex pattern to identify note lines.
|
|
399
|
+
header_footer_pattern (str): Regex pattern to skip header/footer lines.
|
|
400
|
+
line_number_pattern (str): Regex pattern to remove line numbers.
|
|
401
|
+
end_note_pattern (str): Regex pattern to identify the end of notes section.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
dict: Mapping from note key (e.g., "Note 1:") to a dict with 'text' and 'table_id' (if provided).
|
|
405
|
+
|
|
406
|
+
Example return:
|
|
407
|
+
```json
|
|
408
|
+
{
|
|
409
|
+
"Note 1:": {"text": "...", "table_id": "T-7.5-1"},
|
|
410
|
+
"Note 2:": {"text": "...", "table_id": "T-7.5-1"},
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
"""
|
|
415
|
+
import re
|
|
416
|
+
|
|
417
|
+
notes = {}
|
|
418
|
+
note_re = re.compile(note_pattern)
|
|
419
|
+
header_footer_re = re.compile(header_footer_pattern)
|
|
420
|
+
line_number_re = re.compile(line_number_pattern)
|
|
421
|
+
end_note_re = re.compile(end_note_pattern)
|
|
422
|
+
current_note = None
|
|
423
|
+
|
|
424
|
+
for page_num in page_numbers:
|
|
425
|
+
page = pdf.pages[page_num - 1]
|
|
426
|
+
text = page.extract_text()
|
|
427
|
+
if text:
|
|
428
|
+
lines = text.split("\n")
|
|
429
|
+
for line in lines:
|
|
430
|
+
# Always skip header/footer lines, even in note continuation
|
|
431
|
+
if header_footer_re.search(line):
|
|
432
|
+
continue
|
|
433
|
+
if end_note_re.search(line):
|
|
434
|
+
current_note = None
|
|
435
|
+
break
|
|
436
|
+
match = note_re.search(line)
|
|
437
|
+
if match:
|
|
438
|
+
note_number = match.group().strip()
|
|
439
|
+
note_number = re.sub(r"^\d*\s*", "", note_number)
|
|
440
|
+
note_text = line[match.end():].strip()
|
|
441
|
+
notes[note_number] = {
|
|
442
|
+
"text": note_text,
|
|
443
|
+
"table_id": table_id
|
|
444
|
+
} if table_id else {"text": note_text}
|
|
445
|
+
current_note = note_number
|
|
446
|
+
elif current_note:
|
|
447
|
+
line = line_number_re.sub("", line).strip()
|
|
448
|
+
notes[current_note]["text"] += f" {line}"
|
|
449
|
+
if notes:
|
|
450
|
+
self.logger.debug(f"Extracted notes: {list(notes.keys())}")
|
|
451
|
+
return notes
|