기술 문서

Delphi에서 Single이 AcroForm 필드 플래그를 망치는 이유

HotPDF Delphi Component는 모든 PDF 숫자를 THPDFNumericObject.Value에 저장하는데, 이것은 Single로 선언되어 있고 Single은 모든 32비트 정수를 담지 못합니다. AcroForm 필드 플래그(/Ff)는 32비트 마스크라서 상위 비트가 켜진 마스크는 적재 시 반올림됐습니다. $8000FFFF가 $80010000으로 돌아오는 식이었습니다. v2.989.5부터 필드 플래그 읽기는 원래 정수 토큰을 정확히 파싱해 그 비트를 기존 Integer API로 복사합니다

이 버그는 PDF 폼을 전혀 다루지 않더라도 자세히 볼 가치가 있습니다. 아무것도 크래시하지 않았고 예외도 던져지지 않았으며 대부분의 테스트 파일은 통과했습니다. 실제 마스크는 대부분 작기 때문입니다. 실패는 상위 비트와 흔한 하위 플래그가 함께 있을 때만 나타났고, 그때도 비트 하나를 잃은 게 아니라 여러 비트를 한꺼번에 다시 썼습니다. 비트 마스크를 부동소수점 타입에 담는 것은 클래식한 Delphi 함정이며, 이것이 프로덕션 코드에서 생긴 모습입니다

Single은 왜 AcroForm 필드 플래그 비트를 잃을까

Delphi Single은 24비트 가수를 갖습니다. 2^24(16777216)까지의 정수는 정확히 표현하고 그 너머는 빈틈 없이는 불가능합니다. 2^31과 2^32 사이에서 연속하는 Single 값들은 256씩 떨어져 있습니다. 비트 32가 켜진 필드 플래그 마스크는 정확히 그 범위에 살므로, 하위 8비트, 종종 그 이상이 256의 배수로 반올림됩니다

Delphi용 HotPDF AcroForm 필드 플래그 반올림 다이어그램: 32비트 /Ff 마스크 $8000FFFF, 즉 2147549183이 24비트 가수의 Single을 통과해 $80010000으로 돌아옵니다. 표현 가능한 Single 값이 2^31과 2^32 사이에서 256씩 떨어져 있기 때문입니다
2^31 위에서 Delphi Single은 256의 배수만 저장할 수 있으므로, 반올림 처리가 32비트 필드 플래그 마스크의 하위 바이트를 예외 하나 없이 다시 씁니다

ISO 32000-1 §7.3.3은 정수 객체와 실수 객체를 별개의 숫자 종류로 정의하고 부록 C는 각각 다른 구현 한계를 나열하므로, PDF 정수가 32비트 float에 맞을 의무는 없습니다. 필드 플래그 항목은 표 221에서 정수로 정의되며 양으로 읽는 게 아니라 비트별로 읽습니다. 양을 아주 작게 반올림하는 건 무해하지만, 마스크를 반올림하면 이웃 비트로 자리넘김이 번집니다. 전체 효과는 몇 줄의 순수 Delphi로 재현할 수 있습니다:

var
  S: Single;
  Mask: Int64;
begin
  S := $8000FFFF;              // 2147549183, Single로는 표현 불가
  Mask := Round(S);            // 2147549184
  Writeln(IntToHex(Mask, 8));  // 80010000: 비트 1-16 지워지고 비트 17 켜짐

  S := $80003FFF;
  Mask := Round(S);
  Writeln(IntToHex(Mask, 8));  // 80004000: 비트 1-14 지워지고 비트 15 켜짐
end;

교훈은 PDF 너머로 일반화됩니다. Single, Double, float를 담는 Variant, float로 디코딩된 JSON 숫자는 비트 필드의 그릇이 아닙니다. 테스트에 쓴 값이 우연히 작았더라도 마찬가지입니다. Double은 정확 범위를 2^53까지 밀어 32비트 마스크에서는 문제를 숨기지만, 타입이 여전히 잘못된 의도를 표현하는 것은 남습니다. Cardinal, Integer, Int64를 쓰고 이미 반올림된 float가 아니라 원본 텍스트에서 변환하십시오

어떤 필드 플래그가 뒤바뀌나: Radio, Pushbutton, Password, Multiline

