מאמר טכני

יישור מלא לטקסט PDF ב-Delphi עם HotPDF

יישור מלא (Full justification) הוא פריסה שגורמת לעמודת טקסט להתיישר גם בקצה השמאלי וגם בימני, המראה שאתה מצפה לו מספר מודפס או דוח רשמי. קל לתאר זאת ומפתיע כמה קל לטעות בזה, מכיוון שהתשובה לשאלה "לאן הולך המרווח הנוסף" אינה זהה עבור אנגלית כפי שהיא עבור יפנית, ומכיוון שהדרך הנאיבית למדוד כל שורה הופכת עמוד מהיר לאיטי. HotPDF מעניק לך יישור מודע-לסקריפט (script-aware) דרך קריאת פריסת-תיבה בודדת, ומתחת לקריאה זו יושב תיקון ביצועים קלאסי ששווה להבין אותו בפני עצמו

מאמר זה עובר על שניהם. ראשית, הכלל הטיפוגרפי שמחליט כיצד מופץ הרפיון (slack) עבור סקריפטים עם רווחים בין מילים לעומת סקריפטים בלעדיהם. שנית, שינוי המדידה שקיצץ את עלות היישור לעמוד בערך פי שמונים ללא הבדל גלוי בפלט. שניהם משנים אם אתה מייצר מסמכים בנפח ורוצה שהם ייקראו כמו כתיבה (typesetting) אמיתית ולא כפלט ברוחב אחיד (monospaced) שנמתח כדי להתאים

מה שיישור מלא דורש בפועל

שורת טקסט המצוירת ברוחבה הטבעי כמעט אף פעם אינה מגיעה לקצה הימני של העמודה שלה. תמיד יש שארית, רפיון (slack), בין המקום שבו מסתיים הגליף האחרון למקום שבו יושב גבול העמודה. יישור לשמאל משאיר רפיון זה בצד ימין. יישור לימין מזיז אותו שמאלה. מירכוז מפצל אותו. יישור מלא מסיר אותו על ידי הרחבת השורה עצמה עד ששני הקצוות פוגשים את התיבה, והדרך הכנה היחידה לעשות זאת היא לדחוף את הגליפים זה מזה מבפנים

הכלל שמפריד בין יישור טוב לרע הוא המקום שבו אתה שם את הרפיון. סקריפט שכותב מילים עם רווחים ביניהן, כמו אנגלית ושאר המשפחה הלטינית, בעל תפרים טבעיים בכל רווח בין-מילים. הרחבת רווחים אלו בלתי נראית לעין מכיוון שהקוראים כבר מקבלים את זה שרווחי מילים משתנים. סקריפט שכותב ללא רווחים בין מילים, כגון תווים סיניים (Han), קאנה יפנית או האנגול קוריאני, אין לו תפרים כאלה. שם, יש לפזר את הרפיון באופן שווה בין גליפים סמוכים, שזהו העיקרון שסדרי אותיות יפניים קוראים לו kintou-waritsuke, ריווח אחיד (even spacing). השמת הרחבת רווחי-מילים בסגנון לטיני על שורת CJK, או דחיפת כל הרפיון למקום האחד שבו במקרה יש לשורת CJK רווח, מייצרת את הנהרות והפערים המאפיינים פלט של חובבנים

כיצד HotPDF מחליט לאן הולך המרווח

HotPDF מקבל החלטה זו לכל רווח (gap), לא לכל שורה. כאשר הוא מיישר שורה, הוא הולך לאורך כל זוג גליפים סמוך ושואל האם יושב ביניהם גבול הניתן למתיחה. גבול ניתן למתיחה כאשר אחד מהצדדים הוא רווח או טאב, המקרה הלטיני, או כאשר שני הצדדים הם תווים הניתנים לשבירה ב-CJK, המקרה של ריווח אחיד. הוא סופר גבולות אלו, מחלק את הרפיון של השורה באופן שווה ביניהם, ומוסיף חלק זה לכל רווח מתאים

התוצאה נופלת בטבעיות. לשורה אנגלית יש גבולות ניתנים למתיחה רק ברווחי המילים שלה, כך שכל הרפיון נוחת שם והמילים מתרחקות בעוד האותיות בתוך כל מילה שומרות על הריווח הטבעי שלהן. לשורת האן (Han) או קאנה יש גבול ניתן למתיחה כמעט בין כל זוג גליפים, כך שהרפיון מתפזר באופן שווה על פני כל השורה, בדיוק הריווח הבין-גליפים האחיד שסקריפטים אלו דורשים. שורה שהיא מילה לטינית ארוכה אחת ללא רווח פנימי אין לה שום גבול ניתן למתיחה כלל, ולכן HotPDF משאיר אותה ברוחבה הטבעי במקום לקרוע את המילה לגזרים אות-אות. אותו הגיון מטפל בריצות מעורבות של לטינית ו-CJK בשורה אחת ללא טיפול במקרים מיוחדים, מכיוון שההחלטה מקומית לכל גבול

