مقاله فنی

ادامهٔ جدول محتوامحور در صفحات PDF در Delphi

کامپوننت PDFium نسخهٔ 3.117.0 جدولی را که روی مرز یک صفحه می‌شکند وقتی به هم می‌بندد که یا هر دو قطعه لبه‌های صفحه را لمس کنند یا هیچ متن اصلی زیر قطعهٔ اول و بالای قطعهٔ دوم نباشد، با نادیده گرفتن سرصفحه و پاصفحهٔ جاری. ExtractDocumentTables این آزمون محتوامحور را به‌عنوان جایگزینی برای آزمون قدیمی‌تر حاشیهٔ صفحه اعمال می‌کند، قطعه‌ای در صفحهٔ بعدی که سطر اولش یک سلول caption تمام-عرض تکی است را رد می‌کند، و یک سطر تکی که به صفحهٔ بعدی سرریز شده را به‌عنوان بخشی از زنجیرهٔ ادامه‌اش نگه می‌دارد

مقالهٔ تشخیص و استخراج جدول ادامه را به‌صورت چهار gate سختگیرانه معرفی کرده و «لمس لبهٔ صفحه» را یکی از آن‌ها شمرده بود. آن توصیف برای انتشار زمان خودش درست بود، و برای بیشتر جدول‌هایی که مردم واقعاً به کامپوننت می‌دهند هم غلط بود. این مقاله تصحیح است: آزمون حاشیه کدام سندها را نمی‌تواند اداره کند، چه چیزی جایش را گرفت، و آن دو حالت جانبی که fix با خودش کشید

چرا آزمون حاشیهٔ صفحه روی exportهای Word شکست می‌خورد؟

آزمون حاشیهٔ صفحه شکست می‌خورد چون یک واژه‌پرداز چیدن سطرها را در حاشیهٔ پایین متوقف می‌کند، نه در لبهٔ کاغذ. با مقدار پیش‌فرض ContinuationMargin برابر 36 پوینت، قاعدهٔ اصلی می‌خواست لبهٔ پایینی قطعهٔ قبلی در فاصلهٔ 36 پوینتی ته صفحه بیفتد و لبهٔ بالایی قطعهٔ بعدی در فاصلهٔ 36 پوینتی بالای صفحه. سندی که از Word با حاشیه‌های یک-اینچی پیش‌فرضش export شده، آخرین سطرش را حداقل 72 پوینت بالای ته صفحه می‌گذارد، و اگر پاصفحه‌ای باشد بیشتر، پس آن شرط هرگز برقرار نمی‌شد. هر جدول بلند در چنین سندی به‌صورت قطعه‌های مستقل با ContinuationGroup برابر صفر برمی‌گشت و فراخوان دوباره دستی به هم می‌دوختشان. این آزمون همچنان برای همان چیزی که برایش طراحی شده معنا دارد: گزارش‌هایی که layout engineها تولید می‌کنند و صفحه‌ای را تا یک جعبهٔ محتوای ثابت پر می‌کنند و صفحهٔ بعد را از بالا شروع می‌کنند. قاعدهٔ بدی نیست، قاعده‌ای ناقص است، و همین دلیل است که نسخهٔ 3.117.0 نگهش داشت و به‌جای جایگزین کردن، یک مسیر دوم اضافه کرد

آزمون محتوامحور به‌جایش چه چیزی را بررسی می‌کند؟

