Back to AutoHotkey Tools

DEXA Reporting Automation

Parse DEXA reports and generate diagnosis/impression data automatically

How to Use

Hotkey-driven tool — no manual copy/paste required

  1. 1Download both files and place them in the same folder. Double-click the Runner (.exe) to launch — a small DEXA Parser GUI window will appear.
  2. 2Open your DEXA report in PowerScribe and make sure the cursor is active inside the report.
  3. 3Press Alt + D — the script automatically selects all text, reads the patient age/sex and bone density scores, and pastes back a completed impression.
  4. 4If age is not detected automatically, a prompt will ask you to enter it. Review the generated impression and make any clinical edits as needed.

Tip: Press Shift + Alt + D instead to run in debug mode, which shows the parsed text before processing — useful if the output looks wrong.

Download Files

Both files are required — place them in the same folder

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

This is the full source code of the dexa-v6-11.ahk file. You can copy this into a new .ahk file or download the file directly using the button above.