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

Рисование на канве HotPDF в Delphi: векторные пути и цвет

Линейка футера, скопированная из примера потока содержимого PDF, читается как 72 72 m 540 72 l S: перейти в (72, 72), провести линию к (540, 72), обвести. В сыром PDF эта линия сидит на дюйм выше нижнего края страницы US Letter. Скармливаете те же четыре числа API рисования, который меряет от левого верхнего угла, — и линейка приземляется на дюйм ниже верха, прямо через шапку письма. Сама линия в порядке. Поменялась только рамка отсчёта, а переход между двумя рамками на странице в 792 пункта — одно вычитание: 792 минус Y

Где на странице HotPDF находится Y = 0?

Вызовы рисования страницы HotPDF кладут начало координат в левый верхний угол страницы: X растёт вправо, Y вниз — та же рамка, что у VCL-TCanvas. Пользовательское пространство PDF, определённое в ISO 32000-1 §8.3.2.3, стартует внизу слева с Y вверх, и HotPDF конвертирует каждый переданный Y при записи потока содержимого. Так что Y := 72 на THotPDF.CurrentPage означает дюйм ниже верхнего края при любом размере страницы

Размер страницы важен на другом конце. На странице US Letter, 612 на 792 пункта, Y := 720 — дюйм над низом. Новая страница HotPDF — A4, если не сказать иное, а A4 высотой 842 пункта, так что тот же Y := 720 оставляет под ним 122 пункта, примерно 1,7 дюйма. Футеры, позиционируемые вычитанием из 792, уезжают вверх на A4 ровно поэтому. Читайте высоту из CurrentPage.Height вместо хардкода или задавайте CurrentPage.Size явно сразу после BeginDoc

Сравнение VCL TCanvas и API рисования страницы HotPDF: оба меряют от левого верхнего угла с Y вниз, а HotPDF записывает ту же точку у верха страницы US Letter как y = 720 в растущем вверх пользовательском пространстве PDF
HotPDF рисует в той же рамке сверху слева, что TCanvas: точка на дюйм ниже верха страницы 612 на 792 — это Y = 72 в обеих, а в файл HotPDF пишет её как y = 720 пользовательского пространства PDF

Единица — пункт, 1/72 дюйма, потому что THotPDF.Resolution по умолчанию равен 72. Повышение Resolution заставляет координаты и размеры считаться в 1/Resolution дюйма. Для векторной работы оставляйте 72: толщины линий, длины штрихов и координаты читаются тогда в той же единице, которую хранит PDF-файл, и значение из потока содержимого совпадает со значением в исходнике

Текст следует той же модели. По умолчанию Y, который вы даёте TextOut, — верхний край текста, так что подпись и рамка вокруг неё планируются по одной сетке. Несколько вызовов работают с PDF-файлом, а не с поверхностью рисования, и пользуются растущими вверх координатами PDF: сеттеры страничных боксов вроде SetCropBox(Left, Bottom, Right, Top) и семейство StampLoadedPageText / StampLoadedPageImage, штампующее уже загруженный документ. Именно смешение их с вызовами путей даёт большинство перевёрнутых раскладок, поэтому держите их в отдельных подпрограммах

Построение пути и его отрисовка

Путь сперва строится, потом красится. MoveTo ставит начальную точку, ничего не помечая, каждый LineTo добавляет прямой сегмент, и ничего не попадает на страницу, пока вызов отрисовки вроде Stroke не пустит в дело текущий цвет обводки и толщину линии. На языке PDF Stroke выдаёт оператор S, и каждый оператор отрисовки завершает текущий путь, так что следующий MoveTo стартует с чистого

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'DrawPaths.pdf';
    Pdf.BeginDoc;
    Pdf.CurrentPage.Size := Letter;    // 612 x 792 pt; по умолчанию A4

    // Толщина и цвет — состояние страницы: задать один раз, используют все последующие Stroke
    Pdf.CurrentPage.SetLineWidth(1.5);
    Pdf.CurrentPage.SetRGBStrokeColor(clBlack);

    // Линейка на дюйм ниже верха, поля по 72 pt с обеих сторон
    Pdf.CurrentPage.MoveTo(72, 72);
    Pdf.CurrentPage.LineTo(540, 72);
    Pdf.CurrentPage.Stroke;

    // Линейка футера из вступления: на дюйм выше низа
    Pdf.CurrentPage.MoveTo(72, Pdf.CurrentPage.Height - 72);
    Pdf.CurrentPage.LineTo(540, Pdf.CurrentPage.Height - 72);
    Pdf.CurrentPage.Stroke;

    // Три связанных сегмента одним путём, толще и синее
    Pdf.CurrentPage.SetLineWidth(3);
    Pdf.CurrentPage.SetRGBStrokeColor(RGB(30, 90, 200));
    Pdf.CurrentPage.MoveTo(72, 152);
    Pdf.CurrentPage.LineTo(172, 102);
    Pdf.CurrentPage.LineTo(272, 172);
    Pdf.CurrentPage.LineTo(372, 112);
    Pdf.CurrentPage.Stroke;

    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Толщина линии — состояние, а не аргумент. SetLineWidth пишет оператор w, и каждая последующая обводка на странице пользуется этой толщиной, пока вы её не смените, — потому полилиния выходит в 3 пункта, а обе линейки остаются в 1,5. Если не задать вовсе, действует PDF-умолчание 1.0 (ISO 32000-1 §8.4.3.2). У пропавших фигур причина прозаичнее: геометрия, выложенная без вызова отрисовки после, никогда не отрисуется