위의 두 마스크는 아무 데서나 가져온 게 아닙니다. 반올림 한 번이 리더가 보는 필드 타입을 바꾸는 과정을 보여 줍니다. PDF는 플래그 비트를 1부터 세므로 비트 n의 값은 2^(n-1)입니다. ISO 32000-1 §12.7.3.1 표 226이 모든 필드의 비트 1에서 3을 정의하고, 표 228이 버튼 플래그를, 표 230이 텍스트 필드 플래그를 정의합니다

  • $8000FFFF에서 $80010000으로: 비트 16(Radio, $8000, 표 228)이 지워지고 비트 17(Pushbutton, $10000)이 켜져서 라디오 버튼이 푸시 버튼으로 변합니다
  • 같은 반올림이 비트 1에서 3까지 지우므로, 표 226의 ReadOnly, Required, NoExport가 Radio와 함께 사라집니다
  • $80003FFF에서 $80004000으로: 표 230의 비트 13(Multiline, $1000)과 비트 14(Password, $2000)가 모두 지워져, 가려진 비밀번호 상자가 한 줄 평문 텍스트 필드가 됩니다
  • 비트 32 아래에서는 손상이 2^24부터 시작됩니다. 16777219(Comb에 ReadOnly와 Required 더한 값)가 16777220으로 반올림되어 ReadOnly와 Required를 잃고 NoExport가 더해집니다

마지막 사례는 폼 편집을 넘어 HotPDF에도 중요했습니다. ExportLoadedPagesStructured는 유효 마스크에서 JSON과 XML 폼 모델을 만들고 값 억제 판단에 NoExport를 쓰므로, 반올림된 마스크는 평범한 값을 숨기거나 잘못된 readOnly, required 멤버를 보고할 수 있었습니다. 구조화 내보내기 경로도 v2.989.8에서 같은 정확 읽기를 받았습니다

Delphi용 HotPDF AcroForm 필드 플래그 비트맵: 비트 32와 비트 1에서 16이 켜진 /Ff 마스크 $8000FFFF는 Single 반올림 한 번에 비트 1에서 16을 잃고 Pushbutton 비트 17을 얻어 $80010000이 됨
ISO 32000-1 표 226, 228, 230의 플래그는 무리 지어 움직입니다. Radio가 Pushbutton으로 뒤집히는 동안 ReadOnly, Required, NoExport, Multiline, Password가 같은 반올림 단계에서 사라질 수 있습니다

HotPDF가 정확한 32비트 정수 토큰을 읽는 방법

HotPDF Delphi Component는 숫자 객체 모델을 바꾸지 않고 읽는 쪽을 고칩니다. 파서는 이미 Single 값 옆에 원본 토큰 텍스트를 기억하므로, 필드 플래그 리더는 그 토큰을 정수로 파싱하고 한도 있는 Int64에 누적한 뒤 결과 32비트를 공개 API가 반환하는 Integer로 복사합니다. 범용 숫자 API와 옛 비정수 폴백은 그대로이고, 필드 플래그 소비자만 정확 경로를 탑니다

Delphi에서 HotPDF의 정확한 AcroForm 필드 플래그 읽기 경로: 파서가 Single 값 옆에 원본 토큰을 보관하고, TryParseFlagMask가 한도 있는 Int64에 누적하며, 32비트가 Integer로 GetLoadedFormFieldFlags와 SetLoadedFormFieldFlags에 도달
필드 플래그는 반올림된 Single 대신 기억된 토큰 텍스트를 파싱하므로, 상위 비트 마스크의 두 표기법인 2147549183과 -2147418113이 동일한 비트로 도착합니다

파서는 실제 파일이 상위 비트 마스크를 표기하는 두 가지 방식을 모두 받아들여야 합니다. 어떤 작성자는 부록 C의 2^31 - 1 정수 한계를 넘지만 실무에서 흔한 부호 없는 값 2147549183을 내보내고, 다른 작성자는 부호 있는 값 -2147418113을 내보냅니다. 둘은 같은 비트를 기술합니다. 한도 있는 파서의 버전은 다음과 같습니다:

function TryParseFlagMask(const Token: AnsiString; out Mask: Cardinal): Boolean;
var
  P, Digit: Integer;
  Negative: Boolean;
  Number, Limit: Int64;
begin
  Result := False;
  Mask := 0;
  if Token = '' then Exit;
  P := 1;
  Negative := Token[1] = '-';
  if Token[1] in ['+', '-'] then Inc(P);
  if P > Length(Token) then Exit;
  // 부호 있는 토큰은 -2^31까지, 부호 없는 토큰은 2^32 - 1까지
  if Negative then Limit := 2147483648 else Limit := 4294967295;
  Number := 0;
  while P <= Length(Token) do
  begin
    if not (Token[P] in ['0'..'9']) then Exit;  // 실수는 옛 폴백으로
    Digit := Ord(Token[P]) - Ord('0');
    if Number > (Limit - Digit) div 10 then Exit;  // 한도 검사, 오버플로 없음
    Number := Number * 10 + Digit;
    Inc(P);
  end;
  if Negative and (Number <> 0) then Number := 4294967296 - Number;
  Mask := Cardinal(Number);
  Result := True;
