Click here to Skip to main content
15,881,380 members
Articles / Desktop Programming / WPF

Automating Graphic Design with WPF

Rate me:
Please Sign up or sign in to vote.
4.00/5 (3 votes)
26 Oct 2020Apache8 min read 4.9K   4   3
This post is the the second in a short series investigating somewhat exotic parts of .NET.
Here, I will cover: creating a DynamicObject to simplify accessing XML data (we will use this to simplify binding cards templates to the card data), using WPF for graphic design, abusing Razor to do template-based XAML generation, and using Roslyn to compile and run user-provided code within our program.

Being passionate about board games, I have often mused about creating my own. Today, there are very accessible options to prototype and test your own game design with minimal expense. Tabletop Simulator is widely used for playtesting and services like The Game Crafter are available to print as little as a single copy of your game at a reasonable cost.

Still, coming up with tens, or even hundreds, of professionally designed cards is not an easy task. Being a software engineer, I am obviously looking at automating as much as possible of this process, but most of the tools available are created for graphic designers and are very different to what programmers use to do user-interface work.

So I decided to create my own tool.

The complete source code for the project is available on GitHub. The repo contains a fully working generator of card images, which I named Card Artist. Board game designers can use it to create their own cards for playtesting or for professional printing. The GitHub repo also contains binaries for the application as well as documentation on how to create your own card templates.

Following Along

This post is the second in a short series investigating somewhat exotic parts of .NET.

During the series, I will be writing the main components of a template-based image generator. I will cover:

Why WPF? – Choosing a Technology

Since the title of the post spoils that WPF is the right technology for the job, I will present here both my requirements and how WPF is a good choice to fulfill them.

  • It should be easy to integrate into my own application – This excludes all graphic design software that are meant to be used as an independent application and don’t provide APIs. WPF can be easily integrated in any application with no licensing cost. The only restriction will be that our application can only run on Windows.
  • It should support all reasonable layout and typography needs – We will need to support pictures, text (both labels and paragraphs), different fonts and their variations (italic, bold, etc.), design elements and symbols. There are various XAML-based technologies that cover these requirements but WPF is, in my opinion, the most mature and complete. WPF also supports the LayoutTransform which makes rotating and scaling elements and text much more convenient.
  • It should be DPI-independent – We want to be able to generate images for our cards at different pixel densities depending on whether we want to have them printed professionally or simply use them in Tabletop Simulator. WPF allows to use inches or centimeters as measurement units and, while the layout step is performed at an arbitrary 96 DPI, images are actually rendered at whatever resolution we want.
  • It should support at least basic vector graphics – Because we want DPI independence, it is very convenient to express design elements with vector graphics. WPF supports this.
  • The layout and content should be defined with a text language – Because the next step of our project will be using template-based code generation to create all the cards images based on a data source, we want a technology that is text-based. A tool that uses an editor and saves the layout in binary format won’t do. The language must also be concise and expressive enough that a human can edit it manually. XAML is perfect for this and, being XML-based, will get along nicely with Razor.
  • The language should be relatively simple and well documented – WPF becomes somewhat complicated when we introduce data binding, styles and templates. I still love it for its flexibility but we want to keep things simple here. Fortunately, we don’t need any of these functionalities here! The subset of WPF that we use is powerful, relatively simple and painstakingly well documented.
  • It should have readily available tools to help the design process – With this project, I want to focus on automating the image generation. I am not trying to build an editor with preview capabilities, code completion or other advanced features. Having professional WPF design tools like Microsoft Blend, which is included in the free Visual Studio Community Edition, allows great usability without increasing the scope of my project.

Laying Out a Card

Let’s start with the basic layout of a card in XAML:

XML
<Grid xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Background="Black">
  <Border x:Name="Card"
      Width="2.5in" Height="3.5in"
      Margin="0.125in" Padding="0.125in"
      CornerRadius="10" BorderBrush="White" BorderThickness="1">
    <Grid x:Name="SafeArea">
      <!-- The card layout goes here -->
    </Grid>
  </Border>
</Grid>

This is a Poker-sized card and we are able to express the dimensions directly in inches: Width=”2.5in” Height=”3.5in”.

Because we may want to have these cards printed at some point, our layout is actually larger than the card itself to handle drift (the misalignment of printing and cutting of the card). We will have our background fill all the external grid while our card elements will all be inside the SafeArea. We will later handle the Border named Card differently depending on whether our render is meant for printing or for playtesting digitally. The actual size of the bleed (Margin=”0.125in”) and safe area (Padding=”0.125in”) depend on the printing company that we plan to use.

The Render

After saving our XAML file, rendering in WPF is actually pretty simple. I have tested the following code with .NET 5 and it works in a pure console program as well as in a WPF application.

First of all, let’s make sure that our project file has support for WPF. If targeting .NET 5, it should look like this:

XML
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <UseWPF>true</UseWPF>
    <TargetFramework>net5.0-windows</TargetFramework>
  </PropertyGroup>
</Project>

