Tekninen artikkeli

PDF-tekstin mittaus asettelua ja sanarivitystä varten Delphissä

Kutsu, joka sijoittaa tekstin PDF-sivulle, on suoraviivainen. Annat AddText-funktiolle merkkijonon, fontin, koon ja sijainnin, ja glyyfit ilmestyvät näkyviin. Mitä se ei tee, on kertoa sinulle, kuinka leveä tuo merkkijono on, kun se on piirretty, eikä se katkaise pitkää merkkijonoa usealle riville. Yksittäinen kutsu maalaa yhden tekstijakson (run of text) yhteen sijaintiin. Jos jakso on leveämpi kuin sarake, johon sen piti mahtua, se yksinkertaisesti menee reunan yli, eikä mikään piirtokutsussa varoita sinua. Siinä vaiheessa kun haluat kappaleen etkä pelkkää yksittäistä nimikettä (label), puuttuva palanen on valitun fontin ja koon mukaisen merkkijonon leveys, mitattuna ennen kuin kiinnität sen sivulle

Tämä on klassinen asetteluongelma (layout problem). Kääriäksesi kappaleen sarakkeeseen, sinun on tiedettävä sana sanalta, kuinka paljon vaakasuuntaista tilaa kukin ehdokasrivi vie, ja sinun on tiedettävä se ennen kuin piirrät mitään. Sanarivitys (word wrap) on mittaussilmukka, joka on kääritty piirtokutsun ympärille, ja sidonta, joka vain piirtää, antaa sinulle vain jälkimmäisen puoliskon. PDFium-komponentin tekstinmittaustuki täyttää tämän aukon kahdella funktiolla, MeasureText ja MeasureTextWidth, jotka raportoivat merkkijonon renderöidyn laajuuden (extent) jättämättä jälkeäkään millekään sivulle

Miksi mittaus on luokka-apuri (class helper), ei uusi metodi TPdf:ssä

Mittaustuki toimitetaan Delphin luokka-apurina (class helper) TPdf-luokalle, ja se elää omassa yksikössään, sen sijaan että se olisi pultattu uusina metodeina TPdf-luokkaan. Luokka-apuri on kieliominaisuus, jonka avulla voit liittää metodeja olemassa olevaan tyyppiin sen määrittelyn (declaration) ulkopuolelta. Kun yksikkö on näkyvyysalueella (in scope), uusia metodeja kutsutaan aivan kuin ne kuuluisivat luokkaan, joten apurimetodi lukee muodossa Pdf.MeasureTextWidth(...) ilman, että on tarpeen rakentaa erillistä objektia tai välittää sellaista

Syy tällaiseen kerrostamiseen on eristäminen (separation). Ydinluokka TPdf pysyy sellaisenaan, siihen ei lisätä kenttiä eikä olemassa oleviin allekirjoituksiin kosketa, joten projekti, joka ei koskaan tarvitse asettelua, ei koskaan kanna mukanaan mittauskoodia. Projekti, joka sitä tarvitsee, lisää yhden yksikön uses-lausekkeeseen, ja metodit syttyvät käyttöön. Ominaisuudesta tulee valinnainen (opt-in) yksittäisen yksikön rakeisuudella, mikä on puhtain tapa laajentaa tyyppiä, jota et omista tai jota et halua häiritä

uses
  PDFium, FPdfView, FPdfEdit,
  FPdfMeasure;   // the helper unit; brings MeasureText into scope on TPdf

// With the unit in scope the methods read as members of TPdf:
var
  W, H: Double;
begin
  Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
  // W and H are now the rendered width and height in PDF user units
end;

Mittaus sivuun koskematta

Mittauksen on oltava vapaa sivuvaikutuksista. Sen on raportoitava leveys jättämättä mitään taakseen, koska kutsut sitä monta kertaa asettelua päättäessäsi, ja sivun on näytettävä täsmälleen siltä kuin miltä se olisi näyttänyt, jos et olisi koskaan mitannut mitään. Tekniikka, joka tekee tämän mahdolliseksi, on rakentaa tekstiobjekti, kysyä sen kokoa ja heittää se pois ennen kuin sitä koskaan liitetään sivuun

