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,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'^(?:>|>)+', '', 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()
|