מאמר טכני

עריכת מטא-דאטה של PDF טעון ב-Delphi בלי כתיבה מחדש

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

המהלך הנכון הוא להתייחס למסמך הטעון כאל גרף אובייקטים שאתה משנה במקום: להושיט יד אל מילון ה-Info, אל זרם ה-/Metadata ואל ה-Catalog, לשנות את מעט הרשומות שאכפת לך מהן, ולכתוב את התוצאה בחזרה. HotPDF, רכיב ה-PDF הנייטיבי מבוסס VCL עבור Delphi ו-C++Builder, חושף בדיוק את המשטח הזה דרך ממשק הכתיבה למסמך טעון. המאמר הזה עוסק בשימוש נכון בו, ובטעות האחת שכמעט כולם עושים: עריכת מילון ה-Info תוך שכחה שעותק שני של אותה מטא-דאטה חי ב-XMP

שני מקומות מאחסנים את אותה מטא-דאטה, והם חלוקים

PDF נושא מידע על המסמך בשני מיקומים מקבילים, וזה שורש רוב הפניות מסוג "שיניתי את הכותרת אבל Acrobat עדיין מציג את הישנה". הראשון הוא מילון פרטי המסמך, אובייקט ה-/Info הקלאסי עם המפתחות /Title, /Author, /Subject, /Keywords, /Creator ו-/Producer, כהגדרתו ב-ISO 32000-1 §14.3.3. השני הוא חבילת XMP, מסמך XML המאוחסן כזרם התלוי ב-Catalog תחת /Metadata, כהגדרתו ב-§14.3.2 ובנוי על מודל הנתונים XMP של Adobe

שניהם יכולים להחזיק כותרת. שום דבר במפרט אינו מכריח אותם להסכים. מציגים מודרניים ורוב מאמתי PDF/A מעדיפים את חבילת ה-XMP כשהיא קיימת, ונופלים בחזרה למילון ה-Info כשאיננה. אז אם אתה מעדכן רק את /Info — וזה מה שהרוב המכריע של קוד "הגדרת מטא-דאטה ל-PDF" עושה — קורא שסומך על XMP ימשיך להציג את הערך המיושן, ובודק PDF/A יסמן את אי-ההתאמה. הפעולה הנכונה בכל קובץ שכבר יש בו חבילת XMP היא כתיבה כפולה: שנה את רשומת ה-Info וגם חולל מחדש את ה-XMP, כדי שהשניים יישארו עקביים. HotPDF נותן לך את שתי החציים; המשמעת להשתמש בהם יחד היא עליך

HotPDF: תרשים זרימה המראה מציגים ומאמתי PDF/A המעדיפים את חבילת ה-XMP על פני מילון ה-Info, כך שרק כתיבה כפולה עם SetLoadedTitle ו-SetLoadedXMPMetadata שומרת על עקביות המטא-דאטה של ה-PDF
קוראים מודרניים ומאמתי PDF/A מתייעצים קודם עם XMP, ולכן כתיבה למילון ה-Info בלבד משאירה על המסך את הכותרת המיושנת. שיקוף כל ערך אל שני המאגרים מונע מהשניים לסתור זה את זה

עריכת מילון ה-Info

