Статья о том, как легко делать графические кнопки для панелей инструментов, не таская за приложением гору бинарных ресурсов с картинками. Этот метод платформонезависимый и может быть использован в различных языках и средах, позволяющих работать с графикой и растровыми изображениями. Ниже приводятся примеры для C# (WinForms / WPF), JavaScript, Python.

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

Тогда возникла идея - рисовать одноцветные изображения, кодировать их в строку и хранить прямо в свойствах объекта (например - кнопки). Самый простой вид кодировки - когда шесть точек изображения превращаются в шестибитный код, соответствующий некоторому символу из ASCII таблицы с заданным базовым смещением.

пример такой панельки с одноцветными пиктограммами на кнопочках
пример такой панельки с одноцветными пиктограммами на кнопочках

Казалось бы, для этих целей подойдёт Base64. Но это табличный алгоритм кодирования, что делает его использование не таким компактным и удобным, как прямое кодирование в код ASCII. А хотелось иметь простой и универсальный алгоритм расшифровки в несколько строк для всех платформ.

Кодировка выглядит максимально просто: 0-ой байт - код базового смещения, 1 - ширина изображения в пикселях + код смещения, 2 - высота + код смещения, 3 - первые шесть пикселей изображения + код смещения, 4 - следующие шесть пикселей + код, и так далее...

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

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

BitImageTool для рисования битовых изображений
BitImageTool для рисования битовых изображений

Функций "сохранить" и "загрузить" нет, потому как они и не нужны, просто копируем строку из поля результата, или наоборот, вставляем её туда, и изображение в поле редактора обновляется автоматически.

Следует обратить внимание на базовый код - он нужен для того, чтобы избежать в ряде случаев проблем с использованием недопустимых символов внутри строк. Например, такие символы как "<" и ">" могут привести к проблемам внутри XAML строк в WPF, и чтобы их не было в результирующей строке, можно выставить базу, к примеру, на 63. Для каждого изображения этот код свой, он присутствует в строке на нулевой позиции.

Для декодирования строки в изображение можно применить простой алгоритм:

Bitmap stringToBitmap(Color pen, string s)
{
    byte basecode = s[0];
    byte w = s[1] - basecode;
    byte h = s[2] - basecode;
    Bitmap bmp = new Bitmap(w, h);
    for (int y = 0; y < h; y++)
        for (int x = 0; x < w; x++)
            bmp.SetPixel(x, y, ((s[3 + (y * w + x) / 6] - basecode) & (1 << (y * w + x) % 6)) > 0 ? pen : Color.Transparent);
    return bmp;
}

где "pen" - это параметр цвета получаемого изображения, а "s" - сами данные изображения, закодированные в строку.

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

Вот несколько примеров использования:

JavaScript:

<html>
    <body style="background-color: #333">
        <canvas class="textimage" image-data="0@@00080060ncWolI6HV0V1PI0H6@V1VigOnm706001000" image-color="0xFF4040FF"></canvas>
        <canvas class="textimage" image-data="0@@00Po18@0boP488iOB2TW019@h34R0QhOn0070P00000" image-color="0x40FF40FF"></canvas>
        <canvas class="textimage" image-data="0@@00084033h`1>LPW7`o0h70d00;0@30\00=0`30H0000" image-color="0x4040FFFF"></canvas>
        
        <script>
            let canvases = document.getElementsByClassName("textimage");
            Array.from(canvases).forEach(function(canvas)
            {
                let ctx = canvas.getContext("2d");
                let s = canvas.getAttribute("image-data");
                let pen = parseInt(canvas.getAttribute("image-color"));
                let penr = pen >> 24 & 0xFF;
                let peng = pen >> 16 & 0xFF;
                let penb = pen >> 8 & 0xFF;
                let pena = pen & 0xFF;
                
                let basecode = s.charCodeAt(0);
                let w = s.charCodeAt(1) - basecode;
                let h = s.charCodeAt(2) - basecode;
                canvas.width = w;
                canvas.height = h;
                let idata = ctx.getImageData(0, 0, w, h);
                let data = idata.data;
                for (let y = 0; y < h; y++)
                    for (let x = 0; x < w; x++)
                        if (((s.charCodeAt(3 + (y * w + x) / 6) - basecode) & (1 << (y * w + x) % 6)) > 0)
                        {
                            let p = (y * w + x) * 4;
                            data[p] = penr;
                            data[p + 1] = peng;
                            data[p + 2] = penb;
                            data[p + 3] = pena;
                        }
                ctx.putImageData(idata, 0, 0);
            });
        </script>
    </body>