Now we can read the XML file:

C#
var xaml = File.ReadAllText(xamlFilePath);
using var reader = XmlReader.Create(new StringReader(xaml));
var rootElement = (FrameworkElement)XamlReader.Load(reader);

rootElement will be the most external element of our XAML template, in our case a Grid.

The next step is to invoke the layout process for rootElement. This is done in three steps:

  • Wrapping rootElement inside a HwndSource makes sure that all WPF features are functional. Skipping this line will result in some more complex templates not working properly.
  • Measure instructs each part of our layout to calculate its desired size depending on the total available space. Because we know that our template contains the explicit dimensions of the card, we can specify that infinite space is available and still achieve the expected result.
  • Arrange will position each graphic element in the correct place according to our layout.
C#
using var presentationSource = new HwndSource(
  new HwndSourceParameters()) { RootVisual = rootElement };
rootElement.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
rootElement.Arrange(new Rect(
  0, 0, rootElement.DesiredSize.Width, rootElement.DesiredSize.Height));

Now we just need to render our card to a .png file:

C#
var dpi = 300;
var bmp = new RenderTargetBitmap(
  (int)(rootElement.DesiredSize.Width * dpi / 96),
  (int)(rootElement.DesiredSize.Height * dpi / 96),
  dpi, dpi, PixelFormats.Pbgra32);
bmp.Render(rootElement);
var img = BitmapFrame.Create(bmp);
var encoder = new PngBitmapEncoder();
encoder.Frames.Add(img);
using Stream s = File.Create(pngOutputFilePath);
encoder.Save(s);

This code renders the card at 300 DPIs by scaling the size of the RenderTargetBitmap based on the standard behavior of WPF laying out elements at 96 DPIs. We can change the dpi variable to achieve any desired resolution. If we want to send these images to be professionally printed, it would be a good idea to add some code to make sure that they end up with the exact expected size in pixel: we don’t want our cards to be rejected because they are one pixel too short due to the double-to-int conversion.

Some resources on the internet suggest to move the rendering code into the LayoutUpdated event to make sure that the layout process is complete. In my tests, I didn’t see any need for this but, if you experience rendering issues, you can try and see if this helps.

C#
rootElement.LayoutUpdated += (sender, e) => {
  //The render code goes here
};
//The layout code goes here

If you have knowledge about color management, BitmapFrame supports specifying an ICC profile through a ColorContext. But this is way beyond my understanding of digital printing, so I didn’t investigate it further.

Our First Card

We can now test the layout and rendering code with a sample card template. I have broken the code in a few sections to highlight some interesting tidbits.

XML
<Grid xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Background="Black">
  <Border x:Name="Card"
      Width="2.5in" Height="3.5in"
      Margin="0.125in" Padding="0.125in"
      CornerRadius="10" BorderBrush="White" BorderThickness="1">
    <Grid x:Name="SafeArea">
      <Border CornerRadius="8" Background="White"
          HorizontalAlignment="Stretch" VerticalAlignment="Stretch">

The following is a pretty cool workaround suggested by Andrew Mikhailov on Stack Overflow. This is needed to make the Border correctly clip its content respecting its rounded corners.

XML
<Border.OpacityMask><VisualBrush><VisualBrush.Visual>
  <Border Background="Black" SnapsToDevicePixels="True"
      CornerRadius="{Binding CornerRadius,
        RelativeSource={RelativeSource AncestorType=Border}}"
      Width="{Binding ActualWidth,
        RelativeSource={RelativeSource AncestorType=Border}}"
      Height="{Binding ActualHeight,
        RelativeSource={RelativeSource AncestorType=Border}}" />
</VisualBrush.Visual></VisualBrush></Border.OpacityMask>

The rest is pretty straightforward XAML stuff.

XML
<Grid>
  <Grid.RowDefinitions>
    <RowDefinition Height="Auto"/>
    <RowDefinition Height="*"/>
    <RowDefinition Height="Auto"/>
    <RowDefinition Height="*"/>
  </Grid.RowDefinitions>
  <Grid.ColumnDefinitions>
    <ColumnDefinition Width="0.2in"/>
    <ColumnDefinition Width="*"/>
    <ColumnDefinition Width="0.2in"/>
  </Grid.ColumnDefinitions>

I thought it would be cool to demonstrate how to create a pattern-based brush. This is better than using a background image because it will be crisp at any resolution.

XML
<Rectangle Grid.Column="0" Grid.Row="0" Grid.RowSpan="4">
  <Rectangle.Fill>
    <VisualBrush TileMode="Tile" Viewport="0,0,10,10"
        ViewportUnits="Absolute" Viewbox="0,0,10,10"
        ViewboxUnits="Absolute">
      <VisualBrush.Visual><Canvas>
        <Rectangle Fill="#FF7700" Width="10" Height="10" />
        <Path Fill="Black" Data="M 0,0 0,3 7,10 10,10 10,7 3,0" />
        <Path Fill="Black" Data="M 10,0 10,3 7,0" />
        <Path Fill="Black" Data="M 0,10 3,10 0,7" />
      </Canvas></VisualBrush.Visual>
    </VisualBrush>
  </Rectangle.Fill>
