บทความเทคนิค

Text shaping แบบต่อเสริมได้: Uniscribe กับ HarfBuzz

การ shape ข้อความใน component PDFium วิ่งผ่านออบเจกต์ที่ติดตั้งได้เพียงตัวเดียว ConfigureTextShaper ติดตั้ง shaper ที่ entry point การ shape ทุกจุดส่งเส้นทางผ่าน โดยแทนที่และปล่อยตัวเดิมทิ้ง ActiveTextShaper คืนตัวที่ติดตั้งไว้และสร้างค่า default ของ platform ในการใช้ครั้งแรก ActiveTextShaperName รายงานว่า backend ตัวใดทำงานอยู่ ClearTextShaper ถอนการติดตั้งและให้ค่า default ถูกสร้างใหม่ได้ บน Windows ค่า default คือ TPdfUniscribeTextShaper ภายใต้ Free Pascal มี TPdfHarfBuzzTextShaper ซึ่งผูก libharfbuzz ตอนรันไทม์ ไลบรารีที่หายไปจึงเป็นเพียงเงื่อนไขที่ถูกรายงาน ไม่ใช่ความล้มเหลวตอนโหลด

สถาปัตยกรรม text shaping แบบต่อเสริมได้ใน component Delphi ของ PDFium: ConfigureTextShaper, ActiveTextShaper และ ClearTextShaper จัดการ backend ที่ติดตั้งไว้ตัวเดียว ทั้ง Uniscribe บน Windows และ HarfBuzz ที่ผูกตอนรันไทม์ภายใต้ Free Pascal
การเรียก shape ทุกครั้งส่งผ่านออบเจกต์ shaper ที่ติดตั้งไว้ตัวเดียว พร้อมค่า default ของ platform บนแต่ละเป้าหมาย

หนึ่ง interface สอง backend ที่แบ่งงานกันคนละแบบสิ้นเชิง การเข้าใจความไม่สมมาตรนี้คือสิ่งที่กันเส้นทางพกพาไม่ให้ผลิตข้อความที่ถูก shape ถูกต้องแต่วางตำแหน่งผิด

ทำไม backend ของ Windows เป็นคลาสเดียว แต่แบบพกพาเป็นสามส่วน

เพราะ Uniscribe คือสี่ API ที่แสร้งว่าเป็นหนึ่งเดียว ScriptItemize แบ่งสตริงตาม script และ resolve ระดับ bidirectional ScriptShape แมปตัวอักษรเป็น glyph ScriptPlace คำนวณ advance และ offset ScriptLayout จัด run ที่ได้เข้าลำดับภาพ backend ที่สร้างบนมันจึงไม่เหลืออะไรให้เติม เหตุผลที่ shaper ฝั่ง Windows เป็นคลาสเดียวมีเมธอดเดียว

HarfBuzz ครอบเฉพาะสองขั้นกลาง มัน shape และวางตำแหน่ง run ที่ทิศทางและ script ถูกผู้เรียกตัดสินไปแล้ว และไม่มีความเห็นเรื่องว่าย่อหน้าแบ่งเป็น run อย่างไรหรือ run เหล่านั้นเรียงกันตามลำดับใด backend แบบพกพาจึงต้องจัดเตรียมส่วนที่เหลือ: อัลกอริทึม bidirectional resolve ระดับ embedding, ฟังก์ชัน Unicode ของ HarfBuzz แบ่งข้อความตาม script และ run ถูกจัดเรียงตามลำดับภาพที่กฎ L2 ของ UAX #9 ผลิต ครึ่งฝั่ง bidirectional ใหญ่พอที่จะเป็นยูนิตของตัวเอง อธิบายไว้ในบทความระดับ embedding ของ UAX #9

เปรียบเทียบ pipeline การ shape ข้อความ PDF: Uniscribe จัดเตรียม ScriptItemize, ScriptShape, ScriptPlace และ ScriptLayout ภายในคลาสเดียว ขณะที่ HarfBuzz ครอบเฉพาะการ shape และการวางตำแหน่ง ล้อมรอบขั้น UAX #9 ของ component
Uniscribe ครอบทั้งสี่ขั้น เส้นทางพกพาต้องจัดเตรียมการแบ่งชิ้นและลำดับภาพเอง

shaper ไม่ resolve font และนั่นคือความตั้งใจ

Uniscribe อ่านตัว font binary ออกจาก device context ของ GDI ไม่มีสิ่งเทียบเท่าที่พกพาได้ และการแต่งสิ่งนั้นขึ้นภายในยูนิต shaping หมายถึงการตัดสินใจแทนทุกแอปพลิเคชันว่า font มาจาก fontconfig, จาก CoreText, จากโฟลเดอร์ font ของแอป หรือจากฐานข้อมูล backend HarfBuzz จึงรับ resolver: callback ที่แมปชื่อ font เข้ากับไบต์ TrueType หรือ OpenType การคืน False ทำให้คำขอ shape ล้มเหลวเช่นเดียวกับ font GDI ที่อ่านไม่ได้ล้มเหลวบน Windows