آزمون محتوامحور بررسی می‌کند که آیا چیزی جز جدول فضای میان دو قطعه را اشغال کرده، با استفاده از جعبه‌های کلمه در هر صفحه نه هندسهٔ صفحه. ExtractDocumentTables حین پیمایش سند، به ازای هر صفحه پایین‌ترین لبهٔ پایینی هر کلمه‌ای که بالایش بالای نوار پاصفحه می‌افتد و بالاترین لبهٔ بالایی هر کلمه‌ای که پایینش زیر نوار سرصفحه می‌افتد را ثبت می‌کند. ضخامت هر دو نوار ContinuationMargin پوینت است، پس همان option حالا دو نقش دارد: فاصله‌ای برای لبهٔ صفحه و ارتفاع ناحیه‌های سرصفحه و پاصفحهٔ جاری. یک جفت قطعه وقتی پاس می‌شود که لبهٔ پایینی قطعهٔ قبلی در سطح پایین‌ترین متن اصلی روی صفحه‌اش یا پایین‌تر باشد و لبهٔ بالایی قطعهٔ بعدی در سطح بالاترین متن اصلی صفحهٔ بعد یا بالاتر، هر کدام در محدودهٔ AlignmentTolerance. به زبان ساده: جدول آخرین چیز در صفحهٔ N و اولین چیز در صفحهٔ N+1 بوده، و یک شمارهٔ صفحه یا عنوان سند در نوار حاشیه به حساب نمی‌آید. این استثنا دلبخواه نیست. ISO 32000-1 §14.8.2.2 سرصفحه‌ها و پاصفحه‌های جاری را به‌عنوان pagination artifact دسته‌بندی می‌کند، محتوایی که به خاطر شکست صفحه وجود دارد نه به‌رغم آن، و همان ایده‌ای که به یک reader تگ‌دار اجازه می‌دهد از آن‌ها بگذرد همان چیزی است که به یک جدول اجازه می‌دهد از آن‌ها ادامه پیدا کند. مقالهٔ marked content پوشش می‌دهد که فایل‌های تگ‌دار این artifactها را چطور صریحاً اعلام می‌کنند؛ این‌جا این دسته‌بندی از موقعیت استنتاج می‌شود، چون بیشتر جدول‌های exportشده اصلاً تگ ندارند

چرا ادامهٔ جدول در کامپوننت PDFium به دو آزمون نیاز دارد: با حاشیه‌های یک-اینچی Word آزمون حاشیهٔ صفحه لبه‌های قطعه را داخل پنجره‌های 36 پوینتی می‌خواهد که چیدمان هرگز به آن‌ها نمی‌رسد، در حالی که آزمون محتوامحور جعبه‌های کلمه را مقایسه می‌کند و وقتی جدول آخرین محتوای اصلی صفحهٔ N و اولین محتوای صفحهٔ N+1 باشد می‌بندد، با نادیده گرفتن نوارهای سرصفحه و پاصفحهٔ جاری
هر کدام از دو آزمون gate را باز می‌کند، و فقط بعد از آن بررسی‌های باقی‌مانده اجرا می‌شوند: صفحه‌های مجاور، نبودن سطر caption تمام-عرض روی قطعهٔ بعدی، و مطابقت مرزهای ستون تا دو برابر AlignmentTolerance

این دو آزمون با OR ترکیب می‌شوند. یک گزارش از layout engine که جدول‌هایش تا لبهٔ کاغذ می‌روند اولی را پاس می‌کند؛ یک export از Word که جدول‌هایش در حاشیه می‌ایستند دومی را پاس می‌کند؛ سندی که هر دو را دارد دو بار پاس می‌شود. فقط بعد از موفقیت یکی از آن‌ها بقیهٔ gateها اجرا می‌شوند، و به ترتیب ثابت اجرا می‌شوند: شماره‌های صفحه باید مجاور باشند، قطعهٔ بعدی نباید با یک سطر caption شروع شود، و مرزهای ستون باید تا دو برابر AlignmentTolerance که در پیش‌فرض‌ها 6 پوینت است مطابقت داشته باشند. نام enumeration همان TPdfTableContinuation است با مقدارهای ptcNone و ptcStart و ptcMiddle و ptcEnd. قطعه‌ای که ptcEnd علامت خورده و بعد به صفحهٔ دیگری هم می‌بندد به ptcMiddle ارتقا پیدا می‌کند، پس یک جدول سه‌صفحه‌ای به ترتیب صفحه start و middle و end خوانده می‌شود. شماره‌های گروه از 1 شروع می‌شوند و 0 یعنی بی‌لینک، و ToJson همان اطلاعات را به‌صورت عضوهای continuation و continuationGroup بیرون می‌دهد، که اگر یک سرویس پایین‌دستی کار به هم دوختن را انجام بدهد شکل ترجیحی است

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;

    Options := TPdfTableExtractionOptions.Default;
    Options.DetectContinuations := True;     // پیش‌فرض؛ برای شفافیت نشان داده شده
    Options.ContinuationMargin := 54;        // پاصفحهٔ دوستری، حدود 50 پوینت عمق

    Tables := Pdf.ExtractDocumentTables(Options);
    for I := 0 to High(Tables) do
      case Tables[I].Continuation of
        ptcStart:
          Writeln(Format('group %d starts on page %d (%d rows)',
            [Tables[I].ContinuationGroup, Tables[I].PageNumber,
             Tables[I].RowCount]));
        ptcMiddle, ptcEnd:
          Writeln(Format('group %d continues on page %d (%d rows)',
            [Tables[I].ContinuationGroup, Tables[I].PageNumber,
             Tables[I].RowCount]));
      else
        Writeln(Format('standalone table on page %d (%d rows)',
          [Tables[I].PageNumber, Tables[I].RowCount]));
      end;
  finally
    Pdf.Free;
  end;
