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}でstringをDefaultSystemCodePage付きのAnsiStringへcompileしますが、DelphiではUnicodeStringへcompileします。TPdfASaveOptionsのmetadata fieldはすべてstringなので、Title、Author、Subject、Keywords、Creator、Producerは、一方の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がすでにDefaultSystemCodePageをCP_UTF8にsetしているため、narrowingがUTF-8を生成し、下流はたまたま正しくなります。plain console programでは同じnarrowingがANSI codepageへ着地し、StringToUtf8はそれをすでにUTF-8と仮定してoctetをそのままcopyします。6つのsave bridgeがこの形を共有します。SaveAsPdfAToStream、SaveAsPdfUaToStream、SaveAsPdfEToStream、SaveAsPdfXToStream、SaveAsPdfRToStream、SaveAsPdfVTToStreamで、それぞれ独自の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なので、DefaultSystemCodePageはCP_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つを修正しました。FPdfProductionのUtf8BytesToStringとStringToUtf8Bytesです。これらはXFA datasets packetをstringへround-tripし、MergePdfXfaDatasetsがbound valueをsubstituteできるようにします。もう1つはFPdfTrustedListのBytesToUtf8で、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の大部分を見つけられます
stringargument付きのUTF8Encodeをgrepする。FPCではそのcallはno-opで、最もyieldの高いaudit対象です- mode Delphiではすべての
UTF8Stringvariableを疑う。そこではplainAnsiStringで、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にあります