기술 문서

PDF 숫자 문법과 JSON: NaN, Infinity, null 처리

Delphi용 PDF Library(PDFlibPas)는 v3.539.31부터 모든 PDF 숫자에 유효한 JSON을 내놓습니다. GetObjectJSON은 ISO 32000-1은 받아들이지만 RFC 8259가 거부하는 토큰, 예컨대 -.25, +1.5, 007.5를 자릿수 하나하나 지키며 -0.25, 1.5, 7.5로 고쳐 씁니다. GetDocumentJSON과 분석 리포트는 NaN과 Infinity에 null을 기록하고, PLDoubleToStr는 내보내기 한가운데 EInvalidOp를 raise하는 대신 NaN에 0을 씁니다. 수정 전에는 라이브러리가 자기 리더조차 다시 읽기를 거부하는 JSON을 만들어 낼 수 있었습니다

유효한 PDF 숫자가 왜 JSON을 깨뜨릴까?

두 문법이 네 가지 사소한 디테일에서 어긋나고, 원문 텍스트를 존중하는 PDF 파서는 그 디테일을 출력으로 그대로 실어 가기 때문입니다. ISO 32000-1 §7.3.3은 숫자가 플러스 부호로 시작하고, 정수부를 생략하고(.5), 벌거벗은 점으로 끝나고(4.), 선행 0을 가지는 것(007.5)을 허용합니다. RFC 8259 §6은 그중 아무것도 허용하지 않습니다. 선택적 마이너스 하나, 0이거나 1에서 9로 시작하는 정수부, 소수점 뒤의 숫자 최소 하나. 생산자는 PDF 형태를 자유롭게 쓸 수 있고, 실제로 상당수 제너레이터와 손으로 편집한 파일이 그렇게 합니다

샘은 일부러 만든 정밀도 기능에서 새어 나왔습니다. v3.539.19부터 TPDFNumeric.Output는 실수에 대해 토커나이저가 파싱한 정확한 텍스트를 반환하는데, 파싱된 PDF 소수 정밀도 보존에서 기술한 대로 캘리브레이션된 색 값을 저장 시 정확하게 유지해 주는 것이 바로 그것입니다. 토커나이저는 들어오는 길에서 이미 .5를 0.5로, 4.를 4.0으로 패치하고, 정수는 값에서 재포맷되므로 +3은 3으로 돌아옵니다. 그대로 살아남는 것은 나머지입니다. 부호 달린 선행 점(-.25), 실수의 명시적 플러스(+1.5), 선행 0(007.5). 옛 오브젝트 기록자는 Output을 "value": 바로 뒤에 붙였고, 라이브러리 자체 리더의 TJSONParser.ParseNumber는 그 모든 것에서 "Invalid JSON number"로 멈춥니다. 그래서 내보내기는 성공했는데 재임포트는 PDFLIB_ERROR_OBJECT_JSON_INVALID(105)로 실패했습니다

PDFlibPas GetObjectJSON은 RFC 8259가 거부하는 PDF 숫자 토큰을 자릿수 단위로 고쳐 씁니다: -.25는 -0.25가 되고, +1.5는 플러스를 잃고, 007.5는 선행 0을 버리며, 1.250000 같은 소수 자릿수는 저장된 Double에서 포맷하면 이진 노이즈가 더해지므로 그대로 살아남습니다
옛 기록자는 파싱된 정확한 텍스트를 붙였고, 라이브러리 자체 리더는 Invalid JSON number로 멈췄으며, 오류 105는 내보내기 쪽이 성공이라 부른 왕복을 깨뜨렸습니다
uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
  JSON: AnsiString;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('legacy-drawing.pdf', '') = 0 then
      raise Exception.Create('load failed');

    // 오브젝트 12는 [-.25 +1.5 007.5]로 쓰인 배열
    JSON := Lib.GetObjectJSON(12, 0);
    // v3.539.31 이상: 값이 -0.25, 1.5, 7.5로 도착

    // SetObjectJSON은 옵션을 받지 않으므로 0을 넘김
    if Lib.SetObjectJSON(12, JSON, 0) = 0 then
      raise Exception.CreateFmt('round trip rejected, error %d',
        [Lib.LastErrorCode]);

    Lib.SaveToFile('legacy-drawing-roundtrip.pdf');
  finally
    Lib.Free;
  end;
end;

PDFNumberTextToJSON은 모든 자릿수를 어떻게 지킬까?

PDFNumberTextToJSON은 Double에서 재계산하는 대신 토큰을 다시 철자합니다. PDFlibObjectJSON의 이 함수는 선택적 부호를 읽고, 단일 소수점 앞뒤의 숫자들을 모은 다음, JSON이 요구하는 편집만 적용합니다. 플러스를 버리고, 하나를 남기며 선행 0을 깎고, 정수부가 비면 0을 공급하고, 벌거벗은 뒤따르는 점을 버리고, 마이너스를 되돌립니다. 다른 문자를 담거나 숫자가 전혀 없는 토큰은 PLJSONNumber(Value, 10)로 폴백하는데, 이것은 값이 유한하지 않을 때 null을 씁니다