</html>

Этот код достаточно сохранить в файл и запустить в браузере. Главное, чтобы было разрешено исполнение скриптов. Тогда на экране появится несколько разноцветных изображений. Сами изображения заданы свойством image-data тэгов canvas. Их цвет задан свойством image-color.

результат в браузере
результат в браузере

Следующий пример для WPF:

using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Imaging;

namespace EXLib
{
    public class TextEncodedImage : Image
    {
        WriteableBitmap stringToBitmap(SolidColorBrush pen, string s)
        {
            try
            {
                uint ppen = ((uint)pen.Color.A << 24) + ((uint)pen.Color.B << 16) + ((uint)pen.Color.G << 8) + pen.Color.R;
                int basecode = s[0];
                int w = s[1] - basecode;
                int h = s[2] - basecode;
                var bmp = new WriteableBitmap(w, h, 96, 96, PixelFormats.Bgra32, null);

                uint[,] data = new uint[h, w];
                for (int y = 0; y < h; y++)
                    for (int x = 0; x < w; x++)
                        if (((s[3 + (y * w + x) / 6] - basecode) & (1 << (y * w + x) % 6)) > 0) data[y, x] = ppen;

                bmp.WritePixels(new System.Windows.Int32Rect(0, 0, w, h), data, bmp.Format.BitsPerPixel * w / 8, 0);

                return bmp;
            }
            catch { return null; }
        }

        public static readonly DependencyProperty EncodedImageProperty = DependencyProperty.Register("EncodedImage", typeof(string), typeof(TextEncodedImage), new PropertyMetadata(null, propertyChangedCallback));
        public string EncodedImage
        {
            get { return (string)GetValue(EncodedImageProperty); }
            set { SetValue(EncodedImageProperty, value); }
        }

        public static readonly DependencyProperty EncodedImageColorProperty = DependencyProperty.Register("EncodedImageColor", typeof(SolidColorBrush), typeof(TextEncodedImage), new PropertyMetadata(null, propertyChangedCallback));
        public SolidColorBrush EncodedImageColor
        {
            get { return (SolidColorBrush)GetValue(EncodedImageColorProperty); }
            set { SetValue(EncodedImageColorProperty, value); }
        }

        private static void propertyChangedCallback(DependencyObject obj, DependencyPropertyChangedEventArgs e)
        {
            TextEncodedImage o = (TextEncodedImage)obj;
            o.Source = o.stringToBitmap((SolidColorBrush)o.GetValue(EncodedImageColorProperty) ?? Brushes.White, (string)o.GetValue(EncodedImageProperty));
        }
    }
}

Здесь создаётся класс TextEncodedImage, с двумя свойствами (типа DependencyProperty), чтобы можно было их задавать через XAML.

Вот пример использования этого контрола в одном из моих проектов:

...
<DockPanel>
    <Button DockPanel.Dock="Left" HorizontalAlignment="Left" ToolTip="Reload cache" Click="ReloadMenuItem_Click" Width="30" Height="30">
        <local:TextEncodedImage EncodedImage="0@@00080060ncWolI6HV0V1PI0H6@V1VigOnm706001000" Width="16" Height="16" EncodedImageColor="{DynamicResource MenuButton_Refresh}"></local:TextEncodedImage>
    </Button>
    
    <Rectangle Width="2" Fill="{DynamicResource PanelBack}"></Rectangle>
    <Button DockPanel.Dock="Left" HorizontalAlignment="Left" ToolTip="Grab frames for selected file/folder [F3]" Width="30" Height="30" Click="GrabFramesMenuItem_Click">
        <local:TextEncodedImage EncodedImage="0@@00Po18@0boP488iOB2TW019@h34R0QhOn0070P00000" Width="16" Height="16" EncodedImageColor="{DynamicResource MenuButton_GrabFrames}"></local:TextEncodedImage>
    </Button>
    
    <Rectangle Width="2" Fill="{DynamicResource PanelBack}"></Rectangle>
    
    <Button DockPanel.Dock="Left" HorizontalAlignment="Left" ToolTip="Purge Cache&#x0d;&#x0a;It will clean cache from empty links and ulinked frames&#x0d;&#x0a;Run it to keep cache consistency" Width="30" Height="30" Click="PurgeCacheMenuItem_Click">
        <local:TextEncodedImage EncodedImage="0@@000P10H0060P10H0060P10H0PO0l?0o3@E0D5PZ0000" Width="16" Height="16" EncodedImageColor="{DynamicResource MenuButton_PurgeCache}"></local:TextEncodedImage>
    </Button>
    ...
</DockPanel>
...

где xmlns:local - это "clr-namespace:пространство имён проекта".

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

пример панельки - тёмная тема
пример панельки - тёмная тема
пример панельки - светлая тема
пример панельки - светлая тема

Следующий пример для C# WinForms:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;

namespace TextEncodedImage
{
    public enum ToolButtonType { Button, Check, Radio };

    public class ToolButton : Control
    {
        bool mouseOver = false;
        bool mouseDown = false;

        SolidBrush backBrush = new SolidBrush(Color.FromArgb(0xE0, 0xE0, 0xE0));
        Pen borderPen = new Pen(Color.FromArgb(0xD0, 0xD0, 0xD0));
        SolidBrush mouseOverBrush = new SolidBrush(Color.FromArgb(0xF0, 0xF0, 0xF0));
        SolidBrush mouseDownBrush = new SolidBrush(Color.FromArgb(0xFF, 0xFF, 0xFF));
        SolidBrush checkedBrush = new SolidBrush(Color.FromArgb(0xC0, 0xC0, 0xFF));
        SolidBrush mouseOverCheckedBrush = new SolidBrush(Color.FromArgb(0xD0, 0xD0, 0xFF));


        public ToolButton() : base()
        {
            this.Paint += ToolButton_Paint;
            this.MouseEnter += (sender, e) => { mouseOver = true; Refresh(); };
            this.MouseLeave += (sender, e) => { mouseOver = false; Refresh(); };
            this.MouseDown += (sender, e) => { mouseDown = true; check(); Refresh(); };
            this.MouseUp += (sender, e) => { mouseDown = false; Refresh(); };
            Refresh();
        }

        Bitmap stringToBitmap(Color pen, string s)
        {
            try
            {
                byte basecode = s[0];
                byte w = s[1] - basecode;
                byte h = s[2] - basecode;
                Bitmap bmp = new Bitmap(w, h);
                for (int y = 0; y < h; y++)
                    for (int x = 0; x < w; x++)
                        bmp.SetPixel(x, y, ((s[3 + (y * w + x) / 6] - basecode) & (1 << (y * w + x) % 6)) > 0 ? pen : Color.Transparent);
                return bmp;
            }
            catch { return null; }
        }

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

            SolidBrush brush;
            if (mouseDown) brush = mouseDownBrush;
            else if (isChecked && mouseOver && buttonType != ToolButtonType.Radio) brush = mouseOverCheckedBrush;
            else if (IsChecked) brush = checkedBrush;
            else if (mouseOver) brush = mouseOverBrush;
            else brush = backBrush;

