Glyph đã shape hiển thị thành các ô .notdef khi bộ tạo subset font chỉ giữ lại những glyph có thể tới được từ các code point đã phát ra. HotPDF, thành phần VCL PDF gốc cho Delphi và C++Builder, mang đúng khiếm khuyết đó cho tới phiên bản 2.435.0: đầu ra GSUB của OpenType được ghi vào một bitmap sử dụng nội bộ mà bộ tạo subset tuyên bố sẽ tôn trọng nhưng thực tế chưa từng đọc
Đây là một lỗi khác với lỗi được mô tả trong lỗi EndDoc từng âm thầm vô hiệu hóa việc tạo subset font. Lỗi đó nói về thời điểm việc tạo subset chạy so với việc tuần tự hóa, và nó vô hiệu hóa hoàn toàn việc tạo subset. Lỗi này nói về nội dung của subset khi việc tạo subset chạy đúng lịch trình một cách hoàn hảo. Pipeline kích hoạt đúng thời điểm, tiền tố subset sáu chữ cái xuất hiện trên /BaseFont đúng như ISO 32000-1 §9.6.4 yêu cầu, tệp nhỏ lại, mọi trang tiếng Latin kiểm tra đều sạch, và một trang tiếng Ả Rập ra thành một hàng hình chữ nhật rỗng. Lỗi về thứ tự thì ồn ào ngay khi bạn nhìn vào. Lỗi về đóng bao (closure) thì im lặng mãi mãi, vì subset vẫn hợp lệ về mặt cấu trúc và chỉ sai về danh sách thành viên của chính nó
Vì sao glyph đã shape hiển thị thành .notdef?
Vì tập code point mà một tài liệu phát ra không phải là tập glyph mà tài liệu đó vẽ, và một bộ tạo subset gộp chung hai thứ đó sẽ làm rớt mọi glyph được tạo ra bởi shaping. Việc shape văn bản biến một chuỗi ký tự logic thành một chuỗi glyph đã định vị, và toàn bộ mục đích của nó là tạo ra những glyph mà không một ký tự đầu vào đơn lẻ nào ánh xạ tới: một chữ heh Ả Rập ở vị trí giữa, một ligature fi, một tổ hợp phụ âm (conjunct) Devanagari, một biến thể theo ngữ cảnh được chọn bởi tính năng rclt. Mỗi cái trong số đó là một glyph ID do một lookup GSUB tạo ra, không phải cái mà bảng cmap đưa cho bạn ứng với bất kỳ ký tự nào trong chuỗi của bạn. Vì vậy một bộ tạo subset chỉ dựa thuần vào cmap đang duyệt sai chỉ mục. Nó trung thành giữ lại mọi glyph mà văn bản có thể đã dùng trước khi shape và loại bỏ chính xác những glyph mà văn bản thực sự dùng sau khi shape. Trình kết xuất khi đó hỏi font đã nhúng về GID 1847, subset đã đặt về không mục đó trong loca, và chỉ số glyph 0 được trả về thay thế. Chỉ số glyph 0 là .notdef theo định nghĩa của OpenType, đó là lý do dấu hiệu lỗi là một ô rỗng chứ không phải một chữ cái sai hay một lỗi crash. Không có gì trong PDF bị hỏng cấu trúc; font đơn giản là không chứa glyph mà content stream đã yêu cầu
Code point không phải là glyph: ba nguồn của một subset
Một phép đóng bao subset đúng phải hợp nhất ba nguồn độc lập, mỗi nguồn có bộ tích lũy riêng. Nguồn đầu tiên là tập suy ra từ code point: HotPDF tích lũy FUnicodeUsedCps khi các ký tự BMP được phát ra và FUnicodeSmpUsed cho các ký tự thuộc mặt phẳng bổ sung (supplementary plane) được tới qua các cặp surrogate, rồi ánh xạ từng cái qua FUnicodeCpToGid sang một glyph ID. Nguồn thứ hai là tập suy ra từ shaping, các glyph ID mà một phép thay thế GSUB tạo ra, được ghi lại qua MarkUnicodeGlyphUsed và EnableShapingFeatureForSubset vào FUnicodeExtraUsedGlyphs. Nguồn thứ ba là đóng bao thành phần (composite closure): một glyph có numberOfContours bằng -1 trong glyf được lắp ráp từ các glyph ID thành phần, và giữ lại glyph tổ hợp trong khi làm rớt các thành phần của nó tạo ra một đường viền rỗng thay vì một .notdef, điều này có lẽ còn tệ hơn vì nó bị đọc nhầm thành một lỗi giãn cách
HotPDF luôn xử lý đúng nguồn thứ nhất và thứ ba. BuildAndApplyUnicodeFontSubset, điểm vào tạo subset mà EndDoc gọi trước khi tuần tự hóa, gieo mảng glyph-đã-dùng với GID 0, duyệt các code point BMP, duyệt danh sách sử dụng SMP, và giao mảng đó cho một bộ dựng subset giải quyết các thành phần tổ hợp nội bộ. Nguồn thứ hai đã được viết nhưng chưa từng được tiêu thụ, và vì ba nguồn này thất bại trên những loại nội dung khác nhau, khoảng trống đó có thể ẩn nhiều năm trong một codebase mà kho hồi quy chủ yếu là tiếng Latin
Mảng được viết nhưng chưa từng được đọc
Hợp đồng này được ghi tài liệu ở ba nơi và không được tôn trọng ở nơi nào cả. Khai báo của FUnicodeExtraUsedGlyphs nói rằng bộ tạo subset trong EndDoc hợp nhất nó với tập sử dụng suy ra từ code point; chú thích header trên ApplyArabicGSUBRefinement hứa rằng mọi GID thay thế được phát ra đều được truyền qua MarkUnicodeGlyphUsed để bộ tạo subset kéo glyph đó vào font đã nhúng; cùng lời hứa đó xuất hiện y hệt trên ApplyArabicGSUBContextualRefinement cho đường rclt. Cả hai nơi gọi đều thực hiện đúng phần của mình. Một lượt grep qua mọi tham chiếu tới trường đó giải quyết nửa còn lại trong khoảng chín mươi giây: một khai báo, một lần cấp phát SetLength bên trong RegisterUnicodeTTF, và các lần ghi trong hai hàm đánh dấu. Không một lần đọc nào. Đó chính là chẩn đoán đáng ghi nhớ, vì nó tổng quát hóa tốt vượt xa phạm vi font. Khi một trường được ghi bởi nhiều nơi gọi mà không được đọc bởi bất kỳ nơi nào, tính năng mà nó đại diện không tồn tại, bất kể nó được chú thích kỹ lưỡng đến đâu. Bước 1 của bộ tạo subset đủ nhỏ để đọc trong một màn hình, và khoảng trống rõ ràng ngay khi bạn biết cần tìm nó ở đâu
// Step 1: derive the used-glyph set (as it stood before 2.435.0)
SetLength(UsedGlyphs, FUnicodeNumGlyphs);
for I := 0 to FUnicodeNumGlyphs - 1 do
UsedGlyphs[I] := False;
UsedGlyphs[0] := True; // .notdef is always present
for Cp := 0 to $FFFF do // source 1a: BMP code points
if (Cp < Length(FUnicodeUsedCps)) and FUnicodeUsedCps[Cp]
and (Cp < Length(FUnicodeCpToGid)) then
begin
GID := FUnicodeCpToGid[Cp];
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
for I := 0 to High(FUnicodeSmpUsed) do // source 1b: SMP code points
begin
GID := FUnicodeSmpUsed[I].GID;
if (GID > 0) and (GID < FUnicodeNumGlyphs) then
UsedGlyphs[GID] := True;
end;
// source 2 was missing here: nothing ever consulted FUnicodeExtraUsedGlyphs
Một sửa lỗi bằng vòng lặp, và cách tự đánh dấu glyph
Cách sửa là một phép hợp, và lập luận về độ an toàn của nó đến từ chiều của thao tác: nó chỉ đặt bit, không bao giờ xóa bit, nên không glyph nào từng sống sót qua subset có thể bắt đầu bị làm rớt
// v2.435.0: pull GSUB-derived extra glyphs into the subset.
// MarkUnicodeGlyphUsed / EnableShapingFeatureForSubset record GIDs that
// shaping produced but that no emitted code point maps to directly.
for I := 0 to FUnicodeNumGlyphs - 1 do
if (I < Length(FUnicodeExtraUsedGlyphs)) and FUnicodeExtraUsedGlyphs[I] then
UsedGlyphs[I] := True;
Ba đặc tính khiến đây là một thay đổi rủi ro thấp chứ không phải một cuộc viết lại engine font. Nó đơn điệu (monotone), như ở trên. Nó là một thao tác vô tác dụng (no-op) trên các font chưa từng shape gì cả, vì FUnicodeExtraUsedGlyphs vẫn toàn False và đầu ra byte cho một tài liệu chỉ toàn tiếng Latin không đổi. Và nó xuất hiện trước Bước 2, nên cả hai bộ dựng subset đều kế thừa nó: bộ dựng thưa (sparse) giữ nguyên cách đánh số GID gốc, và bộ dựng gọn (compact) _BuildCompactSubsetTTF mà HotPDF chọn dưới PDF/A để đánh số lại các glyph được giữ vào một dải liên tục, thu nhỏ maxp.numGlyphs, và phát ra ánh xạ cũ-sang-mới dưới dạng stream /CIDToGIDMap mà ISO 32000-1 §9.7.4.2 yêu cầu. Cả hai đều gọi _TTFWalkCompositeClosure nội bộ, nên một glyph đã shape mà tình cờ là tổ hợp giờ cũng kéo theo các thành phần của nó. Đóng bao tổ hợp chưa bao giờ bị hỏng; nó đơn giản là chưa từng được tới với những glyph ID này, vì các glyph ID đó không nằm trong tập mà nó duyệt. Nếu bạn điều khiển engine GSUB trực tiếp thay vì dựa vào các lượt tinh chỉnh (refinement) tích hợp sẵn, việc đóng bao trở thành trách nhiệm của bạn, và mọi glyph ID thay thế bạn phát ra phải được đánh dấu trước khi EndDoc đóng băng tập glyph-đã-dùng
var
Pdf: THotPDF;
GIDs: array[0..1] of Word;
LigGID: Word;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'shaped.pdf';
Pdf.BeginDoc;
Pdf.RegisterUnicodeTTF('C:\Fonts\NotoNaskhArabic-Regular.ttf');
Pdf.ShapingFeatures := [sfArabicGSUB, sfStandardLigatures,
sfContextualAlternates];
GIDs[0] := Pdf.GetUnicodeGlyphForCodepoint($0644); // lam
GIDs[1] := Pdf.GetUnicodeGlyphForCodepoint($0627); // alef
if Pdf.ApplyLigatureSubstitution(GIDs, 0, 'liga', LigGID) then
Pdf.MarkUnicodeGlyphUsed(LigGID); // omit this and you get .notdef
Pdf.EnableShapingFeatureForSubset('rclt');
Pdf.CurrentPage.RtLTextOut(50, 700, 0, WideString(ArabicText));
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
EnableShapingFeatureForSubset là đối tác dạng hàng loạt của lệnh gọi từng GID đơn lẻ, và nó cố tình được thiết kế thận trọng. Nó duyệt danh sách lookup GSUB để tìm các lookup được nối với một tag tính năng bốn byte theo đường script và ngôn ngữ đang được chọn hiện tại, và đánh dấu các glyph ID thay thế mà các lookup đó có thể tạo ra. Nó là một thao tác vô tác dụng mang tính phòng thủ khi font không mang bảng GSUB nào hoặc khi tính năng đó vắng mặt trên đường đó, nên gọi nó vô điều kiện là an toàn. Nó cũng cố tình là một xấp xỉ thừa (over-approximation): nó có thể giữ lại những glyph mà một tài liệu cho trước chưa từng vẽ. Với việc tạo subset, bao gồm thừa tốn byte còn bao gồm thiếu tốn tính đúng đắn, khiến đánh đổi đó trở nên dễ dàng. Cấu trúc của các lookup này, và các bảng coverage quyết định glyph nào tham gia, được trình bày trong bài hướng dẫn về các biến thể phong cách GSUB thuần Delphi
Làm sao để chứng minh glyph thực sự nằm trong subset?
Bằng cách đọc font đã phát ra, không phải bằng cách nhìn bằng mắt trang tài liệu trong một trình xem có thể đang âm thầm thay thế bằng một font hệ thống. Kiểm tra bắt được toàn bộ lớp lỗi này mang tính máy móc: trích xuất stream /FontFile2 từ PDF đầu ra, phân tích loca, và xác nhận rằng glyph ID bạn mong đợi mang một mục không rỗng, nghĩa là offset bắt đầu và kết thúc của nó khác nhau. Một mục rỗng là dấu hiệu bộ tạo subset đã quyết định glyph đó không được dùng. Hai thói quen sau đó khiến việc để lọt lỗi này lần nữa khó hơn nhiều. Giữ một trang script-đã-shape trong kho smoke test tự động thay vì chỉ trong tập kiểm tra thủ công, vì tiếng Ả Rập, Devanagari, và Khmer vận hành các đường đóng bao mà dù bao nhiêu độ phủ tiếng Latin cũng không chạm tới. Và bất cứ khi nào một bộ tích lũy tồn tại, hãy assert rằng có thứ gì đó tiêu thụ nó, vì một trường chỉ-ghi là một tính năng biên dịch được, test xanh trên sai kho dữ liệu, và không làm gì cả
Bản sửa dừng lại ở đâu
Đóng bao subset là điều kiện cần để một glyph đã shape hiển thị được, và nó không phải là điều kiện đủ. Glyph đó còn phải có thể định vị được từ content stream, đây là một vấn đề riêng biệt với ranh giới riêng của nó. Các lượt tinh chỉnh Ả Rập tích hợp sẵn của HotPDF chỉ chốt một phép thay thế khi mọi glyph ID thay thế có thể tới được qua một code point dạng trình bày (presentation-form) Unicode thông qua một lượt quét cmap ngược trên khoảng 690 code point từ U+FB50 tới U+FDFF và U+FE70 tới U+FEFF. Khi một phép thay thế rơi vào một glyph ID nằm ngoài dải đó, cửa sổ đầu vào đi qua không đổi thay vì phát ra thứ mà trình đọc không thể định vị; các biến thể đặc thù theo font ở các glyph ID tùy ý cần một code point vùng dùng riêng (private-use) tổng hợp được cấp phát trong U+E000 tới U+F8FF để mang chúng qua đường phát ra. Vì vậy tóm tắt trung thực là bản sửa 2.435.0 loại bỏ một điểm chặn cứng chứ không hoàn thành toàn bộ câu chuyện. Trước đó, một glyph có thể được shape đúng, phát ra đúng, và vẫn biến mất tại thời điểm tạo subset, điều đó có nghĩa là engine shaping không thể được tin tưởng từ đầu đến cuối dù các lookup của nó có tốt đến đâu. Điều còn lại là khả năng định vị, và ràng buộc đó ít nhất thất bại một cách hiển hiện tại điểm phát ra thay vì âm thầm trong một bước build chạy sau tất cả những gì bạn đang theo dõi. Với phía phát ra của cùng pipeline đó, xem hướng dẫn về shaping văn bản Ả Rập và RTL trong PDF Delphi
Việc tạo subset font, engine GSUB, và shaping script phức tạp được mô tả ở đây được cung cấp trong HotPDF Component tiêu chuẩn cho Delphi và C++Builder; trang sản phẩm có đầy đủ tài liệu tham khảo API cho các lệnh gọi font Unicode và shaping được nêu tên ở trên