v3.539.30 이전의 losLab PDF Library TPDFlib.ImportAnnotationsFromFDFString은 FDF 어노테이션 엔트리를 파싱한 개수를 반환하면서 정작 문서에는 하나도 추가하지 않았습니다. 세기는 하되 전부 버린 것이죠. v3.539.30부터 FDF 임포터는 키를 어떤 순서로든 읽고, /Rect를 로캘 독립적으로 올바르게 파싱하며, 짝이 되는 익스포터는 어노테이션의 진짜 /Rect를 기록하므로, 내보내기, 임포트, 두 번째 내보내기가 바이트까지 같은 FDF를 만들어 냅니다. 이 글의 나머지 부분에서는 잘못된 시작 오프셋 하나가 어떻게 완벽한 조용한 실패를 만들어 냈는지, 그 뒤에 어떤 세 가지 결함이 숨어 있었는지, 반환 값을 믿지 않고 임포트를 직접 확인하는 방법을 다룹니다
시나리오는 평범합니다. 리뷰어가 계약서에 마크업을 하고, 코멘트는 FDF 파일로 이동하고(Acrobat에서는 Export Comments라고 부름), Delphi 서비스가 ImportAnnotationsFromFDF로 그것을 깨끗한 사본에 병합합니다. 호출은 7을 돌려 주고, 로그는 "코멘트 7개 임포트됨"이라 말하고, 작업은 녹색이 되는데, 출력 PDF에는 코멘트가 하나도 없습니다. 아무것도 raise되지 않고, 아무것도 경고하지 않으며, 그 숫자는 그럴듯해 보였습니다. 파일 안 엔트리의 실제 개수였으니까요. 버그가 가질 수 있는 최악의 모양이 바로 이것입니다. 성공 신호가 자기가 보고한다고 주장하는 작업과 무관하게 계산되는 카운터뿐인 함수
ImportAnnotationsFromFDFString은 성공을 보고하면서 왜 아무것도 추가하지 않았을까?
임포터는 모든 /Subtype을 빈 문자열로 읽었고, 어노테이션을 만드는 헬퍼는 빈 subtype에서 일찍 빠지는데 호출자는 어차피 결과를 증가시켰습니다. 키 파인더는 /Subtype 바로 다음 위치, 그러니까 값 앞의 공백을 돌려 주었습니다. ReadName은 그 공백에서 시작해 첫 공백 문자에서 멈췄으므로 아무것도 읽기 전에 멈췄습니다. AddAnnotationToPage는 subtype이 없으면 어노테이션 만들기를 거부하는데, 그 자체로는 올바른 방어적 선택입니다만, 반환 값이 없는 procedure였고 Inc(Result)는 그 밖에 놓여 있었습니다. 각 가드는 따로 보면 다 합리적이었습니다. 합치면 "아무것도 안 됨"을 "전부 잘 됨"으로 바꾸었습니다. 수정은 ReadName이 공백을 건너뛰고, PDF name object의 선두 /를 요구하며, [, (, )를 포함한 어떤 구분자에서도 멈추게 합니다. 그래서 /Subtype/Text와 /Subtype /Text가 모두 Text를 산출합니다
그 수정 후에도 반환 값은 주의가 필요했습니다. v3.539.39까지 ImportAnnotationsFromFDFString은 /Annots 배열의 잘 만들어진 딕셔너리마다 결과를 증가시켰습니다. 0 기반 /Page가 범위를 벗어난 엔트리나 /Subtype이 없는 엔트리, 그 둘 다 건너뛰어지는 것들까지 세었죠. PDFlibPas v3.539.40부터 ImportAnnotationsFromFDFString과 ImportAnnotationsFromFDF는 XFDF 임포트처럼 실제 추가된 어노테이션 개수를 반환합니다. FDF 헬퍼 AddAnnotationToPage는 이제 Boolean을 반환하고 카운터는 성공할 때만 움직입니다. 문서를 직접 잴 것이 여전히 더 강한 검사입니다. 구버전에서도 성립하니까요. 아래 스케치는 임포트 전후로 각 페이지의 AnnotationCount를 비교합니다
function TotalAnnotations(Lib: TPDFlib): Integer;
var
Page, Saved: Integer;
begin
Result := 0;
Saved := Lib.SelectedPage;
for Page := 1 to Lib.PageCount do
if Lib.SelectPage(Page) = 1 then
Inc(Result, Lib.AnnotationCount); // 선택된 페이지마다, 위젯 포함
Lib.SelectPage(Saved);
end;
var
Lib: TPDFlib;
Before, Reported, Added: Integer;
begin
Lib := TPDFlib.Create;
try
Lib.LoadFromFile('contract.pdf', '');
Before := TotalAnnotations(Lib);
Reported := Lib.ImportAnnotationsFromFDF('review-comments.fdf');
Added := TotalAnnotations(Lib) - Before;
if Added <> Reported then // v3.539.40부터는 같음
Writeln(Format('Importer reported %d, %d landed on a page', [Reported, Added]));
Lib.SaveToFile('contract-reviewed.pdf');
finally
Lib.Free;
end;
end;
첫 번째 뒤에 숨어 있던 세 가지 결함
subtype만 고쳤다면 같은 함수 안의 버그가 셋 더 드러났을 것입니다. 어노테이션이 한 번도 페이지에 도달하지 않아서 보이지 않았을 뿐인 버그들입니다. 첫째, ReadNumber는 위치를 값 파라미터로 받았습니다. 네 개의 /Rect 숫자를 차례로 읽으면 같은 지점을 네 번 읽는 셈이고, 여는 [도 건너뛰지 않아서 실제로는 아무것도 읽지 못했습니다. 둘째, FindKey는 모든 조회가 앞으로만 가는 커서 하나를 공유했습니다. 익스포터는 /Subtype, /Rect, /Page, /Contents, /T, /Subj 순으로 쓰지만, 임포터는 /Subtype, /Contents, /T, /Subj, /Page, /Rect 순으로 검색했습니다. 커서가 /Contents를 지나는 순간 /Page와 /Rect 검색은 현재 엔트리를 지나쳐 아무것도 찾지 못하거나 다음 어노테이션의 키와 맞아 버렸습니다. 라이브러리가 자기 출력을 읽지 못했던 것입니다. 셋째, 숫자는 시스템 소수점 구분자를 따르는 PLStrToFloat을 통과했습니다. ISO 32000-1 §12.7.7은 FDF를 PDF object syntax로 정의하고 PDF의 딕셔너리 키는 순서가 없으므로(§7.3.7), 키 순서를 가정하는 FDF 파서는 파일을 만든 도구가 어느 쪽이든 구조적으로 틀립니다
고쳐진 임포터는 먼저 각 엔트리의 경계를 잡습니다. FindDictEnd는 여는 <<에서 짝이 되는 >>까지 걸으며, 중첩 딕셔너리를 추적하고 백슬래시 이스케이프가 들어간 literal string 본문을 건너뛰므로, (see section >> 4) 같은 코멘트 안의 >>가 엔트리를 일찍 끝내지 못합니다. 모든 키 조회는 엔트리 자체의 시작에서 시작해 그 끝에 제한되므로 키 순서는 무의해지고, 한 어노테이션이 다른 어노테이션의 /Page를 빌리는 일도 막힙니다. 키 매치는 이름 바로 뒤의 구분자도 받아들입니다. /Contents(Hi)가 /Contents (Hi)만큼 유효하기 때문입니다. 한편 단어 경계 규칙은 /Subj가 /Subtype의 시작과, /T가 /Type과 맞아 버리는 것을 막습니다. ReadNumber는 이제 위치를 var 파라미터로 받고, 공백과 [를 건너뛰며, 형태가 잘못된 토큰에서 raise 대신 부드럽게 실패하는 PLTryStrToFloatInvariant로 파싱합니다. 네 개의 사각형 숫자 중 하나라도 실패하면 반쯤 읽은 사각형을 만드는 대신 네 개 모두 0으로 폴백합니다
FDF 왕복마다 어노테이션이 자기 높이만큼 밀린 이유는?
옛 익스포터는 사각형을 잘못된 좌표 모델로 기록했습니다. 어노테이션의 /Rect는 디폴트 유저 공간의 [llx lly urx ury]입니다(ISO 32000-1 §12.5.2, 사각형은 §7.9.5에 정의). FDF도 같은 배열을 실어 다닙니다. 그런데 ExportAnnotationsToFDFString은 GetAnnotRectEx를 호출했습니다. 이것은 SetOrigin이 제어하는, 라이브러리 드로잉 좌표의 Left, Top, Width, Height를 보고하고 그것을 [L T L+W T+H]로 직렬화합니다. 임포터는 동작하게 되자 그 네 값을 그대로 PDF 사각형으로 되적었으니, 윗변은 왼쪽 아래 코너가 있어야 할 자리에 놓이고 왕복마다 어노테이션이 자기 높이만큼 위로 밀렸습니다. 익스포터는 이제 어노테이션 자체의 /Rect 숫자를 복사합니다. 소수 셋째 자리, 점 구분자, 지수 없음이며, 저장된 배열이 없거나 네 개의 숫자가 아닐 때만 계산된 사각형으로 폴백합니다
이 문제를 못 박는 회귀 테스트는 베낄 가치가 있습니다. 임포터의 반환 값이 아니라 문서와 두 번째 내보내기에 어설트하기 때문입니다. 기대 개수가 2인 점에 주목하세요. AddNoteAnnotation은 Text 어노테이션과 그 Popup을 만들고 둘 다 이동합니다. 테스트는 내보내기와 임포트를 콤마 소수 구분자 아래에서 실행하는데, 이 이야기의 나머지 절반이 바로 그 자리에 있습니다
var
Source, Target: TPDFlib;
FDF: AnsiString;
OldSep: Char;
begin
Source := TPDFlib.Create;
Target := TPDFlib.Create;
try
Source.NewPages(1); // 이제 두 페이지
Source.SelectPage(2);
Source.AddNoteAnnotation(50.5, 60.25, 0, 80, 80, 120, 60,
'Reviewer', 'Check this', 0.25, 0.5, 0.75, 0);
Target.NewPages(1);
OldSep := FormatSettings.DecimalSeparator;
FormatSettings.DecimalSeparator := ','; // 독일어나 프랑스어 데스크톱 흉내
try
FDF := Source.ExportAnnotationsToFDFString; // 여전히 /Rect [50.5 ...를 씀
Target.ImportAnnotationsFromFDFString(FDF);
finally
FormatSettings.DecimalSeparator := OldSep;
end;
Target.SelectPage(2);
Assert(Target.AnnotationCount = 2); // 노트와 그 팝업
Assert(Target.GetAnnotType(1) = 'Text');
Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
finally
Target.Free;
Source.Free;
end;
end;
FDF 경로가 무엇을 실어 다니는지는 분명히 해 두죠. 임포터는 각 엔트리를 /Type, /Subtype, /Rect, /Contents, /T, /Subj를 담은 딕셔너리로 다시 만듭니다. 색, 플래그, 테두리 스타일, 팝업 링크, appearance stream은 이 경로에 속하지 않고, 익스포터는 폼 필드가 form-data 메서드에 속하므로 Widget 어노테이션은 건너뜁니다. 어떤 데이터가 어떤 메서드로 이동하는지 더 넓은 지도는 FDF, XFDF, XFA 폼 데이터 교환 개요에 있고, 실제로 도착한 것을 들여다봐야 한다면 GetAnnotType, GetAnnotTitle, GetAnnotContentsEx 같은 인덱스별 리더가 아웃라인, 어노테이션, 액션 인트로스펙션에서 다뤄집니다
구버전 내보내기의 콤마 소수 FDF와 XFDF 파일은 어떻게 읽을까?
FDF에 대해서는 답이 분명합니다. 콤마는 PDF 문법에서 구분자가 아니므로, 콤마 하나만 닷 없이 담은 숫자 토큰은 콤마 로캘 기계에서 쓰인 소수일 수밖에 없습니다. 구버전은 실제로 그런 파일을 썼습니다. 예컨대 /Rect [10,500 20,250 40,750 60,125] 같은 파일이죠. 새 ReadNumber는 파싱 전에 그 단일 콤마를 점으로 바꿉니다. 콤마 두 개, 또는 콤마와 점을 함께 담은 토큰은 추측하는 대신 거부됩니다. 리더는 지수 표기도 소비하지 않는데, 이는 ISO 32000-1 §7.3.3과 일치합니다. PDF 숫자는 지수 표기를 쓰지 않으니까요
XFDF는 더 까다롭습니다. XML 어트리뷰트에서는 콤마가 구분자니까요. 표준 XFDF(ISO 19444-1)는 rect="50.5,80.25,70.75,100.125"와 dashes="4,2"로 쓰는 반면, v3.539.28 이하는 콤마 로캘 시스템에서 rect="50,500 80,250 70,750 100,125"와 opacity="0,600"으로 썼고, 표준 opacity="0.6"을 읽을 때 EConvertError로 실패하기도 했습니다. v3.539.29부터 양방향 모두 invariant이고, 레거시 모양은 어트리뷰트가 공백으로 쪼개질 때 정확히 기대한 토큰 수(rect는 넷, opacity와 width는 하나)에 토큰마다 숫자-콤마-숫자 형태일 때만 XFDFNormalizeLegacyDecimals가 알아 봅니다. 표준 rect는 절대 매치되지 않습니다. 콤마 셋을 담은 토큰 하나이거나 콤마로 끝나는 토큰들이니까요. dashes는 의도적으로 그대로 둡니다. 4,2는 대시 길이 둘일 수도 있고 레거시 4.2일 수도 있는데, 둘을 가려 낼 규칙이 없습니다
const
// 익스포터 순서가 아닌 키, 옛 콤마 로캘 내보내기의 콤마 소수
LegacyFDF: AnsiString = '%FDF-1.2'#10'1 0 obj'#10'<< /FDF << /Annots ['#10 +
'<< /Rect [10,500 20,250 40,750 60,125] /Page 0 /Contents (First) ' +
'/Subtype /Text /T (Alpha) /Type /Annot >>'#10 +
'] >> >>'#10'endobj'#10'trailer'#10'<< /Root 1 0 R >>'#10'%%EOF'#10;
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create; // 새 문서에는 한 페이지
try
Lib.ImportAnnotationsFromFDFString(LegacyFDF);
Assert(Lib.AnnotationCount = 1);
Assert(Lib.GetAnnotTitle(1) = 'Alpha');
// 점 소수 XFDF로 재출력: rect="10.500 20.250 40.750 60.125"
Writeln(Lib.ExportAnnotationsToXFDFString);
finally
Lib.Free;
end;
end;
어노테이션 임포트 테스트는 실제로 무엇을 어설트해야 할까?
유용한 임포트 테스트는 대상 문서의 상태에 어설트하고, 임포터가 자기 자신에 대해 말하는 것만 검사해서는 안 됩니다. 테스트 스위트 어디도 FDF 임포트 뒤의 AnnotationCount를 검사하지 않았고, 모두가 유일하게 본 숫자인 반환 값이 버그가 건드리지 않은 바로 그 하나였습니다. 여기서 기술한 모든 결함을 잡았을 어설션 셋이 있습니다. 기대한 페이지의 어노테이션 개수, GetAnnotType이나 GetAnnotContentsEx로 되읽은 필드 하나, 첫 내보내기와 바이트 단위로 비교한 두 번째 내보내기입니다. 같은 규율이 문서 구조를 대량으로 다시 쓰는 모든 API에 적용됩니다. 중복 폼 필드 병합에서 기술한 필드 통합도 포함입니다. 검사할 것은 결과 트리이지 반환된 합계가 아닙니다. 파일과 문자열 변형을 갖춘 FDF와 XFDF 어노테이션 메서드는 losLab PDF Library for Delphi and C++Builder에 실려 나갑니다. 코멘트가 이동을 살아남아야 하면 v3.539.30 이상을, 반환 개수가 실제 추가된 것과 일치해야 하면 v3.539.40 이상을 쓰세요