            g.FillRectangle(brush, 0, 0, this.Width, this.Height);
            
            SizeF textSize = g.MeasureString(text, Font);
            g.DrawString(text, Font, new SolidBrush(this.ForeColor), (Width - textSize.Width) / 2, (Height - textSize.Height) / 2);

            if (encodedImageBitmap == null && !string.IsNullOrWhiteSpace(EncodedImage))
                encodedImageBitmap = stringToBitmap(EncodedImagePen, EncodedImage);

            if (encodedImageBitmap != null) g.DrawImage(encodedImageBitmap, EncodedImageLocation);

            g.DrawRectangle(borderPen, 0, 0, this.Width - 1, this.Height - 1);
        }

        private void check()
        {
            if (buttonType == ToolButtonType.Button) isChecked = false;
            if (buttonType == ToolButtonType.Check) isChecked = !isChecked;
            if (buttonType == ToolButtonType.Radio)
            {
                foreach (var c in Parent.Controls) if (c is ToolButton tb && tb.ButtonType == ToolButtonType.Radio && tb.RadioGroup == this.RadioGroup) tb.IsChecked = false;
                isChecked = true;
            }
        }


        ToolButtonType buttonType = ToolButtonType.Button;
        [DefaultValue(ToolButtonType.Button)]
        public ToolButtonType ButtonType
        {
            get { return buttonType; }
            set { buttonType = value; Refresh(); }
        }

        
        bool isChecked = false;
        public bool IsChecked
        {
            get { return isChecked; }
            set { isChecked = value; Refresh(); }
        }

        public int RadioGroup { get; set; }

        string text = "";
        public override string Text
        {
            get { return text; }
            set { text = value; Refresh(); }
        }


        Bitmap encodedImageBitmap;
        string encodedImage = "";
        public string EncodedImage
        {
            get { return encodedImage; }
            set { encodedImage = value; encodedImageBitmap = null; Refresh(); }
        }

        Color encodedImagePen = Color.Black;
        public Color EncodedImagePen
        {
            get { return encodedImagePen; }
            set { encodedImagePen = value; encodedImageBitmap = null; Refresh(); }
        }
        public Point EncodedImageLocation { get; set; }

    }
}

Это код контрола для кнопочек из, собственно, описанной выше утилиты BitImageTool, кнопка тут бывает трёх типов ToolButtonType - обычная (Button), выключатель (Check), переключатель в группе (Radio). И, конечно, содержит изображение, декодированное из текста в свойстве EncodedImage, цвет в свойстве EncodedImagePen.

Не трудно догадаться, что изображения на кнопочках в BitImageTool нарисованы в самом BitImageTool.

И, ещё простой пример на питоне (установите pillow перед запуском):

from PIL import Image

s = "0@@00080060ncWolI6HV0V1PI0H6@V1VigOnm706001000"
pen = (255, 0, 0)

basecode = ord(s[0])
w = ord(s[1]) - basecode
h = ord(s[2]) - basecode
img = Image.new('RGBA', (w, h), (0, 0, 0, 0))

