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

FlattenLoadedPageRotation: выпрямляем поворот страницы PDF

HotPDF выпрямляет поворот страниц PDF методом THotPDF.FlattenLoadedPageRotation: метод оборачивает содержимое каждой повёрнутой страницы в clockwise-трансформацию cm, переписывает каждый бокс страницы, который у неё реально есть, поворачивает геометрию аннотаций, матрицы appearance, явные destination и геометрию помеченной структуры на тот же угол, а затем выставляет /Rotate в 0. Страница выглядит во вьювере идентично, но её система координат теперь прямая. Это важно в тот момент, когда нисходящий инструмент, печатный RIP или ваш собственный штамповочный код игнорирует /Rotate и размещает вещи в сыром user space

Типичный триггер — сканер или мобильное приложение захвата, пишущие ландшафтные страницы как портретный медиум с /Rotate 90. Каждый вьювер показывает их правильно, поэтому никто ничего не замечает, пока кто-то не проштампует номер страницы в «правом нижнем углу» и тот не приземлится боком вдоль левого края, или пока шаг спуска полос, читающий только /MediaBox, не разложит портретный слот под ландшафтную страницу. Сплющивание звучит как матричная работа на одну строчку. На практике оно трогает пять боксов страницы, три сорта аннотационной геометрии, линковые цели документа и дерево структуры, и у каждого из них собственное правило в ISO 32000-1

Куда /Rotate поворачивает страницу PDF?

/Rotate поворачивает страницу по часовой для показа и печати, кратно 90 градусам (ISO 32000-1 §7.7.3.3, Table 30). При 90 градусах левая грань медиума становится верхом, а верхняя грань — правой стороной, так что в y-down device space отображение — это X = (y - Bottom) * Scale и Y = (x - Left) * Scale. При 270 градусах правая грань становится верхом. /Rotate — также один из всего четырёх наследуемых атрибутов страницы, рядом с /Resources, /MediaBox и /CropBox (§7.7.3.4), так что словарь страницы без собственного /Rotate может быть повёрнут предком /Pages. THotPDF.GetLoadedPageRotation идёт по цепочке /Parent и нормализует результат в 0–359 — это и есть нужное вам значение, а не сырой ключ на странице

Направление легко запороть так, что это переживёт тестирование, и прежние сборки HotPDF делали ровно это. Старая матрица page-to-device меняла местами y-компоненты для 90 и 270, что даёт отражение через диагональ вместо поворота: ориентация матрицы переворачивается относительно неповернутого случая. Оба угла всё ещё «выглядят повёрнутыми», битмап имеет переставленные ширину и высоту, а round trip со страницы в вид и обратно возвращает стартовую точку, так что проверки размеров и round-trip тесты все зелёные. Единственная надёжная проверка — куда приземляется угловой маркер, сверенный попиксельно с эталонным рендерером. Поскольку модель вьювера, SIMD render backend и маппинг подсветки скопировали ту же матрицу, все они были исправлены разом, и код сплющивания теперь использует то же clockwise-соглашение, что и рендерер

Как HotPDF сплющивает поворот страницы в Delphi: портретная страница, хранимая с /Rotate 90, показывается по часовой как ландшафтный вид 792 на 612, отображение в устройство X = (y - Bottom) * Scale, Y = (x - Left) * Scale двигает каждый угол, а перестановка y-компонент матрицы даёт отражение, которое ловит только сравнение угловых маркеров
Вьюверы поворачивают страницу по часовой для показа, тогда как байты остаются портретными — GetLoadedPageRotation сперва идёт по цепочке /Parent, потому что /Rotate один из четырёх наследуемых атрибутов страницы

Как FlattenLoadedPageRotation переписывает страницу

FlattenLoadedPageRotation(PageRange, Info) обрабатывает каждую страницу из PageRange, чей эффективный поворот равен 90, 180 или 270, и возвращает число сплющенных страниц. Пустой PageRange значит все страницы; иначе строка использует привычный синтаксис с единицы '1-3,7', а номер страницы вне диапазона бросает исключение, а не пропускается. Исходные потоки содержимого никогда не перекодируются. Метод приклеивает спереди новый поток с q 0 -1 1 0 -Bottom Width+Left cm (для 90 градусов) к /Contents страницы, аппендит поток с Q и напоследок вписывает явный /Rotate 0 в словарь страницы, чтобы унаследованное значение на узле /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... говорят, были ли в документе ссылки, закладки или помеченная геометрия, таростоящие на повёрнутые страницы. Пакет, где каждый файл отчитывается нулём destination, — норм; файл PDF/UA с нулевой структурной геометрией, когда вы ждали ограничивающие рамки фигур, — сигнал осмотреть его руками

Какие боксы страницы переписывает сплющивание и в каком порядке?

