Технічна стаття

Подвійне обертання та помилки масштабу «за розміром» у PDFium для Delphi

Функція FPDF_RenderPageBitmap компонента PDFium приймає аргумент обертання, який PDFium завжди додає поверх того обертання, яке сторінка вже несе у власному записі /Rotate, тож зчитування збереженого обертання сторінки й передача того самого значення назад у виклик рендера обертає сторінку двічі. Ідентична помилка проявляється в математиці масштабу «за розміром»: визначення розміру мініатюри з необерненої ширини й висоти сторінки дає неправильне співвідношення сторін щоразу, коли /Rotate дорівнює 90 чи 270 градусам, бо відрендерене растрове зображення виходить із поміняними місцями шириною та висотою

Збій легко помітити, щойно знаєш, що шукати, і легко пропустити, доки не знаєш. Пакет сканованих рахунків-фактур прибуває із сумішшю портретних і альбомних оригіналів, хтось випрямляє половину з них обертанням на 90 градусів в Acrobat перед архівуванням, і смуга мініатюр у переглядачі Delphi, побудованому на PDFium, рендерить саме ці сторінки боком, догори дриґом чи стисненими в коробку, сформовану для неправильної орієнтації. Ніщо не піднімає виняток. Ніщо не логує помилку. Пікселі просто неправильні, і лише для підмножини сторінок, які хтось обернув постфактум, — саме той тип помилки, що переживає повний прохід контролю якості проти необерненого тестового PDF, а потім проявляється в продакшені на сторінці 47 справжнього

Чому PDFium обертає сторінку двічі?

PDFium автоматично застосовує власне значення /Rotate сторінки щоразу, коли рендерить растрове зображення, незалежно від того, що передано рендереру. Параметр обертання FPDF_RenderPageBitmap, відкритий у PDFiumPas як значення TRotation ro0, ro90, ro180 та ro270 у TPdf.RenderPage, TPdf.RenderTile та TPdf.RenderPageThumbnail, не встановлює кут, у якому сторінка має опинитися; параметр обертання встановлює, скільки додаткового обертання накласти поверх того, що вже вказує словник сторінки, і саме тому кожен із цих методів встановлює його типово в ro0

TPdf.PageRotation читає те саме значення /Rotate через FPDFPage_GetRotation, і коду застосунку воно часто потрібне з причин, що не мають нічого спільного з рендерингом, наприклад для вирішення, як розмістити анотацію в просторі сторінки. Пастка — один рядок: передача PageRotation в аргумент Rotation методу RenderPage, очікуючи, що виклик нормалізує сторінку до вертикального положення. Сторінка, вже збережена з /Rotate 90, відображається коректно, оберненою, у будь-якому переглядачі, що відповідає специфікації, включно з PDFium; додайте ro90 знову поверх цього, і сторінка розвернеться на 180 градусів замість задуманих 90, тоді як сторінка взагалі без обертання отримає небажаний поворот на чверть без жодної причини

// Wrong: PageRotation already reflects /Rotate, and PDFium applies
// it automatically on every render -- passing it again as Rotation
// doubles the angle
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, Pdf.PageRotation, []);

// Right: leave Rotation at its ro0 default and let PDFium apply the
// page's own /Rotate exactly once
Bitmap := Pdf.RenderPage(0, 0, TargetW, TargetH, ro0, []);

Для чого насправді призначений параметр Rotation

Параметр Rotation заслуговує на своє місце в API заради справді іншого завдання: додавання обертання лише для перегляду, що не має нічого спільного зі збереженою орієнтацією сторінки, того типу, який кнопка панелі інструментів «обернути перегляд» застосовує, не торкаючись базового файлу. TPdfView саме з цієї причини тримає два поняття як дві окремі властивості. TPdfView.PageRotation віддзеркалює власне /Rotate сторінки й, через FPDFPage_SetRotation, може записати нове значення назад у документ; TPdfView.Rotation — тимчасова властивість лише для перегляду, що типово ro0 і ніколи не торкається файлу. Читання першої властивості й запис її в другу — уся ця помилка в одному реченні

// View-only: rotates what the user sees, changes nothing in the file
procedure TViewerForm.RotateViewClick(Sender: TObject);
begin
  case PdfView.Rotation of
    ro0:   PdfView.Rotation := ro90;
    ro90:  PdfView.Rotation := ro180;
    ro180: PdfView.Rotation := ro270;
    ro270: PdfView.Rotation := ro0;
  end;
end;

// Persistent: rewrites the page's own /Rotate entry in the document
procedure TViewerForm.RotatePageClick(Sender: TObject);
begin
  case PdfView.PageRotation of
    ro0:   PdfView.PageRotation := ro90;
    ro90:  PdfView.PageRotation := ro180;
    ro180: PdfView.PageRotation := ro270;
    ro270: PdfView.PageRotation := ro0;
  end;
end;

Чому визначення розміру масштабу «за розміром» ламається так само?

