0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Chromeでは正常なのにMacのプレビューで壊れるPDF:PDFKitの落とし穴2つ(増分更新とフォーム)

0
Posted at

ブラウザで動くPDFエディタを作っています。PDFを開いてテキスト・署名・ハイライトを書き込んだり、フォームに入力したりして、結果を増分更新(incremental update)として書き戻すものです。保存したファイルは毎回、pdf.js(Firefoxや多くのWebビューア)、pdfium(Chrome)、poppler(Linuxの多くのツール)の3つで描画して確かめていて、どれも問題ありませんでした。

ところが同じファイルを、AppleのPDFKit(Macの「プレビュー」、iOSの「ファイル」App、SafariのPDF表示で使われているエンジン)で描画すると、ほかの3つでは出ない表示が、2つの原因で出ました。半透明のはずのハイライトが不透明な帯になる、文章が消える、フォームの回答が二重に重なる、の3つです。

どちらも、LinuxやChromeだけでテストしていると気づけません。この記事では原因と対処法を順に説明します。Macなら数秒で再現できるスクリプトを置いてあります:github.com/ppop123/pdfkit-pitfalls

※ このエディタは私たちが作っている LensUp の PDF 編集 です。この記事は、英語で書いた記事(Fine in Chrome, broken in Preview)の日本語版です。

1. 増分更新は、元のファイルと同じ形式の相互参照で書く

前提:増分更新と、相互参照の2つの形式

増分更新は、PDFを書き直さずに末尾へ追記する保存方法です。新しいオブジェクトと変更したオブジェクトをファイルの末尾に足し、その後ろに、ひとつ前の相互参照セクションを指す新しい相互参照セクションを置きます。署名済みのPDFに書き足しても元の署名が壊れないのはこの仕組みのおかげで、大きなスキャンPDFでも、書き込むのは編集した部分だけで済みます。

相互参照セクションには2つの形式があります。昔からあるのは xref テーブルと trailer の組み合わせです。PDF 1.5 で相互参照ストリーム(/Type /XRef)が加わり、たいていはオブジェクトストリーム(/Type /ObjStm)とセットで使われます。オブジェクトストリームは、ストリームではない小さなオブジェクトをいくつもまとめて、1本のストリームに圧縮したものです。

私たちは pdf-lib(増分更新に対応したフォークの @cantoo/pdf-lib)で増分更新用にファイルを開き、デフォルトのまま保存していました。デフォルトではオブジェクトストリームが有効です。つまり元のファイルがどちらの形式でも、追記部分は XRef ストリームで終わり、新しく足した辞書はオブジェクトストリームに詰め込まれていました。

PDFKit で何が起きるか

xref テーブルで終わる1ページのPDFに、蛍光ペンのハイライトを追記します。黄色の長方形を、不透明度60%・乗算(Multiply)の ExtGState を通して塗るものです。テーブル形式で追記すれば、どのビューアでも同じに見えます。XRef ストリーム形式で追記して ExtGState がオブジェクトストリームに入ると、poppler は正しく描きますが、PDFKit では文章があった場所に不透明な黄色の帯が描かれます。

同じハイライトを4通りの形式で追記し、左を Apple PDFKit、右を poppler で描画した結果

環境変数 CG_PDF_VERBOSE=1 を付けて描画すると、CoreGraphics が理由を出力します。

encountered unexpected object type: 7.
missing or invalid object number.
missing or invalid cross-reference stream.

追記部分の XRef ストリームの読み取りをあきらめています。追記部分でそれぞれ単独のオブジェクトとして書かれたものは見つかるので、長方形そのものは描かれます。見つからないのは追記部分のオブジェクトストリームの中身で、ExtGState はそこに入っていました。ExtGState がないので、長方形は不透明なまま文字の上に塗られます。

逆の組み合わせも壊れます。XRef ストリームで終わる元ファイルに、xref テーブル形式で追記するとこうなります。

