PDF 텍스트 페이지는 문자와 상자만 노출할 뿐 줄은 절대 노출하지 않습니다. PDFium Component는 클릭된 문자에서 바깥으로 스캔하면서, 세로 중심이 시드 문자 높이의 절반 이내에 들어오는 문자 상자들을 클러스터링해서 시각적 줄을 만듭니다. 뷰어의 모든 선택 경로는 그 하나의 헬퍼를 호출하므로 마우스, 키보드, 코드가 서로 일치합니다
이 글을 찾아보게 만드는 증상은 구체적이고 불쾌합니다. 사용자가 2단 리포트의 한 문단을 트리플 클릭했더니 페이지의 절반이 선택됩니다. 또는 표 셀을 트리플 클릭했더니 선택 영역이 전체 행과 바닥글의 페이지 번호까지 삼켜버립니다. 뷰어가 고장 난 게 아니라 파일이 답할 수 없는 질문을 던지고 있는 것입니다. PDF에는 선택할 줄이 없으며, 그렇지 않은 척하는 구현은 무엇이든 추측을 하고 있는 것입니다. 이 글은 그 추측을 의도적으로 만들고 일관되게 만드는 것에 관한 것입니다. 여러분이 실제로 필요한 것이 문서에서 텍스트를 뽑아내는 것이라면 PDFium으로 PDF 문서에서 텍스트 추출하기를 보십시오. 텍스트를 배치하며 너비가 필요하다면 텍스트 측정과 줄바꿈을 보십시오. 여기서 다루는 주제는 더 좁습니다. 시각적 줄이 어디서 시작하고 끝나는지 결정하고 정확히 그것을 선택하는 것입니다
PDF 텍스트 페이지에는 왜 줄 객체가 없는가
PDF 콘텐츠 스트림은 그리기를 기술하지 구조를 기술하지 않기 때문입니다. ISO 32000-1 §9.4는 텍스트 객체를 위치 지정 연산자와 표시 연산자를 담은 BT/ET 쌍으로 정의합니다. §9.4.2의 위치 지정 연산자(Td, TD, Tm, T*)는 텍스트 행렬을 페이지 안에서 움직이고, §9.4.3의 표시 연산자(Tj, TJ, ', ")는 그 행렬이 현재 가리키는 곳 어디든 글리프를 칠합니다. 이 모델의 어떤 것도 "이 글리프 연속이 하나의 줄이다"라고 말하지 않습니다. 줄이란 그리기가 끝난 뒤 사람이 보는 것입니다
생성기들은 이를 여러분이 통제할 수 없는 방식으로 더 나쁘게 만듭니다. 양쪽 정렬된 문단은 줄마다 하나의 TJ 배열로 출력될 수도 있고, 각 단어 앞에 명시적인 Tm과 함께 단어마다 하나의 Tj로 출력될 수도 있고, 커닝 조정이 간격을 나르는 단일 표시 연산으로 출력될 수도 있습니다. 2단 레이아웃은 왼쪽 열을 위에서 아래로 출력한 다음 오른쪽 열을 출력할 수도 있고, 생성 애플리케이션이 자신의 내부 객체 목록을 다른 순서로 순회했다면 두 열을 섞어서 출력할 수도 있습니다. PDFium이 여러분에게 넘기는 문자 시퀀스는 콘텐츠 스트림을 따르고, 콘텐츠 스트림은 생성 애플리케이션이 하고 싶었던 대로를 따릅니다. 그래서 여러분이 실제로 얻는 두 함수는 페이지가 담은 문자 수를 알려주는 FPDFText_CountChars와 페이지 공간에서 한 문자의 경계 상자를 반환하는 FPDFText_GetCharBox입니다. 이것이 원시 어휘 전부입니다. 그 위의 모든 것, 단어, 줄, 문단, 열은 여러분이 기하에 대해 수행하는 추론입니다
CR과 LF 감지는 왜 잘못된 검사인가
여러분이 검사하려는 문자들이 신뢰성 있게 존재하지 않고, 존재하더라도 신뢰성 있게 여러분의 것이 아니기 때문입니다. PDFium은 추출된 텍스트를 읽을 수 있게 만들기 위해 텍스트 페이지에 합성 문자를 주입합니다. 두 실행이 시각적으로 분리된 곳에는 공백을, 다음 실행이 새 베이스라인에서 시작하는 곳에는 CR이나 LF를 넣습니다. FPDFText_IsGenerated는 정확히 이런 것들을 파일에서 나온 문자와 구별할 수 있도록 존재하며, PDFium Component는 이를 CharacterGenerated 속성으로 노출합니다
이 문자들로 분할하면 PDFium이 그것들을 합성하면서 내린 모든 판단을 그대로 물려받게 됩니다. 줄바꿈된 문단 안의 강제 줄바꿈과 부드러운 줄바꿈은 합성 후에는 똑같아 보입니다. 생성기가 셀 단위로 출력한 표 행은 마지막 셀과 다음 행의 첫 셀 사이에 베이스라인이 충분히 가깝다는 이유만으로 아무런 줄바꿈도 얻지 못할 수 있습니다. 한편 다른 크기의 본문 텍스트가 이어지는 제목은 사람이 하나로 보는 곳에서 두 개의 줄바꿈을 얻을 수 있습니다. 합성 문자는 전체 페이지 추출을 위한 렌더링 편의이며, 줄 모델이 아니고, 선택이 가장 중요한 바로 그 문서에서 정확히 성능이 떨어집니다
문자 상자를 세로 중심으로 클러스터링하기
신뢰할 수 있는 신호는 기하입니다. 사용자가 클릭한 문자를 시드로 삼아 그 상자의 세로 중심을 계산하고, 이웃 상자들이 허용 오차 안에서 세로 중심을 유지하는 동안 양방향으로 바깥을 향해 걸어갑니다. PDFium Component는 시드 상자 높이의 절반을 그 허용 오차로 사용하며, 페이지 단위 0.5의 하한을 둡니다. 마침표, 얇은 공백, 높이가 거의 0인 글리프 같은 퇴화된 상자가 허용 오차를 아예 없애버려서 한 문자 만에 줄을 끊지 않도록 하기 위함입니다
function TPdfView.LineRangeAt(TxtPage: FPDF_TEXTPAGE; CharIndex: Integer;
out StartIndex, Count: Integer): Boolean;
var
Lo, Hi, Total: Integer;
SeedBox, Box: TPdfRectangle;
SeedYMid, BoxYMid, HalfH: Double;
begin
Result := False;
StartIndex := -1;
Count := 0;
Total := FPDFText_CountChars(TxtPage);
if (CharIndex < 0) or (CharIndex >= Total) then
Exit;
if FPDFText_GetCharBox(TxtPage, CharIndex, SeedBox.Left, SeedBox.Right,
SeedBox.Bottom, SeedBox.Top) = 0 then
Exit;
SeedYMid := (SeedBox.Top + SeedBox.Bottom) / 2;
HalfH := Abs(SeedBox.Top - SeedBox.Bottom) / 2;
if HalfH < 0.5 then // floor for degenerate boxes
HalfH := 0.5;
Lo := CharIndex;
Hi := CharIndex;
while Lo > 0 do
begin
if FPDFText_GetCharBox(TxtPage, Lo - 1, Box.Left, Box.Right,
Box.Bottom, Box.Top) = 0 then
Break;
BoxYMid := (Box.Top + Box.Bottom) / 2;
if Abs(BoxYMid - SeedYMid) > HalfH then
Break;
Dec(Lo);
end;
while Hi < Total - 1 do
begin
if FPDFText_GetCharBox(TxtPage, Hi + 1, Box.Left, Box.Right,
Box.Bottom, Box.Top) = 0 then
Break;
BoxYMid := (Box.Top + Box.Bottom) / 2;
if Abs(BoxYMid - SeedYMid) > HalfH then
Break;
Inc(Hi);
end;
StartIndex := Lo;
Count := Hi - Lo + 1;
Result := True;
end;
이 루프 안의 세 가지 세부 사항이 제자리를 정당화합니다. 허용 오차는 상수가 아니라 시드에서 유도되므로, 24pt 제목은 넓은 밴드를, 7pt 각주 텍스트는 좁은 밴드를 얻으며, 어느 쪽도 다른 쪽의 문자를 훔치지 않습니다. 비교는 베이스라인이나 상자 상단이 아니라 세로 중심을 사용하는데, 이는 위첨자, 인라인으로 다른 크기의 실행, 또는 폰트가 섞인 문장을 이웃과 같은 줄에 유지시켜줍니다. 그리고 실패한 FPDFText_GetCharBox는 건너뛰는 대신 스캔을 종료시키는데, 조회 가능한 기하가 없는 문자는 어느 쪽으로도 증거를 주지 않으며, 이를 지나쳐 계속하면 그 너머의 어떤 문자의 존재만으로 진짜 경계를 뛰어넘게 될 수 있기 때문입니다
모든 선택 경로는 왜 하나의 헬퍼를 공유해야 하는가
"줄"을 각자 구현하는 세 개의 코드 경로는 어긋날 것이고, 그것도 조용히 어긋날 것이기 때문입니다. PDFium Component에서는 트리플 클릭 확장, Shift+Home, Shift+End, 그리고 공개 SelectLineAt 메서드가 모두 같은 LineRangeAt 호출을 통해 자신의 경계를 해석합니다. 트리플 클릭은 선택 앵커에서 시드를 얻고, 시프트 키들은 선택 커서에서 시드를 얻어 그 끝만 움직이며, SelectLineAt은 호출자가 제공한 문자 인덱스에서 시드를 얻어 결과를 마우스 경로가 사용하는 것과 같은 범위 검증기인 SelectTextRange에 넘깁니다. 로직을 중복시키면 실패는 크래시가 아니라 느린 표류가 됩니다. 누군가 좁은 행간의 리포트를 고치려고 트리플 클릭 허용 오차를 조정하면, 이제 같은 문단에서 Shift+End가 트리플 클릭이 멈추는 지점보다 한 문자 짧게 멈춥니다. 사용자가 마우스로 줄을 선택하고 키보드로 확장했더니 선택 영역이 줄어드는 것을 보게 됩니다. SelectLineAt이 일반 선택 파이프라인을 통과하기 때문에, 프로그래밍 방식 선택도 마우스 입력이 활성화되어 있는지 여부와 무관하게 유지되고, 여전히 범위 검증, 다시 그리기, OnSelectionChange 알림을 공짜로 받습니다
// Select the visual line under a client-space point, then read it back
procedure TForm1.SelectLineUnderCursor(X, Y: Integer);
var
CharIndex: Integer;
begin
CharIndex := PdfView1.CharacterIndexAtPos(X, Y, 6.0, 6.0);
if CharIndex < 0 then
Exit;
if PdfView1.SelectLineAt(PdfView1.CurrentPage, CharIndex) then
Memo1.Lines.Add(PdfView1.SelectedText);
end;
CharacterIndexAtPos의 허용 오차 인자에 주목하십시오. 히트 테스트는 페이지 단위로 표현되는 자기만의 여유를 가지며, 이는 줄 허용 오차와는 별개의 관심사입니다. 두 줄 사이의 행간에 떨어진 클릭은 그 상자 안에서 가장 가까운 문자로 해소되며, 줄 스캔은 그렇게 나온 문자가 무엇이든 그것부터 실행됩니다. 너무 관대한 히트 허용 오차를 시드에 넣는 것은 사용자가 가리키지 않은 줄을 선택하게 만드는 쉬운 방법 중 하나입니다
두 개의 인덱스 공간: 문자 인덱스와 텍스트 인덱스
범위를 얻었다면 그것을 문자열 오프셋으로 사용하려는 충동을 참으십시오. FPDFText_GetText는 페이지 텍스트를 UTF-16 버퍼로 반환하지만, 그 인덱스는 FPDFText_GetCharBox와 FPDFText_CountChars가 사용하는 문자 인덱스와 같은 인덱스 공간이 아닙니다. 앞서 논의한 합성 문자들은 사용 가능한 기하가 없는 문자 슬롯을 차지하면서도 텍스트 버퍼 안에 자리하며, 두 번호 체계는 페이지 전체에서 점점 어긋납니다. 그 다리 역할을 하는 것이 FPDFText_GetTextIndexFromCharIndex와 FPDFText_GetCharIndexFromTextIndex이며, PDFium Component에서는 CharacterIndexToTextIndex와 TextIndexToCharacterIndex로 감싸져 있습니다
var
TextStart, TextEnd: Integer;
begin
// char-index range from LineRangeAt -> offsets into the page text buffer
TextStart := Pdf.CharacterIndexToTextIndex(StartIndex);
TextEnd := Pdf.CharacterIndexToTextIndex(StartIndex + Count - 1);
if (TextStart >= 0) and (TextEnd >= TextStart) then
Caption := Pdf.Text(TextStart, TextEnd - TextStart + 1);
end;
가장 심하게 물어뜯는 방향은 역방향입니다. 추출된 문자열 위에 구현된 검색은 텍스트 인덱스를 여러분에게 주며, 그것을 상자나 선택 API에 그대로 넘기면 조용히 잘못된 문자를 가리키게 되고, 그 오차는 페이지 아래로 내려갈수록 커집니다. 기하와 관련된 무언가가 그 숫자를 건드리기 전에 TextIndexToCharacterIndex로 변환하십시오. 서로게이트 쌍은 그 위에 두 번째, 독립적인 오프셋 문제를 더하며, 이모지, CJK, 서로게이트 쌍 글에서 다룹니다
이 휴리스틱이 휘는 지점
한계가 실재하고 도달 가능하다는 점을 스스로에게 솔직히 말해두십시오. 회전된 텍스트가 가장 명확한 경우입니다. 문자 상자는 페이지 공간에서 축 정렬된 사각형이므로, 90도 회전된 텍스트의 경우 하나의 시각적 줄에 속한 상자들의 세로 중심이 페이지 전체에 흩어져 있고, 스캔은 거의 즉시 멈춥니다. 여러분이 얻는 것은 잘못된 선택이 아니라 짧은 선택이며, 이는 더 나은 실패 형태이지만 여전히 실패입니다. 세로쓰기 모드도 같은 이유로 똑같이 동작합니다. 2단 레이아웃은 열들이 서로 세로로 어긋나 있을 때는 작동하고 그렇지 않을 때는 깨집니다. 두 열이 같은 베이스라인 그리드를 공유한다면 오른쪽 열의 문자들이 왼쪽 열 줄의 허용 오차 안에 들어오고, 스캔은 순수 기하에는 멈출 것이 없기 때문에 거터를 곧장 가로질러 실행됩니다. 이를 감지하려면 세로 클러스터링 위에 가로 간격 검사가 필요하며, 그 간격 임계값을 고르는 것은 어떤 문서에 대해 틀려도 괜찮은지에 대한 스스로의 판단입니다. 폰트 크기 혼합은 시드 기준 허용 오차가 잘 처리하는 경우입니다. 11pt 본문 텍스트 안의 인라인 8pt 코드 조각은 자신의 중심을 밴드 안에 유지하고, 다음 베이스라인의 24pt 제목은 본문 줄을 자신에게 끌어당기지 않습니다
여기서 설명한 줄 선택 의미론은 예제에서 사용된 히트 테스트, 선택 범위, 텍스트 인덱스 API와 함께 Delphi와 C++Builder용 PDFium Component에 제공됩니다. 제품 페이지에는 텍스트 페이지와 선택 모델에 대한 전체 레퍼런스가 실려 있습니다