Store, version LaTeX and generate PDF from within DEVONthink

Edit: Updated scripts - Support for bibtex added; Compile only selected TeX file; Run compilation for all available TeXf files in group if group or some other file (not TeX file) is selected

I prefer using LaTeX for (longer) documents and business letters. For years, I stored my TeX files in git repositories in the filesystem and use Makefile’s to generate the PDF files. After moving to MacOS and DEVONthink I moved all generated PDFs to DT. I left the “source” TeX in the filesyste. With versions available within DT4, I thought to move all files to DT.

Benefits of using this script

With this script everything is within DT - TeX source(s) and the generated PDFs - BUT: it requires MacTex to be installed!

The script

Although it has the structure of a performSmartRulescript , I use it as a script in “Contextual Menu” within DT, after suggestions of @cgrunenberg somewhere else.

Primary focus for now is generating letters, the script might need some adjustments to run complex documents with includes.

scripts.zip (43.2 KB)

use AppleScript version "2.4" -- Yosemite (10.10) or later
use scripting additions
use framework "Foundation"
use script "RegexAndStuffLib" version "1.0.7"

property globalSuccessTags : {"auto-processed-pdflatex-generate"}
property globalAppendixName : "Anhänge"
property globalAppendixPrefix : "inkl"
property globalOutputName : "Fertige Dokumente"
property globalTmpName : "Temp"

tell application id "DNtp" to my performSmartRule(selected records)

-----------

-- test performSmartRule
property globalIsDev : true

-- copy template for smart rules
on performSmartRule(theRecords)
	local libraryHandler
	set libraryHandler to script "library-handling"
	
	tell application id "DNtp"
		set currentDb to current database
	end tell
	
	libraryHandler's canRun(currentDb, theRecords)
	
	local scriptName
	set scriptName to "pdflatex"
	
	local theScriptHandler
	set theScriptHandler to libraryHandler's loadScript(scriptName, globalIsDev)
	
	theScriptHandler's dtLog(missing value, "INFO", "Run script", {"script=" & quoted form of scriptName})
	
	theScriptHandler's ForAll(theRecords, globalIsDev)
	
	theScriptHandler's dtLog(missing value, "INFO", "Script finished", {"script=" & quoted form of scriptName})
end performSmartRule

-- Run job for all given records
on ForAll(theRecords, localIsDev)
	-- aggregate tex files over all groups into array
	local theTexFileRecords
	set theTexFileRecords to {}
	
	repeat with theRecord in theRecords
		local theGroup
		tell application id "DNtp"
			set theGroup to my getTheGroup(theRecord)
		end tell
		
		local foundTexFileRecords
		tell application id "DNtp"
			set foundTexFileRecords to my getTexFiles(theRecord, theGroup)
		end tell
		
		repeat with foundTexFileRecord in foundTexFileRecords
			set theTexFileRecords to theTexFileRecords & {{r:foundTexFileRecord, g:theGroup}}
		end repeat
	end repeat
	
	local titleProgressBar
	set titleProgressBar to "Generate PDF documents for groups"
	
	tell application id "DNtp"
		show progress indicator titleProgressBar steps (count of theTexFileRecords) with cancel button
	end tell
	-- https://developer.apple.com/library/archive/documentation/AppleScript/Conceptual/AppleScriptLangGuide/reference/ASLR_error_xmpls.html
	
	set len to length of theTexFileRecords
	set i to 1
	
	my dtLog(missing value, "INFO", quoted form of "Job started", {"count_of_records=" & len})
	
	local firstGroup
	tell application id "DNtp"
		set firstGroup to my getTheGroup(item 1 of theRecords)
	end tell
	
	local outputGroup
	tell application id "DNtp"
		set outputGroup to create location globalOutputName in firstGroup
		delete record outputGroup in firstGroup
		set outputGroup to create location globalOutputName in firstGroup
	end tell
	
	try
		local theTexFileRecord
		repeat with theTexFileRecord in theTexFileRecords
			-- re-set variable to match normal setup
			set theRecord to r of theTexFileRecord
			set theGroup to g of theTexFileRecord
			
			tell application id "DNtp"
				if cancelled progress then
					my dtLog(theRecord, "INFO", quoted form of "Cancel current run of script", {})
					
					exit repeat
				end if
			end tell
			
			tell application id "DNtp"
				set theFilename to the filename of theRecord
				set theGroupname to the name of theGroup
				step progress indicator ((i as rich text) & "/" & len as rich text) & ": " & my joinPath({theGroupname, theFilename})
			end tell
			
			my dtLog(theRecord, "INFO", quoted form of "Work on group", {"index=" & i & "/" & len})
			my ForOne(theRecord, theGroup, outputGroup, localIsDev)
			
			set i to i + 1
		end repeat
	on error errMsg number errNum partial result partialError
		
		tell application id "DNtp"
			hide progress indicator
		end tell
		
		if errNum = 1000 then
			my dtLog(missing value, "WARN", quoted form of "Job execution aborted by user", {"error=" & errNum, "count_of_records=" & len})
			return
		end if
		
		set AppleScript's text item delimiters to {return}
		-- An unknown error occurred. Resignal, so the caller
		-- can handle it, or AppleScript can display the number
		
		display alert errMsg & ("Error number: ") & errNum & return & (partialError as text)
		error errMsg & ("Error number: ") & errNum & return & (partialError as text)
	end try
	
	tell application id "DNtp"
		hide progress indicator
	end tell
	
	my dtLog(missing value, "INFO", quoted form of "Job completed", {"count_of_records=" & len})