גבול אחד מוחרג במכוון בכל מקום. המיקום שאחרי הגליף הסופי של שורה לעולם אינו מטופל כרווח, מכיוון שמתיחה שם רק תציג מחדש שארית ימנית, שזה ההפך מיישור

מדוע השורה האחרונה נעזבת בשקט

השורה הסופית של פסקה מיוחדת, ולטעות בה זהו באג היישור הנפוץ ביותר. השורה האחרונה של פסקה היא בדרך כלל קצרה, לרוב רק כמה מילים, ומתיחתה לרוחב העמודה המלא גוררת מילים אלו על פני העמוד לתוך שורה דלילה ושבורה. טיפוגרפיה נכונה משאירה את השורה האחרונה ברוחבה הטבעי, מיושרת לשמאל

HotPDF מזהה את השורה העוקבת (trailing) לפי מיקום. בעודו עוטף את הטקסט לשורות, הוא יודע מתי השורה שהוא הרגע פיצל מגיעה לסוף המחרוזת המסופקת. אותה שורה סופית נשלחת עם יישור שמאלי רגיל ושומרת על רוחבה הטבעי. כל שורה לפניה מיושרת לשני הקצוות. מעברי שורה קשיחים שאתה כותב לתוך הטקסט מכובדים כפי שנכתבו, ולכן שורה קצרה מכוונת לעולם אינה נמתחת גם כן. הקורא רואה בלוק טקסט מלבני נקי שהשורה האחרונה שלו מסתיימת בטבעיות, שזה מה שהעין מצפה לו

עלות המדידה שהפכה יישור לאיטי

כדי ליישר שורה עליך לדעת את הרוחב המדויק שלה, ועליך לדעת את הקידום (advance) של כל גליף כדי שתוכל למקם את המרווח הנוסף במדויק. המימוש הראשון קיבל מספרים אלו בדרך הברורה. הוא מדד את כל השורה עם שאילתת רוחב Unicode מלאה, ואז מדד קידומת אחרי קידומת כדי לשחזר את הקידום של כל גליף על ידי חישוב ההפרש. עבור שורה של N גליפים אלה N+1 קריאות לתוך מנוע המדידה, וכל קריאה היא גיחת GDI מלאה הלוך-ושוב (round-trip), המבקשת ממערכת ההפעלה לעצב ולמדוד טקסט ולהחזיר את התשובה

לכל שורה זה נשמע זול. על פני עמוד זה לא. קח עמוד A4 צפוף של טקסט גוף, בערך ארבעים וחמש שורות של כ-80 תווים כל אחת. ב-N+1 גיחות הלוך-ושוב לשורה אלו בערך 81 גיחות לכל שורה וכ-3,645 לעמוד, כמעט כולן מושקעות במדידה מחדש של טקסט שהמנוע כבר הסתכל עליו רגעים קודם לכן. בעבודת אצווה המייצרת אלפי עמודים, תקורה זו שולטת בזמן הפריסה, וכל גיחת הלוך-ושוב חוצה את הגבול בין התהליך שלך לתת-מערכת הגרפיקה

קריאה אחת במקום N ועוד אחד

התיקון הוא מסוג השינויים שנראים קטנים ומשתלמים בגדול. GDI כבר יכול לדווח על הרוחב הכולל של מחרוזת ומיקומו של כל גליף בשאילתה בודדת. HotPDF חושף זאת דרך GetWideCharAdvances, הממלא מערך עם הקידום הטבעי של כל גליף, כולל kerning, ומחזיר את הרוחב הכולל, בקריאה אחת במקום ב-N+1. שגרת היישור, _HPDFEmitJustifiedWideLine פנימית, מבקשת את כל הקידומים פעם אחת, מחשבת את הרפיון, מפזרת אותו על פני הגבולות הניתנים למתיחה, ופולטת את השורה

עבור אותו עמוד A4 מדידת השורה יורדת מכ-81 גיחות לאחת, כך שהעמוד יורד מ-3,645 גיחות הלוך-ושוב לכ-45, הפחתה של קרוב לפי שמונים. הפלט זהה בייט-לכל-בייט, מכיוון ששום דבר במדידה לא השתנה פרט לכמות הפעמים שהיא מתבקשת. אותו מנוע GDI, אותם מדדי גופנים, אותו kerning מזינים את אותם מספרים. רק ספירת הגיחות הלוך-ושוב ירדה. כאשר מדידה כבר נכונה, האופטימיזציה הנכונה היא להפסיק לבקש אותה שוב ושוב, לא לקרב (approximate) אותה