end;

چطور یک سطر caption جلوی به‌هم‌چسبیدن دو جدول را می‌گیرد؟

قطعه‌ای در صفحهٔ بعد که سطر اولش یک سلول کشیده‌شده روی همهٔ ستون‌هاست به‌عنوان یک جدول جدید رفتار می‌شود، هرگز به‌عنوان بقیهٔ جدول قبلی. این قاعده وجود دارد چون آزمون محتوامحور به‌تنهایی با ولع زیادی می‌بندد. موردی که رو ش کرد یک فرم از نوع صورت‌جلسه بود: یک جدول نزدیک ته صفحهٔ 1 تمام می‌شود، جدول دومی با عرض ستون‌های یکسان نزدیک بالای صفحهٔ 2 شروع می‌شود، جز پاصفحه چیزی میانشان نیست، و ستون‌ها مو به مو مطابقت دارند. زیر آزمون حاشیه این دو هرگز به هم نمی‌رسیدند چون هیچ‌کدام لبه‌ای را لمس نمی‌کرد؛ زیر آزمون محتوا فوراً به هم بستند و فرمی با چند بخش به یک شبکهٔ بی‌سروته تبدیل شد. چیزی که جدا نگهشان می‌دارد در ساختار سلول دیده می‌شود. جدول دوم با یک caption بخش مثل "RECIPIENT INFORMATION" باز می‌شود که به‌صورت یک سلول ادغام‌شدهٔ تکی در تمام عرض چیده شده، و یک ادامهٔ واقعی هرگز این کار را نمی‌کند، چون آن caption به جدولی تعلق دارد که از قبل در صفحهٔ قبلی شروع شده. TableStartsWithCaptionRow دقیقاً همین را کد می‌کند: قطعه حداقل دو ستون دارد و سلولی در خود دارد با RowIndex = 0 و ColumnIndex = 0 و ColumnSpan = ColumnCount. این بررسی فقط روی قطعهٔ بعدی اجرا می‌شود، پس جدولی که سطر caption خودش در صفحهٔ اولش نشسته تحت تأثیر قرار نمی‌گیرد؛ caption روی صفحهٔ N است و فقط قطعهٔ صفحهٔ N+1 بازرسی می‌شود

gate سطر caption در کامپوننت PDFium: یک ادامهٔ واقعی با سلول‌های داده باز می‌شود و به همان ContinuationGroup می‌پیوندد، در حالی که قطعه‌ای که سطر صفرش یک سلول ادغام‌شده با RowIndex برابر 0 و ColumnIndex برابر 0 و ColumnSpan برابر ColumnCount دارد به‌عنوان ادامه رد می‌شود و به‌عنوان جدول جدید گزارش می‌شود
این بازرسی فقط قطعهٔ بعدی را دست می‌زند، پس جدولی که سطر caption خودش در صفحهٔ اولش نشسته تحت تأثیر قرار نمی‌گیرد، و gate بعد از آن اجرا می‌شود که یکی از دو آزمون لبه از قبل جفت را به هم بسته باشد

مقایسهٔ ستونی که بعد از آن می‌آید، TablesHaveMatchingColumns، سختگیرانه‌تر از «تعداد ستون یکسان» است. موقعیت مرزهای هر قطعه را از مستطیل‌های سلول از نو می‌سازد، مرزهایی که سلول‌های ادغام‌شده پنهانشان کرده‌اند را درون‌یابی می‌کند، و جفت را وقتی هر مرزی بیش از تلورانس لغزیده باشد رد می‌کند. پس دو جدول چهارستونی با تناسب‌های متفاوت بیرون می‌مانند حتی وقتی همه‌چیز دیگر هم‌تراز باشد

