مقاله فنی

حاشیه‌های جدول به‌شکل مستطیل پرشده در PDFium Delphi

استخراج جدول در کامپوننت PDFium، از نسخهٔ 3.117.0، یک مستطیل نازک پرشده را به‌عنوان یک خط جدول می‌گیرد. با فعال بودن DetectFilledRulings که پیش‌فرض است، یک جعبهٔ پرشدهٔ محور-تراز که ضخامتش از MaxRulingThickness (3 پوینت) بیشتر نباشد به یک خط در جهت محور بلندش تبدیل می‌شود، یک جعبهٔ پرشدهٔ بزرگ‌تر چهار لبه‌اش را می‌دهد، و هر مختصات خط جدول پیش از سرهم شدن شبکه تا RulingSnapTolerance (4 پوینت) snap می‌شود. پس جدول‌های exportشده از Word و Google Docs و مرورگرها به‌عنوان شبکه‌های کامل به آشکارساز خط‌دار می‌رسند، به‌جای این‌که به‌صورت قطعه‌قطعه به تشخیص فضای سفید بیفتند

مقالهٔ قبلی دربارهٔ تشخیص و استخراج جدول گفته بود که تشخیص خط‌دار از خطوط رسم‌شده استفاده می‌کند و هر سگمنت مسیر stroked به مختصات صفحه تبدیل می‌شود. آن جمله درست و ناقص بود. شمردن شیءهای مسیر در مجموعه‌ای از 13 سند نمونهٔ واقعی نشان داد 9 تا از آن‌ها اصلاً هیچ مسیر stroked ندارند، ولی هر صفحه‌ای از آن‌ها صدها مستطیل پرشدهٔ 0.5 تا 1 پوینت ضخامت در خود دارد. آشکارساز فقط-stroke هیچ‌چیز نمی‌دید، هر صفحه به تشخیص فضای سفید می‌افتاد، و خروجی به‌جای جدول یک پراکندگی از قطعه‌های کوچک بود. preset ستون‌های فشرده که در 3.116.4 اضافه شد این را در سطح قطعه نرم کرد؛ ریشه این بود که آشکارساز داشت عملگر رنگ‌آمیزی اشتباه را می‌خواند

چرا یک جدول exportشده از Word هیچ خط stroked ندارد؟

یک واژه‌پرداز به حاشیه مثل یک خط فکر نمی‌کند؛ به آن مثل یک جعبه با عرض فکر می‌کند و آن جعبه را با یک fill رنگ می‌کند. ISO 32000-1 §8.5.2.1 عملگر re را به‌عنوان افزودن یک زیرمسیر مستطیل تعریف می‌کند، و §8.5.3 عملگرهای رنگ‌آمیزی را جدا می‌کند: S مسیر را با عرض خط جاری stroke می‌کند، f داخلش را پر می‌کند. یک حاشیهٔ سلول 0.5 پوینتی به‌صورت x y w 0.5 re f بیرون می‌آید، و ماشین‌آلات stroke، با عرض خط و joinها و الگوی dash، هرگز اجرا نمی‌شود. سایه‌زنی سلول همان ساخت با یک جعبهٔ بزرگ‌تر است. یک شبکهٔ stroked که با m و l و S کشیده شده همان چیزی است که آشکارساز اصلی انتظار داشت، و همان چیزی است که تقریباً هیچ خروجی‌ای از یک برنامهٔ اداری تولید نمی‌کند:

% یک حاشیهٔ سلول از یک export واژه‌پرداز: یک جعبهٔ پرشدهٔ 0.5 پوینتی
72 700 468 0.5 re f
% سایه‌زنی سلول: یک جعبهٔ پرشده به اندازهٔ سلول
72 676 117 24 re f
% همان خط شبکهٔ stroked که آشکارساز اصلی برایش نوشته شده بود
72 700 m 540 700 l S

برای آشکارسازی که از FPDFPath_GetDrawMode فقط می‌پرسد آیا پرچم stroke ست است یا نه، هر دو جعبهٔ پرشده نامرئی‌اند. بعد کلمه‌های داخل سلول‌ها به تشخیص فضای سفید می‌رسند، جایی که ستون‌های جداشده با یک گاتر 6 پوینتی زیر مقدار پیش‌فرض MinColumnGap برابر 12 پوینت می‌نشینند، و آنچه برمی‌گردد هر زیرمجموعه‌ای از سطرهاست که تصادفاً آن‌قدر خوب هم‌تراز باشد که از MinRows بگذرد. این همان رفتار قطعه‌قطعه است، و هیچ مقدار تنظیم پارامتر آن را به شبکه‌ای که نویسنده کشیده تبدیل نمی‌کند