for y in range(w):
    for x in range(h):
        if ((ord(s[3 + (y * w + x) // 6]) - basecode) & (1 << (y * w + x) % 6)) > 0:
            img.putpixel((x, y), pen)

img.save('test.png', 'PNG')

этот код просто создаёт изображение из строки "s", цветом pen, и сохраняет в файл test.png

Итоги

Преимущества такого способа:

  • одноцветные изображения можно легко и быстро рисовать

  • также их легко и быстро можно переносить в код

  • нет нужды возиться с ресурсами приложения

  • не требуется подгружать их с диска или из сети

  • можно менять цвет на лету, просто изменяя один параметр (или свойство)

Недостатки:

  • требуются аппаратные ресурсы, чтобы создавать изображение каждый раз при инициализации приложения, однако, алгоритм столь примитивен, что на его фоне загрузка и декодирование одного из стандартных графических форматов изображений может потребовать не меньше

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

Главное, чего мне хотелось добиться - это иметь возможность получать нужные пиктограммы для кнопочек и панелей инструментов быстро и необременительно, не имея навыков рисования и знания продвинутых графических редакторов. В BitImageTool можно нарисовать нужную картинку за пару минут, а иной раз и быстрее, скопировать сгенерированный "текст" в IDE и продолжить работу над кодом, не утопая в раздумьях над тяготами графического оформления приложения.

Скачать BitImageTool можно на гитхабе

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


  1. artptr86
    30.07.2024 09:08
    +1

    let canvases = document.getElementsByClassName("textimage");

    Лучше тогда всё это завернуть в custom element. Будет как-то так:

    <text-image data="0@@00080060ncWolI6HV0V1PI0H6@V1VigOnm706001000" color="0xFF4040FF"></text-image>

    Если же оставлять канвасы как есть, то лучше не делать произвольные атрибуты, а передавать специально для того задуманными data-атрибутами (data-image, data-color).


    1. questfulcat Автор
      30.07.2024 09:08

      Да, возможно, вы правы, спасибо за замечание. Этот код просто пример реализации, для наглядности метода, наверняка там ещё много можно оптимизировать.


  1. printf
    30.07.2024 09:08
    +3

    Прикольно, я для маленьких игр на JS похожее делал. Вот просматривалка с примерами: https://codepen.io/mvasilkov/pen/xxJvEWN

    Кодировка в моем случае была просто засунуть всё в BigInt попиксельно, чтобы минимизировать декодер.


    1. questfulcat Автор
      30.07.2024 09:08

      Интересный вариант, как я понял, там разрядность цвета для пикселей задаётся параметром.

      Думал над четырехцветным вариантом своего метода (по 2 бита на пиксель = 3 цвета плюс фон), но там уже рисовать картинки в редакторе будет более трудоёмко, решил, что самое удобное и простое - один цвет, без палитр.


      1. printf
        30.07.2024 09:08
        +1

        Да-да, я тоже сначала так делал, а изображения вообще прямо в текстовом редакторе посимвольно вбивал.

        Собственный пиксельный редактор поленился писать, поэтому сейчас рабочий вариант это рисовать картинки в Aseprite, а затем кошмарный питоноскрипт их превращает в целевую кодировку.


  1. JerryI
    30.07.2024 09:08
    +3

    Напомнило svg. Я последнее время храню иконки как svg (с обрезанным описанием, 1 цвет, без стилей) строку внутри компонента. Очень удобно по сравнению с классическими ассетами


  1. VirRus77
    30.07.2024 09:08

    Это конечно хорошо. Но не вызывать Dispose для Pen, Brush в своем контроле - это нонсенс. Почитайте как управлять временем жизни unmanaged ресурсов в managed среде c#, и что будет если их не освобождать...

    П.С. И кодестайл для c# нетепичный...


    1. questfulcat Автор
      30.07.2024 09:08

      Спасибо за замечание. В данном случае предполагается, что существование инстансов этого контрола завершается с закрытием приложения (кнопки на формах обычно создаются вместе с формами и существуют до конца работы программы). В этом случае система и так освободит unmanaged ресурсы процесса.

      Если же, в каких-то целях, предполагается динамически создавать и уничтожать объекты данного типа в больших количествах на протяжении работы программы, то да, лучше добавить в код контрола деструктор с вызовыми Dispose() для всех объектов GDI+. Но тут тоже возникает проблема - если во время работы свойству или полю с типом Brush будет назначен не новый SolidBrush, а например системный объект типа Brushes.Red, то надо будет как-то определять этот момент, чтобы не вызывать Dispose() для системных кистей. В общем, тема спорная и не такая простая.

      Как вы закрываете ресурсы объектов типа Pen, Brush в winforms? Поделитесь своим опытом.