end;

두 디테일은 의도적인 것입니다. 한도는 곱셈 전에 검사하므로 400자리 토큰은 Int64를 오버플로하는 대신 깔끔하게 실패합니다. 그리고 3.0 같은 토큰은 거부되어 호출자를 Round(Value)로 되돌립니다. 정확 경로는 정수 마스크를 개선하되 형태가 잘못된 것에 새 의미를 만들지는 않습니다

애플리케이션 코드에서는 시그니처가 아무것도 바뀌지 않습니다. THotPDF.GetLoadedFormFieldFlags(FieldIndex: Integer): Integer는 유효 마스크를 반환하며 /Parent 체인을 HotPDF가 상속된 AcroForm 필드 값을 해석하고 리셋하는 방법에서 설명한 것과 같은 방식으로 따라 올라가고, THotPDF.SetLoadedFormFieldFlags(FieldIndex, AFlags: Integer)는 완전한 로컬 마스크를 씁니다. 비트 32가 켜진 마스크는 그냥 음수 Integer이므로 값을 변환하지 말고 비트를 옮기십시오:

const
  FF_RADIO      = $00008000;  // 비트 16, ISO 32000-1 표 228
  FF_PUSHBUTTON = $00010000;  // 비트 17, 표 228
var
  Pdf: THotPDF;
  I, Native: Integer;
  Bits: Cardinal;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('survey.pdf') <= 0 then Exit;
    for I := 0 to Pdf.GetFormFieldCount - 1 do
    begin
      Native := Pdf.GetLoadedFormFieldFlags(I);
      Move(Native, Bits, SizeOf(Bits));   // 같은 32비트, 변환 없음
      if (Bits and FF_RADIO) <> 0 then
        Writeln(Pdf.GetFormFieldName(I), ': radio, mask $', IntToHex(Bits, 8));
    end;

    Bits := $80000002;                    // 상위 비트에 Required
    Move(Bits, Native, SizeOf(Native));   // Native = -2147483646
    Pdf.SetLoadedFormFieldFlags(0, Native);
    Pdf.SaveLoadedDocument('survey-out.pdf');
  finally
    Pdf.Free;
  end;
end;

상속된 마스크를 읽는 것은 자식 딕셔너리를 그대로 두고, 쓰는 것은 선택된 필드에 로컬 항목으로 마스크 전체를 교체합니다. SetLoadedFormFieldReadOnly와 SetLoadedFormFieldRequired 단축 메서드는 유효 상속 마스크에서 출발해 한 비트만 토글하고 다른 모든 비트를 정확히 유지합니다. 플래그가 아니라 값을 채우는 것은 HotPDF로 적재된 PDF의 폼 필드 값 설정하기를 보십시오

Value와 RememberSourceToken의 순서가 왜 중요할까

순서가 중요한 이유는 THPDFNumericObject가 Single 값이 변하지 않은 동안에만 기억된 토큰을 신뢰하기 때문입니다. RememberSourceToken은 토큰을 현재 Value의 스냅샷과 함께 저장하고, SourceToken 게터는 Value가 스냅샷과 달라지는 순간 빈 문자열을 반환합니다. 이 무효화 규칙이 편집된 숫자가 낡은 원본 텍스트와 함께 저장되는 것을 막습니다

이 규칙은 쓰는 쪽에서 함정으로 변합니다. 토큰을 먼저 기억하고 값을 나중에 대입하면, 스냅샷은 옛 값을 들고 있고 게터는 토큰이 없다고 보고하며, 직렬화기는 반올림된 Single 포매팅으로 폴백합니다. 단축 메서드와 네이티브 필드 생성자를 포함해 라이브러리 안의 모든 플래그 라이터는 같은 순서를 따릅니다:

// 올바름: 스냅샷이 Value와 일치하므로 정확한 토큰이 저장까지 살아남습니다
Item := THPDFNumericObject.Create(nil);
Item.Value := AFlags;
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));

// 잘못됨: 스냅샷이 Value가 바뀌기 전에 찍히므로 SourceToken이
// 무효화되고 반올림된 Single이 대신 기록됩니다
Item := THPDFNumericObject.Create(nil);
Item.RememberSourceToken(AnsiString(IntToStr(AFlags)));
Item.Value := AFlags;

SaveNumericObject는 유효한 토큰이 있으면 언제나 기억된 토큰을 쓰므로, 순서를 맞추는 것이 저장 후 다시 연 파일이 반올림된 근사치가 아니라 원래 정수를 갖게 만드는 열쇠입니다

두 필드가 하나의 이름을 공유하게 만든 null 토큰

