Τεχνικό Άρθρο

Μέτρηση Κειμένου PDF για Διάταξη και Αναδίπλωση Λέξεων στη Delphi

Η κλήση που τοποθετεί κείμενο σε μια σελίδα PDF είναι απλή (straightforward). Δίνετε στην AddText μια συμβολοσειρά (string), μια γραμματοσειρά (font), ένα μέγεθος, και μια θέση, και τα σύμβολα (glyphs) εμφανίζονται. Αυτό που δεν κάνει είναι να σας πει πόσο πλατιά θα είναι αυτή η συμβολοσειρά μόλις σχεδιαστεί (drawn), και δεν διακόπτει (break) μια μεγάλη συμβολοσειρά σε πολλές γραμμές. Μια μοναδική κλήση ζωγραφίζει μία διαδρομή (run) κειμένου σε μία θέση. Εάν η διαδρομή είναι ευρύτερη από τη στήλη που σκοπεύατε να χωρέσει, απλώς περνάει πέρα από την άκρη (edge), και τίποτα στην κλήση σχεδίασης δεν σας προειδοποιεί. Τη στιγμή που θέλετε μια παράγραφο αντί για μια μεμονωμένη ετικέτα (label), το κομμάτι που λείπει είναι το πλάτος μιας συμβολοσειράς στην επιλεγμένη γραμματοσειρά και μέγεθος, μετρημένο πριν το δεσμεύσετε (commit) στη σελίδα

Αυτό είναι το κλασικό πρόβλημα διάταξης (layout). Για να αναδιπλώσετε (wrap) μια παράγραφο σε μια στήλη, πρέπει να γνωρίζετε, λέξη προς λέξη, πόσο οριζόντιο χώρο θα καταλάβει κάθε υποψήφια γραμμή, και πρέπει να το γνωρίζετε αυτό πριν σχεδιάσετε οτιδήποτε. Η αναδίπλωση λέξεων (word wrap) είναι ένας βρόχος μέτρησης (measurement loop) τυλιγμένος γύρω από μια κλήση σχεδίασης, και μια σύνδεση (binding) που μόνο σχεδιάζει σας δίνει το δεύτερο μισό. Η υποστήριξη μέτρησης κειμένου στο στοιχείο PDFium κλείνει αυτό το κενό (gap) με δύο συναρτήσεις, τις MeasureText και MeasureTextWidth, που αναφέρουν την αποδοσμένη έκταση (rendered extent) μιας συμβολοσειράς χωρίς να βάλουν ούτε ένα σημάδι (mark) σε καμία σελίδα

Γιατί η μέτρηση είναι μια βοηθητική κλάση (class helper), όχι μια νέα μέθοδος στο TPdf

Η υποστήριξη μέτρησης έρχεται ως μια βοηθητική κλάση (class helper) της Delphi για το TPdf, που ζει στη δική της μονάδα (unit), αντί ως νέες μέθοδοι βιδωμένες (bolted) στην κλάση TPdf. Ένα class helper είναι ένα χαρακτηριστικό της γλώσσας που σας επιτρέπει να επισυνάψετε (attach) μεθόδους σε έναν υπάρχοντα τύπο από το εξωτερικό της δήλωσής του. Μόλις η μονάδα βρεθεί εντός εμβέλειας (in scope), οι νέες μέθοδοι καλούνται ακριβώς σαν να ανήκαν στην κλάση, οπότε μια βοηθητική μέθοδος διαβάζεται ως Pdf.MeasureTextWidth(...) χωρίς κανένα ξεχωριστό αντικείμενο για κατασκευή ή πέρασμα