Координаты и толщины — Single, так что 72.25 и волосяная линия в 0,5 пункта доживают до файла. Ноль — особый случай. Спецификация определяет толщину 0 как тончайшую линию, которую способно нарисовать устройство вывода: один пиксель на экране при любом зуме и 1/600 дюйма на принтере 600 dpi, из-за чего одна и та же линейка выглядит жирной в просмотрщике и почти исчезает на бумаге. Берите настоящую толщину вроде 0,25 или 0,5 для волосяных линий, и линия сохранит вес на любом устройстве

Модель путей HotPDF: MoveTo, LineTo, Rectangle и Circle строят невидимый текущий путь в памяти, и только коммиты Stroke, Fill или FillAndStroke рисуют его, пользуясь постоянным графическим состоянием цвета обводки, цвета заливки и толщины линии, после чего очищают буфер
Геометрия молча копится в текущем пути, пока оператор отрисовки не закоммитит её с сохранёнными цветом обводки, цветом заливки и толщиной линии — забытый вызов рисования оставляет фигуру ненарисованной

Почему у замкнутого треугольника выемка в одном углу?

Треугольник из трёх вызовов LineTo, вернувшихся в стартовую точку, выглядит замкнутым, но в PDF это всё ещё открытый подпуть. ISO 32000-1 §8.5.3.2 применяет стык линий только там, где сегменты встречаются внутри подпути; два свободных конца получают окончания линий. С умолчальным тупым окончанием и пером в 6 пунктов стартовый угол показывает квадратный укус там, где остальные два угла имеют чистые остроконечные стыки. ClosePath (оператор h) дописывает последний сегмент и велит рендереру состыковать его с первым

Pdf.CurrentPage.SetLineWidth(6);
Pdf.CurrentPage.SetRGBStrokeColor(clBlack);

// Выглядит замкнутым, но нет: тупые окончания сходятся в (72, 500) и оставляют выемку
Pdf.CurrentPage.MoveTo(72, 500);
Pdf.CurrentPage.LineTo(132, 420);
Pdf.CurrentPage.LineTo(192, 500);
Pdf.CurrentPage.LineTo(72, 500);
Pdf.CurrentPage.Stroke;

// Замкнутый подпуть: все три угла получают одинаковый остроконечный стык
Pdf.CurrentPage.MoveTo(232, 500);
Pdf.CurrentPage.LineTo(292, 420);
Pdf.CurrentPage.LineTo(352, 500);
Pdf.CurrentPage.ClosePath;
Pdf.CurrentPage.Stroke;              // или ClosePathStroke одним вызовом

// Заливке ClosePath не нужен: заливка неявно замыкает каждый подпуть
Pdf.CurrentPage.SetRGBFillColor(RGB(220, 60, 60));
Pdf.CurrentPage.MoveTo(392, 500);
Pdf.CurrentPage.LineTo(452, 420);
Pdf.CurrentPage.LineTo(512, 500);
Pdf.CurrentPage.Fill;

Замыкайте каждый многоугольник, который собираетесь обвести, через ClosePath или ClosePathStroke, даже если последняя точка уже совпадает с первой. При 1 пункте выемка на экране невидима; при 6 пунктах она видна при обычном зуме, а в легенде печатного графика читается как глюк рендеринга. Для заливаемых фигур вопрос не встаёт, а EoFill — на случай, когда путь пересекает сам себя и вы хотите правило чёт-нечёт вместо ненулевого обхода

