技術記事

DelphiのPDF抽出でNBSPとソフトハイフンが混じる問題の修正

Delphi向けPDFium Componentは、TPdf.AddTextが使うシステムフォントをUnicodeコードポイントをキーとするCIDフォントとして埋め込みます。すべてのCIDがちょうど1つのToUnicodeマッピングを運ぶためです。これにより、抽出された空白がU+00A0(no-break space)に、ハイフンがU+00AD(ソフトハイフン)になって戻ってくることが、ライブ文書でも保存後のファイルでも止まります

症状が厄介なのは、見えないからです。検索インデックスは「two-x」を取り逃します。保存された文字列にソフトハイフンが入っているからです。CSVエクスポートの区切りは別の位置で切れ、diffツールはどのビューアでも同一に見える行をフラグします。描画されたページには何も問題がなく、グリフの背後のUnicodeだけが壊れています

抽出された空白がU+00A0で戻ってくる理由

抽出された空白がU+00A0に化けるのは、PDFiumがFPDFText_LoadFontで生成するToUnicode CMapがグリフをキーにしており、1つのグリフに2つのコードポイントから到達できるからです。Arialではグリフ3がU+0020とU+00A0の両方に対応し、ハイフングリフはU+002DとU+00ADの両方に対応します。生成されたCMapはしたがって同じCIDを2回マップします。1回はbfcharエントリ経由、もう1回は配列形式のbfrange経由です。そしてリーダーの優先規則がどちらのエントリを好むかで、抽出されるテキストが決まります

1つのArialグリフがDelphiのPDFテキスト抽出を壊した理由:U+0020とU+00A0はグリフ3へ、U+002DとU+00ADはハイフングリフへ到達するため、生成されたToUnicode CMapはCID 0003をbfcharエントリと配列bfrangeで2回マップし、どのコードポイントが抽出されるかはリーダーの優先規則が決めます
lowest-winsの優先規則が空白を普通のまま保ってきたのは長年でした。上流がlast-winsへ切り替えた途端、AddTextの空白は全部NBSPとして、ハイフンは全部ソフトハイフンとして抽出されます
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange

この矛盾は長いあいだ無害でした。PDFiumリーダーが最小のマッピングを勝たせていたからです。上流の変更がリーダーをlast-winsへ切り替え、そのビルドから、AddTextで書かれたすべての空白がNBSPとして、すべてのハイフンがソフトハイフンとして抽出されます。ペアのパターンに注目してください。0x20/0xA0と0x2D/0xADは上位ビットしか違いません。Latin-1の似顔文字を同じアウトラインへ送り込むフォントのcmapから期待されるのは、まさにこれです。抽出コードが昨日まで正常で、今日は見えない文字で失敗するなら、デバッガの表示を信用するのではなくコードポイントをダンプしてください。テキスト取り出しの基礎は、DelphiのPDFiumでPDF文書からテキストを抽出するで扱っています

uses
  SysUtils, PDFium;

const
  // Space/U+00A0とhyphen/U+00ADは1つのArialグリフを共有する。
  // ギリシャのOmega(U+03A9)とオーム記号(U+2126)も同様
  Sample: WString = 'two-x'#$00A0'y'#$00AD'z '#$03A9#$2126;

function CodePoints(const S: WString): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
    Result := Result + 'U+' + IntToHex(Ord(S[I]), 4) + ' ';
end;

var
  Pdf: TPdf;
  Live, Reloaded: WString;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.CreateDocument;
    Pdf.AddPage(1, 595, 842);
    Pdf.AddText(Sample, 'Arial', 12, 72, 770);
    Live := Pdf.Text;                  // ライブの未保存文書
    Pdf.SaveAs('codepoints.pdf');
  finally
    Pdf.Free;
  end;

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'codepoints.pdf';
    Pdf.Active := True;
    Pdf.PageNumber := 1;
    Reloaded := Pdf.Text;              // フル保存と再ロードの後
  finally
    Pdf.Free;
  end;

  if (Live <> Sample) or (Reloaded <> Sample) then
    Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;

保存後にCMapへパッチを当てるだけでは足りなかった理由

