기술 문서

HotPDF로 Delphi에서 아랍어·히브리어 comb 필드 채우기

HotPDF는 THPDFHeadlessDocument.SetTextFieldValueWithFamilies로 AcroForm comb 필드에 아랍어, 히브리어, 결합 기호, 혼합 방향 텍스트를 채운다. comb 격자는 Unicode 스칼라로 세어지고, 합자나 기호를 얹은 기본 글자는 자기 스칼라가 차지하는 k개 셀에 하나의 클러스터로 가운데 정렬되며, 셀에 무엇이 배정되기 전에 줄 전체가 Unicode Bidirectional Algorithm을 통과한다

이 세 규칙은 각각, 라틴 데모에서는 멀쩡해 보이다가 첫 실제 양식에서 무너지는 지름길 하나를 대체한다. 아랍어 이름을 위한 열 칸짜리 comb 필드가 달린 정부 신청서를 상상해 보라. 문자별 로직으로 채우면 고립형 글자들이 왼쪽에서 오른쪽으로 행진하고, lam-alef 합자는 한 칸에 짓눌리는데 이웃 칸은 비어 있고, fatha는 엉뚱한 셀 위에 떠 있다. 그 결함 하나하나가 잘못된 단위를 세거나 잘못된 시점에 텍스트를 정렬하는 데서 온다

Delphi에서 HotPDF로 아랍어 comb 필드를 채우는 두 가지 방식: 문자별 지름길은 lam-alef 합자를 짓누르고, shaped 채움은 각 클러스터를 가운데 정렬한다
Unicode 스칼라를 세고 셀이 배정되기 전에 줄을 재정렬하면 합자가 두 칸에 걸쳐 가운데 정렬되고 빈 셀이 남지 않는다

Comb 플래그는 PDF 라이터에게 실제로 무엇을 요구하는가?

Comb 플래그는 라이터에게 필드를 정확히 MaxLen개의 등간격 위치로 쪼개 각 칸에 문자 하나를 넣으라고 요구한다. ISO 32000-1 §12.7.4.3은 Comb을 Table 228, 즉 텍스트 필드 고유 플래그의 비트 위치 25로 정의하고, Table 229의 MaxLen 항목이 있으며 Multiline, Password, FileSelect 플래그가 꺼져 있을 때만 의미가 있다고 말한다. 비트 자체는 32비트 /Ff 값에서 $1000000이다. comb 필드가 부동소수점 변수를 한 번 거쳤다가 오동작하는 걸 본 적이 있다면 32비트 AcroForm 필드 플래그에 관한 글이 그 비트가 가장 먼저 깨지는 이유를 설명한다

명세는 '문자'라고 말하고 거기서 멈춘다. 고정 폭 라틴 박스를 염두에 두고 쓰였고, 아랍어 글자 쌍이 하나의 glyph가 되거나 모음 기호가 자체 폭이 없을 때 문자가 무엇인지 말하지 않는다. HotPDF는 단위를 골라야 하며, 그러면서도 구조 규칙은 엄격히 강제한다. multiline이기도 한 comb 필드나 양의 MaxLen이 없는 comb 필드는 진단 메시지와 함께 거부되고, password 필드는 애초에 comb 필드일 수 없다

UTF-16 유닛, glyph, grapheme이 아니라 왜 Unicode 스칼라를 세는가?

HotPDF가 comb 셀을 Unicode 스칼라로 세는 이유는 MaxLen을 강제할 때 쓰는 단위와 같아야 하기 때문이다. 격자와 길이 검사가 일치하지 않으면 검증을 통과한 값이 자기 칸에 들어맞지 않는다. 유혹적인 대안 셋은 각각 다른 방식으로 이 일치를 깬다

  • UTF-16 코드 유닛은 Delphi의 Length가 돌려주는 것이므로 U+20BB7 같은 supplementary 문자는 칸 두 개와 필드 예산 두 슬롯을 삼킨다
  • glyph 수는 폰트에 좌우된다. 대부분의 아랍어 폰트는 lam과 alef를 하나의 glyph로 만들므로, 서체를 바꾸는 순간 셀 수가 바뀐다
  • grapheme 수는 기본 글자와 기호를 합치므로, 격자가 사용자가 입력한 모든 스칼라를 세는 MaxLen 검사와 어긋난다

THPDFHeadlessDocument의 MaxLen 검증은 문자열의 UTF-16 길이가 한도를 넘을 때만 문자열을 훑는데, 그때 유효한 서로게이트 페어는 하나로 세고 짝이 없는 서로게이트는 그 자리에서 거부한다. 호출 후 예외를 잡는 대신 UI가 호출 전에 경고하고 싶다면 같은 방식으로 세어라

