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

Двойно завъртане на PDFium и грешки при Fit-Zoom в Delphi

Функцията FPDF_RenderPageBitmap на PDFium Component приема аргумент за завъртане, който PDFium винаги добавя към завъртането, записано в собствения запис /Rotate на страницата, затова прочитането на съхраненото завъртане и подаването на същата стойност обратно към извикването за рендиране завърта страницата два пъти. Същата грешка се появява и в изчисленията за Fit-Zoom: оразмеряването на миниатюра по незавъртените ширина и височина на страницата дава грешно съотношение, когато /Rotate е 90 или 270 градуса, защото полученият растер има разменени ширина и височина

Грешката се забелязва лесно, когато знаете какво да търсите, но дотогава лесно остава скрита. Пристига пакет сканирани фактури с портретни и пейзажни оригинали, някой изправя половината с завъртане на 90 градуса в Acrobat преди архивиране, а лентата с миниатюри в Delphi viewer, изграден върху PDFium, показва точно тези страници настрани, с главата надолу или свити в поле с неправилна ориентация. Не възниква изключение. Не се записва грешка. Пикселите просто са неправилни и то само за страниците, завъртени по-късно — точно такъв дефект преминава през пълна QA проверка с незавъртян тестов 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;

Защо оразмеряването с Fit-Zoom се поврежда по същия начин?

Оразмеряването с Fit-Zoom се поврежда по огледална причина: изчислението започва от грешната двойка числа, а не от грешния ъгъл. Обичайният подход за оразмеряване на поле за миниатюра извлича ширината и височината на страницата от PDFium, сравнява съотношението им със свободното поле и изчислява най-големия правоъгълник, който се побира в него — за незавъртяна страница това работи безпроблемно. Същото изчисление тихо се проваля при страница с /Rotate 90 или /Rotate 270, когато ширината и височината са получени от извикване, което връща вътрешния незавъртен размер: портретна страница A4 с /Rotate 90 все още отчита приблизително 595 на 842 точки, въпреки че PDFium правилно я рендира с приблизително 842 на 595 след прилагане на завъртането, а поле, изчислено от незавъртената двойка, се оказва изцяло с неправилна ориентация

FPDF_GetPageSizeByIndex е конкретен пример за извикване, което по замисъл връща този вътрешен незавъртен размер, което го прави удобно за преглеждане на размерите без зареждане на всяка страница, но рисковано за изчисленията на Fit-Zoom, ако не отчетете завъртането. Решението следва директно от формулировката на проблема: проверете завъртането на страницата преди аритметиката за напасване, разменете ширината и височината при 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 viewer на базата на PDFium продължава от мястото, на което правилното оразмеряване приключва

Как да откриете двойното завъртане, преди клиентът да го види

Двойното завъртане има един надежден визуален признак: страница, завъртяна на 90 градуса при входа, излиза завъртяна на 180 градуса спрямо останалата част от документа, а не на 90, защото допълнителното ro90 се наслагва върху собственото ro90 на страницата, вместо да го замени. Тестова конфигурация само със страници /Rotate 0 няма да улови проблема, тъй като ro0 плюс ro0 отново е ro0 и дефектът остава невидим; необходима е поне една страница, записана с /Rotate 90, и една с /Rotate 270, преди да се доверите на пътя за миниатюри или Fit-Zoom

Основният конвейер за превръщане на страници в растери, описан в рендиране на PDF страници в JPEG с PDFium Component, вече показва правилно завъртените страници без специален код именно защото оставя Rotation с подразбиращото се ro0 и позволява на PDFium сам да приложи /Rotate. Дефектът с двойното завъртане се появява едва когато кодът на приложението започне да чете PageRotation и да го подава на място, за което не е предназначен

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