Bài viết kỹ thuật

Emoji và ký tự CJK phá vỡ WideChar trong PDFium với Delphi

Kéo emoji hay một cái tên trong sổ hộ tịch gia đình tiếng Nhật ra khỏi một PDF dưới dạng văn bản, và đầu ra hiện một hộp, một dấu chấm hỏi, hay không gì cả ở nơi ký tự lẽ ra phải xuất hiện. Thuộc tính Character[] của PDFium Component thường là nguyên nhân: nó đọc mỗi glyph thông qua FPDFText_GetUnicode, hàm trả về một code point Unicode đầy đủ dưới dạng giá trị 32-bit không dấu, sau đó phơi bày nó cho Delphi như một WideChar 16-bit đơn lẻ. Bất kỳ code point nào vượt quá U+FFFF không thể thực hiện chuyến đi đó nguyên vẹn, và sự hỏng hóc không bao giờ lộ ra khi bạn đang nhìn vào trang đã render, vì render và trích xuất văn bản chạy qua các đường code riêng biệt trong PDFium — một tài liệu có thể hiển thị emoji của nó hoàn hảo và vẫn đưa cho bạn dữ liệu rác ngay khi bạn đọc Character[] trong một vòng lặp và xây dựng một chuỗi từ đó

Mặt phẳng đa ngôn ngữ cơ bản và vì sao WideChar dừng lại ở U+FFFF

WideChar của Delphi là một kiểu 16-bit chỉ có thể chứa một code unit UTF-16. Mặt phẳng Đa ngôn ngữ Cơ bản (BMP) của Unicode, phạm vi U+0000 đến U+FFFF, vừa khít bên trong đó, đó là lý do vì sao Latin, Cyrillic, Hy Lạp, và khối CJK Unified Ideographs thông thường đều khứ hồi qua một WideChar đơn lẻ mà không gặp sự cố. Hai họ ký tự thường xuyên rơi ra ngoài phạm vi đó trong các tài liệu thực tế: emoji, nhiều trong số đó nằm trong khối Emoticons bắt đầu ở U+1F600, và các chữ Hán CJK hiếm từ CJK Unified Ideographs Extension B, phạm vi U+20000 đến U+2A6DF dành riêng cho các ký tự Trung, Nhật, Hàn ít phổ biến hơn bao gồm nhiều tên riêng và tên địa danh. UTF-16 xử lý bất cứ thứ gì trên U+FFFF bằng một cặp surrogate — hai code unit 16-bit, một high surrogate trong phạm vi $D800 đến $DBFF theo sau bởi một low surrogate trong $DC00 đến $DFFF, cùng nhau mã hóa một code point — và phép toán đứng sau việc ghép cặp đó đủ cố định để minh họa trực tiếp bằng Pascal

function ToSurrogatePair(CodePoint: LongWord; out Hi, Lo: WideChar): Boolean;
var
  V: LongWord;
begin
  Result := CodePoint > $FFFF;
  if Result then
  begin
    V := CodePoint - $10000;
    Hi := WideChar($D800 + (V shr 10));
    Lo := WideChar($DC00 + (V and $3FF));
  end;
end;

Đưa U+1F600, emoji mặt cười toe toét, qua hàm đó và kết quả là một high surrogate là $D83D và một low surrogate là $DE00, hai giá trị 16-bit, không phải một. Không nửa nào tự nó có nghĩa gì; một $D83D đơn độc nằm trong một chuỗi mà không có $DE00 đứng sau nó là một surrogate treo lơ lửng, và hầu hết code xử lý văn bản gặp phải nó hoặc loại bỏ nó, thay thế bằng một glyph thay thế, hoặc ném ra một lỗi

Vì sao FPDFText_GetUnicode trả về một giá trị mà Character[] không thể chứa?

FPDFText_GetUnicode trả về một LongWord, một giá trị 32-bit đầy đủ, vì mã hóa văn bản PDF đã mang theo giá trị scalar Unicode đầy đủ cho mỗi glyph. CMap ToUnicode của một PDF ánh xạ mã ký tự sang văn bản Unicode, và khi một glyph biểu diễn thứ được gọi không chính thức là một ký tự astral-plane — bất cứ thứ gì vượt quá Mặt phẳng Đa ngôn ngữ Cơ bản — ánh xạ đó là một code point đầy đủ, không phải một mảnh 16-bit. PDFium giải mã nó trở lại thành một giá trị scalar nội bộ và trả về qua ranh giới DLL thông qua FPDFText_GetUnicode, và ranh giới đó chính xác là nơi một giá trị 32-bit phải trở thành thứ gì đó mà một thuộc tính Delphi có thể trả lại cho code của bạn

Cách triển khai hiển nhiên là WideChar(FPDFText_GetUnicode(TextPage, Index)), và đó cũng là cách sai. Một phép ép kiểu cứng từ một giá trị 32-bit thành một kiểu 16-bit chỉ giữ lại 16 bit thấp và vứt bỏ phần còn lại âm thầm, không có ngoại lệ và không có kiểm tra phạm vi. Với U+1F600 điều đó có nghĩa là giữ $F600 và mất đi thực tế rằng giá trị thật từng vượt quá U+FFFF, tạo ra một code unit thậm chí còn không phải một surrogate treo lơ lửng hợp lệ, chỉ là một ký tự Mặt phẳng Đa ngôn ngữ Cơ bản không liên quan tình cờ chia sẻ các bit thấp đó. Nối vài nghìn thứ như vậy lại thành một chuỗi và code hạ nguồn không còn cách nào để phân biệt một ký tự bị hỏng với một ký tự hợp lệ

Character[] và Charcode[] giờ trả về gì cho các code point astral-plane

