Строка, нарисованная в (72, 72), ложится на дюйм ниже верхнего края страницы, что застает врасплох всякого, кто выучил PDF со стороны потока содержимого, где y = 72 сидит на дюйм выше нижнего края. Второй сюрприз вылезает парой сотен строк позже. Отчёт, сверстанный под US Letter, возвращается с первой страницей 612 на 792 пункта и всеми последующими по 595 на 842, потому что строка, задающая размер бумаги, выполнилась ровно один раз
У обоих эффектов один корень. Текст в PDF — это короткая серия смен состояния (шрифт и размер, межсимвольный и межсловный интервал, текстовая матрица для позиции и поворота), за которыми следует один оператор показа текста, а высокоуровневый вызов вывода текста — тонкая обёртка над этой последовательностью. Вызов читает состояние. Он никогда не сбрасывает его за вас, и новая страница стартует с фиксированного набора значений по умолчанию, а не с того, чем кончила предыдущая страница
Куда TextOut кладёт (X, Y) на странице
HotPDF рисует текст вызовом THPDFPage.TextOut(X, Y: Single; angle: Extended; Text: WideString), до которого дотягиваются через Pdf.CurrentPage.TextOut. X и Y отмеряются от левого верхнего угла страницы, Y растёт вниз, и при Resolution по умолчанию 72 одна единица — один пункт, 1/72 дюйма. Страница переводит каждый Y в пользовательское пространство PDF, которое идёт снизу вверх, вычитая его из высоты страницы. Угол задаётся в градусах, а поскольку текст — WideString, Unicode проходит напрямую. Вторая перегрузка принимает PWORD плюс длину для кода, у которого буферы UTF-16 уже есть
Геометрия страницы тоже фиксирована. BeginDoc добавляет первую страницу, и каждая страница, созданная BeginDoc или AddPage, стартует как A4, 595 на 842 пункта, с Arial 12 в качестве текущего шрифта. US Letter (612 на 792) — в одном присваивании: CurrentPage.Size := Letter, но это присваивание принадлежит странице, так что для каждой новой страницы его надо повторить
По умолчанию переданный Y — это верх текста, и базовая линия сидит на одну верхнюю выносную элемента ниже. TextFromBaseLine(True) переключает страницу на позиционирование по базовой линии. Включайте его для всего, что смешивает размеры на одной строке. В режиме верхнего края 9-пунктовый код валюты рядом с 14-пунктной суммой выравнивается верхом с верхом суммы и потому зримо задирается; в режиме базовой линии оба фрагмента сидят на одной строке, как их поставил бы наборщик. Последний блок следующего примера делает ровно это
Размер и стиль живут в SetFont
У TextOut нет параметра размера или стиля. SetFont(FontName, FontStyle, ASize, FontCharset, IsVertical) несёт всё это, причём два последних аргумента по умолчанию равны ANSI_CHARSET и горизонтальному тексту, и всё, что он задал, остаётся в силе для каждого следующего TextOut до очередного SetFont. К большей части путаницы первого дня ведёт именно это. Строка выходит жирной, потому что тремя вызовами ранее что-то установило [fsBold], и ничто это не очистило
var
W: Single;
begin
Pdf.BeginDoc; // первая страница: A4, Arial 12
Pdf.CurrentPage.TextFromBaseLine(True); // каждый Y ниже — это базовая линия
Pdf.CurrentPage.SetFont('Times New Roman', [], 24);
Pdf.CurrentPage.TextOut(72, 72, 0, 'Quarterly Report'); // 24 pt, обычное начертание
Pdf.CurrentPage.SetFont('Times New Roman', [fsBold], 12);
Pdf.CurrentPage.TextOut(72, 104, 0, 'Revenue'); // 12 pt, полужирное
Pdf.CurrentPage.SetFont('Times New Roman', [fsItalic], 11);
Pdf.CurrentPage.TextOut(72, 120, 0, 'figures in thousands'); // 11 pt, курсив
Pdf.CurrentPage.SetFont('Courier New', [fsBold, fsUnderline], 10);
Pdf.CurrentPage.TextOut(72, 136, 0, '+18.4% YoY'); // стили комбинируются
// Разные размеры на одной базовой линии: мерьте, пока текущий кегль 14 pt
Pdf.CurrentPage.SetFont('Arial', [fsBold], 14);
W := Pdf.CurrentPage.GetWideTextWidth('1,284.50');
Pdf.CurrentPage.TextOut(72, 160, 0, '1,284.50');
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(72 + W + 3, 160, 0, 'EUR'); // зазор 3 pt
...
end;
Аргумент стиля — обычный набор TFontStyles, так что [] — обычный текст, а [fsBold, fsItalic] — полужирный курсив. fsUnderline и fsStrikeOut рисуются тонкими заполненными полосами под фрагментом или сквозь него и следуют за фрагментом при повороте. Размер задаётся в пунктах, в тех же единицах, что и координаты, что делает вертикальную арифметику простой: 11-пунктовый текст с шагом 16 пунктов оставляет около пяти пунктов воздуха между строками. Автоматического перевода строки нет. Каждый Y вы считаете сами, что утомительно для абзаца и точно правильно для формы, чьи поля сидят на фиксированных позициях
GetWideTextWidth меряет тем шрифтом, который текущ в момент вызова, поэтому в последнем блоке измерение должно случиться, пока ещё установлен 14-пунктовый полужирный кегль. Переставьте его ниже второго SetFont, и 9-пунктовый суффикс приземлится прямо на последние цифры суммы
Имя шрифта разрешается по шрифтам, установленным на машине, собирающей PDF, и кегль, разрешившийся на десктопе разработчика, может отсутствовать на сборочном сервере, так что гарнитура в готовом файле зависит от машины, на которой его произвели. Вторая половина истории — покрытие алфавитов. Латинооблицовочный шрифт, которому попросили нарисовать кириллицу или CJK, выдаёт квадраты отсутствующих глифов, и это свойство файла шрифта, а не вызова. Когда одна строка смешивает алфавиты, которые ни один одиночный шрифт не покрывает, TextOutWithFallback принимает те же четыре аргумента, переключает гарнитуры фрагмент за фрагментом для символов, которых нет в основном шрифте, и возвращает исходный шрифт по завершении
Как аргумент угла вращает текст?
Ненулевой угол поворачивает фрагмент против часовой стрелки на столько градусов вокруг его якоря, а якорь — это левый верхний угол текста в его собственной системе координат. Якорь не сдвигается. Меняется только направление, в котором маршируют глифы, так что координата, разместившая горизонтальную метку, размещает и её повёрнутого близнеца. Повёрнутые фрагменты держат тот же верхний левый якорь, даже когда страница в режиме базовой линии; TextFromBaseLine сдвигает только горизонтальные фрагменты
var
W, AxisMidY: Single;
begin
AxisMidY := 400; // середина оси Y диаграммы
// Фрагмент под 90 градусов читается снизу вверх и растёт от Y вверх,
// поэтому центрирование на оси означает старт на полширины ниже середины
Pdf.CurrentPage.SetFont('Arial', [fsBold], 11);
W := Pdf.CurrentPage.GetWideTextWidth('Units sold');
Pdf.CurrentPage.TextOut(40, AxisMidY + W / 2, 90, 'Units sold');
// Диагональный водяной знак
Pdf.CurrentPage.SetFont('Arial', [fsBold], 60);
Pdf.CurrentPage.TextOut(150, 250, 45, 'DRAFT');
// Заголовки колонок под 60 градусов: тот же Y, шаг по X
Pdf.CurrentPage.SetFont('Arial', [], 9);
Pdf.CurrentPage.TextOut(120, 600, 60, 'Q1 actual');
Pdf.CurrentPage.TextOut(160, 600, 60, 'Q2 actual');
end;
Девяносто градусов закрывают подписи осей и корешковые названия. При 90 градусах глифы стоят справа от X якоря шириной в одну высоту строки, а фрагмент поднимается к верху страницы на собственную ширину. Вот почему центрирование прибавляет половину ширины к Y, а не вычитает её, и почему метка, заякоренная в самой середине, сидит целиком выше неё. Шестьдесят градусов подходят заголовкам колонок: длинная метка стоит над узкой колонкой, не расползаясь в соседнюю, а 45 градусов — обычный угол водяного знака. Фрагменты на общей базовой линии получают одинаковый Y и ступенчатый X, ровно как стопка горизонтальных строк делит один X и шагает по Y
Почему вторая страница выходит как A4 в Arial 12?
AddPage возвращает индекс новой страницы и делает её текущей, а новая страница стартует со значений по умолчанию: A4, Arial 12, позиционирование по верхнему краю, нулевые межсимвольный и межсловный интервалы. Ничто с предыдущей страницы не переезжает. Такая схема держит каждую страницу самодостаточной — поток содержимого PDF-страницы и так стартует со свежего графического состояния, — но означает, что любой код вёрстки, настроивший страницу один раз в начале документа, тихо сменит размер бумаги и гарнитуру на странице два. Лекарство — собрать все постраничные настройки в одну процедуру и вызывать её после BeginDoc и после каждого AddPage
procedure StartReportPage(Pdf: THotPDF);
begin
Pdf.CurrentPage.Size := Letter; // новая страница стартует как A4
Pdf.CurrentPage.TextFromBaseLine(True); // новая страница стартует в режиме верхнего края
Pdf.CurrentPage.SetFont('Arial', [], 11); // новая страница стартует в Arial 12
end;
procedure WriteReport(Pdf: THotPDF; Lines: TStrings);
const
LeftMargin = 72; // 1 дюйм
TopLine = 72; // первая базовая линия, дюйм вниз
BottomMargin = 72;
Leading = 16;
var
I: Integer;
Y: Single;
begin
StartReportPage(Pdf);
Y := TopLine;
for I := 0 to Lines.Count - 1 do
begin
if Y > Pdf.CurrentPage.Height - BottomMargin then // 720 на Letter
begin
Pdf.AddPage;
StartReportPage(Pdf);
Y := TopLine;
end;
Pdf.CurrentPage.TextOut(LeftMargin, Y, 0, Lines[I]);
Y := Y + Leading;
end;
end;
Под TextOut нет потоковой вёрстки, и Y за нижним краем страницы — всё ещё корректная координата, сколько бы того ни стоил поток содержимого: текст записан, он просто лежит за пределами media box, и ни один просмотрщик его не показывает. Поэтому цикл проверяет Y перед каждой строкой и сам открывает новую страницу. Чтение предела из CurrentPage.Height вместо жёстко вбитых 720 сохраняет циклу корректность, если кто-то позже вернёт хелпер на A4, где тот же дюймовый отступ даёт предел 770
Быстрейшая проверка — та, что сделал бы диалог печати читателя. Откройте вывод, перейдите на страницу два и гляньте в свойства документа: страница Letter отчитывается 8,5 на 11 дюймов, страница A4 — примерно 8,27 на 11,69. Если первая страница и вторая расходятся, какой-то постраничный настрой живёт вне хелпера
Интервалы между символами и словами — тоже состояние страницы
Иногда строка верная, а ширина неправильная: заголовок, который должен накрыть фиксированную линейку, код, читающийся лучше с разряженными цифрами, строка меток, чьи зазоры надо раздвинуть. В PDF по ISO 32000-1 для этого есть два параметра состояния текста: межсимвольный интервал Tc (§9.3.2), добавляемый после каждого глифа, и межсловный Tw (§9.3.3), добавляемый на каждом пробеле. HotPDF задаёт их вызовами SetCharacterSpacing(Spacing: Single) и SetWordSpacing(Spacing: Single) в пунктах при разрешении по умолчанию, и, как шрифт, они остаются в силе, пока их не сменят
У межсловного интервала есть ловушка, вписанная в спецификацию. §9.3.3 применяет Tw только к однобайтовому коду символа 32 и явно не к байту 32 внутри многобайтового кода, поэтому сам по себе оператор ничего не делает для текста, записанного двухбайтовыми кодами глифов, — а именно так обычно пишутся встроенные Unicode-шрифты. HotPDF пишет Unicode-фрагменты именно так, и на этом пути применяет межсловный интервал как явные сдвиги позиции после каждого глифа пробела, так что SetWordSpacing расширяет зазоры в любом случае. Спусковым механизмом остаётся обычный пробел, U+0020. Фрагмент китайского или японского без ASCII-пробелов не получит лишнего места вовсе, а идеографический пробел (U+3000) считается обычным глифом
Межсимвольный интервал — правильный инструмент, когда заголовок обязан достичь целевой ширины: он размазывает изменение равномерно, а не собирает его на пробелах. GetWideTextWidth учитывает действующие в момент вызова межсимвольный и межсловный интервалы, так что мерьте при обоих нулях, а затем выводите интервал:
const
Heading = 'SUMMARY';
Target = 200; // ширина линейки, которую заголовок должен занять, в пунктах
var
Natural, Tc: Single;
begin
Pdf.CurrentPage.SetFont('Arial', [fsBold], 14);
Pdf.CurrentPage.SetCharacterSpacing(0); // мерьте без остаточных интервалов
Natural := Pdf.CurrentPage.GetWideTextWidth(Heading);
// Tc добавляется после каждого глифа, но видимы лишь зазоры между глифами
Tc := (Target - Natural) / (Length(Heading) - 1);
Pdf.CurrentPage.SetCharacterSpacing(Tc); // отрицательные значения сжимают
Pdf.CurrentPage.TextOut(72, 72, 0, Heading);
Pdf.CurrentPage.SetCharacterSpacing(0); // сброс перед основным текстом
Pdf.CurrentPage.SetWordSpacing(6);
Pdf.CurrentPage.SetFont('Arial', [], 11);
Pdf.CurrentPage.TextOut(72, 100, 0, 'Name Department Extension');
Pdf.CurrentPage.SetWordSpacing(0);
end;
Строки сброса важнее строк установки. Оставьте 4-пунктовый межсимвольный интервал после одного заголовка, и каждый абзац ниже унаследует растяжку, которая читается как смутная неправильность, переживает беглую вычитку и потом вылезает в печатном экземпляре. Задайте значение, нарисуйте единственный фрагмент, которому оно нужно, и обнулите его на следующей строке, чтобы никакому последующему коду не пришлось знать, что делал более ранний раздел. Та же страница предлагает SetHorizontalScaling (Tz, по умолчанию 100) для сжатия фрагмента без изменения высоты и SetTextRenderingMode со значениями вроде trStroke для контурного текста — и то и другое держится ровно так же
Если это ваша первая страница вывода, пример HotPDF Hello World закрывает настройку документа и сохранение. Тексту, которому надо заполнить колонку от края до края, посвящён отдельный разбор — полная выключка PDF-текста, а CJK сверху вниз описан в вертикальной вёрстке текста в HotPDF. TextOut, SetFont, вызовы интервалов и настройки размера страницы выше поставляются с HotPDF Delphi Component для Delphi и C++Builder