</Rectangle>

And more “normal’ XAML stuff. The availability of LayoutTransform, which is used below, is one of the main reasons for choosing WPF over other XAML-based technologies.

XML
<TextBlock Grid.Column="0" Grid.Row="0" Grid.RowSpan="4"
    HorizontalAlignment="Stretch" Foreground="White"
    TextAlignment="Center" Margin="-2,0,0,0"
    FontWeight="Bold" FontSize="14"
    Text="Mandatory stop" >
  <TextBlock.LayoutTransform>
    <RotateTransform Angle="-90" />
  </TextBlock.LayoutTransform>
</TextBlock>
<TextBlock Grid.Column="1" Grid.Row="0"
    HorizontalAlignment="Center" FontWeight="Bold" FontStyle="Italic"
    Text="Ride the Jackrabbit" />
<Image Grid.Column="1" Grid.Row="1"
    HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
    Stretch="UniformToFill" Source="C:\foo\Jackrabbit.jpg" />
<TextBlock Grid.Column="1" Grid.Row="2"
    HorizontalAlignment="Center" FontStyle="Italic"
    Text="Attraction" />
<Image Grid.Column="1" Grid.ColumnSpan="2"
    Grid.Row="0" Grid.RowSpan="2"
    HorizontalAlignment="Right" VerticalAlignment="Top"
    Width="0.3in" Height="0.3in" Margin="0.1in"
    Source="C:\foo\Route66.png" />

The absolute image file paths above are a bad practice! Fortunately, we will get rid of them in the next blog post.

The support of Flow Documents is one of the reasons for using WPF. Being able to nicely lay out complex typography is crucial for this project.

XML
          <RichTextBox Grid.Column="1" Grid.Row="3"
              BorderBrush="Transparent" Background="Transparent">
            <FlowDocument>
<Paragraph FontSize="9"><Bold>HERE IT IS!</Bold></Paragraph>
<Paragraph FontSize="9">Along Arizona's stretch of Route 66, halfway between
Holbrook and Winslow, you can find the <Bold>Jack Rabbit Trading Post</Bold>.
</Paragraph>
<Paragraph FontSize="9">Travelers of Route 66 can see billboards advertising
this convenience store and gift shop as early as Missouri.</Paragraph>
            </FlowDocument>
          </RichTextBox>
        </Grid>
      </Border>
    </Grid>
  </Border>
</Grid>

Image 1

And this is the final result.

Behold the Jackrabbit in all its glory!

Fixing the Border

Depending on the use for the rendered card, we may need to remove either the white border trace (we don’t want that printed!) or the area outside of it. This is very easy because WPF creates an object tree from the XAML document. We can retrieve the card border using its x:Name and manipulate it as needed.

Before the layout step, we can add:

C#
var cardBorder = (Border)rootElement.FindName("Card");
if (hideCardEdge)
  cardBorder.BorderBrush = null;

And we can change:

C#
var img = BitmapFrame.Create(bmp);

into:

C#
var img = BitmapFrame.Create(renderBackgroundBleed ?
  bmp :
  new CroppedBitmap(bmp, new Int32Rect(
    (int)(cardBorder.Margin.Left * dpi / 96),
    (int)(cardBorder.Margin.Top * dpi / 96),
    (int)(cardBorder.ActualWidth * dpi / 96),
    (int)(cardBorder.ActualHeight * dpi / 96))));

Data Binding

I wrote almost 100 lines of XAML to render a single card. I don’t want to duplicate that much code for each card, instead I want to write the information about all cards in a file and use data binding to leverage a single template to generate them all.

We could use WPF’s own data binding (see the previous post if you want to know more about XmlDynamicElement):

C#
//Before Measure and Arrange
dynamic cardData = new XmlDynamicElement(cardXmlElement);
rootElement.DataContext = cardData;

which would allow us to reference the content of cardXmlElement from the XAML template:

XML
<TextBlock Text="{Binding Title}" />

Unfortunately, complex WPF data binding can be hard to get right and advanced behaviors usually require writing custom converters in .NET code. I don’t want the user who creates the template to have to write C# code! To solve this problem, in the next blog post, I will templatize the XAML using Razor achieving a much simpler data binding language as well as support for loops and conditional expressions.

License

This article, along with any associated source code and files, is licensed under The Apache License, Version 2.0


Written By
Software Developer (Senior) Microsoft
United States United States
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
QuestionFew notes and one question Pin
Издислав Издиславов27-Oct-20 1:34
Издислав Издиславов27-Oct-20 1:34 
AnswerRe: Few notes and one question Pin
Matteo Prosperi27-Oct-20 6:50
Matteo Prosperi27-Oct-20 6:50 
QuestionBloat Pin
Padanian26-Oct-20 23:39
Padanian26-Oct-20 23:39 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.