Een PDF-tekstpagina stelt tekens en boxen bloot, nooit regels. PDFium Component bouwt een visuele regel door tekenboxen te clusteren waarvan de verticale middens binnen de helft van de hoogte van het zaadteken vallen, scannend naar buiten vanaf het aangeklikte teken totdat de tolerantie overschreden wordt. Elk selectiepad in de viewer roept die ene helper aan, zodat muis, toetsenbord en code het eens zijn
Het symptoom dat je hierheen doet zoeken, is specifiek en onaangenaam. Een gebruiker klikt driemaal op een alinea in een rapport met twee kolommen en krijgt de halve pagina. Of ze klikken driemaal op een tabelcel en de selectie verslindt de hele rij plus het paginanummer in de voettekst. De viewer is niet kapot; hij stelt een vraag die het bestand niet kan beantwoorden. Er is geen regel in een PDF om te selecteren, en elke implementatie die anders doet alsof, gokt. Dit artikel gaat over die gok bewust maken en consistent maken. Als je eigenlijk tekst uit een document wilt halen, zie tekst extraheren uit PDF-documenten met PDFium; leg je tekst uit en heb je breedtes nodig, zie tekstmeting en woordafbreking. Hier is het onderwerp smaller: bepalen waar een visuele regel begint en eindigt, en precies dat selecteren
Waarom heeft een PDF-tekstpagina geen regelobjecten?
Omdat een PDF-contentstream tekenen beschrijft, geen structuur. ISO 32000-1 §9.4 definieert een tekstobject als een BT / ET-paar met positionerings- en toonoperators. De positioneringsoperators van §9.4.2 (Td, TD, Tm, T*) bewegen een tekstmatrix over de pagina, en de toonoperators van §9.4.3 (Tj, TJ, ', ") schilderen glyphs waar die matrix ook maar op dat moment naar wijst. Niets in dat model zegt "deze reeks glyphs is een regel". Een regel is wat een mens ziet nadat het schilderen voltooid is
Producenten maken dit erger op manieren die je niet kunt beheersen. Een uitgevulde alinea kan uitgezonden worden als één TJ-array per regel, of als één Tj per woord met een expliciete Tm ervoor, of als één enkele toonoperatie met kerning-aanpassingen die de spatiëring dragen. Een lay-out met twee kolommen kan de linkerkolom van boven naar beneden uitzenden en dan de rechterkolom, of ze kan ze interleaven als de producent zijn eigen interne objectlijst in een andere volgorde afliep. De tekenreeks die PDFium je geeft, volgt de contentstream, en de contentstream volgt wat de genererende applicatie ook maar wilde doen. De twee functies die je daadwerkelijk krijgt, zijn dus FPDFText_CountChars, dat rapporteert hoeveel tekens de pagina bevat, en FPDFText_GetCharBox, dat de bounding box van één teken in paginaruimte retourneert. Dat is de hele ruwe vocabulaire. Alles daarboven, woorden, regels, alinea's, kolommen, is inferentie die je op geometrie uitvoert
Waarom is CR- en LF-detectie de verkeerde test?
Omdat de tekens waartegen je zou testen niet betrouwbaar aanwezig zijn, en wanneer ze wel aanwezig zijn, zijn ze niet betrouwbaar van jou. PDFium injecteert synthetische tekens in de tekstpagina om geëxtraheerde tekst leesbaar te maken: een spatie waar twee runs visueel gescheiden zijn, een CR of LF waar de volgende run op een nieuwe baseline begint. FPDFText_IsGenerated bestaat precies zodat je die kunt onderscheiden van tekens die uit het bestand kwamen, en PDFium Component stelt het bloot als de eigenschap CharacterGenerated
Splits op die tekens en je erft elke beoordeling die PDFium maakte tijdens het synthetiseren ervan. Een harde regelafbreking binnen een gewrapte alinea en een zachte wrap zien er identiek uit na synthese. Een tabelrij die de producent cel voor cel uitzond, krijgt mogelijk helemaal geen afbreking tussen de laatste cel en de eerste cel van de volgende rij, omdat de baselines toevallig dicht genoeg bij elkaar liggen. Ondertussen kan een kop gevolgd door body-tekst op een andere grootte twee afbrekingen krijgen waar een mens er één ziet. De gegenereerde tekens zijn een rendergemak voor extractie van de hele pagina; ze zijn geen regelmodel, en ze degraderen precies in de documenten waar selectie het meest telt
Tekenboxen clusteren op verticaal midden
Het betrouwbare signaal is geometrie. Neem het teken waarop de gebruiker klikte als zaad, bereken het verticale midden van zijn box, en loop naar buiten in beide richtingen zolang aangrenzende boxen hun verticale middens binnen tolerantie houden. PDFium Component gebruikt de helft van de hoogte van de zaadbox als die tolerantie, met een ondergrens van 0.5 pagina-eenheden zodat gedegenereerde boxen, een punt, een dunne spatie, een glyph met een bijna-nul-hoogte-box, de tolerantie niet tot niets laten inklappen en de regel na één teken afkappen
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;
Drie details in die lus verdienen hun plek. De tolerantie is afgeleid van het zaad in plaats van van een constante, dus een kop van 24pt krijgt een brede band en 7pt-voetnoottekst een smalle, en geen van beide steelt tekens van de ander. De vergelijking gebruikt verticale middens in plaats van baselines of boxtoppen, wat een superscript, een inline run van een andere grootte, of een zin met gemengd font op dezelfde regel houdt als zijn buren. En een mislukte FPDFText_GetCharBox beëindigt de scan in plaats van overgeslagen te worden, omdat een teken zonder ophaalbare geometrie je geen bewijs geeft in welke richting dan ook, en doorgaan voorbij het zou de loop toestaan een echte grens over te springen op basis van een teken verderop
Waarom moet elk selectiepad één helper delen?
Omdat drie codepaden die elk "de regel" implementeren, zullen afwijken, en ze zullen stilzwijgend afwijken. In PDFium Component lossen de uitbreiding bij drievoudig klikken, Shift+Home, Shift+End, en de publieke methode SelectLineAt allemaal hun grenzen op via dezelfde aanroep LineRangeAt. Drievoudig klikken zaait het vanuit het selectieanker; de shift-toetsen zaaien het vanuit de selectiecursor en verplaatsen alleen dat uiteinde; SelectLineAt zaait het vanuit een door de aanroeper geleverde tekenindex en geeft het resultaat aan SelectTextRange, dezelfde bereikvalidator die het muispad gebruikt. Dupliceer de logica in plaats daarvan en het falen is geen crash, het is een langzame drift. Iemand stemt de tolerantie voor drievoudig klikken af om een rapport met strakke regelafstand te repareren, en nu stopt Shift+End één teken korter dan waar drievoudig klikken stopt in dezelfde alinea. Een gebruiker selecteert een regel met de muis, breidt hem uit met het toetsenbord, en ziet de selectie krimpen. Omdat SelectLineAt de gewone selectiepipeline voedt, blijft programmatische selectie ook onafhankelijk van of muisinvoer ingeschakeld is, en krijgt hij nog steeds bereikvalidatie, herschildering, en de melding OnSelectionChange gratis
// 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;
Let op de tolerantie-argumenten op CharacterIndexAtPos. Hit-testing heeft zijn eigen speling, uitgedrukt in pagina-eenheden, en dat is een aparte zorg van de regeltolerantie. Een klik die landt in de regelafstand tussen twee regels, lost op naar welk teken ook maar het dichtstbij is binnen die box; de regelscan draait dan vanaf welk teken dat ook bleek te zijn. Een te genereuze hit-tolerantie in het zaad voeren, is een van de makkelijkere manieren om een regel te selecteren waar de gebruiker niet naar wees
Twee indexruimtes: tekenindex en tekstindex
Zodra je een bereik hebt, weersta de verleiding om het als een string-offset te gebruiken. FPDFText_GetText retourneert de paginatekst als een UTF-16-buffer, maar zijn indices vormen niet dezelfde indexruimte als de tekenindices die FPDFText_GetCharBox en FPDFText_CountChars gebruiken. De eerder besproken gegenereerde tekens zitten in de tekstbuffer terwijl ze tekenslots innemen zonder bruikbare geometrie, en de twee nummeringen drijven uiteen over de pagina. De bruggen zijn FPDFText_GetTextIndexFromCharIndex en FPDFText_GetCharIndexFromTextIndex, door PDFium Component gewikkeld als CharacterIndexToTextIndex en 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;
De richting die het hardst bijt, is de omgekeerde. Een zoekopdracht geïmplementeerd over de geëxtraheerde string geeft je tekstindices, en die rechtstreeks doorgeven aan een box- of selectie-API adresseert stilzwijgend de verkeerde tekens, met een fout die groeit hoe verder je de pagina afdaalt. Converteer met TextIndexToCharacterIndex voordat iets geometrisch het getal aanraakt. Surrogaatparen voegen daar bovenop een tweede, onafhankelijk offsetprobleem aan toe, behandeld in het artikel over emoji, CJK en surrogaatparen
Waar de heuristiek buigt
Wees eerlijk tegen jezelf over de grenzen, want ze zijn echt en bereikbaar. Geroteerde tekst is het duidelijkste geval: een tekenbox is een as-uitgelijnde rechthoek in paginaruimte, dus voor tekst geroteerd 90 graden hebben de boxen van één visuele regel verticale middens verspreid over de pagina, en de scan stopt bijna onmiddellijk. Wat je krijgt is een korte selectie in plaats van een verkeerde, wat het betere faalpatroon is, maar het is nog steeds een falen. Verticale schrijfmodi gedragen zich om dezelfde reden hetzelfde. Lay-outs met twee kolommen werken wanneer de kolommen verticaal ten opzichte van elkaar verschoven zijn en breken wanneer dat niet zo is. Delen beide kolommen een baseline-raster, dan liggen tekens uit de rechterkolom binnen tolerantie van de linkerkolomregel, en de scan zal recht over de goot heen lopen, omdat er in pure geometrie niets is om bij te stoppen. Dat detecteren vereist een horizontale-gap-test bovenop de verticale clustering, en het kiezen van de gap-drempel is een eigen beoordeling over welke documenten je bereid bent fout over te zijn. Gemengde fontgroottes zijn het geval dat de zaad-relatieve tolerantie goed afhandelt: een inline codesegment van 8pt binnen body-tekst van 11pt houdt zijn midden binnen de band, en een kop van 24pt op de volgende baseline trekt de body-regel niet in zichzelf
De hier beschreven regelselectiesemantiek wordt geleverd in het PDFium Component voor Delphi en C++Builder, samen met de hit-testing-, selectiebereik- en tekstindex-API's gebruikt in de voorbeelden; de productpagina draagt de volledige referentie voor het tekstpagina- en selectiemodel