Technický článek

Výběr textového řádku PDF pomocí znakových boxů PDFium v Delphi

Textová stránka PDF vystavuje znaky a boxy, nikdy řádky. PDFium Component staví vizuální řádek shlukováním znakových boxů, jejichž vertikální středy padnou do poloviny výšky výchozího znaku, a prohledává směrem ven od kliknutého znaku, dokud se tolerance nepřekročí. Každá cesta výběru v prohlížeči volá tohoto jednoho pomocníka, takže myš, klávesnice i kód se shodují

Symptom, který vás sem přivede, je konkrétní a nepříjemný. Uživatel trojklikne na odstavec ve dvousloupcovém reportu a dostane půl stránky. Nebo trojklikne na buňku tabulky a výběr pohltí celý řádek plus číslo stránky v patičce. Prohlížeč není rozbitý; klade otázku, na kterou soubor neumí odpovědět. V PDF neexistuje řádek k výběru, a jakákoli implementace, která předstírá opak, hádá. Tento článek je o tom, jak udělat hádání záměrné a konzistentní. Pokud skutečně potřebujete jen vytáhnout text z dokumentu, viz extrakce textu z dokumentů PDF pomocí PDFium; pokud rozvržujete text a potřebujete šířky, viz měření textu a zalamování slov. Zde je téma užší: rozhodnout, kde vizuální řádek začíná a končí, a vybrat přesně to

Proč textová stránka PDF nemá žádné objekty řádků?

