Аффинные матрицы PDF используют соглашение векторов-строк ISO 32000-1 §8.3.3, где точка умножается на матрицу слева: point' = point * M. В компоненте PDFium для Delphi и C++Builder этот единственный факт фиксирует всю поверхность API TPdfMatrix: Multiply добавляет, так что M := M * Op, а PreMultiply предваряет, так что M := Op * M
Каждый классический баг преобразования восходит к тому, что это предложение запомнили задом наперёд. Водяной знак, аккуратно поворачивающийся в вашем тестовом файле и оказывающийся наполовину за пределами страницы в клиентском файле. Миниатюра, выходящая повёрнутой дважды, потому что страница уже несла четверть оборота. Штамп, чьё смещение идеально на A4 и уплывает на Letter. Ни одно из этого не баги рендеринга; это баги порядка умножения, и все они исправимы, как только вы можете вслух сказать, в каком пространстве написана каждая операция
Соглашение о векторах-строках, задающее правила
TPdfMatrix хранит шесть именованных спецификацией элементов и применяет их точно так, как их определяет формат, так что само преобразование — это то, откуда начинается рассуждение. TPdfMatrix.TransformPoint вычисляет x' = x*a + y*c + e и y' = x*b + y*d + f, что и есть шестиэлементная форма, определённая ISO 32000-1 §8.3.4 для оператора cm, конкатенирующего матрицу на текущую матрицу преобразования. Пара (a, b) — первая строка, (c, d) — вторая, а (e, f) — строка переноса. Привычки к векторам-столбцам, приобретённые в OpenGL или на курсе линейной алгебры, введут вас здесь в заблуждение, и введут молча, потому что матрица неверного порядка всё равно остаётся совершенно валидной матрицей. Читайте композит в соглашении строк слева направо, и порядок применения выпадает бесплатно: поскольку point * (M * Op) равно (point * M) * Op, добавленная операция действует на координаты, уже произведённые существующей матрицей, то есть на пространство страницы, тогда как предварённая операция действует до того, как отработает существующая матрица, в собственном входном пространстве объекта
var
M: TPdfMatrix;
Pt: FS_POINTF;
begin
M := TPdfMatrix.Create; // identity
try
// Append order: each call acts on what the previous calls produced.
M.Scale(0.5, 0.5); // M := M * S half size
M.Rotate(90); // M := M * R clockwise, degrees
M.Translate(300, 400); // M := M * T then move on the page
Pt := M.TransformPoint(0, 0); // x*a + y*c + e, x*b + y*d + f
finally
M.Free;
end;
end;
TPdfMatrix.Rotate по умолчанию поворачивает по часовой стрелке и в градусах, с доступными ACounterClockwise и AAngleInRadians, когда ваши исходные данные размечены иначе. Свойства только для чтения a по f и свойство Handle отдают вам сырой FS_MATRIX обратно, что и нужно FPDFPageObj_SetMatrix. Ничто в классе не скрывает от вас шесть чисел, и это намеренно: когда преобразование ведёт себя неправильно, вывод a по f — самая быстрая диагностика, что у вас есть
Почему предварение переноса нуждается в линейной части?
Потому что предварённый сдвиг написан во входном пространстве матрицы, и его нужно пронести через текущую линейную часть, прежде чем он сможет присоединиться к строке переноса. TPdfMatrix.PreTranslate поэтому вычисляет e := dx*a + dy*c + e и f := dx*b + dy*d + f. Добавление — лёгкое направление: TPdfMatrix.Translate написан в пространстве страницы, где ничего не нужно конвертировать, так что он просто прибавляет dx к e и dy к f. Всякий, кто «оптимизирует» PreTranslate до двух сложений, только что удалил поворот и масштаб из сдвига
M := TPdfMatrix.Create;
try
M.Rotate(90); // a=0, b=-1, c=1, d=0
M.Translate(10, 0); // append: e := e + 10
// -> 10 points to the right on the page
M.Reset;
M.Rotate(90);
M.PreTranslate(10, 0); // prepend: e := 10*a + 0*c + e (unchanged)
// f := 10*b + 0*d + f (f - 10)
// -> 10 points along the stamp own x axis,
// which after the turn points down the page
finally
M.Free;
end;
Та же асимметрия проходит через пару масштабирования, и стоит знать, какие элементы каждая из них трогает, прежде чем отлаживать одну из них в три часа ночи. TPdfMatrix.PreScale умножает строки, масштабируя a и b на scaleX, а c и d на scaleY, и оставляет перенос в покое, потому что сдвиг уже произошёл ниже по потоку. Добавляющий TPdfMatrix.Scale вместо этого умножает столбцы, беря a, c, e на scaleX и b, d, f на scaleY, так что существующее смещение масштабируется вместе со всем остальным. Оба — однозадачные пути, пропускающие общее шестиэлементное произведение, и оба сохраняют семантику композиции общей формы в точности
Куда идут два переноса в повороте вокруг точки?
Вокруг операции, не вокруг всей матрицы, и в таком порядке. TPdfMatrix.RotateAt добавляет Translate(-pivot), затем поворот, затем Translate(+pivot), что под соглашением векторов-строк компонуется как Translate(-pivot) * Op * Translate(pivot). Эта последовательность и есть то, что держит точку опоры зафиксированной под новой операцией, всё ещё позволяя существующей матрице сначала произвести свои координаты и передать их дальше. Напишите пару в обратном порядке, как было бы верно в библиотеке векторов-столбцов, и объект вращается по орбите вокруг начала координат, а не вертится на месте, что именно и есть способ, которым центрированный водяной знак оказывается за пределами области обрезки
procedure RotateStampAboutPageCenter(AObj: FPDF_PAGEOBJECT;
const AAngleDegrees, APageWidth, APageHeight: Single);
var
M: TPdfMatrix;
Raw: FS_MATRIX;
begin
if not FPDFPageObj_GetMatrix(AObj, Raw) then
raise Exception.Create('Page object carries no matrix');
M := TPdfMatrix.Create(Raw);
try
// Appends Translate(-pivot) * Rotate * Translate(+pivot) in one call.
M.RotateAt(AAngleDegrees, APageWidth / 2, APageHeight / 2);
Raw := M.Handle;
FPDFPageObj_SetMatrix(AObj, Raw);
finally
M.Free;
end;
end;
Та же композиция стоит за ScaleAt, SkewAt, HorizontalFlipAt, VerticalFlipAt и CentralFlipAt, так что, доверяя паттерну для поворота, вы можете доверять ему и для остального. TPdfMatrix.CentralFlip стоит выделить отдельно: он отрицает все шесть элементов, чтобы дать вам поворот на 180 градусов вообще без тригонометрии, что значит никакого cos от значения, которое должно было быть ровно нулём, и никакого накапливающегося дрейфа при применении его в цикле. Если вы размещаете повторяющиеся метки, а не поворачиваете одну, механика самого размещения покрыта в переиспользуемых штампах страницы через Form XObject, и работа с матрицей здесь сидит прямо поверх этого
Что TryDecompose говорит вам о матрице?
TPdfMatrix.TryDecompose сообщает перенос, масштаб, поворот, сдвиг, детерминант и флаг отражения под соглашением «сначала масштаб, потом поворот», и сообщает их достаточно честно, чтобы быть полезными для решений, а не только для журналирования. ScaleX берётся из длины первой строки, Sqrt(a*a + b*b), так что он всегда положителен. ScaleY тогда — Determinant / ScaleX, что делает его знаковым. Поворот берётся из ArcTan2(-b, a) в градусах, а сдвиг — из скалярного произведения двух строк, нормализованного обоими масштабами
Этот знак у ScaleY — то, что люди удаляют, и удаление его — настоящий баг, а не косметика. Отрицательный детерминант означает, что матрица содержит отражение. Заставьте оба коэффициента масштаба быть положительными, чтобы числа выглядели опрятнее, и вы выбросили отражение, так что матрица, перестроенная из разложения, вернётся зеркальной: текст читается задом наперёд, отсканированная страница переворачивается, импортированный логотип смотрит не туда. Поле IsReflected существует, чтобы вам никогда не пришлось это выводить. Это также проверка, предотвращающая классический двойной поворот, где код добавляет разворот отображения к странице, уже несущей собственный; версия этой проблемы на стороне просмотрщика разобрана в подгонке миниатюры, масштабе и двойном повороте
var
D: TPdfMatrixDecomposition;
begin
if M.TryDecompose(D) then
begin
// D.ScaleX is always positive; D.ScaleY carries the determinant sign.
if D.IsReflected then
Log('mirrored, ScaleY = %.3f', [D.ScaleY]);
if Abs(D.RotationDegrees) > 0.5 then
SkipDisplayRotation; // the object already carries its own turn
end
else
UseIdentityFallback; // near-singular or non-finite: no answer
end;
Вписывание одного прямоугольника в другой без угадывания
TPdfMatrix.TryCreateRectMapping строит матрицу источник-в-назначение за вас и принимает TPdfMatrixFitMode из pmfStretch, pmfContain или pmfCover. Он сначала нормализует оба прямоугольника, потому что от прямоугольников PDF не требуется приходить с левым ниже правого или нижним ниже верхнего, затем выводит независимые масштабы X и Y: pmfStretch держит их независимыми, pmfContain берёт меньший и центрирует леттербокс, pmfCover берёт больший и центрирует обрезку. Сопутствующий MapRectToRect добавляет то же отображение на существующую матрицу, а NewRectMapping выбрасывает EPdfMatrixError там, где форма Try возвращает False. Это примитив, лежащий под каждым размещением ячейки в N-up-спуске полос и переупорядочивании страниц, где каждая исходная страница обязана попасть внутрь вычисленной ячейки без повторного вывода арифметики на каждую разметку
Вырожденные матрицы и честный путь отказа
Конечные входы не гарантируют конечный результат, так что код подгонки вычисляет в Double, а затем перепроверяет суженного Single-кандидата на конечность, прежде чем его опубликовать; отображение, содержащее бесконечность, никогда не отдаётся обратно, как если бы оно было валидным. Та же дисциплина управляет обращением. TPdfMatrix.TryGetInverse отвергает матрицу, используя относительный порог, сравнивая детерминант с эпсилон, умноженным на квадрат наибольшего линейного элемента, а не с фиксированной константой, что и держит тест осмысленным, независимо от того, точки ваши единицы или микрометры. TryDecompose выходит тем же путём, отказываясь, когда длина первой строки или выведенный ScaleY падает на эпсилон или ниже
Выбирайте стиль отказа, подходящий точке вызова, а не оборачивайте всё в try-except по привычке. TryInvert, TryGetInverse, TryInverseTransformPoint, TryTransformBounds и TryCreateRectMapping возвращают False и оставляют свои цели нетронутыми, что подходит для тестирования попадания и поячеечных циклов, где вырожденный объект следует пропустить, а не считать фатальным. Invert, InverseCopy, InverseTransformPoint, MapRectToRect и TransformBounds вместо этого выбрасывают EPdfMatrixError, что подходит для настроечного кода, где сингулярная матрица означает, что вызывающая сторона вычислила что-то неверно. Для пакетной работы TransformPoints и TransformRects выделяют свой результирующий массив ровно один раз, TransformPointsInPlace и TransformRectsInPlace переиспользуют ваше хранилище, а TryTransformBounds накапливает ограничивающий прямоугольник за один проход вместо материализации трансформированных точек сначала
Ничто из этого не экзотическая математика. Это одно соглашение, применённое последовательно, с API, названным так, что соглашение видно в точке вызова: Multiply и обычные глаголы добавляют, семейство Pre предваряет, семейство At заключает операцию в скобки её парой опоры. Запишите порядок в комментарии рядом с любым композитом, что вы строите, потому что код, читающийся верно сегодня, — код, который кто-то развернёт через полгода. Полный справочник TPdfMatrix, наряду с API объектов страницы и рендеринга, которые эти преобразования питают, живёт с PDFium Component для Delphi и C++Builder