הפונקציות שבצד ה-Info דקות וצפויות. SetLoadedTitle, SetLoadedAuthor, SetLoadedSubject, SetLoadedKeywords, SetLoadedCreator ו-SetLoadedProducer מקבלות כל אחת AnsiString יחיד וכותבות את המפתח המתאים אל מילון ה-Info הטעון, מחליפות את הערך אם המפתח קיים ומוסיפות אותו אם לא. כדי להסיר מפתח לגמרי — נניח /Creator דלפני שנוקב בשם הכלים הפנימיים שלך — קרא ל-RemoveLoadedInfoKey עם שם המפתח החשוף. אף אחת מהן אינה נוגעת ב-XMP; הן פועלות אך ורק על אובייקט ה-/Info ש-LoadFromFile איתר כשניתח את הקובץ

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-in.pdf', '') > 0 then
    begin
      Pdf.SetLoadedTitle('Master Services Agreement 2026');
      Pdf.SetLoadedAuthor('Legal Department');
      Pdf.SetLoadedSubject('Executed contract, retention 7 years');
      Pdf.SetLoadedKeywords('contract; MSA; 2026; executed');
      Pdf.SetLoadedProducer('Acme Document Pipeline');
      Pdf.RemoveLoadedInfoKey('Creator');  // הסרת שם הכלי שיצר את הקובץ
      Pdf.SaveLoadedDocument('contract-out.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

פרט אחד שכדאי לשמור עליו ביושר: אלה מקבלות AnsiString. עבור כותרות ASCII זו אינה בעיה, אבל מחרוזות טקסט של PDF הזקוקות לתווים שאינם לטיניים חייבות להיות מקודדות כפי שהמפרט דורש — UTF-16BE עם סימן סדר בתים, או PDFDocEncoding — לפני שאתה מוסר אותן. הספרייה כותבת את הבתים שנתת לה אל תוך אובייקט מחרוזת; היא אינה מנחשת עבורך קידוד. אם הכותרות שלך הן אנגלית פשוטה, התעלם מזה. אם הן נושאות תווים מנוקדים או תווי CJK, קודד במכוון ובדוק במציג אמיתי

כתיבה מחדש של חבילת ה-XMP

SetLoadedXMPMetadata היא החצי השני של הכתיבה הכפולה. העבר לה את חבילת ה-XMP המלאה כ-AnsiString והיא תעשה אחד משניים: אם ה-Catalog כבר מפנה לזרם /Metadata, היא מחליפה את תוכן הזרם הזה במקום, תוך שמירה על אותו מספר אובייקט; אם אין זרם מטא-דאטה, היא יוצרת אחד, מסמנת אותו /Type /Metadata ו-/Subtype /XML, מקצה מספר אובייקט, ומקשרת אותו מה-Catalog. כך או כך אתה מסיים עם אובייקט מטא-דאטה תקין שמציגים יקראו

אתה מספק את ה-XML, ומשמעות הדבר שאתה שולט בסכמה — dc:title, dc:creator, xmp:CreatorTool וכן הלאה. זה כוח ואחריות באריזה אחת: הספרייה אינה מנתחת ואינה מאמתת את החבילה שלך, והיא כותבת את הבתים ללא דחיסה, בלי מסנן זרם כלשהו. חבילה פגומה תעבור בשלום דרך הקריאה ותצוץ מאוחר יותר כתלונה על מטא-דאטה שבורה. בנה את ה-XML בזהירות, ושקף בדיוק את הערכים שכתבת אל מילון ה-Info כדי ששני המבטים לעולם לא יסתרו זה את זה

const
  XMP_TEMPLATE =
    '<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>' +
    '<x:xmpmeta xmlns:x="adobe:ns:meta/">' +
    '<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">' +
    '<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">' +
    '<dc:title><rdf:Alt><rdf:li xml:lang="x-default">%s</rdf:li></rdf:Alt></dc:title>' +
    '<dc:creator><rdf:Seq><rdf:li>%s</rdf:li></rdf:Seq></dc:creator>' +
    '</rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>';
begin
  // אחרי הגדרת מילון ה-Info, שקף את אותם ערכים אל XMP:
  Pdf.SetLoadedTitle('Master Services Agreement 2026');
  Pdf.SetLoadedAuthor('Legal Department');
  Pdf.SetLoadedXMPMetadata(
    AnsiString(Format(XMP_TEMPLATE,
      ['Master Services Agreement 2026', 'Legal Department'])));
  Pdf.SaveLoadedDocument('contract-out.pdf');
end;

הסדר הזה — קודם Info, אחר כך XMP, ואז שמירה — הוא התבנית שכדאי להפנים. שתי הקריאות בלתי תלויות; העקביות קיימת רק משום שהזנת אותן באותן מחרוזות. דלג על קריאת ה-XMP בקובץ שיש בו חבילת XMP ואתה חוזר אל באג ההתיישנות השקטה שכל הפרק הזה קיים כדי למנוע

תרשים המראה מילון Info של PDF וזרם מטא-דאטה XMP המחזיקים שניהם כותרת ומחבר, נערכים במקום לצד עץ הסימניות
מטא-דאטה חיה בשני מקומות — מילון ה-Info וזרם ה-XMP — ולצדם רמזי הקריאה ברמת ה-Catalog ועץ הסימניות. עריכה במקום נוגעת בכל אחד מהם בלי לבנות מחדש את המסמך

הכוונת האופן שבו המציג פותח את הקובץ

שלוש רשומות ב-Catalog מכריעות מה קורא רואה ברגע שהמסמך נפתח, וכל שלושתן הן עריכות בנות שורה אחת על הגרף הטעון. SetLoadedPageMode כותבת את /PageMode כאובייקט שם: העבר 'UseOutlines' כדי לפתוח את חלונית הסימניות, 'UseThumbs' עבור פס התמונות הממוזערות, 'FullScreen' עבור מצב מצגת, או 'UseAttachments' כדי להציג את חלונית הקבצים המצורפים (ISO 32000-1 §7.7.3.1, טבלה 28). SetLoadedPageLayout כותבת את /PageLayout באותה דרך — 'SinglePage', 'OneColumn', 'TwoColumnLeft' וכל השאר. שתיהן מקבלות את השם בלי קו נטוי מוביל; הספרייה מוסיפה אותו בפלט

SetLoadedLanguage כותבת את רשומת ה-/Lang של ה-Catalog, תג השפה הטבעית של המסמך כולו — 'en-US', 'de-DE', תג BCP 47. שים לב להבדל הטיפוסים שמכשיל אנשים: /PageMode ו-/PageLayout הם אובייקטי שם של PDF, בעוד /Lang הוא מחרוזת. HotPDF מטפל בזה נכון פנימית, אבל אם תבדוק אי פעם את הפלט תראה /PageMode /UseOutlines מול /Lang (en-US), ועכשיו אתה יודע למה. רשומת ה-/Lang חשובה יותר משהיא נראית: זה מה שטכנולוגיה מסייעת קוראת כדי לבחור הגייה, וזו דרישה קשיחה לתאימות נגישות PDF/UA

if Pdf.LoadFromFile('handbook.pdf', '') > 0 then
begin
  Pdf.SetLoadedPageMode('UseOutlines');     // /PageMode, שם
  Pdf.SetLoadedPageLayout('TwoColumnLeft'); // /PageLayout, שם
  Pdf.SetLoadedLanguage('en-US');           // /Lang, מחרוזת
  Pdf.SaveLoadedDocument('handbook-tagged.pdf');
end;

שינוי שם סימניות בלי להפריע לעץ

כותרות סימניות הן ניקוי שגרתי — שגיאת כתיב בכותרת, פרק שמוספר מחדש אחרי שהתוכן נבנה. SetLoadedOutlineTitle מקבלת אינדקס מבוסס אפס אל רשומות התוכן העליוניות וכותרת חדשה, הולכת בשרשרת Catalog → /Outlines → /First → /Next עד למיקום הזה, ומחליפה את מחרוזת ה-/Title של הרשומה. היא משנה רק את הכותרת; היעד, מצב הפתיחה או הסגירה, ומבנה הצאצאים נשארים ללא נגיעה

if Pdf.LoadFromFile('report.pdf', '') > 0 then
begin
  Pdf.SetLoadedOutlineTitle(0, 'Executive Summary');
  Pdf.SetLoadedOutlineTitle(1, 'Financial Results');
  Pdf.SaveLoadedDocument('report-renamed.pdf');
end;

שינוי שם בטוח דווקא משום שהוא לעולם אינו נוגע במונים המבניים. מחיקת רשומת תוכן היא המקרה שנושך, וכדאי להבין אותו גם כשאתה רק משנה שמות, כי הוא מלמד אותך מה לא לערוך ביד. כל צומת תוכן נושא /Count, ו— לפי ISO 32000-1 §12.3.3 — המונה הזה אינו מספר הצאצאים הישירים. הוא המספר הכולל של הצאצאים הגלויים: /Count חיובי בערך N פירושו ש-N צאצאים חשופים כרגע, בעוד ערך שלילי פירושו שלצומת יש צאצאים אך הוא מכווץ. כשרשומה עליונית מוסרת, אי אפשר פשוט להפחית אחד ממונה שורש ה-/Outlines; יש לחשב אותו מחדש בסכימה, על פני כל צומת עליוני ששרד, של "אחד עבור הצומת עצמו ועוד ה-/Count החיובי שלו", תוך דילוג על הצאצאים של כל צומת מכווץ (בעל מונה שלילי). טעה בזה וסך הסימניות שקורא מציג יסטה — הוא יקפוץ ביותר מאחד לכל מחיקה. שינוי שם עוקף את כל זה, וזו סיבה נוספת להעדיף את הפונקציה הממוקדת על פני חיטוט ידני במילון

איך השמירה נשארת במקום

כל עריכה שלמעלה משנה אובייקטים בזיכרון; שום דבר אינו מגיע לדיסק עד ש-SaveLoadedDocument רצה. הסיבה שהגישה הזו זולה היא שהשמירה אינה מחוללת מחדש את המסמך — היא משמרת את מספרי האובייקטים הקיימים ואת המבנה ש-HotPDF ניתח בטעינה, וכותבת בחזרה את אותו גרף עם קומץ האובייקטים ששינית ושהוקצו מחדש. זה מה שמונע ממעבר מטא-דאטה לכתוב מחדש את הקובץ כולו, וזה אותו מנגנון עדכון-במקום שמאפשר לזרמי אובייקטים ועדכונים הדרגתיים לעבוד. אם קובצי המקור שלך יוצאים מ-Word או מחבילה משרדית אחרת, לפריסת האובייקטים שלהם יש מוזרויות משלה שכדאי להכיר לפני שאתה עורך אותם; המאמר על זרמי הפניות צולבות היברידיים ב-PDF של Office מסביר איך הקבצים האלה בנויים ומה שורד הלוך ושוב

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

שני גבולות שיש לכבד. ראשית, זהו מודל עריכה במקום, לא כלי השחרה או חיטוי: הסרת מפתח Info מסירה את המפתח הזה, אך אינה מנקה ערכים ישנים שעשויים לשרוד בדור עדכון הדרגתי קודם של אותו קובץ. אם הדרישה שלך היא הסרה אמיתית של מטא-דאטה רגישה, זו פעולה אחרת וכבדה יותר. שנית, כתיבת ה-XMP היא מילולית — הספרייה סומכת על ה-XML שלך ואינה מאמתת אותו — ולכן עבור כל דבר המיועד ל-PDF/A או למאמת קפדני, חולל את החבילה מתבנית ידועה כתקינה ואמת את הפלט. בתוך הקווים האלה, עריכת מטא-דאטה במקום היא הכלי בגודל הנכון: היא מתקנת את מעט הבתים השגויים ומשאירה את תשעים ותשעה האחוזים של הקובץ שכבר היו נכונים בדיוק כפי שהמפיק המקורי כתב אותם

ממשק הכתיבה למסמך טעון שמוצג כאן נשלח עם HotPDF Delphi Component הסטנדרטי עבור Delphi ו-C++Builder, לצד המערך המלא של מתודות עריכת מטא-דאטה, תוכן עניינים ו-Catalog