Applescript to Remove PDF Password

I have always wanted to be able to remove PDF passwords within Devonthink but did not know how to do it programmatically.

It took Claude a few tries as well. Ultimately this works using the Mac CoreGraphics framework.

I suspect this is not the most efficient solution but I do not see any red flags in the script and it does exactly what I was hoping to achieve without having to add dependencies to my Mac setup.

-- Remove Password from PDF in DEVONthink (No Dependencies)
-- Uses the currently selected record in DEVONthink

tell application id "DNtp"
	try
		set theRecords to selected records
		if (count of theRecords) is 0 then error "No record is selected."
		set theRecord to item 1 of theRecords
		if record type of theRecord is not PDF document then error "The selected document is not a PDF."
		if encrypted of theRecord is false then error "This PDF is not password-protected."
		set inputFile to path of theRecord
		if inputFile is missing value then error "Could not get the file path of the selected record."
		set theGroup to parent 1 of theRecord
		set theName to name of theRecord
	on error errMsg
		display alert "Selection Error" message errMsg as critical
		return
	end try
end tell
set thePassword to text returned of (display dialog "Enter the PDF password:" default answer "" with hidden answer)
set outputFile to "/tmp/" & theName & "_unlocked.pdf"
set tmpFile to do shell script "mktemp /tmp/pdf_unlock_XXXXXX.swift"
try
	do shell script "cat > " & quoted form of tmpFile & " << 'ENDOFSWIFT'
