Вызов, помещающий текст на страницу PDF, прост. Вы передаёте AddText строку, шрифт, размер и позицию, и глифы появляются. Что он не делает, так это не сообщает, какой ширины будет эта строка после отрисовки, и не разбивает длинную строку на несколько строк. Один вызов рисует один фрагмент текста в одной позиции. Если фрагмент шире предназначенного для него столбца, он просто выходит за край, и вызов отрисовки никак не предупреждает об этом. Как только вам нужен абзац, а не одна метка, недостающий элемент - это ширина строки в выбранном шрифте и размере, измеренная до того, как она зафиксирована на странице
Это классическая задача вёрстки. Чтобы перенести абзац в столбец, нужно знать, слово за словом, сколько горизонтального пространства займёт каждая пробная строка, и знать это нужно до начала отрисовки. Перенос слов - это цикл измерения, обёртывающий вызов отрисовки, а привязка, которая только рисует, даёт вам лишь вторую половину. Поддержка измерения текста в компоненте PDFium закрывает этот пробел двумя функциями, MeasureText и MeasureTextWidth, которые сообщают отрисованный размер строки без нанесения каких-либо отметок на страницу
Почему измерение - это class helper, а не новый метод TPdf
Поддержка измерения реализована как Delphi class helper для TPdf, находящийся в собственном модуле, а не как новые методы, добавленные в класс TPdf. Class helper - это языковая конструкция, позволяющая прикреплять методы к существующему типу извне его объявления. Как только модуль находится в области видимости, новые методы вызываются точно так же, как если бы они принадлежали классу, поэтому метод helper читается как Pdf.MeasureTextWidth(...) без отдельного объекта для создания или передачи
Причина такого подхода - разделение. Тип ядра TPdf остаётся как есть, без добавления полей и без изменения существующих сигнатур, поэтому проект, которому никогда не нужна вёрстка, никогда не несёт код измерения. Проект, которому он нужен, добавляет один модуль в предложение uses, и методы становятся доступны. Функциональность становится опциональной с гранулярностью одного модуля, что является самым чистым способом расширить тип, которым вы не владеете или который не хотите трогать
uses
PDFium, FPdfView, FPdfEdit,
FPdfMeasure; // the helper unit; brings MeasureText into scope on TPdf
// With the unit in scope the methods read as members of TPdf:
var
W, H: Double;
begin
Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
// W and H are now the rendered width and height in PDF user units
end;
Измерение без воздействия на страницу
Измерение должно быть свободным от побочных эффектов. Оно должно сообщать ширину, не оставляя ничего позади, потому что вы вызываете его многократно при решении задачи вёрстки, и страница должна выглядеть точно так же, как если бы вы никогда не измеряли. Техника, делающая это возможным, состоит в том, чтобы создать текстовый объект, запросить его размер и выбросить до того, как он когда-либо будет прикреплён к странице
Последовательность состоит из четырёх вызовов PDFium. FPDFPageObj_NewTextObj создаёт текстовый объект для документа, используя имя шрифта и размер. FPDFText_SetText задаёт строку, которую несёт этот объект. FPDFPageObj_GetBounds считывает ограничивающий прямоугольник объекта. FPDFPageObj_Destroy освобождает объект. Принципиально важно, что ничто в этой последовательности не вызывает API вставки на страницу. Объект создаётся, запрашивается и уничтожается изолированно, поэтому документ не изменяется, когда функция возвращает управление. Это одноразовый зонд, единственный вывод которого - четыре числа его ограничивающего прямоугольника
Это надёжный способ сделать это, потому что PDFium не предоставляет удобной ширины продвижения на гляф, которую вы могли бы суммировать самостоятельно. Метрики глифов зависят от программы шрифта, от кодировки и от того, как PDFium загружает начертание, и нет публичного вызова, который выдаёт продвижение каждого символа в строке. Ограничивающий прямоугольник реального текстового объекта, с другой стороны, вычисляется той же машиной, которая расставляла бы глифы для отрисовки, поэтому он отражает фактический отрисованный размер, а не приближение. Создание одного одноразового объекта и чтение его границ - наиболее надёжное измерение, которое может дать библиотека
// The shape of MeasureText, expressed against the verified PDFium calls.
// A text object is built, measured, and destroyed; no page is involved.
procedure TPdfMeasureHelper.MeasureText(const Text, Font: WString;
FontSize: Single; out Width, Height: Double);
var
TextObject: FPDF_PAGEOBJECT;
L, B, R, T: Single;
begin
Width := 0;
Height := 0;
if Self.Document = nil then
Exit;
TextObject := FPDFPageObj_NewTextObj(Self.Document,
FPDF_BYTESTRING(AnsiString(Font)), FontSize);
if TextObject = nil then
Exit;
try
if FPDFText_SetText(TextObject, FPDF_WIDESTRING(WideString(Text))) = 0 then
Exit;
if FPDFPageObj_GetBounds(TextObject, L, B, R, T) <> 0 then
begin
Width := R - L;
Height := T - B;
end;
finally
FPDFPageObj_Destroy(TextObject); // probe discarded, page untouched
end;
end;
Координаты и единицы измерения результата
Ограничивающий прямоугольник возвращается в виде четырёх рёбер: левого, нижнего, правого и верхнего, а два размера получаются вычитанием. Ширина - это правое минус левое, высота - верхнее минус нижнее. Оба выражены в пользовательских единицах PDF, где одна единица равна одной семьдесят второй дюйма, то же координатное пространство, в котором вы позиционируете текст на странице. На этом этапе нет никаких скрытых единиц устройства и никаких пикселей. Ширина 36 означает полдюйма на странице при любом конечном разрешении рендеринга
Вертикальная ось направлена так, как это определяет PDF, с Y, возрастающим вверх, вот почему высота - это верхнее минус нижнее, а не наоборот. Эта деталь важна при перемещении курсора вниз по столбцу. Вы измеряете высоту строки, затем вычитаете её из текущей базовой линии, чтобы найти следующую, потому что движение вниз по странице означает движение к меньшим значениям Y. Если пунктом назначения является экран, а не бумага, вы преобразуете пользовательские единицы в пиксели устройства с разрешением дисплея: значение в пользовательских единицах, умноженное на DPI и делённое на 72, даёт пиксели, поэтому ширину столбца, заданную в пунктах, можно сопоставить с измеренным фрагментом до принятия решения о месте переноса
Что происходит при вырожденном вводе
Функции написаны так, чтобы безмолвно завершать работу при ошибке. Если нет открытого документа или если текстовый объект не может быть создан, результатом является нулевой размер, а не генерированное исключение. Ширина и высота инициализируются нулём в начале и перезаписываются только после успешного считывания ограничивающего прямоугольника. Пустая строка, отсутствующий документ, шрифт, который библиотека не может преобразовать в объект, - каждый из них возвращает ноль, а не генерирует исключение
Этот выбор упрощает цикл измерения, потому что цикл, обрабатывающий тысячи слов, - не то место для обработки исключений на каждой итерации. Цена - проверка на стороне вызывающего кода. Нулевая ширина - это сигнальное значение, а не факт о тексте, поэтому код, делящий на измеренную ширину или предполагающий положительное значение, должен защититься от нуля перед его использованием. Трактуйте ноль как «не удалось измерить», и контракт ясен; проигнорируйте его, и вырожденный ввод тихо превращается в вёрстку с колонкой перекрывающихся глифов
Жадный перенос слов на основе измерений
Имея функцию ширины, перенос слов - это короткий жадный цикл. Вы разбиваете абзац на слова, ведёте текущую строку, и для каждого слова измеряете, какой будет строка, если добавить к ней это слово. Пока пробная строка ещё укладывается в ширину столбца, вы продолжаете добавлять; когда она выходит за пределы, вы сбрасываете текущую строку через AddText и начинаете новую со слова, которое не уместилось. Накопление полностью выполняется через MeasureTextWidth, и единственное, что когда-либо попадает на страницу, - это строка, которую вы уже подтвердили как умещающуюся
procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
Words: TArray<WideString>;
Line, Trial: WideString;
I: Integer;
Y: Double;
begin
Words := WideString(Para).Split([' ']);
Line := '';
Y := TopY;
for I := 0 to High(Words) do
begin
if Line = '' then
Trial := Words[I]
else
Trial := Line + ' ' + Words[I];
// Measure the candidate line before drawing anything.
if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
begin
Pdf.AddText(X, Y, Font, FontSize, Line); // flush the line that fit
Y := Y - LineHeight; // Y decreases going down
Line := Words[I]; // overflowing word starts next line
end
else
Line := Trial;
end;
if Line <> '' then
Pdf.AddText(X, Y, Font, FontSize, Line); // flush the final line
end;
Цикл измеряет пробную строку, а не каждое слово с последующим суммированием, потому что ширина строки - это не сумма ширин её слов. Пробелы между словами вносят свой вклад, а измеренный фрагмент фиксирует это напрямую. Жадное правило - уместить как можно больше слов, сколько позволяет столбец, и переносить на последнем умещающемся - это то же самое правило, которое заполняет пробел между чистым AddText и настоящим абзацем. Вызов отрисовки никогда не был сложной частью. Измерение, которое должно ему предшествовать, является таковым, и именно это предоставляет helper
Где это применяется
Измерение - это уровень между генерацией контента и его рендерингом, поэтому оно естественно сочетается с остальным рабочим процессом создания документа с нуля. Если вы собираете страницы и размещаете текст в первую очередь, основы рассмотрены в статье создание PDF-документов с нуля с помощью компонента PDFium в Delphi, где подробно описаны AddText и настройка страницы. Когда шрифт, который вы измеряете, важен не меньше строки, потому что метрики зависят от начертания, в статье анализ свойств шрифта PDF с компонентом PDFium в Delphi показано, как библиотека сообщает сведения о шрифте, которые управляют этими ограничивающими прямоугольниками. Оба используют одну и ту же привязку - PDFium Component для Delphi и Lazarus, где measurement helper поставляется вместе с API документа, страницы и текста, описанными в этом блоге