Как стать автором
Обновить

Типографика и WPF — Рисуем красивый текст

Время на прочтение7 мин
Количество просмотров5.1K

Важно: этот подход устарел, теперь можно просто использовать DirectWrite и получать все плюшки OpenType. Пример конкретной реализации можно найти вот тут.



Введение


Как известно, в WPF есть достаточно мощная встроенная система типографики. К сожалению, эта система ориентирована в основном на работу с документами и, тем самым, все типографические изыски вроде поддержки OpenType невозможно использовать для какого-нибудь простого контрола вроде Label. Но, не смотря ни на что, есть все-таки возможность получить качественную отрисовку текста – просто нужно немного помучаться.

Задача


Зачем вообще рисовать текст? Ну, мне например хочется иметь в блоге красивые заголовки – сделанные теми шрифтами которые выбрал я. Конечно есть уже решения на базе картинок или Flash, но они либо ресурсоемки (как например отрисовка SVG), либо несовместимы с IE (например те что используют элемент Canvas). К тому же, ни одна из доступных систем не поддерживает ни OpenType ни ClearType, то есть она не удобна для мелкого текста, и не позволяет полностью амортизировать свои вложения в дорогие шрифты1.

Решение


Для того, чтобы получить наши заголовки, мы воспользуемся типографической системой WPF. Поскольку наши цели достаточно примитивны, мы будем использовать только три основных класса:


  • Run — этот класс содержит минимальный отрезок текста в WPF и является аналогом <span> в HTML. Каждый Run может иметь свой типографический стиль, что позволяет нам при компоновке смешивать жирный текст, курсив и другие стили в одном предложении.
  • Paragraph — это параграф или некий аналог <div> в HTML. Его суть – содержать несколько Runов (и не только) и показывать их как одно целое. Поскольку мы планируем делать заголовки, одного параграфа нам хватит.
  • FlowDocument — это документ, который может содержать параграфы. По сути дела это некий контрейнер, который держит разные текстовые блоки и может адаптироваться, например, к разным размерам страницы. Нам это не особо нужно, но документ как контейнер нам пригодится, потому что визуальную информацию (то есть текстуру) мы будем вытаскивать именно из него.

Три конструкта, описанные выше, очень легко использовать. Вот достаточно простой пример:



// строка<br/>
Run r = new Run("Hello rich WPF typography");<br/>
// параграф<br/>
Paragraph p = new Paragraph();<br/>
p.Inlines.Add( r );<br/>
// весь документ<br/>
FlowDocument fd = new FlowDocument();<br/>
fd.Blocks.Add(p);<br/>

Субпиксельная оптимизация


На данном этапе мы могли бы просто нарисовать наш FlowDocument в текстуру, но тогда бы мы получили простой черно-белый antialiasing, в то время как с субпиксельной отрисовкой текст выглядит намного четче.



Давайте посмотрим на то, как можно получить эффект, аналогичный ClearType. Во-первых, поскольку нам нужно получить в 3 раза больше информации в горизонтальном плане, давайте растянем наш текст так, чтобы он был в 3 раза шире.



DocumentPaginator dp = ((IDocumentPaginatorSource)fd).DocumentPaginator;<br/>
ContainerVisual cv = new ContainerVisual();<br/>
cv.Transform = new ScaleTransform(3.0, 1.0);<br/>
cv.Children.Add(dp.GetPage(0).Visual);<br/>

Итак, мы создали некий контейнер для Visual элементов (для визуальной составляющей документа), вытащили из документа первую страницу, поместили ее в этот ContainerVisual, и растянули его в 3 раза по горизонтали. Все хорошо, но пока это всего лишь Visual который нужно как-то нарисовать. Не проблема – для этого есть соответствующий API, который рисует Visual прямо в битмап. А точнее не в Bitmap а в RenderTargetBitmap:



// рисуем. не забудьте умножить конечную ширину на 3<br/>
RenderTargetBitmap rtb = new RenderTargetBitmap(2400, 100, 72, 72, PixelFormats.Pbgra32);<br/>
rtb.Render(cv);<br/>

Пожалуй тут и начинаются «капризы» WPF, потому как прямой конверсии в привычный нам System.Drawing.Bitmap нет. Но это ничего – достаточно сериализовать данные в поток а потом получить их из этого потока и у нас получится по сути дела то же самое:



PngBitmapEncoder enc = new PngBitmapEncoder();<br/>
enc.Frames.Add(BitmapFrame.Create(rtb));<br/>
Bitmap zeroth;<br/>
using (MemoryStream ms = new MemoryStream())<br/>
{<br/>
  // пишем все байты в поток<br/>
  enc.Save(ms);<br/>
  // из этого же потока создаем битмап<br/>
  zeroth = new Bitmap(ms);<br/>
}<br/>

Итак, мы получили «нулевой» битмап, то есть печку, от которой мы будем плясать. Если сейчас взять и сохранить битмап, получится примерно вот это:





Не следует удивляться – это действительно просто текст, растянутый в 3 раза с помощью типографический системы WPF. Теперь, дабы подготовить нашу картинку к субпиксельной оптимизации, давайте распределим энергию каждого пиксела на его соседей – двух слева и двух справа2. Это позволит нам сделать очень ровный, не раздражающий пользователя, рисунок. Чтобы это сделать, создадим полезную структурку под названием argb:

public struct argb<br/>
{<br/>
  public int a, r, g, b;<br/>
  public void AddShift(Color color, int shift)<br/>
  {<br/>
    a += color.A >> shift;<br/>
    r += color.R >> shift;<br/>
    g += color.G >> shift;<br/>
    b += color.B >> shift;<br/>
  }<br/>
}<br/>

