Teknisk artikel

PDF-tekstlinjevalg med PDFium karakterbokse i Delphi

En PDF-tekstside eksponerer tegn og bokse, aldrig linjer. PDFium Component bygger en visuel linje ved at klynge karakterbokse, hvis vertikale midtpunkter falder inden for halvdelen af den valgte tegnhøjde, og scanner udad fra det klikkede tegn, indtil tolerancen overskrides. Hver valgsti i vieweren kalder den ene hjælpefunktion, så mus, tastatur og kode er enige

Symptomet, der sender dig ud og lede efter dette, er specifikt og ubehageligt. En bruger tripleklikker et afsnit i en to-spaltet rapport og får halvdelen af siden. Eller de tripleklikker en tabelcelle, og valget sluger hele rækken plus sidenummeret i bunden. Vieweren er ikke ødelagt; den besvarer et spørgsmål, filen ikke kan svare på. Der er ingen linje i en PDF at vælge, og enhver implementering, der lader som andet, gætter. Denne artikel handler om at gøre gættet bevidst og at gøre det konsistent. Behøver man faktisk at trække tekst ud af et dokument, se udtrækning af tekst fra PDF-dokumenter med PDFium; lægger man tekst ud og behøver bredder, se tekstmåling og word wrap. Her er emnet snævrere: at afgøre, hvor en visuel linje starter og slutter, og vælge netop det

Hvorfor har en PDF-tekstside ingen linjeobjekter?