failed to find start of cross-reference section.
font `Helvetica-7098480789' not found in document.

今度は元ファイル側のオブジェクトストリームが失われ、フォントがそこに入っていました。ハイライトは描かれますが、文章が消えます。

元ファイルの最後のセクション 追記の形式 PDFKit
xref テーブル xref テーブル 正常
xref テーブル XRef ストリーム(オブジェクトストリームあり) 追記部分のオブジェクトストリームの中身が失われる
XRef ストリーム xref テーブル 元ファイルのオブジェクトストリームの中身が失われる
XRef ストリーム XRef ストリーム 正常

私たちのエディタのチェックでは、pdf.js・pdfium・poppler は4通りとも問題なく読みました。Linux だけで回すテストで見つからなかったのは、まさにこのためです。

対処法

元ファイルの最後の startxref が指す位置を見ます。そこに xref というキーワードがあれば最後のセクションはテーブル、なければストリームです。そのうえで、追記も同じ形式で書きます。

// 元ファイルの最後の相互参照セクションの形式:'table' か 'stream'
function xrefForm(bytes) {
  const tail = new TextDecoder('latin1').decode(bytes.subarray(Math.max(0, bytes.length - 2048)));
  const found = [...tail.matchAll(/startxref\s+(\d+)/g)];
  const offset = found.length ? Number(found[found.length - 1][1]) : NaN;
  if (!(offset >= 0 && offset < bytes.length)) return 'table';
  let i = offset;
  while (i < bytes.length && /\s/.test(String.fromCharCode(bytes[i]))) i += 1;
  return new TextDecoder('latin1').decode(bytes.subarray(i, i + 4)) === 'xref' ? 'table' : 'stream';
}

const doc = await PDFDocument.load(bytes, { forIncrementalUpdate: true });
// … 編集 …
const saved = await doc.save({ useObjectStreams: xrefForm(bytes) === 'stream' });

トレーラーがテーブルで、そこから XRef ストリームも指しているハイブリッド参照のファイルは、最後のセクションがテーブルなので、ここでは 'table' になります。

2. PDFKit は、入力済みのテキストフィールドを自分で描く

PDFKit の挙動

フォームフィールドの見た目は、本来は外観ストリーム(/AP)で決まります。書き出す側が値を描き込んでおく、小さなコンテンツストリームです。ところが PDFKit は、値が入ったテキストフィールドについては違う動きをします。値(/V)を、フィールドのデフォルト外観文字列(/DA)が指定するフォントを使って CoreText で自分で描き、外観ストリームからはテキストを描く演算子(BT … Tj … ET)を取り除きます。外観ストリームのそれ以外の部分、たとえばパスや Form XObject は、そのまま描きます。

前半の挙動は、pdf-lib が作る外観でも確認できます。下の図の1行目では、poppler は pdf-lib が自動でサイズを決めた Helvetica を描いていますが、PDFKit はそれを無視して、値を自分で別のサイズに描いています。

1つの入力済みテキストフィールドを3種類の外観で書き出し、左を Apple PDFKit、右を poppler で描画した結果

なぜ困ったか

私たちのテキストエンジンは、文字の整形(ペルシア語やアラビア語の文字の連結、左右の書字方向が混ざったテキストの並べ替え、フォント間のフォールバック)を自前で行い、その結果をグリフのアウトラインとして描きます。どのビューアでもまったく同じグリフが表示されるようにするためです。フィールドの外観の中では、このアウトラインはパスです。PDFKit はパスを描き、その上に /V を CoreText でもう一度描きました。図の2行目のように、回答がすべて二重になります。実物の IRS W-9(米国の税務書式)では、2つの位置がずれてさえいました。PDFKit は自分の描く値を左揃えにし、私たちの外観は右揃えだったからです。

次のどれを試しても、2回目の描画は止まりませんでした。

  • フィールドを読み取り専用にする
  • /NeedAppearances を false にする
  • /Tx BMC … EMC のマークコンテンツを外す
  • /DA のフォントや色を変える

対処法

値はやはりテキストとして書きます。ただし実在のフォントではなく、グリフの描画手続き(/CharProcs)が同じアウトラインになっている Type 3 フォントを使います。PDFKit は Type 3 も含めてテキストを描く演算子を取り除き、値を自分で1回だけ描きます。pdf.js・pdfium・poppler は Type 3 のグリフ、つまり私たちのアウトラインを描きます。図の3行目です。

再現スクリプト field-demo.mjs の該当部分です。run は fontkit でレイアウトした値、pathOps はグリフのパスを PDF のパス演算子に変換する関数です。

// 値のグリフのアウトラインを、Type 3 フォントのグリフ描画手続きにする
const charProcs = {}, differences = [1], widths = [];
run.glyphs.forEach((glyph, i) => {
  const name = `g${i + 1}`;
  const proc = doc.context.stream([`${run.positions[i].xAdvance} 0 d0`, ...pathOps(glyph.path, 0, 0, 1), 'f'].join('\n'));
  charProcs[name] = doc.context.register(proc);
  differences.push(PDFName.of(name));
  widths.push(run.positions[i].xAdvance);
});
const upem = outlineFont.unitsPerEm;
const type3 = doc.context.register(doc.context.obj({
  Type: 'Font', Subtype: 'Type3', FontBBox: [0, -upem, 2 * upem, 2 * upem], FontMatrix: [1 / upem, 0, 0, 1 / upem, 0, 0],
  CharProcs: charProcs, Encoding: { Type: 'Encoding', Differences: differences },
  FirstChar: 1, LastChar: run.glyphs.length, Widths: widths, Resources: {},
}));
// 外観ストリームの中では、普通のテキストとして描く
const codes = run.glyphs.map((_, i) => (i + 1).toString(16).padStart(2, '0')).join('');
ops = ['/Tx BMC', 'q', '0 g', 'BT', `/F3 ${SIZE} Tf`, `${PAD} ${BASELINE} Td`, `<${codes}> Tj`, 'ET', 'Q', 'EMC'];

グリフの描画手続きはフォントの単位(units per em)のまま書き、FontMatrix を 1/unitsPerEm にして大きさを合わせています。d0 の最初の値がそのグリフの送り幅です。デモなので、グリフ1つに文字コードを1つずつ割り当てています(255グリフまで)。

トレードオフとして、プレビューで表示されるのは PDFKit による値の描画(/DA のフォントと、PDFKit の揃え方)で、私たちの描画ではありません。私たちの場合は、CoreText がペルシア語を正しく整形するので許容できました。

関連して2点あります。

  • ドロップダウンとリストボックスは別です。 PDFKit はこれらを描き直さないので、選択フィールドではアウトラインのままにしています。
  • Acrobat には、アウトラインで別の問題がありました。 Acrobat Reader は、あるフォームではアウトラインの外観を描きましたが、別のフォーム(pdf-lib で作成し、/MK で背景色を指定したフィールド)では枠が空白のままでした。Type 3 版はどちらのフォームでも描かれました。

おまけ:Acrobat の Reader 拡張機能(/UR3)

W-9 のような公的な書式には、使用権限(/Perms の /UR3、いわゆる Reader 拡張機能)が付いていることがあります。発行されたときのファイルに対する署名です。ファイルを変更すると、Acrobat Reader は開いたときに次の警告を出します(英語版での表示)。

This document enabled extended features in Adobe Acrobat Reader. The document has been changed since it was created and use of extended features is no longer available.

保存すれば必ずファイルは変わるので、いまは保存のたびに /Perms から /UR3 と古い /UR を削除しています(ほかに何も残らなければ /Perms ごと削除)。証明署名(/DocMDP)は残します。iText 5 の PdfReader.removeUsageRights() も同じ処理をしています。なお、確認できているのは Acrobat Reader で警告が出ることまでで、修正後のファイルを Acrobat Reader で開き直す確認はまだしていません。

「プレビュー」を開かずに PDFKit でテストする

PDFKit は Swift から20行ほどでスクリプトにできます。PDFDocument でファイルを開き、PDFPage.draw(with:to:) で1ページ目をビットマップに描いて(フォームのウィジェットも描かれます)、PNG に書き出すだけです。リポジトリの render-pdfkit.swift がこれです。

// Apple の PDFKit(プレビュー、iOS の「ファイル」App、Safari のエンジン)で PDF の1ページ目を描画する
// CG_PDF_VERBOSE=1 を付けて実行すると、CoreGraphics のパーサーの警告が stderr に出る
// 使い方: ./render-pdfkit in.pdf out.png [scale]
import AppKit
import Foundation
import PDFKit

let args = CommandLine.arguments
guard args.count >= 3 else { print("usage: render-pdfkit in.pdf out.png [scale]"); exit(2) }
guard let doc = PDFDocument(url: URL(fileURLWithPath: args[1])), let page = doc.page(at: 0) else { print("cannot open"); exit(1) }
let scale = CGFloat(args.count > 3 ? Double(args[3]) ?? 2 : 2)
let box = page.bounds(for: .mediaBox)
let w = Int(box.width * scale), h = Int(box.height * scale)
guard let ctx = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: 0,
                          space: CGColorSpaceCreateDeviceRGB(),
                          bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { exit(1) }
ctx.setFillColor(CGColor(red: 1, green: 1, blue: 1, alpha: 1))
ctx.fill(CGRect(x: 0, y: 0, width: w, height: h))
ctx.scaleBy(x: scale, y: scale)
page.draw(with: .mediaBox, to: ctx) // 注釈(フォームのウィジェットを含む)も描かれる
guard let image = ctx.makeImage(),
      let png = NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:]) else { exit(1) }
try png.write(to: URL(fileURLWithPath: args[2]))
print("rendered \(w)x\(h)")
swiftc -O render-pdfkit.swift -o render-pdfkit
CG_PDF_VERBOSE=1 ./render-pdfkit in.pdf out.png

CG_PDF_VERBOSE=1 を付けると、この記事で引用したような CoreGraphics の警告が出ます。Quick Look のサムネイル生成(qlmanage -t)も PDFKit を通ります。

Python で pdfium と比べる場合の注意です。pypdfium2 は、先に pdf.init_forms() を呼ばないとフォームフィールドを描きません。呼ばないとフィールドがすべて空に見えるので、自分の書き出し処理のバグと勘違いしがちです。

まとめ

  • 3つのレンダラーで正しく見えても、Mac や iPhone のユーザーなら誰もが持っているビューアで正しく見えるとは限りません。PDF が Apple のデバイスに届くなら、PDFKit でも描画して確かめましょう。できれば macOS ランナーの CI で、少なくとも手元で。
  • 増分更新では、更新するファイルと同じ形式の相互参照で書きましょう。
  • 入力済みのテキストフィールドの値は、PDFKit が自分で描きます。値をパスとして外観に入れるのは避けましょう。Type 3 フォントのテキストにすると、私たちが試したビューアではどれも正しく表示されました。

再現用の Node と Swift のコードは MIT ライセンスで公開しています:github.com/ppop123/pdfkit-pitfalls

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?