uses
  FPdfTextShaping
{$IFDEF FPC}
  , FPdfTextShapingHb
{$ENDIF}
  ;

function TFontCatalogue.Resolve(const FontName: WideString;
  out FontData: TBytes): Boolean;
var
  Path: string;
begin
  // นโยบายของคุณ: fontconfig, CoreText, โฟลเดอร์ font ของแอป หรือฐานข้อมูล
  Result := FLookup.TryGetValue(LowerCase(FontName), Path);
  if Result then
    FontData := TFile.ReadAllBytes(Path);
end;

procedure InstallShaper(Catalogue: TFontCatalogue);
begin
{$IFDEF FPC}
  // ความเป็นเจ้าของถูกส่งต่อให้ยูนิต เรียกหนึ่งครั้งตอน start-up
  // ก่อนที่อะไรจะ shape ข้อความ
  ConfigureTextShaper(TPdfHarfBuzzTextShaper.Create(Catalogue.Resolve));
{$ENDIF}
  // บน Delphi ค่า default ของ platform (Uniscribe) ถูกสร้างตามต้องการ
  // จึงไม่ต้องติดตั้งอะไรเลย
  LogInfo('shaping backend: ' + ActiveTextShaperName);
end;

การกันการค้นพบ font ออกนอก shaper ให้ประโยชน์ที่สองซึ่งปรากฏใน server: process เดียวกัน shape ด้วยชุด font ที่ฝังมาซึ่งไม่เกี่ยวกับสิ่งที่ติดตั้งบนเครื่อง ซึ่งเป็นสิ่งที่คุณต้องการเมื่อผลลัพธ์ต้องทำซ้ำได้แบบไบต์ต่อไบต์ข้ามเครื่อง component ยังเปิดเผย host system font provider สำหรับกรณีที่คุณต้องการ font ที่ติดตั้งจริง อธิบายไว้ในบทความ system font provider

record ผลลัพธ์เป็นกลางต่อ backend และ cluster คือเหตุผล

backend ทั้งสองเติม TPdfShapedText ชุดเดียวกัน: ข้อความต้นฉบับ ชื่อ font, ขนาด, ไบต์ font, อาร์เรย์ของ run, ความกว้างรวม จำนวน glyph และจำนวนตัวอักษรเชิงตรรกะ TPdfShapedRun แต่ละตัวแบกช่วงของมันในข้อความต้นฉบับ ตำแหน่ง X เชิงภาพ ความกว้าง ระดับ bidirectional และ flag right-to-left บวกกับ glyph ของมัน TPdfShapedGlyph แต่ละตัวแบก identifier ของ glyph, advance, offset X และ Y และ cluster ที่มันสังกัดในรูปจุดเริ่มกับความยาวในข้อความต้นฉบับ

field cluster เหล่านี้คือสิ่งที่ทำให้ record ใช้งานได้ ไม่ใช่แค่มีข้อมูล การ shape ไม่ใช่การแมปแบบหนึ่งต่อหนึ่ง: พยางค์ Devanagari กลายเป็น glyph เดียวจากสี่ตัวอักษร, ligature อาหรับหลอมสองตัว และตัวอักษรเดียวอาจผลิตเครื่องหมายหลายตัว ไม่มีช่วง cluster คุณวาง caret ไม่ได้ hit-test การคลิกไม่ได้ หรือไฮไลต์การเลือกไม่ได้ เพราะพูดไม่ได้ว่า glyph หนึ่งเป็นของตัวอักษรใด มีแล้วเลขคณิตเป็นเรื่อง local และโค้ดชุดเดียวใช้ได้กับทั้งสอง backend

ช่วง cluster ของ glyph ใน TPdfShapedText: glyph พยางค์ Devanagari หนึ่งตัวจากสี่ตัวอักษร, ligature อาหรับจากสองตัว และฐานบวกเครื่องหมายจากหนึ่งตัวอักษร ทั้งหมดแมปกลับผ่าน ClusterStart กับ ClusterLength
ช่วง cluster แมป glyph ทุกตัวกลับไปที่ตัวอักษรต้นทาง caret, hit test และการเลือกจึงทำงานได้
var
  Shaped: TPdfShapedText;
  R, G: Integer;
