Script to reformat PDFs for Kindle using k2pdfopt

If you haven’t already encountered it, k2pdfopt is a very handy tool for reformatting PDFs to fit on various e-ink devices. I wanted a way to easily reformat the documents in my DEVONthink database, so I wrote a toolbar script.

To use, create a folder ~/.bin and download k2pdfopt to there. Put the following script in the Toolbar subfolder of the scripts folder and add it to the DEVONthink toolbar. Select a file, click the icon, and it will run k2pdfopt for you. You can also use it in smart rules. I have a Kindle Paperwhite, but if you have a different device you may want to adjust the formatting options. They’re in the do shell script call below.

If you have any suggestions for improvement I’m all ears!

-- Convert PDFs for Kindle using k2pdfopt
-- Adapted from the Deadlink script

on run
	tell application id "DNtp"
		my convertPdf(selected records)
	end tell
end run

on performSmartRule(theFiles)
	my convertPdf(theFiles)
end performSmartRule

on convertPdf(theFiles)
	tell application id "DNtp"
		repeat with thisRecord in theFiles
			if (type of thisRecord is PDF document) then
				set recName to (name of thisRecord as string)
				show progress indicator ("Converting " & recName) steps -1
				log message recName info "Converting for Kindle"
				set {path:recPath, location group:recGroup} to thisRecord
				
				try
					set |encrypted| to (do shell script "grep -a -m 1 '/Encrypt' " & (quoted form of recPath))
					if |encrypted| ≠ "" then
						log message "Encrypted files cannot be converted." info recName
					end if
				on error --  non-zero status errors --> non-encrypted
					with timeout of 3000 seconds
						try
							set output to do shell script "~/.bin/k2pdfopt -dev kp2 -c -fc- -s- -n " & (quoted form of recPath)
							-- log message (name of thisRecord as string) info output
						on error errMsg
							log message recName info ("Error: " & errMsg)
							return
						end try
					end timeout
					
					set newPath to texts 1 thru ((length of recPath) - 4) of recPath & "_k2opt.pdf"
					-- DT3 won't import the file if it's in the database tree, so we have to move it to /tmp
					do shell script "mv " & (quoted form of newPath) & " " & "/tmp"
					set newPath to "/tmp/" & (do shell script "basename " & (quoted form of newPath))
					
					log message recName info "Importing new file at " & newPath
					set newRecord to import newPath name (recName & " - Kindle") to recGroup
					
					hide progress indicator
					log message recName info "Converted successfully"
				end try
			end if
		end repeat
	end tell
end convertPdf
1 Like
  • You should add an error trap in case no documents are selected.
  • Why are you using two handlers?
You should add an error trap in case no documents are selected.

In this case there won’t be any items to iterate over so the script just exits silently. I’m open to suggestions on how else to provide feedback to the user, though.

Why are you using two handlers?

I copied this pattern from the “Deadlink” script which was able to both be run from the toolbar and as a smart rule; I haven’t actually looked into it beyond the naming of the handlers but I assume the “on run” is for the toolbar invocation whereas the “performSmartRule” is for smart rules. Is that correct?

I haven’t actually looked into it beyond the naming of the handlers but I assume the “on run” is for the toolbar invocation whereas the “performSmartRule” is for smart rules. Is that correct?

Yes, but there doesn’t need to be a third handler. And if you’re talking about my old Deadlink script, that would be old code I haven’t looked at in years.

Here’s my suggested improvements though I can’t claim they’re more understandable, just more efficient…

-- Convert PDFs for Kindle using k2pdfopt
-- Adapted from the Deadlink script
-- Cleaned up and optimized by BLUEFROG

on run
	tell application id "DNtp"
		if selected records is {} then return
		my performSmartRule(selected records)
	end tell
end run

on performSmartRule(theFiles)
	tell application id "DNtp"
		repeat with thisRecord in theFiles
			if (type of thisRecord is PDF document) then
				set {path:recPath, location group:recGroup, name without extension:recName} to thisRecord
				show progress indicator ("Converting " & recName) steps -1
				log message recName info "Converting for Kindle"
				set recName to (quoted form of recName & "-Kindle.pdf") as string
				if (encrypted of thisRecord) then
					log message "Encrypted files cannot be converted." info recName
					return
				end if
				with timeout of 3000 seconds
					try
						set output to do shell script "/usr/local/bin/k2pdfopt -dev kp2 -c -fc- -s- -n " & (quoted form of recPath) & " -o $TMPDIR" & recName & " > /dev/null; echo $TMPDIR" & recName
					on error errMsg
						log message recName info ("Error: " & errMsg)
						return
					end try
				end timeout
				
				log message recName info "Importing new file"
				set newRecord to import output to current group
				open tab for record newRecord
				hide progress indicator
				log message recName info "Converted successfully"
			end if
		end repeat
	end tell
end performSmartRule

Bear in mind, this was quickly done so I’m sure it could be further massaged. :slight_smile:

Thanks for that – that answers my question if a handler can return – looks a lot cleaner than having the main action happen in a on error branch.

@ndpi: why do you use a hidden folder (~/.bin) to store the script instead of a directory in $PATH like /usr/local/bin? And why not use the k2pdfopt option -o to specify an output filename instead of creating the new file inside DT’s library and then move it out before you import it? Seems a bit contrived to me.

I think you’re responding to the OP, not me :wink:

Both of you – you modified the try ... on error logic to use a return instead, didn’t you?

Yes because there wasn’t a need as I saw it.

Thanks for the suggestions. I’ve incorporated them and refactored the script significantly to separate concerns, improve readability, and give more granular feedback/error handling:

