مقاله فنی

تخت کردن چرخش صفحهٔ PDF بدون شکستن جعبه‌ها در Delphi

HotPDF چرخش صفحهٔ PDF را با THotPDF.FlattenLoadedPageRotation تخت می‌کند: این متد محتوای هر صفحهٔ چرخیده را در یک transform از جنس cm ساعتگرد می‌پیچد، هر page box ای که صفحه واقعاً دارد را بازنویسی می‌کند، هندسهٔ حاشیه‌نویسی‌ها و ماتریس‌های appearance و مقصدهای صریح و هندسهٔ ساختار tag شده را با همان زاویه می‌چرخاند و بعد /Rotate را روی 0 می‌گذارد. صفحه در viewer عین همان به نظر می‌رسد، اما دستگاه مختصاتش حالا راست است. همان لحظه‌ای مهم می‌شود که یک ابزار پایین‌دستی، یک print RIP یا کد مهر زدن خودت به /Rotate بی‌توجهی کند و چیزها را در user space خام جاگذاری کند

محرک معمولی یک اسکنر یا یک اپ کپچر موبایلی است که صفحات افقی را به‌شکل رسانهٔ عمودی با /Rotate 90 می‌نویسد. هر viewer آن‌ها را درست نشان می‌دهد، پس هیچ‌کس متوجه نمی‌شود تا وقتی کسی شمارهٔ صفحه را در «پایین سمت راست» مهر بزند و به‌شکل خوابیده در طول لبهٔ چپ فرود بیاید، یا یک مرحلهٔ imposition که فقط /MediaBox را می‌خواند برای یک صفحهٔ افقی یک جایگاه عمودی بچیند. تخت کردن از دور یک کار ماتریسی یک‌خطی به نظر می‌رسد. در عمل به پنج page box و سه نوع هندسهٔ حاشیه‌نویسی و مقصدهای لینک سند و درخت ساختار دست می‌زند و هر کدام قاعدهٔ خودش را در ISO 32000-1 دارد

/Rotate صفحهٔ PDF را به کدام سمت می‌چرخاند؟

/Rotate صفحه را برای نمایش و چاپ ساعتگرد می‌چرخاند، در مضاربی از 90 درجه (ISO 32000-1 §7.7.3.3، جدول 30). در 90 درجه لبهٔ چپ رسانه تبدیل به بالا و لبهٔ بالا تبدیل به سمت راست می‌شود، پس در یک device space با y رو-به-پایین نگاشت چنین است: X = (y - Bottom) * Scale و Y = (x - Left) * Scale. در 270 درجه لبهٔ راست تبدیل به بالا می‌شود. /Rotate همچنین یکی از تنها چهار attribute صفحهٔ ارثی است، کنار /Resources و /MediaBox و /CropBox (§7.7.3.4)، پس dictionary صفحه‌ای که /Rotate خودش را ندارد همچنان می‌تواند توسط یک اجداد /Pages چرخانده شود. THotPDF.GetLoadedPageRotation زنجیرهٔ /Parent را می‌پیماید و نتیجه را در بازهٔ 0 تا 359 نرمال می‌کند، که همان مقداری است که می‌خواهی، نه کلید خام روی صفحه

جهت به‌سادگی به شکلی اشتباه گرفته می‌شود که از تست جان سالم به در می‌برد، و بیلدهای قبلی HotPDF دقیقاً همین را کردند. ماتریس قدیمی صفحه-به-دستگاه مؤلفه‌های y را برای 90 و 270 جابه‌جا می‌کرد، که به‌جای چرخش یک بازتاب روی قطر تولید می‌کند: جهت ماتریس نسبت به حالت نچرخیده برعکس می‌شود. هر دو زاویه همچنان «چرخیده» به نظر می‌رسند، بیت‌مپ عرض و ارتفاع جابه‌جا شده دارد، و یک رفت‌وبرگشت از صفحه به نما و برعکس نقطهٔ شروع را برمی‌گرداند، پس چک‌های ابعاد و تست‌های رفت‌وبرگشت همه سبز می‌شوند. تنها چک قابل‌اتکا این است که نشانگر گوشه کجا فرود می‌آید، مقایسه‌شده پیکسل‌به‌پیکسل با یک رندرکنندهٔ مرجع. چون مدل viewer و backend رندر SIMD و نگاشت هایلایت همان ماتریس را کپی کرده بودند، همه با هم اصلاح شدند و کد تخت کردن حالا از همان قرارداد ساعتگرد رندرکننده استفاده می‌کند

