技術記事

FPC codepage trapでPDF/A XMP metadataが壊れる

PDFium Delphi ComponentはPDF/A output向けのXMP packetを、UTF-8 fragmentをAnsiStringへ連結して組み立てます。Free Pascal 3.2.2では、document titleにnon-ASCII characterが入った瞬間、そのpacketが静かにvalid UTF-8でなくなりました。ISO 19005-1 6.7.2はmetadata streamにvalid UTF-8を要求するため、fileはvalidationに失敗します。version 3.103.1では、StringToUtf8にあるencoder自身を修正しました。興味深いのはpatchではありません。同じsource lineがDelphiでは正しいbyteを出し、Lazarus LCL applicationでも正しいbyteを出し、同一unitからcompileしたplain Free Pascal console programではcorrupt byteを出したことです。これを理解するには3つのFree Pascal string behaviorが揃う必要があり、それぞれ単独では筋が通っています

同じmetadata codeがDelphiとFPCで異なるbyteを出す理由

stringが両compilerで同じtypeではないためです。FPC 3.2.2は{$MODE Delphi}stringDefaultSystemCodePage付きのAnsiStringへcompileしますが、DelphiではUnicodeStringへcompileします。TPdfASaveOptionsのmetadata fieldはすべてstringなので、TitleAuthorSubjectKeywordsCreatorProducerは、一方のcompilerではUTF-16 code unit、他方ではsingle-byte characterとcodepage labelを持ちます。同じrecord、異なるpayloadです。value自体はdocumentからUTF-16で届きます。TPdf.GetTitleなどはWStringを返し、FPCではWideString、Delphiではstringです。SaveAsPdfAToStreamはmarkerをinjectする前に、blankのoption fieldをInfo dictionaryからfillします。このassignmentはFree Pascalではnarrowing conversionで、RTLはtarget string codepageを通じて実行します。LCL programではLazUTF8がすでにDefaultSystemCodePageCP_UTF8にsetしているため、narrowingがUTF-8を生成し、下流はたまたま正しくなります。plain console programでは同じnarrowingがANSI codepageへ着地し、StringToUtf8はそれをすでにUTF-8と仮定してoctetをそのままcopyします。6つのsave bridgeがこの形を共有します。SaveAsPdfAToStreamSaveAsPdfUaToStreamSaveAsPdfEToStreamSaveAsPdfXToStreamSaveAsPdfRToStreamSaveAsPdfVTToStreamで、それぞれ独自のoption recordを持ちます

// PDFium.pas:document accessorは常にUTF-16
//   WString = FPCではWideString、Delphiではstring(UnicodeString)
function TPdf.GetTitle: WString;

// FPdfPdfa.pas:save option recordはmetadataを`string`で保持する
TPdfASaveOptions = record
  Conformance: TPdfAConformance;
  IccProfileData: TBytes;
  Title: string;      // DelphiではUnicodeString
                      // FPCではAnsiString + DefaultSystemCodePage
  Author: string;
  Subject: string;
  Keywords: string;
  Creator: string;
  Producer: string;
  CreationDate: string;
  ModDate: string;
  DocumentId: TBytes;
  InstanceId: TBytes;
  class function Default: TPdfASaveOptions; static;
end;

// SaveAsPdfAToStreamはInfo dictionaryからblank fieldをbackfillする
// narrowingを暗黙のままにせず、明示的に書く
if Eff.Title = '' then
  Eff.Title := WStringToStr(GetTitle);

6つのbridgeを1つのWStringToStr helperへrouteしてもRTLの動作自体は変わりません。しかしconversionがreaderに見える場所へ移り、まさにこの種類の問題を隠していた92個のimplicit-conversion warningが消えました。これはPDFium buildにおけるDelphiとFPC cross-compiler pitfallで説明したDelphi側corruptionのmirror imageです。そこではDelphiのconcatenationが、FPCなら保持するhigh byteを壊します

明白なfixを打ち負かす3つのFree Pascal behavior

明白なfixはUTF8Encodeを呼べば終わり、というものです。FPC 3.2.2のmode Delphiでは、それが3回にわたって静かに失敗します

var
  W: UnicodeString;
  S: string;
  U: UTF8String;
  R: RawByteString;
  Xmp: AnsiString;
