GDI+: графика нового поколения

Часть 3. Построение векторных изображений

Автор: Виталий Брусенцев
The RSDN Group

Источник: RSDN Magazine #3
Опубликовано: 14.04.2003
Версия текста: 1.0
Графические объекты
Stateful model в GDI
Stateless model в GDI+
Кисти, краски, перья… и прочий "мусор"
Разделение методов закраски и отрисовки
Семейство Brush: набор кисточек на любой вкус
К штыку приравняли перо…
Векторные примитивы
Качество вывода и устранение контурных неровностей
Сплайны
Кривые Безье
Метафайлы
Загрузка
Воспроизведение
Создание и сохранение нового метафайла
Преобразование в растровое изображение
Изучение команд метафайла
Перечисление записей: специфика .NET
Полезные ресурсы

Curves.zip: пример построения кривых (Visual C++)
Clock.zip: приложение – часы (C#)
MetaGen.zip: пример создания метафайлов (Visual C++)
PlayMeta.zip: работа с метафайлами (С#)

В предыдущей части статьи (см. RSDN Magazine №1'2002) рассматривались возможности GDI+ в сфере растровой графики. Третья часть будет посвящена таким вопросам, как вывод векторных примитивов (а также работа с необходимыми для этого графическими объектами). Кроме этого, речь пойдет о таком полезном понятии, как метафайлы GDI+. По-прежнему предполагается, что для компиляции примеров у читателя имеется, как минимум, Visual C++ 6.0 (c установленным Platform SDK, желательно, последней версии) или .NET Framework SDK. Откомпилированные приложения можно найти на компакт-диске к журналу.

Графические объекты

Для вывода примитивов требуются определенные настройки: установка цвета и толщины линий («пера»), вида заливки сплошных областей («кисти»), размеров и начертания шрифтов и так далее. Как правило, различные графические программные интерфейсы хранят такие настройки в виде собственных структур данных – графических объектов. Это справедливо как для GDI, так и для GDI+, однако использование объектов различается. Рассмотрим эти отличия на примере маленькой задачи – рисования квадрата.

Stateful model в GDI

В GDI использована программная модель, называемая stateful model. Это означает, что для устройства вывода запоминаются сделанные настройки и имеется понятие текущего выбранного объекта. Перед выводом примитивов необходимый графический объект требуется выбрать в устройство вывода (с помощью функции SelectObject). Установив таким образом, например, толщину линии в 3 пиксела, затем можно вывести несколько отрезков выбранной толщины:

[C++, GDI]
void OnPaint(HDC hDc)
{
  HPEN hPen = CreatePen(PS_SOLID, 3, RGB(0, 0, 128));
  HGDIOBJ hOldPen = SelectObject(hDc, hPen);
  MoveToEx(hDc, 10, 10, 0);
  LineTo(hDc, 10, 100);
  LineTo(hDc, 100, 100);
  LineTo(hDc, 100, 10);
  LineTo(hDc, 10, 10);
  SelectObject(hDc, hOldPen);
  DeleteObject(hPen);
}

Такой подход имеет свои преимущества – единожды установив выбранное перо (HPEN), не нужно каждый раз передавать его в функцию вывода. Однако простота очень часто оборачивалась серьезными проблемами – печально известной утечкой ресурсов.

Взгляните на выделенную строку. Зачем понадобилось запоминать и восстанавливать в контексте старое перо, если оно все равно не использовалось для рисования? Все дело в том, что выбранный в контексте графический объект не может быть удален – такая уж архитектура была выбрана при создании GDI. Следовательно, без этой строки вызов DeleteObject завершится неудачно, и созданное перо останется «висеть» в GDI Heap. Так как сообщение WM_PAINT приходит окну довольно часто, в системах Windows 9x (где размер кучи GDI ограничен величиной 64 Кб) это быстро приведет к исчерпанию графических ресурсов системы. После этого все программы начнут вести себя очень странно: не перерисовывать некоторые элементы меню и иконки, рисовать текст подозрительными шрифтами и т.д. Системы на платформе NT будут «держаться на плаву» несколько дольше, так как их графическая куча может расти по мере необходимости, но и там существует предел на число одновременно созданных в процессе графических объектов – 12 тысяч.

Другая проблема заключается в том, что C++-программисты привыкли к автоматической очистке используемых ресурсов благодаря использованию классов с деструкторами и «умных указателей». При этом легко вообще забыть о необходимости что-то явно удалять. Однако существующие библиотеки классов-оберток GDI не могут за программиста решить проблему удаления выбранного в контексте объекта. Это также приводит к утечкам GDI-ресурсов.

Stateless model в GDI+

Отличительной для программиста особенностью GDI+ является изменение программной модели в работе с устройствами вывода. Концепция текущего выбранного в устройстве графического объекта выброшена как неэффективная и устаревшая. Вместо последовательной установки параметров контекста (Graphics) используется перечисление атрибутов при каждом вызове графического метода. Устройство вывода как бы не обладает состоянием, а получает необходимую информацию через параметры. Такая модель носит название stateless model.

Разумеется, такая классификация носит условный характер. Существуют и такие параметры (например, текущие настройки сглаживания, выбранная система координат и т.д.), которые хранятся в классе Graphics и связанных с ним структурах. Но состояние примитивов GDI+ не хранится в контексте отображения.

Теперь каждый метод, использующий для вывода графический объект, требует в качестве параметра явного указания этого объекта:

[C++, GDI+]
using namespace Gdiplus;
void OnPaint(Graphics &g)
{
  Pen pen(Color::Blue, 3);
  Point points[] = {
    Point(10, 10), Point(100, 10), Point(100, 100), 
    Point(10, 100), Point(10, 10) 
  };
  // Вывести указанным пером Pen набор линий из массива точек points
  g.DrawLines(&pen, points, sizeof points / sizeof points[0]);
}

Это полностью исключает описанную проблему утечки ресурсов и позволяет освобождать все необходимые ресурсы в деструкторах классов-оберток GDI+ для языка C++.

Если вы, уважаемый читатель, новичок в C++, вас, возможно, смутит странная конструкция: sizeof points / sizeof points[0]. Это всего-навсего полезный прием для вычисления количества элементов произвольного массива на этапе компиляции. Размер (в байтах) массива points делится на размер его первого элемента.

На C# такие ухищрения не требуются: массивы в этом языке являются объектами, и у них имеется стандартное свойство Length.

Кисти, краски, перья… и прочий "мусор"

Вы уже, наверное, догадались из названия, что сейчас речь пойдет не о самой GDI+, а о ее взаимодействии с .NET Framework. Как известно, в этой среде очистка объектов осуществляется не сразу при их выходе из области видимости, а позднее, при старте так называемого сборщика мусора (Garbage Collector, GC). При этом создание объекта в динамической памяти (куче) является гораздо более дешевой операцией. К тому же, не требуется следить за его удалением – GC позаботится об этом сам. Такие комфортные условия буквально подталкивают программистов к написанию кода в следующем стиле:

[C#]
private void Form1_Paint(object sender, PaintEventArgs e)
{
  Graphics g = e.Graphics;
  Point[] points = { 
    new Point(10, 10), new Point(10, 100), 
    new Point(100, 100), new Point(100, 10), new Point(10, 10)
  };
  g.DrawLines(new Pen(Color.Blue, 3), points);
}

Как видите, здесь динамически создаются два объекта: массив структур Point и экземпляр класса Pen. Но нужно четко различать эти два случая. Коротко остановимся на этом, не углубляясь в детали .NET Framework.

Для создания обычного объекта (например, массива value-типов) требуется только динамическая память. Но многие объекты управляют ресурсами – сущностями, количество которых по определению ограничено неким лимитом, не зависящим от нашей программы. К их числу относятся подключения к БД, файловые дескрипторы, графические объекты GDI+ и многое другое. При таком подходе они будут освобождаться только в момент разрушения объекта. Это допустимо для демонстрационных примеров, но в серьезном приложении может привести к преждевременному исчерпанию ресурсов системы.

Как ни странно, такая опасность больше всего угрожает системам с большим количеством установленной памяти. Просто вызов GC (а следовательно, и очистка ресурсов) откладывается до момента нехватки динамической памяти, а это может произойти очень не скоро.

К счастью, разработчики GDI+ для .NET понимали эту проблему и реализовали для всех необходимых классов интерфейс IDisposable. Подробнее о нем можно прочитать в статье Игоря Ткачева "Автоматическое управление памятью в .NET" в первом номере журнала RSDN Magazine. Здесь же скажу только, что его метод Dispose предписывает объекту освободить связанные с ним ресурсы. При использовании конструкции using в C# необходимую очистку можно выполнять неявно:

[C#]
private void Form1_Paint(object sender, PaintEventArgs e)
{
  Graphics g = e.Graphics;
  Point[] points = { 
    new Point(10, 10), new Point(10, 100), 
    new Point(100, 100), new Point(100, 10), new Point(10, 10)
  };
  using (Pen pen = new Pen(Color.Blue, 3))
    g.DrawLines(pen, points);
}

При выходе из области действия using компилятор сгенерирует вызов pen.Dispose().

Разделение методов закраски и отрисовки

Переход к stateless-модели потребовал перепроектировать основные методы рисования примитивов. Раньше поведение, например, функции, Rectangle определялось тем, какая кисть и какое перо выбраны в контексте отображения. Для рисования, скажем, незалитых прямоугольников требовалось выбрать в контексте кисть HOLLOW_BRUSH.

В GDI+, как уже было сказано, состояние примитивов не сохраняется в устройстве вывода (Graphics). Вместо этого практически все методы рисования замкнутых фигур имеют две версии: DrawXXXX для рисования контура фигур (этим методам в качестве параметра требуется экземпляр класса Pen) и FillXXXX для заливки (таким методам передается класс, порожденный от Brush).

Семейство Brush: набор кисточек на любой вкус

Для заливки сплошных областей соответствующие методы библиотеки требуют указывать объект класса Brush. Сам по себе этот класс практически бесполезен, так как его нельзя использовать непосредственно. Зато пять его потомков предоставляют богатую функциональность. Рассмотрим их чуть подробнее.

SolidBrush

Самый простой класс из семейства Brush. Предназначен для заливки областей однородным цветом. Этот цвет можно указать как в конструкторе класса, так и позднее, для уже сконструированного объекта:

// создаем кисть ярко-красного цвета
SolidBrush br(Color(255,0,0));
. . .
// устанавливаем для кисти черный цвет
br.SetColor(Color::Black);

Единственное (но довольно существенное) отличие класса SolidBrush от аналогичного графического объекта GDI – это поддержка полупрозрачности. Напомню, что класс Color позволяет указать помимо трех цветовых компонентов (RGB) еще и величину непрозрачности alpha (по умолчанию, alpha принимает значение 255 и цвет является полностью непрозрачным).

Разработчики WinForms приготовили пользователям .NET приятный сюрприз. Помимо класса Color, содержащего 141 константу с именем стандартного цвета, в этой среде существует также и класс System.Drawing.Brushes, который также содержит 141 статическое свойство – кисть, уже инициализированную заданным цветом. Например, для рисования красного круга диаметром 100 единиц можно написать очень короткое выражение:

g.FillEllipse(Brushes.Red, 0, 0, 100, 100);

Как и одноименные константы класса Color, эти поля также содержат в поле alpha значение 255.

HatchBrush

Класс HatchBrush предоставляет средства заливки двухцветным узором, основанным на битовом шаблоне (pattern). Конструктор этого класса на C++ выглядит так:

HatchBrush( HatchStyle hatchStyle, const Color& foreColor, const Color& backColor );

GDI+ содержит уже готовый набор из 53 шаблонов, описываемых перечислением HatchStyle (сравните это количество с 6 предопределенными стилями в GDI). Параметры foreColor и backColor позволяют указать, соответственно, цвет линий шаблона и фоновый цвет кисти.

Вы можете контролировать расположение начальной точки шаблона относительно области рисования (метод Graphics::SetRenderingOrigin и свойство Graphics.RenderingOrigin в .NET).

TextureBrush

Этот класс позволяет заливать области растровым рисунком, или текстурой. Для инициализации объекта класса TextureBrush необходима картинка – экземпляр класса Image. Причем это могут быть как растровые изображения (работа с которыми подробно описывалась в предыдущей части), так и метафайлы (с которыми мы познакомимся далее). Это предоставляет нам гигантские возможности: нарисовав замысловатый узор, можно, в свою очередь, использовать его как «кисточку» для еще более сложного рисования. Вот короткий пример для WinForms:

// читаем исходное изображение (метафайл)
private Image img = Image.FromFile("MyBrush.emf");

private void Form1_Paint(object sender, PaintEventArgs e)
{
  Graphics g = e.Graphics;

  // закрашиваем форму созданной кистью
  g.FillRectangle(new TextureBrush(img), ClientRectangle);
}

Для инициализации класса предназначено несколько перегруженных конструкторов (я не стану здесь их приводить). Помимо картинки (Image), они могут принимать в качестве параметра экземпляры классов WrapMode и ImageAttributes. Эти классы управляют различными аспектами вывода изображений (рассмотренными в предыдущей части при обсуждении растров).

Важно добавить, что, в отличие от класса HatchBrush, TextureBrush позволяет не только использовать для заливки рисунки с неограниченным набором цветов, но и подчиняется координатным трансформациям GDI+. Используемую текстуру можно масштабировать, перемещать и поворачивать любым желаемым образом.

LinearGradientBrush, PathGradientBrush

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

// Создаем кисть с градиентом на все окно и полупрозрачностью
LinearGradientBrush brush(bounds, Color(130, 255, 0, 0), Color(255, 0, 0, 255),
  LinearGradientModeBackwardDiagonal);

Первый параметр конструктора имеет тип Rect (Rectangle для .NET) и содержит в себе координаты прямоугольной области, в которой и происходит переход между стартовым цветом (второй параметр) и конечным цветом (третий параметр). Как видите, ничто не мешает одному из цветов быть полупрозрачным (alpha = 130). Если выводимая фигура превышает по габаритным размерам заданную прямоугольную область, то возникает вопрос: каким цветом закрашивать такие участки? Это поведение определяется методом SetWrapMode (так же, как и в случае с другими видами неоднородной заливки).

Последний параметр является элементом перечисления LinearGradientMode и определяет направление градиента, то есть расположение точек с полярными участками цвета.

Класс PathGradientBrush отличается от своего «близнеца» тем, что может использовать градиентный переход сразу между несколькими цветами. Положение требуемых полярных точек определяется геометрическим путем (path), который может принимать произвольную форму.

Оба эти класса также поддерживают координатные трансформации GDI+. Кроме того, закон распределения цветового перехода в них поддается регулировке (см. метод SetSigmaBellShape в .NET и SetBlendBellShape в версии для C++).

К штыку приравняли перо…

Класс Pen (для .NET – System.Drawing.Pen) предназначен для настройки параметров различных выводимых линий: отрезков прямых, дуг окружностей, эллипсов, сплайнов и кривых Безье. Помимо привычных для «пера» свойств – таких, как цвет и толщина линии , он содержит целую гамму различных дополнительных настроек. Во-первых, при создании экземпляра класса Pen в качестве источника можно указать класс типа Brush – а, значит, для линий доступны все возможные варианты градиентных заливок, текстур и шаблонов (pattern fill):

[C++]
Pen( const Brush* brush, REAL width=1.0 );
[C#]
public Pen( Brush brush );
public Pen( Brush brush, float width );

Во-вторых, при указании цвета, как обычно в GDI+, допустимо задавать степень непрозрачности alpha, создавая полупрозрачные линии.

[C++]
Pen( const Color& color, REAL width=1.0);
[C#]
public Pen( Color color );
public Pen( Color color, float width );

Так же, как и для класса Brush, .NET Framework предоставляет более простой способ создания пера стандартного (именованного) цвета. В классе System.Drawing.Pens имеется 141 статическое свойство с именами стандартных цветов. Вот короткий пример:

g.DrawEllipse(Pens.Red, 0, 0, 100, 100);

И, наконец, для задания геометрических характеристик линий в интерфейсе класса Pen существует множество настроек ( см. таблицу 1).

МетодОписание
SetAlignmentОпределяет расположение точек пера относительно геометрической линии при рисовании многоугольников: внутри многоугольника или по центру его контура.
SetCompoundArrayПозволяет составлять линию из набора параллельных линий различной толщины. Подробнее см. пример Clock.
SetDashCap, SetDashOffset, SetDashPattern, SetDashStyleПозволяют указать одно из 5 стандартных начертаний линий или задать свой собственный стиль прерывистого начертания.
SetStartCap, SetEndCap, SetLineCapОпределяют геометрию и начертание (сплошное или прерывистое) концевых участков линии. Позволяют указать для каждого конца линии любую из 8 стандартных форм.
SetCustomStartCap, SetCustomEndCapУстанавливают произвольную форму концевых участков, основанную на геометрическом пути (Path). В качестве параметра требуют объект класса, производного от CustomLineCap. Для рисования более сложных стрелок существует готовый класс AdjustableArrowCap.
SetLineJoinОпределяют внешний вид соединения отрезков многоугольника: скругленное, продолженное за пределы многоугольника или обрезанное.
SetMiterLimitЗадает величину продолжения соединяемых под острым углом отрезков (MiterJoin).

Для всех перечисленных Set-методов, как обычно, существуют их Get- эквиваленты. В .NET доступ к настройкам пера осуществляется посредством свойств.

Векторные примитивы

В этом разделе я не буду подробно останавливаться на средствах вывода традиционных векторных примитивов GDI+. Их использование не составит никакого труда для программистов, уже знакомых с GDI. А вот новинки (такие, как сплайны) заслуживают особого внимания. Все же, в качестве “учебного мини-пособия”, приведем здесь программу GDI+ Clock, созданную для WinForms на языке C#.


Рисунок 1.

Программа отображает текущее время на стилизованном «аналоговом» циферблате. В ней использован большой набор примитивов: прямоугольники, эллипсы и отрезки прямых. Для расчета их координат используются не геометрические преобразования GDI+ (это тема отдельной статьи), а простые вычисления. Что действительно заслуживает внимания, так это отображение стрелок часов. Каждая стрелка (см. рисунок 1) рисуется всего одним вызовом метода DrawLine! Как видите, GDI+ позволяет создавать довольно сложные изображения простыми средствами. Полный текст программы приведен на компакт-диске к журналу.

Качество вывода и устранение контурных неровностей

Как правило, человеческое зрение негативно воспринимает «зубчатые», пикселизованные изображения, особенно в дисплейной графике низкого разрешения. Например, человек способен читать текст с экрана в среднем на 30% медленнее, чем с бумаги. Для борьбы с этим явлением придумано множество технологий сглаживания – начиная от ClearType для LCD-мониторов и заканчивая Full Screen Anti-Aliasing (FSAA) для современных графических ускорителей.

Как уже упоминалось в прошлой части статьи, библиотека GDI+ имеет собственный набор средств для улучшения зрительного восприятия выводимой графики. При обработке растров используется интерполяция – вычисление промежуточных цветов выводимых пикселов, а для векторных изображений может применяться антиалиасинг – устранение контурных неровностей при помощи градаций цвета.

При антиалиасинге для построения, скажем, прямой линии, применяются не целочисленные алгоритмы, а их модификации с плавающей точкой. Для каждой точки выводимой линии вычисляется степень ее прозрачности (в зависимости от удаления данной точки от прямой).

Управление антиалиасингом осуществляется вызовом метода Graphics::SetSmoothingMode (для .NET – установкой свойства SmoothingMode). В качестве параметра используется элемент перечисления SmoothingMode:

enum SmoothingMode{
  SmoothingModeInvalid     = QualityModeInvalid,
  SmoothingModeDefault     = QualityModeDefault,
  SmoothingModeHighSpeed   = QualityModeLow,
  SmoothingModeHighQuality = QualityModeHigh,
  SmoothingModeNone,
  SmoothingModeAntiAlias
};

В данный момент фактически нет разницы между режимами SmoothingModeDefault, SmoothingModeHighSpeed и SmoothingModeNone: все они выключают антиалиасинг примитивов. Изображение при этом приобретает привычные ступенчатые края:


Для включения режима сглаживания используйте константы SmoothingModeHighQuality или SmoothingModeAntiAlias:


Установка режима SmoothingModeInvalid не имеет смысла, так как вернет ошибку выполнения (а в среде .NET сгенерирует исключение).

Сплайны

Одно из значений английского слова splineлекало, то есть деревянное или металлическое приспособление для вычерчивания на бумаге кривых линий. Раньше для изготовления таких чертежных инструментов использовали металлическую пластину, которую фиксировали в заданных точках. Пластина огибала направляющие по гладкой кривой. Меняя силу натяжения и материал пластины, можно было получить целое семейство кривых для одного набора точек. Позднее эта зависимость превратилась в набор формул, описывающих кубические сплайны. Они широко используются в инженерных расчетах: например, для аппроксимации результатов экспериментов. При использовании GDI+ вам не понадобится знание этих формул. Для вычерчивания сплайнов достаточно вызвать функцию семейства DrawCurve:

Status DrawCurve( const Pen* pen, const Point* points,  INT count );
Status DrawCurve( const Pen* pen, const PointF* points, INT count );
Status DrawCurve( const Pen* pen, const Point* points, INT count, INT offset, 
  INT numberOfSegments, REAL tension );
Status DrawCurve( const Pen* pen, const PointF* points, INT count, INT offset,
  INT numberOfSegments, REAL tension );
Status DrawCurve( const Pen* pen, const Point* points, INT count, REAL tension );
Status DrawCurve( const Pen* pen, const PointF* points, INT count, REAL tension );

Указатель points должен указывать на первый элемент массива структур Point (для целочисленных координат) или PointF (для координат с плавающей точкой).

Реализация этих методов GDI+ для .NET отличается только тем, что не требует указания количества элементов массива points.

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

Характерной особенностью сплайнов является то, что построенная кривая проходит через каждую точку из заданного набора. Кроме того, для расчета координат сплайна в концевых точках требуются начальные условия. Они могут быть получены аппроксимацией, но их можно задать и явно. Для этого необходимо вызвать версию метода DrawCurve с параметрами offset и numberOfSegments и передать в этот метод массив с большим количеством точек, чем требуется для рисования. Параметр offset определяет индекс (начиная с 0) точки в массиве, с которой начнется рисование сегментов кривой, а параметр numberOfSegments указывает их количество. "Невидимые" точки будут использованы для расчетов.

Я не использовал эти дополнительные параметры в демонстрационном приложении, но вы можете поэкспериментировать с ними, модифицировав код нижеследующего метода:

void CCurveDlg::DrawSpline(Gdiplus::Graphics &g)
{
    using namespace Gdiplus;
    g.SetSmoothingMode(SmoothingModeHighQuality);
    g.DrawCurve(&Pen(Color::Blue, 3), points, sizeof points/sizeof points[0]);
}

Вот пример образуемой кривой (вы можете перемещать узловые точки):


И, завершая краткое знакомство со сплайнами, замечу, что в классе Graphics имеется также семейство методов DrawClosedCurve, позволяющих выводить замкнутые кривые по заданному набору точек:

Status DrawClosedCurve( const Pen* pen, const Point* points, INT count );
Status DrawClosedCurve( const Pen* pen, const PointF* points, INT count );
Status DrawClosedCurve( const Pen* pen, const Point* points, INT count, REAL tension );
Status DrawClosedCurve( const Pen* pen, const PointF* points, INT count, REAL tension );

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

Status FillClosedCurve( const Brush* brush, const Point* points, INT count );
Status FillClosedCurve( const Brush* brush, const PointF* points, INT count );
Status FillClosedCurve( const Brush* brush, const Point* points,
  INT count, FillMode fillMode, REAL tension );
Status FillClosedCurve(const Brush* brush, const PointF* points,
  INT count, FillMode fillMode, REAL tension );

Эти методы отличаются от вышеназванных наличием параметра Brush (для задания способа заливки) и возможностью указания элемента перечисления FillMode. Он служит для определения того, будут ли заливаться внутренние области (образованные взаимным пересечением кривой). По умолчанию этот параметр принимает значение FillModeAlternate, что означает «не заливать внутренние области, образованные по правилу четности пересечений». При указании параметра FillModeWinding будет залита вся фигура, образованная внешним контуром кривой.

ПРИМЕЧАНИЕ

В реализации GDI+ для .NET обнаружился забавный ляпсус. По неизвестной мне причине, для метода DrawClosedCurve реализован также недокументированный вариант, принимающий параметр FillMode. Разумеется, его указание никак не влияет на поведение этой функции (да он и игнорируется в ее теле, как любезно сообщает Anakrino).

Кривые Безье

С кривыми Безье программисты знакомы уже довольно давно. В PostScript они используются для описания шрифтов и рисования любых кривых, включая эллиптические. Windows GDI также поддерживает построение кривых Безье – например, с помощью функций PolyBezierTo и PolyDraw (последняя недоступна в операционных системах Windows 9x).

Библиотека GDI+ никак не могла обойти вниманием столь серьезный инструмент. Кривые Безье не только поддерживаются при рисовании, но и активно используются самой библиотекой – в частности, при сохранении путей (paths).

ПРИМЕЧАНИЕ

Своим названием кривые Безье обязаны их создателю, Пьеру Этьену Безье (1910-1999). Работая в компании Renault над CAD-системой UNISURF, он создал простое и понятное для рядового дизайнера средство описания сложных кривых. С тех пор они получили широкое распространение в конструкторских и дизайнерских системах.

Для построения кривой Безье необходимо задать 4 точки: две концевые, или опорные (end points) и две направляющие (control points). По принятому соглашению, концевыми считаются первая и четвертая точки кривой, а вторая и третья точки рассматриваются как направляющие. Они, в общем случае, не лежат на кривой, но определяют ее форму:


Следующие методы позволяют соединить две опорные точки кривой Безье с помощью двух направляющих точек.

Status DrawBezier( <kw>const</kw> Pen* pen, <kw>const</kw> Point& pt1, <kw>const</kw> Point& pt2,
  <kw>const</kw> Point& pt3, <kw>const</kw> Point& pt4 );
Status DrawBezier( <kw>const</kw> Pen* pen, <kw>const</kw> PointF& pt1, <kw>const</kw> PointF& pt2, 
  <kw>const</kw> PointF& pt3, <kw>const</kw> PointF& pt4 );
Status DrawBezier( <kw>const</kw> Pen* pen, INT x1, INT y1, INT x2, INT y2,
  INT x3, INT y3, INT x4, INT y4 );
Status DrawBezier( <kw>const</kw> Pen* pen, REAL x1, REAL y1, REAL x2, REAL y2, 
  REAL x3, REAL y3, REAL x4, REAL y4 );

Вот фрагмент демонстрационного приложения Curves, выводящий таким образом сегмент кривой Безье:

void CCurveDlg::DrawBezier(Gdiplus::Graphics &g)
{
    using namespace Gdiplus;
    g.SetSmoothingMode(SmoothingModeHighQuality);

    // рисуем две линии к направляющим точкам
    g.DrawLine(&Pen(Color::Gray, 1), points[0], points[1]);
    g.DrawLine(&Pen(Color::Gray, 1), points[2], points[3]);

    // выводим собственно кривую
    g.DrawBezier(&Pen(Color::Blue, 3), points[0], points[1],
        points[2], points[3]);
}


Вы можете поэкспериментировать с программой, перемещая узловые точки и заставляя принимать кривую самые причудливые формы – и все это всего за один вызов метода DrawBezier!

Существуют также методы DrawBeziers, позволяющие передать массив точек points для построения сразу нескольких сегментов кривой.

Status DrawBeziers( const Pen* pen, const Point* points, INT count );
Status DrawBeziers( const Pen* pen, const PointF* points, INT count );

Переменная count должна содержать число элементов массива points. Для построения N сегментов кривой необходимо передать массив, состоящий ровно из 3*N+1 точек, иначе вызов функции завершится неудачно.

ПРИМЕЧАНИЕ

Аналогичные методы класса Graphics в .NET также не требуют указания параметра count.

Метафайлы

Начиная с самой первой версии, в Windows поддерживается концепция метафайлов – записываемой последовательности графических команд. Такую последовательность можно сохранить на диск в виде файла, а затем в любой момент воспроизвести, как магнитофонную запись. Такой файл будет состоять из набора записей, соответствующих выполненным командам GDI.

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

Первые версии Windows поддерживали довольно ограниченный формат метафайлов – Windows Metafiles. Они поддерживали только 16-битную координатную систему, не содержали информации о разрешении устройства вывода и позволяли записывать не все существующие команды GDI. Такие метафайлы сохраняются в файлах с расширением wmf.

Для Windows 95 был создан новый формат Enhanced Metafiles, свободный от недостатков своего предшественника. Он обладает настоящей независимостью от устройства вывода и поддерживает все команды GDI. Такие «улучшенные» метафайлы хранятся в файлах с расширением emf (Enhanced Metafile Format).

Библиотека GDI+ привнесла новый набор графических команд, и для их сохранения в метафайлах пришлось расширить оригинальный набор записей EMF. Такой расширенный формат получил название… кто бы мог подумать… правильно, EMF+. Но, при желании, в GDI+ можно записывать и оригинальные EMF. При этом все вызовы GDI+ будут транслироваться в набор команд GDI. Разумеется, специфические для GDI+ команды (такие, как установка режима антиалиасинга) не могут быть сохранены в этом формате.

Кроме того, поддерживается и так называемый дуальный формат (Dual EMF+), содержащий двойной набор записей EMF и EMF+. Такие файлы будут открываться в обеих средах.

Для работы с метафайлами в иерархии GDI+ существует класс Metafile. Далее мы рассмотрим некоторые вопросы, которые могут возникнуть при его использовании.

Загрузка

Если вы читали предыдущую часть статьи, то загрузка метафайла не должна составить для вас никакого труда. Дело в том, что класс Metafile является таким же полноправным потомком класса Image, как и Bitmap. Следовательно, для него работают те же самые методы загрузки – из файлов (с указанием имени) и потоков IStream. Вот небольшой пример на C++:

Metafile mf1(L"Sample1.emf");
Metafile *mf2 = (Metafile*) Image::FromFile(L"Sample2.emf");
LPSTREAM pIS;
. . .
Metafile * mf3 = (Metafile*) Image::FromStream(pIS);
. . .
delete mf2;
delete mf3;

Отметим, что конструктор и метод FromFile требуют в качестве имени файла Unicode-строку. При указании путей с подкаталогами в .NET очень удобно воспользоваться новшеством C# – так называемыми verbatim strings:

Image img=Image.FromFile(@"C:\RSDN\GDIPlus\3\Sample.emf");

При этом не придется дублировать символ ‘\’ – разделитель каталогов.

Методы Image::FromFile и Image::FromStream возвращают указатель на класс Image. Если вы уверены в том, что загружен будет именно метафайл (а не растр), то указатель можно «силой» привести к необходимому типу Metafile*. В противном случае, для выяснения типа загруженного изображения можно использовать метод GetRawFormat (в .NET – свойство RawFormat), возвращающий GUID кодека, использованного для загрузки. Для загруженных EMF и EMF+ этот метод вернет константу формата ImageFormatEMF (для .NET свойство примет значение ImageFormat.EMF).

Кроме того, в среде .NET на нас работает вся мощь reflection. У созданного объекта класса Image можно вызвать стандартный метод GetType, который вернет информацию о типе объекта. В частности, вызов

Text = img.GetType().FullName;

присвоит заголовку формы строку «System.Drawing.Imaging.Metafile».

Воспроизведение

Для «проигрывания» содержимого загруженного метафайла в контекст отображения используется старый добрый метод Graphics::DrawImage, содержащий огромное количество вариантов (16 для версии С++ и 30 для .NET). Вот короткая иллюстрация применения этого метода в WinForms-приложении:

Image img;

private void Form1_Paint(object sender, PaintEventArgs e)
{
  if(img!=null) 
  {
    e.Graphics.DrawImage(img, 0, 0);
  }
}

Так же, как и при выводе растров, поддерживаются преобразования координат и геометрические искажения исходного рисунка. Кроме того, исправно работают уже описанные в прошлой части методы исправления цветов и изменения атрибутов вывода (с использованием класса ImageAttributes). Использование метафайлов привносит только одну тонкость: при использовании вариантов метода DrawImage с указанием ограничивающего прямоугольника будут воспроизводиться только те записи, которые соответствуют командам, попадающим в область вывода.

Кроме того, в реализации класса Graphics для .NET Framework существует также совершенно бесполезный метод DrawImageUnscaled, который просто вызывает DrawImage с передаваемыми параметрами (иногда игнорируя часть из них). Это нетрудно выяснить, заглянув в дизассемблированный листинг каждого перегруженного варианта метода DrawImageUnscaled. Можно только порадоваться трудолюбию сотрудников Microsoft, не устающих плодить сущности без необходимости.

Создание и сохранение нового метафайла

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

Причина проста: если для преобразования метафайла в растровое изображение (например, для вывода на экран или растровый принтер) достаточно выполнить содержащиеся в нем команды, то обратное неверно. Для преобразования произвольного изображения в векторный формат требуется очень большая вычислительная работа (нетривиальный анализ), и в общем случае эта задача без участия человека неразрешима. Поэтому «векторного кодека сохранения» библиотека GDI+ не предоставляет (как можно убедиться, прочитав предыдущую часть статьи про растры).

Вместо этого предлагается воспользоваться специальными «конструкторами сохранения» метафайлов. Их легко опознать по обязательному наличию двух параметров: строки с именем файла и параметра referenceHdc, который должен содержать контекст устройства отображения.

Metafile( const WCHAR* fileName, HDC referenceHdc, 
  EmfType type, const WCHAR* description );

Созданный метафайл будет содержать информацию о разрешении этого контекста. Параметр description позволяет указать строковое описание, которое будет сохранено в метафайле. Параметр type определяет формат создаваемого метафайла (например, позволяет задать уже упомянутый формат Dual EMF+):

enum EmfType{
  EmfTypeEmfOnly     = MetafileTypeEmf,
  EmfTypeEmfPlusOnly = MetafileTypeEmfPlusOnly,
  EmfTypeEmfPlusDual = MetafileTypeEmfPlusDual
};

Работа с метафайлом осуществляется через объект Graphics (так же, как и рисование в растровый буфер). Ссылку на метафайл можно передать объекту Graphics в конструкторе. Запись считается законченной при удалении контекста отображения.

Для иллюстрации процесса сохранения нам необходим работающий пример. Немного подшутив над своим же кодом, я чуть модифицировал программу на C++ из самой первой части статьи. Теперь вместо того, чтобы выводить текст приветствия в окно, программа сохраняет его в метафайл:

  using namespace Gdiplus;
  // Все строки - в кодировке Unicode
  WCHAR welcome[]=L"Welcome, EMF+ !";
  RectF bounds(0, 0, 400, 300);

  Metafile metafile(L"Sample.emf", GetDC(0)); 
  Graphics g(&metafile);

  // Создаем кисть с градиентом на все окно и полупрозрачностью
  LinearGradientBrush brush(bounds, Color(130, 255, 0, 0), Color(255, 0, 0, 255),
    LinearGradientModeBackwardDiagonal);  
    
  // Готовим формат и параметры шрифта
  StringFormat format;
  format.SetAlignment(StringAlignmentCenter);
  format.SetLineAlignment(StringAlignmentCenter);
  Font font(L"Arial", 48, FontStyleBold);

  // Выводим текст приветствия, длина -1 означает,
  // что строка заканчивается нулем    
  g.DrawString(welcome, -1, &font, bounds, &format, &brush);

Вы можете найти ее на компакт-диске.

Преобразование в растровое изображение

Что, если созданный или загруженный с диска метафайл необходимо преобразовать в растр и сохранить, например, в формате PNG? Первое решение напрашивается само собой: создать растровую картинку (Bitmap) в памяти, подготовить объект Graphics для вывода в этот растр и нарисовать в полученный контекст исходный метафайл. Такая техника («теневой буфер») была подробно рассмотрена в предыдущей части, при обсуждении мерцания.

Тем не менее, библиотека GDI+, как обычно, предоставляет и более простой способ «ободрать кошку». Для создания растра необходимо просто использовать конструктор Bitmap, принимающий в качестве параметра указатель на Image (в .NET – экземпляр класса Image). Все! В результате получается полноценный растр, который можно сохранить на диск:

Bitmap bm = new Bitmap(img);
bm.Save(@"C:\RSDN\GDIPlus\3\Sample.png", ImageFormat.Png);

Использование этого метода ограничено только тем, что полученный растр будет создан с разрешением дисплея. Заметьте, что в сохраненной картинке будет содержаться корректная информация о прозрачности (в тех областях, которые не были затронуты командами вывода).

Изучение команд метафайла

Формат записей метафайла документирован, и для разбора его содержимого вполне можно написать программу, читающую структуры данных и интерпретирующую их содержимое. Но зачем? Такая работа уже проделывается библиотекой GDI+ при отображении метафайлов, и разработчики могут использовать ее в своих приложениях. Для этого предназначен метод EnumerateMetafile класса Image, у которого есть множество перегруженных вариантов. Здесь в качестве иллюстрации приведены только два объявления: одно для C++ (библиотека GDI+ для Windows) и второе для C# (WinForms).

[C++]
Status EnumerateMetafile(
  const Metafile* metafile,
  const Point& destPoint,
  EnumerateMetafileProc callback,
  VOID* callbackData,
  ImageAttributes* imageAttributes
);
[C#]
[ComVisible(false)]
public void EnumerateMetafile(
   Metafile metafile,
   Point destPoint,
   Graphics.EnumerateMetafileProc callback,
   IntPtr callbackData,
   ImageAttributes imageAttr
);

Как видите, этот метод очень похож на DrawImage. Точно так же необходимо передать отображаемый метафайл и начальные координаты для «рисования» (destPoint). Как и у DrawImage, существует возможность изменить геометрию и атрибуты выводимого изображения. Единственные уникальные параметры – это callback и callbackData. Первый является указателем на процедуру (в .NET – делегатом), которая будет последовательно вызываться библиотекой для каждой обнаруженной записи в метафайле. Второй параметр полезен, если для работы этой процедуры требуются какие-то дополнительные параметры. Вот примерное определение такой процедуры:

[C++]
BOOL CALLBACK metaCallback( EmfPlusRecordType recordType, unsigned int flags,
  unsigned int dataSize, const unsigned char* recordData, void* callbackData );
[C#]
public bool metaCallback( EmfPlusRecordType recordType, int flags,
  int dataSize, IntPtr recordData, PlayRecordCallback callbackData );

Воспользовавшись параметром callbackData, можно, например, передать в процедуру metaCallback (версии для C++) указатель на метафайл, записи которого перечисляются в данный момент. Тогда для выполнения графических команд можно будет воспользоваться методом Metafile::PlayRecord:

BOOL CALLBACK metaCallback( EmfPlusRecordType recordType, unsigned int flags, 
  unsigned int dataSize, const unsigned char* recordData, void* callbackData)
{
  // выполним явное приведение типа указателя к Metafile*
  ((Metafile*)callbackData)->PlayRecord(recordType, flags, dataSize, recordData);
  return TRUE;
}

Параметр recordType при каждом вызове callback-процедуры принимает значение из перечисления EmfPlusRecordType, содержащего аж 253 элемента (самого большого перечисления GDI+). Все эти элементы либо прямо соответствуют командам GDI/GDI+, либо обозначают служебные записи метафайла. При вызове PlayRecord можно подменять этот параметр своими значениями для выполнения совершенно других команд GDI+ в методе PlayRecord. Но для этого необходимо твердо знать значение соответствующих недокументированных параметров recordData и dataSize (и передавать, соответственно, измененные значения и в них).

Перечисление записей: специфика .NET

В документации по WinForms указывается, что среда .NET предоставляет собственную версию делегата для выполнения соответствующих команд метафайла в теле нашего callback-обработчика. Эта версия передается в обработчик в параметре callbackData. Для выполнения команды достаточно вызвать полученный делегат (пример скопирован из документации):

// Define callback method.
private bool MetafileCallback(
               EmfPlusRecordType recordType,
               int flags,
               int dataSize,
               IntPtr data,
               PlayRecordCallback callbackData)
{
  // Play only EmfPlusRecordType.FillEllipse records.
  if (recordType == EmfPlusRecordType.FillEllipse)
  {
    // Play metafile.
    callbackData(recordType, flags, dataSize, data);
  }
  return true;
}
ПРЕДУПРЕЖДЕНИЕ

Внимание! Это явная ошибка документации .NET Framework (ох, уже в который раз!) Приведенный пример откомпилируется, но его выполнение ни к чему хорошему не приведет. О том же сказано и в книге Петцольда [3].

Эксперименты показали, что в параметре callbackData всегда передается нулевое значение, независимо от фазы луны. Вызов такого «делегата» завершится исключением NullReferenceException.

Все-таки создать рабочую версию применения метода EnumerateMetafile для .NET вполне возможно. Для этого нам придется немного повозиться с методом Metafile.PlayRecord. Дело в том, что он в качестве параметра data принимает массив байтов:

public void PlayRecord( EmfPlusRecordType recordType, int flags,
  int dataSize, byte[] data );

Но в теле callback-метода нам доступен только unmanaged-указатель IntPtr recordData (см. объявление делегата). Для копирования необходимых данных в managed-массив .NET можно воспользоваться классом System.Runtime.InteropServices.Marshal:

public bool DrawRecordsCallback( EmfPlusRecordType recordType, int flags,
  int dataSize, IntPtr recordData, PlayRecordCallback callbackData)
{
  // это не сработает:
  //callbackData(recordType, flags, dataSize, recordData);

  byte[] arr = new byte[dataSize];

  // см. примечание
  if(recordData!=IntPtr.Zero) 
    Marshal.Copy(recordData, arr, 0, dataSize);

  // вызываем только выбранные пользователем команды
  if(frmItems.lbRecords.GetItemChecked(curRecord++))
    mfImage.PlayRecord(recordType, flags, dataSize, arr);

  return true;
}

Как видим, метод Copy позволяет перенести данные в управляемый хип, что нам и требовалось.

ПРИМЕЧАНИЕ

Увидев в книге Петцольда очень похожий пример вызова PlayRecord с использованием Marshal.Copy, я поначалу обомлел от такого совпадения. Тем не менее, нам есть, чем ответить мастеру, чтобы не быть обвиненными в плагиате. :)

Версия Петцольда всегда вызывает метод Copy. При передаче 0 в параметре recordData этот метод попытается обратиться к несуществующему участку памяти. Автор книги сам признается, что его программа почему-то не в состоянии отображать создаваемые WinForms метафайлы, но работает с GDI EMF. Для исправления этой ошибки достаточно внести проверку такого условия, что и присутствует в вышеприведенном листинге.

Да, для достижения заветной цели (перебора записей метафайла в WinForms) нам пришлось немного потрудиться. Но эти трудности с лихвой окупаются другим замечательным качеством .NET – reflection. Любой элемент перечисления (даже такого гигантского, как EmfPlusRecordType), «знает» свое строковое имя, что позволяет легко создать версию приложения, перечисляющую записи выбранного метафайла поименно. Вот фрагмент демонстрационной программы:

public bool EnlistRecordsCallback( EmfPlusRecordType recordType, int flags,
  int dataSize, IntPtr recordData, PlayRecordCallback callbackData)
{
  frmItems.lbRecords.Items.Add(recordType.ToString(), true);
  return true;
}

Просто, не правда ли? Метод ToString вернет строковое имя элемента перечисления, избавляя программиста от утомительного кодирования 253 строковых констант. Вот результат работы данного метода:


Полный текст приложения (оно позволяет выполнять только выбранные пользователем команды метафайлов) находится на компакт-диске к журналу. Там же имеется откомпилированная версия, которую можно использовать для изучения содержимого различных метафайлов (например, входящих в комплект поставки Microsoft Visual Studio):


Удачи в исследовательской работе!

Полезные ресурсы

  1. Platform SDK Documentation. Незаменимый ресурс для программистов GDI+.
  2. .NET Framework Documentation. Документация для .NET-программистов.
  3. Charles Petzold. Programming Windows with C#. В этой книге большое внимание уделено программированию графики в .NET.
  4. Bob Powell's GDU+ FAQ. Набор полезных советов для начинающих. http://www.bobpowell.net/gdiplus_faq.htm
  5. Фень Юань. Программирование графики для Windows. Исчерпывающее руководство по программированию GDI


Эта статья опубликована в журнале RSDN Magazine #3. Информацию о журнале можно найти здесь