保存済みファイルへのパッチは、そのファイルしか直しません。しかも、パッチがCMap構造をバイト単位でそのまま保つ場合に限られます。最初の修正、FPdfCompressユニットのRepairSubsetToUnicodeCMapsは、非増分のTPdf.SaveAsのたびに走り、衝突する各CIDを解決します。bfcharエントリが勝ち、上位ビットしか違わないペアはより小さいbase-Latinコードポイントへ解決され、それ以外は最初のマッピングを保ちます

興味深いのは否定的な結果のほうです。衝突するCMapを、start-code形式でも配列形式でも綺麗に再構築することは、自明な一手に見えました。しかしPDFiumは再構築されたCMapをどれも丸ごと拒否し、Identityへフォールバックします。ネイティブリーダーが受け入れた唯一の出力は、ブロックレイアウトとCIDカバレッジをそのままにした、衝突する16進値の同長置換でした。2つ目の教訓はもっと謙虚なものです。当時のメモは、メモリ内のケースを「ライブ文書にはToUnicodeストリームがまったくない」せいにしていました。DLLを直接呼んでみたところ、それでは説明が付きませんでした。ライブ文書も同じ曖昧なストリームを運んでいたのです。つまり本当の修正は、PDFiumがCMapを生成するより前に行われなければならなかった。修復ルーチンは、他のPDFiumベースのツールが作ったPDFへの防衛としてライブラリに残ります

uses
  Classes, FPdfCompress;

var
  Source, Dest: TFileStream;
begin
  Source := TFileStream.Create('from-other-tool.pdf',
    fmOpenRead or fmShareDenyWrite);
  try
    Dest := TFileStream.Create('repaired.pdf', fmCreate);
    try
      // 同長の編集のみ。修復可能な衝突を持たないファイルと、
      // クロスリファレンスストリーム・オブジェクトストリームのファイルはそのままコピーされる
      RepairSubsetToUnicodeCMaps(Source, Dest);
    finally
      Dest.Free;
    end;
  finally
    Source.Free;
  end;
end;

グリフではなくコードポイントでフォントをキーイングする

根本的な修正は、PDFiumにCMapの生成をそもそも頼まないことです。TPdf.LoadCachedFontは現在、システムフォントのバイトをTPdf.LoadUnicodeKeyedCidFontへ渡します。これはフォント自身のsfnt cmapテーブルを読み、format 12サブテーブルを優先し、なければformat 4へフォールバックします。コードポイントはソート・重複除去されて返り、CID k+1がk番目のコードポイントへ割り当てられ、CID 0は.notdefのまま残ります。明示的なCIDToGIDMapが各CIDを自分のグリフへ送るため、U+0020とU+00A0は同じアウトラインを描く2つの異なるCIDを得ます。そしてToUnicode CMapは各CIDを1つのコードポイントにだけマップします。フォントは続いてFPDFText_LoadCidType2Fontを通してロードされます。明示的CID-to-GIDマップによるCID Type 2フォント埋め込みでグリフレベルの書き込みの背後にあるのと同じエントリポイントです

PDFium Componentのコードポイントキーイング修正:LoadUnicodeKeyedCidFontがフォントのsfnt cmapを読み、CID k+1を各ソート済みコードポイントへ割り当ててCID 0をnotdefとし、明示的なCIDToGIDMapを配線してU+0020とU+00A0に異なるCIDを保たせ、BuildUnicodeKeyedCidCMapがすべてのCIDにちょうど1つのコードポイントを与えます
NBSP、ソフトハイフン、オーム記号はどちらの優先規則でも自分自身のまま生き延び、ライブ文書でもどの保存後でも。CMap修復は直すものが何も見つからなくなります
// TPdf.LoadUnicodeKeyedCidFontから簡略化
SetLength(CidToGidMap, (Length(Entries) + 1) * 2);   // CID 0 = .notdef
for I := 0 to High(Entries) do
begin
  CidToGidMap[(I + 1) * 2]     := Byte(Entries[I].GlyphID shr 8);
  CidToGidMap[(I + 1) * 2 + 1] := Byte(Entries[I].GlyphID and $FF);