Сплющивание переписывает только боксы, которые у страницы уже есть, и читает каждый бокс, прежде чем писать хоть один. Порядок важен из-за цепочки дефолтов: GetLoadedPageBox(PageIndex, pbCropBox, ...) возвращает /MediaBox, когда у страницы нет /CropBox, а /BleedBox, /TrimBox и /ArtBox дефолтят к CropBox (§14.11.2). Ранняя версия читала, трансформировала и писала по одному боксу за раз. Она сперва переписывала MediaBox, потом читала «CropBox», получала уже повёрнутый MediaBox, поворачивала его второй раз и писала CropBox, которого у страницы никогда не было, — и ландшафтная страница обрезалась до квадрата. Правила наследования делятся так же: MediaBox и CropBox ищутся по цепочке /Parent, тогда как Bleed, Trim и ArtBox засчитываются, только если сидят на самом словаре страницы, так что бродячий /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]));
  // True даже без ключа /TrimBox: значение откатывается к 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 возвращает то, что показывают вьюверы и печатают принтеры, — CropBox, обрезанный до MediaBox и нормализованный так, что Left меньше Right, — и рендерер HotPDF, SVG-экспорт, вьювер и печатный путь пользуются одним и тем же боксом. Когда вам нужен размер страницы, который видит человек, зовите GetLoadedPageVisibleBox, а не читайте /MediaBox

Почему HotPDF читает каждый бокс страницы до записи любого при FlattenLoadedPageRotation: BleedBox, TrimBox и ArtBox дефолтят к CropBox, который сам откатывается к MediaBox, поэтому поворот боксов по одному заставлял CropBox прочитать уже переписанный MediaBox, и второй поворот писал бокс, которого у страницы не было, обрезая ландшафтную страницу до квадрата
Цепочка дефолтов значит, что вывод одного бокса — вход другого: сперва прочитайте всё, трансформируйте от исходного начала MediaBox, потом пишите

Почему аннотации ломаются, если повернуть только /Rect?

Аннотации ломаются, потому что appearance-поток не рисуется прямо в /Rect. По §12.5.5 вьювер сперва трансформирует /BBox формы её /Matrix, затем масштабирует и транслирует ограничивающую рамку результата в /Rect. Поверните только /Rect — и штамп 200 × 40 сплющится в слот 40 × 200, нечитаемый и лежащий на боку. Поэтому FlattenLoadedPageRotation домножает справа clockwise-поворот страницы на каждый appearance-/Matrix (для 90 градусов — [0 -1 1 0 0 0] в соглашении вектор-строк) по всем appearance /N, /R и /D и каждому состоянию внутри них. Один appearance-поток может делиться несколькими аннотациями или состояниями, поэтому каждый поток поворачивается ровно один раз за вызов. Единственный случай без чистого ответа — поток, разделяемый страницами с разными поворотами; он следует за первой страницей, которая до него добралась

Ещё два правила удерживают поля форм и стикеры на местах. Запись /MK /R виджета (§12.5.6.19) — угол против часовой, поэтому clockwise-угол страницы из него вычитается по модулю 360; пропустите — и следующая регенерация appearance нарисует текст поля не в ту сторону. Аннотации с флагом NoRotate (бит 5, значение 16, §12.5.3) остаются прямыми на повёрнутой странице и вращаются вокруг верхнего левого угла своего /Rect, так что сплющивание хранит их ширину, высоту и прямой вид, двигая лишь угол туда, куда его ставит поворот. Помимо аннотаций, метод также поворачивает /QuadPoints, /Vertices, /L и /InkList, переписывает явные destination, называющие страницу (точки /XYZ, прямоугольники /FitR и поменянные местами на 90 и 270 градусах /FitH / /FitV, §12.3.2.2), и трансформирует помеченную геометрию вроде записей /BBox атрибутов у структурных элементов, чей /Pg — эта страница

Почему аннотации ломаются, когда страницу HotPDF сплющивают поворотом одного /Rect: штамп 200 на 40 масштабируется в слот 40 на 200 и становится нечитаемым, поэтому FlattenLoadedPageRotation домножает clockwise-поворот на каждый appearance /Matrix по /N, /R и /D, поправляет идущий против часовой /MK /R и вращает NoRotate-аннотации вокруг их верхнего левого угла
Вьювер втискивает трансформированный BBox appearance в /Rect, поэтому сам поток обязан повернуться — один проход на разделяемый appearance, ровно один раз за вызов

Чего сплющивание не покрывает?

Сплющивание — геометрическая перезапись собственных объектов одной страницы, и несколько ситуаций остаются за его бортом тихо, а не громко

  • Страницы с уже нулевым эффективным поворотом или с отсутствующим MediaBox либо нулевой шириной или высотой пропускаются без ошибки; сверьте возвращаемое значение с числом страниц, которые ждали изменить
  • Form XObject, на которые ссылаются ресурсы страницы, хранят собственный /BBox в form space, потому что внешний cm их уже поворачивает; скан дерева структуры следует только /K и /A, так что в ресурсы страницы или аннотации он второй раз не заходит
  • Destination находятся сканом каждого косвенного объекта по разу на каждую сплющенную страницу, так что большой документ с сотнями повёрнутых страниц платит за этот проход на каждой из них
  • Рендерер страниц 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 в битмап, а редактирование и N-up сшивка на загруженных PDF показывает ту же технику аппендинга к потоку содержимого, на которой держатся префикс и суффикс поворота. HotPDF, включая FlattenLoadedPageRotation и читатели боксов страницы, доступен для Delphi и C++Builder на странице компонента HotPDF Delphi PDF component