کامپوننت PDFium چطور یک جعبهٔ پرشده را به یک خط جدول تبدیل می‌کند؟

TableCollectObjectRulings هر شیء مسیر را یک زیرمسیر به یک زیرمسیر بررسی می‌کند. حالت رنگ‌آمیزی از FPDFPath_GetDrawMode می‌آید؛ یک مسیر وقتی پرشده شمرده می‌شود که DetectFilledRulings روشن باشد و حالت fill برابر none نباشد. هر نقطه از ماتریس شیء عبور داده و جمع می‌شود، تا MaxSubpathPoints (8) به ازای هر زیرمسیر، و هر سگمنت منحنی زیرمسیر را منحنی‌علامت می‌زند. وقتی زیرمسیر بسته می‌شود یا یک MoveTo جدید شروع می‌شود، FlushSubpath تصمیم می‌گیرد که چه بود: یک زیرمسیر منحنی دور انداخته می‌شود، و همچنین هر چندضلعی بسته‌ای که نقطه‌هایش همگی روی لبه‌های جعبهٔ محیطی در حداقل یک محور و در محدودهٔ PointTolerance (0.05 پوینت) ننشسته باشند. یک مثلث یا یک chevron یا یک تب گرد هرگز به خط جدول تبدیل نمی‌شود، و همین نگهبان هنر تزئینی را بیرون از شبکه می‌دارد

نمودار کامپوننت PDFium از این‌که TableCollectObjectRulings چطور زیرمسیرهای بسته را در Delphi به خطوط جدول تبدیل می‌کند: FlushSubpath طرح‌های منحنی و چندضلعی‌های بیرون از لبه‌های جعبهٔ محیطی را دور می‌اندازد، MaxRulingThickness جعبه‌های نازک را به یک خط به ازای هر محور بلند تقسیم می‌کند، سلول‌های سایه‌زده چهار خط لبه‌ای می‌دهند و DetectFilledRulings مربع‌های ریز را بیرون نگه می‌دارد
یک زیرمسیر بسته فقط وقتی زنده می‌ماند که محور-تراز باشد، و بعد جعبهٔ محیطی تصمیم می‌گیرد که یک خط جدول است، یا چهار لبهٔ یک سلول سایه‌زده، یا هیچ

آنچه زنده می‌ماند یک مستطیل محور-تراز است که با جعبهٔ محیطی‌اش دسته‌بندی می‌شود. عرضی در حد MaxRulingThickness یا کمتر با ارتفاعی بیشتر از آن، یک خط جدول عمودی در مرکز افقی می‌دهد که جعبه را از پایین تا بالا پوشش می‌دهد؛ حالت آینه‌ای یک خط جدول افقی می‌دهد. هر دو بُعد بالای آستانه یعنی یک سلول سایه‌زده، و آن جعبه چهار خط جدول می‌دهد، یکی به ازای هر لبه. هر دو بُعد در حد آستانه یا کمتر هیچ‌چیز نمی‌دهند، پس یک bullet مربع 2 پوینتی با یک خط اشتباه گرفته نمی‌شود. یک مسیر stroked مسیر قدیمی‌تر از AddLine را می‌رود، یک خط جدول به ازای هر سگمنت محور-تراز، پس شبکه‌ای که با S کشیده شده دقیقاً مثل قبل اداره می‌شود، و مسیری که با هر دو fill و stroke رنگ شده قطعه‌های هم‌پوشانی تولید می‌کند که پاس merge آن‌ها را جمع می‌کند:

uses
  PDFium;

