Teknisk artikkel

PDF-tekstlinjeutvalg med PDFium tegnbokser i Delphi

En PDF-tekstside eksponerer tegn og bokser, aldri linjer. PDFium Component bygger en visuell linje ved å klynge tegnbokser hvis vertikale sentre faller innenfor halvparten av frøtegnets høyde, og skanner utover fra det klikkede tegnet inntil toleransen overskrides. Hver utvalgssti i viseren kaller den ene hjelperen, slik at mus, tastatur og kode er enige

Symptomet som sender deg på leting etter dette er spesifikt og ubehagelig. En bruker trippelklikker et avsnitt i en tospaltet rapport og får halve siden. Eller de trippelklikker en tabellcelle og utvalget svelger hele raden pluss sidetallet i bunnteksten. Viseren er ikke ødelagt; den stiller et spørsmål filen ikke kan svare på. Det finnes ingen linje i en PDF å velge, og enhver implementasjon som later som noe annet gjetter. Denne artikkelen handler om å gjøre gjetningen bevisst og gjøre den konsistent. Hvis det du egentlig trenger er å hente tekst ut av et dokument, se utvinning av tekst fra PDF-dokumenter med PDFium; hvis du legger ut tekst og trenger bredder, se tekstmåling og tekstbryting. Her er emnet smalere: å avgjøre hvor en visuell linje starter og slutter, og velge nøyaktig det

Hvorfor har en PDF-tekstside ingen linjeobjekter?