بر سر سطری که به صفحهٔ بعد سرریز می‌شود چه می‌آید؟

یک شبکهٔ خط‌دار که یک سطرش را به صفحهٔ بعد می‌برد حالا تشخیص داده و بسته می‌شود، به شرط این‌که در یک زنجیرهٔ ادامه جا بگیرد؛ به‌تنهایی دور انداخته می‌شود. مقدار پیش‌فرض MinRows برابر 2 وجود دارد تا یک جفت خط سرگردان به‌عنوان جدول گزارش نشود، ولی سطر آخری که از شکست صفحه هُل داده شده یک سطر واقعی است که کفِ سختِ 2 بی‌صدا انداختش، و بقیهٔ جدول کامل به نظر می‌رسید در حالی که نبود. اسکن سطح-سند در سه گام اداره‌اش می‌کند. وقتی DetectContinuations و DetectRuledTables هر دو ست باشند، پاس هر-صفحه آشکارساز خط‌دار را با کف سطر موقتاً پایین‌آمده به 1 اجرا می‌کند، و همین دلیل است که ExtractTables حالا برای شبکه‌های خط‌دار MinRows برابر 1 را می‌پذیرد در حالی که تشخیص بر اساس فضای سفید کف درونی 2 را نگه می‌دارد. ادامه‌ها روی کل نتیجه علامت می‌خورند. بعد هر جدولی که از MinRows فراخوان کوتاه‌تر و عضو هیچ زنجیره‌ای نباشد حذف می‌شود. قطعهٔ تک‌سطری فقط چون بسته شده زنده می‌ماند، و یک شبکهٔ یک-سطری در میانهٔ یک صفحهٔ معمولی دقیقاً مثل قبل فیلتر می‌شود

چگونه کامپوننت PDFium سطر خط‌داری را که از شکست صفحه سرریز شده نگه می‌دارد: پاس خط‌دار هر-صفحه وقتی DetectContinuations و DetectRuledTables ست باشند با کف سطر برابر یک اجرا می‌شود، ادامه‌ها روی کل نتیجه علامت می‌خورند، و فقط قطعه‌هایی که از MinRows کوتاه‌ترند و بیرون هر زنجیره نشسته‌اند حذف می‌شوند
سطر سرریزشده زنده می‌ماند چون زنجیره‌اش بسته شده، در حالی که یک شبکهٔ یک-سطری تنها در یک صفحهٔ معمولی دقیقاً مثل قبل فیلتر می‌شود، و جدول‌های تشخیص‌داده‌شده با فضای سفید کف دوستری‌شان را بدون چنین تخفیفی نگه می‌دارند
// هر زنجیره را به‌صورت یک CSV از نو بساز، و سطرهای هدر تکراری را
// روی قطعه‌های ادامه بینداز
procedure ExportChains(const Tables: TPdfTables; const Folder: string);
var
  I, R: Integer;
  Lines: TStringList;
  Csv: TStringList;
begin
  Csv := TStringList.Create;
  Lines := TStringList.Create;
  try
    for I := 0 to High(Tables) do
    begin
      if Tables[I].Continuation in [ptcNone, ptcStart] then
        Csv.Clear;
      Lines.Text := string(Tables[I].ToCsv);
      if (Tables[I].Continuation in [ptcMiddle, ptcEnd]) and
         (Lines.Count > 1) and (Tables[I].RowCount > 1) then
        Lines.Delete(0);            // هدری که واژه‌پرداز تکرارش کرده
      for R := 0 to Lines.Count - 1 do
        Csv.Add(Lines[R]);
      if Tables[I].Continuation in [ptcNone, ptcEnd] then
        Csv.SaveToFile(Format('%s\page%d-group%d.csv',
          [Folder, Tables[I].PageNumber, Tables[I].ContinuationGroup]));
    end;
  finally
    Lines.Free;
    Csv.Free;
  end;
end;