end ForAll

-- Run job for single record
on ForOne(theRecord, theGroup, outputGroup, localIsDev)
	-- generat PDF from LaTeX file
	local pdfPath
	try
		set pdfPath to generatePDF(theRecord, theGroup, outputGroup)
	on error errMsg number errNum partial result partialError
		if errNum = 2000 then
			my dtLog(theRecord, "WARN", quoted form of "Job failed for record. Moving to next one.", {"error=" & errNum, "error_msg=" & errMsg})
			return missing value
		end if
		
		set AppleScript's text item delimiters to {return}
		-- An unknown error occurred. Resignal, so the caller
		-- can handle it, or AppleScript can display the number
		error errMsg & ("Error number: ") & errNum & return & (partialError as text)
	end try
	
	local generatedPdfRecord
	set generatedPdfRecord to addPdfToDt(theRecord, outputGroup, pdfPath)
	
	addAppendicesToPdf(theRecord, outputGroup, generatedPdfRecord, localIsDev)
end ForOne

on getTheGroup(theRecord)
	local theGroup
	set theGroup to theRecord
	
	tell application id "DNtp"
		if record type of theRecord is not group then
			-- Get the parent group of the original record
			
			local theGroup
			set theGroup to location group of theRecord
			
			my dtLog(theGroup, "WARN", quoted form of "Record is not a group - using parent location instead", {"record-type=" & record type of theRecord, "new-location=" & (location with name of theRecord)})
		end if
	end tell
	
	return theGroup
end getTheGroup

on getTexFiles(theRecord, theGroup)
	local foundTexFileRecords
	
	tell application id "DNtp"
		if kind of theRecord is "TeX" then
			set foundTexFileRecords to {theRecord}
		else
			set foundTexFileRecords to (search "extension==tex name:!<_ name:!<- name:!<." in theGroup)
		end if
	end tell
	
	return foundTexFileRecords
end getTexFiles

on addPdfToDt(theRecord, outputGroup, pdfPath)
	local newRecord
	tell application id "DNtp"
		set newRecord to import path pdfPath to outputGroup
	end tell
	
	local newRecordLocation
	tell application id "DNtp"
		set newRecordLocation to location with name of newRecord
	end tell
	
	my dtLog(newRecord, "DEBUG", quoted form of "Import PDF file into database", {"path=" & quoted form of pdfPath, "location-new-record=" & newRecordLocation})
	
	return newRecord
end addPdfToDt

