책상 위에 이런 요청이 올라온다. 이미 렌더링된 명세서 묶음에서 계좌번호를 검게 가리고, 종이를 아끼기 위해 한 장에 두 페이지씩 실어 보내 달라는 것이다. 이 작업의 두 절반 모두 당신이 직접 만든 적 없는 PDF에 대한 content-stream 수술이다. 그래서 편하게 그릴 수 있는 page canvas도 없고, 기대고 쓸 font manager도 없다. 다른 도구가 배치해 둔 페이지에 raw drawing operator를 덧붙이면서, 로드된 문서의 object graph를 직접 편집하고 있는 셈이다. HotPDF는 이 용도에 정확히 두 개의 진입점을 제공하는데, 더 위험한 쪽이 겉보기에는 오히려 무해해 보인다
HotPDF는 Delphi와 C++Builder용 네이티브 VCL PDF 컴포넌트다. round-nine loaded-document API는 디스크에서 연 페이지에, 처음부터 만든 새 페이지가 아니라 이미 존재하는 페이지에, 완전히 새로운 내용을 생성하는 첫 메서드들을 추가했다. 여기서 다루는 두 메서드는 RedactLoadedRect와 StitchLoadedPage다. 전자는 특정 영역 위를 불투명 사각형으로 칠하고, 후자는 한 페이지를 축소해 다른 페이지 위에 그린다. 둘 다 ISO 32000-1 §8.5의 content-stream operator를 페이지의 /Contents stream에 써 넣는 방식으로 동작한다. 그 operator가 무엇을 하는지, 그리고 그만큼 중요한 문제로 무엇을 하지 않는지를 이해해야 도구가 제대로 작동하고, 데이터 유출도 피할 수 있다
로드된 페이지에 operator 덧붙이기
일반적인 HotPDF API로 페이지를 만들 때는 컴포넌트가 content stream을 소유하고, TextOut과 vector 호출을 대신 직렬화해 준다. 하지만 로드된 페이지는 다르다. 그 페이지의 /Contents는 이미 존재하는 stream object이며, 공유돼 있을 수도 있고 content array의 일부일 수도 있어서, 기존 내용을 망가뜨리지 않고 그 안에 끼워 넣어야 한다. round nine은 이를 안전하게 만드는 작은 helper 세 개를 도입했다. NewIndirectStream은 빈 버퍼와 /Length 0 항목을 가진 새로운 간접 THPDFStreamObject를 할당하고, ResolveLoadedStream은 indirect reference를 따라 실제 stream까지 내려가며, AppendLoadedStream은 stream 끝에 raw byte를 쓰고 /Length를 다시 기록해 저장된 object가 올바른 형태를 유지하게 한다
두 public method가 따르는 패턴은 같다. 페이지의 /Contents를 찾고, 그것을 stream으로 해석하며, 쓸 수 있는 stream이 없으면 새로 만들어 연결한다. 그런 다음 operator를 덧붙인다. 새 바이트는 stream의 끝에 들어가므로, painter 모델에 따라 원래 레이아웃이 그려 놓은 모든 것 위에 렌더링된다. 이 순서가 redaction 사각형이 동작하는 전부이며, 동시에 그 사각형이 많은 사람이 생각하는 것과 다른 이유이기도 하다
RedactLoadedRect: 삭제가 아니라 불투명 덮개
RedactLoadedRect는 zero-based 페이지 인덱스, 네 개의 user-space 좌표, 그리고 0에서 1 범위의 colour component 세 개를 받는다
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('statement.pdf') > 0 then
begin
// Cover the account-number band on page 1 with solid black.
// Coordinates are PDF user space: origin bottom-left, points.
Pdf.RedactLoadedRect(0, 56, 690, 320, 706, 0, 0, 0);
Pdf.SaveLoadedDocument('statement-covered.pdf');
end;
finally
Pdf.Free;
end;
end;
내부적으로 이 메서드는 content stream에 세 가지 operator를 기록한다. DeviceRGB에서의 fill-colour 설정(r g b rg), 사각형 path(x y w h re), 그리고 fill(f)이다. 너비와 높이는 X2 - X1, Y2 - Y1로 계산되므로, 사용자는 마주 보는 두 꼭짓점만 넘기면 되고 메서드가 범위를 계산한다. 색으로 0, 0, 0을 넘기면 검은 막대가 나오고, 1, 1, 1을 넘기면 흰 페이지와 맞는 흰 막대가 나온다. 좌표는 로드된 페이지 자체의 user space 기준이므로 원점은 좌하단이고 단위는 point다. 정확히 배치하려면 페이지의 /MediaBox를 알아야 하며, GetLoadedPageBox에 pbMediaBox를 넘기면 그 정보를 얻을 수 있다
이 문장은 두 번 읽어야 한다. 채워진 사각형은 내용을 시각적으로 가릴 뿐, 제거하지 않는다. 사각형 아래의 텍스트, 이미지, vector art는 여전히 PDF 안에 있고, 여전히 object graph 안에 있으며, 페이지를 복사하거나 text extractor를 돌리거나 content stream에서 그 사각형을 지워 버릴 수 있는 누구에게나 다시 추출 가능하다. 이것은 법적 또는 보안 의미의 redaction이 아니라 visual masking이다. 계좌번호, 의료 기록, 신원 정보처럼 정말 민감한 데이터를 숨기려는 상황이라면, 검은 상자를 올려놓고 파일을 내보내는 것은 나중에 반드시 드러날 데이터 유출이다. 진짜 redaction은 밑에 깔린 content object를 삭제해야 하며, 그 위를 칠하는 것으로 끝나지 않는다
메서드 이름에 "Redact"가 들어가 있는 것은 결과가 어떻게 오해될지를 경고해 주는 데는 유용하지만, 무엇을 삭제해 준다는 약속은 아니다. 구현도 이 점을 솔직하게 드러낸다. 자체 주석에서 이를 "visual redaction primitive"라고 부르며, 실제 내용 제거형 redaction에는 기존 operator를 순회하고 재작성하는 content-stream interpreter가 필요하다고 적어 둔다. HotPDF의 loaded-document 경로는 여기서 그런 일을 하지 않는다. 따라서 안전한 규칙은 아주 좁다. RedactLoadedRect는 민감하지 않은 미관상의 가림에만 써야 한다. 예를 들면 draft watermark를 숨기거나, 스크린샷 전에 일부 영역을 비우거나, 내부 검토용 proof에서 오래된 로고를 덮는 경우다. 상자 아래의 내용이 유출됐을 때 문제가 된다면, 이 메서드는 잘못된 도구이고, 올바른 답은 해당 데이터 없이 문서를 다시 생성하거나 진짜 content-removal pipeline을 쓰는 것이다
StitchLoadedPage: 축소하고, 이동하고, 그리기
N-up 배치는 훨씬 다루기 쉬운 문제다. 숨기는 내용은 없고, 단지 재배치만 하기 때문이다. StitchLoadedPage는 대상 페이지 인덱스, 원본 페이지 인덱스, X/Y 오프셋, scale factor를 받아, 그 위치와 크기로 원본 페이지를 대상 페이지 위에 그린다
// Overlay page 2 (index 1) onto page 1 (index 0),
// scaled to 70% and nudged up-right.
Pdf.StitchLoadedPage(0, 1, 40, 380, 0.7);
// Convenience 2-up: source page on the right half of the target.
Pdf.StitchLoadedPageSideBySide(0, 1);
이 메서드가 덧붙이는 operator 문자열은 표준적인 transform-and-paint 시퀀스다. graphics state를 저장하는 q, 대각선에 scale을 넣고 translation 슬롯에 offset을 넣는 cm matrix, external object를 호출하는 /StitchSrc Do, 그리고 상태를 복원하는 Q가 이어진다. q/Q 쌍은 중요하다. 이 쌍이 transform을 고립시켜, stitch된 페이지의 좌표계가 이후에 덧붙이는 다른 내용으로 새지 않게 한다. 또한 이 메서드는 obvious mistake도 막는다. 인덱스 범위 오류, target과 source가 같은 경우, 0 이하의 scale은 감지해 1.0으로 보정하며, 예외를 던지지 않고 조용히 빠져나간다. 그래서 입력을 직접 확인해야 한다. 조용한 no-op는 겉으로는 성공과 구분되지 않기 때문이다
StitchLoadedPageSideBySide는 일반 메서드 위에 얇게 씌운 convenience helper다. target의 media-box 너비를 읽어 절반으로 나누고, 그 절반 너비를 X offset으로, 고정된 0.5 scale을 사용해 StitchLoadedPage를 호출함으로써 source를 오른쪽 절반에 놓는다. 이 하드코딩된 0.5는 source와 target이 같은 너비를 공유한다고 가정한다. 그렇지 않으면 source가 자기 절반을 깔끔하게 채우지 못하므로, 두 media box를 기준으로 직접 scale을 계산해 일반 StitchLoadedPage를 써야 한다
단순화된 XObject 전략과 ISO 수준의 타협
여기서부터는 구현이 의도적으로 택한 지름길을 알아야, 출력물을 다양한 viewer에 맡겨도 괜찮을지 판단할 수 있다. 올바른 N-up 배치는 source page의 content를 Form XObject로 감싸야 한다. ISO 32000-1 §8.10.1에 따르면, 이 self-contained drawable object는 /Type /XObject, /Subtype /Form, 그리고 자체 /BBox clipping box를 가져야 한다. 그런데 HotPDF의 round-nine stitch는 그 wrapper를 만들지 않는다. 대신 source page dictionary 자체를 target의 /Resources /XObject 아래에 StitchSrc라는 이름으로 직접 등록하고, Do로 그리도록 한다. page dict와 Form XObject는 content model을 충분히 많이 공유한다. 둘 다 content stream과 resource dictionary를 참조하기 때문에, 많은 reader에서 결과가 렌더링되기는 한다
하지만 이것은 규격에 맞는 Form XObject가 아니다. /Subtype /Form 표시도 없고 자체 /BBox도 없기 때문에, 엄격한 소비자라면 Do를 무시하거나 예상과 다르게 clipping해도 전혀 이상하지 않다. 이 round의 TechnicalNotes도 이를 분명하게 말한다. 이 방식은 "대부분의 reader에서 렌더링되지만" "엄격한 ISO 준수 Form XObject는 아니다". 완전한 준수를 원한다면, 별도의 단계에서 진짜 Form XObject stream을 합성해야 한다. 따라서 stitch 출력은 여느 비준수 구조와 같은 태도로 다뤄야 한다. 내 PC의 viewer 하나가 아니라, 고객이 실제로 쓰는 viewer들에서 확인해야 하며, archival 용도나 strict validator 통과가 필요한 PDF라면 이 경로에 의존해서는 안 된다. 문서를 프로그래밍 방식으로 변경할 때마다 Delphi에서 PDF preflight를 자동화하는 작업이 릴리스 파이프라인에서 가치가 생기는 이유도 여기에 있다
이 메서드들이 맞는 곳과 맞지 않는 곳
두 메서드 모두 content-stream 도구이므로, 사고방식은 직접 drawing을 할 때와 같다. 컴포넌트로 새 페이지를 만들어 본 적이 있다면, 이 호출 뒤에 있는 vector와 colour operator는 Delphi의 HotPDF canvas drawing 글에서 본 것들과 익숙할 것이다. 차이는 단 하나, 여기서는 자신이 소유한 stream이 아니라 다른 누군가가 작성한 stream에 덧붙인다는 점뿐이다. 다음 세 가지 경계는 꼭 기억해야 한다
- Redaction은 외형적 처리일 뿐이다.
RedactLoadedRect는 내용 위를 칠할 뿐이며 절대 삭제하지 않는다. 민감한 정보라면 소스를 다시 생성하거나 진짜 content removal을 사용해야 한다. 검은 상자는 보안이 아니다 - Stitch는 의도적으로 비준수다. source page는 §8.10.1의
/Subtype /Form과/BBox없이 pseudo-XObject처럼 참조된다. 따라서 대상 viewer에서 렌더링을 확인해야 하며, strict validation이 필요한 경우에는 피해야 한다 - 좌표는 페이지 user space 기준이다. 원점은 좌하단이고 단위는 point이며, 페이지 자신의 media box가 기준이 된다. 무엇을 배치하기 전에
GetLoadedPageBox로 box를 읽어야 한다. 로드한 페이지 크기가 당신이 가정한 것과 다를 수 있기 때문이다
이 한계를 이해하고 쓰면, 이 조합은 꽤 실용적인 workflow를 커버한다. 인쇄용으로 페이지를 재배치하고, 기밀이 아닌 영역을 가리고, SaveLoadedDocument로 다시 저장하는 일까지, 전체 재렌더링 없이 끝낼 수 있다. 이런 stitch 및 mask primitive를 포함한 loaded-document API는 Delphi와 C++Builder용 HotPDF Component에 들어 있으며, 같은 round에 추가된 form-field, annotation, FDF 메서드들과 함께 제공된다