Техническая статья

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

Функция FPDF_RenderPageBitmap в компоненте PDFium принимает аргумент rotate, который PDFium всегда добавляет поверх того поворота, что страница уже несёт в собственной записи /Rotate, так что чтение сохранённого поворота страницы и передача того же значения обратно в вызов рендеринга поворачивает страницу дважды. Точно такая же ошибка проявляется в арифметике масштаба «по размеру»: расчёт размера миниатюры из неповёрнутых ширины и высоты страницы даёт неверное соотношение сторон всякий раз, когда /Rotate равен 90 или 270 градусов, потому что отрисованное растровое изображение выходит с переставленными местами шириной и высотой

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

Почему PDFium поворачивает страницу дважды?

PDFium применяет собственное значение /Rotate страницы автоматически при каждой отрисовке растрового изображения, независимо от того, что передаётся в рендерер. Параметр rotate у FPDF_RenderPageBitmap, предоставляемый в PDFiumPas как значения TRotation ro0, ro90, ro180 и ro270 у TPdf.RenderPage, TPdf.RenderTile и TPdf.RenderPageThumbnail, не задаёт угол, к которому страница должна прийти в итоге; параметр rotate задаёт, сколько дополнительного поворота наложить поверх того, что уже указывает словарь страницы, поэтому каждый из этих методов по умолчанию устанавливает его в 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 уже несёт это исправление, так что кратчайший путь к корректной миниатюре — вызвать его, а не пересобирать логику подгонки и поворота вручную. Получив индекс страницы с отсчётом от единицы и максимальные ширину и высоту, 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