DEXA Reporting Automation
Parse DEXA reports and generate diagnosis/impression data automatically
AutoHotkey Script
The main automation logic. Open to inspect or edit the script directly.
dexa-v6-06.ahk
Download ScriptAHK Script Runner
Executable runner to launch the script if AutoHotkey is not installed (requires the .ahk file in the same folder).
dexa-v6-06.exe
Download RunnerHow to Use
- Download both the Script (.ahk) and the Runner (.exe) using the buttons above.
- Place both files together into the same folder on your computer.
- Double-click the Runner (.exe) to start the automation tool.
- Follow the on-screen prompts or GUI provided by the DEXA ScriptViewer script to parse your DEXA reports.
Full DEXA Automation Script
View the complete source code or copy it to your clipboard
1#Requires AutoHotkey v2.0
2#SingleInstance force
3
4; Version variable
5global VERSION := "6.06"
6
7global PatientAge := ""
8global PatientSex := ""
9global TScores := Map()
10global ZScores := Map()
11global OriginalReport := ""
12global FinalReport := ""
13global ImpressionInfo := "" ; for diagnostic summary display
14global StatusCtrl := "" ; GUI Status Control
15global DebugMode := false
16global SeenRegions := Map() ; To track duplicates
17
18; GLOBAL SETTINGS FOR COLUMN MAPPING (Default: T is 1st score, Z is 2nd)
19global ColMap_T := 1
20global ColMap_Z := 2
21
22; Create GUI
23CreateGUI()
24
25CreateGUI() {
26 global StatusCtrl
27 ; Define helper text
28 helperText := "DEXA Parser v" . VERSION . " - Ready`n`n"
29 . "USAGE: Press Alt + d to process report`n"
30 . "(Hold Shift+Alt+d for Debug Mode)`n`n"
31 . "CRITERIA:`n"
32 . "• Female < 60: Z-score + T-score`n"
33 . "• Female ≥ 60: T-score only`n"
34 . "• Male < 50: Z-score only`n"
35 . "• Male ≥ 50: T-score only`n`n"
36 . "Works with Spine/Hip/Forearm DEXA scans`n"
37 . "Close window to exit."
38
39 ; Create GUI window
40 myGui := Gui("+Resize +MinSize300x280", "DEXA Parser v" . VERSION)
41 myGui.MarginX := 10
42 myGui.MarginY := 10
43
44 ; Set font
45 myGui.SetFont("s9", "Segoe UI")
46
47 ; Add title
48 titleCtrl := myGui.Add("Text", "w320 h25 Center", "DEXA Parser Script")
49 titleCtrl.SetFont("s12 Bold")
50
51 ; Add spacer
52 myGui.Add("Text", "h8")
53
54 ; Add instructions text
55 myGui.Add("Text", "w320 h180", helperText)
56
57 ; Add Status Bar
58 myGui.Add("Text", "w320 h1 0x10") ; Separator line
59 StatusCtrl := myGui.Add("Text", "w320 h20 vStatusText", "Status: Idle")
60 StatusCtrl.SetFont("s9 Italic")
61
62 ; Close handler
63 myGui.OnEvent("Close", (*) => ExitApp())
64
65 ; Show GUI
66 myGui.Show("w340 h320")
67
68 return myGui
69}
70
71UpdateStatus(text) {
72 global StatusCtrl
73 if IsObject(StatusCtrl)
74 StatusCtrl.Text := "Status: " . text
75}
76
77; Main Hotkey (Alt + d)
78!d:: {
79 RunParser(false)
80}
81
82; Debug Hotkey (Shift + Alt + d)
83+!d:: {
84 RunParser(true)
85}
86
87RunParser(debug) {
88 global DebugMode
89 DebugMode := debug
90
91 UpdateStatus("Selecting text...")
92
93 ; Clear Clipboard to prevent "Stale Data" race condition
94 A_Clipboard := ""
95
96 ; Select All with increased delay for slow UIs
97 Send("^a")
98 Sleep(300)
99
100 Send("^c")
101
102 ; Wait up to 2 seconds for NEW text to appear
103 if !ClipWait(2) {
104 UpdateStatus("Error: Clipboard timeout")
105 MsgBox("Failed to copy text. The clipboard did not update in time.", "Error", 0x10)
106 return
107 }
108
109 UpdateStatus("Parsing data...")
110 ParseDEXAReport()
111
112 ; Select all again and paste the final report to overwrite
113 if (FinalReport != "") {
114 UpdateStatus("Pasting report...")
115 Send("^a")
116 Sleep(300) ; Increased delay to ensure selection is registered
117 Send("^v")
118 Sleep(500)
119 UpdateStatus("Idle")
120 } else {
121 UpdateStatus("Aborted")
122 }
123}
124
125
126NormalizeWhitespace(text) {
127 ; Explicitly replace Non-Breaking Spaces (Chr 160) which RegEx \s might miss in some AHK versions
128 text := StrReplace(text, Chr(160), " ")
129
130 text := RegExReplace(text, "\p{Zs}+", " ")
131 text := StrReplace(text, "`t", " ")
132 text := StrReplace(text, "`r", "")
133 text := RegExReplace(text, "[ -]", " ")
134 text := RegExReplace(text, " +", " ")
135 return text
136}
137
138ParseDEXAReport() {
139 global PatientAge, PatientSex, TScores, ZScores, OriginalReport, FinalReport, ImpressionInfo, DebugMode, SeenRegions
140
141 if (A_Clipboard = "") {
142 MsgBox("Clipboard is empty or contains no text.", "DEXA Parser", 0x40)
143 return
144 }
145
146 OriginalReport := A_Clipboard
147 FinalReport := ""
148
149 ClipboardText := NormalizeWhitespace(A_Clipboard)
150 ClipboardText := RegExReplace(ClipboardText, ":[\s\t]+", ": ")
151 ClipboardText := NormalizeWhitespace(ClipboardText)
152
153 if (DebugMode) {
154 MsgBox("Debug View - Normalized Text Start:`n`n" . SubStr(ClipboardText, 1, 500), "Debug", 0x40)
155 }
156
157 PatientAge := ""
158 PatientSex := ""
159 TScores.Clear()
160 ZScores.Clear()
161 SeenRegions.Clear()
162
163 if RegExMatch(ClipboardText, "Age:\s*(\d+)", &AgeMatch) {
164 PatientAge := AgeMatch[1]
165 }
166
167 if RegExMatch(ClipboardText, "Sex:\s*(\w+)", &SexMatch) {
168 PatientSex := SexMatch[1]
169 }
170
171 ParseBoneDensityTable(ClipboardText)
172
173 ; SANITY CHECK: Minimum Data Threshold
174 if (TScores.Count == 0) {
175 MsgBox("No bone density data found in selection.`n`nTroubleshooting:`n1. Ensure the 'Results' table is selected.`n2. Check if the table header contains 'Region'.`n3. Try Shift+Alt+D for Debug Mode.", "Parse Error", 0x10)
176 return
177 }
178
179 ; Warn if very few regions found (possible bad copy)
180 if (TScores.Count < 2) {
181 result := MsgBox("Warning: Only " . TScores.Count . " valid body region found.`n(Usually expect Spine, Hips, etc.)`n`nProceed?", "Sanity Check", 0x34)
182 if (result == "No")
183 return
184 }
185
186 Impression := GenerateImpression()
187
188 if (Impression = "")
189 return
190
191 FinalReport := InsertImpression(OriginalReport, Impression)
192
193 ; Copy to clipboard
194 A_Clipboard := FinalReport
195}
196
197ParseBoneDensityTable(Text) {
198 global TScores, ZScores, DebugMode, SeenRegions, ColMap_T, ColMap_Z
199 Lines := StrSplit(Text, "`n")
200 InResultsSection := false
201 HeaderFound := false
202
203 ; Reset column mapping to default (T first, Z second)
204 ColMap_T := 1
205 ColMap_Z := 2
206
207 for Line in Lines {
208 Line := Trim(Line, "`r `t")
209 if (Line = "")
210 continue
211
212 Line := RegExReplace(Line, "\s+", " ")
213
214 ; ROBUSTNESS 1: Flexible Header Detection
215 if (!InResultsSection && (RegExMatch(Line, "i)Region.*(BMD|T-?score|Z-?score)")
216 || RegExMatch(Line, "i)Bone mineral density.*scores")
217 || RegExMatch(Line, "i)Bone Density Results:"))) {
218
219 InResultsSection := true
220 HeaderFound := true
221
222 ; Dynamic Mapping: Check position of "T-score" vs "Z-score" if present
223 PosT := InStr(Line, "T-score") ? InStr(Line, "T-score") : InStr(Line, "Tscore")
224 PosZ := InStr(Line, "Z-score") ? InStr(Line, "Z-score") : InStr(Line, "Zscore")
225
226 if (PosT > 0 && PosZ > 0 && PosZ < PosT) {
227 ColMap_T := 2
228 ColMap_Z := 1
229 }
230 continue
231 }
232
233 ; STOP parsing if we hit the footer, "Previous Exams", "Comparison", or FRAX
234 if (InResultsSection && (
235 InStr(Line, "World Health Organization")
236 || InStr(Line, "Comparison with Previous Exams")
237 || InStr(Line, "COMPARISON:")
238 || InStr(Line, "Change:")
239 || InStr(Line, "FRAX")
240 )) {
241 InResultsSection := false
242 return
243 }
244
245 if (InResultsSection && !InStr(Line, "Classification")) {
246 if (!ParseBoneDensityLine(Line)) {
247 InResultsSection := false
248 return
249 }
250 }
251 }
252
253 if (DebugMode && !HeaderFound) {
254 MsgBox("Debug Warning: Table Header NOT found.`nLooking for line containing 'Region' and 'BMD'/'Score'.", "Debug", 0x30)
255 }
256}
257
258ParseBoneDensityLine(Line) {
259 global TScores, ZScores, SeenRegions, ColMap_T, ColMap_Z
260 Line := Trim(Line)
261 Line := RegExReplace(Line, "\s+", " ")
262
263 ; Expanded Date Exclusion
264 ; Blocks: 02/02/2026, 2/2/26, 2026-02-02, 02-Feb-2026
265 if (RegExMatch(Line, "\d{1,2}/\d{1,2}/\d{2,4}")
266 || RegExMatch(Line, "\d{4}-\d{2}-\d{2}")
267 || RegExMatch(Line, "\d{2}-[A-Za-z]{3}-\d{4}"))
268 return true
269
270 ; --- ATTEMPT 1: Standard 3-Number Format (Region BMD T Z) ---
271 ; FIX V6: STRICT DECIMAL RULE
272 ; Requires `[0-9]*\.[0-9]+` (a number with a dot).
273 ; This ignores integers like "33", "2026", etc.
274 ; FIX V6.06: HYPHEN SUPPORT for Z-scores
275 if RegExMatch(Line, "^(.+?)\s+([0-9]*\.[0-9]+)[^\d\.-]+([0-9.-]+|[-])\s+([0-9.-]+|[-])", &Match) {
276 Region := Trim(Match[1])
277 Score1 := Match[3]
278 Score2 := Match[4]
279
280 ; Handle hyphens as missing values
281 if (Score1 == "-") Score1 := ""
282 if (Score2 == "-") Score2 := ""
283
284 TScore := (ColMap_T == 1) ? Score1 : Score2
285 ZScore := (ColMap_Z == 1) ? Score1 : Score2
286
287 ProcessDataLine(Region, TScore, ZScore)
288 return true
289 }
290
291 ; --- ATTEMPT 2: "Greedy Score Search" (Catches lines like "L1, L2 -2.6, -2.7") ---
292 ; Matches a line starting with a word, followed by ANY sequence of decimal numbers
293 if RegExMatch(Line, "^([A-Za-z0-9 /,]+)\s+([+-]?\d+\.\d+)", &Match) {
294 Region := Trim(Match[1])
295
296 ; Find ALL numbers in the line that look like scores
297 LowestFoundT := 1000
298 Pos := 1
299 FoundAny := false
300
301 Loop {
302 ; Search for next float
303 Pos := RegExMatch(Line, "([+-]?\d+\.\d+)", &NumMatch, Pos)
304 if (!Pos)
305 break
306
307 Val := NumMatch[1]
308 if (IsNumeric(Val) && Abs(Val) < 10) { ; Basic sanity check
309 if (Val < LowestFoundT)
310 LowestFoundT := Val
311 FoundAny := true
312 }
313 Pos += StrLen(Val)
314 }
315
316 if (FoundAny) {
317 ; In this fallback format, we assume Z-scores are not reliable or present
318 ; So we only register the lowest found number as the T-Score.
319 ProcessDataLine(Region, LowestFoundT, "")
320 return true
321 }
322 }
323
324 return true ; Continue parsing other lines
325}
326
327ProcessDataLine(Region, TScore, ZScore) {
328 global TScores, ZScores, SeenRegions
329
330 ; EXCLUSION LIST: Forearm artifacts and Ward's Triangle
331 if (InStr(Region, "Total Forearm")
332 || InStr(Region, "UD Forearm")
333 || InStr(Region, "Ultra")
334 || InStr(Region, "Ward"))
335 return
336
337 ; Physiological Check
338 if (TScore != "" && Abs(TScore) > 10)
339 return
340 if (ZScore != "" && Abs(ZScore) > 10)
341 return
342
343 ; Duplicate Region Check
344 if (SeenRegions.Has(Region)) {
345 return false ; STOP parsing
346 }
347
348 if (TScore != "")
349 TScores[Region] := TScore
350 if (ZScore != "")
351 ZScores[Region] := ZScore
352
353 SeenRegions[Region] := true
354}
355
356IsNumeric(str) {
357 try {
358 if (str == "" || str == "-") return false
359 Float(str)
360 return true
361 } catch {
362 return false
363 }
364}
365
366GenerateImpression() {
367 global TScores, ZScores, PatientAge, PatientSex, ImpressionInfo
368
369 if !IsObject(TScores)
370 TScores := Map()
371 if !IsObject(ZScores)
372 ZScores := Map()
373
374 ; Safety check for Age
375 if (!PatientAge || PatientAge = 0 || PatientAge = "") {
376 ib := InputBox("Patient Age could not be detected automatically.`nPlease enter Age:", "DEXA Parser", "w250 h130 +AlwaysOnTop")
377 if (ib.Result = "Cancel" || ib.Value = "") {
378 MsgBox("Processing Cancelled: No Age Provided.")
379 return ""
380 }
381 PatientAge := ib.Value
382 }
383
384 ; SANITY CHECK: Age Plausibility
385 if (PatientAge < 18 || PatientAge > 110) {
386 ib := InputBox("Warning: Patient Age is " . PatientAge . ".`nIs this correct?`n`nEdit below if needed, or Cancel to abort.", "Age Check", "w250 h140 +AlwaysOnTop", PatientAge)
387 if (ib.Result = "Cancel")
388 return ""
389 PatientAge := ib.Value
390 }
391
392 if !IsSet(PatientSex) || PatientSex = ""
393 PatientSex := ""
394
395 lowestT := 1000
396 lowestTRegion := ""
397
398 lowestZ := 1000
399 lowestZRegion := ""
400
401 for region, val in TScores {
402 score := Round(val, 1)
403 if (score < lowestT) {
404 lowestT := score
405 lowestTRegion := region
406 }
407 }
408
409 ; Handle missing Z-scores safely
410 hasZ := (ZScores.Count > 0)
411 if (hasZ) {
412 for region, val in ZScores {
413 score := Round(val, 1)
414 if (score < lowestZ) {
415 lowestZ := score
416 lowestZRegion := region
417 }
418 }
419 }
420
421 age := PatientAge + 0
422 sex := Trim(PatientSex)
423
424 impression := "TECHNICAL LIMITATIONS: None`n"
425 . "------------------------------------------------------------------------------`n"
426 . "IMPRESSION:`n`n"
427
428 ; ALERT: Missing Z-Scores for Young Patients
429 if (age < 50 && !hasZ) {
430 MsgBox("ALERT: Patient is " . age . " years old but NO Z-scores were found in the report.`n`nThe impression will use T-scores, but clinical correlation is advised.", "Missing Data", 0x30 + 0x40000)
431 }
432
433 ; Women under 60
434 if (sex ~= "i)^F" && age < 60) {
435 if (hasZ) {
436 zDiag := (lowestZ <= -2.0) ? "Below the expected range for age in premenopausal women" : "Within the expected range for age in premenopausal women"
437 zDiag .= " (lowest Z-score " . lowestZ . " at " . lowestZRegion . ")."
438 impression .= zDiag . "`n"
439 }
440
441 if (lowestT <= -2.5) {
442 tDiag := "Osteoporosis in postmenopausal women"
443 siteText := " (" . lowestTRegion . ")"
444 } else if (lowestT < -1.0) {
445 tDiag := "Osteopenia in postmenopausal women"
446 siteText := " (" . lowestTRegion . ")"
447 } else {
448 tDiag := "Normal bone density in postmenopausal women"
449 siteText := ""
450 }
451
452 impression .= tDiag . " based on the lowest T-score" . siteText . " using the World Health Organization criteria.`n`n"
453
454 impression .= "In premenopausal women, Z-scores are preferred and the WHO classification is not applicable. "
455 . "In these patients, a Z-score of -2.0 or lower is defined as “below the expected range for age” and above -2.0 is “within the expected range for age.” "
456 . "The diagnosis of osteoporosis cannot be made on the basis of bone mineral density alone.`n"
457 }
458
459 ; Men under 50
460 else if (sex ~= "i)^M" && age < 50) {
461 if (hasZ) {
462 zDiag := (lowestZ <= -2.0) ? "Below the expected range for age" : "Within the expected range for age"
463 zDiag .= " (lowest Z-score " . lowestZ . " at " . lowestZRegion . ")."
464 impression .= zDiag . "`n`n"
465 } else {
466 impression .= "Z-SCORES NOT AVAILABLE for this patient. T-scores reported below.`n`n"
467 ; Fallback to T-score logic for clarity
468 if (lowestT <= -2.5) {
469 classification := "Osteoporosis"
470 siteText := " (" . lowestTRegion . ")"
471 } else if (lowestT < -1.0) {
472 classification := "Osteopenia"
473 siteText := " (" . lowestTRegion . ")"
474 } else {
475 classification := "Normal bone density"
476 siteText := ""
477 }
478 impression .= classification . " based on the lowest T-score" . siteText . ".`n`n"
479 }
480
481 impression .= "In male patients below age 50, Z-scores are preferred and the WHO classification is not applicable. "
482 . "In these patients, a Z-score of -2.0 or lower is defined as “below the expected range for age” and above -2.0 is “within the expected range for age.” "
483 . "The diagnosis of osteoporosis cannot be made on the basis of bone mineral density alone.`n"
484 }
485
486 ; Women 60+ or Men 50+
487 else {
488 if (lowestT <= -2.5) {
489 classification := "Osteoporosis"
490 siteText := " (" . lowestTRegion . ")"
491 } else if (lowestT < -1.0) {
492 classification := "Osteopenia"
493 siteText := " (" . lowestTRegion . ")"
494 } else {
495 classification := "Normal bone density"
496 siteText := ""
497 }
498
499 impression .= classification . " based on the lowest T-score" . siteText . " using the World Health Organization criteria.`n"
500 }
501
502 impression .= "------------------------------------------------------------------------------"
503 return impression
504}
505
506InsertImpression(text, impression) {
507 searchPatterns := [
508 "Osteoporosis (T-score at or below -2.5).",
509 "FRAX(R) Estimated 10-year Fracture Risk:",
510 "Comparison With Previous Exams:",
511 "Electronically Signed",
512 "Signed by"
513 ]
514
515 insertIndex := 0
516 matchLen := 0
517 foundPattern := ""
518
519 for pattern in searchPatterns {
520 if (pos := InStr(text, pattern)) {
521 insertIndex := pos
522 matchLen := StrLen(pattern)
523 foundPattern := pattern
524 break
525 }
526 }
527
528 if (insertIndex > 0) {
529 if (foundPattern = "Osteoporosis (T-score at or below -2.5).") {
530 newlinePos := InStr(text, "`n", , insertIndex + matchLen)
531 splitPos := (newlinePos > 0) ? newlinePos + 1 : insertIndex + matchLen + 1
532 return SubStr(text, 1, splitPos - 1) . "`n`n" . impression . "`n`n" . SubStr(text, splitPos)
533 } else {
534 return SubStr(text, 1, insertIndex - 1) . "`n" . impression . "`n`n" . SubStr(text, insertIndex)
535 }
536 } else {
537 MsgBox("Could not find standard insertion point. Appending to bottom.", "DEXA Parser", 0x30 + 0x40000)
538 return text . "`n`n" . impression
539 }
540}This is the full source code of the dexa-v6-06.ahk file. You can copy this into a new .ahk file or download the file directly using the button above.