on addAppendicesToPdf(theRecord, outputGroup, generatedPdfRecord, localIsDev)
	local recordGroup
	tell application id "DNtp"
		set recordGroup to (location group of theRecord)
	end tell
	
	-- set location to add merged pdf to
	local appendixGroup
	tell application id "DNtp"
		set appendixGroup to create location globalAppendixName in recordGroup
	end tell
	
	local tmpGroup
	tell application id "DNtp"
		set tmpGroup to create location globalTmpName in recordGroup
		delete record tmpGroup in recordGroup
		set tmpGroup to create location globalTmpName in recordGroup
	end tell
	
	-- search for appendices
	local appendicesPdfRecords
	tell application id "DNtp"
		set appendicesPdfRecords to (search "type:pdf" in appendixGroup)
	end tell
	
	-- nothing to merge
	if length of appendicesPdfRecords is 0 then
		return missing value
	end if
	
	local modifiedRecords
	set modifiedRecords to {}
	tell application id "DNtp"
		repeat with appendicesPdfRecord in appendicesPdfRecords
			local headlineMarkdownRecord
			set headlineMarkdownRecord to create record with {name:name without extension of appendicesPdfRecord & "_h1", content:name without extension of appendicesPdfRecord & return & return & comment of appendicesPdfRecord as rich text, record type:markdown} in tmpGroup
			
			local headlinePdfRecord
			set headlinePdfRecord to convert record headlineMarkdownRecord to PDF document in tmpGroup
			
			local duplicateRecord
			set duplicateRecord to duplicate record appendicesPdfRecord to tmpGroup
			set name of duplicateRecord to (name of duplicateRecord) & "_content"
			
			set mergedRecord to merge records {headlinePdfRecord, duplicateRecord} in tmpGroup
			
			copy mergedRecord to end of modifiedRecords
		end repeat
	end tell
	
	-- add/merge appendices to/with PDF file
	-- merge is missing value on single pdf input
	local allFilesRecord
	tell application id "DNtp"
		set allFilesRecord to merge records {generatedPdfRecord} & modifiedRecords in outputGroup
	end tell
	
	tell application id "DNtp"
		set name of allFilesRecord to my joinWithDash({name without extension of theRecord, globalAppendixPrefix, globalAppendixName})
	end tell
	
	local groupLocation
	tell application id "DNtp"
		set groupLocation to location with name of outputGroup
	end tell
	
	local allFilesLocation
	tell application id "DNtp"
		set allFilesLocation to location with name of allFilesRecord
	end tell
	
	local cntAppendices
	set cntAppendices to length of appendicesPdfRecords
	
	my dtLog(generatedPdfRecord, "INFO", quoted form of "Created merged PDF", {"merged-pdf-location=" & allFilesLocation, "group-location=" & groupLocation}, "count-merged-appendices=" & cntAppendices)
	
	if localIsDev is false then
		tell application id "DNtp"
			delete record tmpGroup in recordGroup
		end tell
	end if
end addAppendicesToPdf

on getRelativeLocation(theRecord, theGroup)
	local theParent
	tell application id "DNtp"
		set theParent to location group of theRecord
	end tell
	
	if theParent is missing value then
		my dtLog(theGroup, "DEBUG", quoted form of "Reached database root", missing value)
		return
	end if
	
	local parentLocation
	tell application id "DNtp"
		set parentLocation to location with name of theParent
	end tell
	
	local groupLocation
	tell application id "DNtp"
		set groupLocation to location with name of theGroup
	end tell
	
	if parentLocation is groupLocation then
		my dtLog(theGroup, "DEBUG", quoted form of "Compare locations for the group and the base group", {"base-group-location=" & parentLocation, "group-location=" & groupLocation & "result=same"})
		
		return {}
	end if
	
	local locationName
	tell application id "DNtp"
		set locationName to name of theParent
	end tell
	
	local newLocations
	set newLocations to my getRelativeLocation(theParent, theGroup)
	
	return newLocations & {locationName}
end getRelativeLocation

