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.
Files changed (45) hide show
  1. dcmspec/__init__.py +0 -0
  2. dcmspec/apps/__init__.py +1 -0
  3. dcmspec/apps/cli/__init__.py +0 -0
  4. dcmspec/apps/cli/dataelements.py +89 -0
  5. dcmspec/apps/cli/iodattributes.py +125 -0
  6. dcmspec/apps/cli/iodmodules.py +92 -0
  7. dcmspec/apps/cli/modattributes.py +265 -0
  8. dcmspec/apps/cli/tdwiicontent.py +365 -0
  9. dcmspec/apps/cli/uidvalues.py +84 -0
  10. dcmspec/apps/cli/upsdimseattributes.py +109 -0
  11. dcmspec/apps/cli/upsioddimseattributes.py +330 -0
  12. dcmspec/apps/ui/iod_explorer/README.md +33 -0
  13. dcmspec/apps/ui/iod_explorer/__init__.py +9 -0
  14. dcmspec/apps/ui/iod_explorer/config/README.md +171 -0
  15. dcmspec/apps/ui/iod_explorer/config/iod_explorer_config.json +4 -0
  16. dcmspec/apps/ui/iod_explorer/config/iod_explorer_config_debug.json +4 -0
  17. dcmspec/apps/ui/iod_explorer/config/iod_explorer_config_example.json +4 -0
  18. dcmspec/apps/ui/iod_explorer/config/iod_explorer_config_minimal_logging.json +4 -0
  19. dcmspec/apps/ui/iod_explorer/iod_explorer.py +989 -0
  20. dcmspec/config.py +90 -0
  21. dcmspec/csv_table_spec_parser.py +85 -0
  22. dcmspec/doc_handler.py +214 -0
  23. dcmspec/dom_table_spec_parser.py +831 -0
  24. dcmspec/dom_utils.py +116 -0
  25. dcmspec/iod_spec_builder.py +444 -0
  26. dcmspec/iod_spec_printer.py +59 -0
  27. dcmspec/json_spec_store.py +110 -0
  28. dcmspec/module_registry.py +51 -0
  29. dcmspec/pdf_doc_handler.py +451 -0
  30. dcmspec/progress.py +232 -0
  31. dcmspec/service_attribute_defaults.py +124 -0
  32. dcmspec/service_attribute_model.py +231 -0
  33. dcmspec/spec_factory.py +451 -0
  34. dcmspec/spec_merger.py +536 -0
  35. dcmspec/spec_model.py +461 -0
  36. dcmspec/spec_parser.py +37 -0
  37. dcmspec/spec_printer.py +131 -0
  38. dcmspec/spec_store.py +50 -0
  39. dcmspec/ups_xhtml_doc_handler.py +117 -0
  40. dcmspec/xhtml_doc_handler.py +182 -0
  41. dcmspec-0.2.1.dist-info/METADATA +139 -0
  42. dcmspec-0.2.1.dist-info/RECORD +45 -0
  43. dcmspec-0.2.1.dist-info/WHEEL +4 -0
  44. dcmspec-0.2.1.dist-info/entry_points.txt +11 -0
  45. 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