Το PDFium Component για Delphi ενσωματώνει τα system fonts που χρησιμοποιεί το TPdf.AddText ως CID fonts με κλειδί το code point Unicode, ώστε κάθε CID να κουβαλάει ακριβώς μία αντιστοίχιση ToUnicode. Αυτό εμποδίζει τα εξαγόμενα διαστήματα να επιστρέφουν ως U+00A0 (no-break space) και τις παύλες ως U+00AD (soft hyphen), τόσο από το ζωντανό έγγραφο όσο και από το αποθηκευμένο αρχείο
Το σύμπτωμα είναι βρώμικο επειδή είναι αόρατο. Ένας δείκτης αναζήτησης χάνει το «two-x» επειδή το αποθηκευμένο string περιέχει soft hyphen, μια εξαγωγή CSV σπάει διαφορετικά, ένα εργαλείο diff σημαδεύει γραμμές που μοιάζουν πανομοιότυπες σε κάθε viewer. Τίποτα στη σελίδα που αποδίδεται δεν είναι λάθος· λάθος είναι μόνο το Unicode πίσω από τα glyphs
Γιατί τα εξαγόμενα διαστήματα επιστρέφουν ως U+00A0;
Τα εξαγόμενα διαστήματα γίνονται U+00A0 επειδή η CMap ToUnicode που παράγει το PDFium στο FPDFText_LoadFont έχει κλειδί το glyph, και ένα glyph μπορεί να φτάσει από δύο code points. Στην Arial, το glyph 3 εξυπηρετεί και το U+0020 και το U+00A0, και το glyph της παύλας εξυπηρετεί και το U+002D και το U+00AD. Η παραγόμενη CMap αντιστοιχίζει επομένως το ίδιο CID δύο φορές, μία μέσω μιας εγγραφής bfchar και μία μέσω ενός bfrange σε μορφή array, και όποια εγγραφή ευνοεί ο κανόνας προτεραιότητας του reader γίνεται το εξαγόμενο κείμενο
1 beginbfchar
<0003> <0020>
endbfchar
1 beginbfrange
<0003> <0010> [<00A0> ...]
endbfrange
Για πολύ καιρό αυτή η αντίφαση ήταν αβλαβής, αφού ο reader του PDFium άφηνε τη χαμηλότερη αντιστοίχιση να κερδίσει. Μια αλλαγή upstream μετέτρεψε τον reader σε τελευταίο-κερδίζει, και από εκείνο το build κάθε διάστημα γραμμένο με AddText εξάγονταν ως NBSP και κάθε παύλα ως soft hyphen. Προσέξτε το μοτίβο στα ζεύγη: τα 0x20/0xA0 και 0x2D/0xAD διαφέρουν μόνο στο υψηλό bit, που είναι ακριβώς ό,τι θα περίμενε κανείς από μια γραμματοσειρά της οποίας το cmap στέλνει τους ομοειδείς του Latin-1 στο ίδιο περίγραμμα. Αν ο κώδικας εξαγωγής σας ήταν μια χαρά χθες και τώρα αποτυγχάνει σε αόρατους χαρακτήρες, ξαναγράψτε τα code points αντί να εμπιστεύεστε την προβολή του debugger· τα βασικά του τραβήγματος κειμένου καλύπτονται στο εξαγωγή κειμένου από έγγραφα PDF με PDFium στο Delphi
uses
SysUtils, PDFium;
const
// Το διάστημα/U+00A0 και η παύλα/U+00AD μοιράζονται ένα glyph της Arial, όπως
// και το ελληνικό Ωμέγα (U+03A9) και το σύμβολο Ohm (U+2126)
Sample: WString = 'two-x'#$00A0'y'#$00AD'z '#$03A9#$2126;
function CodePoints(const S: WString): string;
var
I: Integer;
begin
Result := '';
for I := 1 to Length(S) do
Result := Result + 'U+' + IntToHex(Ord(S[I]), 4) + ' ';
end;
var
Pdf: TPdf;
Live, Reloaded: WString;
begin
Pdf := TPdf.Create(nil);
try
Pdf.CreateDocument;
Pdf.AddPage(1, 595, 842);
Pdf.AddText(Sample, 'Arial', 12, 72, 770);
Live := Pdf.Text; // ζωντανό, μη αποθηκευμένο έγγραφο
Pdf.SaveAs('codepoints.pdf');
finally
Pdf.Free;
end;
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'codepoints.pdf';
Pdf.Active := True;
Pdf.PageNumber := 1;
Reloaded := Pdf.Text; // μετά από πλήρες save και επαναφόρτωση
finally
Pdf.Free;
end;
if (Live <> Sample) or (Reloaded <> Sample) then
Writeln('Mismatch: ', CodePoints(Live), '/ ', CodePoints(Reloaded));
end;
Γιατί το μπάλωμα της CMap μετά την αποθήκευση δεν αρκούσε
Το μπάλωμα του αποθηκευμένου αρχείου διορθώνει μόνο το αποθηκευμένο αρχείο, και μόνο αν το μάλωμα κρατάει τη δομή της CMap ανέπαφη byte προς byte. Η πρώτη διόρθωση, το RepairSubsetToUnicodeCMaps στη μονάδα FPdfCompress, τρέχει μετά από κάθε μη σταδιακό TPdf.SaveAs και επιλύει κάθε συγκρουόμενο CID: η εγγραφή bfchar κερδίζει, ένα ζεύγος που διαφέρει μόνο στο υψηλό bit επιλύεται στον μικρότερο code point βάσης-Latin, και οτιδήποτε άλλο κρατάει την πρώτη του αντιστοίχιση
Το ενδιαφέρον μέρος είναι το αρνητικό αποτέλεσμα. Το να ξαναχτιστεί καθαρά η συγκρουόμενη CMap, σε μορφή start-code ή array, έμοιαζε η προφανής κίνηση, και το PDFium απέρριπτε κάθε ξαναχτισμένη CMap ολότελα, πέφτοντας πίσω στο Identity. Η μόνη έξοδος που δέχτηκε ο native reader ήταν μια αντικατάσταση επί τόπου ίσου μήκους των συγκρουόμενων hex τιμών, με τη διάταξη μπλοκ και την κάλυψη CID ανέγγιχτες. Το δεύτερο μάθημα ήταν πιο ταπεινό: η σημείωσή μας τότε κατηγορούσε την περίπτωση στη μνήμη στο ότι το ζωντανό έγγραφο δεν είχε καθόλου stream ToUnicode. Το να καλέσουμε απευθείας τη DLL το διαψεύδησε, αφού το ζωντανό έγγραφο κουβαλάει το ίδιο αμφίσημο stream, που σήμαινε ότι η πραγματική διόρθωση έπρεπε να συμβεί πριν το PDFium παράγει ποτέ την CMap. Η ρουτίνα επιδιόρθωσης μένει στη βιβλιοθήκη ως άμυνα για PDF παραγόμενα από άλλα εργαλεία πάνω σε PDFium
uses
Classes, FPdfCompress;
var
Source, Dest: TFileStream;
begin
Source := TFileStream.Create('from-other-tool.pdf',
fmOpenRead or fmShareDenyWrite);
try
Dest := TFileStream.Create('repaired.pdf', fmCreate);
try
// Μόνο επεξεργασίες ίσου μήκους· αρχεία χωρίς επισκευάσιμη σύγκρουση,
// και αρχεία με cross-reference stream ή object stream, αντιγράφονται ως έχουν
RepairSubsetToUnicodeCMaps(Source, Dest);
finally
Dest.Free;
end;
finally
Source.Free;
end;
end;
Γραμματοσειρά με κλειδί το code point αντί για το glyph
Η ριζική διόρθωση είναι να πάψει κανείς να ζητά από το PDFium να παράγει καθόλου την CMap. Το TPdf.LoadCachedFont δίνει τώρα τα bytes της system font στο TPdf.LoadUnicodeKeyedCidFont, που διαβάζει τον δικό της sfnt πίνακα cmap της γραμματοσειράς, προτιμώντας υποπίνακα format 12 και πέφτοντας στο format 4. Τα code points επιστρέφουν ταξινομημένα και χωρίς διπλότυπα, και το CID k+1 ανατίθεται στον k-οστό code point, με το CID 0 να μένει ως .notdef. Ένα ρητό CIDToGIDMap στέλνει κάθε CID στο glyph του, ώστε τα U+0020 και U+00A0 να παίρνουν δύο διαφορετικά CIDs που ζωγραφίζουν το ίδιο περίγραμμα, και η CMap ToUnicode αντιστοιχίζει κάθε CID σε ένα και μόνο code point. Η γραμματοσειρά φορτώνεται έπειτα μέσω FPDFText_LoadCidType2Font, το ίδιο σημείο εισόδου πίσω από τη γραφή επιπέδου glyph στο ενσωμάτωση CID Type 2 font με ρητούς χάρτες CID-to-GID
// Συμπυκνωμένο από το TPdf.LoadUnicodeKeyedCidFont
SetLength(CidToGidMap, (Length(Entries) + 1) * 2); // CID 0 = .notdef
for I := 0 to High(Entries) do
begin
CidToGidMap[(I + 1) * 2] := Byte(Entries[I].GlyphID shr 8);
CidToGidMap[(I + 1) * 2 + 1] := Byte(Entries[I].GlyphID and $FF);
end;
ToUnicode := BuildUnicodeKeyedCidCMap(Entries); // ένα CID, ένα code point
Result := FPDFText_LoadCidType2Font(Document, @Data[0], Length(Data),
PAnsiChar(ToUnicode), @CidToGidMap[0], Length(CidToGidMap));
Όταν το FPDFText_SetText γράφει αργότερα ένα string, η αντίστροφη αναζήτηση προσγειώνεται σε ένα μόνο CID ανά χαρακτήρα, ώστε NBSP, soft hyphen και σύμβολο Ohm να επιβιώνουν το καθένα ως εαυτό του κάτω από οποιονδήποτε κανόνα προτεραιότητας, στη μνήμη και μετά από οποιοδήποτε save. Επειδή το αποθηκευμένο αρχείο κουβαλάει το δικό του stream ToUnicode του component αντί για ένα παραγόμενο από τη μηχανή, το RepairSubsetToUnicodeCMaps δεν βρίσκει τίποτα να διορθώσει μέσα του
Γιατί μια εγγραφή bfrange μπορεί να σβήσει ολόκληρο μπλοκ;
Ένα μόνο bfrange του οποίου η ακολουθία CID διασχίζει ένα όριο xxFF κάνει το PDFium να πετάξει ολόκληρο το μπλοκ που κάθεται μέσα. Το ISO 32000-1 §9.10.3 αφήνει μόνο το τελευταίο byte του προορισμού να μεταβάλλεται μέσα σε ένα εύρος, αλλά η πλευρά CID έχει τη δική της παγίδα: το HandleBeginBFRange του PDFium παράγει το υψηλό CID ως (low and $FFFFFF00) or (high and $FF). Μια ακολουθία από CID 00FE έως 0101 διαβάζεται επομένως ως 00FE έως 0001, χαμηλότερο μεγαλύτερο από υψηλότερο, και ολόκληρο το μπλοκ σημειώνεται άκυρο. Η αποτυχία είναι σιωπηλή: το SetText πετυχαίνει, η σελίδα αποδίδεται τέλεια, και η εξαγωγή επιστρέφει U+0000 για κάθε χαρακτήρα σε εκείνο το μπλοκ
Το BuildUnicodeKeyedCidCMap τερματίζει μια ακολουθία πριν ο code point ή το CID φτάσει ένα χαμηλό byte FF, κρατάει κάθε μπλοκ μέσα στο όριο των 100 εγγραφών της γραμματικής της CMap, και γράφει code points supplementary plane ως μεμονωμένες εγγραφές bfchar με προορισμούς ζευγών surrogate UTF-16, αφού η προσαύξηση ενός ζεύγους surrogate μέσα σε ένα εύρος δεν έχει ορισμένη έννοια· η πλευρά surrogate αυτής της ιστορίας βρίσκεται στο χειρισμό emoji, CJK και ζευγών surrogate στο Delphi. Μια CMap μόνο με bfchar θα παρέκαμπτε το πρόβλημα του ορίου ολότελα, με πολλαπλάσιο μέγεθος
Τι δεν καλύπτει η γραμματοσειρά με κλειδί το code point;
Η διαδρομή με κλειδί το code point καλύπτει κάθε γραμματοσειρά που εκθέτει υποπίνακα cmap Unicode, και πέφτει πίσω στην παλιά συμπεριφορά με κλειδί το glyph για τις υπόλοιπες. Τα όρια που αξίζει να ξέρετε πριν βασιστείτε σε αυτή:
- Symbol fonts με μόνο ένα cmap (3,0), και κάθε γραμματοσειρά που η διαδρομή CID αποτυγχάνει να φορτώσει, περνούν από
FPDFText_LoadFontόπως πριν, οπότε ένα glyph που μοιράζονται δύο code points μπορεί ακόμη εκεί να εξάγεται αμφίσημα - Χωρίς υποπίνακα format 12 ο χάρτης περιορίζεται στο BMP, και το πλήθος των εγγραφών έχει πλαφόν 65535 ώστε κάθε CID να χωράει σε δύο bytes πάνω από το μηδέν
- Τα σταδιακά saves (
saIncremental) προσπερνούν τοRepairSubsetToUnicodeCMapsσχεδιαστικά, επειδή μια σταδιακή αναθεώρηση πρέπει να μένει μόνο-προσθήκη· τα fonts με κλειδί το code point το καθιστούν άσχετο για το κείμενο που γράφει το ίδιο το component - Τα TrueType Collections θέλουν επιπλέον προσοχή: το
GetFontDataτου GDI επιστρέφει ολόκληρο το .ttc, και τοFPDFText_LoadCidType2Fontδεν έχει παράμετρο δείκτη face, οπότε το να ζητήσεις NSimSun από simsun.ttc ενσωμάτωνε και απέδιδε SimSun, face 0. Το component πλέον ταυτίζει το όνομα της οικογένειας απέναντι στον πίνακα name (nameID 1 και 16) και εξάγει τη ζητούμενη face ως ανεξάρτητο sfnt πριν γίνει parse το cmap· αν το parsing αποτύχει, τα bytes της συλλογής περνούν ως έχουν και η συμπεριφορά επανέρχεται στο face 0
Η γραφή κειμένου, η ενσωμάτωση γραμματοσειρών και η εξαγωγή μοιράζονται ένα μοντέλο σελίδας σε Delphi, C++Builder και Lazarus, και το πλήρες API περιγράφεται στη σελίδα προϊόντος PDFium Component για Delphi