TeX Alchemist Online

TeX のこと,フォントのこと,Mac のこと

macOS 12.3 以降の環境でコマンドラインからPDFを結合する

前記事の続編です。

doratex.hatenablog.jp

かつては Automator アクションに内蔵されたスクリプトが便利だった

macOS 12.2 以前では,システム標準にインストールされているAutomatorアクションの中に内包された Python スクリプトを呼び出すことで,コマンドラインから複数のPDFを結合することが可能でした。

例えば,input1.pdfinput3.pdf を結合して output.pdf を得たい場合,次のコマンドで実行できました。

$ /System/Library/Automator/"Combine PDF Pages".action/Contents/Resources/join.py -o output.pdf input1.pdf input2.pdf input3.pdf

この join.py というスクリプトは,Python2 + PyObjC で書かれたスクリプトで,内部的には macOS の API を PyObjC ブリッジで呼び出すことでPDFを結合しようというスクリプトでした。

しかし,macOS 12.3 では,Python2, Python3, PyObjC は全てまとめて macOS 標準インストールから削除されてしまいました。その結果,現時点では,この join.py(およびこれを包含する Combine PDF Pages という Automator アクション)は,「macOS に標準インストールされているのに動かない」という,ちぐはぐな状態となってしまっています。

そこで,この join.py に代わり,AppleScriptObjCで「macOS標準インストール状態でコマンドラインから実行可能なPDF結合コマンド」を実現することを目指しましょう。

AppleScriptObjC でPDF結合する

まずは,AppleScriptObjC でPDF結合するスクリプトを書いてみます。

use framework "Quartz"

global CA, NSURL, PDFDocument
set CA to current application
set NSURL to CA's NSURL
set PDFDocument to CA's PDFDocument

--- 与えられたPDFのページ数を返すハンドラ
on pdfPageCount(pdfPath)
    set url to NSURL's fileURLWithPath:pdfPath
    set doc to PDFDocument's alloc's initWithURL:url
    doc's pageCount()
end pdfPageCount

--- PDF結合を実行するハンドラ
on joinPDF(pdfPaths, outputPath)
    set newPDF to PDFDocument's alloc's init
    set pageCount to 0
    repeat with pdfPath in pdfPaths
        set url to (NSURL's fileURLWithPath:pdfPath)
        set doc to (PDFDocument's alloc's initWithURL:url)
        set lastIndex to ((my pdfPageCount(pdfPath)) - 1)
        repeat with i from 0 to lastIndex
            (newPDF's insertPage:(doc's pageAtIndex:i) atIndex:pageCount)
            set pageCount to (pageCount + 1)
        end repeat
    end repeat
    newPDF's writeToFile:outputPath
end joinPDF

--- テスト実行
set sources to {"/tmp/input1.pdf", "/tmp/input2.pdf", "/tmp/input3.pdf"}
set outputPath to "/tmp/output.pdf"

my joinPDF(sources, outputPath)

Bashシェルスクリプトに変換する

これを次のようなシェルスクリプトとしてくるんで,join.sh として保存します。

#!/bin/bash
SCRIPTNAME=$(basename "$0")

function realpath () {
  f=$@;
  if [ -d "$f" ]; then
    base="";
    dir="$f";
  else
    base="/$(basename "$f")";
    dir=$(dirname "$f");
  fi;
  dir=$(cd "$dir" && /bin/pwd);
  echo "$dir$base"
}

function joinPDF () {
    /usr/bin/osascript \
      -e 'use framework "Quartz"' \
      -e "global CA, NSURL, PDFDocument" \
      -e "set CA to current application" \
      -e "set NSURL to CA's NSURL" \
      -e "set PDFDocument to CA's PDFDocument" \
      -e "on pdfPageCount(pdfPath)" \
      -e "set url to NSURL's fileURLWithPath:pdfPath" \
      -e "set doc to PDFDocument's alloc's initWithURL:url" \
      -e "doc's pageCount()" \
      -e "end pdfPageCount" \
      -e "on joinPDF(pdfPaths, outputPath)" \
      -e "set newPDF to PDFDocument's alloc's init" \
      -e "set pageCount to 0" \
      -e "repeat with pdfPath in pdfPaths" \
      -e "set url to (NSURL's fileURLWithPath:pdfPath)" \
      -e "set doc to (PDFDocument's alloc's initWithURL:url)" \
      -e "set lastIndex to ((my pdfPageCount(pdfPath)) - 1)" \
      -e "repeat with i from 0 to lastIndex" \
      -e "(newPDF's insertPage:(doc's pageAtIndex:i) atIndex:pageCount)" \
      -e "set pageCount to (pageCount + 1)" \
      -e "end repeat" \
      -e "end repeat" \
      -e "newPDF's writeToFile:outputPath" \
      -e "end joinPDF" \
      -e "my joinPDF({$1}, \"$2\")" \
      > /dev/null
}

function usage() {
    echo "Usage: $SCRIPTNAME -o <output> <input1> <input2> ..."
    echo
    echo "options:"
    echo "  -h, --help                       Show help"
    echo "  -o <output>, --output <output>   Specify output file"
    echo
}

# parse arguments
declare -a args=("$@")
declare -a params=()

OUTPUT=""

I=0
while [ $I -lt ${#args[@]} ]; do
    OPT="${args[$I]}"
    case $OPT in
        -h | --help )
            usage
            exit 0
            ;;
        -o | --output )
            if [[ -z "${args[$(($I+1))]}" ]]; then
                echo "$SCRIPTNAME: option requires an argument -- $OPT" 1>&2
                exit 1
            fi
            OUTPUT="${args[$(($I+1))]}"
            I=$(($I+1))
            ;;
        -- | -)
            I=$(($I+1))
            while [ $I -lt ${#args[@]} ]; do
                params+=("${args[$I]}")
                I=$(($I+1))
            done
            break
            ;;
        -*)
            echo "$SCRIPTNAME: illegal option -- '$(echo $OPT | sed 's/^-*//')'" 1>&2
            exit 1
            ;;
        *)
            if [[ ! -z "$OPT" ]] && [[ ! "$OPT" =~ ^-+ ]]; then
                params+=( "$OPT" )
            fi
            ;;
    esac
    I=$(($I+1))
done

# handle invalid arguments
if [ ${#params[@]} -eq 0 ]; then
    echo "$SCRIPTNAME: too few arguments" 1>&2
    echo "Try '$SCRIPTNAME --help' for more information." 1>&2
    exit 1
fi

if [ "$OUTPUT" = "" ]; then
    echo "Option -o cannot be omitted."
    echo "Try '$SCRIPTNAME --help' for more information." 1>&2
    exit 1
fi

# change input path list into AppleScript style
declare -a list=()
for FILE in "${params[@]}"; do
    list+=("\"$(realpath $FILE)\"")
done
INPUTS="$(echo "$(IFS=","; echo "${list[*]}")")"

# join PDFs
joinPDF "$INPUTS" "$OUTPUT"

こうして完成した上記スクリプトは,次のように macOS 標準インストール状態でPDF結合を実現できます

$ ./join.sh -o output.pdf input1.pdf input2.pdf input3.pdf

(スクリプト中でエラー処理は全然していませんが)とりあえずこれで join.py を代替するという目的は達成できたことでありましょう。