-- Convert PDFs for Kindle using k2pdfopt

property k2pdfoptPath : "/usr/local/bin/k2pdfopt"
property k2pdfoptOptions : "-dev kp2 -c -fc- -s- -n"
property conversionTimeout : 3000

on run
	tell application id "DNtp"
		if selected records is {} then return
		my performSmartRule(selected records)
	end tell
end run

on checkRecordCanBeProcessed(theRecord)
	tell application id "DNtp"
		set {name without extension:recName} to theRecord
		
		if (not type of theRecord is PDF document) then
			log message recName info "Only PDFs can be converted."
			return false
		end if
		
		if (encrypted of theRecord) then
			log message recName info "Encrypted files cannot be converted."
			return false
		end if
		
		return true
	end tell
end checkRecordCanBeProcessed

on convertPdf(theRecord)
	tell application id "DNtp"
		set {path:recPath, name without extension:recName} to theRecord
		log message recName info "Converting for Kindle..."
		
		with timeout of conversionTimeout seconds
			set tmpDir to do shell script k2pdfoptPath & " " & k2pdfoptOptions & " " & (quoted form of recPath) & " -o $TMPDIR > /dev/null; echo $TMPDIR"
		end timeout
		
		set newRecFile to (tmpDir & recName & ".pdf")
		log message newRecFile info "Importing new file..."
		set newRecord to import newRecFile to current group
		open tab for record newRecord
		
		log message recName info "Converted successfully."
	end tell
end convertPdf

on performSmartRule(theFiles)
	tell application id "DNtp"
		set totalFiles to count of theFiles
		log message ("Converting " & totalFiles & " files to Kindle-friendly format")
		set currentFile to 0
		
		repeat with thisRecord in theFiles
			if not my checkRecordCanBeProcessed(thisRecord) then
				log message recName info "Skipping file because it cannot be converted."
			else
				set {name without extension:recName} to thisRecord
				set currentFile to currentFile + 1
				show progress indicator ("Converting " & recName & " (" & currentFile & " of " & totalFiles & ")") steps totalFiles
				
				try
					my convertPdf(thisRecord)
				on error errMsg
					log message recName info ("Error: " & errMsg)
					display dialog "Error converting " & recName & ": " & errMsg buttons {"Stop", "Continue"} default button "Continue"
					if button returned of result is "Stop" then
						log message recName info "Exiting, user cancelled processing."
						hide progress indicator
						error errMsg number -128 -- User cancelled
					end if
				end try
			end if
			
			step progress indicator
			
		end repeat
		hide progress indicator
	end tell
end performSmartRule

It’s an old habit of mine. I keep personal tools and scripts which aren’t managed by my system package manager (in this case, HomeBrew) in ~/.bin and add it to my $PATH in my shell RC file. I’ve learned the hard way that it’s better not to rely on $PATH in scripts; it often causes problems. (I’ve seen a few topics in this forum where people were bitten by this.)

That said, there’s no major concern with putting k2pdfopt there if you prefer, so it’s really just a matter of taste! De gustibus, etc.

Unless I’m mistaken (I’m no AppleScript expert), it looks as if you were processing the record regardless of the result of checkRecordCanBeProcessed. If that’s the case, why worry about checking in the first place?

Also, in do shell script k2pdfoptPath & k2pdfoptOptions it seems (!) that you are concatenating "/usr/local/bin/k2pdfopt" and "-dev kp2 -c -fc- -s- -n", which looks to me(!) as if you’d have "/usr/local/bin/k2pdfopt-dev kp2 -c -fc- -s- -n" afterward. I may be wrong, of course, but given that you took care to insert space after the options…

In JavaScript, I’d do something like (just for illustrative purposes, not tested):

const app = Application("DEVONthink 3");
const curApp = Application.currentApplication();
curApp.includeStandardAdditions = true;
const k2pdfoptions = "-dev kp2 -c -fc- -s- -n ";
const k2pdfpath = "/usr/local/bin/k2pdfopt";
const targetDir = "/tmp/";

function convertPDF(record) {
  const name = record.nameWithoutExtension();
  const newName = `${name} - Kindle.pdf`;
  const newPath = `${targetDir}${newName}`;
  curApp.doShellScript(`${k2pdfpath} ${k2pdfoptions} -o'${newPath}' '${record.path()}' > /dev/null` );
  const newRec = app.import(newPath, {to: app.currentGroup});
  app.openTabFor({record: newRec});
}

function performsmartrule(records) {
  records.filter(r => r.type() === 'PDF document'  
    && !r.encrypted()).forEach(r => {
    convertPDF(r);
  })
}

The main difference being that performsmartrule passes only unencrypted PDFs to convertPDF by using filter first. Which uses a local variable to store the output filename instead of echoing it back to stdout in doShellScript – that approach seems a bit too complicated. What’s missing is any error check. If for some reason the new file can’t be created, the code will not notice at all. And I omitted all the log stuff as well as the progress indicator. Those can be added, of course.

1 Like

Good catches! Clearly I was being lax with my testing. (In my defense, DEVONthink’s caching of AppleScripts makes iterating on them somewhat annoying.)

Fixed in the post above.

In JavaScript, I’d do something like […]

AppleScript isn’t exactly my cup of tea either. Did you know that it doesn’t even have a continue statement? Wild.

That’s why I don’t touch it with a ten foot pole. No string processing worthy of that name, no array methods, basically nothing that other languages offer nowadays. Not to mention that it’s lacking a formal specification and a grammar. And far too verbose.

<advertisement>

</advertisement>

It seems you’ve further complicated things by introducing new handlers but if it makes sense to you, go for it.