Teknisk artikel

Mäta PDF-text för layout och radbrytning i Delphi

Anropet som lägger text på en PDF-sida är enkelt. Du ger AddText en sträng, ett teckensnitt, en storlek och en position, och glyferna visas. Vad den inte gör är att tala om för dig hur bred den strängen kommer att vara när den väl är ritad, och den bryter inte en lång sträng över flera rader. Ett enda anrop målar en teckenkörning på en position. Om körningen är bredare än kolumnen du menade att den skulle passa i, springer den helt enkelt förbi kanten, och inget i ritningsanropet varnar dig. I samma ögonblick som du vill ha ett stycke snarare än en enda etikett, är den saknade pusselbiten bredden på en sträng i det valda teckensnittet och storleken, mätt innan du överför det till sidan

Detta är det klassiska layoutproblemet. För att bryta ett stycke in i en kolumn måste du veta, ord för ord, hur mycket horisontellt utrymme varje kandidatrad kommer att ta, och du måste veta det innan du ritar något. Radbrytning är en mätslinga invirad runt ett ritningsanrop, och en bindning som bara ritar ger dig den andra halvan. Textmätningsstödet i PDFium-komponenten stänger den luckan med två funktioner, MeasureText och MeasureTextWidth, som rapporterar den renderade omfattningen av en sträng utan att sätta ett märke på någon sida

Varför mätning är en klasshjälpare, inte en ny metod på TPdf

Mätningsstödet anländer som en Delphi-klasshjälpare för TPdf, som lever i sin egen enhet, snarare än som nya metoder fastbultade i TPdf-klassen. En klasshjälpare är en språkfunktion som låter dig koppla metoder till en befintlig typ från utanför dess deklaration. När enheten väl är i scope anropas de nya metoderna precis som om de tillhörde klassen, så en hjälparmetod läses som Pdf.MeasureTextWidth(...) utan något separat objekt att konstruera eller skicka runt

Anledningen till att lägga det i lager på det här sättet är separation. Kärntypen TPdf förblir som den är, utan något fält tillagt och ingen befintlig signatur rörd, så ett projekt som aldrig behöver layout bär aldrig på mätningskoden. Ett projekt som faktiskt behöver det lägger till en enhet till en uses-sats och metoderna tänds. Funktionalitet blir valfritt på granulariteten av en enda enhet, vilket är det renaste sättet att utöka en typ du inte äger eller inte vill störa

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;

Att mäta utan att röra sidan

Mätningen måste vara fri från sidoeffekter. Den måste rapportera en bredd utan att lämna något efter sig, eftersom du anropar den många gånger medan du beslutar om en layout och sidan måste se exakt ut som den skulle ha gjort om du aldrig hade mätt överhuvudtaget. Tekniken som gör detta möjligt är att bygga ett textobjekt, fråga det om dess storlek och kasta bort det innan det någonsin fästs vid en sida

Sekvensen är fyra PDFium-anrop. FPDFPageObj_NewTextObj skapar ett textobjekt mot dokumentet, givet teckensnittets namn och storlek. FPDFText_SetText sätter den sträng som objektet bär. FPDFPageObj_GetBounds läser tillbaka objektets avgränsningsruta. FPDFPageObj_Destroy frigör objektet. Avgörande är att inget i den sekvensen anropar API:et för sidinsättning. Objektet skapas, frågas ut och förstörs isolerat, så dokumentet är oförändrat när funktionen returnerar. Det är en slit-och-släng-sond vars enda utdata är de fyra numren på dess avgränsningsruta

Detta är det robusta sättet att göra det på eftersom PDFium inte exponerar en bekväm per-glyf-frammatningsbredd som du skulle kunna summera själv. Glyf-mått beror på teckensnittsprogrammet, på kodningen, och på hur PDFium laddar typsnittet, och det finns inget publikt anrop som ger dig frammatningen för varje tecken i en sträng. Avgränsningsrutan för ett riktigt textobjekt, å andra sidan, beräknas av samma maskineri som skulle lägga ut glyferna för ritning, så det återspeglar den faktiska renderade omfattningen snarare än en uppskattning. Att bygga ett engångsobjekt och läsa dess gränser är den mest pålitliga mätningen biblioteket kan ge

// 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;

Koordinater och enheter för resultatet

