Andrey on .NET | Создание изображений из элементов управления WPF

Создание изображений из элементов управления WPF

Существует достаточно распространенная задача, когда окно приложения или его часть необходимо сохранить как изображение. В WPF есть класс, который очень упрощает ее решение – RenderTargetBitmap. Он позволяет получить изображение любого элемента управления WPF (включая его дочерние элементы) в растровом формате. Рассмотрим пример использования данного класса и некоторые особенности.

Сразу перейдем к коду примера.

/// <summary>Renders the Visual object and store it to file.</summary>
/// <param name="baseElement">The Visual object to be used as a bitmap. </param>
/// <param name="imageWidth">The height of the bitmap.</param>
/// <param name="imageHeight">The width of the bitmap.</param>
/// <param name="pathToOutputFile">Full path to the output file.</param>
private void SaveControlImage(
    Visual baseElement, int imageWidth, int imageHeight, string pathToOutputFile)
{
    // 1) get current dpi
    var pSource = PresentationSource.FromVisual(Application.Current.MainWindow);
    Matrix m = pSource.CompositionTarget.TransformToDevice;
    double dpiX = m.M11 * 96;
    double dpiY = m.M22 * 96;

    // 2) create RenderTargetBitmap
    var elementBitmap = new RenderTargetBitmap(imageWidth, imageHeight, dpiX, dpiY, PixelFormats.Default);

    // 3) undo element transformation
    var drawingVisual = new DrawingVisual();
    using (DrawingContext drawingContext = drawingVisual.RenderOpen()) {
        var visualBrush = new VisualBrush(baseElement);
        drawingContext.DrawRectangle(
            visualBrush, 
            null, 
            new Rect(new Point(0, 0), new Size(imageWidth / m.M11, imageHeight / m.M22)));
    }

    // 4) draw element
    elementBitmap.Render(drawingVisual);

    // 5) create PNG image
    var encoder = new PngBitmapEncoder();
    encoder.Frames.Add(BitmapFrame.Create(elementBitmap));

    // 6) save image to file
    using (var imageFile = new FileStream(pathToOutputFile, FileMode.Create, FileAccess.Write)) {
        encoder.Save(imageFile);
        imageFile.Flush();
        imageFile.Close();
    }
}

Код достаточно простой, но краткие комментарии к каждому шагу не повредят:

  • Шаг 1 - Получаем DPI для монитора. Т.к. WPF для размеров и координат оперирует единицей измерения равной 1/96 дюйма, то используем коэффициент 96 для получения DPI.
  • Шаг 2 – Создаем экземпляр класса RenderTargetBitmap
  • Шаг 3 – Сдвигаем элемент к левому верхнему углу изображения (т.к. для него скорее всего задано смещение). При этом так же учитываем масштабирование монитора (m.M11 и m.M12)
  • Шаг 4 – Переводим элемент в растровое изображение. Тут есть интересный момент с фоном элемента, который мы рассмотрим далее.
  • Шаг 5 – Переводим растровое изображение в PNG формат. В WPF доступны следующие кодировщики: BmpBitmapEncoder, GifBitmapEncoder, JpegBitmapEncoder, PngBitmapEncoder, TiffBitmapEncoder, WmpBitmapEncoder.
  • Шаг 6 – Создаем поток в памяти и записываем туда данные для дальнейшего PNG файла.

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

  • Смещенное изображение элемента WPFПервый заключается в шаге 3. Если не убрать сдвиги элемента, то сохраняемые элементы будут так же смещены и на изображении. Например, как на рисунке справа (чтобы было лучше заметно специально добавлен серый фон).
  • Второй момент в прозрачности фона многих элементов управления. Сохраненное изображение WPF GridГенерируемое изображение будет содержать пиксели в формате с альфа-каналом (например PixelFormat.Pbgra32). При сохранении в формат, без поддержки прозрачности необходимо будет задать фон. В приложенном примере для упрощения задан белый фон для Grid. Поэтому его копии можно сохранять в любом другом формате (можно попробовать, заменив PngBitmapEncoder на любой другой из отмеченных выше).