같은 릴리스는 이 마스크들을 테스트하다 발견한 두 번째 파서 결함도 고쳤습니다. 스트림에서 읽은 딕셔너리의 null 값이 소비되지 않는 문제였습니다. HotPDF는 다음 토큰을 분류하려고 peek하는데, 이 검사는 스트림 위치를 토큰 시작에 둡니다. 스트리밍 딕셔너리 리더의 otNull 분기는 null 객체를 만들었지만 null의 4바이트를 지나 진행하지 않았습니다

결과는 멈춤이 아니라 조용한 오독이었습니다. 다음 키 조회가 잘못된 위치에서 앞으로 훑다가 현재 딕셔너리의 끝을 지나 다음 간접 객체에서 키를 읽었고, 그 키가 현재 필드를 덮어썼습니다. 필드 목록에서의 증상은 서로 다른 두 필드가 같은 이름과 같은 값을 보고하는 것이었습니다. 배열과 span 파서는 이미 null을 올바르게 소비하고 있었으므로, 수정은 일반 파서를 다시 쓰는 대신 그 한 분기에서 스트림을 4바이트 진행시키는 것이었습니다. 움직이지 않는 커서는 잘못된 입력에서 HotPDF를 멈추게 한 PDF 파서 커서 정지와 같은 부류의 버그이며, 증상만 달랐습니다

수정은 상속도 분명히 했습니다. 로컬 /Ff null이나 null로 해석되는 참조는 없는 것으로 취급되어 조회가 부모로 계속되고, 명시적인 0은 부모를 덮는 진짜 마스크입니다

{$R-} 지시자가 증명하는 것과 증명하지 않는 것

플래그 버그의 첫 가설은 큰 마스크가 Integer에 대입될 때의 범위 검사 오류였습니다. 틀렸고, 그 이유는 알아 둘 가치가 있는 Delphi 디테일입니다. HPDFDoc.pas의 ParseSimpleDictionary 앞에 놓인 로컬 {$R-} 지시자에는 짝이 되는 복원이 없어서, 그 뒤의 유닛 내 모든 함수가 범위 검사를 끈 채 컴파일됐습니다. Delphi는 함수 끝에서 스위치 지시자를 리셋하지 않고, include 파일은 include된 시점의 지시자 상태를 물려받습니다

따라서 커맨드라인의 -$R+나 -Cr는 유닛의 초기 설정만 증명합니다. 아래쪽에 있는 특정 함수에 대해서는 아무 말도 하지 않습니다. 주어진 루틴에 범위 검사가 활성인지 알려면 바로 위의 가장 가까운 지시자를 찾은 뒤 실제 경계 값으로 동작을 확인하십시오. 오래된 두 로컬 플래그 소비자는 가설이 예측한 범위 예외를 한 번도 던지지 않았으므로, 그 부재했던 예외는 수정의 증거로 인용될 수 없었습니다. 진짜 결함은 알려진 플래그 비트들에 걸친 Single 반올림이었습니다

정확 토큰 읽기는 자기 범위에 대해서도 정직합니다. 유닛 전역 지시자 상태를 바꾸지 않고, 예약 비트에 의미를 부여하지 않으며, 모든 비트 조합이 유효한 필드라고 주장하지 않습니다. 보장하는 것은 파일 안의 32비트가 코드가 보는 32비트라는 것입니다

FAQ

THPDFNumericObject.Value를 Double이나 Int64로 바꾸지 않은 이유는요?

공개 필드의 타입을 바꾸면 그것을 읽거나 대입하는 모든 호출자가 깨지고, PDF 숫자는 정수인 만큼 실수이기도 합니다. Single을 유지한 채 필드 플래그를 원본 토큰에서 읽는 방식은 기존 선언이나 호출 규약을 하나도 바꾸지 않고 비트 마스크의 버그를 고칩니다

SetLoadedFormFieldFlags는 비트 32가 켜진 마스크를 받나요?

네. 마스크를 부호 있는 Integer로 넘기십시오. $80000002와 -2147483646은 같은 32비트입니다. GetLoadedFormFieldFlags는 저장 후 다시 적재해도 Required를 포함해 같은 값을 반환합니다

어떤 HotPDF 버전이 정확한 AcroForm 필드 플래그를 읽나요?

정확한 필드 플래그 읽기와 null 토큰 수정은 HotPDF v2.989.5에 들어갔고, 구조화 JSON과 XML 내보내기는 v2.989.8에서 정확 마스크를 채택했습니다. 이전 버전은 2^24를 넘는 모든 마스크를 Single을 통해 반올림합니다

정확한 AcroForm 필드 플래그, 상속된 필드 조회, 적재된 폼 편집은 Delphi 및 C++Builder용 HotPDF Component의 일부입니다. PDF 문서를 만들고, 적재하고, 편집하는 네이티브 PDF 라이브러리입니다