PDFlibPas PDFNumberTextToJSON는 부호를 읽고 단일 소수점 주변의 숫자를 모아 JSON이 요구하는 편집만 적용하는 반면, 다른 문자나 빈 숫자열은 PLJSONNumber로 폴백하며, 이것은 숫자 대신 NaN과 Infinity에 null을 씁니다
재철자가 재계산을 이깁니다. 토커나이저가 들어오는 길에서 이미 .5와 4.를 패치했으므로, 기록자는 살아남은 모든 자릿수를 지키고 왕복은 정확히 같은 값을 재현합니다
  • -.25는 -0.25가 되고, +.5는 0.5가 됩니다
  • +1.5는 1.5가 됩니다
  • 007.5는 7.5가 되는 반면, 0.75는 그대로입니다
  • 그런 토큰이 기록자에 도달하기라 하면 4.는 4가 됩니다
  • 2.22221과 1.250000은 뒤따르는 0을 포함해 모든 소수 자릿수를 유지합니다

저장된 Double에서 포맷하는 편이 더 짧았겠지만 틀렸을 것입니다. 정밀도 수정이 존재하는 같은 이유입니다. 디폴트 출력 정밀도는 소수 넷 자리고, 완전 정밀도 변환조차 십진 literal에 이진 노이즈를 더할 수 있습니다. 자릿수를 지키면 각 JSON 숫자 텍스트를 PDF 토커나이저에 넘기는 SetObjectJSON과 ImportObjectJSON이 정확히 같은 값을 재현합니다. 보장은 바이트가 아니라 값을 다룹니다. 재임포트 후 -.25는 -0.25로 저장되고 저장됩니다. 두 철자 모두 §7.3.3 아래에서 동등하지만, 바이트 수준 diff는 변경을 표시할 것입니다. 그러니 서명이 바이트를 덮은 문서에서 내보내기-임포트 사이클을 no-op으로 다루지 마세요

JSON이 표현할 수 없는 숫자는 어떻게 되나?

GetDocumentJSON은 이제 NaN이나 무한대인 모든 숫자에 null을 씁니다. RFC 8259 §6에는 둘 다를 위한 문법이 없기 때문입니다. Infinity는 들리는 것보다 만들기 쉽습니다. PDF 토커나이저는 반복 곱셈으로 숫자를 Double에 쌓는데, 이것은 1.8 × 10308 근처에서 꽉 차므로, 300자리를 조금 넘는 정수 literal은 조용히 +Inf가 됩니다. 정직한 파일은 그런 literal을 담지 않지만, 퍼징되고 적대적인 파일은 담습니다. 그래서 악성 파일에 맞서 Pascal PDF 파서 강화하기의 케이스들과 같은 테스트 코퍼스에 속하는 것입니다. 옛 문서 기록자는 비정수를 Str(D:0:6)로 포맷했고, +Inf에 대해 +Inf라는 텍스트를 썼으니, 그걸 파싱할 JSON 컨슈머는 없습니다

null은 의도적인 손실입니다. GetDocumentJSON 출력의 컨슈머는 숫자가 나타날 수 있는 어디서든 null을 받아들여야 하고, 그것을 빠진 키가 아니라 "값이 있었지만 표현될 수 없다"로 읽어야 합니다. 원래 literal은 문서 JSON에서 복구할 수 없으므로, 신경 쓰는 파이프라인은 디폴트를 대입하는 대신 오브젝트를 기록하고 파일을 의심스러운 것으로 다뤄야 합니다

NaN 하나가 SVG나 JSON 내보내기를 중단시킬 수 있었던 이유는?

라이브러리의 콘텐츠 스트림, SVG, XML, CSV, 대부분의 JSON 뒤에 있는 invariant 숫자 포매터 PLDoubleToStr가 입력을 스케일하고 Round를 불렀고, Delphi가 x87 무효 연산 예외를 마스크하지 않는 Win32 같은 타깃에서 Round(NaN)은 EInvalidOp를 raise하기 때문입니다. 예외는 기록자가 이미 출력 일부를 내보낸 뒤에 터졌으므로, 메트릭 안의 0/0이나 호출자가 넘긴 NaN 같은 하나의 퇴화한 측정값이 잘린 파일을 남겼습니다. PLDoubleToStr는 이제 NaN에 0을 반환하고, 정수 분기도 소수 분기처럼 ±9.2e18로 클램프하므로, Infinity 역시 유한한 literal로 나옵니다

