Контур текста довольно полезная штука. Не только в праздничном оформлении, но и во вполне деловой практике. Оконтуренный текст достаточно просто получить в GDI+ и, прямо скажем, совсем не просто в D2D. Однако, как быть в старом добром GDI?
GDI Path
В старом добром GDI есть такая штука, как траектория, Path. В контексте устройства может быть только одна траектория, и это не объектная вещь, как в GDI+. Поэтому начало траектории в контексте DC знаменует функция именно такого вида:
1 |
function BeginPath(DC: HDC): BOOL; stdcall; |
и завершает траекторию:
1 |
function EndPath(DC: HDC): BOOL; stdcall; |
Между BeginPath и EndPath могут находиться функции рисования, список которых можно узнать сходив по ссылке.
Нарисовать получившуюся траекторию текущим пером можно функцией:
1 |
function StrokePath(DC: HDC): BOOL; stdcall; |
Если требуется только залить текущей кистью, то используется функция:
1 |
function FillPath(DC: HDC): BOOL; stdcall; |
Если требуется нарисовать контур и залить, то используется такое комбо:
1 |
function StrokeAndFillPath(DC: HDC): BOOL; stdcall; |
Таким образом, чтобы нарисовать контур текста, необходимо сделать следующее:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
// GDI Path: Нарисовать контур текста procedure DrawTextGDIPath(ACanvas: TCanvas; const AOutputPt: TPoint; const AText: string; AWidth: Integer; AColor: TColor; ATransparent: Boolean = False); begin with ACanvas do begin // Если этого не сделать, будет рамка вокруг текста Brush.Style := bsClear; // Говорим контексту, что траектория стартует BeginPath(Handle); // Рисуем текст TextOut(AOutputPt.X, AOutputPt.Y, AText); // Говорим контексту, что траектория закончена EndPath(Handle); // Цвет и толщина контура Pen.Color := AColor; Pen.Width := AWidth; // Если прозрачность не нужна if not ATransparent then begin // Цвет и стиль заливки Brush.Style := bsSolid; Brush.Color := Font.Color; end; // Рисуем текст if ATransparent then // Рисуем только контур StrokePath(Handle) else // Рисуем контур и заливаем StrokeAndFillPath(Handle); end; end; |
И, если честно, то получилось так себе:
Потому что зубцы и ступеньки:
GDI+ Path
Процесс рисования контура в GDI+ и замысловатей, и проще одновременно:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
// Нарисовать контур текста GDI+ procedure DrawTextGDIP(ACanvas: TCanvas; const AOutputPt: TPoint; const AText: string; AWidth: Single; AColor: TColor; ATransparent: Boolean = False); var gp: TGPGraphics; gpPen: TGPPen; gpFont: TGPFont; gpPath: TGPGraphicsPath; gpBrush: TGPBrush; gpFamily: TGPFontFamily; begin gp := TGPGraphics.Create(ACanvas.Handle); gpPen := nil; gpFont := nil; gpPath := nil; gpBrush := nil; gpFamily := nil; if not ATransparent then AWidth := AWidth * 2; try gp.SetSmoothingMode(SmoothingModeAntiAlias8x4); with ACanvas do begin gpPen := TGPPen.Create( ColorRefToARGB(ColorToRGB(AColor)), AWidth); gpPath := TGPGraphicsPath.Create; gpFont := TGPFont.Create(Handle, Font.Handle); gpFamily := TGPFontFamily.Create; gpFont.GetFamily(gpFamily); gpPath.AddString(AText, -1, gpFamily, gpFont.GetStyle, gpFont.GetSize, TGPPoint(AOutputPt), nil); gp.DrawPath(gpPen, gpPath); if not ATransparent then begin gpBrush := TGPSolidBrush.Create( ColorRefToARGB(ColorToRGB(Font.Color))); gp.FillPath(gpBrush, gpPath); end; end; finally FreeAndNil(gpFamily); FreeAndNil(gpBrush); FreeAndNil(gpPath); FreeAndNil(gpFont); FreeAndNil(gpPen); FreeAndNil(gp); end; end; |
Вначале происходит инициализация всех нужных классов.
Затем происходит отрисовка текста в траектории gpPath:
1 2 3 4 5 6 |
gpPath.AddString(AText, -1, gpFamily, gpFont.GetStyle, gpFont.GetSize, TGPPoint(AOutputPt), nil); |
Затем мы рисуем траекторию либо контуром, либо контур и заливка. Алгоритм, по сути, тот же самый, что и для GDI Path.
Отдельно хочется акцентировать на создании шрифта:
1 |
gpFont := TGPFont.Create(Handle, Font.Handle); |
Это очень простая строка, и почему так происходит можно посмотреть тут.
И далее мы просто вытаскиваем из шрифта все нужные данные:
1 2 3 4 |
... gpFont.GetStyle, gpFont.GetSize, ... |
Также можно посмотреть нюансы TGPFontFamily.
1 2 |
gpFamily := TGPFontFamily.Create; gpFont.GetFamily(gpFamily); |
Результат прекрасен, именно то, что хотелось видеть. Да, я топлю за GDI+ )))
Время отрисовки правда больше в 3 раза, чем в GDI, но ведь красота требует жертв, не правда ли?
Напоминаю, это всё таки GDI+ со всем своим неисчерпаемым ресурсом изобразительных средств. Например, если вместо скучного одноцветного пера мы создадим перо на изображении, станет повеселее:
1 2 3 |
img := TGPBitmap.Create(PenBitmap.Handle, PenBitmap.Palette); gpBrush := TGPTextureBrush.Create(img); gpPen := TGPPen.Create(gpBrush, AWidth); |
Естественно, всё что создаём, в конце надо освободить.
В процедуру передаётся координата точки отрисовки для текста. Она одинакова для всех процедур, используемых в этом проекте. И считается следующим образом:
1 2 3 4 5 |
// Центрируем надпись ACanvas.Font.Assign(pb.Font); Pnt := rct.CenterPoint; Pnt.X := Pnt.X - ACanvas.TextWidth(str) div 2; Pnt.Y := Pnt.Y - ACanvas.TextHeight(str) div 2; |
Так вот, в GDI+ свои понятия о ширине, высоте и выводе текста на экран. Поэтому будет наблюдаться некоторое смещение. Если работаем в GDI+, то при расчётах координат необходимо придерживаться понятий GDI+.
GDI Text
Траектория в GDI — хороший инструмент. Но в силу своей абсолютной не-эстетичности, и нашего заскорузлого перфекционизма, для отрисовки контура текста нам категорически не подходит. Зубцы.. ступеньки.. Скорость — да, впечатляет. Остальное — отстой.
Кто выбрал для себя комфортный путь GDI+, дальше может не читать.
Кто упёрт и верит в тернистый и извилистый GDI, двигаемся дальше.
Никакого другого способа нарисовать красивый контур текста, используя только GDI, кроме как нарисовать тот же самый текст, но со смещением по разные стороны от местоположения текста — нет.
Непрозрачный контур текста
Сказано-сделано.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
// Нарисовать контур текста многократным повторением procedure _DrawTextGDI(ACanvas: TCanvas; const AOutputPt: TPoint; const AText: string; AWidth: Integer; AColor: TColor; ATransparent: Boolean = False); var clr: TColor; X,Y: Integer; begin with ACanvas do begin clr := Font.Color; Font.Color := AColor; Brush.Style := bsClear; // Рисовать надписи цвета контура со смещением // Этот цикл не оптимизирован, копипастить не надо for X := -AWidth to AWidth do for Y := -AWidth to AWidth do TextOut(AOutputPt.X+X, AOutputPt.Y+Y, AText); if not ATransparent then begin // Возвращаем шрифту цвет Font.Color := clr; // Рисуем текст TextOut(AOutputPt.X, AOutputPt.Y, AText); end; end; end; |
А неплохо.
Единственная проблема, такой текст не может быть прозрачным. Потому что рамка на самом деле — это один и тот же текст, нарисованный со смещением со всех сторон от конечного местоположения текста.
Грубо это можно представить так:
А потом поверх «шлёпается» текст с заданным цветом. Происходит это тут:
1 2 3 4 5 6 7 |
if not ATransparent then begin // Возвращаем шрифту цвет Font.Color := clr; // Рисуем текст TextOut(AOutputPt.X, AOutputPt.Y, AText); end; |
Если вот эта вся мешанина под текстом была бы одного цвета, то всё выглядело бы так:
Теперь давайте решим проблему прозрачности.
Прозрачный контур текста. Спрайт
Проблема прозрачности может быть решена копированием предварительно сохранённого фона в область текста, которая должна стать «прозрачной». Прозрачный канвас — это миф, ребята. Всегда надо что-то куда-то копировать. Даже если мы этого не делаем, за нас это делает система.
Перенести часть битмапа на другой можно кучей разных способов, которые по большому счёту, делают одно и то же. Собирают в одно целое разные части с указанием битовой операции, которую нужно применить к пикселям того или иного изображения. Я буду использовать BitBlt и по минимуму MaskBlt. В итоге, мы всё равно откажемся от этой концепции.
Почему я так подробно останавливаюсь на том, что не пригодится. Во-первых, всегда есть люди, которые утверждают, что можно сделать так, и будет быстрее и лучше. Сами при этом ни разу ничего подобного не делали, но готовы спорить до хрипоты, до драки.
Вторая причина — со спрайтами работа именно такая. Это не пригодится только в случае рисования текста. Если будет когда-нибудь статья про спрайты, то можно будет сюда сослаться. А в самой статье сделать несколько по другому. Для полноты картины будет весьма полезно.
Шаг1. Сохранить фон
Итак, перед тем, как начать рисовать контур, запоминаем фон.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// 1. Если прозрачный контур, подготовим фон if ATransparent then begin // Прямоугольник текста rct.TopLeft := AOutputPt; rct.Right := rct.Left + ACanvas.TextWidth(AText)+ round(ACanvas.TextHeight(AText)/2); rct.Bottom := rct.Top + ACanvas.TextHeight(AText)+1; // Копируем фон под будущей надписью bmp := TBitmap.Create; bmp.SetSize(rct.Width, rct.Height); bmp.Canvas.CopyRect(bmp.Canvas.ClipRect, ACanvas, rct); end; |
Шаг 2. Рисование текста
Затем рисуем контур, а вернее этот дикий и пока не оптимизированный массив текста.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// 2. Рисуем контур with ACanvas do begin clr := Font.Color; Font.Color := AColor; Brush.Style := bsClear; // Рисовать надписи цвета контура со смещением // Этот цикл не оптимизирован, копипастить не надо for X := -AWidth to AWidth do for Y := -AWidth to AWidth do TextOut(AOutputPt.X+X, AOutputPt.Y+Y, AText); if not ATransparent then begin // Возвращаем шрифту цвет Font.Color := clr; // Рисуем текст TextOut(AOutputPt.X, AOutputPt.Y, AText); end; end; |
Шаг 3. Маска
Затем готовим маску, рисуем белые буквы на чёрном фоне:
1 2 3 4 5 6 7 8 9 |
// 3. Создаём маску - белые буквы на чёрном фоне msk := TBitmap.Create; msk.PixelFormat := pf1Bit; // Это обязательно msk.SetSize(bmp.Width, bmp.Height); msk.Canvas.Brush.Color := clBlack; msk.Canvas.FillRect(msk.Canvas.ClipRect); msk.Canvas.Font.Assign(ACanvas.Font); msk.Canvas.Font.Color := clWhite; msk.Canvas.TextOut(0, 0, AText); |
Шаг 4. Подготовка прозрачной части фона
После этого готовим битмап, содержащий часть, которая должна стать прозрачной в итоговом изображении:
1 2 3 4 5 6 7 |
// 4. Создаём битмап, где нарисован текст и залит фоном src := TBitmap.Create; src.Assign(bmp); // Заранее заготовленный фон MaskBlt(src.Canvas.Handle, 0, 0, rct.Width, rct.Height, bmp.Canvas.Handle, 0, 0, msk.Handle, 0, 0, SRCPAINT); |
Шаг 5. Обратная маска
После этого рисуем обратную маску, где фон белый, а буквы чёрные:
1 2 3 4 5 |
// 5. Маска теперь - черные буквы на белом фоне. Так быстрее msk.Canvas.Brush.Color := clWhite; msk.Canvas.FillRect(msk.Canvas.ClipRect); msk.Canvas.Font.Color := clBlack; msk.Canvas.TextOut(0, 0, AText); |
Шаг 6. Перенос маски на итоговое изображение
Далее переносим черные буквы на прозрачную часть в итоговое изображение:
1 2 3 4 5 |
// 6. Копируем черные буквы на приёмник BitBlt(ACanvas.Handle, rct.Left, rct.Top, rct.Width, rct.Height, msk.Canvas.Handle, 0, 0, SRCAND); |
На рисунке 10 помните была серая заготовка под контур. Вот сейчас видно, что от неё останется.
Шаг 7. Копирование прозрачной части фона
И наконец, копируем «прозрачную часть» (рис.12)
1 2 3 4 5 |
// 7. Копируем фон на место чёрных букв BitBlt(ACanvas.Handle, rct.Left, rct.Top, rct.Width, rct.Height, src.Canvas.Handle, 0, 0, SRCPAINT); |
Итог. Плачевный
В итоге мы получили такую же ступенчатую внутреннюю часть, как если бы рисовали через GDI Path. Это грустно признавать, но маски, которые необходимы для работы алгоритма, ступенчаты, и они не могут быть в этом случае никакими другими.
P.S. Соблазны
Возникает соблазн вместо шагов 5 и 6 сразу нарисовать чёрный текст на будущей прозрачной части итогового изображения. Это путь губителен, как и любой другой соблазн, потому что приводит к следующим неприятностям:
Оптимизация цикла
У нас сейчас используется цикл полного перебора. Проще говоря N * N или N2.
1 2 3 |
for X := -AWidth to AWidth do for Y := -AWidth to AWidth do TextOut(AOutputPt.X+X, AOutputPt.Y+Y, AText); |
Если ширина контура = 1, то N=3 (-1,0,1) и алгоритм нарисует в итоге 3 * 3 = 9 надписей. Если ширина контура = 4, то N = 9 (-4,-3,-2,-1,0,1,2,3,4) и в итоге будет 9 * 9 = 81. Замедление при увеличении ширины контура будет расти в геометрической прогрессии.
Давайте произведём модернизацию цикла, уберём уже нарисованные части:
1 2 3 4 5 6 7 8 9 10 11 12 |
// 2N for X := -AWidth to AWidth do begin TextOut(AOutputPt.X+X, AOutputPt.Y-AWidth, AText); TextOut(AOutputPt.X+X, AOutputPt.Y+AWidth, AText); end; // (N-2)*2 = 2N-4 for Y := -AWidth+1 to AWidth-1 do begin TextOut(AOutputPt.X-AWidth, AOutputPt.Y+Y, AText); TextOut(AOutputPt.X+AWidth, AOutputPt.Y+Y, AText); end; |
Таким образом у нас получается 2N + 2N — 4 = 4(N-1). Если ширина контура = 4 и N = 9, получаем 4*8 = 32. Ощутимо, по сравнению с 81, правда?
Да я больше скажу, если контур = 1, N = 3, то в итоге получим 8, а не 9.
Сравним результаты. Чтобы не мешалась прозрачность, отключим.
Видим, что при абсолютной внешней идентичности, оптимизация разгоняет отрисовку в разы.
Прозрачный контур текста с антиалиасом
Как отрисовку сделать быстрее разобрались. Теперь, как сделать качественней.
Вначале точно также сохраним фон, но в 32-битном варианте:
1 2 3 4 5 6 7 8 |
rct.TopLeft := AOutputPt; rct.Right := rct.Left + ACanvas.TextWidth(AText) + round(ACanvas.TextHeight(AText)/2); rct.Bottom := rct.Top + ACanvas.TextHeight(AText)+1; bmp := TBitmap.Create; bmp.PixelFormat := pf32Bit; // Важно! bmp.SetSize(rct.Width, rct.Height); bmp.Canvas.CopyRect(bmp.Canvas.ClipRect, ACanvas, rct); |
Затем нарисуем текст в черно-белой гамме, но в 32 битах.
1 2 3 4 5 6 7 8 |
msk := TBitmap.Create; msk.PixelFormat := pf32Bit; // Важно! msk.SetSize(bmp.Width, bmp.Height); msk.Canvas.Brush.Color := clBlack; msk.Canvas.FillRect(msk.Canvas.ClipRect); msk.Canvas.Font.Assign(ACanvas.Font); msk.Canvas.Font.Color := clWhite; msk.Canvas.TextOut(0, 0, AText); |
Ровно то же самое, но в 32 битах. Но какая разница с рисунком 11!
Когда рисуется шрифт, система применяет некое подобие антиалиаса, чтобы начертание было спокойным, уравновешенным и радовало глаз.
В этой маске, на границе соприкосновения белого с чёрным, существует градиент:
Переведём цвета в примитивную серую гамму, путём среднего арифметического, и будем воспринимать результат, как альфа канал.
Теперь перенесём данные градиента (msk) в альфа-канал фона (bmp), который скопировали заранее, до отрисовки текста:
1 2 3 4 5 6 7 8 9 |
s := msk.ScanLine[rct.Height-1]; d := bmp.ScanLine[rct.Height-1]; for y := 0 to rct.Height-1 do for x := 0 to rct.Width-1 do begin d^.rgbReserved := (s^.rgbBlue+s^.rgbGreen+s^.rgbRed) div 3; Inc(s); Inc(d) end; |
Везде, где маска чёрная — будет абсолютная прозрачность, где белая — абсолютная непрозрачность, а в местах соприкосновения чёрного с белым будет полупрозрачность с интенсивностью градиента.
Чтобы этот код работал, необходимо, чтобы битмапы совпадали по размерам и были в формате 32 бита. Поэтому во фрагментах кода выше есть комментарий: Важно!
А дальше просто отрисуем поверх:
1 2 |
bmp.AlphaFormat := afDefined; ACanvas.Draw(rct.Left, rct.Top, bmp); |
И в итоге мы получаем очень приятный сглаженный контур (кликнув по картинке, можно рассмотреть поближе):
Листинг
Вот теперь можно смело копипастить и использовать. Возможно, надо как-то переобозвать во что-то типа DrawTextContour, DrawTextWithBorder и т.д.:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
procedure DrawTextGDI(ACanvas: TCanvas; const AOutputPt: TPoint; const AText: string; AWidth: Integer; AColor: TColor; ATransparent: Boolean = False); var clr: TColor; rct: TRect; X,Y: Integer; bmp, msk: TBitmap; s,d: PRGBQuad; begin bmp := nil; msk := nil; try // Если контур прозрачный, сохраним фон if ATransparent then begin rct.TopLeft := AOutputPt; rct.Right := rct.Left + ACanvas.TextWidth(AText) + round(ACanvas.TextHeight(AText)/2); rct.Bottom := rct.Top + ACanvas.TextHeight(AText)+1; bmp := TBitmap.Create; bmp.PixelFormat := pf32Bit; // Важно! bmp.SetSize(rct.Width, rct.Height); bmp.Canvas.CopyRect(bmp.Canvas.ClipRect, ACanvas, rct); end; with ACanvas do begin clr := Font.Color; Font.Color := AColor; Brush.Style := bsClear; // 2N for X := -AWidth to AWidth do begin TextOut(AOutputPt.X+X, AOutputPt.Y-AWidth, AText); TextOut(AOutputPt.X+X, AOutputPt.Y+AWidth, AText); end; // (N-2)*2 = 2N-4 for Y := -AWidth+1 to AWidth-1 do begin TextOut(AOutputPt.X-AWidth, AOutputPt.Y+Y, AText); TextOut(AOutputPt.X+AWidth, AOutputPt.Y+Y, AText); end; // Если непрозрачный контур, рисуем поверх if not ATransparent then begin Font.Color := clr; TextOut(AOutputPt.X, AOutputPt.Y, AText); end; end; // Если контур прозрачный, перенесём ранее сохранённый // фон на итоговое изображение, используя альфа-канал if ATransparent then begin // 32-битная маска msk := TBitmap.Create; msk.PixelFormat := pf32Bit; // Важно! msk.SetSize(bmp.Width, bmp.Height); msk.Canvas.Brush.Color := clBlack; msk.Canvas.FillRect(msk.Canvas.ClipRect); msk.Canvas.Font.Assign(ACanvas.Font); msk.Canvas.Font.Color := clWhite; msk.Canvas.TextOut(0, 0, AText); // Обход битмапов, равных по размеру // Получаем указатели на начало массивов пикселей s := msk.ScanLine[rct.Height-1]; // source d := bmp.ScanLine[rct.Height-1]; // dest // Создаём альфа-канал на сохранённом фоне for y := 0 to rct.Height-1 do for x := 0 to rct.Width-1 do begin d^.rgbReserved := (s^.rgbBlue+s^.rgbGreen+s^.rgbRed) div 3; Inc(s); Inc(d) end; // Просто рисуем фон поверх // Всё что непрозрачно - станет фоном, // что полупрозрачно - смешается с итоговым, // что прозрачно - останется итоговым bmp.AlphaFormat := afDefined; ACanvas.Draw(rct.Left, rct.Top, bmp); end; finally FreeAndNil(bmp); FreeAndNil(msk); end; end; |
Скачать
Друзья, спасибо за внимание!
Исходник (zip) 81.7 Кб. Delphi XE 7, XE 10, XE 11
Исполняемый файл (zip) 1.0 Мб.
Об использовании и скорости поговорим в другой раз, и так много получилось ))) Обязательно сообщу в телеге, подписывайтесь!!!
Ваш перфекционизм впечатляет :). Но лишний раз убедился, что не зря все эти штуки с текстом делаю в GDI+
Поддерживаю всецело. GDI+ для этого и создавался.
Спасибо )))