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 Script
AHK 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 Runner
How to Use
  1. Download both the Script (.ahk) and the Runner (.exe) using the buttons above.
  2. Place both files together into the same folder on your computer.
  3. Double-click the Runner (.exe) to start the automation tool.
  4. 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.