У этой структуры только одно предназначение – брать составляющие элементы некого Color, модулировать его параметры путем сдвига, и записывать результат. А теперь воспользуемся этой структурой:



public Bitmap Coalesce(Bitmap bmp)<br/>
{<br/>
  int width = bmp.Width;<br/>
  int height = bmp.Height;<br/>
  Bitmap output = new Bitmap(width, height);<br/>
  for (int y = 0; y < height; ++y)<br/>
  {<br/>
    for (int x = 2; x < width - 2; ++x)<br/>
    {<br/>
      argb final = new argb();<br/>
      final.AddShift(bmp.GetPixel(x - 2, y), 3);<br/>
      final.AddShift(bmp.GetPixel(x - 1, y), 2);<br/>
      final.AddShift(bmp.GetPixel(x, y), 1);<br/>
      final.AddShift(bmp.GetPixel(x + 1, y), 2);<br/>
      final.AddShift(bmp.GetPixel(x + 2, y), 3);<br/>
      output.SetPixel(x, y, System.Drawing.Color.FromArgb(<br/>
                              Clamp(final.a),<br/>
                              Clamp(final.r),<br/>
                              Clamp(final.g),<br/>
                              Clamp(final.b)));<br/>
    }<br/>
  }<br/>
  return output;<br/>
}<br/>


Выше мы также воспользовались функцией Clamp(), которая гарантирует что значение цвета всегда меньше или равно 255.



Если мы сейчас снова посмотрим на этот текст, то ничего интересного не увидим – просто текст чуть-чуть «смазался» по горизонтали:





Следующий этап – это получить-таки конечное изображение, т.е. сжать его по горизонтали в 3 раза, используя альфа-значения подпикселей в качестве красного, синего и зеленого цветов соответственно. Единственная поправка – это то что нам нужно инвертировать эти альфа-значения, то есть вычесть значение из 255:



Bitmap second = new Bitmap((int)(first.Width / 3), first.Height);<br/>
for (int y = 0; y < first.Height; ++y)<br/>
{<br/>
  for (int x = 0; x < second.Width; ++x)<br/>
  {<br/>
    // насыщение берем из альфа-значений, а самой альфе присваиваем 255<br/>
    System.Drawing.Color final = System.Drawing.Color.FromArgb(255,<br/>
      255 - first.GetPixel(x * 3, y).A,<br/>
      255 - first.GetPixel(x * 3 + 1, y).A,<br/>
      255 - first.GetPixel(x * 3 + 2, y).A);<br/>
    second.SetPixel(x, y, final);<br/>
  }<br/>
}<br/>


Последнее, что можно сделать – это обрезать битмап. Вот и все:





В результате мы получили текст с поддержкой ClearType-образной отрисовки. А что же до поддержки OpenType, так это просто. Например, на своем сайте я использую вот это скрипт:



Run r1 = new Run(text.Substring(0, 1))<br/>
{<br/>
  FontFamily = new FontFamily(fontName),<br/>
  FontSize = fontSize,<br/>
  FontStyle = FontStyles.Italic<br/>
};<br/>
if (char.IsLetter(text[0]))<br/>
  r1.SetValue(Typography.StandardSwashesProperty, 1);<br/>
Run r2 = new Run(text.Substring(1, text.Length - 2))<br/>
{<br/>
  FontFamily = new FontFamily(fontName),<br/>
  FontSize = fontSize,<br/>
  FontStyle = FontStyles.Italic<br/>
};<br/>
r2.SetValue(Typography.NumeralStyleProperty, FontNumeralStyle.OldStyle);<br/>
Run r3 = new Run(text.Substring(text.Length - 1))<br/>
{<br/>
  FontFamily = new FontFamily(fontName),<br/>
  FontSize = fontSize,<br/>
  FontStyle = FontStyles.Italic<br/>
};<br/>
r3.SetValue(Typography.StylisticAlternatesProperty, 1);<br/>


Я думаю тут и без слов все понятно – первая буква заголовка использует «рукописную» форму, а последняя – альтернативную. Можно применить к нашему заголовку:





Впрочем, поскольку у нашего заголовка нет альтернативы для конечной буквы s, можно взять что-нибудь более «показательное»:





Заключение


Примеры использования всего вышеописанного есть в моем блоге – я использую эту подсистему для генерации загаловков. И прежде чем вы спросите – нет, индексации такой подход не мешает (если не верите, сделайте ‘view source’ и посмотрите как это реализовано), да и с «читалками» вроде Google Reader тоже никаких проблем нет. С другой стороны, на моем блоге видны некие баги системы, которые я на данный момент устраняю.



То что я описал выше – ужасно медленный подход. Функции GetPixel() и SetPixel() – это настоящее зло, и по-хорошему все манипуляции с битмапами должны быть сделаны в С++ с использованием OpenMP или Intel TBB. Но в моем случае картинку нужно генерировать всего один раз (причем ее генерирую я – сразу после того как добавляю запись в блог), так что мне все равно. А вытащить байты из битмапа и обработать их через P/Invoke – несложно.



Заметки


  1. К тому же, некоторые системы требуют загрузки ваших шрифтов к разработчикам на сайт.

  2. Этот алгоритм взят отсюда. Единственная разница – я использовал те коэффициенты которые легче использовать, чтобы обойтись сдвигом вместо умножения.



Петербургская Группа ALT.NET

Теги:
Хабы:
Всего голосов 44: ↑34 и ↓10+24
Комментарии25

Публикации

Истории

Работа

.NET разработчик
49 вакансий

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань