기술 문서

Delphi에서 접근성 PDF를 위한 자동 구조 태깅

생성된 보고서를 스크린 리더로 열면 1분 안에 익숙한 실패들이 나타납니다. 모든 페이지에서 "40페이지 중 3페이지"가 소리 내어 읽히고, 회사 로고는 뒤에 아무 것도 없는 "그래픽"으로 불리며, 표는 열 이름 없이 숫자의 긴 나열로 나옵니다. PDF Library for Delphi는 자동 구조 태깅으로 이 세 가지를 모두 피합니다. SetAutoTagMode를 한 번 호출하면, 평범한 드로잉 호출이 문서가 그려지는 동시에 문단과 제목, 그림, 표, 건너뛰어도 되는 페이지 장식을 만들어 냅니다

수동 경로도 여전히 있습니다. 모든 드로잉 호출을 BeginTag와 EndTag로 감쌀 수 있고, 구조가 특이한 문서라면 그것이 맞는 도구입니다. 하지만 평범한 보고서, 청구서, 명세서라면 손 태깅은 접근성을, 제품의 수명 내내 무언가를 그리는 모든 코드 경로에서 아무도 한 쌍을 잊지 않는다는 조건 위에 올려 놓는 것입니다. 컴플라이언스 요구사항을 저희는 저 조건에 걸지 않겠습니다

SetAutoTagMode가 켜는 것들

SetAutoTagMode는 비트 마스크를 받고 호출 전에 적용되던 모드를 반환합니다. 비트 하나가 콘텐츠 한 종류를 담당합니다:

  • AUTOTAG_TEXT(1): 텍스트는 문단이 되고, 제목이 등록돼 있으면 제목이 됩니다
  • AUTOTAG_FURNITURE(2): 반복 머리말, 꼬리말, 페이지 번호는 리더가 건너뛰는 아티팩트가 됩니다
  • AUTOTAG_FIGURE(4): 그려진 이미지는 그림이 되고, 장식으로 선언했다면 아티팩트가 됩니다
  • AUTOTAG_TABLE(8): 그려진 표는 행과 셀까지 구조 트리에 옮겨 넣습니다
  • AUTOTAG_DEFAULT(15): 네 가지 모두. 새 보고서 코드에 저희가 권하는 값이기도 합니다

모드를 켜면 문서가 태깅된 것으로 표시되는데, 이 단계가 겉보기보다 중요합니다. ISO 32000-1 §14.7.1에 따르면 카탈로그의 /MarkInfo 사전이 /Marked true라고 말해 주지 않으면 리더는 파일을 태깅되지 않은 것으로 취급합니다. 완전하고 올바른 구조 트리를 통째로 담은 파일도, 보조 기술에게 트리를 찾으라고 알려 주는 플래그 하나가 빠지면 구조가 전혀 없다고 안내받을 수 있습니다. 트리는 있는데 아무도 그것을 읽지 않습니다

텍스트 태깅은 호출 단위로 동작합니다. DrawText, DrawWrappedText, DrawTextBox 호출 하나가 요소 하나가 되므로, 다섯 번의 DrawText로 한 줄씩 그린 문단은 리더에게 다섯 개의 한 줄짜리 문단으로 들립니다. 레이아웃 코드가 이미 줄바꿈을 스스로 한다면 바꿀 가치가 있습니다. 문단 전체를 DrawWrappedText에 넘겨 호출 하나가 문단 하나를 내놓게 하세요

SetAutoTagMode 비트 마스크가 평범한 Delphi 드로잉 호출을 태그된 PDF 구조 트리로 매핑하는 다이어그램, 필수인 Marked 플래그 강조
SetAutoTagMode의 활성 비트 하나하나가 평범한 드로잉 호출을 구조 트리의 대응 요소로 라우팅합니다
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);                          // 왼쪽 위 원점, y는 아래로 증가
    Lib.SetDocumentLanguage('en-US');
    Lib.SetAutoTagMode(AUTOTAG_DEFAULT);       // RegisterHeading 호출보다 먼저
    Lib.AddStandardFont(4);

    Lib.SetTextSize(18);
    Lib.RegisterHeading(1, 'Annual service report');
    Lib.DrawText(72, 96, 'Annual service report');        // H1로 태깅됨

    Lib.SetTextSize(11);
    Lib.DrawWrappedText(72, 130, 450,
      'Every unit installed before 2024 was inspected. ' +
      'Units that failed the torque check were serviced on site.');  // 문단 하나

    // ... 나머지 페이지들 ...

    // 기존 모든 페이지를 훑으므로 페이지를 다 그린 뒤 한 번만 호출
    Lib.AddPageFooter('', 'Page &P of &N', 36, 1.0, 1);  // Pagination 아티팩트
    Lib.SaveToFile('report.pdf');
  finally
    Lib.Free;
  end;
end;

셋째 줄의 주석에는 이유가 있습니다. RegisterHeading은 텍스트 태깅이 이미 켜져 있을 때만 제목 레벨을 큐에 넣고, SetAutoTagMode는 실행될 때 큐에 남아 있던 레벨을 비웁니다. 제목을 먼저 등록한 뒤에 모드를 켜면, 제목은 평범한 문단으로 나오는데 어디에도 오류는 없습니다

제목은 왜 바로 앞에서 그린 이미지를 기다렸다가 붙을까?

RegisterHeading은 다음 텍스트 조각의 레벨을 정하고, 그 레벨을 소비할 수 있는 것은 텍스트뿐입니다. 사이에 이미지가 그려지면 이미지는 그림이 되고, 제목은 뒤따르는 텍스트를 위해 큐에 남습니다. 어떤 태그든 큐의 레벨을 소비하게 만드는 뻔한 구현이라면, 섹션 제목을 등록하고 배너 이미지를 그린 뒤 진짜 제목을 그리는 흔한 순서에서 배너가 H2로 태그되고 진짜 제목은 문단으로 강등됩니다. 제목으로 내비게이션하던 리더는 그림 위에 착지합니다

큐가 동작하는 방식에서 두 가지가 더 따라 나옵니다. 제목 레벨은 RegisterHeading에 어떤 문자열을 넘겼든 다음 텍스트에 붙습니다. 레이아웃이 제목 위에 작은 "Section 3" 킥커를 그린다면 킥커가 제목이 됩니다. 그리고 6보다 깊은 레벨은 H6으로 기록되는데, 리더는 그 너머를 알지 못합니다. Title 인수는 자기 본론대로 DrawTOC가 나중에 목차로 만들 아웃라인을 채워 줍니다

이미지에도 같은 "한 항목에 소진" 처리가 적용됩니다. RegisterFigure는 다음 이미지가 달고 다닐 설명을 공급하고, RegisterDecoration은 다음 이미지를 의미 없는 구분선, 테두리, 배경으로 선언하는데, 그 이미지는 Layout 아티팩트로 기록됩니다(ISO 32000-1 §14.8.2.2). 선언 하나는 정확히 이미지 하나에 소비되므로, 나중에 그린 이미지가 앞선 이미지를 위해 쓴 설명을 물려받는 일은 없습니다. 엉뚱한 그림에 붙은 대체 텍스트는 아예 없는 것보다 나쁩니다. 리더가 확신에 차서 거짓을 말하기 때문입니다

어떤 이미지가 장식인지 추리는 기능은 일부러 만들지 않았습니다. 이미지는 리더가 설명해야 할 콘텐츠이거나 건너뛰어야 할 콘텐츠인데, 어느 쪽인지는 호출하는 코드만 알기 때문입니다. 피해야 할 경우는 나머지 셋째 경우입니다. 아무 선언 없이 그린 이미지는 설명 없는 그림이 되는데, 이것은 PDF/UA를 통과하지 못하고 스크린 리더를 "그래픽" 하고 말한 뒤 아무 것도 이어가지 못하게 만듭니다. 보고서 코드에서 DrawImage를 grep해 보고, 걸린 곳마다 바로 위에 RegisterFigure나 RegisterDecoration이 있는지 확인하세요

등록된 PDF 제목이 사이에 그린 이미지를 건너뛰고 다음 텍스트까지 대기하는 모습과, RegisterFigure와 RegisterDecoration이 각각 정확히 하나의 이미지에 소비되는 모습을 보여 주는 PDF Library for Delphi 다이어그램
RegisterHeading은 그 사이에 무엇이 그리든 다음 텍스트까지 기다립니다
Lib.RegisterHeading(2, 'Gearbox inspection');   // 다음 텍스트를 위해 대기

Lib.RegisterFigure('Exploded view of the gearbox assembly');
Lib.AddImageFromFile('gearbox.png', 0);       // 이미지를 로드하고 선택
Lib.DrawImage(72, 160, 360, 270);             // 태그된 Figure가 됨

Lib.RegisterDecoration;                       // 의미 없는 구분선
Lib.AddImageFromFile('divider.png', 0);
Lib.DrawImage(72, 450, 360, 4);               // layout 아티팩트 안에 그려짐

Lib.DrawText(72, 480, 'Gearbox inspection');  // 이제서야 H2가 나타남

설명은 문서의 그 어떤 문자열보다 세심한 주의를 받아 마땅합니다. 시각장애 독자는 그림 대신 설명을 받으며, 그것이 돌아오는 전부입니다. "Chart"는 아무것도 알려 주지 않습니다. "분기별 지역 매출, 3분기에는 동부 지역이 최고"는 시각 독자가 이미지에서 두 초 만에 얻는 것을 말해 줍니다

표: 헤더 행은 선언하지 절대 추측하지 않습니다

표 비트가 켜져 있으면 DrawTableRows는 표와 그 행, 셀을 구조 트리에 기록하므로, 리더는 격자를 무관한 텍스트 나열로 읽어 버리는 대신 값이 어느 열에 앉아 있는지 말할 수 있습니다. SetTableHeaderRowCount는 앞쪽 몇 개의 행이 헤더인지 알려 줍니다. 그 행들은 column scope을 단 TH 셀로 기록되는데, 덕분에 리더는 사용자가 옮겨간 값 앞에 "Torque"를 먼저 말해 줄 수 있습니다

헤더 행 수의 기본값은 0이고, 이것은 저희가 내린 선택입니다. 표의 첫 행이 대개 헤더이니 추측했다면 대부분 맞았을 겁니다. 하지만 틀린 추측의 비용은 한쪽으로 기울어 있습니다. 데이터 행을 헤더로 잘못 태그하면 그 열의 모든 값 앞에서 소리 내어 읽히는데, 이는 헤더가 아예 없는 표보다 훨씬 혼란스럽습니다. 여러분이 내는 코드 한 줄이 그 실패 모드보다 쌉니다

헤더 행을 선언하는 것과 매 페이지마다 반복하는 것은 별개의 결정입니다. SetTableHeaderRowCount는 의미론적인 것이고 페이지의 아무 것도 바꾸지 않습니다. 반복은 레이아웃이고, DrawTaggedTableRows의 RepeatHeaderRows 인수에 속합니다. 둘을 하나의 설정으로 묶었다면, 올바른 헤더 태그를 요청하는 순간 요청한 적 없는 반복 헤더 밴드 그리기도 함께 시작되고, 페이지마다 검증돼 있던 레이아웃이 밀려났을 겁니다. RepeatHeaderRows로 넘긴 행들도 헤더로 태그되므로 같은 말을 두 번 할 필요는 없습니다

표 헤더 셀이 구조 트리에서 column scope을 달고 있는 한편, RepeatHeaderRows는 페이지 넘김 반복을 레이아웃 전용 결정으로 남겨 두는 PDF Library for Delphi 다이어그램
헤더 행은 구조 트리에 한 번 기록되고, 각 TH 셀에 column scope이 붙습니다
var
  TableID: Integer;
begin
  TableID := Lib.CreateTable(40, 3);
  Lib.SetTableHeaderRowCount(TableID, 1);       // 1행은 헤더 밴드
  Lib.SetTableCellContent(TableID, 1, 1, 'Part');
  Lib.SetTableCellContent(TableID, 1, 2, 'Torque');
  Lib.SetTableCellContent(TableID, 1, 3, 'Unit');
  // ... 데이터 행을 채움 ...
  // 1..40행을 600pt 밴드에 그리며 페이지마다 헤더 행 하나를 반복
  Lib.DrawTaggedTableRows(TableID, 72, 150, 600, 1, 40, 1);
end;

자동 문서 안의 손 태깅 구간

손으로 연 태그가 아직 열려 있는 동안에는 자동 태깅이 물러섭니다. 문서의 일부를 코드가 직접 설명하고 나머지는 라이브러리에 맡기되, 둘이 서로 안으로 중첩되는 일이 없게 하는 장치이며, 실제 문서 대부분이 원하는 배치입니다. 표지와 서명 블록의 구조는 여러분만 이해합니다. 그 사이의 이백 페이지 본문은 그렇지 않습니다