var
  Pdf: TPdf;
  Options: TPdfTableExtractionOptions;
  Tables: TPdfTables;
  Mode: string;
  I: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'itinerary-from-word.pdf';
    Pdf.LoadDocument;
    Pdf.PageNumber := 1;                     // بر مبنای 1

    Options := TPdfTableExtractionOptions.Default;
    // این‌ها پیش‌فرض‌های 3.117.0 هستند، برای شفافیت صریح نوشته شده‌اند
    Options.DetectFilledRulings := True;     // جعبه‌های نازک پرشده به خط جدول تبدیل می‌شوند
    Options.MaxRulingThickness := 3.0;       // پوینت؛ جعبه‌های ضخیم‌تر سایه‌زنی شمرده می‌شوند
    Options.RulingSnapTolerance := 4.0;      // پوینت؛ مقدار 0 snapping را غیرفعال می‌کند
    Options.IncludeFormXObjects := True;

    Tables := Pdf.ExtractTables(Options);
    for I := 0 to High(Tables) do
    begin
      if Tables[I].DetectionMode = ptdmRuled then
        Mode := 'ruled'
      else
        Mode := 'whitespace';
      Writeln(Format('%dx%d %s, confidence %.2f',
        [Tables[I].RowCount, Tables[I].ColumnCount, Mode,
         Tables[I].Confidence]));
    end;
  finally
    Pdf.Free;
  end;
end;

RulingSnapTolerance برای جدول‌های سلول-سایه‌زده چه می‌کند؟

RulingSnapTolerance همان چیزی است که یک جدول ساخته‌شده فقط از سایه‌زنی را به یک شبکهٔ واحد متصل می‌کند. بعضی exportها اصلاً حاشیه نمی‌کشند: هر سلول یک جعبهٔ پرشده به رنگ خودش است و جعبه‌های مجاور با یک گاتر سفید 1 تا 3 پوینتی جدا می‌شوند. هر جعبه چهار خط لبه‌ای می‌دهد، ولی لبهٔ راست یک سلول و لبهٔ چپ سلول بعدی 2 پوینت فاصله دارند، و آزمون اتصال از RulingTolerance استفاده می‌کند که پیش‌فرضش 1 پوینت است. بدون snapping، هر سلول مؤلفهٔ متصل خودش از چهار خط را می‌سازد، هیچ مؤلفه‌ای به MinRows نمی‌رسد، و صفحه هیچ‌چیز گزارش نمی‌کند. TableSnapRulings هر مختصات X در بازی را جمع می‌کند (موقعیت هر خط عمودی به‌علاوهٔ شروع و پایان هر خط افقی) و هر مختصات Y را به همان شکل، هر فهرست را مرتب می‌کند، با زنجیره کردن مقدارهایی که همسایه‌شان بیش از تلورانس اختلاف ندارد خوشه‌بندی‌اش می‌کند، هر خوشه را با میانگینش جانشین می‌کند، و بعد هر موقعیت و شروع و پایان را به نزدیک‌ترین مرکز خوشه جابه‌جا می‌کند. دو سمت یک گاتر به یک خط تبدیل می‌شوند و اتصال برقرار می‌ماند

نمودار کامپوننت PDFium از RulingSnapTolerance که یک جدول سلول-سایه‌زده را در Delphi متصل می‌کند: سلول‌های مجاور یک گاتر 2 پوینتی می‌گذارند، خطوط لبه‌ای‌شان بیرون RulingTolerance برابر 1 پوینت می‌افتند، و TableSnapRulings آن دو مقدار X را به یک میانگین خوشه‌ای زنجیره می‌کند تا آزمون اتصال بالاخره یک خط شبکهٔ مشترک ببیند
snapping پیش از merge و پیش از آشکارساز خط‌دار اجرا می‌شود، پس دو سمت یک گاتر سفید به یک خط تبدیل می‌شوند و هر سلول از جزیره‌ای از چهار خط بودن دست برمی‌دارد

snapping پیش از TableMergeRulings اجرا می‌شود، که خطوط جدول را مرتب می‌کند و قطعه‌های هم‌راستا که در محدودهٔ RulingTolerance لمس یا هم‌پوشانی دارند را به هم می‌چسباند، و هر دو پیش از آن اجرا می‌شوند که TableDetectRuled اصلاً داده را ببیند، پس آزمون اتصال جفت‌به‌جفت متناسب با تعداد خطوط شبکه است نه تعداد قطعه‌های هر-سلول. روی یک شبکهٔ stroked این پاس‌ها بی‌ضررند، چون مختصاتی که از قبل یکسان بودند روی خودشان snap می‌شوند. تنها چیزی که باید در ذهن نگه داشت این است که خوشه‌بندی زنجیره‌ای خودش هیچ محدودیت عرضی ندارد: رشته‌ای از مختصات که هر 3 پوینت فاصله دارند به یک مرکز واحد فرو می‌ریزد. در پیش‌فرض 4 پوینتی این فقط روی ستون‌های باریک‌تر از یک کاراکتر اثر می‌گذارد، ولی اگر سندی گاترهای واقعی 3 پوینتی دارد که باید جدا بمانند، تلورانس را کم کن یا روی 0 بگذار تا snapping خاموش شود:

