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
- 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.
- 2Open your DEXA report in PowerScribe and make sure the cursor is active inside the report.
- 3Press Alt + D — the script automatically selects all text, reads the patient age/sex and bone density scores, and pastes back a completed impression.
- 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
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.