I am working on managing PDF files on my computer.
I have created a macro to move downloaded PDF files to different folders (no magic here).
I have created macros to compress and encrypt PDF files based on user prompted inputs (no magic here other than I doing it in a VM which limited the available "tools"). In case anyone is interested macros are included below.
I would now like to add the ability of extracting the information on the first page of the PDF file (i.e., to grab the account number or other unique identifier) to manage the downloaded file based on its unique identifier to:
Compress the file.
Encrypt the file, if indicated.
Move the file to the correct folder.
Rename the file based on naming rules.
Output a list of files moved that day / week.
I would like your thoughts on the following:
I am thinking the best way to do this would be i) convert the PDF file to text using a CLI tool to avoid opening the file ii) using the Keyboard Maestro Read A File action to assign the text to a variable iii) use a switch statement based on the unique identifier in the variable to send the file for processing and iv) complete the process.
I would appreciate comments / thoughts on whether there is a better approach / process and, if yes, what is it (as I would like to build this once).
What is the preferred and recommended CLI tool for converting PDFs to Text without opening the PDF? I have Ghostscript on my computer but from what I have read pdftotext may be preferred.
And finally, is it worth the effort (other than the intellectual challenge which I know I will like) to develop such a macro given:
a) I don't know how I will deal with images / scanned PDFs unless pdftotext or other CLI tools can read them.
b) I could download Hazel and get this functionality (at least from what I have read).
Look forward to your feedback to help me decide what is next.
(It seems weird to use the shell when there's an "Execute a Shortcut" Action, but it's the easiest way I've found to pass in a file path as input to the Shortcut -- I'd love to be shown a better method!)
If that works -- you get the PDF text displayed -- then change the macro to save the shell script results to a variable and extract what you need from that.
Appreciated, I will give it a go in the coming days (need to get some real work done today).
I have searched past threads and see that others have tried to do the same thing but there is no follow up on their success or failure (including resulting macros), or whether they gave in and when to Hazel.
One follow up, is there an Apple Script of Shell Script command that bypasses the need to use Shortcuts (i.e., I recognize the ease and elegance of Shortcuts, I am just not a big fan). Was thinking something along the lines of (that you AI):
use framework "Foundation"
use framework "PDFKit"
use scripting additions
-- Get KM instance ID for local vars
set kmInst to system attribute "KMINSTANCE"
-- Get input file path from local_FilePath
tell application "Keyboard Maestro Engine"
set filePath to getvariable "local_FilePath" instance kmInst
end tell
if filePath is "" then
error "No PDF file path provided."
end if
set ca to current application
set pdfURL to ca's |NSURL|'s fileURLWithPath:filePath
set pdfDoc to ca's PDFDocument's alloc()'s initWithURL:pdfURL
if pdfDoc is missing value then
error "Could not open PDF at " & filePath
end if
set pageCount to pdfDoc's pageCount() as integer
set allText to ""
repeat with i from 0 to pageCount - 1
set aPage to pdfDoc's pageAtIndex:i
set pageText to aPage's string()
set allText to allText & (pageText as text) & return
end repeat
-- Output to local_FileText
tell application "Keyboard Maestro Engine"
setvariable "local_FileText" instance kmInst to allText
end tell
Make sure you change the set pageText... line within the repeat to
set pageText to aPage's |string|()
...or it will crash when you run it. (You have to tell the compiler to not treat string as an AppleScript keyword.)
In my testing, using Shortcuts is almost twice as fast as using the AppleScript, even if you limit the AS version to page 1 of a multipage PDF (something you can't do with the Shortcut). That is most probably down to the time it takes to instantiate an AS environment -- whether that'll matter enough to overcome your dislike of Shortcuts is something only you'll know
Of course, none of this helps if the PDF is an image and you need to OCR it. Hazel can OCR PDFs on the fly and use the results in its rules (it can't, AFAIK, save a generated text layer with the document) so you should be able automatically extract the information needed and use Hazel to trigger your KM macro, passing the information as a parameter.
I gave in once to get Keyboard Maestro to trigger DND Focus so I may do it again. I juts feel it is the start of a slippery slope!
Agreed, which is why I asked about CLI tools that can do OCR in my OP.
I confirm your comment that Hazel cannot save a generated text layer with the document. I researched Hazel closely and this limitation is clear.
One follow up, if I am using Hazel what would be benefit of involving Keyboard Maestro as opposed to letting Hazel do all the work, what am I missing? Very interested in this response as it will also apply to my window management for which I am using Moom w/o Keyboard Maestro's involvement.
You could try OCRmyPDF -- never used it myself, but it seems well-liked and is actively maintained.
Because it resaves the file as a PDF/A to embed the created text layer it will update the file's metadata (one of the reasons Hazel doesn't offer this function). That may or may not matter to your workflow.
Two utilities may have overlapping functionality, but either may do those some of those things better or be easier for you to use for particular tasks. And the best utilities -- like KM, Hazel and Moom -- make it easy for you to pass data in and out, call their functions from other utilities, and so on.
If you can more easily do what you want in Hazel -- great! You can then treat that Hazel workflow as a subroutine, integrating into your KM macros wherever you want.
Pick the best tool for each job, tie those tools together with KM, profit!
I am likely going to use two CLI tools; pdftotext (for readable PDFs) and OCRmyPDF (for non-readable PDFs).
I will start with simply using pdftotext as I checked the PDF files that I want to process and they appear to all be readable but I have a plan should I need one (and ultimately to bullet proof the macro).
Agreed and excellent point.
I do like the concept of replicating Hazel for my needs. It should be fun.
The framework is already built (see the below macro with currently includes the catch all case at the end of the Switch Case). While it needs some cleanup you can see the direction the macro will follow.
The only I thing need to do is build one subroutine for one "source PDF" (i.e., a PDF that I want to process) which will act as the "base subroutine" for the other "Source PDFs". The only thing I should need to change will be the destination folder and perhaps the naming convention which will be done in the main macro and passed to the "processing subroutine" to make things as efficient as possible.
Will try to get pdftotext working within the next day or two (real work getting in the way) and will then share an updated macro for the first "source PDF".
Agreed and excellent point.
If I were to do that though then I would simply purchase Hazel as that would be easier and faster than building this out in Keyboard Maestro. That, however, would deny of the fun and learning of building this.
As promised below is the template macro that is working for two specific source statements and has a catch all at the end. The catch all i) moved the downloaded PDF files to the desktop and ii) checks for duplicate names and, if indicated, as a suffix (of the users choice).
I have also included a ReadMe macro which explains the colour coding.
I am have a date variable Local_ReformattedDate which is the form YYYYMMDD. I need to reduce the DD by one (i.e., 20251206 to 20251205) but everything I try fails (including suggestions from ChatGPT and Perplexity). How do I do this?
You could do this easily with string manipulation, but that would break when DD was 01. So instead, just treat it like a date. Once you do that, you could manipulate it any number of ways; here's a one-liner shell script that works:
date -j -v-1d -f "%Y%m%d" "20251206" "+%Y%m%d"
The -j means "don't try to set the date," -v-1d takes one day away, the string after -f is the input format, and the final string is the output format. The formats are as specified in the ICU date/time format codes.
Example:
$ date -j -v-1d -f "%Y%m%d" "20251206" "+%Y%m%d"
20251205
I'm with @griffman -- date calcs are a minefield, even for something as seemingly simple as "the day before a date", so use something already built to do it.
Rob's given you a shell script version -- problem is, you've got to get your original date into the right format. AppleScript can also do date manipulation and -- bonus! -- you can leverage Data Detectors in the same way that Mail, Notes, etc do.
Here's a script you can play around with in Script Editor -- change the string in line 5 to test your various date formats:
use framework "Foundation"
use framework "AppKit"
use scripting additions
set theString to "November 22, 2025"
set anNSString to current application's NSString's stringWithString:theString
set theNSDataDetector to current application's NSDataDetector's dataDetectorWithTypes:(current application's NSTextCheckingTypeDate) |error|:(missing value)
set resultsArray to theNSDataDetector's matchesInString:theString options:0 range:{0, anNSString's |length|()}
set theDate to (resultsArray's valueForKey:"date") as date
return theDate - 1 * days
So you can replace your entire "Reformat Date" subroutine with a single Action in your main macro. Demo (you only need take the AppleScript Action):
AS has limited formatting options when converting dates objects to text, hence the kludgey code at the end. And date output depends on your OS settings -- as written it works for dates localised as day/moth/year, but if you are a month/day/year user change word 2 in the outText line to word 1.
I was therefore looking for a way of doing this that would not break when DD = 1 (or when DD = 30 / 31 and I need to increase it by 1).
Agreed.
I was trying to use either the TIME() or the ICUDate() function but I could not figure it out. I tried everything but i) neither ChatGPT, Perplexity nor I could not get it to work and ii) I might have gotten messed / mixed up between Calculations and Text fields.
Is there a way to manipulate it using either the TIME() or the ICUDate() functions?
Greatly appreciated, it worked great. I simply modified it to change the input to a Keyboard Maestro variable.
Agreed. I do not want to tell you how much time I wasted this morning trying to figure this out. I got no where (see my above response to Rob).
Agreed. Luckily the numbers are already in the right format, perhaps not by the best way.
Appreciated. I will need to study the Apple Script as I am not that familiar with it and the use of Data Detectors but it is on the list of things to do / learn.
I am in Canada so I am DD/MM/YYYY but for file naming purposes I use YYYYMMDD so that files appear in chronological order.
And, just to be clear, then path you are suggesting is:
Replace the Reformat Date subroutine with your Apple Script; and
When a +/- one day adjustment is needed use the Shell Script command.
Do I have this correct?
And finally, is there a way to adjust the date using either the TIME() or ICUDate() that I missed?
No, they aren't -- you've original text in the format "January 26, 2026" which you convert in your subroutine. This works with the text you extract, spitting out the YYYYMMDD for the day before that, replacing your subroutine completely.
Again, try the demo -- but change the text in the first Action to
I bought a fish on January 1, 2026 and ate it for lunch
...and see what it spits out. That's the beauty of Data Detectors -- you can be a bit more lax with your initial date extraction regex.
And I'd never used Data Detectors until about an hour ago... Every day's a learning day on the KM Forum.
You need both -- pump your date into the TIME() Function to get unixtime in seconds (remembering to also set "12 noon" to avoid time zone issues), subtract a day's worth of seconds, put the result into the %ICUDateTimeFor% Token:
When I wrote "likely the numbers are already in the right format" I meant in teh YYYYMMDD format for Shell Script command as a result of the ReformatDate subroutine I called.
I did run the macro and noticed that:
The macro takes almost any date format and outputs in the YYYYMMDD format I am looking for.
It subtracts 1 from the "input date"
I say almost any date because dates in using / (i.e., DD/MM/YYY do not work), dates using YYYYMMDD do not work, etc.
I wrote what I did because:
In most cases I do not need to subtract 1 from teh statement date.
To minimize the actions / code I thought it would be best to i) use your Apple Script to put the date in the YYYYMMDD format and ii) use something simple like teh Shell Script to reduce by one when needed. I suppose I could also branch to the two different Apple Scripts (i.e., with / without reducing the date by one).
I am in complete agreement with the last statement, for sure!!
Appreciated.
One follow up, I would like to use the variable %Variable%Local_ReformattedDate% within the above expression. I tried a number of different approach -- including the one below -- and none of them worked. How would I do this?
I know that Local_Year, Local_Month and Local_Day are being properly extracted because of the Display Text box but I cannot get the calculation work (I also tried %Variable%Local_Year%, etc. but still no go.)
You've a year digit missing in the first -- slash-dates work fine for me with 4-digit years, two-digit dates also work but there is an assumption that, for today (26 Jan), 50 and higher are in the 1900s while 49 and lower are the 2000s (I'm guessing this cutoff increments every year. Moral -- don't use two-digit years in your dates
YYYYMMDD works for me too, so I'm not sure what's happening for you. Basically, if the Data Detector can determine a (sensible) date in the string it will. But the script will error if there is more than one date in the string, 1 Jan 2025 today for example, but that's because I'm not smart enough to handle an ObjC array
Just use one -- pass the number of days to subtract as a parameter, use that number as the days multiplier. For a KM variable Local_daysToSubtract:
use framework "Foundation"
use framework "AppKit"
use scripting additions
set inst to system attribute "KMINSTANCE"
tell application "Keyboard Maestro Engine"
set dateString to getvariable "Local_PDFDate" instance inst
set daysToSubtract to (getvariable "Local_daysToSubtract" instance inst) as number
end tell
set anNSString to current application's NSString's stringWithString:dateString
set theNSDataDetector to current application's NSDataDetector's dataDetectorWithTypes:(current application's NSTextCheckingTypeDate) |error|:(missing value)
set resultsArray to theNSDataDetector's matchesInString:dateString options:0 range:{0, anNSString's |length|()}
set theDate to (resultsArray's valueForKey:"date") as date
set previousDate to theDate - daysToSubtract * days
set outText to "" & year of previousDate & (text -2 thru -1 of ("0" & word 2 of short date string of previousDate)) & (text -2 thru -1 of ("0" & day of previousDate))
return outText
The only trick is that KM variables are strings so we have to coerce it to a number (done as part of getting the value, in line 7).
Works fine here -- at least, my version does. But I've had to create it from scratch so it may not be the same as yours.
You really do need to post a minimal macro that shows the problem, otherwise we're just guessing.
While I do like the "Substrings" Action, one "Search Using Regular Expression" Action can replace all three:
Got it! Have been digging into the Apple Script code. Very clever and powerful!
Ahhh, I doubt that!
Me like, great idea!! Would mean changing the order of things a bit but that is doable!
Changed to regular expression, fewer actions, thank you.
Got it t work, the problem was I was using Set Variable to Calculation when I should have been using Set Variable to Text. While I should have know this I find dealing with TIME() and Text Variables when using them for calculations very confusing (and something I need to get a far better understanding of).
I thought I would share some information with you.
I have been looking into OCRmyPDF and note the following:
OCRmyPDF contains the ability to i) OCR a PDF file (obviously) and ii) produce a text file either following OCR or should the PDF file already be text readable or a combination thereof.
OCRmyPDP removes the need for pdftptext which I am now using to read PDF files.
Will be replacing pdfttext with OCRmyPDF in my workflowas it will make the macro more robust as it will be able to handle any PDF file (other than password protected files).
Thought you may be interested and, again, thank you for all the help.
I have added OCRmyPDF to the PDF to text conversion / extraction process in my macro so that I can process image files as well.
I thought that I would remove pdftotext as OCRmyPDF can provide text output for both image and readable PDFs (i.e., why have two tools?).
In testing the PDF to text conversion I noticed that for readable PDF files that pdftotext is much faster and I have therefore implemented a hybrid approach; specifically:
If the PDF file is a text file then use pdftotext.
If the PDF file is an image file then use OCRmyPDF.
Would appreciate comments / thoughts as to whether the hybrid approach (using both pdftotext and OCRmyPDf) or the dedicated tool approach (OCRmyPDF only) is the correct or preferred approach (as I am new to all of this).
Thank you.
And, for those interested the hybrid approach Shell Script that Gemini and I came up with is here:
# 1. Ensure Homebrew tools are in the PATH
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:$PATH"
INPUT_PATH="$KMVAR_Local_SourcePDFPath"
# 2. Extract text and strip whitespace/newlines to check if it's TRULY empty
RAW_TEXT=$(/opt/homebrew/bin/pdftotext -raw "$INPUT_PATH" - 2>/dev/null)
STRIPPED_TEXT=$(echo "$RAW_TEXT" | tr -d '[:space:]')
# 3. Check if we actually found alphanumeric content
if [ -n "$STRIPPED_TEXT" ]; then
# If text is found, output the original raw text
echo "$RAW_TEXT"
else
# 4. If empty, run OCR.
# We output the OCR text to stdout (-) and send the dummy PDF to /dev/null
/opt/homebrew/bin/ocrmypdf \
--force-ocr \
--sidecar - \
--output-type none \
"$INPUT_PATH" \
/dev/null 2>/dev/null
fi
PS. The macro is fast and works very well, thanks to everyone for the all the help! I will be adding "file management capabilities" as the final feature (at least for now) where i) the user can elect to open, ignore or add to a list the processed file and ii) the user -- at any time -- will be able to open the list to review and / or check off the list of processed files (i.e., the thinking being that everything can happen in the background and at a later and more convenient time the processed files can be reviewed with no "tracking concerns". Happy to share should anyone be interested.
Because pdftotext extracts any text that's in the PDF while (from a brief read of its web page) OCRmyPDFalways renders then OCRs the PDF, ignoring any "original" text.
That's why pdftotext is faster. And why you might get different results -- OCRmyPDF might include text from logos and other embedded images that pdftotext will ignore, and is more likely get the flow of text wrong on more complicated documents as it will use the "visual" rather than pdftotext's "logical" layout (even a simple two-column layout with a narrow gutter could confuse it).
It'll be document dependent, so runs some tests and see how you get on.