세 가지 규칙이 어떤 조합이든 출력을 유효하게 지켜 줍니다. 아티팩트 안에서는 아무것도 태그되지 않습니다. 아티팩트의 marked-content 시퀀스는 구조 요소를 동시에 담을 수 없고, 그것을 시도한 파일은 PDF/UA 검증에 실패합니다. 빈 텍스트는 요소를 열지 않으므로, 빈 문자열로 호출된 엉뚱한 DrawText가 리더가 빈 것으로 안내하는 요소를 남겨 두지 않습니다. 그리고 내부적으로 다른 드로잉 호출을 통과하는 라이브러리 호출은 바깥 수준에서 태그 하나를 열어, 그 조각들이 자기들만의 두 번째 요소 안에 중첩되어 끝나는 일이 없습니다

저장한 뒤에는 트리를 다시 읽어 확인하고 믿으세요. 읽기 API는 1 기반인데, 저희도 이것에 걸렸습니다. 첫 확인 루프를 0부터 StructTreeItemCount - 1까지 돌렸더니 시작에서 빈 레코드를 얻고 마지막 요소에 닿지 못했고, 한동안 문단 하나가 사라졌다고 확신했습니다. 사라진 것은 없었습니다. 루프가 양쪽 끝에서 하나씩 어긋났을 뿐입니다

var
  I: Integer;
begin
  // 손으로 태깅한 풀쿼트. 자동 태깅은 여기에 끼어들지 않는다
  Lib.BeginTag('BlockQuote', '', '');
  Lib.DrawWrappedText(72, 520, 450,
    'All readings were taken with the unit at operating temperature.');
  Lib.EndTag;
  Lib.SaveToFile('report.pdf');

  // 트리를 다시 읽는다. 인덱스는 1부터 StructTreeItemCount까지
  if Lib.LoadFromFile('report.pdf', '') = 1 then
    for I := 1 to Lib.StructTreeItemCount do
      Writeln(Lib.GetStructTreeItemText(I, 2), '  ',    // 해석된 타입
              Lib.GetStructTreeItemText(I, 4));         // /Alt, 있다면
end;

이런 목록은 빠른 위생 점검이지 판결문이 아닙니다. 확인하고 싶은 것은 첫 문단 앞에 나오는 H1, 각 그림이 설명을 달고 있는 것, /Alt가 빈 그림이 없는 것입니다

그리는 순서와 읽는 순서가 갈라질 때

자동 태깅은 콘텐츠가 그려진 순서대로 요소를 기록합니다. 레이아웃 코드가 본문보다 사이드바를 먼저 그린다면 트리가 담는 순서도, 스크린 리더가 읽는 순서도 그것입니다. 시각적 순서와 읽기 순서가 실제로 갈라지는 문서, 다른 구조 타입이나 요소별 언어가 필요한 문서라면 수동 태깅 API가 더 알맞고, 태그된 PDF와 접근성 구조 안내가 역할, 범위, 헤더 바인딩을 자세히 다룹니다

직접 그리지 않은 페이지, 그러니까 marked content가 없는 가져온 페이지에는 AutoTagPages가 사후에 동작합니다. 주어진 페이지들의 텍스트 크기 중간값을 본문 텍스트로 잡고, 눈에 띄게 큰 텍스트 뭉치는 제목으로 취급하며, 나머지 그리기는 아티팩트로 표시합니다. 재생성할 수 없는 콘텐츠에는 맞는 도구입니다. 자기 코드가 그리는 것이라면 저희는 그리는 시점의 태깅을 선호합니다. 라이브러리가 글꼴 크기로 추측하는 대신 무엇이 제목인지 들으니까요

문서가 완성되면 가정 대신 검사기를 돌리세요. PDF/A와 PDF/UA preflight 노트는 만들어 낸 구조의 판정을 얻는 방법을 보여 주고, 데이터셋 기반 보고서 내보내기 글은 데이터에서 레이아웃을 만드는 보고서 엔진에서 이 호출들이 어디에 앉는지 보여 줍니다

PDF Library for Delphi는 Delphi, C++Builder, Lazarus를 위한 네이티브 Pascal로 작성돼 외부 PDF 런타임을 배포할 필요가 없으므로, 태그된 출력은 페이지를 그리는 코드 그 자체에서 나옵니다. 전체 API 레퍼런스와 플랫폼 목록은 PDF Library for Delphi 제품 페이지에 있습니다