مقاله فنی

خواندن Actionهای Bookmark و Annotation PDF در Delphi

شما یک پوشه از PDFها را از جایی بالادستی به ارث می‌برید، و کار در ظاهر trivial به نظر می‌رسد: به من بگو کدام bookmarkها به یک URL خارجی می‌پرند، کدام‌ها JavaScript اجرا می‌کنند، و داخلی‌ها واقعاً کجا فرود می‌آیند. بعد API reference را باز می‌کنید و می‌بینید library می‌تواند همهٔ آن actionها را ایجاد کند اما هیچ راهی برای خواندن دوبارهٔ آن‌ها نمی‌دهد. این عدم تقارن در ابزارهای PDF همه‌جا دیده می‌شود. نوشتن یک bookmark که https://example.com را باز می‌کند یک خطی است؛ پرسیدن از یک bookmark موجود که «چه می‌کنی و به کدام target؟» معمولاً یعنی راه رفتن دستی در درخت object خام از طریق /A, /S, /Dest و یک fan-out از fit-type variantها که تقریباً هیچ‌کس بار اول درست درنمی‌آورد

PDFlibPas یک PDF library بومی Object Pascal برای Delphi و C++Builder است، و مدت زیادی همان شکاف را داشت: setterهای غنی در سمت write، getterهایی که فقط یک TPDFObject ساده را به شما پس می‌دادند و شما را مجبور می‌کردند خودتان در object tree جست‌وجو کنید. release v3.77.0 بخشی از این شکاف را با مجموعهٔ کوچکی از introspection callهای typed بست که نوع action، payload action و geometry destination را به‌شکل recordهای ساده گزارش می‌کنند. این مقاله دربارهٔ این است که آن callها چگونه روی مدل action و destination در ISO 32000-1 map می‌شوند، و سه دام مشخصی که باعث می‌شوند نسخه‌های دست‌ساز این کد بی‌سروصدا غلط شوند

چرا خواندن actionها سخت‌تر از نوشتن آن‌هاست

یک action در PDF یک dictionary با یک /S key است که subtype آن را نام می‌برد: GoTo, GoToR, URI, Launch, Named, JavaScript, و یک دنبالهٔ بلندتر که به‌ندرت با آن روبه‌رو می‌شوید (ISO 32000-1 §12.6.4). مشکل این است که payload برای هر subtype در یک key متفاوت زندگی می‌کند، و هیچ slot یکنواختی از جنس «target را بده» وجود ندارد. یک URI action آدرس خود را در /URI. یک GoToR یا Launch action یک file specification را در /F. یک JavaScript action script خود را در /JS نگه می‌دارد، که می‌تواند یا یک string باشد یا یک stream. یک GoTo action هیچ payloadی برای خودش ندارد؛ target آن یک destination است که از /D آویزان است و بعد باید جداگانه resolve شود

وقتی شما یک action را می‌نویسید، نوعش را از قبل می‌دانید، پس هیچ‌کدام از این‌ها اهمیت ندارد. وقتی آن را می‌خوانید، باید اول بر اساس /S branch بزنید، بعد به key درست دست پیدا کنید، بعد با این واقعیت کنار بیایید که همان مفهوم منطقی واحد («چیزی که این action به آن اشاره می‌کند») به سه شکل ناسازگار encode شده است. دقیقا همین branching است که getterهای typed جذب می‌کنند. GetOutlineActionInfo و GetAnnotActionInfo هر دو یک TPDFlibActionInfo record برمی‌گردانند:

type
  TPDFlibActionKind = (akNone, akGoTo, akGoToR, akURI,
                       akLaunch, akNamed, akJavaScript);

  TPDFlibActionInfo = record
    Kind: TPDFlibActionKind;
    URI: AnsiString;          // populated for akURI
    JavaScript: WideString;   // populated for akJavaScript
    FileName: AnsiString;     // populated for akGoToR / akLaunch
    OpenInNewWindow: Boolean; // akGoToR / akLaunch
  end;

record به شما می‌گوید کدام fieldها معنا دارند، از طریق Kind. اگر Kind به‌شکل akURI برگردد، URI را بخوانید و بقیه را نادیده بگیرید. اگر به‌شکل akGoToوقتی این طور است، به destination می‌روید که یک call جداگانه است و پایین‌تر پوشش داده می‌شود. akNone این پاسخ صادقانه وقتی bookmark یا annotation اصلاً actionی ندارد است، نه یک zero که مجبور باشید معنی‌اش را حدس بزنید

واکشیدن در درخت outline برای پیدا کردن یک bookmark