Protože content stream PDF popisuje kreslení, ne strukturu. ISO 32000-1 §9.4 definuje textový objekt jako dvojici BT / ET obsahující polohovací a zobrazovací operátory. Polohovací operátory z §9.4.2 (Td, TD, Tm, T*) přesouvají textovou matici po stránce a zobrazovací operátory z §9.4.3 (Tj, TJ, ', ") vykreslují glyfy tam, kam matice právě ukazuje. Nic v tomto modelu neříká „tento úsek glyfů je řádek". Řádek je to, co vidí člověk, až je vykreslování hotové

Producenti to komplikují způsoby, které nemůžete ovlivnit. Zarovnaný odstavec může být vydán jako jedno pole TJ na řádek, nebo jako jedno Tj na slovo s explicitním Tm před každým, nebo jako jedna zobrazovací operace s úpravami kerningu nesoucí mezery. Dvousloupcové rozvržení může vydat levý sloupec shora dolů a poté pravý sloupec, nebo je může proložit, pokud producent procházel svůj vlastní vnitřní seznam objektů v jiném pořadí. Sekvence znaků, kterou vám PDFium předá, sleduje content stream, a content stream sleduje, cokoli se generující aplikaci zachtělo dělat. Takže dvě funkce, které skutečně máte, jsou FPDFText_CountChars, která hlásí, kolik znaků stránka obsahuje, a FPDFText_GetCharBox, která vrací ohraničující box jednoho znaku v prostoru stránky. To je celý surový slovník. Vše nad tím, slova, řádky, odstavce, sloupce, je inference, kterou provádíte nad geometrií

Proč je detekce CR a LF špatný test?

Protože znaky, proti kterým byste testovali, nejsou spolehlivě přítomné, a když přítomné jsou, nejsou spolehlivě vaše. PDFium vkládá syntetické znaky do textové stránky, aby byl extrahovaný text čitelný: mezeru tam, kde jsou dva úseky vizuálně odděleny, CR nebo LF tam, kde další úsek začíná na nové základní linii. FPDFText_IsGenerated existuje přesně proto, abyste je mohli odlišit od znaků, které pocházejí ze souboru, a PDFium Component to vystavuje jako vlastnost CharacterGenerated

Rozdělíte-li podle těchto znaků, zdědíte každé rozhodnutí, které PDFium udělalo při jejich syntéze. Tvrdý zalom řádku uvnitř zabaleného odstavce a měkké zalomení vypadají po syntéze identicky. Řádek tabulky, který producent vydal buňku po buňce, může nedostat žádné zalomení mezi poslední buňkou a první buňkou dalšího řádku, protože základní linie jsou náhodou dostatečně blízko. Mezitím nadpis následovaný tělem textu v jiné velikosti může dostat dvě zalomení tam, kde člověk vidí jedno. Generované znaky jsou vykreslovací pohodlí pro extrakci celé stránky; nejsou modelem řádku a degradují přesně v dokumentech, kde na výběru nejvíc záleží

Shlukování znakových boxů podle vertikálního středu

Spolehlivým signálem je geometrie. Vezměte znak, na který uživatel klikl, jako výchozí bod, spočítejte vertikální střed jeho boxu a prohledávejte směrem ven oběma směry, dokud sousední boxy udržují své vertikální středy v toleranci. PDFium Component používá jako tuto toleranci polovinu výšky výchozího boxu, s dolní hranicí 0.5 jednotky stránky, aby se degenerované boxy — tečka, tenká mezera, glyf s téměř nulovou výškou boxu — nezhroutily toleranci na nic a nepřerušily řádek po jednom znaku

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;

Tři detaily v této smyčce mají své opodstatnění. Tolerance se odvozuje z výchozího bodu místo z konstanty, takže 24bodový nadpis dostane široké pásmo a 7bodová poznámka pod čarou úzké, a žádný neubírá znaky tomu druhému. Porovnání používá vertikální středy místo základních linií nebo horních okrajů boxu, což udrží horní index, vloženou pasáž jiné velikosti nebo větu se smíšeným fontem na stejném řádku jako její sousedy. A neúspěšné FPDFText_GetCharBox ukončí skenování místo toho, aby bylo přeskočeno, protože znak bez zjistitelné geometrie neposkytuje důkaz ani jedním směrem, a pokračování za ním by umožnilo prohledávání přeskočit přes skutečnou hranici na základě znaku o kus dál

Proč musí každá cesta výběru sdílet jednoho pomocníka?

Protože tři cesty kódu, z nichž každá implementuje „řádek", se od sebe rozejdou, a rozejdou se potichu. V PDFium Component prochází rozšíření při trojkliku, Shift+Home, Shift+End a veřejná metoda SelectLineAt svá vymezení přes stejné volání LineRangeAt. Trojklik jej inicializuje z kotvy výběru; klávesy shift jej inicializují z kurzoru výběru a pohybují pouze tímto koncem; SelectLineAt jej inicializuje z indexu znaku dodaného volajícím a předá výsledek do SelectTextRange, stejného validátoru rozsahu, který používá cesta myši. Duplikujte tuto logiku místo toho a selhání není pád, je to pomalý drift. Někdo doladí toleranci trojkliku, aby opravil report s těsným řádkováním, a nyní Shift+End zastaví o jeden znak dřív, než trojklik zastaví na stejném odstavci. Uživatel vybere řádek myší, rozšíří jej klávesnicí a sleduje, jak se výběr zmenšuje. Protože SelectLineAt krmí obyčejný pipeline výběru, programový výběr také zůstává nezávislý na tom, zda je vstup myši povolen, a stále zdarma dostává validaci rozsahu, překreslení a oznámení 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;

Všimněte si argumentů tolerance u CharacterIndexAtPos. Hit testing má vlastní vůli, vyjádřenou v jednotkách stránky, a je to samostatná záležitost od tolerance řádku. Kliknutí, které dopadne do proluky mezi dvěma řádky, se vyhodnotí na ten znak, který je v rámci tohoto boxu nejbližší; skenování řádku pak běží od toho, čím se tento znak ukázal být. Vložení příliš velkorysé tolerance zásahu do výchozího bodu je jedním ze snadnějších způsobů, jak vybrat řádek, na který uživatel neukazoval

Dva prostory indexů: index znaku a index textu

Jakmile máte rozsah, odolejte pokušení použít jej jako offset řetězce. FPDFText_GetText vrací text stránky jako buffer UTF-16, ale jeho indexy nejsou stejný prostor indexů jako indexy znaků, které používají FPDFText_GetCharBox a FPDFText_CountChars. Generované znaky probírané dříve sedí v textovém bufferu, přičemž zabírají sloty znaků bez použitelné geometrie, a obě číslování se napříč stránkou rozcházejí. Mosty jsou FPDFText_GetTextIndexFromCharIndex a FPDFText_GetCharIndexFromTextIndex, obalené v PDFium Component jako CharacterIndexToTextIndex a 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;

Směr, který kouše nejtvrději, je ten opačný. Vyhledávání implementované nad extrahovaným řetězcem vám dá indexy textu, a předání těch přímo do API pro box nebo výběr tiše adresuje špatné znaky, s chybou, která roste čím dál níž po stránce jdete. Konvertujte pomocí TextIndexToCharacterIndex ještě předtím, než se čísla dotkne cokoli geometrického. Náhradní páry (surrogate pairs) přidávají navrch druhý, nezávislý problém s offsetem, který popisuje článek o emoji, CJK a náhradních párech

Kde se heuristika ohýbá

Buďte k sobě upřímní ohledně limitů, protože jsou skutečné a dosažitelné. Otočený text je nejjasnější případ: znakový box je osově zarovnaný obdélník v prostoru stránky, takže u textu otočeného o 90 stupňů mají boxy jednoho vizuálního řádku vertikální středy rozptýlené po stránce, a skenování se zastaví téměř okamžitě. Dostanete krátký výběr místo chybného, což je lepší způsob selhání, ale stále je to selhání. Vertikální režimy psaní se chovají stejně ze stejného důvodu. Dvousloupcová rozvržení fungují, když jsou sloupce vertikálně vůči sobě posunuté, a selhávají, když nejsou. Sdílejí-li oba sloupce stejnou mřížku základních linií, znaky z pravého sloupce sedí v toleranci řádku levého sloupce a skenování proběhne přímo přes mezisloupcovou mezeru, protože v čisté geometrii tam není nic, u čeho by se zastavilo. Detekce tohoto vyžaduje test horizontální mezery navrch vertikálního shlukování, a volba prahu mezery je vlastní úsudek o tom, u kterých dokumentů jste ochotni být v chybě. Smíšené velikosti fontu jsou případ, který tolerance relativní k výchozímu bodu zvládá dobře: vložený 8bodový úsek kódu uvnitř 11bodového textu těla si udrží svůj střed uvnitř pásma, a 24bodový nadpis na dalším řádku nestrhne řádek těla k sobě

Sémantika výběru řádku popsaná zde je součástí komponenty PDFium pro Delphi a C++Builder, spolu s API pro hit testing, rozsah výběru a index textu použitými v příkladech; stránka produktu nese kompletní referenci pro model textové stránky a výběru