Fordi en PDF-innholdsstrøm beskriver tegning, ikke struktur. ISO 32000-1 §9.4 definerer et tekstobjekt som et BT / ET-par som inneholder posisjonerings- og visningsoperatorer. Posisjoneringsoperatorene i §9.4.2 (Td, TD, Tm, T*) flytter en tekstmatrise rundt på siden, og visningsoperatorene i §9.4.3 (Tj, TJ, ', ") tegner glyffer der hvor den matrisen for øyeblikket peker. Ingenting i den modellen sier «denne sekvensen av glyffer er en linje». En linje er hva et menneske ser etter at tegningen er ferdig

Produsenter gjør dette verre på måter du ikke kan kontrollere. Et blokkjustert avsnitt kan sendes ut som ett TJ-array per linje, eller som ett Tj per ord med en eksplisitt Tm foran hvert, eller som én enkelt visningsoperasjon med kerning-justeringer som bærer avstanden. Et tospaltet oppsett kan sende ut venstre spalte fra topp til bunn og deretter høyre spalte, eller det kan flette dem hvis produsenten gikk gjennom sin egen interne objektliste i en annen rekkefølge. Tegnsekvensen PDFium gir deg følger innholdsstrømmen, og innholdsstrømmen følger hva enn den genererende applikasjonen følte for å gjøre. Så de to funksjonene du faktisk får er FPDFText_CountChars, som rapporterer hvor mange tegn siden inneholder, og FPDFText_GetCharBox, som returnerer avgrensningsboksen til ett tegn i sideplassen. Det er hele det rå vokabularet. Alt over det, ord, linjer, avsnitt, spalter, er utledning du gjør på geometri

Hvorfor er CR- og LF-deteksjon feil test?

Fordi tegnene du ville testet mot ikke er pålitelig til stede, og når de er til stede er de ikke pålitelig dine. PDFium injiserer syntetiske tegn inn i tekstsiden for å gjøre uttrukket tekst lesbar: et mellomrom hvor to strekk er visuelt separert, en CR eller LF hvor neste strekk starter på en ny grunnlinje. FPDFText_IsGenerated finnes nettopp slik at du kan skille de fra tegn som kom ut av filen, og PDFium Component eksponerer det som egenskapen CharacterGenerated

Del på de tegnene, og du arver hver dømmekraftsavgjørelse PDFium tok mens den syntetiserte dem. Et hardt linjeskift inne i et brutt avsnitt og en myk brytning ser identiske ut etter syntese. En tabellrad produsenten sendte ut celle for celle får kanskje ingen brytning i det hele tatt mellom siste celle og første celle i neste rad, fordi grunnlinjene tilfeldigvis er nære nok. I mellomtiden kan en overskrift etterfulgt av brødtekst i en annen størrelse få to brytninger hvor et menneske ser én. De genererte tegnene er en render-bekvemmelighet for helside-uttrekk; de er ikke en linjemodell, og de degraderer i nøyaktig de dokumentene hvor utvalg betyr mest

Å klynge tegnbokser etter vertikalt senter

Det pålitelige signalet er geometri. Ta tegnet brukeren klikket som frø, beregn den vertikale midten av boksen dens, og gå utover i begge retninger mens naboboksene holder sine vertikale sentre innenfor toleranse. PDFium Component bruker halvparten av frøboksens høyde som den toleransen, med et gulv på 0.5 sideenheter slik at degenererte bokser, et punktum, et tynt mellomrom, en glyff med en nesten null-høyde boks, ikke kollapser toleransen til ingenting og kutter linjen etter ett tegn

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;

Tre detaljer i den løkken fortjener sin plass. Toleransen utledes fra frøet snarere enn fra en konstant, så en 24pt overskrift får et bredt bånd og 7pt fotnotetekst får et smalt, og ingen av dem stjeler tegn fra den andre. Sammenligningen bruker vertikale sentre snarere enn grunnlinjer eller bokstopper, som holder en hevet skrift, et innebygd tegn i annen størrelse, eller en blandet font-setning på samme linje som naboene sine. Og et mislykket FPDFText_GetCharBox avslutter skanningen i stedet for å bli hoppet over, fordi et tegn uten hentbar geometri ikke gir deg noe bevis noen vei, og å fortsette forbi det ville la gangen hoppe over en genuin grense på styrken av et tegn lenger nede

Hvorfor må hver utvalgssti dele én hjelper?

Fordi tre kodestier som hver implementerer «linjen» vil divergere, og de vil divergere stille. I PDFium Component løser trippelklikk-utvidelse, Shift+Home, Shift+End, og den offentlige SelectLineAt-metoden alle sine grenser gjennom samme LineRangeAt-kall. Trippelklikk sår den fra utvalgsankeret; shift-tastene sår den fra utvalgsmarkøren og flytter bare den enden; SelectLineAt sår den fra en kallerlevert tegnindeks og gir resultatet til SelectTextRange, samme områdevalidator musstien bruker. Dupliser logikken i stedet, og feilen er ikke en krasj, det er en langsom drift. Noen justerer trippelklikk-toleransen for å fikse en rapport med tett linjeavstand, og nå stopper Shift+End ett tegn kort av der trippelklikk stopper på samme avsnitt. En bruker velger en linje med musen, utvider den med tastaturet, og ser utvalget krympe. Fordi SelectLineAt mater den vanlige utvalgspipelinen, forblir programmatisk utvalg også uavhengig av om musinndata er aktivert, og får fortsatt områdevalidering, omtegning og OnSelectionChange-varslingen 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;

Legg merke til toleranseargumentene på CharacterIndexAtPos. Trefftesting har sin egen slark, uttrykt i sideenheter, og det er en separat bekymring fra linjetoleransen. Et klikk som lander i linjeavstanden mellom to linjer løses til hvilket tegn som er nærmest innenfor den boksen; linjeskanningen kjøres deretter fra hva enn det tegnet viste seg å være. Å mate en for sjenerøs trefftoleranse inn i frøet er en av de enklere måtene å velge en linje brukeren ikke pekte på

To indeksrom: tegnindeks og tekstindeks

Når du har et område, motstå trangen til å bruke det som en strengoffset. FPDFText_GetText returnerer sideteksten som en UTF-16-buffer, men indeksene dens er ikke samme indeksrom som tegnindeksene brukt av FPDFText_GetCharBox og FPDFText_CountChars. De genererte tegnene diskutert tidligere sitter i tekstbufferen mens de opptar tegnslotter uten brukbar geometri, og de to nummereringene driver fra hverandre på tvers av siden. Broene er FPDFText_GetTextIndexFromCharIndex og FPDFText_GetCharIndexFromTextIndex, pakket inn av PDFium Component som CharacterIndexToTextIndex og 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;

Retningen som biter hardest er den motsatte. Et søk implementert over den uttrukne strengen gir deg tekstindekser, og å sende de rett til en boks- eller utvalgs-API adresserer stille de feil tegnene, med en feil som vokser jo lenger ned på siden du går. Konverter med TextIndexToCharacterIndex før noe geometrisk rører tallet. Surrogatpar legger til et andre, uavhengig offsetproblem oppå dette, som er dekket i artikkelen om emoji, CJK og surrogatpar

Hvor heuristikken bøyer seg

Vær ærlig med deg selv om grensene, fordi de er reelle og de er nåbare. Rotert tekst er det klareste tilfellet: en tegnboks er et akseparallelt rektangel i sideplassen, så for tekst rotert 90 grader er boksene til en visuell linje med vertikale sentre spredt over siden, og skanningen stopper nesten umiddelbart. Det du får er et kort utvalg snarere enn et feil ett, som er den bedre feilmodusen, men det er fortsatt en feil. Vertikale skrivemoduser oppfører seg på samme måte av samme grunn. Tospaltede oppsett fungerer når spaltene er vertikalt forskjøvet fra hverandre og bryter når de ikke er det. Hvis begge spaltene deler et grunnlinjegitter, sitter tegn fra høyre spalte innenfor toleranse av venstre spalte-linjen, og skanningen vil kjøre rett over gapet, fordi det i ren geometri ikke er noe der å stoppe ved. Å oppdage det trenger en horisontal gap-test oppå den vertikale klyngingen, og å velge gap-terskelen er sin egen dømmekraftsavgjørelse om hvilke dokumenter du er villig til å ta feil om. Blandede fontstørrelser er tilfellet den frø-relative toleransen håndterer godt: et innebygd 8pt kodesegment inne i 11pt brødtekst holder sitt senter innenfor båndet, og en 24pt overskrift på neste grunnlinje trekker ikke brødtekstlinjen inn i seg selv

Linjeutvalg-semantikken beskrevet her leveres i PDFium Component for Delphi og C++Builder, sammen med trefftesting-, utvalgsområde- og tekstindeks-API-ene brukt i eksemplene; produktsiden bærer hele referansen for tekstsiden og utvalgsmodellen