قبل از این‌که بتوانید یک bookmark را introspect کنید، به handle آن نیاز دارید. PDFlibPas گره‌های outline را با یک integer ID شناسایی می‌کند، و FindOutlineByTitle یکی را با visible text آن و با کنترل صریح روی این‌که جست‌وجو تا کجا جلو می‌رود پیدا می‌کند:

type
  TPDFlibOutlineSearchDepth =
    (osdSiblingsOnly, osdChildrenOnly, osdFullSubTree);

function FindOutlineByTitle(const Title: WideString;
  StartOutlineID: Integer;
  Depth: TPDFlibOutlineSearchDepth): Integer;

پارامتر Depth همان بخشی است که ارزش مکث کردن دارد. osdSiblingsOnly زنجیرهٔ siblingها را در level گرهٔ شروع scan می‌کند و متوقف می‌شود؛ یک bookmark هم‌سطح را پیدا می‌کند، اما هیچ‌وقت وارد فرزندان آن peer نمی‌شود. osdChildrenOnly یک level پایین‌تر می‌رود، به فرزندان مستقیم گرهٔ شروع. osdFullSubTree کل branch را به‌صورت recursive طی می‌کند. انتخاب اشتباه یک miss خاموش است، نه یک error: جست‌وجوی فقط-sibling برای عنوانی که دو level پایین‌تر زندگی می‌کند به‌سادگی zero برمی‌گرداند، و شما نتیجه می‌گیرید bookmark وجود ندارد در حالی که از ابتدا آن‌جا بوده است. GetFirstOutline را به‌عنوان start ID بدهید تا جست‌وجو از document root انجام شود

var
  Lib: TPDFlib;
  FoundID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('report.pdf', '') = 1 then
    begin
      // Search the whole tree from the root for a nested bookmark.
      FoundID := Lib.FindOutlineByTitle('Appendix B',
        Lib.GetFirstOutline, osdFullSubTree);
      if FoundID <> 0 then
        // FoundID is now a handle you can pass to the action and
        // destination getters below.
        ;
    end;
  finally
    Lib.Free;
  end;
end;

تطبیق روی خود رشتهٔ عنوان انجام می‌شود، و آن را به‌صورت یک WideString مقایسه می‌کند، پس case-sensitive است و متن Unicode را دقیقاً همان‌طور که ذخیره شده احترام می‌گذارد. اگر PDFهای مبدأ شما از producerهای ناسازگار می‌آیند، عنوانی را که جست‌وجو می‌کنید همان‌طور normalize کنید که document آن را ذخیره کرده است، وگرنه دنبال missهای خیالی خواهید رفت

حل کردن action و target یک bookmark

با یک handle در دست، GetOutlineActionInfo به شما view تایپ‌شده را می‌دهد. الگو این است: آن را صدا بزنید، روی Kind switch کنید، fieldی را بخوانید که آن kind پر می‌کند

var
  Info: TPDFlibActionInfo;
begin
  Info := Lib.GetOutlineActionInfo(FoundID);
  case Info.Kind of
    akURI:
      Writeln('Opens URL: ', Info.URI);
    akGoToR, akLaunch:
      Writeln('Opens file: ', Info.FileName,
        ' (new window: ', Info.OpenInNewWindow, ')');
    akJavaScript:
      Writeln('Runs script: ', string(Info.JavaScript));
    akGoTo:
      Writeln('Jumps within this document');  // see destination below
    akNamed:
      Writeln('Named action (NextPage, Print, etc.)');
    akNone:
      Writeln('Bookmark has no action');
  end;
end;

اینجا اولین تلهٔ واقعی قرار دارد، و همان چیزی است که feedback تست در جریان پیاده‌سازی رو کرد. یک getter قدیمی‌تر وجود دارد، GetActionURL, و رفتن سراغ آن برای خواندن یک URI action اشتباهِ بدیهی است. GetActionURL یک file specification را از طریق /F key resolve می‌کند. این برای GoToR و Launch درست است، چون target آن‌ها واقعاً فایل است، اما برای یک URI action key اشتباهی است. address یک URI action یک string ساده روی خود action و در /URI key است، نه یک file spec. یک URI action را به pathِ file-spec بدهید و یک نتیجهٔ خالی یا بی‌معنا می‌گیرید. getter تایپ‌شده این را درون خود با خواندن /URI مستقیم برای akURI و فقط فراخوانی resolverِ file-specification برای akGoToR و akLaunch مدیریت می‌کند، که دقیقاً همان تمایزی است که نسخهٔ دست‌نویس معمولاً در آن blur می‌شود