Fordi en PDF-indholdsstrøm beskriver tegning, ikke struktur. ISO 32000-1 §9.4 definerer et tekstobjekt som et BT / ET-par indeholdende positionerings- og visningsoperatorer. Positioneringsoperatorerne i §9.4.2 (Td, TD, Tm, T*) flytter en tekstmatrix rundt på siden, og visningsoperatorerne i §9.4.3 (Tj, TJ, ', ") maler glyffer, hvor end den matrix aktuelt peger. Intet i den model siger "denne stribe af glyffer er en linje". En linje er, hvad et menneske ser, efter malingen er gjort

Producenter gør dette værre på måder, man ikke kan styre. Et opdelt afsnit kan udsendes som ét TJ-array pr. linje, eller som ét Tj pr. ord med en eksplicit Tm før hvert, eller som en enkelt visningsoperation med kerning-justeringer, der bærer mellemrummet. Et to-spaltet layout kan udsende venstre kolonne top til bund og så højre kolonne, eller det kan interleave dem, hvis producenten gennemgik sin egen interne objektliste i en anden rækkefølge. Tegnsekvensen, PDFium giver dig, følger indholdsstrømmen, og indholdsstrømmen følger, hvad end den genererende applikation følte for at gøre. Så de to funktioner, man faktisk får, er FPDFText_CountChars, som rapporterer, hvor mange tegn siden indeholder, og FPDFText_GetCharBox, som returnerer det afgrænsende felt for ét tegn i siderum. Det er hele det rå vokabular. Alt over det, ord, linjer, afsnit, kolonner, er inferens man udfører på geometri

Hvorfor er CR- og LF-detektion den forkerte test?

Fordi de tegn, man ville teste mod, ikke pålideligt er til stede, og når de er til stede, er de ikke pålideligt ens egne. PDFium injicerer syntetiske tegn ind i tekstsiden for at gøre udtrukket tekst læselig: et mellemrum, hvor to strøg er visuelt adskilt, en CR eller LF, hvor det næste strøg starter på en ny baseline. FPDFText_IsGenerated findes netop, så man kan skelne dem fra tegn, der kom ud af filen, og PDFium Component eksponerer det som egenskaben CharacterGenerated

Splitter man på de tegn, arver man hver dømmekraft, PDFium udøvede under syntesen af dem. Et hårdt linjeskift inde i et opdelt afsnit og et blødt wrap ser identiske ud efter syntese. En tabelrække, producenten udsendte celle for celle, får måske intet break overhovedet mellem den sidste celle og den første celle i næste række, fordi baselinerne tilfældigvis er tæt nok på hinanden. Imens kan en overskrift efterfulgt af brødtekst i en anden størrelse få to breaks, hvor et menneske ser ét. De genererede tegn er en render-bekvemmelighed til helside-udtrækning; de er ikke en linjemodel, og de forringes netop i de dokumenter, hvor valg betyder mest

Klyngedannelse af karakterbokse efter vertikalt midtpunkt

Det pålidelige signal er geometri. Tag tegnet, brugeren klikkede, som frø, beregn det vertikale midtpunkt af dets boks, og gå udad i begge retninger, mens naboboksene holder deres vertikale midtpunkter inden for tolerance. PDFium Component bruger halvdelen af frøboksens højde som den tolerance, med en gulv på 0.5 sideenheder, så degenererede bokse, et punktum, et tyndt mellemrum, en glyf med en næsten-nul-højde boks, ikke kollapser tolerancen til intet og skærer linjen af efter ét 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økke fortjener deres plads. Tolerancen udledes af frøet frem for af en konstant, så en 24pt-overskrift får et bredt bånd, og 7pt-fodnotetekst får et smalt, og ingen af dem stjæler tegn fra det andet. Sammenligningen bruger vertikale midtpunkter frem for baselines eller boks-toppe, hvilket holder en subscript, et indbygget andet-størrelses-strøg eller en blandet-skrifttype-sætning på samme linje som deres naboer. Og et fejlet FPDFText_GetCharBox afslutter scanningen frem for at blive sprunget over, fordi et tegn uden hentbar geometri giver intet bevis hverken vej, og at fortsætte forbi det ville lade gangen hoppe over en ægte grænse på styrken af et tegn længere fremme

Hvorfor skal hver valgsti dele én hjælpefunktion?

Fordi tre kodestier, der hver implementerer "linjen", vil divergere, og de vil divergere stille. I PDFium Component løser tripleklik-udvidelse, Shift+Home, Shift+End, og den offentlige SelectLineAt-metode alle deres grænser gennem det samme LineRangeAt-kald. Tripleklik seeder det fra valgets anker; shift-tasterne seeder det fra valgets cursor og flytter kun den ende; SelectLineAt seeder det fra et kalder-leveret tegnindeks og afleverer resultatet til SelectTextRange, samme intervalvalidator, musestien bruger. Duplikér logikken i stedet, og fejlen er ikke et crash, det er en langsom drift. Nogen tuner tripleklik-tolerancen for at rette en rapport med tæt linjeafstand, og nu stopper Shift+End ét tegn kortere end tripleklik stopper på samme afsnit. En bruger vælger en linje med musen, udvider den med tastaturet, og ser valget krympe. Fordi SelectLineAt fodrer den almindelige valg-pipeline, forbliver programmatisk valg også uafhængigt af, om musinput er aktiveret, og får stadig intervalvalidering, gentegning og OnSelectionChange-notifikationen 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;

Bemærk tolerance-argumenterne på CharacterIndexAtPos. Hit-testing har sin egen slack, udtrykt i sideenheder, og det er en separat bekymring fra linjetolerancen. Et klik, der lander i mellemrummet mellem to linjer, løses til hvad end tegn der er nærmest inden for den boks; linjescanningen kører så fra hvad end tegn, det viste sig at være. At fodre en for generøs hit-tolerance ind i frøet er en af de lettere måder at vælge en linje, brugeren ikke pegede på

To indeksrum: tegnindeks og tekstindeks

Har man et interval, modstå trangen til at bruge det som en strengoffset. FPDFText_GetText returnerer sidens tekst som en UTF-16-buffer, men dens indekser er ikke samme indeksrum som tegnindekserne, brugt af FPDFText_GetCharBox og FPDFText_CountChars. De genererede tegn, diskuteret tidligere, sidder i tekstbufferen, mens de optager tegnpladser uden brugbar geometri, og de to nummereringer glider fra hinanden på tværs af siden. Broerne er FPDFText_GetTextIndexFromCharIndex og FPDFText_GetCharIndexFromTextIndex, pakket af 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, der bider hårdest, er den omvendte. En søgning implementeret over den udtrukne streng giver tekstindekser, og at sende dem direkte til en boks- eller valg-API adresserer stiltiende de forkerte tegn, med en fejl, der vokser jo længere ned ad siden man kommer. Konvertér med TextIndexToCharacterIndex, før noget geometrisk rører tallet. Surrogatpar tilføjer et andet, uafhængigt offset-problem oven på dette, som dækkes i artiklen om emoji, CJK og surrogatpar

Hvor heuristikken bøjer

Vær ærlig med dig selv om grænserne, for de er reelle og de kan rammes. Roteret tekst er det klareste tilfælde: en karakterboks er et akse-justeret rektangel i siderum, så for tekst roteret 90 grader er boksene for én visuel linje spredt vertikalt over siden med midtpunkter langt fra hinanden, og scanningen stopper næsten øjeblikkeligt. Hvad man får, er et kort valg frem for et forkert, hvilket er den bedre fejltilstand, men det er stadig en fejl. Vertikale skriveretninger opfører sig på samme måde af samme grund. To-spaltede layouts virker, når kolonnerne er vertikalt forskudt fra hinanden, og går i stykker, når de ikke er det. Deler begge kolonner en baseline-grid, sidder tegn fra den højre kolonne inden for tolerance af den venstre kolonnes linje, og scanningen vil køre lige over gyden, fordi der rent geometrisk ikke er noget der til at stoppe den. At registrere det behøver en horisontal gap-test oven på den vertikale klyngedannelse, og at vælge gap-tærsklen er sin egen dømmekraft om, hvilke dokumenter man er villig til at tage fejl om. Blandede skriftstørrelser er tilfældet, den frø-relative tolerance håndterer godt: et indbygget 8pt-kodespan inde i 11pt brødtekst holder sit midtpunkt inden for båndet, og en 24pt-overskrift på næste baseline trækker ikke brødtekstlinjen ind i sig selv

Linjevalg-semantikken beskrevet her leveres i PDFium Component til Delphi og C++Builder, sammen med hit-testing-, valginterval- og tekstindeks-API'erne brugt i eksemplerne; produktsiden bærer den fulde reference for tekstside- og valgmodellen