چگونه HotPDF چرخش صفحه را در Delphi تخت می‌کند: یک صفحهٔ عمودی ذخیره‌شده با /Rotate 90 به‌شکل ساعتگرد یک نمای افقی 792 در 612 نشان می‌دهد، نگاشت دستگاه یعنی X = (y - Bottom) * Scale و Y = (x - Left) * Scale هر گوشه را جابه‌جا می‌کند، و جابه‌جا کردن مؤلفه‌های y ماتریس یک بازتاب تولید می‌کند که فقط مقایسهٔ نشانگر گوشه آن را می‌گیرد
viewerها برای نمایش صفحه را ساعتگرد می‌چرخانند در حالی که بایت‌ها عمودی می‌مانند؛ GetLoadedPageRotation اول زنجیرهٔ /Parent را می‌پیماید، چون /Rotate یکی از چهار attribute ارثی صفحه است

FlattenLoadedPageRotation چطور یک صفحه را بازنویسی می‌کند

FlattenLoadedPageRotation(PageRange, Info) هر صفحه در PageRange را که چرخش مؤثرش 90 یا 180 یا 270 است پردازش می‌کند و تعداد صفحاتی را که تخت کرده برمی‌گرداند. یک PageRange خالی یعنی همهٔ صفحات؛ وگرنه رشته از سینتکس معمولی یک-مبنا یعنی '1-3,7' استفاده می‌کند و شمارهٔ صفحهٔ خارج از بازه به‌جای رد شدن exception بالا می‌دهد. content streamهای اصلی هرگز دوباره انکود نمی‌شوند. متد یک stream جدید شامل q 0 -1 1 0 -Bottom Width+Left cm (برای 90 درجه) را به ابتدای /Contents صفحه اضافه می‌کند، یک stream شامل Q را به انتها می‌چسباند و در نهایت یک /Rotate 0 صریح داخل dictionary صفحه می‌نویسد تا یک مقدار ارثی روی گرهٔ /Pages نتواند صفحه را بار دوم بچرخاند