Ο λόγος που διαστρωματώνεται (layer) με αυτόν τον τρόπο είναι ο διαχωρισμός (separation). Ο πυρήνας (core) του τύπου TPdf παραμένει ως έχει, χωρίς να προστεθεί κανένα πεδίο και χωρίς να αγγιχτεί καμία υπάρχουσα υπογραφή (signature), οπότε ένα έργο (project) που δεν χρειάζεται ποτέ διάταξη δεν φέρει ποτέ τον κώδικα μέτρησης. Ένα έργο που το χρειάζεται, προσθέτει μία μονάδα σε έναν όρο uses και οι μέθοδοι ανάβουν (light up). Η ικανότητα γίνεται προαιρετική (opt-in) στην κοκκοποίηση (granularity) μιας μεμονωμένης μονάδας, που είναι ο πιο καθαρός τρόπος για να επεκτείνετε έναν τύπο που δεν σας ανήκει ή που δεν θέλετε να ενοχλήσετε (disturb)

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;

Μέτρηση χωρίς να αγγίζετε τη σελίδα

Η μέτρηση πρέπει να είναι απαλλαγμένη από παρενέργειες (side effects). Πρέπει να αναφέρει ένα πλάτος χωρίς να αφήνει τίποτα πίσω της, επειδή την καλείτε πολλές φορές ενώ αποφασίζετε για μια διάταξη και η σελίδα πρέπει να φαίνεται ακριβώς όπως θα φαινόταν αν δεν είχατε μετρήσει ποτέ. Η τεχνική που το καθιστά αυτό δυνατό είναι η δημιουργία ενός αντικειμένου κειμένου, η ερώτηση για το μέγεθός του, και η απόρριψή του (throw it away) πριν προσαρτηθεί (attached) ποτέ σε μια σελίδα

Η ακολουθία είναι τέσσερις κλήσεις του PDFium. Το FPDFPageObj_NewTextObj δημιουργεί ένα αντικείμενο κειμένου σε σχέση με το έγγραφο, δεδομένου του ονόματος και του μεγέθους της γραμματοσειράς. Το FPDFText_SetText ορίζει τη συμβολοσειρά που φέρει αυτό το αντικείμενο. Το FPDFPageObj_GetBounds διαβάζει πίσω το πλαίσιο οριοθέτησης (bounding box) του αντικειμένου. Το FPDFPageObj_Destroy απελευθερώνει (frees) το αντικείμενο. Το πιο σημαντικό, τίποτα σε αυτήν την ακολουθία δεν καλεί το API εισαγωγής σελίδας. Το αντικείμενο δημιουργείται, ερωτάται (queried), και καταστρέφεται σε απομόνωση (in isolation), οπότε το έγγραφο παραμένει αμετάβλητο όταν η συνάρτηση επιστρέφει. Είναι ένας αναλώσιμος (throwaway) αισθητήρας (probe) του οποίου η μόνη έξοδος (output) είναι οι τέσσερις αριθμοί του πλαισίου οριοθέτησής του

Αυτός είναι ο ισχυρός (robust) τρόπος για να το κάνετε, επειδή το PDFium δεν εκθέτει (expose) ένα βολικό πλάτος προώθησης (advance width) ανά σύμβολο (per-glyph) που θα μπορούσατε να αθροίσετε μόνοι σας. Οι μετρήσεις των συμβόλων εξαρτώνται από το πρόγραμμα της γραμματοσειράς, από την κωδικοποίηση, και από το πώς το PDFium φορτώνει τη μορφοποίηση (face), και δεν υπάρχει δημόσια κλήση που να σας παραδίδει (hands) την προώθηση κάθε χαρακτήρα σε μια συμβολοσειρά. Το πλαίσιο οριοθέτησης ενός πραγματικού αντικειμένου κειμένου, από την άλλη πλευρά, υπολογίζεται από τον ίδιο μηχανισμό (machinery) που θα τοποθετούσε τα σύμβολα (lay the glyphs out) για σχεδίαση, οπότε αντικατοπτρίζει την πραγματική αποδοσμένη (rendered) έκταση αντί για μια προσέγγιση (approximation). Η κατασκευή ενός αναλώσιμου (disposable) αντικειμένου και η ανάγνωση των ορίων του είναι η πιο αξιόπιστη μέτρηση που μπορεί να δώσει η βιβλιοθήκη

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

Συντεταγμένες και μονάδες του αποτελέσματος

