1 мая 2009 в 12:50

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

.NET*

Важно: этот подход устарел, теперь можно просто использовать 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

Дмитpий Hecтepук @mezastel
карма
111,8
рейтинг –0,1
Пользователь
Самое читаемое Разработка

Комментарии (25)

  • –1
    У меня одного картинки не загружаются?
  • +1
    столько текста про красивый текст и ниодной картинки
  • –1
    у меня все картинки отображаются
  • –1
    Опера АС — все отлично…
  • –1
    Это конечно круто и поучительно… но использование такого шрифта в заголовках упомянутого блога — чересчур (три-четыре шрифта на страницу получается).
    • –1
      Почему черезчур?
      • 0
        Объективный критерий — это четвёртый, если считать со ссылками, вид шрифта на странице.

        А субъективно — не смотрится такой шрифт в «календарном» дизайне. Вокруг всё такое прямоугольное, а заголовки какие-то круглые.
  • –1
    Ну это всё хорошо, а вот как посмотреть на рабочий пример в ASP.NET?
    • 0
      nesteruk.org/blog — asp.net блог который это использует.
      • –2
        да я верю в то, что оно работает.
        Можно мне отдельно получить .sln & .prj?
  • +2
    Надеюсь ваш метод никто не будет применять в таком виде, как вы привели его здесь. SetPixel с вызовом ненужных функций внутри двухуровнего цикла это жесть чистой воды и удар по яйцам системе любой мощности. Даже на высокоуровневом .NET это можно было реализовать раз в 50 эффективнее.
    • 0
      Полностью согласен, оптимизирую по мере надобности.
  • +1
    отлично!
    • +2
      Ужасно! Не применяйте на практике приведенный здесь код. Как пользователь прошу. Перепишите нормально, прежде чем использовать.
  • 0
    Замечание: Paragraph — это скорее аналог <p>, а аналог div-а — это Section.

    Ну, и для отрисовки SetPixel — это действительно удар по яйцам, как уже говорили. Можно использовать штатный WriteableBitmap, не говоря уже о raw-доступе к пиксельному буферу BitmapSource и 3rd party классах типа VideoRendererElement, которые предоставляют еще более эффективный доступ к пикселям.
  • +1
    Хм. А я что-то не понял смысла преобразования. На самой первой картинке текст уже размазанный. Зачем его растягивать и размазывать ещё раз?

    Ну. И непонятно, что же всё-таки мешает использовать SVG? Он, конечно, неспешный, но в буквах же не сотни тысяч полигонов для отрисовки. Достаточно быстро должно работать. Плюс пользователь сам может выбрать параметры сглаживания (я вот не люблю antialiasing) и качества отображения. Плюсом SVG даёт корректное масштабирование.
    • 0
      Просто если не размывать текст по горизонтали, то в последствии «эффект ClearType» будет некрасивый — это описано тут: www.grc.com/ct/cttech.htm
      • 0
        Спасибо за ссылку.
    • +1
      Использовать SVG мешает то, что

      • • Не поддерживается в IE ну совсем никак
      • • Файлы достигают размера 2Мб, а картинки — всего 4Кб, то есть в 500 раз меньше.
      • • Абсолютно непонятно как получить фичи OpenType без шаманства. (с шаманством вроде свой ОТ-библиотеки все работало)
  • 0
    Ну. И небольшая поправка к тексту: наверное, не нужно противопоставлять OpenMP работе с Set/GetPixel. Это вещи всё же из разных категорий. И не факт, кстати, что многопоточность в этой вот конкретно задаче поможет ускорение получить: данные не используются повторно, и вычислений над ними мало, следовательно, всё упрётся в общую шину к памяти или к кэшу. Хотя, проверять надо, конечно.
    • 0
      В данной задаче, совершенно очевидно что многопоточность позволила бы существенно ускорить процесс.
      • 0
        Из-за общей шины к памяти и низкого уровня повторного использования данных совсем не очевидно, imho. Время будет, напишу тест, чтобы это продемонстрировать.
      • 0
        В данной задаче совершенно очевидно, что наличие у автора головы на плечах и рук не из жопы растущих позволили бы существенно ускорить процесс. Многопоточность нужна уже после выполнения этих условий.
        • 0
          Так автору и не нужна была скорость, в чём он честно признался.
  • 0
    WPF в Mono ведь нет от слова совсем, верно? В линуксах не погонять?

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