مقاله فنی

نگاشت متن PDF به byteهای content stream در Delphi

یک کاراکتر اشتباه در شماره invoice و تنها primitive ویرایشی موجود، کل text run را بازنویسی می‌کند. PDF Library for Delphi این فاصله را پر می‌کند: GetTextBlockCharContentLocation هر position استخراج‌شده UTF-16 را به instruction، operand و بازه byte کدگذاری‌شده‌ای که آن را تولید کرده برمی‌گرداند و ReplaceTextBlockCharSourceBytes فقط همان بازه را overwrite می‌کند. Text extraction معمولاً تمام چیزهایی را که برای این کار لازم دارید دور می‌ریزد. Unicode، width و geometry را می‌گیرید اما provenance ناپدید می‌شود، بنابراین کاراکتر position 7 از block 3 فقط یک کاراکتر است. کدام stream آن را تولید کرده، کدام instruction و operand و کدام byte داخل operand: همه از دست رفته‌اند. هر strategy برای point edit که روی این اطلاعات ناقص بنا شود باید حدس بزند؛ معمولاً با جست‌وجوی یک substring در content decode‌شده و امید به اینکه دقیقاً یک بار ظاهر شود. روی یک page واقعی، چنین چیزی تضمین‌شده نیست

چرا بازنویسی کل text run صفحه را خراب می‌کند؟

چون run فقط text نیست. operatorهای text-showing در ISO 32000-1 §9.4.3 شامل TJ هستند که operand آن arrayی است و stringها را با adjustmentهای عددی interleave می‌کند؛ همین عددها typesetting را می‌سازند. خطی که به شکل [(AB) -120 (CD)] TJ چیده شده، یک kern به اندازه 120 هزارم em بین دو string دارد. اگر یک Tj تازه با text به‌هم‌چسبیده تولید کنید، kern از بین می‌رود، line کمی reflow می‌شود و در یک form، value از box خود بیرون می‌لغزد. همین ایراد درباره font هم صادق است: byteهای operand، codeهایی در encoding انتخاب‌شده توسط Tf هستند نه Unicode و در یک composite font ممکن است CIDهای دو بایتی باشند که هیچ رابطه مستقیمی با کاراکتری که extractor به شما می‌دهد ندارند. برای regenerate کردن run باید درباره encoding فونت، map مربوط به /ToUnicode و پوشش glyph درست عمل کنید. point editing با هرگز خارج نشدن از byte domain، همه این مسائل را دور می‌زند

GetTextBlockCharContentLocation چه چیزی برمی‌گرداند؟

این method یک کاراکتر را به record نه‌فیلدی resolve می‌کند و هر field به‌جای value، یک address است. ContentLayer index یک‌مبنای array مربوط به /Contents page است یا وقتی کاراکتر از content تو‌در‌تو آمده باشد 0 است. StreamObjectNumber و StreamGeneration stream دربرگیرنده را مشخص می‌کنند. InstructionIndex position صفرمبنا در content program decode‌شده است، OperandIndex operand مربوط به text string و ArrayElementIndex element داخل array از نوع TJ است یا برای operand از نوع direct string مقدار -1 دارد. سپس SourceByteOffset و SourceByteLength بازه byte را داخل همان string decode‌شده نام‌گذاری می‌کنند

Var
  Lib: TPDFlib;
  ListID, Block, CharPos: Integer;
  ContentLayer, StreamObjectNumber, StreamGeneration: Integer;
  InstructionIndex, OperandIndex, ArrayElementIndex: Integer;
  SourceByteOffset, SourceByteLength, Flags: Integer;
Begin
  Lib:= TPDFlib.Create;
  Try
    Lib.LoadFromFile('invoice.pdf', '');
    Lib.SelectPage(1);
    ListID:= Lib.ExtractPageTextBlocks(3);
    Try
      // Block و CharPos از scan خودتان روی GetTextBlockText می‌آیند
      If Lib.GetTextBlockCharContentLocation(ListID, Block, CharPos,
        ContentLayer, StreamObjectNumber, StreamGeneration,
        InstructionIndex, OperandIndex, ArrayElementIndex,
        SourceByteOffset, SourceByteLength, Flags)= 1 Then
      Begin
        // ContentLayer = 0 یعنی glyph داخل یک Form XObject تو‌در‌تو قرار دارد
        // ArrayElementIndex = -1 یعنی operand ساده Tj، نه یک array از نوع TJ
      End;
    Finally
      Lib.ReleaseTextBlocks(ListID);
    End;
  Finally
    Lib.Free;
  End;
End;

lookup در زمان query هیچ هزینه‌ای ندارد. renderer هنگام decode کردن هر content layer، spanهای منطقی را که طی می‌کند register می‌کند؛ بنابراین query مربوط به position، یک binary search روی list مرتب‌شده intervalهاست نه scan خطی همه content spanها برای هر کاراکتر. وقتی درخواست می‌کنید چیزی دوباره parse نمی‌شود؛ map در همان extraction pass که هزینه‌اش را قبلاً داده‌اید ساخته شده است. اگر از قبل hitها را با PDF text search دارای مختصات hit enumerate می‌کنید، افزودن location مربوط به content برای هر hit تقریباً رایگان است

ویرایش byteها، نه Unicode

ReplaceTextBlockCharSourceBytes یک AnsiString از byteهای raw replacement را در encoding فعال font مربوط به PDF می‌گیرد. کل طراحی همین است و عمدی است. هیچ چیز transcode یا re-encode نمی‌شود و library درباره font حدس نمی‌زند. library byteهای شما را روی range نام‌گذاری‌شده string هدف splice می‌کند و content layer دربرگیرنده را دوباره emit می‌کند. stringهای مجاور در همان array از نوع TJ و kernهای عددی بین آن‌ها byte-identical باقی می‌مانند. به layout بالا برگردیم: پیدا کردن B در [(AB) -120 (CD)] TJ مقدار ArrayElementIndex برابر 0، SourceByteOffset برابر 1 و SourceByteLength برابر 1 می‌دهد. اگر آن را با Z عوض کنید، content صادرشده شامل (AZ) است و همچنان -120 و (CD) بعد از آن و دست‌نخورده باقی می‌مانند. regression suite دقیقاً همین را assert می‌کند، چون ادعای «kerning را حفظ کردیم» از آن ادعاهایی است که ممکن است بی‌سروصدا دیگر درست نباشد

Function EditableHere(Flags: Integer): Boolean;
Begin
  Result:= ((Flags and PDF_TEXT_CHAR_CONTENT_LOCATION_VALID)<> 0)and
    ((Flags and (PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED or
      PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT or
      PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED or
      PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER or
      PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED))= 0);
End;

// ...
If EditableHere(Flags) Then
Begin
  If Lib.ReplaceTextBlockCharSourceBytes(ListID, Block, CharPos, 'Z')= 1 Then
  Begin
    // هر location در list قدیمی دیگر stale است؛ دوباره extract کنید
    Lib.ReleaseTextBlocks(ListID);
    ListID:= Lib.ExtractPageTextBlocks(3);
  End
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_STALE Then
    // layer از زمان extraction زیر دست ما تغییر کرده است
  Else If Lib.LastErrorCode= PDFLIB_ERROR_TEXT_LOCATION_READ_ONLY Then
    // flagی را که باید check می‌کردیم جا انداختیم یا نسخه جدید flagی اضافه کرده است
End;

دو نکته عملی ارزش به خاطر سپردن دارند. call موقتاً به pageی می‌رود که text list از آن extract شده و چه در success و چه در failure، page انتخاب‌شده قبلی را restore می‌کند؛ بنابراین cursor شما را بی‌سروصدا جابه‌جا نمی‌کند. همچنین در حالت success، snapshotهای page element را clear می‌کند و هر handleای را که از enumeration pass قبلی نگه داشته بودید invalid می‌سازد

کدام کاراکترها قابل ویرایش نیستند؟

شش category وجود دارد و library هرکدام را در bitmask مربوط به Flags نام می‌برد، به‌جای اینکه مبهم failure بدهد. این موضوع از happy path مهم‌تر است، چون در documentهای واقعی caseهای unmappable رایج‌اند و هرکدام دلیل متفاوتی دارند

  • PDF_TEXT_CHAR_CONTENT_LOCATION_LIGATURE: چند position استخراج‌شده UTF-16 از یک glyph source گسترش پیدا می‌کنند. یک entry از /ToUnicode که یک code را به fi map می‌کند، دو کاراکتر را با یک byte range به شما می‌دهد؛ آن‌ها را یک glyph source در نظر بگیرید و range را یک بار edit کنید
  • PDF_TEXT_CHAR_CONTENT_LOCATION_GENERATED: کاراکتر هنگام layout synthesize شده است. spaceهای کلمه که infer شده‌اند حالت معمول هستند و اصلاً source byte ندارند؛ بنابراین SourceByteOffset مقدار -1 و SourceByteLength مقدار 0 برمی‌گرداند
  • PDF_TEXT_CHAR_CONTENT_LOCATION_ACTUALTEXT: متنی که می‌خوانید از یک replacement با /ActualText آمده است. از string جایگزین‌شده به source byteها reverse mapping یکتایی وجود ندارد، پس location فقط diagnostic است
  • PDF_TEXT_CHAR_CONTENT_LOCATION_NESTED: glyph داخل یک Form XObject است. byteها addressable هستند اما Form ممکن است توسط چند page رسم شود؛ بنابراین edit کردن آن از high-level API، تغییری است که درخواستش را نداده‌اید
  • PDF_TEXT_CHAR_CONTENT_LOCATION_TRANSCODED: operand یک hex string دارای byte order mark از نوع UTF-16BE بوده و extraction path موجود، آن را پیش از font mapping decode کرده است. offsetهای نتیجه decode‌شده دیگر به byteهای اصلی address نمی‌دهند، پس valid flag clear می‌شود
  • PDF_TEXT_CHAR_CONTENT_LOCATION_CROSS_LAYER: string operand و text-showing operator آن در دو stream متفاوت قرار دارند

مورد آخر شایسته یک جمله مستقل است، چون engineerها معمولاً فرض می‌کنند نمی‌تواند رخ دهد. ISO 32000-1 §7.8.2 می‌گوید streamهای داخل array مربوط به /Contents یک page به هم concatenate می‌شوند و مرز میان آن‌ها فقط لازم است در یک lexical boundary قرار بگیرد. بنابراین BT /F1 16 Tf 220 340 Td (CrossLayer) در یک stream و Tj ET در stream بعدی، pageی کاملاً قانونی است. mapping position تشخیصی را نگه می‌دارد اما آن را read-only علامت می‌زند، چون instruction index مربوط به operator به layer دیگری تعلق دارد و استفاده از آن برای address کردن byteهای operand می‌تواند file را corrupt کند

library از کجا می‌فهمد map هنوز معتبر است؟

با fingerprintهایی که بلافاصله پیش از write check می‌شوند. هر extraction list، page source و برای هر content layer طول layer و دو rolling hash مستقل را ثبت می‌کند: یک hash از نوع FNV-1a و یک hash از نوع xor شبیه DJB2. پیش از اینکه ReplaceTextBlockCharSourceBytes چیزی را parse کند، layer هدف را دوباره می‌خواند و هر سه value را مقایسه می‌کند. هر تغییر byte در هر نقطه از آن layer، PDFLIB_ERROR_TEXT_LOCATION_STALE برمی‌گرداند و write انجام نمی‌شود. این رفتار عمداً conservative است: check در سطح layer انجام می‌شود، نه instruction؛ بنابراین edit نامرتبط در جای دیگری از همان content stream نیز location شما را invalid می‌کند. این trade درستی است: offset داخل streamی که حتی یک byte جابه‌جا شده، تقریباً درست نیست؛ corruption خاموش است. همین discipline بر باقی editing surface، از جمله state tracker مربوط به CTM و clipping در content stream، حاکم است. بعد از هر replacement موفق، list را دور بیندازید و دوباره extract کنید

mapping فقط‌خواندنی از مسیر Direct Access

DAGetTextBlockCharContentLocation برای pageی که از مسیر Direct Access باز شده، همان record را با همان vocabulary مربوط به flagها به شما می‌دهد. این قابلیت ذاتاً فقط diagnostic است: ReplaceTextBlockCharSourceBytes روی document انتخاب‌شده و قابل‌ویرایش کار می‌کند و Direct Access یک read path است. داده‌های location پس از بسته شدن file handle در text block list باقی می‌مانند، بنابراین برای audit آفلاین قابل استفاده‌اند

FileHandle:= Lib.DAOpenFileReadOnly('audit.pdf', '');
Try
  PageRef:= Lib.DAFindPage(FileHandle, 1);
  DirectList:= Lib.DAExtractPageTextBlocks(FileHandle, PageRef, 3);
  Try
    Lib.DAGetTextBlockCharContentLocation(DirectList, Block, 1,
      ContentLayer, StreamObjectNumber, StreamGeneration,
      InstructionIndex, OperandIndex, ArrayElementIndex,
      SourceByteOffset, SourceByteLength, Flags);
    // locationها بعد از DACloseFile هم قابل خواندن می‌مانند
  Finally
    Lib.DAReleaseTextBlocks(DirectList);
  End;
Finally
  Lib.DACloseFile(FileHandle);
End;

از آن برای پاسخ دادن به پرسش‌ها استفاده کنید، نه برای تغییر دادن چیزها. کدام pageها textی دارند که هرگز نمی‌توانید in-place edit کنید؟ چه مقدار از این corpus با overrideهای /ActualText می‌رسد؟ خروجی کدام vendor، operatorها را بین content layerهای مختلف تقسیم می‌کند؟ وقتی هر کاراکتر یک address دارد، این queryها ارزان هستند و ارزش دارد پیش از متعهد شدن به correction pipeline آن‌ها را اجرا کنید

point editing کجا متوقف می‌شود؟

point editing یک scalpel است، نه text engine. byteها را in-place تغییر می‌دهد، بنابراین replacement textی که از original پهن‌تر یا باریک‌تر باشد reflow یا rewrap نمی‌شود و kernهای اطرافش نیز update نمی‌شوند. جایگزین کردن یک رقم با رقم دیگر در fieldی monospace، کاربرد مناسبی است. تایپ دوباره یک paragraph مناسب نیست. مهم‌تر اینکه این ابزار اصلاً security tool نیست: overwrite کردن glyph byteها، byteهای اصلی را از revision history فایل قابل بازیابی باقی می‌گذارد؛ بنابراین هر چیزی که نیاز محرمانگی دارد باید در true redaction برای حذف content، نه پوشاندن آن انجام شود. در مقابل این محدودیت‌ها، چیزی که می‌گیرید صداقت است. هر کاراکتر یا یک byte address دارد که می‌توانید روی آن عمل کنید، یا flag نام‌گذاری‌شده‌ای دارد که دلیل نداشتن آن را توضیح می‌دهد؛ fingerprint check نیز map stale را به hard error تبدیل می‌کند، نه page خراب. mapping از character به content byte و replacement in-place source byte به‌عنوان بخشی از سطح text extraction و content editing در PDF Library for Delphi ارائه می‌شوند؛ همان native Object Pascal PDF library برای Delphi، C++Builder و Lazarus