دو جزئیات در آن روتین عمدی است. سرریز تک‌سطری هرگز بریده نمی‌شود، چون نگهبان روی RowCount نگهش می‌دارد، و یک واژه‌پرداز که سطر هدر را در هر صفحه تکرار می‌کند قطعه‌ای تولید می‌کند که خط اولش باز هم هدر است، پس انداختن خط صفر روی قطعه‌های middle و end برای آن حالت درست است و برای generatorی که هدرها را تکرار نمی‌کند غلط. قبل از این‌که روتین را روی یک پوشه رها کنی، یک سند را بررسی کن

قواعد هنوز کجا می‌ایستند

آزمون محتوامحور فقط به‌اندازهٔ لایهٔ متنی که می‌خواند خوب است. روی یک صفحهٔ اسکن‌شده که اصلاً متن ندارد، کرانه‌های متن اصلی ثبت‌شده به مرزهای صفحه برمی‌گردند، شرط «چیزی در میان نیست» به‌صورت بی‌اثر برآورده می‌شود، و فقط gateهای سطر caption و ستون باقی می‌مانند؛ یک شبکهٔ خط‌دار روی چنین صفحه‌ای باز هم به‌عنوان یک اسکلت خالی پیدا می‌شود، پس زنجیره ممکن است درست بسته شود، ولی هیچ‌چیز دربارهٔ متن اطرافش واقعاً تأیید نشده. اگر این مهم است اول یک لایهٔ متنی اضافه کن. پاصفحه‌هایی که به‌صورت تصویر رندر شده‌اند نه متن، برای منطق نوار نامرئی‌اند و به همان دلیل بی‌ضرر

این نوارها یک عدد واحدند. پاصفحه‌ای عمیق‌تر از ContinuationMargin خط‌های پایینی‌اش را داخل ناحیهٔ اصلی جا می‌گذارد، که باعث می‌شود قطعهٔ قبلی دنبال‌شده‌با-متن به نظر برسد و بایند را بلاک می‌کند؛ option را تا عمق واقعی نوار بالا ببر، همان‌طور که مثال اول می‌کند. خیلی بالا ببر و یک پاراگراف پایانی کوتاه نزدیک ته صفحه به داخل نوار می‌لغزد و نادیده گرفته می‌شود، که جدول را به هر چه بعدش بیاید می‌بندد. قاعدهٔ caption یک شکست آینه‌ای دارد: generatorی که یک بنر ادغام‌شدهٔ "continued" را به‌عنوان سطر اول هر قطعهٔ ادامه می‌نویسد آن قطعه‌ها را به‌عنوان جدول جدید ردشده می‌بیند، و تنها علاج امروز این است که بعد از هیچ شل‌کردنی خودت با ContinuationGroup به هم بدوزی، چون این قاعده هیچ کلیدی ندارد

جدول‌های تشخیص‌داده‌شده با فضای سفید هیچ‌کدام از این تخفیف تک‌سطری را نمی‌گیرند. استراتژی فضای سفید برای دیدن یک جدول اصلاً به دو سطر هم‌تراز نیاز دارد، پس یک جدول بدون خط که یک سطرش سرریز شده باز هم یک سطر کم گزارش می‌شود. وقتی به این برخوردی، جعبه‌های کلمهٔ پشت بلوک‌های متن ساخت‌یافته و ترتیب خواندن موقعیت‌های خام را می‌دهند تا بازیابی‌اش کنی. در مجموعهٔ نمونه‌ای که این کار را جلو انداخت، سیزده export از واژه‌پردازها و مرورگرها، هر پنج سند با جدول‌های چندصفحه‌ای واقعی به زنجیره‌های تکی بسته شدند و آن فرم صورت‌جلسه که قبلاً به هم چسبیده بود جدا ماند، که همان معیاری است که این انتشار با آن سنجیده شد، نه یک وعده دربارهٔ هر چیدمانی

علامت‌گذاری ادامه و قاعدهٔ caption و پاس تک‌سطری همه در مسیر سطح-سند زندگی می‌کنند که بیلدهای Delphi و C++Builder و Lazarus شریکش هستند؛ API کامل استخراج جدول در صفحهٔ کامپوننت PDFium برای Delphi توصیف شده