Ну дополнительно - пример кода печати элемента WPF. Это очень просто:

PrintDialog dlgPrint = new PrintDialog();
// Print
if (dlgPrint.ShowDialog() == true) {
    dlgPrint.PrintVisual(element, "The picture is drawn dynamically");
}

Осталось дать ссылку на демонстрационный проект для Visual Studio 2010:
RenderTargetBitmapDemo.zip.

Комментарии (21) -

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

@ Юлия: Пожалуйста.

Максим 23.09.2013 13:23:10

Пример использует монитора устройства, а у многих он переопределен в настройках(например у меня) и скрин снимается неправильно.

@ Максим: А что понимается под "переопределен в настройках"?

Максим 24.09.2013 22:45:01

Sorry, это касалось вот этого кода:http://habrahabr.ru/post/94292/ Просто я был очень запутанный.

Спасибо! Заработало. Два дня бодалась с сохранением Chart из  WPFtoolkit в изображение

Никита 23.05.2019 16:17:57

Вопрос на засыпку: что значит "96"?

Никита Коэффициент для перевода из единиц WPF в DPI. Внутри WPF для координат и размеров использует 1/96 дюйма как единицу измерения.

Никита 23.05.2019 18:51:18

Да, это так, но есть проблемо.
Вот этот вариант получения изображения из элемента окна работает, но не на всю катушку.
Мы работали над редактором изображений (что-то типа лассо из фотошопа), тоже прибегли к RenderTargetBitmap и всё бы было супер, если бы не константа 96.

На огромных мониторах это просто не работает должным образом:
    double dpiX = m.M11 * 96;
    double dpiY = m.M22 * 96;

Появ��яются смещения вырезаемой области, размытие и т.д.
Поэтому, решение довольно банальное (Вам и остальным на заметку):
Вычислить эту константу динамически, а именно:
1. рассчитать диагональ в дюймах, на основании максимально возможного разрешения экрана (WMI в помощь):
monitor.MaxHorizontalImageSize = (byte)wmiObject["MaxHorizontalImageSize"] / 2.54;
                    monitor.MaxVerticalImageSize = (byte)wmiObject["MaxVerticalImageSize"] / 2.54;
                    monitor.Diagonal = Math.Round(Math.Sqrt(monitor.MaxHorizontalImageSize * monitor.MaxHorizontalImageSize +
                        monitor.MaxVerticalImageSize * monitor.MaxVerticalImageSize), 1);

2. Вычислить непосредственно эту константу, назовем ее "PPI":
ppi = Math.Round(Math.Sqrt(screenWidth * screenWidth + screenHeight * screenHeight) / diagonal, 1);

Где screenWidth и screenHeight  - текущие значения разрешения экрана.

3. если ppi < 96, то используем это значение, а если ppi > 96, то присваиваем ppi значение 96 (больше 96 получается при всех этих манипуляциях у планшетов, современных 15-17-дюймовых мониторах, а меньше 96 - на огромных экранах. Например 52 при диагонали 43 и разрешении 1920*1080).
if (ppi > 96)
            {
                ppi = 96;
            }

В итоге, будет примерно так :
           UIElement imgContainer; //<---- в нашем случае, это canvas
            Rect bounds = VisualTreeHelper.GetDescendantBounds(imgContainer);

            PresentationSource pSource = PresentationSource.FromVisual(Application.Current.MainWindow);
            Matrix m = pSource.CompositionTarget.TransformToDevice;

            double dpiX = m.M11 * ppi;
            double dpiY = m.M22 * ppi;

            int width = (int)(bounds.Width + bounds.X);
            int height = (int)(bounds.Height + bounds.Y);

            RenderTargetBitmap rtb = new RenderTargetBitmap(
                width,
                height,
                dpiX, dpiY, PixelFormats.Pbgra32);

Надеюсь, мой опыт будет полезен остальным и ускорит процесс их разработки.

Никита: Cпасибо за дополнение.
Исходый код из 2010 года для DPI <= 96 работал нормально. Но сейчас действительно надо учитывать более высокие DPI.