نوع‌های fit و geometry پشت آن‌ها

یک akGoTo action یعنی «درون این document navigation کن»، اما هیچ چیزی دربارهٔ کجا یا چگونه نمی‌گوید. این وظیفهٔ destination است، و destinationها ظرافت بیشتری از آن‌چه مردم انتظار دارند دارند. یک PDF destination فقط یک page number نیست؛ یک page به‌همراه یک fit specification است که می‌گوید viewer چگونه آن page را frame کند (ISO 32000-1 §12.3.2.2). GetOutlineDestinationInfo آن را به‌شکل یک record برمی‌گرداند:

type
  TPDFlibDestinationKind = (dkNone, dkXYZ, dkFit, dkFitH,
    dkFitV, dkFitR, dkFitB, dkFitBH, dkFitBV);

  TPDFlibDestinationInfo = record
    Kind: TPDFlibDestinationKind;
    Page: Integer;   // 1-based; 0 when unresolved
    Left, Top, Right, Bottom, Zoom: Double;
  end;

هشت kindِ fit هرکدام یک سؤال frame کردن متفاوت را جواب می‌دهند. dkXYZ یک point مشخص را با zoom صریح در گوشهٔ بالا-چپ قرار می‌دهد، پس از Left، Top و Zoom استفاده می‌کند. dkFit کل page را در window جا می‌دهد و coordinates را نادیده می‌گیرد. dkFitH و dkFitV عرض یا ارتفاع page را با یک coordinate مرتبطِ واحد - یک top edge یا یک left edge - fit می‌کنند. dkFitR جالب‌ترین مورد است: یک rectangle مشخص را fit می‌کند، پس هر چهار edge مهم‌اند. خانوادهٔ dkFitB* همین کارها را نسبت به bounding boxِ content قابل‌مشاهده انجام می‌دهد، نه نسبت به کل page. دانستن این‌که برای هر kind کدام fieldها live هستند، تفاوت بین خواندن درست destination و چاپ coordinateهای بی‌ارزشی است که اتفاقاً zero شده‌اند

PDF reader bookmark navigation panel showing a nested outline tree
هر bookmark در این navigation panel به یک action و، برای jumpهای داخلی، به یک destination با fit type و coordinates خودش resolve می‌شود.

در پشت صحنه، پیاده‌سازی به یک هم‌راستایی عمدی تکیه می‌کند که دانستنش ارزش دارد، چون توضیح می‌دهد چرا mapping قابل‌اعتماد است. GetDestType یک integer 1..8 را برای هشت fit kind دقیقاً به همان ترتیب XYZ/Fit/FitH/FitV/FitR/FitB/FitBH/FitBV برمی‌گرداند. TPDFlibDestinationKind طوری declare شده است که ordinalهایش یک‌به‌یک align شوند: dkXYZ ordinal 1 است، dkFitBV ordinal 8 است، و dkNone در zero نشسته است. پس conversion یک cast مستقیم ordinal با یک guard روی range است، نه یک lookup table که با رشد enum از sync خارج شود. این یک جزئیات کوچک است، اما از آن نوع چیزهایی است که اگر به شکل ساده‌لوحانه انجام شود، اولین بار که کسی ترتیب یک enumeration را عوض کند به یک off-by-one bug تبدیل می‌شود

var
  Dest: TPDFlibDestinationInfo;
begin
  Dest := Lib.GetOutlineDestinationInfo(FoundID);
  if Dest.Page = 0 then
    Exit;  // destination did not resolve
  case Dest.Kind of
    dkXYZ:
      Writeln(Format('Page %d at (%.0f, %.0f), zoom %.2f',
        [Dest.Page, Dest.Left, Dest.Top, Dest.Zoom]));
    dkFitR:
      Writeln(Format('Page %d, rect L%.0f T%.0f R%.0f B%.0f',
        [Dest.Page, Dest.Left, Dest.Top, Dest.Right, Dest.Bottom]));
    dkFit, dkFitB:
      Writeln(Format('Page %d, fit whole page', [Dest.Page]));
  else
    Writeln(Format('Page %d, fit kind %d',
      [Dest.Page, Ord(Dest.Kind)]));
  end;
end;

یک Page از zero نشانهٔ این است که destination resolve نشده است، معمولاً چون action هیچ destinationی ندارد یا named destination پیدا نشده است. قبل از این‌که به هر coordinate اعتماد کنید، آن را بررسی کنید. همچنین توجه داشته باشید که GetOutlineDestinationInfo هر دو جایی را که یک destination می‌تواند زندگی کند نگاه می‌کند: مستقیماً روی /Dest bookmark، و داخل GoTo actionِ embedded از طریق /D. لازم نیست بدانید producer از کدام form استفاده کرده است