0은 숫자 슬롯이 숫자를 담아야 하는 콘텐츠 스트림에는 옳은 답이고, 0이 그럴듯한 측정값이 되는 리포트에는 틀린 답입니다. 구분을 지켜야 하는 JSON 기록자는 PDFlibExtra의 PLJSONNumber(Value, Decimals)를 씁니다. NaN이나 Infinity에는 null을 쓰고 그 외에는 invariant 숫자를 쓰는 함수죠. PLJSONNumber는 이제 GetSimilarImageDeduplicationReportJSON, GetAnnotationHitsJSON과 바코드, 데스큐, structured text, PDF/VCR 리포트를 뒷받침합니다. 데스큐 리포트는 예전에 유한하지 않은 각도에 0을 썼고 이제 null을 씁니다

PDFlibPas는 NaN과 Infinity를 세 갈래로 막습니다: AddPageMatrix, ScalePage, RedactRegion는 유한하지 않은 인자를 앞에서 거부하고, PLDoubleToStr는 콘텐츠 스트림 슬롯에 0을 쓰며, PLJSONNumber는 0이 그럴듯한 측정값으로 읽힐 리포트에 null을 씁니다. 예전에는 Round(NaN)가 내보내기 한가운데 EInvalidOp를 raise했습니다
0은 콘텐츠 스트림에는 옳은 답이고 리포트에는 틀린 답입니다. 그래서 리포트 기록자는 모든 Double을 PLJSONNumber에 넘기고, 값이 있었지만 표현될 수 없다는 것을 null이 말하게 합니다
uses
  SysUtils, PDFlibTypes, PDFlibExtra;

function SkewReportJSON(Page: Integer; Angle, Confidence: Double): string;
var
  B: PLStringBuilder;
begin
  B := PLStringBuilder.Create(128);
  try
    // 모든 Double을 먼저 텍스트로 포맷할 것, PLJSONNumber는
    // NaN이나 Infinity에 null을 쓰고 항상 소수점을 사용
    B.Append('{"page":').Append(Page)
     .Append(',"angle":').Append(string(PLJSONNumber(Angle, 4)))
     .Append(',"confidence":').Append(string(PLJSONNumber(Confidence, 4)))
     .Append('}');
    // B.Append(Angle) 금지: Double 오버로드는 유저 로캘을 따름
    Result := B.ToString;
  finally
    B.Free;
  end;
end;

유저 로캘은 여전히 어디서 JSON으로 스며들까?

국가 설정을 참조하는 어떤 포매터를 통해서든입니다. 기계 판독 출력의 전수 감리는 정확히 하나를 남겨 두었었습니다. GetSimilarImageDeduplicationReportJSON의 maxAcceptedMeanError, FloatToStr의 얇은 래퍼인 PLFloatToStr로 쓰인 것이죠. 소수점 구분자가 콤마인 데스크톱에서 리포트는 "maxAcceptedMeanError":1,5를 담았고, JSON 파서는 이를 값 1 뒤에 엉뚱한 토큰이 붙은 것으로 읽습니다. 이 필드는 지각 이미지 중복 제거에서 받아들인 최악의 픽셀 오차를 보고하며, 이제 PLJSONNumber(Stats.MaxAcceptedMeanError, 6)를 통과합니다. 남은 함정은 PLStringBuilder입니다. Delphi에서는 System.SysUtils.TStringBuilder의 단순 별칭인데, 그 Append(Double) 오버로드는 유저 로캘을 통해 포맷합니다. 반면 FPC 빌드는 라이브러리 자체 클래스를 쓰므로, Free Pascal이나 en-US 기계에서의 테스트는 절대 잡아 내지 못합니다

uses
  System.SysUtils, System.JSON, PDFlibrary;

var
  Lib: TPDFlib;
  Report: WideString;
  Parsed: TJSONValue;
begin
  // 테스트 런 안에서 독일어나 프랑스어 데스크톱 재현
  FormatSettings.DecimalSeparator := ',';
  Lib := TPDFlib.Create;
  try
    // 근접 중복 이미지를 실제로 담은 픽스처를 쓸 것,
    // 아니면 평균 오차가 0이라 버그가 숨은 채 남음
    Lib.LoadFromFile('scanned-batch.pdf', '');
    // 임계값 2, 2, 4의 드라이 런: 문서는 수정되지 않음
    Lib.GetSimilarImageDeduplicationReportJSON(2, 2, 4, Report);
    Parsed := TJSONObject.ParseJSONValue(Report);
    if Parsed = nil then
      raise Exception.Create('report is not valid JSON on a comma locale');
    Parsed.Free;
  finally
    Lib.Free;
  end;
end;

JSON 출력의 회귀 스위트가 정직하려면 픽스처 셋이 필요합니다. -.25, +1.5, 007.5를 실은 페이지, 400자리 정수를 담은 오브젝트, 그리고 콤마 로캘 아래에서 도는 아무 리포트 하나. 각각 눈대신 엄격한 파서로 검증합니다. Delphi용 PDF Library의 오브젝트 JSON, 문서 JSON, 분석 리포트는 Delphi, C++Builder, Free Pascal을 통틀어 같은 숫자 규칙을 공유합니다. 전체 기능 목록은 PDF Library for Delphi 제품 페이지에 있습니다