Avgränsningsrutan kommer tillbaka som fyra kanter, vänster, botten, höger och topp, och de två dimensionerna faller ut genom subtraktion. Bredd är höger minus vänster och höjd är topp minus botten. Båda uttrycks i PDF-användarenheter, där en enhet är en sjuttiotvåondels tum, samma koordinatrymd i vilken du placerar text på sidan. Det finns ingen dold enhetsenhet och ingen pixel inblandad i det här skedet. En bredd på 36 betyder en halv tum av sidan, oavsett den slutliga renderingsupplösningen

Den vertikala axeln löper som PDF definierar den, med Y ökande uppåt, vilket är anledningen till att höjden är topp minus botten snarare än tvärtom. Den detaljen är viktig när du flyttar fram en markör ner i en kolumn. Du mäter en rads höjd, sedan subtraherar du den från den aktuella baslinjen för att hitta nästa, eftersom att röra sig neråt på sidan betyder att röra sig mot mindre Y. Om din destination är en skärm snarare än papper, konverterar du användarenheter till enhetspixlar med skärmupplösningen: ett värde i användarenheter multiplicerat med DPI och dividerat med 72 ger pixlar, så en kolumnbredd du sätter i punkter kan matchas mot en mätt körning innan du bestämmer var brytningen ska gå

Vad som händer vid urartad indata

Funktionerna är skrivna för att misslyckas tyst. Om det inte finns något öppet dokument, eller om textobjektet inte kan skapas, är resultatet en noll-omfattning snarare än ett kastat undantag. Bredden och höjden initieras till noll i början och skrivs endast över när en avgränsningsruta har lästs tillbaka framgångsrikt. En tom sträng, ett saknat dokument, ett teckensnitt biblioteket inte kan lösa till ett objekt, vart och ett av dessa returnerar noll snarare än att kasta ett fel

Det valet håller en mätslinga enkel, eftersom en slinga som löper över tusentals ord inte är platsen för undantagshantering vid varje iteration. Kostnaden är att anroparen bär kontrollen. En nollbredd är en vaktpost, inte ett faktum om texten, så kod som dividerar med en uppmätt bredd eller antar ett positivt värde måste skydda mot noll innan den litar på det. Behandla noll som "kunde inte mäta" och kontraktet är tydligt; ignorera det och en urartad indata blir tyst en layout med en kolumn av överlappande glyfer

En girig radbrytning byggd på mätningen

Med en breddfunktion till hands är radbrytning en kort girig slinga. Du delar upp stycket i ord, håller en aktuell rad, och för varje ord mäter du vad raden skulle vara om du lade till det ordet. Så länge provraden fortfarande ryms inom kolumnbredden fortsätter du att lägga till; när den skulle svämma över tömmer du den aktuella raden med AddText och startar en ny med ordet som inte fick plats. Ackumuleringen görs helt och hållet med MeasureTextWidth, och det enda som någonsin når sidan är en rad du redan har bekräftat får plats

procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
  FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
  Words: TArray<string>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := string(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(Line, Font, FontSize, X, Y);   // 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(Line, Font, FontSize, X, Y);      // flush the final line
end;

Loopen mäter provraden istället för att mäta varje ord och summera, eftersom bredden på en rad inte är summan av dess ords bredder. Mellanslagen mellan orden bidrar, och en uppmätt körning fångar det direkt. Den giriga regeln, passa in så många ord som kolumnen tillåter och bryt vid det sista som får plats, är samma regel som fyller klyftan mellan ett rått AddText och ett riktigt stycke. Ritningsanropet var aldrig den svåra delen. Mätningen som måste föregå det är det, och det är exakt vad hjälparen tillhandahåller

Var detta passar in

Mätning är lagret mellan att generera innehåll och att rendera det, så det paras naturligt med resten av ett dokumentarbetsflöde från grunden. Om du sätter ihop sidor och placerar text från början, finns grundarbetet i att skapa PDF-dokument från grunden med PDFium-komponenten i Delphi, där AddText och siduppsättning täcks till fullo. När teckensnittet du mäter spelar lika stor roll som strängen, eftersom mått beror på typsnittet, visar analys av PDF-teckensnittsegenskaper med PDFium-komponenten i Delphi hur biblioteket rapporterar teckensnittsinformationen som driver dessa avgränsningsrutor. Båda bygger på samma bindning, PDFium Component för Delphi och Lazarus, där mätningshjälparen levereras tillsammans med de dokument-, sid- och text-API:er som beskrivs över den här bloggen