Sarja on neljä PDFium-kutsua. FPDFPageObj_NewTextObj luo tekstiobjektin asiakirjaa vasten, kun sille annetaan fontin nimi ja koko. FPDFText_SetText asettaa merkkijonon, jota kyseinen objekti kantaa. FPDFPageObj_GetBounds lukee takaisin objektin rajoituslaatikon (bounding box). FPDFPageObj_Destroy vapauttaa objektin. Ratkaisevaa on, ettei mikään tässä sarjassa kutsu sivulle lisäyksen (page-insertion) ohjelmointirajapintaa. Objekti luodaan, sitä kysellään ja se tuhotaan eristyksissä, joten asiakirja on muuttumaton, kun funktio palaa. Se on kertakäyttöinen anturi, jonka ainoa tuloste on sen rajoituslaatikon neljä lukua

Tämä on vankka tapa tehdä se, koska PDFium ei tarjoa kätevää glyyfikohtaista etenemän (advance width) leveyttä, jonka voisit laskea itse yhteen. Glyyfimetriikat riippuvat fonttiohjelmasta, koodauksesta ja siitä, kuinka PDFium lataa kirjasimen (face), eikä ole olemassa julkista kutsua, joka ojentaisi sinulle merkkijonon jokaisen merkin etenemän. Todellisen tekstiobjektin rajoituslaatikko sen sijaan lasketaan samalla koneistolla, joka asettelisi glyyfit piirtämistä varten, joten se heijastaa todellista renderöityä laajuutta arvion sijaan. Yhden kertakäyttöisen objektin rakentaminen ja sen rajojen lukeminen on luotettavin mittaus, jonka kirjasto voi antaa

// The shape of MeasureText, expressed against the verified PDFium calls.
// A text object is built, measured, and destroyed; no page is involved.
procedure TPdfMeasureHelper.MeasureText(const Text, Font: WString;
  FontSize: Single; out Width, Height: Double);
var
  TextObject: FPDF_PAGEOBJECT;
  L, B, R, T: Single;
begin
  Width  := 0;
  Height := 0;
  if Self.Document = nil then
    Exit;
  TextObject := FPDFPageObj_NewTextObj(Self.Document,
    FPDF_BYTESTRING(AnsiString(Font)), FontSize);
  if TextObject = nil then
    Exit;
  try
    if FPDFText_SetText(TextObject, FPDF_WIDESTRING(WideString(Text))) = 0 then
      Exit;
    if FPDFPageObj_GetBounds(TextObject, L, B, R, T) <> 0 then
    begin
      Width  := R - L;
      Height := T - B;
    end;
  finally
    FPDFPageObj_Destroy(TextObject);   // probe discarded, page untouched
  end;
end;

Tuloksen koordinaatit ja yksiköt

Rajoituslaatikko palaa neljänä reunana: vasen, ala, oikea ja ylä, ja kaksi ulottuvuutta saadaan vähennyslaskulla. Leveys on oikea miinus vasen ja korkeus on ylä miinus ala. Molemmat on ilmaistu PDF:n käyttäjäyksiköinä (user units), joissa yksi yksikkö on yksi 1/72 tuumaa – samassa koordinaattiavaruudessa, jossa sijoitat tekstin sivulle. Tässä vaiheessa ei ole mukana piilotettua laiteyksikköä eikä pikseleitä. Leveys 36 tarkoittaa puolta tuumaa sivusta, olipa lopullinen renderöintiresoluutio mikä tahansa

Pystyakseli kulkee niin kuin PDF sen määrittelee: Y kasvaa ylöspäin, minkä vuoksi korkeus on ylä miinus ala eikä päinvastoin. Sillä yksityiskohdalla on merkitystä, kun siirrät kursorin alas saraketta. Mittaat rivin korkeuden ja vähennät sen sitten nykyisestä peruslinjasta (baseline) löytääksesi seuraavan, koska sivulla alaspäin siirtyminen tarkoittaa siirtymistä kohti pienempää Y:tä. Jos kohteesi on näyttö paperin sijaan, muunnat käyttäjäyksiköt laitepikseleiksi näytön resoluutiolla: arvo käyttäjäyksiköissä kerrottuna DPI:llä ja jaettuna 72:lla antaa pikselit, joten pisteinä asettamaasi sarakkeen leveyttä voidaan verrata mitattuun jaksoon, ennen kuin päätät, mihin katkaisu tulee

Mitä tapahtuu rappeutuneella (degenerate) syötteellä

Funktiot on kirjoitettu epäonnistumaan hiljaisesti (fail quietly). Jos asiakirjaa ei ole auki, tai jos tekstiobjektia ei voida luoda, tulos on nollan suuruinen laajuus poikkeuksen nostamisen sijaan. Leveys ja korkeus alustetaan nollaan ylimpänä ja ylikirjoitetaan vasta, kun rajoituslaatikko on luettu onnistuneesti takaisin. Tyhjä merkkijono, puuttuva asiakirja, fontti jota kirjasto ei voi ratkaista objektiksi – jokainen näistä palauttaa nollan poikkeuksen heittämisen sijaan