Delphi에서 HotPDF comb 셀에 대해 UTF-16 유닛, glyph, grapheme을 Unicode 스칼라와 비교하는 매트릭스, lam-alef, beh와 fatha, U+20BB7, 악센트 e 포함
Unicode 스칼라 열만 MaxLen 검사와 일치한다 — 서로게이트 페어는 하나로 세어지고 셀 수는 결코 폰트에 좌우되지 않는다
// comb 격자와 같은 방식으로 Unicode 스칼라를 센다:
// 유효한 서로게이트 페어는 하나의 스칼라다
function CountScalars(const S: UnicodeString): Integer;
var
  I: Integer;
begin
  Result := 0;
  I := 1;
  while I <= Length(S) do
  begin
    if (Ord(S[I]) >= $D800) and (Ord(S[I]) <= $DBFF) and
       (I < Length(S)) and
       (Ord(S[I + 1]) >= $DC00) and (Ord(S[I + 1]) <= $DFFF) then
      Inc(I, 2)
    else
      Inc(I);
    Inc(Result);
  end;
end;

몇 가지 값이 단위들이 얼마나 갈라지는지, 그리고 스칼라 열만이 comb 필드의 예산과 일치하는 이유를 보여 준다

값UTF-16 유닛스칼라(셀)전형적 glyph
Lam + alef (لا)22합자 1개
Beh + fatha (بَ)222개, 기호는 기본 글자에 붙음
U+20BB7 (𠮷)211개
분해된 e + U+0301221개 또는 2개, 폰트에 좌우됨

합자와 결합 기호는 셀을 어떻게 나눠 갖는가?

k개 스칼라를 커버하는 shaped 클러스터는 k개의 연속 셀을 차지하고 하나의 단위로 가운데 정렬된다. shaping 후에 모든 클러스터는 자기가 나온 원본 텍스트의 정확한 구간으로 되돌아가 매핑되므로, HotPDF는 어느 스칼라, 따라서 어느 셀을 자기 소유로 갖는지 안다. 위의 lam-alef 합자는 두 칸을 받아 두 칸에 걸쳐 가운데 정렬된다. shadda와 fatha를 얹은 beh는 세 스칼라이므로 세 칸을 받고, 기호는 여전히 글자에 붙어 있다

가운데 정렬이 뻔한 구현이 틀어지는 자리다. 각 glyph를 자기 칸에서 가운데 정렬하면 기호가 기본 글자에서 떨어져 나간다. 기호의 위치는 자기가 장식하는 글자에 상대적일 때만 의미가 있기 때문이다. HotPDF는 클러스터 안의 모든 glyph을 shaped 진행폭과 X·Y 오프셋 그대로 두고, 자연 진행폭, 배치된 glyph 원점, 실제 glyph 잉크를 합친 수평 범위로 클러스터를 가운데 정렬한다. 클러스터는 블록으로 움직인다. 내부는 아무것도 재배열되지 않는다

놓치기 쉬운 결과가 하나 있다. U+200F RIGHT-TO-LEFT MARK, U+061C ARABIC LETTER MARK, embedding과 isolate 제어 문자 같은 방향성 제어 문자는 glyph 없는 Unicode 스칼라다. MaxLen에 포함되며 빈 채로 남는 셀 하나를 차지한다. 원본 데이터가 이런 기호를 이름에 뿌려 놓는 시스템에서 왔다면 채우기 전에 벗겨내라. 그렇지 않으면 필드가 빈틈을 보여 주고 사용자가 기대하는 것보다 한 글자 일찍 값을 거부한다

bidi 재정렬은 왜 셀 배정 전에 일어나야 하는가?