כיצד השורה מגיעה לעמוד

לאחר שהרפיון מחולק, HotPDF פולט את השורה עם ExtTextOut ומערך קידום לכל גליף, מערך ה-Dx. כל רשומה היא המרחק ממקורו של גליף אחד לבא אחריו, שזהו הקידום הטבעי של אותו גליף פלוס החלק שלו ברפיון כאשר גבול ניתן למתיחה עוקב אחריו. זה ממופה ישירות אל תוך מודל ההדמיה של PDF. טקסט ממוקם נכתב עם האופרטור TJ, מערך שמשלב ריצות גליפים עם התאמות אופקיות מפורשות, וערכי ה-Dx הופכים להיות בדיוק התאמות אלו. זו הסיבה שהמרווח הנוסף נוחת בין גליפים במיקומי תת-נקודה (sub-point) מדויקים במקום להיות מזויף עם תווי ריפוד (padding characters), ומדוע שורת HotPDF מיושרת נמדדת נכונה אם כלי במורד הזרם (downstream) קורא אותה בחזרה

אינך קורא ל-ExtTextOut בעצמך עבור פסקאות מיושרות. נקודת הכניסה היא WideTextOutBox, אשר עוטפת מחרוזת Unicode לתוך תיבה ומחילה את היישור שאתה מבקש. היא מפצלת את הטקסט לשורות שמתאימות לרוחב התיבה, ממקמת כל שורה במורד גובה התיבה, ומחזירה את מספר התווים שהיא הצליחה להכניס לפני שנגמר לה המקום האנכי. היישור נבחר על ידי מתג היישור (enum)

type
  THPDFJustificationType = (jtLeft, jtCenter, jtRight, jtJustify);

שלושת הראשונים מסבירים את עצמם: יישור שמאלי, ממורכז, וימני. הרביעי, jtJustify, הוא היישור המלא לשני הקצוות המתואר כאן, וזהו הערך ש-WideTextOutBox קורא כדי להפעיל את הריווח מודע-הסקריפט

יישור פסקה הלכה למעשה

דוגמה שלמה יוצרת מסמך, מגדירה גופן, ושופכת פסקה לתוך תיבה עם יישור מלא. אותו קוד מיישר טקסט לטיני ו-CJK ללא שינוי דגל (flag), מכיוון שהמודעות-לסקריפט חיה מתחת ל-API

uses
  HPDFDoc;

procedure JustifyParagraph;
var
  Pdf: THotPDF;
  Body: WideString;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'Justified.pdf';
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', 11);

    Body :=
      'Full justification spreads the slack on each filled line so both ' +
      'edges meet the column, while the last line keeps its natural width. ' +
      'For scripts with word gaps the space lands between words; for ' +
      'scripts without them it spreads evenly between glyphs.';

    // X, Y, LineSpacing, BoxWidth, BoxHeight, Text, Align
    Pdf.CurrentPage.WideTextOutBox(72, 72, 4, 380, 240, Body, jtJustify);

    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

כדי לצייר את אותו בלוק מיושר שמאלה, ממורכז או מיושר ימינה, שנה רק את הארגומנט הסופי ל-jtLeft, jtCenter, או jtRight. העטיפה, מיקום השורה וערך ההחזרה נשארים זהים. הרוחב הנמדד שמניע את כל ארבעת הנתיבים מגיע מ-GetWideTextWidth, שאילתת הרוחב המודעת ל-Unicode שמודדת WideString נכונה במקום שבו מדידה ישנה לפי-בית (byte-wise) הייתה שוגה במידות של כל מה שמעבר ל-Latin-1, שזה מה שגורם לתיבה לעטוף טקסט CJK וזוגות פונדקאים (surrogate-pair) במקום הנכון מלכתחילה

יישור הוא שכבה אחת מתוך מחסנית (stack) גדולה יותר של עיצוב טקסט. כאשר שורה מכילה סקריפטים שמסדרים מחדש או מחברים את הגליפים שלהם, החלטות הריווח כאן יושבות על גבי העבודה המתוארת במאמר שלנו על עיצוב טקסט של סקריפטים מורכבים, וכאשר גופן נושא וריאנטים טיפוגרפיים שאתה רוצה לבחור, ראה כיצד להניע חלופות סגנוניות של OpenType GSUB. כל זה כלול ברכיב HotPDF עבור Delphi ו-C++Builder, לצד ממשקי ה-API הרחבים יותר של טקסט, פריסה ומסמך המכוסים לאורך בלוג זה