begin
  // Trap 1:mode DelphiではUTF8String *variable*もplain AnsiString
  // そのためassignmentがoctetをhost codepageへ直接戻す
  U := UTF8Encode(W);

  // Trap 2:SはすでにAnsiStringなので、UTF8Encodeは何もしない
  R := UTF8Encode(S);                 // decodeもencodeもerrorもない
  R := UTF8Encode(UnicodeString(S));  // こちらは本当にencodeする

  // Trap 3:concatenationはすべてのoperandをdestination codepageへ統一し、
  // RawByteString destinationも例外ではない
  Xmp := Xmp + R;
end;

trap oneでは、encoded resultを生成したAnsiStringまたはRawByteStringにとどめなければなりません。途中でUTF8String temporaryを通してoutputすると、行った仕事を元に戻します。trap twoは最も長く隠れます。UTF8Encode(S)はcompileし、実行し、正しいlengthのvalueを返しますが、argumentがすでにAnsiStringならconversionをまったく行いません。最初にUnicodeStringへwidenして初めてdecodeします。trap threeは、正しいencoderでもbroken documentを出せる理由です。BuildXmpBytesはlocal Xmp: AnsiStringへpacketをaccumulateし、Free Pascalはconcatenationの各operandをdestination variableのcodepageへconvertし、途中でmulti-byte sequenceをsingle ANSI byteへfoldします

Falseを渡したSetCodePageが保証するもの

SetCodePage(RawByteString(Result), DefaultSystemCodePage, False)はbyteに触れずにstringをrelabelします。第3 parameterはConvertで、Falseは「payloadはすでにtarget codepageにあり、tagだけ変える」と意味します。contentについてのlieですが、意図したものです。octetは実際にはUTF-8ですが、host codepageとしてtagすることで、trap threeのconcatenationがconvertするのを止めます。XMP bufferへraw byteとしてjoinし、反対側からも変更されずに出ます

function StringToUtf8(const S: string): AnsiString;
begin
{$IFDEF UNICODE}
  // Delphi:UTF8EncodeはCP_UTF8-tagged octetを返し、
  // AnsiStringへのconcatenationがそれを保持する
  Result := AnsiString(UTF8Encode(S));
{$ELSE}
  // FPC:先にwidenする。そうしないとAnsiString argumentへのUTF8Encodeはno-op
  Result := UTF8Encode(UnicodeString(S));
  // transcodeせずにrelabelする。XMP packetとPDF string objectを
  // 組み立てるANSI-tagged bufferへのconcatenationでoctetを生かす
  if Length(Result) > 0 then
    SetCodePage(RawByteString(Result), DefaultSystemCodePage, False);
{$ENDIF}
end;

boundaryを明確にしてください。このretagはFPC-onlyで、tag付きとuntagged stringを混ぜること全般への許可ではありません。ここで動くのは下流にconsumer patternが1つだけあるためです。AnsiStringへappendしてからbufferをbyteとしてwriteします。retagしたvalueをhost codepageのtextとしてinterpretしようとすればmojibakeを読むことになり、それは正しい動作です。reverse directionは両compilerで同じように処理します。incoming bufferをSetCodePage(..., False)CP_UTF8としてtagし、UTF8ToStringを呼びます

regression test自身が同じtrapを持った理由

source literalからexpected byteをbuildするtestはlibraryではなくcompilerをtestしているからです。Pascal source fileに書いた#$C3#$A9のようなconstantは、そのfileのcompile-time codepageを持ちます。AnsiString parameterへ渡すとRTLがre-encodeします。まさにtest対象のconversionです。expectationはruntimeにbyteごとにassembleし、byteごとにcompareしなければなりません。異なるtagを持つ2つのAnsiString valueに=を使うと、比較前にcodepageをreconcileし、明るいfalse negativeを返すためです

function BytesPattern(const Values: array of Byte): AnsiString;
var
  I: Integer;
begin
  SetLength(Result, Length(Values));
  for I := 0 to High(Values) do
    Result[I + 1] := AnsiChar(Values[I]);
end;

procedure TPdfATests.StringToUtf8_AnsiCodePageString_EncodesUtf8;
var
  Saved: Word;
  Wide: WideString;
  Narrowed: string;
  Encoded, ExpectedUtf8: AnsiString;