HotPDF는 줄 전체를 먼저 Unicode Bidirectional Algorithm(UAX #9)에 통과시키고, 그다음에 시각 위치에서 각 스칼라에 셀을 준다. 논리 순서로 셀을 배정한 뒤 결과를 뒤집는 방식은 순수 아랍어 문자열에서는 동작하지만 줄에 숫자나 라틴 토큰이 섞이는 순간 실패한다. 그런 런은 오른쪽에서 왼쪽으로 가는 줄 안에서 왼쪽에서 오른쪽 읽기를 유지해야 하기 때문이다. 아랍어 접두사와 숫자를 지닌 참조 코드 같은 값은 재정렬이 줄 전체를 한꺼번에 볼 때만 숫자 순서를 지킨다. 복합 스크립트 텍스트 shaping 개요가 논리 순서와 시각 순서 문제를 더 깊이 다룬다

Delphi용 HotPDF에서 숫자가 섞인 아랍어 값의 comb 셀 배정: 논리 순서는 숫자를 뒤집지만 UAX 9 우선 방식은 숫자를 온전히 유지한다
줄 전체에 bidirectional algorithm을 먼저 돌린 뒤 시각 위치에서 각 스칼라에 셀을 주면 포함된 숫자가 순서를 지킨다

정렬은 그다음 사용된 셀 격자 전체를 필드 안에 배치한다. 필드가 물려받은 /Q 항목이 있으면 그것이 이기고, Options.Alignment는 양식에 없을 때만 적용된다. 오른쪽에서 왼쪽으로 가는 양식에서 이것이 중요하다. 좌측 정렬 comb 필드의 짧은 아랍어 값은 왼쪽 칸들을 채우는데, 명세상으로는 옳지만 양식 설계자가 의도한 일은 거의 없다. 양식의 /Q를 고치거나, 필드에 자체 quadding이 없을 때 htaRight를 설정하라. 위젯 회전은 /MK의 /R 항목에서 오고, 90도나 270도에서는 논리 텍스트 뷰포트가 너비와 높이를 맞바꾸지만 셀 로직은 같다

Delphi에서 혼합 폰트 comb 필드 채우기

혼합 폰트 comb 텍스트는 넘겨준 모든 폰트에 대해 하나의 셀 격자, 하나의 폰트 크기, 하나의 베이스라인을 쓴다. FallbackFontFamilies를 True로 두면 HotPDF는 설정된 순서의 공급 패밀리에서 완전한 grapheme마다 폰트를 고르므로, 아랍어 서체가 글자를 담고 라틴 서체가 포함된 ASCII를 담는 식이 가능하다. 각 폰트 구간은 줄 전체를 컨텍스트로 shaping되고, 기호가 폰트를 넘어 기본 글자에서 갈라지는 일은 없으며, 폰트 경계를 넘어 합자가 만들어지는 일도 없다. 구간마다 자기 지역 격자로 배치하거나 자기 바운딩 박스로 스케일하는 편이 더 단순하겠지만, 폴백이 줄 중간에 폰트를 바꾸는 순간 셀 위치와 회전된 필드가 모두 깨진다

uses
  System.SysUtils, System.IOUtils,
  HPDFHeadlessDocument, HPDFHeadlessAppearance;

procedure FillArabicComb(const SourceFile, TargetFile, Value: string);
var
  Doc: THPDFHeadlessDocument;
  Field: THPDFHeadlessFormField;
  Families: THPDFHeadlessRichTextFontFamilies;
  Options: THPDFHeadlessTextAppearanceOptions;
  FieldIndex: Integer;
begin
  Doc := THPDFHeadlessDocument.Create;
  try
    Doc.LoadFromFile(SourceFile);
    FieldIndex := Doc.FindFormField('applicant.name_ar');
    if FieldIndex < 0 then
      raise Exception.Create('Field not found');
    Field := Doc.GetFormField(FieldIndex);
    if (Field.Flags and $1000000) = 0 then   // 비트 25: Comb
      raise Exception.Create('Not a comb field');

    SetLength(Families, 2);                  // 1부터 16까지 패밀리
    Families[0].FamilyName := 'Noto Naskh Arabic';
    Families[0].Fonts[hrfsRegular] :=
      TFile.ReadAllBytes('NotoNaskhArabic-Regular.ttf');
    Families[1].FamilyName := 'Noto Sans';
    Families[1].Fonts[hrfsRegular] :=
      TFile.ReadAllBytes('NotoSans-Regular.ttf');

    Options := THPDFHeadlessTextAppearanceOptions.Default;
    Options.ShapeComplexText := True;        // 이미 기본값이다
    Options.FallbackFontFamilies := True;    // 기본값은 False다
    Options.FontSize := 0;                   // 자동 맞춤, 최대 12
    Options.Alignment := htaRight;           // /Q가 없을 때만 사용된다

    Doc.SetTextFieldValueWithFamilies(FieldIndex, Value, Families, Options);
    Doc.SaveToFile(TargetFile);              // SourceFile과 달라야 한다
  finally
    Doc.Free;
  end;
end;

예제가 설정하지 않는 것에 주목하라. 문서 채우기 중에는 Width, Height, Rotation, CombCells가 각 위젯 사각형, /MK 항목, 필드의 MaxLen에서 유도되므로 이 멤버에 넣은 값은 덮어쓰인다. FontSize 0은 자동 크기 조절을 요청하는데, 참여 폰트 전부의 공유 메트릭과 잉크 높이, 그리고 각 클러스터의 범위를 k개 셀의 너비에 맞춰 검사한다. 고정 크기는 그저 위젯에 잘려 나온다. CharacterSpacing과 WordSpacing은 comb 위치를 절대 움직이지 않는다. 클러스터가 어디 앉는지는 격자만이 결정하기 때문이다. HotPDF는 값이 실제로 shaping을 요구할 때만 HarfBuzz와 FriBidi 런타임을 로드하고, shaping이 필요 없는 값은 기존의 직접 comb 배치를 그대로 유지한다. 권한과 저장을 포함한 로드된 PDF 채우기 워크플로 전반은 로드된 PDF에서 양식 필드 값 설정하기를 보라

shaped comb appearance는 무엇을 보장하지 않는가?

shaped comb appearance는 올바른 셀 배정, 온전한 클러스터, 보존된 논리 값은 보장한다. 그러나 모든 도구에서 매끄러운 필사체 잇기나 완벽한 텍스트 추출까지 보장하지는 않는다. 두 한계 모두 comb 필드라는 존재에서 나오므로, 고객에게 무엇을 약속하기 전에 알아 둬야 한다

첫 번째는 셀을 넘나드는 필사체 잇기다. 아랍어 글자는 여전히 줄 전체 컨텍스트로 shaping되므로 각자 올바른 어두·중간·어말 형태를 얻지만, 고정된 셀 피치가 개별 클러스터 사이에 공간을 만들고 이어지는 획은 흐르는 텍스트에서처럼 만나지 않는다. 잇기 문자를 박스에 그리는 데서 오는 본질적 한계이고, 가운데 정렬을 아무리 해도 숨겨지지 않는다

두 번째는 텍스트 추출이다. 필드의 /V는 순수 논리 값을 간직하고, appearance의 부모 Form XObject는 입력 전체를 담은 /ActualText 구간 하나(ISO 32000-1 §14.9.4)를 실는다. 자식 Form에는 없다. ActualText를 존중하는 추출기는 원본 문자열을 복원한다. 무시하는 추출기는 glyph 위치에서 텍스트를 재조립하며 다른 순서를 돌려주거나 클러스터 텍스트를 복제할 수 있다. 여러 glyph로 된 클러스터에는 glyph마다 명확한 단일 Unicode 값이 없기 때문이다. 하류 시스템이 값을 필요로 한다면 페이지 텍스트가 아니라 필드를 읽어라

남은 경계는 기계적이다. comb 텍스트는 탭 없는 단일 줄이어야 하고, 셀보다 스칼라가 많은 값은 잘리는 대신 실패한다. 실패는 트랜잭션적이다. 없는 glyph, 잘못된 폰트, 예산 초과, 취소는 이전 값과 appearance를 그대로 두므로, 거부된 채움은 반쯤 그려진 필드를 절대 남기지 않는다

try
  Doc.SetTextFieldValueWithFamilies(FieldIndex, Value, Families, Options);
except
  on E: EHPDFHeadlessDocumentError do
    if E.Code = hdLimitExceeded then
      ShowFieldError('The value has more characters than the field allows')
    else
      raise;
  on E: EHPDFHeadlessAppearanceError do
    if E.Failure = haMissingGlyph then
      ShowFieldError('None of the supplied fonts covers this text')
    else
      raise;
end;
// 어느 쪽 오류든 필드는 이전 값과 appearance를 유지한다

shaped comb 필드가 양식 파이프라인에서 맞는 자리

shaped comb 지원이 가장 큰 빛을 내는 곳은 이미 존재하는 양식이다. 박스 필드로 설계돼 아랍어, 히브리어, 페르시아어, 우르두어로 채워지는 세금 신고서, 비자 신청서, 은행 온보딩 패키지, 신분 등록이 그것이다. 스칼라로 세고, 원본 값은 /V에 간직하고, 모든 폰트에 하나의 격자를 적용하고, 오른쪽에서 왼쪽 글자와 숫자와 결합 기호가 섞인 값으로 테스트하라. 그 문자열 하나가 이 글의 모든 규칙을 운동시킨다. 여기서 다룬 헤드리스 문서 API, comb shaping, 폰트 폴백은 HotPDF Delphi PDF 컴포넌트의 일부다