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,989 @@
1
+ """IOD Explorer - GUI application for dcmspec.
2
+
3
+ This module provides a graphical user interface for exploring DICOM specifications,
4
+ allowing users to browse IODs, modules, and attributes through an interactive interface.
5
+ """
6
+
7
+ import tkinter as tk
8
+ from tkinter import ttk, messagebox
9
+ import tkinter.font as tkfont
10
+ from tkhtmlview import HTMLLabel
11
+
12
+ from typing import List, Tuple
13
+ import re
14
+ import logging
15
+ import os
16
+
17
+ from anytree import PreOrderIter
18
+ from bs4 import BeautifulSoup
19
+
20
+ from dcmspec.config import Config
21
+ from dcmspec.iod_spec_builder import IODSpecBuilder
22
+ from dcmspec.spec_factory import SpecFactory
23
+ from dcmspec.xhtml_doc_handler import XHTMLDocHandler
24
+ from dcmspec.dom_table_spec_parser import DOMTableSpecParser
25
+
26
+ # Canonical DICOM Part 3 TOC (Table of Contents) URL
27
+ PART3_TOC_URL = "https://dicom.nema.org/medical/dicom/current/output/chtml/part03/ps3.3.html"
28
+ # Canonical DICOM Part 3 HTML URL
29
+ PART3_HTML_URL = "https://dicom.nema.org/medical/dicom/current/output/html/part03.html"
30
+
31
+
32
+ class StatusManager:
33
+ """Handles status bar messaging with consistent logic."""
34
+
35
+ def __init__(self, status_var):
36
+ """Initialize the StatusManager.
37
+
38
+ Args:
39
+ status_var: A tkinter StringVar or similar object used to update the status bar text.
40
+
41
+ """
42
+ self.status_var = status_var
43
+
44
+ def show_count_status(self, count: int):
45
+ """Show count-based status when no selection."""
46
+ message = f"Showing {count} IODs"
47
+ self.status_var.set(message)
48
+
49
+ def show_selection_status(self, title: str, iod_type: str, is_iod: bool = True):
50
+ """Show selection-based status when item selected."""
51
+ if is_iod:
52
+ self.status_var.set(f"{title} {iod_type} • Click ▶ to expand")
53
+ else:
54
+ self.status_var.set(f"{iod_type}: {title}")
55
+
56
+ def show_loading_status(self, message: str):
57
+ """Show loading status."""
58
+ self.status_var.set(message)
59
+
60
+
61
+ def load_app_config() -> Config:
62
+ """Load app-specific configuration with priority search order.
63
+
64
+ Search order:
65
+ 1. App-specific config files (iod_explorer_config.json) - Tier 1
66
+ - Current directory
67
+ - ~/.config/dcmspec/
68
+ - App config directory (src/dcmspec/apps/iod_explorer/config/)
69
+ - Same directory as script (legacy support)
70
+ 2. Base library config file (config.json) - Tier 2 fallback
71
+ - Platform-specific user config directory via Config class
72
+ 3. Default values if no config files found
73
+
74
+ Note: The base Config class always looks for a config file. When we pass
75
+ config_file=None, it uses user_config_dir(app_name)/config.json as default.
76
+
77
+ Returns:
78
+ Config: Configuration object with app-specific settings.
79
+
80
+ """
81
+ import os
82
+
83
+ # Look for app-specific config file in several locations (highest priority)
84
+ app_config_locations = [
85
+ "iod_explorer_config.json", # Current directory
86
+ os.path.expanduser("~/.config/dcmspec/iod_explorer_config.json"), # User config
87
+ os.path.join(os.path.dirname(__file__), "config", "iod_explorer_config.json"), # App config dir
88
+ os.path.join(os.path.dirname(__file__), "iod_explorer_config.json"), # Same dir as script (legacy)
89
+ ]
90
+
91
+ config_file = next(
92
+ (
93
+ location
94
+ for location in app_config_locations
95
+ if os.path.exists(location)
96
+ ),
97
+ None,
98
+ )
99
+ # If no app-specific config found, let Config class use its default location
100
+ # This will be: user_config_dir("iod_explorer")/config.json
101
+ config = Config(app_name="iod_explorer", config_file=config_file)
102
+
103
+ # Set default log level if not specified
104
+ if config.get_param("log_level") is None:
105
+ config.set_param("log_level", "INFO")
106
+
107
+ return config
108
+
109
+
110
+ def setup_logger(config: Config) -> logging.Logger:
111
+ """Set up logger with configurable level from config.
112
+
113
+ Args:
114
+ config (Config): Configuration object containing log_level setting.
115
+
116
+ Returns:
117
+ logging.Logger: Configured logger instance.
118
+
119
+ """
120
+ logger = logging.getLogger("iod_explorer")
121
+
122
+ # Remove any existing handlers to avoid duplicates
123
+ for handler in logger.handlers[:]:
124
+ logger.removeHandler(handler)
125
+
126
+ # Create console handler
127
+ console_handler = logging.StreamHandler()
128
+
129
+ # Get log level from config
130
+ log_level_str = config.get_param("log_level") or "INFO"
131
+ log_level = getattr(logging, log_level_str.upper(), logging.INFO)
132
+
133
+ logger.setLevel(log_level)
134
+ console_handler.setLevel(log_level)
135
+
136
+ # Create formatter
137
+ formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
138
+ console_handler.setFormatter(formatter)
139
+
140
+ # Add handler to logger
141
+ logger.addHandler(console_handler)
142
+
143
+ return logger
144
+
145
+
146
+ class IODExplorer:
147
+ """Main window for the IOD Explorer application."""
148
+
149
+ def __init__(self, root: tk.Tk):
150
+ """Initialize the IOD Explorer application.
151
+
152
+ This method initializes the backend services and domain model, as well as the frontend controllers and views.
153
+ """
154
+ # --- Backend Services ---
155
+
156
+ # Load app-specific configuration
157
+ self.config = load_app_config()
158
+ # Initialize logger using configuration
159
+ self.logger = setup_logger(self.config)
160
+
161
+ # Log startup information
162
+ self.logger.info("Starting IOD Explorer")
163
+ # Log configuration information at INFO level
164
+ log_level_configured = self.config.get_param('log_level') or 'INFO'
165
+ config_source = ("app-specific" if self.config.config_file and
166
+ "iod_explorer_config.json" in self.config.config_file else "default")
167
+ self.logger.info(f"Logging configured: level={log_level_configured.upper()}, source={config_source}")
168
+ # Log operational configuration at INFO level (important for users to know)
169
+ config_file_display = self.config.config_file or "none (using defaults)"
170
+ self.logger.info(f"Config file: {config_file_display}")
171
+ self.logger.info(f"Cache directory: {self.config.cache_dir}")
172
+
173
+ # --- Domain Model ---
174
+
175
+ # Initialize document handler for DICOM standard XHTML documents
176
+ self.doc_handler = XHTMLDocHandler(config=self.config, logger=self.logger)
177
+ # Initialize DOM parser for DICOM standard version extraction
178
+ self.dom_parser = DOMTableSpecParser(logger=self.logger)
179
+ # URL for DICOM Part 3 Table of Contents
180
+ self.part3_toc_url = PART3_TOC_URL
181
+ # Initialize list of all IODs
182
+ self.iod_list = []
183
+ # Store IOD models to keep AnyTree nodes in memory
184
+ self.iod_models = {} # table_id -> model mapping
185
+ # Store DICOM version
186
+ self.dicom_version = "Unknown"
187
+
188
+ # --- Frontend State / Controller ---
189
+
190
+ # --- View ---
191
+
192
+ self.root = root
193
+ self.root.title("IOD Explorer")
194
+ self._init_window_geometry()
195
+ self.setup_ui()
196
+
197
+ # Load and display IOD modules in the UI (not initialization, but triggers initial data load and view update)
198
+ self.load_iod_modules()
199
+
200
+ def _init_window_geometry(self):
201
+ """Set window size and center it on screen."""
202
+ window_width = 1200
203
+ window_height = 700
204
+ screen_width = self.root.winfo_screenwidth()
205
+ screen_height = self.root.winfo_screenheight()
206
+ x = (screen_width - window_width) // 2
207
+ y = (screen_height - window_height) // 2
208
+ self.root.geometry(f"{window_width}x{window_height}+{x}+{y}")
209
+
210
+ def setup_ui(self):
211
+ """Set up the user interface."""
212
+ # Create main frame
213
+ main_frame = ttk.Frame(self.root)
214
+ main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
215
+
216
+ # Create top frame for controls and labels
217
+ top_frame = ttk.Frame(main_frame)
218
+ top_frame.pack(fill=tk.X, pady=(0, 10))
219
+
220
+ # Add version label in top frame
221
+ self.version_label = ttk.Label(top_frame, text="", font=("Arial", 10))
222
+ self.version_label.pack(side=tk.LEFT)
223
+
224
+ # Create resizable paned window for IOD list and details
225
+ paned_window = ttk.PanedWindow(main_frame, orient=tk.HORIZONTAL)
226
+ paned_window.pack(fill=tk.BOTH, expand=True)
227
+
228
+ # Left panel container
229
+ left_panel = ttk.Frame(paned_window)
230
+ paned_window.add(left_panel, weight=1)
231
+
232
+ # Right panel container
233
+ right_panel = ttk.Frame(paned_window)
234
+ paned_window.add(right_panel, weight=1)
235
+
236
+ # Left panel header
237
+ header_frame = ttk.Frame(left_panel)
238
+ header_frame.pack(fill=tk.X, pady=(0, 5))
239
+
240
+ iod_list_label = ttk.Label(header_frame, text="DICOM IOD List", font=("Arial", 12, "bold"))
241
+ iod_list_label.pack(side=tk.LEFT)
242
+
243
+ # Right panel header
244
+ details_header_frame = ttk.Frame(right_panel)
245
+ details_header_frame.pack(fill=tk.X, pady=(0, 5))
246
+
247
+ details_label_header = ttk.Label(details_header_frame, text="Details", font=("Arial", 12, "bold"))
248
+ details_label_header.pack(side=tk.LEFT)
249
+
250
+ # Left frame for treeview
251
+ left_frame = ttk.Frame(left_panel)
252
+ left_frame.pack(fill=tk.BOTH, expand=True)
253
+
254
+ # Configure grid for treeview area
255
+ left_frame.columnconfigure(0, weight=1) # Treeview column expands
256
+ left_frame.columnconfigure(1, weight=0) # Scrollbar column fixed
257
+ left_frame.rowconfigure(0, weight=1) # Treeview row expands
258
+ left_frame.rowconfigure(1, weight=0) # Scrollbar row fixed
259
+
260
+ # Treeview with scrollbar - configure with monospaced font for better tag display
261
+ self.tree = ttk.Treeview(
262
+ left_frame,
263
+ columns=("iod_type", "usage"),
264
+ show="tree headings"
265
+ )
266
+
267
+ # Configure monospaced font using TTK style
268
+ style = ttk.Style()
269
+
270
+ # Configure monospaced font - simple preference stack
271
+ available_fonts = tkfont.families()
272
+
273
+ # Preferred monospaced fonts in order of preference
274
+ font_preferences = ["Menlo", "Monaco", "Courier New", "Andale Mono", "TkFixedFont"]
275
+
276
+ # Select the first available font from our preference list
277
+ selected_font = "TkFixedFont" # System default monospace fallback
278
+
279
+ for font_name in font_preferences:
280
+ if font_name in available_fonts or font_name == "TkFixedFont":
281
+ selected_font = font_name
282
+ break
283
+ self.logger.debug(f"Selected monospaced font: {selected_font}")
284
+
285
+ # Configure the treeview with the selected font
286
+ style.configure("Treeview", font=(selected_font, 10))
287
+ style.configure("Treeview.Heading", font=("Arial", 10, "bold"))
288
+
289
+ # Add left padding to treeview items for better alignment
290
+ style.configure("Treeview", padding=(5, 0))
291
+
292
+ # Verify the configuration
293
+ actual_font = style.lookup("Treeview", "font")
294
+ self.logger.debug(f"Final font configuration: {actual_font}")
295
+ self.tree.heading("#0", text="Name")
296
+ self.tree.heading("iod_type", text="Kind")
297
+ self.tree.heading("usage", text="")
298
+ self.tree.column("#0", width=400)
299
+ self.tree.column("iod_type", width=100, stretch=tk.NO)
300
+ self.tree.column("usage", width=30, stretch=tk.NO) # Small column for usage icon
301
+
302
+ # Grid layout for treeview and scrollbars
303
+ self.tree.grid(row=0, column=0, sticky="nsew")
304
+
305
+ # Scrollbars for treeview
306
+ tree_scroll_y = ttk.Scrollbar(left_frame, orient=tk.VERTICAL, command=self.tree.yview)
307
+ tree_scroll_x = ttk.Scrollbar(left_frame, orient=tk.HORIZONTAL, command=self.tree.xview)
308
+ self.tree.configure(yscrollcommand=tree_scroll_y.set, xscrollcommand=tree_scroll_x.set)
309
+
310
+ tree_scroll_y.grid(row=0, column=1, sticky="ns")
311
+ tree_scroll_x.grid(row=1, column=0, sticky="ew")
312
+
313
+ # Right frame for details
314
+ right_frame = ttk.Frame(right_panel)
315
+ right_frame.pack(fill=tk.BOTH, expand=True)
316
+
317
+ # Configure grid for details area
318
+ right_frame.columnconfigure(0, weight=1) # Text column expands
319
+ right_frame.columnconfigure(1, weight=0) # Scrollbar column fixed
320
+ right_frame.rowconfigure(0, weight=1) # Text row expands
321
+ right_frame.rowconfigure(1, weight=0) # Scrollbar row fixed
322
+
323
+ # Details text in HTML area with grid layout, using the selected font and size
324
+ self.details_text = HTMLLabel(
325
+ right_frame,
326
+ html=(
327
+ f'<div style="font-family: {selected_font}; font-size: 10px;">'
328
+ f'<span>Select an IOD to view details.</span><br>'
329
+ f'</div>'
330
+ ),
331
+ width=50,
332
+ height=30
333
+ )
334
+ self.details_text.grid(row=0, column=0, sticky="nsew")
335
+ self.details_font_family = selected_font # Store for later use
336
+ self.details_font_size = 10
337
+
338
+ # Add scrollbars that match the treeview style
339
+ details_scroll_y = ttk.Scrollbar(right_frame, orient=tk.VERTICAL, command=self.details_text.yview)
340
+ details_scroll_x = ttk.Scrollbar(right_frame, orient=tk.HORIZONTAL, command=self.details_text.xview)
341
+ self.details_text.configure(yscrollcommand=details_scroll_y.set, xscrollcommand=details_scroll_x.set)
342
+
343
+ details_scroll_y.grid(row=0, column=1, sticky="ns")
344
+ details_scroll_x.grid(row=1, column=0, sticky="ew")
345
+
346
+ # Bind treeview selection event
347
+ self.tree.bind("<<TreeviewSelect>>", self.on_tree_select)
348
+
349
+ # Status bar
350
+ status_frame = ttk.Frame(main_frame)
351
+ status_frame.pack(fill=tk.X, side=tk.BOTTOM, pady=(5, 0))
352
+
353
+ self.status_var = tk.StringVar()
354
+ self.status_var.set("Ready")
355
+
356
+ # Initialize status manager
357
+ self.status_manager = StatusManager(self.status_var)
358
+
359
+ status_bar = ttk.Label(status_frame, textvariable=self.status_var, relief=tk.FLAT)
360
+ status_bar.pack(side=tk.LEFT, fill=tk.X, expand=True)
361
+
362
+ def load_iod_modules(self):
363
+ """Load IOD modules from the DICOM specification."""
364
+ self.status_var.set("Loading IOD modules...")
365
+ self.root.update()
366
+
367
+ self._last_progress_percent = -1 # Add this line before defining the callback
368
+
369
+ def progress_callback(percent):
370
+ # Update the status bar with the current download progress
371
+ if percent == -1:
372
+ # Indeterminate progress
373
+ self.status_var.set("Downloading IOD modules... (progress unknown)")
374
+ self.root.update()
375
+ self._last_progress_percent = percent
376
+ elif (percent % 10 == 0 or percent == 100) and percent != self._last_progress_percent:
377
+ # Update every 10% and only if the percent changedÒ
378
+ self.status_var.set(f"Downloading IOD modules... {percent}%")
379
+ self.root.update()
380
+ self._last_progress_percent = percent
381
+
382
+ try:
383
+ # Clear existing items
384
+ for item in self.tree.get_children():
385
+ self.tree.delete(item)
386
+
387
+ # Use XHTMLDocHandler to download and parse the HTML with caching
388
+ cache_file_name = "ps3.3.html"
389
+ soup = self.doc_handler.load_document(
390
+ cache_file_name=cache_file_name,
391
+ url=self.part3_toc_url,
392
+ progress_callback=progress_callback
393
+ )
394
+
395
+ # Extract and display DICOM version using the library method
396
+ self.dicom_version = self.dom_parser.get_version(soup, "")
397
+ self.version_label.config(text=f"Version {self.dicom_version}")
398
+
399
+ # Find the list of tables div
400
+ list_of_tables = soup.find('div', class_='list-of-tables')
401
+ if not list_of_tables:
402
+ messagebox.showerror("Error", "Could not find list-of-tables section")
403
+ return
404
+
405
+ # Extract list of IODs
406
+ iod_list = self.extract_iod_list(list_of_tables)
407
+
408
+ # Store the data
409
+ self.iod_list = iod_list
410
+
411
+ # Populate the treeview directly
412
+ self.populate_treeview(iod_list)
413
+
414
+ # Update status
415
+ self.status_manager.show_count_status(count=len(iod_list))
416
+
417
+ except RuntimeError as e:
418
+ messagebox.showerror("Error", f"Failed to load DICOM specification:\n{str(e)}")
419
+ self.status_var.set("Error loading modules")
420
+ except Exception as e:
421
+ messagebox.showerror("Error", f"An error occurred:\n{str(e)}")
422
+ self.status_var.set("Error loading modules")
423
+
424
+ def extract_iod_list(self, list_of_tables) -> List[Tuple[str, str, str, str]]:
425
+ """Extract IOD list from the list of tables section.
426
+
427
+ Returns:
428
+ List of tuples (title, table_id, href, iod_type)
429
+
430
+ """
431
+ iod_list = []
432
+
433
+ # Find all dt elements
434
+ dt_elements = list_of_tables.find_all('dt')
435
+
436
+ for dt in dt_elements:
437
+ # Find anchor tags within the dt
438
+ anchor = dt.find('a')
439
+ if anchor and anchor.get('href'):
440
+ href = anchor.get('href')
441
+ text = anchor.get_text(strip=True)
442
+
443
+ # Check if this is an IOD Modules table
444
+ if 'IOD Modules' in text:
445
+ # Extract table ID from href (after the #)
446
+ if '#' in href:
447
+ table_id = href.split('#')[-1]
448
+ else:
449
+ # Fallback: try to extract from href path
450
+ table_id = href.split('/')[-1].replace('.html', '')
451
+
452
+ # Extract the title (remove the table number prefix)
453
+ title_match = re.match(r'^[A-Z]?\.\d+(?:\.\d+)*-\d+\.\s*(.+)$', text)
454
+ title = title_match[1] if title_match else text
455
+
456
+ # Strip " IOD Modules" from the end of the title
457
+ title = title.removesuffix(" IOD Modules")
458
+
459
+ # Determine IOD type based on table_id
460
+ iod_type = ("Composite" if "_A." in table_id else
461
+ "Normalized" if "_B." in table_id else "Other")
462
+
463
+ iod_list.append((title, table_id, href, iod_type))
464
+
465
+ return iod_list
466
+
467
+ def populate_treeview(self, iod_modules: List[Tuple[str, str, str, str]]):
468
+ """Populate the treeview with IOD modules."""
469
+ for title, table_id, href, iod_type in iod_modules:
470
+ self.tree.insert("", tk.END, text=title, values=(iod_type, ""),
471
+ tags=(table_id, iod_type))
472
+
473
+ def _is_model_cached(self, table_id: str) -> bool:
474
+ """Check if the IOD model is already cached."""
475
+ model_file_name = f"Part3_{table_id}_expanded.json"
476
+ cache_file_path = os.path.join(self.config.cache_dir, "model", model_file_name)
477
+ exists = os.path.exists(cache_file_path)
478
+ return exists
479
+
480
+ def on_tree_select(self, event):
481
+ """Handle treeview selection event."""
482
+ selection = self.tree.selection()
483
+ if not selection:
484
+ return
485
+
486
+ # Get selected item data
487
+ item = selection[0]
488
+ item_values = self.tree.item(item, "values")
489
+ title = self.tree.item(item, "text")
490
+ tags = self.tree.item(item, "tags")
491
+
492
+ # Determine if this is a top-level IOD or a module/attribute item
493
+ if self._is_top_level_iod_item(tags):
494
+ self._handle_iod_selection(item, title, tags)
495
+ else:
496
+ self._handle_module_attribute_selection(item, item_values, title, tags)
497
+
498
+ def _is_top_level_iod_item(self, tags):
499
+ """Check if the selected item is a top-level IOD item.
500
+
501
+ A top-level IOD item is identified by its tag starting with "table_".
502
+ """
503
+ return (tags and
504
+ isinstance(tags[0], str) and
505
+ tags[0].startswith("table_"))
506
+
507
+ def _handle_iod_selection(self, item, title, tags):
508
+ """Handle selection of a top-level IOD item."""
509
+ table_id = tags[0]
510
+ iod_type = tags[1] if len(tags) > 1 else "Unknown"
511
+
512
+ # Update status
513
+ self.status_manager.show_selection_status(title, iod_type, is_iod=True)
514
+
515
+ # Check if IOD spec model is already loaded in memory
516
+ if self.iod_models.get(table_id):
517
+ self._update_details_text(table_id, title, iod_type)
518
+ return
519
+
520
+ # Load IOD model otherwise (from cache or from web)
521
+ self._load_iod_model(item, table_id, title, iod_type, is_cached=self._is_model_cached(table_id))
522
+
523
+ def _load_iod_model(self, item, table_id, title, iod_type, is_cached=True):
524
+ """Load IOD model from cache or web.
525
+
526
+ Args:
527
+ item: The tree item to populate with the IOD structure
528
+ table_id: The table identifier for the IOD
529
+ title: The IOD title for display
530
+ iod_type: The IOD type (Composite, Normalized, etc.)
531
+ is_cached: The flag indicating if the model is cached
532
+
533
+ """
534
+ try:
535
+ # Update the status bar with loading information
536
+ if is_cached:
537
+ self.status_manager.show_loading_status(f"Loading {title} from cache...")
538
+ else:
539
+ self.status_manager.show_loading_status(f"Loading {title} (this may take a moment)...")
540
+
541
+ self.root.update()
542
+
543
+ # Build the IOD model and populate the treeview
544
+ model, _ = self._build_iod_model(table_id, self.logger)
545
+ self._update_treeview_and_details(item, model, table_id, title, iod_type)
546
+
547
+ except Exception as e:
548
+ self._handle_iod_loading_error(e, table_id, title, iod_type)
549
+
550
+ def _update_treeview_and_details(self, item, model, table_id, title, iod_type):
551
+ """Update the treeview and the details pane with the loaded IOD model."""
552
+ if model:
553
+ # Store the model in memory
554
+ self.iod_models[table_id] = model
555
+
556
+ if model.content:
557
+ # Populate the tree item with the IOD structure
558
+ self._populate_treeview_item(item, model.content)
559
+
560
+ self._update_details_text(table_id, title, iod_type)
561
+ self.status_manager.show_selection_status(title, iod_type, is_iod=True)
562
+
563
+ def _handle_iod_loading_error(self, error, table_id, title, iod_type):
564
+ """Handle errors that occur during IOD model loading."""
565
+ if "No module models were found" in str(error):
566
+ detailed_msg = (f"Failed to load IOD structure for {title}:\n\n"
567
+ f"The IOD references modules that could not be found or parsed. "
568
+ f"This may happen if:\n"
569
+ f"• Module reference tables are missing from the DICOM specification\n"
570
+ f"• Module tables have different naming conventions\n"
571
+ f"• The IOD table format is not supported\n\n"
572
+ f"Technical details: {str(error)}")
573
+ messagebox.showwarning("IOD Structure Not Available", detailed_msg)
574
+ self.logger.warning(f"Failed to build IOD model for {table_id}: {str(error)}")
575
+ else:
576
+ messagebox.showerror("Error", f"Failed to load IOD structure:\n{str(error)}")
577
+
578
+ self._update_details_text(table_id, title, iod_type)
579
+ self.status_manager.show_selection_status(title, iod_type, is_iod=True)
580
+
581
+ def _handle_module_attribute_selection(self, item, item_values, title, tags):
582
+ """Handle selection of a module or attribute item."""
583
+ node_type = item_values[0] if len(item_values) > 0 else "Unknown"
584
+ usage = item_values[1] if len(item_values) > 1 else ""
585
+
586
+ # Find the corresponding AnyTree node
587
+ node = self._find_node_from_path(item, tags)
588
+ display_path = self._build_readable_path(node) if node else title
589
+
590
+ # Generate details HTML depending on node type
591
+ details = self._generate_node_details(node_type, node, title, usage)
592
+
593
+ # Update UI
594
+ self._update_details_html(details)
595
+ self.status_var.set(f"Selected: {node_type} - {display_path}")
596
+
597
+ def _find_node_from_path(self, item, tags):
598
+ """Find the AnyTree node corresponding to the selected tree item."""
599
+ if not tags or len(tags) == 0:
600
+ return None
601
+
602
+ node_path = tags[0]
603
+ table_id = self._find_parent_table_id(item)
604
+
605
+ if not table_id or table_id not in self.iod_models:
606
+ return None
607
+
608
+ model = self.iod_models[table_id]
609
+ if not model or not hasattr(model, 'content') or not model.content:
610
+ return None
611
+
612
+ return self._traverse_node_path(model.content, node_path)
613
+
614
+ def _find_parent_table_id(self, item):
615
+ """Walk up the tree to find the parent IOD's table_id."""
616
+ current_item = item
617
+ while current_item:
618
+ parent_item = self.tree.parent(current_item)
619
+ if not parent_item: # This is a root item
620
+ item_tags = self.tree.item(current_item, "tags")
621
+ if item_tags and item_tags[0].startswith("table_"):
622
+ return item_tags[0]
623
+ break
624
+ current_item = parent_item
625
+ return None
626
+
627
+ def _traverse_node_path(self, root_node, node_path):
628
+ """Traverse the AnyTree structure to find the node at the given path."""
629
+ try:
630
+ path_parts = node_path.split("/")
631
+ current_node = root_node
632
+
633
+ # Navigate through the path (skip the first part which is the root)
634
+ for part in path_parts[1:]:
635
+ found = False
636
+ for child in current_node.children:
637
+ if str(child.name) == part:
638
+ current_node = child
639
+ found = True
640
+ break
641
+ if not found:
642
+ return None
643
+
644
+ return current_node
645
+ except Exception as e:
646
+ self.logger.debug(f"Error finding node at path {node_path}: {e}")
647
+ return None
648
+
649
+ def _generate_node_details(self, node_type, node, title, usage):
650
+ """Generate HTML details for a module or attribute node."""
651
+ if node_type == "Module" and node:
652
+ return self._generate_module_details(node)
653
+ elif node_type == "Attribute" and node:
654
+ return self._generate_attribute_details(node)
655
+ else:
656
+ return self._generate_fallback_details(title, node_type, usage)
657
+
658
+ def _generate_module_details(self, node):
659
+ """Generate HTML details for a module node."""
660
+ name = getattr(node, 'module', 'Unknown Module')
661
+ usage = getattr(node, 'usage', '')
662
+ module_ref = getattr(node, 'ref', '')
663
+ ie = getattr(node, 'ie', '')
664
+
665
+ details = f"<h2>{name} Module</h2>"
666
+
667
+ if ie:
668
+ details += f"<span><b>Information Entity:</b> {ie}</span><br>"
669
+
670
+ if usage:
671
+ usage_display = self._format_usage_display(usage)
672
+ details += f"<span><b>Usage:</b> {usage_display}</span><br>"
673
+
674
+ if module_ref:
675
+ details += self._format_module_reference(module_ref)
676
+ return details
677
+
678
+ def _generate_attribute_details(self, node):
679
+ """Generate HTML details for an attribute node."""
680
+ elem_name = getattr(node, 'elem_name', 'Unknown')
681
+ elem_tag = getattr(node, 'elem_tag', '')
682
+ elem_type = getattr(node, 'elem_type', '')
683
+ elem_description = getattr(node, 'elem_description', '')
684
+
685
+ details = f"<h2>{elem_name} Attribute</h2>"
686
+
687
+ if elem_tag:
688
+ details += f"<span><b>Tag:</b> {elem_tag}</span><br>"
689
+
690
+ if elem_type:
691
+ type_display = self._format_type_display(elem_type)
692
+ details += f"<span><b>Type:</b> {type_display}</span><br>"
693
+
694
+ if elem_description:
695
+ details += f"{elem_description}"
696
+
697
+ return details
698
+
699
+ def _generate_fallback_details(self, title, node_type, usage):
700
+ """Generate fallback HTML details when node is not available."""
701
+ details = f"<h2>{title} {node_type}</h2>"
702
+ if usage:
703
+ details += f"<span><b>Usage/Type:</b> {usage}</span><br>"
704
+ return details
705
+
706
+ def _format_usage_display(self, usage):
707
+ """Format usage code into a readable display string."""
708
+ if usage.startswith("M"):
709
+ return "Mandatory (M)"
710
+ elif usage.startswith("U"):
711
+ return "User Optional (U)"
712
+ elif usage.startswith("C"):
713
+ if len(usage) > 1 and " - " in usage:
714
+ conditional_part = usage[usage.find(" - ") + 3:]
715
+ return f"Conditional (C) - {conditional_part}"
716
+ else:
717
+ return "Conditional (C)"
718
+ else:
719
+ return usage
720
+
721
+ def _format_module_reference(self, module_ref: str) -> str:
722
+ """Format module reference as an HTML anchor into a DICOM Part 3 URL."""
723
+ # Use the built-in 'xml' parser for both full documents and fragments,
724
+ # since DICOM standard files and cell values are well-formed XHTML.
725
+ soup = BeautifulSoup(module_ref, "xml")
726
+ anchor = soup.find("a", class_="xref")
727
+ if not anchor or not anchor.has_attr("href"):
728
+ return f"<span><b>Reference:</b> {module_ref}</span><br>"
729
+ href = anchor["href"]
730
+ module_url = f"{PART3_HTML_URL}{href}" if href.startswith("#") else href
731
+ return (
732
+ f'<span><b>Reference:</b> '
733
+ f'<a href="{module_url}" target="_blank">{anchor.get_text(strip=True)}</a>'
734
+ f'</span><br>'
735
+ )
736
+
737
+ def _format_type_display(self, elem_type):
738
+ """Format DICOM attribute type into a readable display string."""
739
+ type_map = {
740
+ "1": "Mandatory (1)",
741
+ "1C": "Conditional (1C)",
742
+ "2": "Mandatory, may be empty (2)",
743
+ "2C": "Conditional, may be empty (2C)",
744
+ "3": "Optional (3)",
745
+ "": "Unspecified"
746
+ }
747
+ return type_map.get(elem_type, f"Other ({elem_type})" if elem_type else "Unspecified")
748
+
749
+ def _update_details_html(self, details):
750
+ """Update the details pane with formatted HTML."""
751
+ self.details_text.set_html(
752
+ f'<div style="font-family: {self.details_font_family}; '
753
+ f'font-size: {self.details_font_size}px;">{details}</div>'
754
+ )
755
+
756
+
757
+ def _build_iod_model(self, table_id: str, logger: logging.Logger):
758
+ """Build the IOD model for the given table_id using the IODSpecBuilder API.
759
+
760
+ This method uses the IODSpecBuilder.build_from_url() method which handles:
761
+ - Cache detection and loading (fast for cached models)
762
+ - Web download and parsing (slower for non-cached models)
763
+ - Model building and JSON serialization
764
+
765
+ The method is called both:
766
+ 1. Directly for cached models (fast, no progress dialog needed)
767
+ 2. From background threads with progress dialogs for non-cached models
768
+
769
+ Args:
770
+ table_id (str): The table identifier (e.g., "table_A.49-1")
771
+ logger (logging.Logger): Logger instance for progress tracking and debugging
772
+
773
+ Returns:
774
+ IOD model object with content attribute containing the AnyTree structure,
775
+ or None if building failed.
776
+
777
+ """
778
+ url = PART3_HTML_URL
779
+ cache_file_name = "Part3.xhtml"
780
+ model_file_name = f"Part3_{table_id}_expanded.json"
781
+
782
+ # Determine if this is a composite or normalized IOD
783
+ composite_iod = "_A." in table_id
784
+
785
+ # Create the IOD specification factory
786
+ c_iod_columns_mapping = {0: "ie", 1: "module", 2: "ref", 3: "usage"}
787
+ c_iod_unformatted = {0: True, 1: True, 2: False, 3: True}
788
+ n_iod_columns_mapping = {0: "module", 1: "ref", 2: "usage"}
789
+ n_iod_unformatted = {0: True, 1: False, 2: True}
790
+ iod_columns_mapping = c_iod_columns_mapping if composite_iod else n_iod_columns_mapping
791
+ iod_unformatted = c_iod_unformatted if composite_iod else n_iod_unformatted
792
+ iod_factory = SpecFactory(
793
+ column_to_attr=iod_columns_mapping,
794
+ name_attr="module",
795
+ config=self.config,
796
+ logger=logger, # Use the custom logger for progress tracking
797
+ parser_kwargs={"unformatted": iod_unformatted}
798
+ )
799
+
800
+ # Create the Modules specification factory
801
+
802
+ # Ensure that the Attribute Description is parsed as formatted HTML by
803
+ # setting unformatted to False for elem_description (column 3), others remain True
804
+ parser_kwargs = {"unformatted": {0: True, 1: True, 2: True, 3: False}}
805
+ # Skip the elem_type column for normalized IODs (for Module tables where it does exist such as SOP Common)
806
+ if not composite_iod:
807
+ parser_kwargs["skip_columns"] = [2]
808
+
809
+ module_factory = SpecFactory(
810
+ column_to_attr={0: "elem_name", 1: "elem_tag", 2: "elem_type", 3: "elem_description"},
811
+ name_attr="elem_name",
812
+ parser_kwargs=parser_kwargs,
813
+ config=self.config,
814
+ logger=logger, # Use the custom logger for progress tracking
815
+ )
816
+
817
+ # Create the IOD builder
818
+ builder = IODSpecBuilder(
819
+ iod_factory=iod_factory,
820
+ module_factory=module_factory,
821
+ logger=logger, # Use the custom logger for progress tracking
822
+ )
823
+
824
+ # Build and return the IOD specification model
825
+ return builder.build_from_url(
826
+ url=url,
827
+ cache_file_name=cache_file_name,
828
+ json_file_name=model_file_name,
829
+ table_id=table_id,
830
+ force_download=False,
831
+ )
832
+
833
+ def _populate_treeview_item(self, parent_item, content):
834
+ """Populate the treeview item with IOD structure from the model content using AnyTree traversal."""
835
+ if not content:
836
+ return
837
+
838
+ # Use AnyTree's PreOrderIter to traverse the entire tree structure
839
+ # Skip the root content node itself, start with its children
840
+ tree_items = {} # Map from node to tree item for building hierarchy
841
+
842
+ for node in PreOrderIter(content):
843
+ if node == content:
844
+ # Skip the root content node
845
+ continue
846
+
847
+ # Determine the parent tree item
848
+ if node.parent == content:
849
+ # Direct child of content - parent is the IOD item
850
+ parent_tree_item = parent_item
851
+ else:
852
+ # Child of another node - find parent in our mapping
853
+ parent_tree_item = tree_items.get(node.parent, parent_item)
854
+
855
+ # Determine node type and display text
856
+ if hasattr(node, 'module'):
857
+ # This is a module node of an IOD
858
+ module_name = getattr(node, 'module', 'Unknown Module')
859
+
860
+ display_text = module_name
861
+ node_type = "Module"
862
+
863
+ # Check if this is a normalized IOD from the parent item's IOD type
864
+ parent_values = self.tree.item(parent_item, "values") if parent_item else None
865
+ is_normalized = parent_values and len(parent_values) > 0 and parent_values[0] == "Normalized"
866
+
867
+ # For normalized IODs, modules don't have usage information
868
+ # For composite IODs, keep only the first character of usage
869
+ usage = "" if is_normalized else getattr(node, 'usage', '')[:1]
870
+
871
+ elif hasattr(node, 'elem_name'):
872
+ # This is an attribute node
873
+ attr_name = getattr(node, 'elem_name', 'Unknown Attribute')
874
+ attr_tag = getattr(node, 'elem_tag', '')
875
+ elem_type = getattr(node, 'elem_type', '')
876
+
877
+ display_text = f"{attr_tag} {attr_name}" if attr_tag else attr_name
878
+
879
+ node_type = "Attribute"
880
+ usage = elem_type # Use elem_type for attributes in usage column
881
+
882
+ else:
883
+ # Unknown node type
884
+ display_text = str(getattr(node, 'name', 'Unknown Node'))
885
+ node_type = "Unknown"
886
+ usage = ""
887
+
888
+ # Insert the node into the tree, store node path in tags
889
+ # Node path provides a unique identifier that can be used to find the node later
890
+ node_path = "/".join([str(n.name) for n in node.path])
891
+
892
+ tree_item = self.tree.insert(
893
+ parent_tree_item, tk.END, text=display_text,
894
+ values=(node_type, usage, ""), tags=(node_path,) # Empty string for favorite column
895
+ )
896
+ tree_items[node] = tree_item
897
+
898
+ def _update_details_text(self, table_id: str, title: str, iod_type: str):
899
+ """Update the details text area with IOD specification information only."""
900
+ # Build details as HTML using <span> and <br> for spacing (tkhtmlview ignores margin styles)
901
+ details = (
902
+ f'<h1>{title} IOD</h1>'
903
+ )
904
+
905
+ # Check if we have a model for this IOD
906
+ if table_id in self.iod_models and self.iod_models[table_id] and hasattr(self.iod_models[table_id], 'content'):
907
+ # Add reference information using <span> and <br>
908
+ if iod_type == "Composite":
909
+ details += '<div style="margin-bottom: 1em;"><b>Kind: </b>Composite</div>'
910
+ elif iod_type == "Normalized":
911
+ details += '<div style="margin-bottom: 1em;"><b>Kind: </b>Normalized</div>'
912
+ else:
913
+ details += '<div style="margin-bottom: 1em;"><b>Kind: </b>Other IOD type</div>'
914
+ details += f'<span>loaded from DICOM PS3.3 Table {table_id.replace("table_", "")}</span><br>'
915
+
916
+ else:
917
+ details += '<span>IOD structure not available.</span><br>'
918
+ details += (
919
+ '<span>'
920
+ "This may occur if the IOD references modules that cannot be found or "
921
+ "parsed from the DICOM specification."
922
+ '</span><br>'
923
+ )
924
+
925
+ html = (
926
+ f'<div style="font-family: {self.details_font_family}; '
927
+ f'font-size: {self.details_font_size}px;">{details}</div>'
928
+ )
929
+ self.details_text.set_html(html)
930
+
931
+ def _build_readable_path(self, node):
932
+ """Build a human-readable path from the AnyTree using node names."""
933
+ path_parts = []
934
+
935
+ # Walk up the tree from the current node to the root
936
+ current = node
937
+ while current and current.parent: # Stop before the root content node
938
+ if hasattr(current, 'module'):
939
+ # This is a module node - use module name
940
+ node_name = getattr(current, 'module', 'Unknown Module')
941
+ elif hasattr(current, 'elem_name'):
942
+ # This is an attribute node - use elem_name
943
+ elem_name = getattr(current, 'elem_name', 'Unknown Attribute')
944
+ node_name = re.sub(r'^(?:&gt;|>)+', '', elem_name) # Remove leading > characters
945
+ else:
946
+ # Fallback to node name
947
+ node_name = str(getattr(current, 'name', 'Unknown'))
948
+
949
+ path_parts.insert(0, node_name) # Insert at beginning to build path from root
950
+ current = current.parent
951
+
952
+ # Join with " > " separator for a readable hierarchical path
953
+ return "/".join(path_parts)
954
+
955
+ def main() -> None:
956
+ """Entry point for the IOD Explorer GUI application.
957
+
958
+ Loads configuration and starts the GUI. Configuration can be customized
959
+ by placing a iod_explorer_config.json file in:
960
+ 1. Current directory
961
+ 2. ~/.config/dcmspec/
962
+ 3. App config directory (src/dcmspec/apps/iod_explorer/config/)
963
+ 4. Same directory as script (legacy support)
964
+
965
+ Example config file:
966
+ {
967
+ "cache_dir": "./cache",
968
+ "log_level": "INFO"
969
+ }
970
+
971
+ Supported log levels:
972
+ - DEBUG: Detailed information for debugging
973
+ - INFO: General information about application flow (default)
974
+ - WARNING: Warnings about potential issues
975
+ - ERROR: Error messages for serious problems
976
+ - CRITICAL: Critical errors that may stop the application
977
+
978
+ The application will display configuration information at startup, including:
979
+ - Log level and configuration source
980
+ - Config file location
981
+ - Cache directory path
982
+ """
983
+ root = tk.Tk()
984
+ IODExplorer(root)
985
+ root.mainloop()
986
+
987
+
988
+ if __name__ == "__main__":
989
+ main()