end;
ToUnicode := BuildUnicodeKeyedCidCMap(Entries);       // 1つのCIDに1つのコードポイント
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
  PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));

FPDFText_SetTextが後で文字列を書くとき、逆引きは文字ごとに単一のCIDに着地します。NBSP、ソフトハイフン、オーム記号はどちらの優先規則でも自分自身のまま生き延びます。メモリでもどの保存後でも。保存済みファイルはエンジン生成ではなくコンポーネント自身のToUnicodeストリームを運ぶため、RepairSubsetToUnicodeCMapsは直すものを何も見つけません

1つのbfrangeエントリがブロック全体を消せる理由

CIDの連なりがxxFF境界をまたぐ1つのbfrangeは、PDFiumに自分が居座るブロック全体を破棄させます。ISO 32000-1 §9.10.3は範囲内で変わり得るのを宛先の最終バイトだけに限りますが、CID側には独自の罠があります。PDFiumのHandleBeginBFRangeは上位CIDを(low and $FFFFFF00) or (high and $FF)として導きます。したがってCID 00FEから0101への連なりは、00FEから0001として、つまりlowがhighより大きい状態で読まれ、ブロック全体が無効と印されます。失敗は静かです。SetTextは成功し、ページは完璧に描画され、抽出はそのブロックの全文字についてU+0000を返します

PDFのCMapパースにおける静かなbfrangeの罠:CID 00FEから0101への連なりはxxFF境界をまたぎ、HandleBeginBFRangeは上位CIDを0001と導き、lowがhighより大きいことでブロック全体が無効と印され、SetTextと描画は成功したまま、抽出はブロック内の全文字についてU+0000を返します
BuildUnicodeKeyedCidCMapは、低バイトがFFになる前に連なりを終えることで罠を避け、ブロックを100エントリ制限内に保ち、追加面のコードポイントは個別のbfcharエントリとして書きます

BuildUnicodeKeyedCidCMapは、コードポイントかCIDのどちらかが低バイトFFに達する前に連なりを終え、すべてのブロックをCMap文法の100エントリ制限内に保ち、追加面のコードポイントはUTF-16サロゲートペアの宛先を持つ個別のbfcharエントリとして書きます。範囲の中でサロゲートペアを増やしていくことに定義された意味はないためです。サロゲート側の話は、Delphiでの絵文字・CJK・サロゲートペア処理にあります。bfcharだけのCMapは境界問題を丸ごと回避できますが、サイズは数倍になります

コードポイントキーイングのフォントがカバーしないもの

コードポイントキーイングの経路がカバーするのはUnicodeのcmapサブテーブルを持つフォントすべてであり、それ以外は旧来のグリフキーイングの挙動へフォールバックします。頼る前に知っておくべき境界は以下の通りです

  • (3,0)のcmapしか持たないシンボルフォントと、CID経路のロードに失敗したフォントは、従来どおりFPDFText_LoadFontを通ります。2つのコードポイントで共有されるグリフが、そこでは依然として曖昧に抽出され得ます
  • format 12サブテーブルがなければマップはBMPに限られ、エントリ数は65535で頭打ちです。すべてのCIDがゼロより上の2バイトに収まるようにするためです
  • 増分保存(saIncremental)は設計上RepairSubsetToUnicodeCMapsをスキップします。増分リビジョンは追記専用であり続けなければならないからです。コンポーネント自身が書くテキストについては、コードポイントキーイングのフォントがこの問題を無関係にします
  • TrueType Collectionには特別な注意が要ります。GDIのGetFontDataは.ttc全体を返し、FPDFText_LoadCidType2Fontにはfaceインデックスのパラメータがありません。そのためsimsun.ttcからNSimSunを要求すると、face 0のSimSunが埋め込まれ描画されていました。コンポーネントは現在、ファミリー名をnameテーブル(nameID 1と16)と照合し、cmapのパース前に要求されたfaceを独立したsfntとして切り出します。パースが失敗すればコレクションのバイトがそのまま通り、挙動はface 0へ戻ります

テキスト書き込み、フォント埋め込み、抽出は、Delphi、C++Builder、Lazarusを通じて1つのページモデルを共有しています。APIの全体はPDFium Component for Delphiの製品ページで説明しています