// استراتژی خط‌دار را جدا کن و مقایسه کن هر تنظیم روی یک صفحه چه می‌بیند
function CountRuledTables(Pdf: TPdf; FilledRulings: Boolean;
  SnapTolerance: Double): Integer;
var
  Options: TPdfTableExtractionOptions;
begin
  Options := TPdfTableExtractionOptions.Default;
  Options.DetectWhitespaceTables := False;
  Options.DetectFilledRulings := FilledRulings;
  Options.RulingSnapTolerance := SnapTolerance;
  Result := Length(Pdf.ExtractTables(Options));
end;

// یک export از Word معمولاً 0 و N و بعد کمتر از N گزارش می‌کند:
// فقط-stroke هیچ‌چیز نمی‌بیند، snapping سلول‌های سایه‌زده را متصل می‌کند،
// و خاموش کردن snap هر سلول سایه‌زده را به‌عنوان جزیرهٔ خودش رها می‌کند
Writeln(CountRuledTables(Pdf, False, 4.0));
Writeln(CountRuledTables(Pdf, True, 4.0));
Writeln(CountRuledTables(Pdf, True, 0.0));

خطوط جدول داخل form XObjectها

ابزارهای چیدمان صفحه اغلب یک جدول، یا کل بدنهٔ صفحه، را در یک form XObject می‌پیچند و با Do رنگش می‌کنند. ISO 32000-1 §8.10.1 مشخص می‌کند که ماتریس form موقع رنگ شدن form با ماتریس تبدیل جاری الحاق می‌شود، پس یک مستطیل داخل form در فضای form زندگی می‌کند و فقط بعد از دو یا چند تبدیل روی صفحه می‌نشیند. TableCollectObjectRulings وقتی IncludeFormXObjects ست باشد به داخل شیءهای form بازگشت می‌کند: ماتریس شیء را می‌خواند، آن را از طریق TableMultiplyMatrix با ماتریس والد ترکیب می‌کند — که ترتیب آرگومان‌هایش یعنی «اول از ماتریس اول بگذر، بعد دومی» — و فرزندان را با FPDFFormObj_CountObjects و FPDFFormObj_GetObject می‌شمارد و ماتریس ترکیب‌شده را پایین می‌فرستد. تودرتویی عمیق‌تر از MaxFormDepth (8) بی‌صدا رد می‌شود، که یک نگهبان در برابر فایل‌های بیمارگونه است نه محدودیتی که هیچ export واقعی به آن نزدیک شود. دلیل اهمیت ترتیب ضرب همانی است که در prepend در برابر append در ماتریس بحث شد: عوض کردن عملوندها جملهٔ انتقال را جابه‌جا می‌کند، و خطی که باید بالای صفحه بنشیند به‌جایش روی مبدأ می‌افتد

نمودار کامپوننت PDFium از خطوط جدول داخل یک form XObject در Delphi: یک مستطیل نازک که به‌صورت 72 700 468 0.5 re f نوشته شده در فضای form زندگی می‌کند و فقط بعد از آن روی صفحه می‌نشیند که TableMultiplyMatrix ماتریس CTM والد را با ماتریس form ترکیب می‌کند، با بازگشت از طریق FPDFFormObj_CountObjects تا MaxFormDepth
این مستطیل در فضای form نوشته شده و فقط بعد از آن به بالای صفحه می‌رسد که ماتریس‌ها با ترتیبی ضرب شوند که جملهٔ انتقال را سر جای خودش نگه دارد

چرا بودجهٔ خطوط جدول چهار برابر شد؟