Το πλαίσιο οριοθέτησης επιστρέφει ως τέσσερις ακμές (edges), αριστερά, κάτω, δεξιά και πάνω, και οι δύο διαστάσεις προκύπτουν με αφαίρεση (subtraction). Το πλάτος είναι το δεξί μείον το αριστερό και το ύψος είναι το πάνω μείον το κάτω. Και τα δύο εκφράζονται σε μονάδες χρήστη (user units) PDF, όπου μία μονάδα είναι το ένα εβδομηκοστό δεύτερο της ίντσας, ο ίδιος χώρος συντεταγμένων στον οποίο τοποθετείτε κείμενο στη σελίδα. Δεν υπάρχει καμία κρυφή (hidden) μονάδα συσκευής και κανένα pixel δεν εμπλέκεται σε αυτό το στάδιο. Ένα πλάτος 36 σημαίνει μισή ίντσα σελίδας, ανεξάρτητα από την τελική ανάλυση (resolution) απόδοσης

Ο κατακόρυφος άξονας (vertical axis) τρέχει με τον τρόπο που τον ορίζει το PDF, με το Y να αυξάνεται προς τα πάνω, γι' αυτό και το ύψος είναι το πάνω μείον το κάτω παρά το αντίστροφο. Αυτή η λεπτομέρεια έχει σημασία όταν προωθείτε έναν δρομέα (cursor) προς τα κάτω σε μια στήλη. Μετράτε το ύψος μιας γραμμής, μετά το αφαιρείτε από την τρέχουσα γραμμή βάσης (baseline) για να βρείτε την επόμενη, επειδή η μετακίνηση προς τα κάτω στη σελίδα σημαίνει μετακίνηση προς μικρότερο Y. Εάν ο προορισμός σας είναι μια οθόνη αντί για χαρτί, μετατρέπετε τις μονάδες χρήστη σε pixel συσκευής με την ανάλυση οθόνης: μια τιμή σε μονάδες χρήστη πολλαπλασιασμένη με το DPI και διαιρεμένη με το 72 δίνει pixels, οπότε ένα πλάτος στήλης που ορίζετε σε στιγμές (points) μπορεί να συγκριθεί (matched against) με μια μετρημένη διαδρομή (measured run) πριν αποφασίσετε πού πάει η διακοπή (break)

Τι συμβαίνει με εκφυλισμένη (degenerate) είσοδο

Οι συναρτήσεις είναι γραμμένες ώστε να αποτυγχάνουν αθόρυβα (fail quietly). Εάν δεν υπάρχει ανοιχτό έγγραφο, ή εάν δεν μπορεί να δημιουργηθεί το αντικείμενο κειμένου, το αποτέλεσμα είναι μια μηδενική έκταση αντί για μια προκληθείσα εξαίρεση (raised exception). Το πλάτος και το ύψος αρχικοποιούνται (initialised) σε μηδέν στην κορυφή και αντικαθίστανται (overwritten) μόνο μόλις διαβαστεί επιτυχώς ένα πλαίσιο οριοθέτησης. Μια κενή συμβολοσειρά, ένα έγγραφο που λείπει, μια γραμματοσειρά που η βιβλιοθήκη δεν μπορεί να επιλύσει (resolve) σε αντικείμενο, το καθένα από αυτά επιστρέφει μηδέν αντί να πετάξει (throwing) εξαίρεση

Αυτή η επιλογή κρατά απλό έναν βρόχο (loop) μέτρησης, επειδή ένας βρόχος που τρέχει πάνω από χιλιάδες λέξεις δεν είναι το μέρος για χειρισμό εξαιρέσεων σε κάθε επανάληψη (iteration). Το κόστος είναι ότι ο καλών (caller) φέρει τον έλεγχο. Ένα μηδενικό πλάτος είναι ένας φρουρός (sentinel), όχι ένα γεγονός για το κείμενο, οπότε ο κώδικας που διαιρεί με ένα μετρημένο πλάτος ή υποθέτει μια θετική τιμή πρέπει να προστατεύεται (guard) έναντι του μηδενός πριν το εμπιστευτεί. Αντιμετωπίστε (treat) το μηδέν ως "δεν μπόρεσε να μετρηθεί" και η σύμβαση (contract) είναι σαφής· αγνοήστε το, και μια εκφυλισμένη (degenerate) είσοδος γίνεται αθόρυβα μια διάταξη (layout) με μια στήλη από αλληλοκαλυπτόμενα (overlapping) σύμβολα