Визначення розміру масштабу «за розміром» ламається з дзеркально протилежної причини: обчислення починається не з тих чисел, а не з неправильного кута. Типовий спосіб визначити розмір коробки мініатюри запитує в PDFium ширину й висоту сторінки, порівнює це співвідношення сторін із доступною коробкою й обчислює найбільший прямокутник, що в неї вписується, — що чисто працює для необерненої сторінки. Те саме обчислення тихо провалюється для сторінки з /Rotate 90 чи /Rotate 270, коли ширина й висота надійшли з виклику, що повідомляє власний, необернений розмір сторінки: сторінка A4 у портретній орієнтації з /Rotate 90 усе одно повідомляє приблизно 595 на 842 точки, хоча PDFium рендерить її, коректно, приблизно 842 на 595, щойно обертання набуває чинності, і коробка «за розміром», обчислена з необерненої пари, опиняється сформованою для геть неправильної орієнтації

FPDF_GetPageSizeByIndex — один конкретний приклад виклику, що за задумом повідомляє цей власний, необернений розмір, що робить його зручним для сканування розмірів сторінок без завантаження кожної сторінки й ризикованим для математики масштабу «за розміром», що забуває це врахувати. Виправлення прямо випливає з називання проблеми: перевірте обертання сторінки перед виконанням арифметики підгонки, поміняйте місцями ширину й висоту щоразу, коли це обертання 90 чи 270 градусів, обчисліть коробку підгонки з поміняної пари, і все одно передайте ro0 у сам виклик рендера, бо саме PDFium залишається тим, хто застосовує справжнє обертання

Правильні мініатюри без повторного винаходу математики підгонки

TPdf.RenderPageThumbnail уже несе це виправлення, тож найкоротший шлях до коректної мініатюри — викликати його, а не збирати логіку підгонки-й-обертання вручну заново. Отримавши 1-базований індекс сторінки та максимальну ширину й висоту, RenderPageThumbnail обчислює коробку підгонки, коригує її для /Rotate 90 чи 270 внутрішньо, і повертає растрове зображення, яким володіє викликач, не турбуючи поточну сторінку документа й не спричиняючи подію OnPageChange, що важить для смуги мініатюр, побудованої поряд із живим переглядачем на тому самому екземплярі TPdf

// PageW, PageH are a page's own (unrotated) dimensions in points, for
// example from FPDF_GetPageSizeByIndex, which reports size before
// /Rotate is applied
function FitBox(PageW, PageH: Double; Rotation: TRotation;
  MaxW, MaxH: Integer; out FitW, FitH: Integer): Boolean;
var
  PgW, PgH, Swap: Integer;
begin
  PgW := Round(PageW);
  PgH := Round(PageH);
  if PgW < 1 then PgW := 1;
  if PgH < 1 then PgH := 1;

  if Rotation in [ro90, ro270] then
  begin
    Swap := PgW;
    PgW := PgH;
    PgH := Swap;
  end;

  Result := (MaxW > 0) and (MaxH > 0);
  if not Result then
    Exit;

  if PgW * MaxH > PgH * MaxW then
  begin
    FitW := MaxW;
    FitH := (MaxW * PgH) div PgW;
  end
  else
  begin
    FitH := MaxH;
    FitW := (MaxH * PgW) div PgH;
  end;
end;

Допоміжний метод FitBox все одно варто тримати напохваті, бо RenderPageThumbnail покриває лише випадок одного растрового зображення. Власна сітка мініатюр, смуга попереднього перегляду друку чи діалог вибору сторінок, що розкладає кілька сторінок проти незалежних коробок, потребує тієї самої, обізнаної про обертання, математики підгонки, не обов'язково бажаючи свіже растрове зображення для кожної плитки, а власні режими масштабування «за сторінкою» та «за шириною» в TPdfView внутрішньо спираються на ту саму ідею, обираючи між шириною й висотою сторінки для обчислення коефіцієнта масштабу на основі поточного обертання перегляду перед порівнянням із доступною клієнтською областю. Якщо продуктивність масштабування й прокрутки в такому переглядачі — наступна проблема в списку, супутня стаття про кешування рендера та плавне масштабування в переглядачі Delphi на основі PDFium продовжує саме там, де закінчується коректне визначення розміру

Виявлення подвійного обертання ще до того, як це зробить клієнт

Подвійне обертання має одну надійну візуальну ознаку: сторінка, обернена на 90 градусів на вході, виходить із виглядом обернутої на 180 відносно решти документа, не на 90, бо додаткове ro90 наклалося поверх власного ro90 сторінки замість того, щоб його замінити. Тестовий набір, побудований лише зі сторінок /Rotate 0, ніколи цього не спіймає, оскільки додавання ro0 до ro0 усе одно ro0, і помилка залишається невидимою; тестовому набору потрібна принаймні одна сторінка, збережена з /Rotate 90, та одна з /Rotate 270, перш ніж шляху коду мініатюр чи масштабу «за розміром» можна довіряти

Базовий конвеєр сторінка-в-растрове-зображення, розглянутий у статті рендеринг сторінок PDF у JPEG за допомогою компонента PDFium, уже коректно рендерить обернені сторінки без жодного спеціального коду, саме тому, що залишає Rotation на типовому ro0 і дозволяє PDFium самому застосовувати /Rotate. Помилка подвійного обертання з'являється лише тоді, коли код застосунку починає зчитувати PageRotation назад і подавати його туди, де йому не місце

Обізнані про обертання виклики рендера та визначення розміру мініатюр, описані тут, — частина компонента PDFium для Delphi та C++Builder, поряд з рештою API рендерингу, перегляду та вилучення тексту, побудованих на тих самих класах TPdf і TPdfView