begin
  if ShapePdfText(Line, 'Noto Sans Arabic', 14, ptdAuto, Shaped) then
    for R := 0 to High(Shaped.Runs) do
    begin
      // run มาถึงในลำดับภาพพร้อมค่า VisualX เติมไว้แล้ว
      X := Shaped.Runs[R].VisualX;
      for G := 0 to High(Shaped.Runs[R].Glyphs) do
      begin
        EmitGlyph(Shaped.Runs[R].Glyphs[G].GlyphID,
          X + Shaped.Runs[R].Glyphs[G].OffsetX,
          Shaped.Runs[R].Glyphs[G].OffsetY);
        X := X + Shaped.Runs[R].Glyphs[G].Advance;
      end;
    end;
end;

งบประมาณอยู่ใน record ของ options

TPdfTextShapingOptions แบกทิศทางหนึ่งบวกเพดานสามตัว: จำนวนตัวอักษรสูงสุด จำนวน glyph สูงสุด และจำนวน run สูงสุด โดยมี class function Default ที่ใส่ค่าสมเหตุสมผลให้ เพดานเหล่านี้ไม่ใช่ความหวาดระแวงกับอินพุตเสีย ๆ แต่เป็นเลขคณิต การ shape ขยายตัว: font ที่มีการแทนที่ตามบริบทอย่างเอาเป็นเอาตายปล่อย glyph มากกว่าจำนวนตัวอักษรอินพุตได้ และย่อหน้าที่สลับ script ทุกไม่กี่ตัวอักษรให้ run หนึ่งตัวต่อหนึ่งการสลับ เอกสารที่ถูกประกอบขึ้นเพื่อขยายทั้งคู่เปลี่ยนสตริงธรรมดาให้เป็นการจัดสรรขนาดใหญ่ บริการที่ shape ข้อความจาก PDF ที่ไม่น่าเชื่อถือจึงต้องการลิมิตที่มันเลือกเอง ไม่ใช่ลิมิตที่เครื่องบังคับให้

การตั้งทิศทางอย่างชัดเจน แทนที่จะปล่อยไว้แบบอัตโนมัติ คุ้มค่าที่จะทำทุกครั้งที่คุณรู้อยู่แล้ว โหมดอัตโนมัติใช้กฎทิศทางย่อหน้าเดาจากตัวอักษรแรกที่แข็งแรง ซึ่งถูกกับข้อความอิสระแต่ผิดกับฟิลด์ฟอร์มที่ทิศทางเป็นคุณสมบัติของฟิลด์ ไม่ใช่ของค่าที่ใครบางคนพิมพ์ลงไป

ผูกตอนรันไทม์ ไม่ใช่ dependency ตอน build

backend HarfBuzz โหลดไลบรารีแบบ dynamic นั่นคือการตัดสินใจด้าน deployment ที่มีผลจริง: ไบนารีเดียวรันได้ทั้งบนเครื่องที่มี HarfBuzz และเครื่องที่ไม่มี โดยรายงานความสามารถที่ลดลงในกรณีที่สองแทนการสตาร์ตไม่ได้ สำหรับไลบรารีที่ถูกส่งไปให้นักพัฒนาอื่น นั่นเป็นแนวจัดการเดียวที่ใช้ได้ เพราะคุณกำหนดให้ผู้บริโภคทุกรายของ component PDF ต้องไปหาไลบรารี shaping ที่เขาอาจไม่ต้องใช้และจับคู่เวอร์ชันมาให้ไม่ได้

กฎคู่กันสำหรับผู้เรียกคือให้เช็ก ActiveTextShaper คืน nil เมื่อ platform ไม่มีค่า default และไม่มีตัวใดถูกตั้งค่า และ entry point การ shape รายงานสิ่งนั้นเป็น shaper ที่ไม่พร้อมใช้ แทนที่จะเป็นความล้มเหลวของการ shape สองอย่างนี้เป็นปัญหาต่างกันและสมควรได้ข้อความต่างกัน: ข้อแรกเป็นช่องว่างด้าน deployment อีกอันเป็นปัญหาของ font หรือข้อความ

ติดตั้งครั้งเดียว ก่อนที่อะไรจะ shape

การติดตั้งแทนที่และปล่อย shaper ก่อนหน้า การเรียกซ้ำจึงปลอดภัยแต่ไร้ความหมาย และการเรียกขณะอีกเธรดกำลัง shape ไม่ปลอดภัยเลย ให้ทำตอน start-up ถ้าคุณต้องถอยไปค่า default ของ platform ในภายหลัง ให้ส่ง nil ซึ่งเป็นวิธีเดียวกับที่คุณยกเลิก test double ตอนท้ายการทดสอบ

เมื่อ backend ถูกติดตั้งแล้ว การวัดและการตัดคำทำงานเหมือนกันบนทุก platform เพราะพวกมันกิน metric ของ run และ glyph แทนการเรียก platform โดยตรง โมเดลการตัดคำอธิบายไว้ในบทความการวัดข้อความและ word wrap platform และ toolchain ที่รองรับสำหรับ component ระบุไว้บนหน้าผลิตภัณฑ์PDFium Delphi component