Các thuộc tính Character[]Charcode[] của PDFium Component trả về U+FFFD, ký tự thay thế Unicode, bất cứ khi nào code point bên dưới vượt quá U+FFFF, thay vì âm thầm cắt cụt nó. Sự bảo vệ đó nằm trực tiếp bên trong getter thuộc tính đứng sau Character[]

function TPdf.GetCharacter(Index: Integer): WideChar;
var
  Code: LongWord;
begin
  LoadTextPage;
  Code := FPDFText_GetUnicode(FTextPage, Index);
  if Code > $FFFF then
    Result := #$FFFD          // astral-plane code point: cannot fit in one WideChar
  else
    Result := WideChar(Code);
end;

Trả về U+FFFD thay vì một mảnh bị cắt cụt là một bản sửa có chủ đích, hẹp thay vì một cuộc tái thiết kế. Character[]Charcode[] được khai kiểu WideChar trên cả TPdf lẫn TPdfView, và việc mở rộng kiểu trả về đó để mang một code point đầy đủ sẽ phá vỡ mọi caller hiện có mong đợi một glyph cho mỗi chỉ số có nghĩa là một giá trị 16-bit. U+FFFD là placeholder được chính Unicode Standard chỉ định riêng cho đúng tình huống này, nên một caller kiểm tra nó nhận được một tín hiệu được định nghĩa, có tài liệu thay vì dữ liệu sai âm thầm. Một trường hợp biên đáng biết: U+FFFD cũng là một ký tự hợp pháp tự thân, nên trên tài liệu hiếm hoi đã chứa sẵn một glyph ký tự thay thế thật, chỉ số đó không thể phân biệt được với một ký tự astral bị cắt cụt chỉ bằng giá trị

Làm sao để trích xuất emoji và văn bản CJK Extension B đúng cách trong Delphi?

Gọi Text thay vì duyệt Character[] bất cứ khi nào nội dung văn bản thực sự quan trọng, vì Text đọc qua FPDFText_GetText và trả về một WString đầy đủ với các cặp surrogate đúng đắn cho mỗi ký tự astral-plane trong phạm vi, thay vì một giá trị độ rộng cố định cho mỗi chỉ số. Pdf.Text(0, MaxInt), hay lối tắt Pdf.Text, trích xuất toàn bộ một trang đúng đắn trong một lệnh gọi, và Pdf.Text(StartIndex, Count) kéo một phạm vi nhỏ hơn theo cùng cách. Character[] vẫn xứng đáng có vị trí của nó khi bạn chỉ cần dữ liệu vị trí, font, hay cờ tại một chỉ số và không bao giờ đụng đến chính code point — CharacterOrigin[], FontSize[], và CharacterMapError[] không quan tâm liệu glyph bên dưới có phải astral hay không

function ExtractLineSafely(Pdf: TPdf): WString;
var
  I: Integer;
begin
  Result := '';
  for I := 0 to Pdf.CharacterCount - 1 do
    if not (Pdf.CharacterGenerated[I] or Pdf.CharacterMapError[I]) then
      Result := Result + Pdf.Text(I, 1);   // full code point, never a truncated WideChar
end;

Kiểm tra bỏ-qua-đã-sinh-và-chưa-ánh-xạ trong vòng lặp đó là cùng khuôn mẫu dùng cho trích xuất văn bản thuần túy trong trích xuất văn bản từ tài liệu PDF với PDFium Component; thay đổi duy nhất là dòng cuối, đổi một lượt nối trực tiếp Character[I] lấy một lệnh gọi một-chỉ-số vào Text để các ký tự astral đến dưới dạng các cặp surrogate hoàn chỉnh thay vì các placeholder thay thế

Nơi điều này thực sự cắn: xuất chat, tên riêng, và font CJK nhúng

Emoji xuất hiện ở bất cứ đâu một PDF ghi lại giao tiếp không chính thức: nhật ký chat đã xuất, các bản đổ đánh giá app-store, bản ghi hệ thống ticketing được lưu thành PDF cho một kho lưu trữ tuân thủ. CJK Extension B xuất hiện ở một nơi hẹp hơn nhưng có mức độ rủi ro cao hơn, tên riêng và tên địa danh, vì các sổ hộ tịch gia đình Nhật Bản, hồ sơ đăng ký hộ khẩu Trung Quốc, và giấy tờ tùy thân Đài Loan là các nguồn kinh điển của các ký tự chưa bao giờ lọt vào khối CJK thông thường. Một pipeline tính lương hay xác minh danh tính trích xuất tên từ giấy tờ chính phủ đã scan chính xác là loại khối lượng công việc nơi một ký tự bị làm hỏng âm thầm biến thành một lần khớp thất bại thay vì một trục trặc thẩm mỹ

Các chữ Hán CJK hiếm cũng có xu hướng đi kèm với các vấn đề về font, không chỉ mã hóa, vì một font phải mang một glyph cho một code point trong dải U+20000 trước khi bất cứ thứ gì có thể render được, và ít font hệ thống đã cài đặt làm được điều đó. Bất kỳ ai đã duyệt FontIsEmbedded[] theo từng ký tự theo cách đọc thuộc tính font PDF với PDFium Component mô tả nên kiểm tra cùng chỉ số cho cả hai vấn đề cùng nhau: một chỉ số trả về U+FFFD từ Character[] và báo cáo một font không nhúng là một tài liệu sẽ không trích xuất lẫn không in đúng ký tự đó, và bản sửa thuộc về thượng nguồn trong cách PDF được sản xuất, không phải trong code trích xuất của bạn

Các thuộc tính Character[], Charcode[], và Text được mô tả ở đây là một phần của PDFium Component tiêu chuẩn dành cho Delphi và C++Builder