مقدار پیش‌فرض MaxRulingSegments در 3.117.0 از 4096 به 16384 بالا رفت چون حاشیه‌های هر-سلول با تعداد بسیار بیشتری از خطوط شبکهٔ stroked می‌رسند. یک جدول stroked با 30 سطر و 6 ستون 38 سگمنت خط است. همان جدول اگر به‌صورت جعبه‌های پرشده export شود تا چهار حاشیه به ازای هر سلول است، یعنی 720 قطعه قبل از merge، و یک فرم با سلول‌های سایه‌زده این را دو برابر می‌کند. دو جدول از این نوع در یک صفحه بودجهٔ قدیمی را ته می‌کرد. این بودجه در TableAppendRuling از طریق Check تحمیل می‌شود، که EPdfError را با پیام "Table ruling-segment budget exceeded" بالا می‌آورد؛ هیچ نتیجهٔ تنزل‌کرده‌ای وجود ندارد، هیچ شبکهٔ ناقصی، و پاس فضای سفید هم اجرا نمی‌شود. اگر خودت بودجهٔ تنگ‌تری برای ورودی غیرقابل‌اعتماد می‌گذاری، استثنا را بگیر و تصمیم بگیر، به‌جای اینکه یک نتیجهٔ خالی را «جدولی نیست» بخوانی:

Options := TPdfTableExtractionOptions.Default;
Options.MaxRulingSegments := 2048;        // عمداً تنگ برای ورودی غیرقابل‌اعتماد
try
  Tables := Pdf.ExtractTables(Options);
except
  on E: EPdfError do
  begin
    Log(E.Message);                       // 'Table ruling-segment budget exceeded'
    Options.MaxRulingSegments := 16384;   // پیش‌فرض 3.117.0
    Tables := Pdf.ExtractTables(Options);
  end;
end;

نتایج اندازه‌گیری‌شده و جایی که این رویکرد می‌ایستد

روی همان 13 سند نمونه، استخراج از 43 جدول — که 9 تایشان خط‌دار و 34 تایشان قطعه‌های فضای سفید یا مثبت کاذب بودند — به 41 جدول خط‌دار و صفر مثبت کاذب فضای سفید رسید. بخشی از این پاک‌سازی مال دو تغییر همراه در 3.117.0 است: کلمه‌هایی که یک شبکهٔ خط‌دار از قبل ادعایشان را کرده پیش از اجرای تشخیص فضای سفید حذف می‌شوند، پس یک جدول هرگز دوبار گزارش نمی‌شود، و یک مرز ستون در تشخیص فضای سفید حالا باید یک کریدور بدون متن در تمام سطرهایی که جدا می‌کند باشد، و همین چیزی است که جلوی امتیاز گرفتن پاراگراف‌های justified به‌عنوان جدول‌های 5x4 را گرفت. خوانندهٔ مستطیل پرشده همان چیزی است که خود جدول‌ها را از ستون قطعه به ستون خط‌دار منتقل کرد

ارزش دارد مرزها را صریح بگوییم. صفحه‌ای بدون لایهٔ متنی باز هم اسکلت شبکه را می‌دهد، با هر سلول خالی، چون خطوط جدول از هندسه می‌آیند و متن از صفحهٔ متنی؛ صفحه‌های اسکن‌شده اول به OCR نیاز دارند. شکل‌های پرشده با منحنی یا گوشه‌های گرد یا طرح‌های غیرمستطیلی کاملاً دور انداخته می‌شوند، پس جدولی که حاشیه‌هایش به‌صورت طرح‌های مستطیل-گرد کشیده شده مثل قبل به تشخیص فضای سفید نیاز دارد. جدولی که نه حاشیه دارد و نه سایه‌زنی با هیچ‌کدام از این‌ها عوض نمی‌شود و همچنان قلمرو استراتژی فضای سفید است که در مقالهٔ استخراج جدول توصیف شده؛ وقتی حتی آن هم کافی نباشد، جعبه‌های کلمه و بلوک‌های متن ساخت‌یافته و ترتیب خواندن مادهٔ خام یک خوانندهٔ دامنه‌محورند. دموی TableExtractionLab که همراه کامپوننت می‌آید DetectFilledRulings را در پنل optionهایش عرضه می‌کند، که سریع‌ترین راه برای دیدن این است که یک export مشخص با آن و بدون آن چه شکلی است؛ API کامل در صفحهٔ کامپوننت PDFium برای Delphi توصیف شده