import Foundation
import CoreGraphics
let inputPath = CommandLine.arguments[1]
let password = CommandLine.arguments[2]
let outputPath = CommandLine.arguments[3]
let inURL = URL(fileURLWithPath: inputPath) as CFURL
guard let pdf = CGPDFDocument(inURL) else {
    fputs(\"Error: Could not open PDF.\\n\", stderr)
    exit(1)
}
if pdf.isEncrypted {
    guard pdf.unlockWithPassword(password) else {
        fputs(\"Error: Incorrect password.\\n\", stderr)
        exit(1)
    }
}
let outURL = URL(fileURLWithPath: outputPath) as CFURL
guard let ctx = CGContext(outURL, mediaBox: nil, nil) else {
    fputs(\"Error: Could not create output PDF.\\n\", stderr)
    exit(1)
}
for i in 1...pdf.numberOfPages {
    guard let page = pdf.page(at: i) else { continue }
    var mediaBox = page.getBoxRect(.mediaBox)
    ctx.beginPage(mediaBox: &mediaBox)
    ctx.drawPDFPage(page)
    ctx.endPage()
}
ctx.closePDF()
print(\"Done\")
ENDOFSWIFT"
	
	
	do shell script "/usr/bin/swift " & quoted form of tmpFile & " " & quoted form of inputFile & " " & quoted form of thePassword & " " & quoted form of outputFile
	do shell script "rm -f " & quoted form of tmpFile
	
	tell application id "DNtp"
		set newRecord to import path outputFile to theGroup
		set name of newRecord to theName & " (unlocked)"
	end tell
	do shell script "rm -f " & quoted form of outputFile
	display notification "Unlocked PDF imported into DEVONthink." with title "PDF Unlocked"
	
on error errMsg
	do shell script "rm -f " & quoted form of tmpFile
	display alert "Failed to remove password" message errMsg as critical
	
end try

1 Like

:thinking:

Could you have written this from scratch?

Yes - I would imagine it would take me at least 4 hours to write it from scratch, maybe more.

With AI it took 10 minutes.

With enough time I could certainly get up to full speed in Applescript so that I could write it much quicker. But for purposes of buildilng internal tools for my practice, I have used AI to build local Node applications, a browser extension, a complex report generator integrated to HIPAA compliance standards with Monday.com’s GraphQL API, a programmable custom rich text editor which can combine a Word file and Google Sheet into a PDF file, and a few others. I am working on an iOS app at the moment.

There is no way I can achive proficiency in all of those software technologies and also maintain a separate non-coding career. In fact even if I were a full-time developer I doubt I could maintain proficiency in all of them.

It’s certainly important to be able to read code but I think it is becoming increasingly less important to write code even for progressional developers. Equally helpful is to use AI which lets you plan the code before it is written - in the projects above I rejected a number of AI coding proposals that would not have been workable, but at the same time AI suggested a number of appraoches to a problem using libraries or technique I did not know were possible.

Perhaps ironically - my ability to write code is increasing as I use AI since I learn new techniques and can easily ask questions about how something works.

It’s quite poorly written AppleScript and barely an AppleScript as well. It’s a strange melange that appears haphazardly thrown together from a small pool of AS training and a larger pool of Swift. But that’s what AI does, especially when it doesn’t have enough training data to work with.

Does it “work” ? Perhaps. Is it well done? Not especially. It’s like going to a job site and finding a new hire hammering nails with the butt of a drill. Will it work? I suppose so, over time… and likely to damage something.

Also, any code that’s recommending the use of rm -f to people who have no clue how dangerous those five characters can be, is not something I would recommend. That’s a massive red flag IMHO.

2 Likes

A proper prompt usually avoids such issues. A poor prompt is like telling a craftsman to build a cupboard and complaining afterwards that he didn’t use the desired wood :wink:

2 Likes

Just to clarify: the main burden of the script is shouldered by Swift, not AppleScript. One could, I think, easily rewrite the whole thing in pure AS-ObjC. Also, with better error handling – mixing clean script error traps with fputs is a weird approach. As is, BTW, passing the immutable Script code as a here document to do shell script. Just save the stuff in a file and let that one execute by the shell. What this code does instead is save a here document to a temporary swift file, execute that one and throw it away afterwards.

As @bluefrog remarked correctly: rm -f is the road to hell. And in this case, the -f is not even necessary.

And that would probably have been a cleaner solution. But yes, clean is becoming less and less important nowadays.

3 Likes

Does it “work” ? Perhaps. Is it well done? Not especially. It’s like going to a job site and finding a new hire hammering nails with the butt of a drill. Will it work? I suppose so, over time… and likely to damage something.

Or maybe more like using a manual screwdriver instead of an electric one on a large jobsite? Certainly not efficient - but yes it “works” and nothing “wrong” with it.

I totally understand why this code would not be appropriate in commercial software or in a computer programming class. If you can suggest an alternative script that better meets professional standards I would be glad to learn. But for a personal script why spend hours writing it by hand when AI can do it in minutes and I can scan for red flags? (My understanding is even “professionals” at top-rate tech companies use AI for a significant % of coding currently.)

Also, any code that’s recommending the use of rm -f to people who have no clue how dangerous those five characters can be, is not something I would recommend. That’s a massive red flag IMHO.

Yes deleting files can be a risk in a script or elsewhere. But here it is clear only Temp files are being created and that is actually Apple’s recommended approach as I understand it. Indeed this is the key to the puzzle that Claude figured out. My understanding is there is no pure Applescript solution to unlocking a PDF. The unlock can be done in Swift but a Swift script only works if it is stored in the filesystem somewhere. So this Applescript creates that Swift script temporarily then removes what it created.

The ideal solution of course would be for this feature to be native to DT4. But for a user who obviously cannot modify DT4 itself, is there an Applescript or JXA solution that does not require such a temp file and which does not create a dependency by requiring a permanent Swift script stored somewhere?

Is there a way to write this script purely in applescript and not with the “hack” of a Swift bridge?

If so I am quite open to learning how this could have been done better.

To be fair to Claude, the initial script it proposed used a Python library. But I don’t like adding dependencies needlessly - it means I have to install those on each of my computers and it makes it harder to share the script with others.

Saving the Swift script in a file would have been a solution indeed- but I explicitly asked Claude to write this with no dependencies. So it followed my request. If that meant a rm -f instruction then it’s on me, not on AI.

There’s absolutely no need to bridge AS to Swift here. A simple AS-ObjC bridge would have worked equally well, in fact better, because it would be straightforward and not using a clumsy Shell-here document-Swift path, recreating the same code over and over again. Awkward is a very polite way to describe this approach.

Not to mention that the code checks twice if the PDF is encrypted (not trusting itself?), uses the first parent group instead of locationGroup, and a very wounded CoreGraphics approach where PDFKit would’ve been a lot easier.

Here’s a Human Intelligence implementation in JXA. No rm -f. No CoreGraphics, just PDFKit. Clean copying of pages from original to new PDF. Simple import (there is a name parameter to the importPath command). And it even contains some comments :wink:

The code is tested with exactly one PDF, where it worked ok. Given the state of PDFKit, no promises that it will work with other documents.

ObjC.import('PDFKit');

(() => {
  const app = Application("DEVONthink")
  app.includeStandardAdditions = true;

  const records = app.selectedRecords();
  if (!records.length) throw("No record is selected");
  const pdfRec = records[0];
  if (!pdfRec.recordType() === 'pdf document') throw("The selected document is not a PDF")
  if (!pdfRec.encrypted()) throw("This PDF is not password-protedted.");

  const group = pdfRec.locationGroup;
  const name = pdfRec.nameWithoutExtension();

  // Get password, decrypt PDF

  const dialogReturn = app.displayDialog("Enter the PDF password:", 
    {defaultAnswer: "", hiddenAnswer: true});
  const password = dialogReturn.textReturned;
  const newPDF = decryptPDF(pdfRec.path(), password);

  // Write decrypted PDF to new file
  const filename = `/tmp/${name}-unlocked.pdf`;
  newPDF.writeToFile($(filename));

  // Import new file into DT and delete it
  const newRecord = app.importPath(filename, {to: group, name: `${name} (unlocked)`});
  var error;
  $.NSFileManager.defaultManager
		.removeItemAtPathError(
			$(filename).stringByStandardizingPath,
			error
		)
  if (! newRecord) throw `Importing ${filename} failed`;
  if (error) throw `Could not delete ${filename}`;
})()

// Function to decrypt a PDF and copy its pages to a new object

function decryptPDF(path, password) {
  const inURL = $.NSURL.fileURLWithPath($(path));
  const pdfDocProtected = $.PDFDocument.alloc.initWithURL(inURL);
  if (!pdfDocProtected.unlockWithPassword($(password))) { 
    throw "Wrong password given";
  }
  const pdfDocNew = $.PDFDocument.alloc.init;
  for (i = 0; i < pdfDocProtected.pageCount; i++) {
    pdfDocNew.insertPageAtIndex(pdfDocProtected.pageAtIndex(i),i);
  }
  return pdfDocNew;
}

3 Likes

Maybe by using a third-party scriptable app.

Yes you could probably do it with UI scripting of Adobe Acrobat’s “Remove Security” Feature

Or UI scripting of any PDF Viewer by entering the password and then Printing to PDF

Not sure UI scripting is ever a reliable solution.

Thank you @chrillek

I got a few errors on some test files.

This modified version seems to work on all the test cases so far.

Much appreciated.

ObjC.import("PDFKit");
var app = Application("DEVONthink");
app.includeStandardAdditions = true;
var records = app.selectedRecords();
if (!records.length) throw("No record is selected");
var pdfRec = records[0];
var name = pdfRec.name().replace(/\.pdf$/i, "");
var pdfPath = pdfRec.path();
var parent = pdfRec.parents()[0];

var pw = app.displayDialog("Enter the PDF password:",
  {defaultAnswer: "", hiddenAnswer: true}).textReturned;

var inURL = $.NSURL.fileURLWithPath($(pdfPath));
var doc = $.PDFDocument.alloc.initWithURL(inURL);
if (!doc) throw("Could not open PDF");
if (!doc.unlockWithPassword($(pw))) throw("Wrong password");

// Copy pages into a new unencrypted document
var newDoc = $.PDFDocument.alloc.init;
for (var i = 0; i < doc.pageCount; i++) {
  newDoc.insertPageAtIndex(doc.pageAtIndex(i), i);
}

var outPath = "/tmp/" + name + "-unlocked.pdf";
var outURL = $.NSURL.fileURLWithPath($(outPath));
newDoc.writeToURL(outURL);

app.importPath(outPath, {to: parent, name: name + " (unlocked)"});

$.NSFileManager.defaultManager.removeItemAtPathError(
  $(outPath).stringByStandardizingPath, null
);

What’s the advantage of using name() and later removing the extension instead of using nameWithoutExtension? And why parents()[0] instead of locationGroup()

I was getting non-specific “Coregraphics Error” messges and had to add debugging code to see why. It was confusing because the ObjC bridge appeared to result in different and less specific error-handling than usual Applescript or JXA.

Those changes resulted in the correct values passed that I anticipated - such as the filename and group name of the selected document, which were initially not recognized properly seemed to be the cause of the errors.

It is possible that some of the changes were not necessary but once I got it apparently working I left it as is.

I guess you’re referring to Script Editor. That program has no idea of ObjC-AS or Obj-JXA. All it will ever tell you about are AppleEvents, and those are not happening in the bridges. Debugging problems in this context is nearly impossible. One can add print statements but they rarely tell us more than the kind of object we’re looking at.

1 Like

Yes that explains very well what I was experiencing.

And Claude doesn’t know that much about it either so I was on my own :slight_smile:

Thanks for the help.