on generatePDF(theTexFileRecord, theGroup, outputGroup)
	local texFileName
	tell application id "DNtp"
		set texFileName to proposed filename of theTexFileRecord as rich text
	end tell
	
	local texFileNameNoExt
	tell application id "DNtp"
		set texFileNameNoExt to name without extension of theTexFileRecord as rich text
	end tell
	
	local relativeLocationList
	set relativeLocationList to my getRelativeLocation(theTexFileRecord, theGroup)
	
	local relativeLocation
	set relativeLocation to my joinPath(relativeLocationList)
	
	my dtLog(theGroup, "DEBUG", quoted form of "Gather relative location for TeX file", {"tex-file=" & texFileName, "relative-location=" & relativeLocation})
	
	-- Create unique export destination using record's UUID
	local mkTempDirCmd
	set mkTempDirCmd to "mktemp -d"
	
	local tempDir
	set tempDir to do shell script mkTempDirCmd
	
	local exportedPath
	tell application id "DNtp"
		-- Export the group
		my dtLog(theGroup, "DEBUG", quoted form of "Export record to temporary folder", {"directory=" & quoted form of tempDir})
		set exportedPath to export record theGroup to tempDir
	end tell
	
	local groupLocation
	tell application id "DNtp"
		set groupLocation to location with name of theGroup
	end tell
	
	-- If export was successful, run pdflatex in that directory
	if exportedPath is missing value then
		tell application id "DNtp"
			my dtLog(theGroup, "WARN", quoted form of "Export failed for group", {"group=" & quoted form of groupLocation, "export-path=" & exportedPath})
		end tell
		
		tell application id "DNtp"
			display alert "Export failed for group for " & groupLocation & " failed. See logs for full log trace"
		end tell
		
		error "Group export failed" number 2000
	end if
	
	local workingDirectory
	set workingDirectory to my joinPath({exportedPath, relativeLocation})
	
	local pdfCmd
	set pdfCmd to generateCmdString(workingDirectory, texFileName, texFileNameNoExt)
	my dtLog(theGroup, "DEBUG", quoted form of "Run pdf generator command", {"cmd=" & quoted form of pdfCmd})
	
	try
		do shell script pdfCmd
	on error errMsg number errNum partial result partialError
		my dtLog(theTexFileRecord, "ERROR", quoted form of "Generating PDF failed", {"filename=" & texFileName, "export-directory=" & quoted form of exportedPath, "error_msg=" & quoted form of errMsg & quoted form of (partialError as text), "error_number=" & errNum})
		
		local logFileName
		tell application id "DNtp"
			set logFileName to (name without extension of theTexFileRecord as rich text) & ".log"
		end tell
		
		local logPath
		tell application id "DNtp"
			set logPath to my joinPath({exportedPath, relativeLocation, logFileName})
		end tell
		
		tell application id "DNtp"
			display alert "Generating PDF for " & texFileName & " failed. See DEVONthink and pdflatex logs for full log trace."
		end tell
		
		local logExists
		set logExists to (do shell script "test -f " & quoted form of logPath & " && echo 'yes' || echo 'no'") is "yes"
		
		my dtLog(theGroup, "DEBUG", quoted form of "Test for existence of log file for PDF generation", {"path=" & quoted form of logPath, "result=" & logExists as text})
		
		if logExists then
			my dtLog(outputGroup, "WARN", quoted form of "Adding pdflatex logs to DEVONthink", {"path=" & quoted form of logPath})
			
			local logRecord
			tell application id "DNtp"
				set logRecord to import path logPath to outputGroup
				set name of logRecord to (name of logRecord) & "-pdflatex-error"
			end tell
			
			-- set selection for scripts
			tell application id "DNtp"
				local theWindow
				set theWindow to think window 1
				set selection of theWindow to {logRecord}
			end tell
		end if
		
		error "PDF generation failed" number 2000
	end try
	
	local pdfFileName
	tell application id "DNtp"
		set pdfFileName to (name without extension of theTexFileRecord as rich text) & ".pdf"
	end tell
	
	local texFileLocation
	tell application id "DNtp"
		set texFileLocation to location group of theTexFileRecord
	end tell
	
	local pdfPath
	tell application id "DNtp"
		set pdfPath to my joinPath({exportedPath, relativeLocation, pdfFileName})
	end tell
	
	local pdfExists
	set pdfExists to (do shell script "test -f " & quoted form of pdfPath & " && echo 'yes' || echo 'no'") is "yes"
	
	my dtLog(theGroup, "DEBUG", quoted form of "Test for existence of PDF file", {"path=" & quoted form of pdfPath, "result=" & pdfExists as text})
	
	if not pdfExists then
		error "PDF generation failed" number 2000
	end if
	
	return pdfPath
end generatePDF

on generateCmdString(workingDirectory, theFilename, theFilenameNoExt)
	local cmd
	set cmd to "cd " & quoted form of workingDirectory & " && /Library/TeX/texbin/pdflatex " & quoted form of theFilename & " && (/Library/TeX/texbin/bibtex " & quoted form of theFilenameNoExt & " || exit 0) && /Library/TeX/texbin/pdflatex " & quoted form of theFilename & " && /Library/TeX/texbin/pdflatex " & quoted form of theFilename
	return cmd
end generateCmdString