Μια άπληστη (greedy) αναδίπλωση λέξεων χτισμένη πάνω στη μέτρηση

Με μια συνάρτηση πλάτους στο χέρι, η αναδίπλωση λέξεων είναι ένας σύντομος (short) άπληστος (greedy) βρόχος. Χωρίζετε την παράγραφο σε λέξεις, διατηρείτε μια τρέχουσα γραμμή, και για κάθε λέξη μετράτε ποια θα ήταν η γραμμή αν προσαρτούσατε (appended) αυτήν τη λέξη. Ενόσω η δοκιμαστική γραμμή εξακολουθεί να χωράει στο πλάτος της στήλης συνεχίζετε να προσθέτετε· όταν θα ξεχείλιζε (overflow) ξεπλένετε (flush) την τρέχουσα γραμμή με την AddText και ξεκινάτε μια νέα με τη λέξη που δεν χώρεσε. Η συσσώρευση (accumulation) γίνεται εξ ολοκλήρου με την MeasureTextWidth, και το μόνο πράγμα που φτάνει ποτέ στη σελίδα είναι μια γραμμή που έχετε ήδη επιβεβαιώσει ότι χωράει

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;

Ο βρόχος μετράει τη δοκιμαστική γραμμή (trial line) αντί να μετράει κάθε λέξη και να αθροίζει, επειδή το πλάτος μιας γραμμής δεν είναι το άθροισμα των πλατών των λέξεών της. Τα κενά (spaces) μεταξύ των λέξεων συμβάλλουν (contribute), και μια μετρημένη διαδρομή (measured run) το αποτυπώνει (captures) αυτό άμεσα. Ο άπληστος (greedy) κανόνας, χωρέστε όσες λέξεις επιτρέπει η στήλη και διακόψτε στην τελευταία που χωράει, είναι ο ίδιος κανόνας που γεμίζει το κενό μεταξύ μιας ακατέργαστης (raw) AddText και μιας πραγματικής παραγράφου. Η κλήση σχεδίασης δεν ήταν ποτέ το δύσκολο μέρος. Η μέτρηση που πρέπει να προηγείται (precede) από αυτήν είναι, και αυτό είναι ακριβώς που παρέχει ο βοηθός (helper)

Πού ταιριάζει αυτό (Where this fits)

Η μέτρηση είναι το στρώμα (layer) μεταξύ της δημιουργίας (generating) περιεχομένου και της απόδοσής (rendering) του, οπότε συνδυάζεται (pairs) φυσικά με το υπόλοιπο της ροής εργασιών (workflow) ενός εγγράφου από το μηδέν (from-scratch). Εάν συναρμολογείτε (assembling) σελίδες και τοποθετείτε κείμενο εξαρχής (in the first place), οι βάσεις (groundwork) βρίσκονται στη δημιουργία εγγράφων PDF από το μηδέν με το στοιχείο PDFium στη Delphi, όπου η AddText και η ρύθμιση (setup) σελίδας καλύπτονται πλήρως. Όταν η γραμματοσειρά που μετράτε έχει την ίδια σημασία με τη συμβολοσειρά, επειδή οι μετρήσεις (metrics) εξαρτώνται από τη μορφοποίηση (face), η ανάλυση ιδιοτήτων γραμματοσειρών PDF με το στοιχείο PDFium στη Delphi δείχνει πώς η βιβλιοθήκη αναφέρει (reports) τις πληροφορίες της γραμματοσειράς που οδηγούν αυτά τα πλαίσια οριοθέτησης. Και τα δύο βασίζονται στην ίδια σύνδεση (binding), το Στοιχείο PDFium για Delphi και Lazarus, όπου ο βοηθός (helper) μέτρησης αποστέλλεται (ships) μαζί με τα API εγγράφων, σελίδων και κειμένου που περιγράφονται σε αυτό το ιστολόγιο