HotPDF Component는 COLRv1 반복과 반사 그라데이션을 글리프를 래스터화하지 않고 벡터 PDF shading으로 그립니다. 지오메트리는 컬러 라인 사이클이 정수 개 딱 덮이도록 늘어나고, 각 사이클은 Extend를 끈 채 자기만의 axial 또는 radial shading을 받으며, 색은 리니어 라이트 CalRGB로 보간되고, Porter-Duff in/out 합성 모드는 luminosity soft mask로 표현됩니다
HotPDF가 Delphi에서 COLR v1과 SVG 컬러 이모지 폰트를 그리는 방법을 다룰 때, 네이티브 경로가 내보내지 않는 것 목록에 repeat와 reflect extend 모드가 있었고, 그런 글리프는 래스터 폴백으로 갔습니다. 최신 HotPDF 버전은 벡터로 그립니다. 거기까지 가는 데 몇 번의 헛발질이 있었고, 대부분은 PDF에 대한 그럴듯해 보이는 가정이 최소 하나의 널리 쓰이는 뷰어에서 틀렸기 때문이었습니다. 이 글은 그 가정들을 짚어 봅니다. CSS, SVG, OpenType 그라데이션을 PDF로 옮기는 사람이라면 누구나 같은 것들을 만나게 될 테니까요
PDF 뷰어는 왜 그라데이션을 대신 반복해 주지 못할까
PDF shading에는 repeat나 reflect 모드가 없어서, 생산자가 모든 사이클을 직접 배치해야 합니다. ISO 32000-1 §8.7.4.5.3과 §8.7.4.5.4가 axial과 radial shading의 /Extend를 정의하는데, 하는 일이라고는 축 끝을 지나 t0나 t1의 색을 계속 이어주는 것뿐입니다. 그건 COLRv1 EXTEND_PAD이고 그 이상도 이하도 아닙니다. OpenType COLR v1은 또한 파라미터가 정수를 지날 때마다 컬러 라인이 처음부터 다시 시작하는 EXTEND_REPEAT와, 사이클마다 하나씩 거꾸로 도는 EXTEND_REFLECT를 정의합니다
HotPDF의 첫 시도는 함수가 주기적 일을 하게 만들려 했습니다. 넓은 도메인의 Type 0 샘플 함수(§7.10.2) 하나를 등록하고, 25사이클 폭이라면 대략 [-12.73, 12.73], 접힌 샘플로 채우고 Extend를 켰습니다. 첫 사이클 안에서는 최소 테스트 파일이 올바르게 렌더됐지만, 그 밖에서는 MuPDF가 평균 색 하나를 칠하고 Poppler도 나은 게 없었습니다. 두 뷰어 모두 shading 파라미터를 따라 함수를 평가하고 축 너머는 채워 이어주며, 확장 영역에서 주기적 함수를 다시 조회하는 일은 하지 않습니다. 넓은 도메인도 사양을 잘못 읽은 것이었습니다. shading 파라미터 t는 함수에 그대로 넘어가고, §7.10.1에 따라 함수 도메인 밖의 입력은 클리핑되므로, 도메인이 shading의 /Domain(기본값 [0 1])과 맞지 않는 함수는 샘플 중 일부만 읽힙니다
통하는 설계는 세 부분으로 이루어지며, 모두 MuPDF와 Poppler 렌더로 검증했습니다. shading 지오메트리가 사이클 정수 N개를 물리적으로 덮이므로 어떤 픽셀도 Extend에 의존하지 않습니다. 샘플 함수는 뷰어가 실제로 넘기는 것과 맞춰 기본 [0 1] 도메인을 유지합니다. 그리고 샘플 수열 자체를 사이클당 256 구간으로 접어서, 함수가 어떤 뷰어 기능에도 의존하지 않고 repeat나 reflect 색 데이터를 갖게 합니다
컬러 라인을 Type 0 샘플로 접기
접기란 컬러 라인을 사이클마다 한 번 샘플링하고, reflect 모드에서는 홀수 사이클에서 수열을 미러링하는 것입니다. 아래 헬퍼는 HotPDF API가 아니라 순수 Delphi이며, 순진한 버전이 틀리는 두 가지를 제대로 합니다. 저장된 sRGB 바이트가 아니라 리니어 라이트에서 보간하고, hard stop(같은 오프셋의 두 stop)을 반대쪽 색으로 해석하는데, 이것이 OpenType과 CSS 의미론입니다
uses
System.SysUtils, System.Math;
type
TColorStop = record
Offset: Single; // 한 사이클 안의 0..1, 오름차순 정렬
R, G, B: Byte; // CPAL 스타일 sRGB 바이트
end;
function SRGBToLinear(V: Byte): Double;
var
C: Double;
begin
C := V / 255;
if C <= 0.04045 then
Result := C / 12.92
else
Result := Power((C + 0.055) / 1.055, 2.4);
end;
function LinearToSRGB(L: Double): Byte;
var
C: Double;
begin
L := EnsureRange(L, 0, 1);
if L <= 0.0031308 then
C := L * 12.92
else
C := 1.055 * Power(L, 1 / 2.4) - 0.055;
Result := Round(C * 255);
end;
// 컬러 라인 한 사이클을 8비트 RGB 트리플로. Reverse = True이면
// 사이클을 미러링합니다. reflect 모드가 홀수 사이클에서 하는 일입니다
function SampleCycle(const Stops: array of TColorStop; Count: Integer;
Reverse: Boolean): TBytes;
var
I, S: Integer;
T, F, Span: Double;
begin
SetLength(Result, Count * 3);
for I := 0 to Count - 1 do
begin
T := I / (Count - 1);
if Reverse then
T := 1 - T;
S := 0;
// >=는 hard stop의 뒤쪽을 고릅니다
while (S < High(Stops) - 1) and (T >= Stops[S + 1].Offset) do
Inc(S);
Span := Stops[S + 1].Offset - Stops[S].Offset;
if Span > 0 then
F := EnsureRange((T - Stops[S].Offset) / Span, 0, 1)
else
F := 1;
Result[I * 3] := LinearToSRGB(SRGBToLinear(Stops[S].R) +
(SRGBToLinear(Stops[S + 1].R) - SRGBToLinear(Stops[S].R)) * F);
Result[I * 3 + 1] := LinearToSRGB(SRGBToLinear(Stops[S].G) +
(SRGBToLinear(Stops[S + 1].G) - SRGBToLinear(Stops[S].G)) * F);
Result[I * 3 + 2] := LinearToSRGB(SRGBToLinear(Stops[S].B) +
(SRGBToLinear(Stops[S + 1].B) - SRGBToLinear(Stops[S].B)) * F);
end;
end;
HotPDF 안의 COLRv1 렌더러는 이 스케치보다 더 나갑니다. 빅엔디안 16비트 리니어 샘플을 CalRGB 함수에 쓰고 알파는 별도의 16비트 그레이 샘플로 저장하는데, 다음에 보여 줄 공개 헬퍼는 8비트 DeviceRGB 트리플을 받으므로 스케치가 sRGB 바이트로 되돌려 변환하는 것입니다. 리더가 그 샘플들로 무엇을 하는지 이론은 3D 컬러 LUT로서의 Type 0 샘플 함수 글이 보간 모델을 자세히 다룹니다
각 사이클이 자기만의 shading이 필요한 이유
각 사이클에 별도 shading이 필요한 이유는 리더가 샘플 함수를 정확히 평가하지 않기 때문입니다. 컬러 테이블을 미리 계산하는데, 이 테이블이 한 사이클의 마지막 색에서 다음 사이클의 첫 색으로 이어지는 구간을 보간해 버립니다. MuPDF는 shading의 1차원 함수를 256항목 컬러 테이블로 저장합니다. 11사이클을 하나의 2816샘플 함수에 넣으면 저장된 샘플은 전부 정확할 수 있어도 MuPDF는 여전히 채널당 12에서 14 레벨의 오차를 보입니다. 256개 테이블 항목이 전체 폭에 흩어지고 hard repeat 이음새가 램프로 매끄러워지기 때문입니다. Poppler와 같은 파일의 해석적 보간은 모두 정확했으므로, 이건 파일 오류가 아니라 리더 정밀도 한계이고 샘플을 더해도 소용이 없습니다
해결책은 모든 리더 테이블을 한 사이클 안에 가두는 것입니다. HotPDF는 주기적 선형 그라데이션을 원래 사이클마다 독립적인 axial shading 하나로 내보내고, Extend 플래그 둘 다 false로 두어, 끝에서 시작으로의 색 점프가 한 테이블 안이 아니라 두 shading 사이 경계에 걸리게 합니다. reflect의 경우 홀수 판정은 컬러 라인 시작 이전의 음수 사이클을 포함해 원래의 부호 있는 사이클 번호로 하고, 샘플을 다시 계산하는 대신 shading 방향을 뒤집습니다. 모든 사이클은 하나의 리니어 라이트 isolated 표면에 그려지고 페인트 알파는 끝에 한 번 적용되므로, 반투명 그라데이션이 이음새를 따라 불투명도를 두 배로 만들지 않습니다
공개 메서드인 THotPDF.RegisterSampledAxialGradient와 THotPDF.RegisterSampledRadialGradient는 샘플 shading 빌딩 블록을 여러분의 드로잉에 노출합니다. 둘 다 지오메트리 뒤에 (..., DomainT0, DomainT1: Single; const Samples: TBytes; SampleCount, Cycles: Integer)를 받고, PDF 1.3을 요구하며, Samples가 정확히 SampleCount * 3바이트가 아니면 예외를 던지고, THPDFPage.SetFillPattern에 쓸 패턴 이름을 반환합니다. 양 끝에 Extend를 켜고, 뷰어가 무시하는 커스텀 /Cycles 패턴 키로 Cycles를 기록합니다. 사이클당 shading 하나 규칙을 따르려면 사이클마다 패턴 하나를 등록하고 밴드 사각형으로 클리핑하세요
// 사이클마다 하나의 shading으로 만든 수평 repeat 또는 reflect 그라데이션.
// 패턴 좌표는 기본 PDF 공간입니다. 수평 축은 이 예제를 페이지의
// 위에서 아래로 가는 Y 관례와 무관하게 유지합니다
procedure PaintPeriodicBands(Pdf: THotPDF; const Stops: array of TColorStop;
X, Y, Width, Height, CycleLength: Single; Reflect: Boolean);
const
SamplesPerCycle = 257; // 사이클당 256 구간
var
Cycle, CycleCount: Integer;
X0, X1: Single;
Samples: TBytes;
PatternName: AnsiString;
begin
CycleCount := Ceil(Width / CycleLength);
for Cycle := 0 to CycleCount - 1 do
begin
X0 := X + Cycle * CycleLength;
X1 := X0 + CycleLength;
Samples := SampleCycle(Stops, SamplesPerCycle, Reflect and Odd(Cycle));
// Domain [0, 1]: shading 파라미터가 그대로 함수에 도달합니다
PatternName := Pdf.RegisterSampledAxialGradient(X0, 0, X1, 0, 0, 1,
Samples, SamplesPerCycle, 1);
if PatternName = '' then
Exit; // 문서 버전이 PDF 1.3 미만으로 잠김
Pdf.CurrentPage.SetFillPattern(PatternName);
// 밴드가 shading을 정확히 한 사이클로 클리핑하므로 Extend는 절대 안 보입니다
Pdf.CurrentPage.Rectangle(X0, Y, Math.Min(X1, X + Width) - X0, Height);
Pdf.CurrentPage.Fill;
end;
end;
아주 긴 폭은 다른 요령이 필요합니다. 사이클당 shading 하나는 사이클당 리소스 하나라는 뜻이기 때문입니다. 주기적 선형 COLRv1 그라데이션에서 HotPDF는 배증 방식으로 전환합니다. 리프 form이 한 사이클 repeat 또는 2사이클 reflect 프리미티브를 담고, 각 Form XObject 레벨은 이전 레벨을 자기 폭만큼 밀어 두 번 그립니다. form 수는 폭에 대해 로그로 자라지만 뷰어는 여전히 보이는 모든 사이클을 칠하므로, 위상 정렬 후 커버리지는 65536 사이클 단위로 제한됩니다. 프리미티브를 Type 1 타일링 패턴에 넣던 초기 버전은 폐기됐습니다. 리더들이 타일 간격을 디바이스 그리드에 맞추는 바람에, 정수 픽셀 수가 아닌 사이클에서 Poppler가 위상에서 벗어났습니다
radial 원 패밀리의 파라미터 범위 풀기
radial 그라데이션은 올바른 원 패밀리에 대해 사이클 범위를 계산했을 때에만 글리프를 덮습니다. COLRv1 radial 페인트는 수학이 다른 세 패밀리로 나뉩니다. D = c1 - c0이고 dr = r1 - r0일 때, 엄격히 중첩된 패밀리는 |dr| > |D|, 비중첩 원뿔은 |D|² > dr², 접선 패밀리는 |D| = |dr|입니다. HotPDF는 원래 패밀리의 반지름 0 지점에서 파라미터를 재고, 내보내는 모든 원의 중심과 반지름을 같은 원래 파라미터에 묶어 두므로, 재매핑된 음수 반지름이 독립적으로 잘리는 일은 없습니다
- 중첩 패밀리는 채우기 박스와 단조롭게 교차하므로, 파라미터 범위는 네 모서리와 박스에 닿는 첫 원에 대한 이분법에서 나옵니다. 최대 64사이클까지 사이클당 256샘플로 하나의 shading에 샘플링되고 4096샘플에서 제한되며, 더 깊은 폭은 사이클별 슬라이스로 전환합니다
- 원뿔과 등반지름 스트립은 원래 사이클 경계에서 별개의 짧은 radial shading을 씁니다. 각각 257샘플에
Extend를 끄고, 격리된 non-knockout 그룹 안에서 원래 파라미터 순서로 합성되어 원이 겹치는 곳에서 가장 큰 유효 파라미터가 이깁니다 - 접선 패밀리는 원 방정식을 선형으로 환원합니다. 공유 접선점 기준으로 근은
q = |W|² / (2·(W·V))이고, 덮이는 영역은 그 점 앞쪽의 열린 반평면입니다
접선 사례에는 진짜 특이점이 있습니다. 접선이 채우기 박스를 가로지를 때 그 선 근처 픽셀에 도달하는 데 필요한 사이클 수는 무한하므로, 유한한 shading으로는 덮을 수 없습니다. HotPDF는 접선점에서 64사이클 예산을 쓰고 남는 얇은 대역은 투명하게 둡니다. 박스의 네 모서리가 모두 접선점 뒤에 있으면 페인트 전체가 투명하고 글리프의 나머지는 계속 그려집니다. 유한 원뿔 경로는 최대 4096 필요 사이클을 받으며, 커버리지는 1024 슬라이스로 제한되고 한도를 넘는 부분은 칠하지 않습니다. 1024슬라이스 글리프는 대략 1.8MB입니다
리니어 라이트 색과 소수 둘째 자리 함정
COLRv1은 리니어 라이트에서 보간하고 합성하므로, HotPDF는 모든 CPAL 바이트를 sRGB 역 전송 함수로 디코딩하고 솔리드, shading, 내부 그룹을 D65 백점, sRGB 원색, /Gamma [1 1 1](§8.6.5.3)의 하나의 CalRGB 공간에 칠합니다. 완성된 루트 표면만 호출자의 블렌드 공간으로 변환됩니다. 저장된 sRGB 바이트를 보간하면 중간 지점이 눈에 띄게 어두워지는데, 모든 그라데이션 엔진이 한 번씩 저지르는 클래식한 감마 실수입니다
리니어 라이트로의 전환은 오래된 포매터 버그를 드러냈습니다. 어두운 CPAL 채널 값 12는 리니어 라이트에서 약 0.0036765가 되는데, scn 뒤의 공유 숫자 포매터가 소수 둘째 자리만 남겨 그 성분은 뷰어가 파일을 만지기 전에 0으로 기록됐습니다. 증상은 리더 양자화와 똑같이 보였고 첫 직감은 MuPDF의 8비트 그룹 버퍼 탓이었습니다. 이제 채우기와 선 성분은 작은 값을 보존하고 프로세스 로캘을 무시하는 지수 없는 10진 포매터를 거칩니다. 교훈은 일반화됩니다. 어두운 색이 사라지면 뷰어를 조사하기 전에 콘텐츠 스트림을 덤프해 기록된 피연산자부터 확인하십시오
그 수정 뒤에도 뷰어는 여전히 문제가 됩니다. MuPDF 1.28.2는 격리된 투명도 그룹이 명시적인 블렌드 색 공간을 실고 있으면 0 근처의 리니어 채널을 잃고, Poppler 26.07.0 Splash는 그룹 블렌드 공간을 아예 무시합니다. 옵트인 속성인 THotPDF.COLRv1DeviceSpaceGroupBlend(기본값 False)는 블렌드 색 공간 없이 COLRv1 그룹을 내보내, 콘텐츠 색과 shading, 함수는 리니어 라이트 CalRGB로 남은 채 그룹이 상속된 디바이스 공간에서 합성됩니다. 절충점은 그룹 색 공간을 존중하는 뷰어에서 겹치는 반투명 레이어가 디바이스 공간에서 블렌드된다는 것입니다
PDF가 정확히 표현할 수 있는 COLRv1 합성 모드는
HotPDF는 COLRv1 합성 모드 5에서 8(SRC_IN, DEST_IN, SRC_OUT, DEST_OUT)을 네이티브로 렌더링하고, 모드 9에서 12(SRC_ATOP, DEST_ATOP, XOR, PLUS)는 OnColorGlyphRasterize로 보냅니다. in과 out 모드는 한 표면을 다른 표면의 알파로 곱한 것인데, 이것이 정확히 soft mask가 계산하는 것입니다(§11.6.5). 살아남는 피연산자는 평범한 form으로 캡처하고, 마스크되는 피연산자는 알파 패스에서 하나의 격리된 DeviceGray 그룹 안의 평평한 콘텐츠로 캡처해 /BC [0]인 /Luminosity 마스크로 씁니다. out 모드는 모든 알파에 1 − α를 쓰고 마스크 콘텐츠 아래에 불투명한 흰 커버를 놓아, 다른 피연산자가 칠하지 않은 곳에서 생존자가 계속 보이게 합니다
나머지 넷은 두 표면의 프리멀티플라이드 덧셈이 필요합니다. PDF의 source-over 모델에서 배경 항은 항상 소스의 (1 − α)로 감쇠되고, 두 표면을 더하거나 배경을 소스의 커버리지로만 스케일하는 프리미티브가 없으므로, atop과 xor, plus는 벡터로 쓸 수 없습니다. 제약이 하나 더 있었는데 벌거벗은 테스트 PDF에서 나왔습니다. 두 뷰어 모두 콘텐츠가 form Do를 중첩하는 soft-mask 그룹을 잘못 계산하므로, 마스크되는 피연산자는 평평해야 합니다. 솔리드 색, pad 그라데이션과 그 둘을 감싸는 PaintGlyph 래퍼는 자격이 있고, 내부 form을 만드는 레이어와 변환, 주기적 그라데이션은 글리프를 폴백으로 보냅니다
var
Pdf: THotPDF;
Fallback: TEmojiFallback; // 아래 이벤트 시그니처를 가진 핸들러 클래스
begin
Pdf := THotPDF.Create(nil);
Fallback := TEmojiFallback.Create;
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'periodic-icons.pdf';
// 옵트인: MuPDF와 Poppler를 위한 device-space 그룹 합성
Pdf.COLRv1DeviceSpaceGroupBlend := True;
// atop/xor/plus 글리프와 서브셋 밖의 나머지를 받습니다:
// (Sender; Format; const FontBytes, AssetData: TBytes; GlyphID: Word;
// PaletteIndex, PixelSize: Integer; out Width, Height: Integer;
// out RGBA: TBytes; out Handled: Boolean)
Pdf.OnColorGlyphRasterize := Fallback.Rasterize;
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF('Fonts\GradientIcons.ttf');
if not Pdf.DrawRegisteredColorGlyph(Pdf.CurrentPage, $E001,
72, 144, 'Gradient Icons', 48) then
Writeln('U+E001 was neither drawn natively nor rasterized');
Pdf.EndDoc;
finally
Fallback.Free;
Pdf.Free;
end;
end;
벡터 경로가 아직 미치지 못하는 곳
벡터 출력이 모든 뷰어에서 동일한 픽셀과 같은 것은 아니며, HotPDF 수용 기록도 그렇게 솔직하게 말합니다. 사이클 경계에서 한두 스텝 안의 샘플은 리더 보간으로 매끄러워지므로, 픽셀 검사는 이음새에서 떨어진 곳을 샘플링하고 함수 스트림은 디코딩으로 검증합니다. 따로 저장된 스트레이트 RGB와 알파 샘플의 보간은 저알파 끝점과 hard stop 근처에서 근사이며, 측정된 오차는 사이클당 256샘플에서 2차지만 정확하진 않습니다. Poppler Splash는 픽셀 중심이 아니라 왼쪽 모서리에서 샘플링하므로, 가장 가파른 램프에서 작은 차이로 드러납니다
예산도 진짜 한계입니다. 중첩 radial 패밀리는 4096 사이클 폭을 넘으면 거절하고, 원뿔과 접선 패밀리는 4096 필요 사이클을 넘으면 거절하며, 특이선 근처의 접선 대역은 비어 남습니다. 가변 stop 알파는 PDF 1.4 투명도가 필요하므로 PDF/A-1, PDF/X-1a, PDF/X-3에서는 켜지지 않습니다. 글리프가 이 한계 밖이면 DrawRegisteredColorGlyph는 연산자를 쓰기 전에 그래프 전체를 거절하는데, 이는 이전과 같은 전부 아니면 전무 동작입니다
FAQ
HotPDF는 COLRv1 repeat와 reflect 그라데이션을 래스터화하나요?
아니요. 최신 HotPDF 버전은 사이클 전체를 덮는 벡터 axial과 radial shading으로 내보냅니다. atop, xor, plus 합성 같은 지원 서브셋 밖의 그래프만 OnColorGlyphRasterize로 갑니다
모든 사이클에 샘플 함수 하나를 쓰지 않는 이유는요?
리더는 함수에서 컬러 테이블을 미리 계산하고, MuPDF의 테이블은 256항목입니다. 여러 사이클에 흩어지면 모든 repeat 이음새를 가로질러 보간합니다. 사이클당 shading 하나가 각 테이블을 한 사이클 안에 가둡니다
COLRv1DeviceSpaceGroupBlend는 언제 켜야 하나요?
COLRv1 글리프의 흑사 근처 채널이 MuPDF에서 0으로 떨어질 때 켜십시오. 그룹 색 공간을 존중하는 뷰어에서 겹치는 반투명 레이어가 리니어 라이트로 블렌드되어야 한다면 끄십시오
네이티브 COLRv1 그라데이션, 컬러 이모지, 샘플 shading, 투명도 그룹은 모두 Delphi 및 C++Builder용 HotPDF Component의 일부로, Delphi와 C++Builder 코드에서 PDF 문서를 직접 생성합니다