Tämä valinta pitää mittaussilmukan yksinkertaisena, koska tuhansien sanojen yli kulkeva silmukka ei ole oikea paikka poikkeusten käsittelylle jokaisella iteraatiolla. Hintana on se, että kutsuja kantaa vastuun tarkistuksesta. Nollaleveys on vartija-arvo (sentinel), ei tosiasia tekstistä, joten koodin, joka jakaa mitatulla leveydellä tai olettaa positiivisen arvon, on suojattava nollaa vastaan ennen kuin se luottaa siihen. Käsittele nollaa merkityksessä "ei voitu mitata" ja sopimus on selvä; jätä se huomiotta, niin rappeutunut syöte muuttuu hiljaisesti asetteluksi, jossa on sarake päällekkäisiä glyyfejä

Mittaukseen perustuva ahne (greedy) sanarivitys

Kun leveysfunktio on käytettävissä, sanarivitys on lyhyt ahne silmukka. Jaat kappaleen sanoiksi, pidät yllä nykyistä riviä, ja jokaisen sanan kohdalla mittaat, millainen rivi olisi, jos liittäisit kyseisen sanan. Niin kauan kuin kokeilurivi yhä mahtuu sarakkeen leveyteen, jatkat lisäämistä; kun se menisi yli, tyhjennät (flush) nykyisen rivin AddText-kutsulla ja aloitat uuden sillä sanalla, joka ei mahtunut. Kertyminen tehdään kokonaan MeasureTextWidth-kutsulla, ja ainoa asia, joka koskaan saavuttaa sivun, on rivi, jonka olet jo vahvistanut mahtuvan

procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
  FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
  Words: TArray<WideString>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := WideString(Para).Split([' ']);
  Line  := '';
  Y     := TopY;
  for I := 0 to High(Words) do
  begin
    if Line = '' then
      Trial := Words[I]
    else
      Trial := Line + ' ' + Words[I];
    // Measure the candidate line before drawing anything.
    if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
    begin
      Pdf.AddText(X, Y, Font, FontSize, Line);   // flush the line that fit
      Y    := Y - LineHeight;                    // Y decreases going down
      Line := Words[I];                          // overflowing word starts next line
    end
    else
      Line := Trial;
  end;
  if Line <> '' then
    Pdf.AddText(X, Y, Font, FontSize, Line);      // flush the final line
end;

Silmukka mittaa kokeiluriviä sen sijaan, että se mittaisi jokaisen sanan ja laskisi ne yhteen, koska rivin leveys ei ole sen sanojen leveyksien summa. Sanojen väliset välilyönnit vaikuttavat siihen, ja mitattu jakso (run) vangitsee sen suoraan. Ahne sääntö, mahduta niin monta sanaa kuin sarake sallii ja katkaise viimeisen mahtuvan kohdalla, on sama sääntö, joka täyttää raa'an AddText-kutsun ja aidon kappaleen välisen kuilun. Piirtokutsu ei koskaan ollut se vaikea osa. Sitä edeltävä mittaus on, ja se on täsmälleen sitä, mitä apuri tarjoaa

Mihin tämä sopii

Mittaus on sisällön luomisen ja sen renderöinnin välinen kerros, joten se sopii luontevasti yhteen muun tyhjästä aloitetun (from-scratch) asiakirjatyönkulun kanssa. Jos olet kokoamassa sivuja ja asettelemassa tekstiä ylipäätään, perusta on artikkelissa PDF-asiakirjojen luominen tyhjästä PDFium-komponentin avulla Delphissä, jossa AddText ja sivun asetukset käsitellään täydellisesti. Kun fontti, jota olet mittaamassa, on yhtä tärkeä kuin merkkijono, koska metriikat riippuvat kirjasimesta (face), artikkeli PDF-fontin ominaisuuksien analysointi PDFium-komponentilla Delphissä näyttää, kuinka kirjasto raportoi niitä rajoituslaatikoita ohjaavat fonttitiedot. Molemmat rakentuvat samalle sidonnalle, PDFium-komponentille Delphille ja Lazarukselle, jossa mittausapuri toimitetaan yhdessä tässä blogissa kuvattujen asiakirja-, sivu- ja tekstiohjelmointirajapintojen kanssa