Никита Все же что-то не так в вашем примере. Зачем вы делаете вот это
double dpiX = m.M11 * ppi;
double dpiY = m.M22 * ppi;
Для мониторов с большим DPI это аналогично
double dpiX = m.M11 * 96;
double dpiY = m.M22 * 96;
Что аналогично коду статьи.

Или вы хотели написать double dpiX = ppi? Но тогда почему просто dpiX не ограничить до 96?

Никита 23.05.2019 20:18:38

В моем примере есть момент:
if (ppi > 96)
{
    ppi = 96;
}

То есть, если будет выяснено, что DPI > 96, то будет именно так, как и говорите:
double dpiX = m.M11 * 96;
double dpiY = m.M22 * 96;

Если условие не будет выполнено и DPI будет меньше 96, то уже будет так, например:
double dpiX = m.M11 * 52;
double dpiY = m.M22 * 52;

ppi будет либо меньше 96, либо равно 96, как идет по стандарту.
Поэтому, код отрабатывает и на экранах с большой диагональю ppi уже не равно 96 и dpiX/Y уже другие

Никита 23.05.2019 20:20:46

На экранах с более мелкой диагональю, чаще всего уже ppi будет равно 96. По умолчанию значение равно у нас 96 (при инициализации). Весь этот сыр-бор чисто из-за больших экранов

Никита 23.05.2019 20:22:08

Если не снизить руками DPI до 96 там, где выдает 104, например, то уже будут проблемы, о которых я написал в первый раз

Никита Я именно про это ограничение и говорил. Сейчас запустил пример на 4K мониторе небольшой диагональю. В результате ppi = 96, и получается double dpiX = m.M11 * 96. Как следствие - отсечена часть изображения.

Вариант ограничить DPI значением 96 дает полную и по сути правильную картинку, но визуально уменьшенную на 1/4 (m.M11 == m.M11 == 1.25 т.к. в настройках монитора стоит scale = 125%)

Можно вот так изменить строку 22
drawingContext.DrawRectangle(visualBrush, null, new Rect(new Point(0, 0), new Size(imageWidth / m.M11, imageHeight / m.M22)));

Никита 23.05.2019 20:55:03

Да, согласен. Если стоит увеличение, то надо разделить на значения из M11 и M22, иначе будет отсечение. Поправил у себя, добавил деление. Вот и родилась истина))

Никита 23.05.2019 21:10:28

Ё моё, увеличил экран (125% поставил) и у меня как-раз ограниченно обрезать стал. 1 к 1 - всё хорошо, если 125%, то грустьFrown

Никита Вот такой вариант
- без ограничения dpiX и dpiY,
- но с
drawingContext.DrawRectangle(visualBrush, null, new Rect(new Point(0, 0), new Size(imageWidth / m.M11, imageHeight / m.M22)));

дает правильную картинку на обычном и 4K мониторе при разных коэффициентах масштабирования (пробовал от 100 до 150).

Поправил статью.

Собственно, IMHO это правильно что размеры делятся на коэффициент масштабирования, т.к. он зависит от монитора. Дальше уже IMHO или картинку или кисть масштабировать. Могу ошибаться, т.к. глубоко не копал. Сейчас больше web занят, WPF уже давно не использовал в работе. Сейчас просто из интереса немного поглядел что можно сделать при работе с 4K и подобными мониторами. Надеюсь поможет или натолкнет на мысли как сделать лучше.

Максим 25.06.2020 11:36:59

Привет.
У меня очень похожий код, на календаре отмечаю необходимые мне дни иконками png. Повесил на кнопку и все работает. Однако, если делать согласно MVVM, и перенести все это на ViewModel, то рисовать что либо во view отказывается! Через debug код работает отлично, все точки проходит. но физически ничего не рисуется. Нет идей, почему такое может происходить?

Максим 29.06.2020 8:38:23

К сожалению с ходу идей почему такое происходит нет.

Никита 29.06.2020 8:42:50

Сложно что-то сказать без исходного кода

Добавить комментарий