Прямоугольники, круги и привычка TCanvas

Замкнутые примитивы избавляют от бухгалтерии MoveTo. Rectangle(X, Y, Width, Height) берёт левый верхний угол и размер, Circle(X, Y, Radius) — центр и радиус, и любой красится через Fill, Stroke или FillAndStroke. Цвет заливки и цвет обводки независимы: SetRGBFillColor и SetRGBStrokeColor берут TColor, так что Delphi-константы и RGB(...) подходят напрямую, а SetRGBColor ставит оба сразу

Форма аргументов Rectangle — та самая ловушка. TCanvas.Rectangle берёт (Left, Top, Right, Bottom), и годы VCL-кода отрисовки тренируют пальцы передавать второй угол. Скармливаете HotPDF Rectangle(72, 202, 232, 292) — получаете коробку шириной 232 пункта и высотой 292 вместо 160 на 90. Когда ваши числа — реально два угла, вызывайте MFRectangle(X, Y, X1, Y1), который берёт координаты углов и считает размер за вас; Rectangle приберегите для кода, думающего размерами

В PDF-файле нет оператора круга. Circle пишет четыре сегмента кубической кривой Безье с привычным коэффициентом контрольных точек 0.5523 — это видно, если распаковать поток содержимого страницы: одна m и четыре оператора c. Ellipse(X, Y, Width, Height) делает то же внутри ограничивающего бокса, чей левый верхний угол — (X, Y)

HotPDF также открывает THotPDF.Canvas — стандартный TCanvas текущей страницы, и программа CanvasDraw из Graphic-демонстраций заполняет страницу Ledger обычными вызовами Canvas.Rectangle, Ellipse и RoundRect. Этот путь оправдан при переносе существующего VCL-кода отрисовки. Новому коду рисования лучше на CurrentPage: каждый вызов отображается в один PDF-оператор в пунктах, и выход предсказуем вплоть до числа

Выносной блок за четыре коммита

Выноска использует всё вышеперечисленное разом: залитая с обводкой панель, линия-указатель, якорная точка и две строки текста, размещённые внутри панели в тех же координатах сверху слева. FillAndStroke (оператор B) красит интерьер и кант одного пути одним вызовом, поэтому прямоугольник строится один раз, а не два

Анатомия выносного блока из примитивов HotPDF: прямоугольник, закоммиченный FillAndStroke, обведённая линия-указатель из MoveTo и LineTo, залитая якорная точка и текстовые плашки TextOut на той же сетке сверху слева, что и фигуры
Выноску строят четыре коммита — FillAndStroke красит панель и кант вместе, указатель и точка переиспользуют состояние обводки и заливки, а каждый сдвиг подписи — простая арифметика от угла бокса (90, 122)
procedure DrawCallout(Page: THPDFPage);
begin
  // 1. Панель: бледная заливка и более тёмный кант, один путь, один коммит
  Page.SetRGBFillColor(RGB(255, 244, 214));
  Page.SetRGBStrokeColor(RGB(180, 130, 40));
  Page.SetLineWidth(1);
  Page.Rectangle(90, 122, 240, 70);      // левый верхний (90, 122), 240 x 70
  Page.FillAndStroke;

  // 2. Указатель от левого края панели вниз к цели
  Page.SetLineWidth(1.5);
  Page.MoveTo(90, 177);
  Page.LineTo(66, 246);
  Page.Stroke;

  // 3. Якорная точка на конце указателя, цветом канта
  Page.SetRGBFillColor(RGB(180, 130, 40));
  Page.Circle(64, 250, 3);
  Page.Fill;

  // 4. Подписи: каждый Y — верх текста, отмеренный по той же сетке
  Page.SetFont('Arial', [fsBold], 10);
  Page.TextOut(102, 134, 0, 'Check this total');
  Page.SetFont('Arial', [], 9);
  Page.TextOut(102, 152, 0, 'Rounded to two decimals per line,');
  Page.TextOut(102, 164, 0, 'then summed');
end;

// Вызывается между BeginDoc и EndDoc:
//   DrawCallout(Pdf.CurrentPage);

