v3.121.1 이전 PDFium Component에서는 TPdf.Annotation[]으로 주석을 읽고 레코드를 다시 대입하면 원본이 /N만 갖고 있었는데도 /AP appearance 사전에 빈 /R과 /D 항목이 추가될 수 있었습니다. PDF/A 검증기는 그 사전을 거부합니다. v3.121.1부터 게터는 실제로 읽은 appearance만 보고하므로, 바뀌지 않은 왕복 전송은 새로 쓰는 것이 없습니다. 이 실패는 자세히 이해할 가치가 있습니다. 보통의 트리거는 파일을 더 준수하게 만들려던 수정이지 덜 준수하게 만들려던 게 아니기 때문입니다
주석을 그대로 다시 쓰면 무엇이 잘못될까요?
짧은 답은 이렇습니다. 주석이 원래 없던 appearance 스트림을 얻게 되고, 편집 전에 PDF/A 검증을 통과하던 파일이 편집 후에 떨어집니다. 전형적인 시나리오는 이렇게 흘러갑니다. 고객 아카이브에 Print 플래그가 없는 square와 text 주석이 도착하고, PDF/A는 모든 주석이 인쇄되길 요구하니 페이지를 순회하며 afPrint를 더하고 각 레코드를 다시 대입합니다. 그 코드는 appearance를 건드리는 곳이 없습니다. TPdf.Annotation[]의 레코드는 TPdfAnnotation이고, SetAnnotationData는 Has* 센티널이 set된 모든 필드를 씁니다. HasContents / ContentsText 쌍이 원래 그렇게 동작하도록 설계됐죠. 문제는 게터가 존재하지 않는 모드에 대해 빈 문자열과 함께 HasAppearanceRollover와 HasAppearanceDown을 True로 set했다는 것이고, 세터는 성실히 빈 스트림 두 개를 썼습니다:
procedure MarkAnnotationsPrintable(const FileName: string);
var
Pdf: TPdf;
PageNo, I: Integer;
A: TPdfAnnotation;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Active := True;
for PageNo := 1 to Pdf.PageCount do
begin
Pdf.PageNumber := PageNo;
for I := 0 to Pdf.AnnotationCount - 1 do
begin
A := Pdf.Annotation[I];
if not (afPrint in A.Flags) then
begin
A.Flags := A.Flags + [afPrint] - [afHidden, afInvisible, afNoView];
// v3.121.1 이전에는 이 대입이 원본 주석이 /AP/N만 갖고 있을 때
// 빈 /AP/R과 /AP/D 스트림도 함께 썼습니다
Pdf.Annotation[I] := A;
end;
end;
end;
Pdf.SaveAs(ChangeFileExt(FileName, '.printable.pdf'));
finally
Pdf.Free;
end;
end;
ISO 32000-1 §12.5.5는 appearance 사전을 세 항목으로 정의합니다. 정상 appearance의 /N, rollover의 /R, down의 /D입니다. /R과 /D는 선택이고, 없으면 뷰어는 /N으로 폴백합니다. 하지만 빈 /R 스트림은 부재가 아닙니다. 아무것도 그리지 않는 유효한 스트림이므로, rollover appearance를 존중하는 뷰어에서는 포인터가 주석 위로 지나가는 순간 빈 사각형이 보입니다. PDF/A는 더 엄격합니다. ISO 19005-1(Corrigendum 2 포함)과 ISO 19005-2 / 19005-3은 주석 appearance 사전에서 /N만 허용합니다. veraPDF는 PDF/A-1에 대해 규칙 6.5.3-4로, PDF/A-2와 PDF/A-3에 대해 규칙 6.3.3-2로 왕복된 파일을 보고하고, 내장 TPdf.ValidatePdfA는 이를 pvaiAnnotationApDictViolation으로 나열합니다. 표준의 한 조항을 만족시키려던 편집이 다른 조항을 깨뜨렸습니다
FPDFAnnot_GetAP는 없는 appearance에 왜 2를 반환할까요?
PDFium은 요청한 appearance 스트림이 존재하지 않을 때조차 FPDFAnnot_GetAP에서 0을 반환하지 않습니다. 이 함수는 평범한 PDFium two-call 패턴을 따릅니다. nil 버퍼를 넘겨 필요한 바이트 크기를 받고, 할당한 뒤, 다시 호출해 UTF-16LE 텍스트를 복사합니다. 크기에는 언제나 UTF-16 종결자가 포함되므로, 없는 스트림은 빈 문자열과 종결자, 즉 2바이트로 보고됩니다. v3.121.1 이전 게터는 ByteLength >= SizeOf(FPDF_WCHAR)를 검사했는데 모든 호출이 통과하는 검사라서, 어떤 appearance든 가진 주석이면 세 HasAppearance* 플래그가 모두 True로 돌아왔습니다. 레코드 왕복은 이어서 각 모드에 빈 문자열을 저장하라고 FPDFAnnot_SetAP에 요청했고, PDFium은 그걸 담을 스트림을 만들었습니다. 예외도 경고도 없었고 보이는 페이지는 동일했으니, 결함이 뷰어가 아니라 veraPDF 피쳐에서 드러난 이유입니다
v3.121.1이 appearance의 존재를 판정하는 방법
GetPageAnnotation 안에서 AppearanceNormal, AppearanceRollover, AppearanceDown을 채우는 헬퍼 ReadAppearance는 이제 종결자 너머로 최소한 문자 하나를 실은 결과만 내용으로 취급합니다. 첫 호출은 SizeOf(FPDF_WCHAR)보다 많은 바이트와 짝수 바이트 개수를 반환해야 합니다. 홀수 길이는 UTF-16일 수 없기 때문입니다. 실제로 텍스트를 복사하는 두 번째 호출도 다시 검증됩니다. 반환된 길이가 2 이하이거나 할당한 버퍼보다 크면 HasValue를 False로 되돌리고 문자열은 빈 채로 둡니다. 쓰기 쪽은 바뀐 것이 없습니다. SetAnnotationData는 여전히 HasAppearance* 플래그가 True인 모드에만 FPDFAnnot_SetAP를 호출하므로, /N만 있는 주석에서 읽은 레코드는 이제 /N만 다시 씁니다. 회귀 피쳐는 양방향을 커버합니다. 정상 appearance를 가진 square 주석을 그대로 읽고 다시 쓰면 PDF/A-1b, PDF/A-2b, PDF/A-3b를 통과하고, 같은 주석에서 Print 플래그를 떼면 기대한 플래그 규칙에서만 떨어집니다
없는 스트림과 빈 스트림은 똑같아 보이므로 게터는 보수적으로 유지됩니다
네이티브 API는 존재하지 않는 appearance 스트림과 존재하지만 빈 스트림을 구별하지 못하고, PDFium Component는 그렇다고 거짓말하지 않습니다. 두 경우 모두 FPDFAnnot_GetAP에서 같은 2바이트를 반환하므로 둘 다 HasAppearanceRollover = False와 빈 AppearanceRollover로 읽힙니다. 설계할 때 감안해야 할 결과가 둘 있습니다. 첫째, False 센티널은 "읽은 내용이 없으므로 다시 쓰면 이 모드는 그대로 둔다"이지 "사전에 /R 키가 없다"가 아닙니다. 둘째, 레코드는 이미 파일 안에 있는 빈 스트림을 탐지하지 못합니다. 오래된 빌드나 다른 도구로 손상된 문서는 멀쩡하게 읽히고, 레코드를 다시 대입해도 고치지도 악화시키지도 않습니다. 그런 파일을 찾으려면 바이트 수준 검사가 필요하고, TPdf.ValidatePdfA와 PDFium Component로 PDF/A 프리플라이트 검증 워크플로가 정확히 그 일을 합니다
appearance를 의도적으로 지우려면 어떻게 할까요?
센티널을 명시적으로 set하고 빈 문자열을 넘기면 세터가 그대로 씁니다. 이 버그의 둔탁한 수정으로 SetAnnotationData에서 빈 문자열을 막는 방법도 있었겠지만, 그러면 의도적으로 appearance를 지우는 호출자가 깨지고, 텍스트에 대해 HasContents와 HasAuthor가 따르는 것과 같은 계약도 깨집니다. 그래서 수정은 전적으로 게터에 살고, 세터는 호출자가 원하는 것을 계속 존중합니다:
// rollover appearance를 교체한 다음 다시 지웁니다
A := Pdf.Annotation[0];
A.HasAppearanceRollover := True;
A.AppearanceRollover := 'q Q';
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// A.HasAppearanceRollover는 True이고 텍스트는 'q Q'로 왕복됩니다
A.HasAppearanceRollover := True; // 의도를 명시적으로 재확인
A.AppearanceRollover := ''; // 의도적으로 빈 스트림을 씁니다
Pdf.Annotation[0] := A;
A := Pdf.Annotation[0];
// HasAppearanceRollover = False와 빈 문자열로 읽힙니다:
// 여기서 빈 스트림과 없는 스트림은 구별되지 않습니다
명시적으로 비운 /R이나 /D도 위에서 인용한 PDF/A 규칙 아래에서는 여전히 추가 키로 셉니다. 목표가 아카이브 프로파일이라면, 비어 있지 않은 /N을 쓰고 나머지 두 모드를 건드리지 않는 모양만이 검증을 통과합니다. PDFium Component로 XFDF 내보내기와 가져오기 같은 주석을 문서 간에 옮기는 워크플로도 같은 규칙을 따라야 합니다. 원본이 실제로 갖던 모드만 복사하고 나머지 센티널은 False로 두세요
PDF/A를 안전하게 지키는 읽기-수정-쓰기 패턴
v3.121.1 이상으로 업그레이드하고, appearance 센티널은 게터가 돌려준 그대로 두고, 출하 전에 저장된 파일을 검증하세요. 오래된 빈 스트림은 부재로 읽히므로, 검증 단계는 레코드가 아니라 직렬화된 문서를 봐야 하고, 배치마다 돌리기에 충분히 싸습니다
uses
PDFium, FPdfPdfa; // FPdfPdfa는 TPdfAValidationIssue를 선언합니다
function AnnotationAppearancesAreClean(Pdf: TPdf): Boolean;
var
Report: TPdfAValidationResult;
begin
// Pdf에 현재 로드된 문서를 검증합니다. 열린 이후
// Pdf.Annotation[]으로 이루어진 편집도 포함됩니다
Report := Pdf.ValidatePdfA;
Result := not (pvaiAnnotationApDictViolation in Report.Issues);
end;
검토를 위해 페이지에 색을 입히거나 주석을 다는 패널에도 같은 규율이 적용됩니다. PDFium Component로 Delphi 주석 검토 워크플로 만들기에서 다루는 워크플로입니다. 레코드는 엔진이 읽을 수 있었던 것의 스냅샷이고, 직접 set하지 않은 센티널은 그대로 돌려보내야 합니다. 전체 주석 API, PDF/A 프리플라이트와 네이티브 PDFium 엔진은 Delphi, C++Builder, Lazarus용 PDFium Component에 함께 실려 나옵니다