begin
  Saved := DefaultSystemCodePage;
  try
    SetMultiByteConversionCodePage(1252);
    Wide := WideChar($0043) + WideChar($0061) + WideChar($0066) + WideChar($00E9);
    Narrowed := Wide;                    // test対象のnarrowing
    Encoded := StringToUtf8(Narrowed);
  finally
    SetMultiByteConversionCodePage(Saved);
  end;
  // 'Caf' + U+00E9をruntimeでUTF-8として組み立て、literalのre-encodeを防ぐ
  ExpectedUtf8 := BytesPattern([$43, $61, $66, $C3, $A9]);
  AssertTrue('StringToUtf8 must emit UTF-8 for a string carrying a non-UTF-8 codepage',
    SameOctets(Encoded, ExpectedUtf8));
end;

harness自体はLCL programなので、DefaultSystemCodePageCP_UTF8です。bugが見えるのはtestがそれをchangeした後だけです。try..finally内のSetMultiByteConversionCodePage(1252)が、1 testの間だけplain console environmentを再現します。end-to-end checkはさらに進み、両方向をassertします。marker injectionが生成したXMP packetは$43 $61 $66 $C3 $A9を含み、$43 $61 $66 $E9含んではいけません。そうすればraw single-byte outputへ戻るfuture regressionが、hex dumpではもっともらしいfileを作るのではなく、明確に失敗します。non-Latin metadataを扱うなら、同じwidening disciplineがDelphiのWideChar handlingを壊すemojiとCJK textにも適用されます

narrowingが他に到達する場所

XMPは見える被害者ですが、同じcodebaseのTBytesからstringへのbridgeには同じexposureがありました。v3.103.1でさらに2つを修正しました。FPdfProductionUtf8BytesToStringStringToUtf8Bytesです。これらはXFA datasets packetをstringへround-tripし、MergePdfXfaDatasetsがbound valueをsubstituteできるようにします。もう1つはFPdfTrustedListBytesToUtf8で、byte-order markをstripした後European trusted-list XMLをdecodeします。どちらも現在はbufferをRawByteStringへstageし、convertせずCP_UTF8としてtagし、UTF8ToStringでdecodeします。1つのmoduleはすでにimmuneで、その理由はcopyする価値があります。XFDF writerは自身のtext typeをXFDFStringと宣言し、FPCではWideString、DelphiではUnicodeStringへresolveします。そのためencoderがcodepage-tagged AnsiStringを見ることはありません。structural fixはこれです。serializationの正確な地点までtextをUTF-16 typeに保ち、byteへのconversionを1つのnarrow functionに所有させます。このfamilyのbugはすべて、一方の端がUTF-16、もう一方がoctetであるpipelineの真ん中にstring fieldが置かれたことから来ました

dual-compiler PDF codeで確認すべきもの

両compilerで動き、standards-conformant PDFへmetadataを書くObject Pascalを出荷するなら、次の4つのcheckでvalidatorより前にこのclassの大部分を見つけられます

  • string argument付きのUTF8Encodeをgrepする。FPCではそのcallはno-opで、最もyieldの高いaudit対象です
  • mode DelphiではすべてのUTF8String variableを疑う。そこではplain AnsiStringで、encoded byteをassignすると元へtranscodeします
  • single-byte codepageを設定したSetMultiByteConversionCodePage下で少なくとも1つregressionを実行する。LCL test harnessはCP_UTF8で動くため、plain console programは再現できません
  • expected byte vectorはruntimeにbuildし、octet単位でcompareする。source literalも=もcodepage reconciliationを通り、探しているdefectを隠します

これはexoticなFree Pascal triviaではありません。byte-oriented string typeとUTF-16 typeを並存させたlanguageに普通にかかるコストで、2つのcompilerがstringの意味についてreasonableですが異なる選択をした結果です。PDF workへの実用上の帰結は狭く明確です。IDEで正しく読めるmetadataがXMP packetへinvalid UTF-8として届くことがあり、ISO 19005-1 6.7.2はどのcompilerが入れたかを気にしません。archival pipelineを構築するなら、encoding layerには周囲のPDF/A archival compliance workflowと同じだけの注意が必要です。PDFium Delphi Componentはlibraryの一部としてこれらのconversionを提供するため、SaveAsPdfAと5つのstandards siblingは、caller側のcodepage configurationなしでDelphi、Lazarus、plain Free Pascal buildからconformant UTF-8 metadataを出力します。完全なAPI documentationとcurrent releaseはPDFium Delphi Component product pageにあります