Каждый блок ставит собственные цвет заливки, цвет обводки и толщину прямо перед нуждающейся в них фигурой, даже если предыдущий блок оставил то же значение. Это стоит нескольких лишних операторов в файле. Взамен любой блок можно сдвинуть, удалить или выделить в собственную процедуру, не унаследовав шальной красной заливки от всего, что исполнялось раньше, а для кода рисования, меняющегося вместе с отчётами, этот размен стоит брать каждый раз

Цветам нужна та же дисциплина. Затравка заливок через Random($FFFFFF) делает демо-страницу живой, а генерируемый отчёт — несравнимым между прогонами: две сборки из одинаковых данных дают разные байты. Ведите цвет от данных или от фиксированной палитры, как делает демо CanvasDraw своей таблицей CanvasColors на восемь записей, и одинаковый вход даст одинаковый файл

Штрихи, кривые и прозрачность

SetDash — состояние страницы, как и толщина линии. SetDash([3, 3], 0) делает каждую последующую обводку «три пункта штрих, три пункта пропуск», второй аргумент сдвигает точку старта узора, а NoDash возвращает сплошное перо. Массив штрихов — array of Byte, так что длины — целые пункты от 1 до 255, и в поток содержимого они записываются ровно как заданы. Штрих, поставленный для одной группы линий сетки, останется на каждой линейке после, поэтому заверните штриховой участок в GStateSave и GStateRestore: они выдают q и Q, и восстановление сохранённого состояния возвращает прежние штрих, толщину и цвета одним шагом

Кривые расширяют тот же путь. CurveToC(X1, Y1, X2, Y2, X3, Y3) добавляет кубическую Безье от текущей точки к (X3, Y3) с двумя контрольными точками, а CurveToV и CurveToY покрывают случаи, когда первая или вторая контрольная точка совпадает с концом. Один путь может смешивать сегменты LineTo и CurveToC перед одним Stroke или Fill — так рисуются скруглённые углы и сглаженные линии графиков

Прозрачность живёт в именованном графическом состоянии, потому что PDF хранит альфу в словаре ExtGState, а не в цвете. THotPDF.RegisterExtGState(FillAlpha, StrokeAlpha) берёт значения от 0 до 1 и возвращает имя; CurrentPage.SetGraphicsState(Name) применяет его ко всему, что рисуется после. Регистрируйте каждую прозрачность один раз на документ и переиспользуйте имя, чтобы файл нёс один словарь на прозрачность независимо от числа фигур. Альфа требует PDF 1.4, и HotPDF поднимает выходную версию до 1.4, когда нужно; при установленном StrictVersionLock он оставляет версию в покое и возвращает пустое имя. Когда PDFACompliance выбирает профиль PDF/A-1, альфа ниже 1 бросает исключение: ISO 19005-1 §6.4 запрещает там прозрачность. Профили PDF/A-2 и PDF/A-3 её принимают

var
  Half: AnsiString;
begin
  // Регистрируйте один раз на документ, переиспользуйте имя на любой странице
  Half := Pdf.RegisterExtGState(0.5, 1.0);   // заливка 50%, непрозрачная обводка

  Pdf.CurrentPage.GStateSave;                // q
  Pdf.CurrentPage.SetDash([3, 3], 0);
  Pdf.CurrentPage.SetLineWidth(0.5);
  Pdf.CurrentPage.SetRGBStrokeColor(clGray);
  Pdf.CurrentPage.MoveTo(72, 400);
  Pdf.CurrentPage.LineTo(540, 400);
  Pdf.CurrentPage.Stroke;

  Pdf.CurrentPage.SetGraphicsState(Half);
  Pdf.CurrentPage.SetRGBFillColor(clYellow);
  Pdf.CurrentPage.Rectangle(72, 380, 200, 16);  // подсветка над строкой текста
  Pdf.CurrentPage.Fill;
  Pdf.CurrentPage.GStateRestore;             // Q: сплошное перо, полная непрозрачность снова
end;

Векторные вызовы подходят геометрии, которую считает ваша программа: линейки таблиц, оси и столбцы графиков, выноски, логотип из дюжины путей. Фотография или скриншот — в AddImage и ShowImage; трассировка пикселей путями лишь раздувает файл. Проверяя вывод, зумируйте до 1600% на углах толстых штрихов и на любой штриховой линии: выемки от незакрытых путей и штрихи, протёкшие за свой участок, на этом увеличении очевидны, а на 100% легко пропускаются

Каждый вызов из этой статьи живёт на объекте страницы HotPDF Delphi Component, который работает в VCL-проектах Delphi и C++Builder и записывает результат простыми PDF-операторами путей