Actionهای annotation و SelectPage trap

Link annotationها actionها را دقیقاً همان‌طور حمل می‌کنند که bookmarkها این کار را می‌کنند، و GetAnnotActionInfo همان TPDFlibActionInfo record را با همان patternِ kind-then-payload برمی‌گرداند. اما اینجا یک catch حالت‌مند وجود دارد که در outlineها وجود ندارد، و این سومین تله است

Annotationها متعلق به pageها هستند، و PDFlibPas annotationهای page فعلی را از طریق stateی expose می‌کند که فقط بعد از انتخاب آن page معتبر می‌شود. GetAnnotActionInfo را بدون این‌که ابتدا SelectPage(N) را صدا زده باشید call کنید و annotation handle صفر می‌شود؛ call akNone برمی‌گرداند و شما به‌اشتباه نتیجه می‌گیرید page هیچ annotation قابل‌اقدامی ندارد. fix یک خط است، اما وقتی دارید روی pageها loop می‌زنید فراموش‌کردنش آسان است:

var
  P: Integer;
  Info: TPDFlibActionInfo;
begin
  for P := 1 to Lib.PageCount do
  begin
    Lib.SelectPage(P);   // mandatory before touching annotations
    // GetAnnotActionID(1) <> 0 is the reliable "has an action"
    // test. CheckPageAnnots returns a boolean-style flag, not a
    // count, so it is the weaker signal here.
    if Lib.GetAnnotActionID(1) <> 0 then
    begin
      Info := Lib.GetAnnotActionInfo(1);
      if Info.Kind = akURI then
        Writeln(Format('Page %d link -> %s', [P, Info.URI]));
    end;
  end;
end;

دو چیز در آن loop عمدی‌اند. اول، SelectPage(P) قبل از هر annotation access در هر iteration می‌آید؛ state annotation در سطح page carry over نمی‌شود. دوم، test وجود از GetAnnotActionID(1) <> 0 به‌جای CheckPageAnnots استفاده می‌کند. مورد دوم presence را به‌شکل یک flag شبیه boolean گزارش می‌کند، نه یک count، پس یک action ID غیرصفر دقیق‌تر راهی است برای پرسیدن «آیا یک annotation اول وجود دارد، و آیا actionی دارد که بتوانم بخوانم؟» یک ظرافت دیگر هم ارزش دارد اشاره شود: برای annotationها، یک JavaScript action script مستقیماً از /JS خوانده می‌شود، با decode کردن stream وقتی script آن‌طور ذخیره شده باشد و خواندن string در غیر این صورت، پس هر دو encoding رایج را تحمل می‌کند

جایگاه introspection در سمت read

این getterها عمداً narrow هستند. آن‌ها readهای pure هستند که روی لایه‌های موجود action و destination با handle عددی کتابخانه ساخته شده‌اند، پس هیچ write pathی را لمس نمی‌کنند و هیچ خطری برای documentهایی که هم‌زمان ویرایش می‌کنید اضافه نمی‌کنند. آن‌ها فقط آنچه را در file هست گزارش می‌کنند؛ هیچ policyای را با آن validate نمی‌کنند و چیزی را بازنویسی نمی‌کنند. اگر هدف شما برعکس است، یعنی ساخت bookmarkها و link annotationهایی که این actionها را از همان ابتدا حمل کنند، آن در سمت write است، و مقالهٔ همراه دربارهٔ actionهای فرم تعاملی و JavaScript در Delphi walks through creating them. For pulling the visible and structural content out of a PDF rather than its navigation graph, see extracting text, images, and fonts with PDFlibPas

مرز صادقانه‌ای که باید در ذهن داشت این است: introspection فقط چیزی را می‌بیند که producer واقعاً نوشته است. یک bookmark که actionش توسط generator معیوب باقی مانده، یا destinationی که به یک target نام‌گذاری‌شده اشاره می‌کند که هرگز تعریف نشده، به‌صورت akNone یا یک page صفر ظاهر می‌شود، نه به‌صورت exception. این رفتار درست برای یک read API است که فایل‌های غیرمطمئن را audit می‌کند، اما به این معنی است که کد شما باید آن zero resultها را «غایب یا unresolved» بداند، نه تضمین یک input خوش‌فرم. introspection تایپ‌شدهٔ action و destination که اینجا نشان داده شد بخشی از PDFlibPas است، کتابخانهٔ بومی PDF برای Delphi و C++Builder