var
  Pdf: THotPDF;
  Info: THPDFRotationFlattenInfo;
  Flattened: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('scanned-batch.pdf') > 0 then
    begin
      // '' یعنی همهٔ صفحات؛ صفحات در 0 درجه اسکن می‌شوند اما دست نخورده می‌مانند
      Flattened := Pdf.FlattenLoadedPageRotation('', Info);
      Writeln(Format('Scanned %d, flattened %d pages', [Info.ScannedPageCount, Info.FlattenedPageCount]));
      Writeln(Format('Turned %d annotations, %d destinations, %d tagged geometry entries',
        [Info.TransformedAnnotationCount, Info.TransformedDestinationCount,
         Info.TransformedStructureGeometryCount]));
      if Flattened > 0 then
        Pdf.SaveLoadedDocument('scanned-batch-upright.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

رکورد THPDFRotationFlattenInfo ارزش لاگ شدن دارد نه دور انداختن. ScannedPageCount اندازهٔ بازه است، FlattenedPageCount با مقدار برگشتی برابر است، و سه شمارندهٔ Transformed... به تو می‌گویند آیا سند لینک یا بوکمارک یا هندسهٔ tag شده‌ای داشته که به صفحات چرخیده اشاره می‌کرده. یک بسته که هر فایلش صفر مقصد گزارش می‌کند عادی است؛ یک فایل PDF/UA تگ‌شده که وقتی انتظار جعبه‌های محصورکنندهٔ figure را داری صفر هندسهٔ ساختار گزارش می‌کند سیگنالی است که باید دستی سراغش بروی

تخت کردن کدام page boxها را بازنویسی می‌کند و به چه ترتیبی؟

تخت کردن فقط جعبه‌هایی را بازنویسی می‌کند که صفحه از قبل دارد و هر جعبه را قبل از نوشتن هر کدام می‌خواند. ترتیب مهم است به خاطر زنجیرهٔ پیش‌فرض: GetLoadedPageBox(PageIndex, pbCropBox, ...) وقتی صفحه /CropBox ندارد برمی‌گرداند /MediaBox را، و /BleedBox و /TrimBox و /ArtBox به CropBox برمی‌گردند (§14.11.2). یک نسخهٔ قبلی واقعاً یک بار یک جعبه را می‌خواند و transform و می‌نوشت. اول MediaBox را بازنویسی می‌کرد، بعد «CropBox» را می‌خواند، همان MediaBox از قبل چرخیده را پس می‌گرفت، بار دوم می‌چرخاندش و یک CropBox می‌نوشت که صفحه هرگز نداشت، که یک صفحهٔ افقی را به یک مربع برش می‌داد. قواعد ارث هم همین‌طور تقسیم می‌شوند: MediaBox و CropBox در طول زنجیرهٔ /Parent جست‌وجو می‌شوند، در حالی که Bleed و Trim و ArtBox فقط وقتی حساب می‌شوند که روی خود dictionary صفحه نشسته باشند، پس یک /TrimBox سرگردان روی گرهٔ /Pages غایب تلقی می‌شود و هرگز روی صفحه کپی نمی‌شود

procedure DumpPageGeometry(Pdf: THotPDF; PageIndex: Integer);
var
  L, B, R, T: Single;
begin
  Writeln('Effective /Rotate: ', Pdf.GetLoadedPageRotation(PageIndex));
  if Pdf.GetLoadedPageBox(PageIndex, pbMediaBox, L, B, R, T) then
    Writeln(Format('MediaBox [%g %g %g %g]', [L, B, R, T]));
  // حتی بدون کلید /TrimBox هم True است: مقدار به CropBox و بعد MediaBox برمی‌گردد
  if Pdf.GetLoadedPageBox(PageIndex, pbTrimBox, L, B, R, T) then
    Writeln(Format('TrimBox  [%g %g %g %g]', [L, B, R, T]));
  // Letter از پیش تنظیم شده؛ GetLoadedPageVisibleBox در شکست خروجی‌ها را دست‌نخورده می‌گذارد
  L := 0; B := 0; R := 612; T := 792;
  Pdf.GetLoadedPageVisibleBox(PageIndex, L, B, R, T);
  Writeln(Format('Visible  [%g %g %g %g]', [L, B, R, T]));
end;

آن هلپر را قبل و بعد از تخت کردن اجرا کن و اعداد خودشان را توضیح می‌دهند. برای یک صفحهٔ 90 درجه با MediaBox برابر [0 0 612 792]، MediaBox تخت‌شده می‌شود [0 0 792 612]؛ هر جعبهٔ بازنویسی‌شده از همان چرخش ساعتگرد، نسبت به مبدأ MediaBox اصلی، عبور می‌کند، پس MediaBox جدید همیشه از مبدأ شروع می‌شود و بقیهٔ جعبه‌ها موقعیتشان داخل آن حفظ می‌شود. GetLoadedPageVisibleBox چیزی را برمی‌گرداند که viewerها نمایش می‌دهند و پرینترها چاپ می‌کنند، یعنی CropBox بریده‌شده به MediaBox و نرمال‌شده به‌طوری که Left کوچک‌تر از Right باشد، و رندرکننده و export از SVG و viewer و مسیر چاپ HotPDF همه از همان جعبه استفاده می‌کنند. وقتی اندازهٔ صفحه‌ای را می‌خواهی که آدم می‌بیند، GetLoadedPageVisibleBox را صدا بزن نه خواندن /MediaBox را

چرا HotPDF در FlattenLoadedPageRotation هر page box را قبل از نوشتن هر کدام می‌خواند: BleedBox و TrimBox و ArtBox به CropBox برمی‌گردند که خودش به MediaBox برمی‌گردد، پس چرخاندن جعبه‌ها یکی‌یکی باعث می‌شد CropBox همان MediaBox بازنویسی‌شده را بخواند و چرخش دوم جعبه‌ای را می‌نوشت که صفحه هرگز نداشت و یک صفحهٔ افقی را به مربع برش می‌داد
زنجیرهٔ پیش‌فرض یعنی خروجی یک جعبه ورودی جعبهٔ دیگری است؛ اول همه را بخوان، نسبت به مبدأ MediaBox اصلی transform کن، بعد بنویس

چرا وقتی فقط /Rect را می‌چرخانی حاشیه‌نویسی‌ها می‌شکنند؟

حاشیه‌نویسی‌ها می‌شکنند چون یک appearance stream مستقیم در /Rect کشیده نمی‌شود. طبق §12.5.5 اول viewer، /BBox فرم را با /Matrix اش transform می‌کند، بعد جعبهٔ محصورکنندهٔ آن نتیجه را مقیاس و منتقل می‌کند تا در /Rect جا بگیرد. فقط /Rect را بچرخان و یک مهر 200 در 40 در یک جایگاه 40 در 200 فشرده می‌شود، ناخوانا و خوابیده. پس FlattenLoadedPageRotation چرخش ساعتگرد صفحه را از سمت راست روی /Matrix هر appearance ضرب می‌کند (برای 90 درجه، [0 -1 1 0 0 0] در قرارداد بردار-سطر)، روی هر سه appearance یعنی /N و /R و /D و هر وضعیتی داخلشان. یک appearance stream می‌تواند بین چند حاشیه‌نویسی یا وضعیت مشترک باشد، پس هر stream دقیقاً یک بار در هر فراخوانی چرخانده می‌شود. تنها حالت بدون جواب تمیز streamی است که بین صفحاتی با چرخش‌های متفاوت مشترک است؛ از اولین صفحه‌ای که به آن می‌رسد پیروی می‌کند

دو قاعدهٔ دیگر فیلدهای فرم و یادداشت‌های چسبان را سر جایشان نگه می‌دارند. درایهٔ /MK /R یک widget (§12.5.6.19) یک زاویهٔ پادساعتگرد است، پس زاویهٔ ساعتگرد صفحه از آن کم می‌شود، به پیمانهٔ 360؛ اگر این را رد کنی تولید دوبارهٔ appearance بعدی متن فیلد را در جهت اشتباه می‌کشد. حاشیه‌نویسی‌هایی که فلگ NoRotate دارند (موقعیت بیت 5، مقدار 16، §12.5.3) روی صفحهٔ چرخیده راست می‌مانند و دور گوشهٔ بالا-چپ /Rect شان می‌چرخند، پس تخت کردن عرض و ارتفاع و ظاهر ایستاده‌شان را نگه می‌دارد و فقط آن گوشه را به جایی می‌برد که چرخش تعیین می‌کند. فراتر از حاشیه‌نویسی‌ها، متد همچنین /QuadPoints و /Vertices و /L و /InkList را می‌چرخاند، مقصدهای صریحی را که صفحه را نام می‌برند بازنویسی می‌کند (نقطه‌های /XYZ، مستطیل‌های /FitR، و /FitH / /FitV که در 90 و 270 درجه جابه‌جا می‌شوند، §12.3.2.2)، و هندسهٔ tag شده مثل درایه‌های /BBox در attributeها را برای عناصر ساختاری که /Pg شان همان صفحه است transform می‌کند

چرا وقتی صفحهٔ HotPDF فقط با چرخاندن /Rect تخت می‌شود حاشیه‌نویسی‌ها می‌شکنند: یک مهر 200 در 40 در جایگاه 40 در 200 مقیاس می‌شود و ناخوانا می‌گردد، پس FlattenLoadedPageRotation چرخش ساعتگرد را از سمت راست روی /Matrix هر appearance در /N و /R و /D ضرب می‌کند، /MK /R پادساعتگرد را تنظیم می‌کند و حاشیه‌نویسی‌های NoRotate را روی گوشهٔ بالا-چپ شان می‌چرخاند
viewer جعبهٔ محصورکنندهٔ transform شده appearance را در /Rect جا می‌دهد، پس خود stream باید بچرخد؛ یک پاس به‌ازای هر appearance مشترک، دقیقاً یک بار در هر فراخوانی

تخت کردن چه چیزهایی را پوشش نمی‌دهد؟

تخت کردن یک بازنویسی هندسی از اشیای خودِ یک صفحه است و چند حالت آرام و بی‌سروصدا بیرون از آن می‌مانند

  • صفحاتی که چرخش مؤثرشان از قبل 0 است یا MediaBox شان غایب است یا عرض یا ارتفاع صفر دارد، بدون خطا رد می‌شوند؛ مقدار برگشتی را با تعداد صفحاتی که انتظار تغییرشان را داشتی مقایسه کن
  • Form XObjectهایی که از منابع صفحه ارجاع می‌شوند /BBox خودشان را در form space نگه می‌دارند، چون cm بیرونی از قبل آن‌ها را می‌چرخاند؛ اسکن درخت ساختار فقط /K و /A را دنبال می‌کند پس هرگز بار دوم وارد منابع صفحه یا حاشیه‌نویسی‌ها نمی‌شود
  • مقصدها با اسکن کردن هر شیء غیرمستقیم به‌ازای هر صفحهٔ تخت‌شده پیدا می‌شوند، پس سند بزرگی با صدها صفحهٔ چرخیده بهای آن پیمایش را به‌ازای هر صفحه می‌پردازد
  • رندرکنندهٔ صفحهٔ HotPDF حاشیه‌نویسی‌ها را نمی‌کشد، پس یک بررسی بصری از مهرهای چرخیده اول FlattenLoadedAnnotations لازم دارد
// appearanceها را داخل محتوا بپز تا رندرکننده بتواند نشانشان بدهد،
// بعد صفحهٔ 1 را قبل و بعد از حذف /Rotate اش رندر کن
Pdf.FlattenLoadedAnnotations('1');
Before := Pdf.RenderLoadedPageToBitmap(0, 96);
try
  Pdf.FlattenLoadedPageRotation('1', Info);
  After := Pdf.RenderLoadedPageToBitmap(0, 96);
  try
    Assert((Before.Width = After.Width) and (Before.Height = After.Height));
    // اینجا پیکسل‌های نشانگر گوشه را مقایسه کن، نه فقط ابعاد را
  finally
    After.Free;
  end;
finally
  Before.Free;
end;

برای پیش‌زمینهٔ بیشتر، سمت حاشیه‌نویسی این داستان در ساختن appearance حاشیه‌نویسی قبل از تخت کردنش ادامه پیدا می‌کند، رندرکنندهٔ پشت مقایسهٔ قبل-و-بعد در رندر کردن یک صفحهٔ PDF بارگذاری‌شده به بیت‌مپ پوشش داده شده، و redaction و دوخت N-up روی PDFهای بارگذاری‌شده همان تکنیک اضافه کردن به content stream را نشان می‌دهد که پیشوند و پسوند چرخش به آن تکیه دارند. HotPDF، از جمله FlattenLoadedPageRotation و خواننده‌های page-box، برای Delphi و C++Builder در صفحهٔ HotPDF Delphi PDF component در دسترس است