on dtLog(theRecord, level, msg, msgInfo)
	if class of msgInfo is not list and msgInfo is not missing value then
		error "Invalid definition of msgInfo in dtLog: Needs to be {}"
	end if
	
	if msgInfo is missing value then
		set logMsg to "msg=" & msg
	else
		set logMsg to "msg=" & msg & " " & (join strings msgInfo using delimiter return)
	end if
	
	if level is not "DEBUG" then
		tell application id "DNtp"
			if theRecord is missing value then
				log message "level=INFO " & logMsg
			else
				log message record theRecord info "level=INFO " & logMsg
			end if
		end tell
		
		return
	end if
	
	if globalIsDev is true then
		tell application id "DNtp"
			if theRecord is missing value then
				log message "level=INFO " & logMsg
			else
				log message record theRecord info "level=INFO " & logMsg
			end if
		end tell
	end if
end dtLog

on joinPath(elements)
	return join strings elements using delimiter "/"
end joinPath

on joinWithDash(elements)
	return join strings elements using delimiter "-"
end joinWithDash

Setup

  1. Install TeX Live
    brew install --cask mactex-no-gui bibdesk

  2. Create scripts - pdflatex.scpt and library-handling.scpt (or replace/remove code from library-handling.scpt) in ~/Library/Application Scripts/com.devon-technologies.think/Contextual Menu

  3. Create template/example group in DT

    template.zip (27.1 KB)

  4. Select test.tex and run pdflatex script

  5. Change global variables matching your needs

    property globalSuccessTags : {“auto-processed-pdflatex-generate”}

    property globalAppendixName : “Anhänge”

    property globalAppendixPrefix : “inkl”

    property globalOutputName : “Fertige Dokumente”

    property globalTmpName : “Temp”

How it works

  • It search for TeX files in group(s) if group is selected or a non-TeX file or compiles selected TeX file only
  • It exports groups to filesystem
  • It generates PDF with pdflatex; bibtex; pdflatex; pdflatex
  • It ignores TeX files prefixed with an underscore - assumption: included files
  • It adds PDF file to DT
  • It adds additional files to the document if stored at a predefined place - globalAppendixName
  • Use template.zip (27.1 KB) as an example to see how it works
  • Errors etc. are logged to DT
  • Log of pdflatex is added to output directory on error + current selection is set to pdflatex-log file
  • Files beginning with _ or - are ignored while searching for TeX-files if a group is selected
4 Likes

Thanks for this! I had no clue that something like this would have been possible and I am now brimming with questions as to how you use it and how you find it useful. Try to be brief here:

  1. It sounds like this does not keep the intermediary files when compiling. I take it then that you do not put a latex file in DT until you are relatively finished writing it so you do not have to regenerate those often?
  2. Have you found a good way to syntax color the latex? Any other additional plugins for latex that you have found helpful (e.g. spellcheck appropriate for latex)
  3. How do you manage bibtex files? This one point is possibly the one I am most interested in as it is such a pain to do this and having all my references and corresponding bibliographic info in DT would be great!

It sounds like this does not keep the intermediary files when compiling.

You mean the *.aux files? All files generated during compilation are totally in the filesystem. Only PDF is imported into DT after the job has finished.

You would need to change the script to fit your needs, but should be technically possible. You might need teach DT some new plain text file formats - check the manual for this or look for TOML and a post of mine in the forum.

Have you found a good way to syntax color the latex? Any other additional plugins for latex that you have found helpful (e.g. spellcheck appropriate for latex)

I use an external editor for this - zed *(Open With > Zed.app or cmd + double click on the file)

How do you manage bibtex files? This one point is possibly the one I am most interested in as it is such a pain to do this and having all my references and corresponding bibliographic info in DT would be great!

Nowadays, I write letters with LaTeX. But I think, what I would do is, store the bibtex file in DT and open it in an external editor like bibdesk (Open With > Bibdesk.app)

I updated the script to support bibtex out of the box. For more customisation look for

on generateCmdString(workingDirectory, theFilename, theFilenameNoExt)
	local cmd
	set cmd to "cd " & quoted form of workingDirectory & " && /Library/TeX/texbin/pdflatex " & quoted form of theFilename & " && (/Library/TeX/texbin/bibtex " & quoted form of theFilenameNoExt & " || exit 0) && /Library/TeX/texbin/pdflatex " & quoted form of theFilename & " && /Library/TeX/texbin/pdflatex " & quoted form of theFilename
	return cmd
end generateCmdString
1 Like

The script also features extensive logging within DT:

Edit of original post: Better error handling