KARPACH

WEB DEVELOPER BLOG

Building an envelope printing app with WPF, GDI+ and the Microsoft Store

The standard answer to “how do I print an address on an envelope” is Microsoft Word’s envelope wizard. I don’t have Word, and I wasn’t about to start paying for a Microsoft 365 subscription to put two addresses on a piece of paper a few times a month. The free alternatives I found were either web tools that want you to hand over an address list, or utilities with a fixed layout you can’t move.

So I wrote my own: Envelope Address Printer, a small .NET 8 WPF app that puts two draggable address blocks on a canvas shaped like a real envelope and prints exactly what you see. No Office subscription, and rather more control than the Word wizard offers — in Word the address boxes sit where Word decides, and font changes happen in a dialog you can’t see the result of. Here you drag each block to where you want it on a to-scale envelope, set the font family, size and color per block, and the canvas is the preview.

Envelope Address Printer main window

What it does, briefly:

  • Recipient and return address blocks you drag around a live canvas — the canvas is the envelope, at scale
  • Four envelope sizes: No. 10, C4, C5, DL
  • Per-block font family, size and color (each block is configured independently)
  • Optional sender logo image next to the return address
  • Return address and the last 10 recipients persist between runs
  • Print preview before committing an envelope to the printer

The rest of this post is about the parts that didn’t work the first time.

One project, two UI frameworks

The app is WPF, but WPF has no printer-selection dialog worth using and no print preview at all. System.Windows.Controls.PrintDialog gives you a printer picker but the actual page rendering means building a DocumentPaginator over WPF visuals, and there is no PrintPreviewDialog equivalent in the box. WinForms has both, plus System.Drawing.Printing.PrintDocument, which is a far more direct way to say “draw this string at this point on the page.”

So the .csproj enables both:

<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>

That compiles, and then every file in the project starts failing with CS0104: ambiguous reference. Both frameworks contribute implicit global usings, so Application, FontFamily, Point, UserControl, MouseEventArgs and Color all resolve to two different types at once.

The fix is a GlobalUsings.cs that picks a winner project-wide:

global using Application    = System.Windows.Application;
global using FontFamily     = System.Windows.Media.FontFamily;
global using MouseEventArgs = System.Windows.Input.MouseEventArgs;
global using Point          = System.Windows.Point;
global using UserControl    = System.Windows.Controls.UserControl;

WPF wins by default, and the one file that genuinely needs WinForms — the print service — reaches for it through a local alias instead:

using WinForms = System.Windows.Forms;

using var dlg = new WinForms.PrintDialog { Document = doc };

Color is the nastiest of the bunch, because System.Drawing stays an implicit global using for the whole project. Any file that also imports System.Windows.Media has to fully qualify it:

var color = (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(hex)!;

It looks ugly, but aliasing Color globally would have broken the GDI+ side. Fully qualifying in the three files that care was the smaller evil.

Two coordinate systems that must agree

The on-screen canvas is WPF, so it measures in device-independent pixels: 96 DIP to the inch. The printed page is GDI+ System.Drawing.Printing, which by default measures in hundredths of an inch. A No. 10 envelope is 9.5″ × 4.125″, which is 912 × 396 DIP on screen and 950 × 412 on paper.

The temptation is to store block positions in one unit and convert. That falls apart the moment the user switches envelope size — a block sitting at “x = 400 pixels” is in a sensible place on a C4 and hanging off the edge of a DL.

Positions are therefore stored as fractions of the envelope, 0 to 1, and each renderer multiplies by its own page dimensions. Dropping a block on the canvas converts pixels down to a fraction:

[RelayCommand]
private void ToDragCompleted(Point p)
{
    PrintSettings.ToLeftFraction = CanvasWidth  > 0 ? p.X / CanvasWidth  : AppConstants.AddressBlockPositions.ToLeftFraction;
    PrintSettings.ToTopFraction  = CanvasHeight > 0 ? p.Y / CanvasHeight : AppConstants.AddressBlockPositions.ToTopFraction;
    _addressService.SavePrintSettings(PrintSettings);
}

and the print path multiplies the same fraction back up against GDI+ units, never touching DIPs at all:

float pageW = (float)AppConstants.InchesToGdiUnits(widthIn);   // hundredths of an inch
float pageH = (float)AppConstants.InchesToGdiUnits(heightIn);

DrawAddress(g, recipientText, toFont, toBrush,
    pageW * (float)settings.ToLeftFraction,
    pageH * (float)settings.ToTopFraction,
    pageW * (float)AppConstants.AddressBlockDimensions.ToPrintBlockWidthFraction,
    pageH * (float)AppConstants.AddressBlockDimensions.ToPrintBlockHeightFraction);

Both conversions live in a single AppConstants class, which is the only place 96.0 and 100 appear. Envelope switching then costs nothing: recompute the canvas size, re-multiply the fractions, done.

One thing that is deliberately not a fraction: font size. Points are absolute, so 14pt on screen and 14pt on paper are the same physical height, and the preview stays honest.

Getting the printer to admit it knows what an envelope is

This was the longest detour. PrintDocument lets you set DefaultPageSettings.PaperSize to a custom PaperSize with any dimensions you like, and that mostly works — the page comes out the right size. What it does not do is tell the printer driver “this is an envelope,” so the driver keeps its letter-tray settings, reports a PrintableArea for letter paper, and some drivers helpfully scale or reject the job.

The right move is to ask the driver for its own PaperSize entry matching the correct PaperKind. That has two wrinkles.

First, System.Drawing.Printing.PaperKind only has a named member for Number10Envelope. C4, C5 and DL exist in the underlying Win32 DMPAPER_* constants but not as friendly enum names, so they get cast in by value:

public static PaperKind GetPaperKind(EnvelopeSize size) =>
    size switch
    {
        EnvelopeSize.No10 => PaperKind.Number10Envelope, // DMPAPER_ENV_10 = 20
        EnvelopeSize.C4   => (PaperKind)30,              // DMPAPER_ENV_C4 = 30
        EnvelopeSize.C5   => (PaperKind)28,              // DMPAPER_ENV_C5 = 28
        EnvelopeSize.DL   => (PaperKind)27,              // DMPAPER_ENV_DL = 27
        _                 => PaperKind.Number10Envelope
    };

Second, you can’t resolve the paper size when you build the document, because at that point the user hasn’t picked a printer yet. PrintDocument raises QueryPageSettings immediately before each page, with the actual selected printer’s settings attached — that’s the hook:

doc.QueryPageSettings += (_, qe) =>
{
    var nativeSize = qe.PageSettings.PrinterSettings
        .PaperSizes
        .Cast<PaperSize>()
        .FirstOrDefault(ps => ps.Kind == paperKind)
        ?? customSize;

    qe.PageSettings.PaperSize = nativeSize;
    qe.PageSettings.Landscape = true;
    qe.PageSettings.Margins   = new Margins(0, 0, 0, 0);
};

If the driver doesn’t list that kind — plenty of PDF writers don’t — it falls back to a custom PaperSize built from the known dimensions. Note that the custom size is constructed with height and width swapped relative to the layout values, because PaperSize describes the sheet in portrait and Landscape = true rotates it.

The last piece is a decision not to use what the driver reports. PrintPage lays out against the nominal envelope dimensions, not e.Graphics.VisibleClipBounds or PageSettings.PrintableArea. Printable area varies by driver and by hardware margins, and honoring it means the same envelope prints differently on two printers — while the on-screen preview shows only one of them. Laying out against the true physical envelope keeps WYSIWYG intact; the cost is that an address dragged into the outer few millimeters may clip on a printer with large hardware margins.

The preview dialog has a Print button, and it doesn’t tell you

The app has a five-print free trial, and the counter increments after a successful print. PrintPreviewDialog quietly broke that: its toolbar includes a printer icon that calls PrintDocument.Print() directly. No event, no return value, no notification to the calling code. Free unlimited printing, one click away.

There is no property to hide it, so the toolbar gets walked and the button is switched off by name:

foreach (Control control in dlg.Controls)
{
    if (control is ToolStrip toolStrip)
    {
        foreach (ToolStripItem item in toolStrip.Items)
        {
            if (item.Name == "printToolStripButton")
            {
                item.Visible = false;
                break;
            }

            // Fallback check if the name differs in some .NET versions
            if (item.ToolTipText == "Print")
            {
                item.Visible = false;
                break;
            }
        }
    }
}

Reaching into another control’s private layout by string is exactly as fragile as it looks, hence the tooltip fallback. The result is the preview window below — zoom and page-layout buttons, no printer icon.

Print preview window

The same bug had a mirror image on the real print path. IPrintService.Print originally returned void, so cancelling the printer dialog still burned a trial print. It now returns whether the dialog was accepted:

bool isPrinted = _printService.Print(ToText, FromText, PrintSettings);

if (!isUnlocked && isPrinted)
{
    _licensingService.IncrementPrintCount();
    await UpdateLicensingStatusAsync();
}

Parenting a WinForms dialog to a WPF window

WinForms.PrintDialog.ShowDialog() with no argument shows an unowned dialog: it can end up behind the main window, and Alt+Tab treats it as a separate top-level thing. It wants an IWin32Window, which WPF windows are not. You get the HWND from WindowInteropHelper and wrap it in four lines:

private static WinForms.IWin32Window? GetOwnerHandle()
{
    var mainWindow = Application.Current?.MainWindow;
    if (mainWindow is null) return null;
    var handle = new WindowInteropHelper(mainWindow).Handle;
    return handle == IntPtr.Zero ? null : new NativeWindow(handle);
}

private sealed class NativeWindow(IntPtr handle) : WinForms.IWin32Window
{
    public IntPtr Handle { get; } = handle;
}

The handle == IntPtr.Zero check matters — the HWND doesn’t exist until the window is sourced, so calling this too early in startup silently gives you an unowned dialog again.

Store add-on licensing: the key isn’t the key

The full version is a durable in-app purchase, checked through Windows.Services.Store. Two things bit me here.

StoreContext is a WinRT API designed for UWP, where there’s an implicit window. On Win32 it has no idea which window to attach its purchase UI to, and calls fail or hang until you tell it:

var storeContext = StoreContext.GetDefault();
var hwnd = new WindowInteropHelper(Application.Current.MainWindow).Handle;
InitializeWithWindow.Initialize(storeContext, hwnd);

Which means the license check cannot run during OnStartup before the window exists. It’s kicked off after Show() instead:

MainWindow = mainWindow;
mainWindow.Show();

// Must run after MainWindow is assigned so the Store license check
// can associate with a window handle.
_ = mainViewModel.InitializeLicensingStatusAsync();

The second one only shows up once someone has actually bought the add-on. StoreAppLicense.AddOnLicenses is a dictionary, and the obvious code is:

license.AddOnLicenses.TryGetValue(FullVersionStoreId, out var addOnLicense)

That never matches. The dictionary is keyed by <StoreId>/<Sku>"9MTPB8D2TFGH/0010", not "9MTPB8D2TFGH". So a paid-up license reads as “not purchased” and the app keeps showing the trial limit, with no error from the API anywhere — just an absent key. Matching on the prefix fixes it:

private static StoreLicense? FindFullVersionLicense(StoreAppLicense license)
{
    foreach (var kvp in license.AddOnLicenses)
    {
        if (kvp.Key == FullVersionStoreId || kvp.Key.StartsWith(FullVersionStoreId + "/", StringComparison.Ordinal))
        {
            return kvp.Value;
        }
    }

    return null;
}

Debugging this is awkward because Store APIs only work when the app is packaged and installed, which is not how you run it under F5. Detecting that is itself an exception-driven check — Windows.ApplicationModel.Package.Current throws InvalidOperationException when unpackaged rather than returning null:

private static bool IsPackaged()
{
    try
    {
        return Windows.ApplicationModel.Package.Current != null && Windows.ApplicationModel.Package.Current.Id != null;
    }
    catch (InvalidOperationException)
    {
        return false;
    }
}

Unpackaged runs fall back to a local flag in the database so the UI is still testable. That’s a development bypass, not an entitlement check, and it’s worth being clear-eyed about which one you’ve written.

MSIX signing: the certificate subject is not a label

The CI build produces a signed MSIX. My first attempt created a self-signed certificate with a friendly-looking subject:

New-SelfSignedCertificate -Type CodeSigningCert -Subject "CN=Karpach.EnvelopPrinter" ...

signtool was happy. Installation failed with 0x8007000B. The certificate subject must match the Publisher attribute in AppxManifest.xml character for character — and once you reserve a name in Partner Center, that value becomes CN=6884325E-1F8A-4326-8D57-2AFC390B7FD6, not anything human-readable. The build now reads the publisher out of the manifest instead of hardcoding it:

[xml]$manifest = Get-Content "msix-publish\AppxManifest.xml"
$publisher = $manifest.Package.Identity.Publisher

$cert = New-SelfSignedCertificate -Type CodeSigningCert -Subject $publisher `
  -FriendlyName "Envelope Printer Test Certificate" `
  -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3") `
  -NotAfter (Get-Date).AddYears(5)

It also deletes any cached .pfx first, so a manifest change can’t leave a stale certificate signing packages with the wrong subject.

Versioning had a related snag. Locally the version comes from git tags via MinVer, which produces things like 1.1.3-alpha.0.5 on untagged commits. The Store requires strictly Major.Minor.Build.0 with a fourth part of zero. CI therefore skips MinVer entirely and derives the version from the run number, which is monotonic by construction:

- name: Build solution
  run: dotnet build --configuration Release --no-restore /p:MinVerSkip=true /p:Version=${{ env.DISPLAY_VERSION }} /p:FileVersion=${{ env.MSIX_VERSION }} /p:AssemblyVersion=${{ env.MSIX_VERSION }}

with DISPLAY_VERSION = 1.1.<run_number> and MSIX_VERSION = 1.1.<run_number>.0.

Schema changes without EF migrations

Settings live in SQLite at %AppData%\Karpach.EnvelopPrinter\app.db via EF Core 8. The first release shipped with Database.EnsureCreated(), which is convenient and a dead end: it creates the schema once and then never touches it again. Every column added since — the trial counter, the font colors, the logo position — would simply be missing on an existing user’s database, and EnsureCreated() reports no problem at all. You find out when a query fails.

Since users' addresses live in that file, dropping and recreating it wasn’t acceptable. EnsureDatabase() now probes for each column and adds it if absent:

try
{
    using var cmd = conn.CreateCommand();
    cmd.CommandText = "SELECT FromFontColor FROM PrintSettings LIMIT 1";
    cmd.ExecuteNonQuery();
}
catch
{
    using var cmd = conn.CreateCommand();
    cmd.CommandText = "ALTER TABLE PrintSettings ADD COLUMN FromFontColor TEXT NOT NULL DEFAULT '#000000'";
    cmd.ExecuteNonQuery();
}

Exception-driven, repeated per column, and honestly it should have been proper EF migrations from day one. It ends with a single SELECT naming every expected column; if that throws, the schema has drifted beyond repair and the file gets deleted and rebuilt.

Deleting it has its own trap. Microsoft.Data.Sqlite pools connections, so the file handle outlives the DbContext and File.Delete fails with a sharing violation:

SqliteConnection.ClearAllPools();
if (File.Exists(dbPath))
    File.Delete(dbPath);

Small things that turned out to matter

Drag versus click on the same control. The address blocks are both draggable and click-to-edit. Treating MouseLeftButtonUp as a click means a 2-pixel wobble while dragging drops you into edit mode. A 4-pixel threshold separates the two intents:

if (!_isDragging && (Math.Abs(dx) > DragThreshold || Math.Abs(dy) > DragThreshold))
    _isDragging = true;

On mouse-up, _isDragging decides whether to persist a position or focus the text box.

The color palette is free. Rather than build a color picker, the dropdown reflects over System.Windows.Media.Colors and turns each named static property into a swatch with a #RRGGBB string. The hex string is what gets stored, which is also what GDI+ accepts via ColorTranslator.FromHtml — one representation, both renderers.

Address settings dialog

Logos get re-encoded on import. The user picks any image; it’s decoded, capped at 1000px on the long edge using BitmapImage.DecodePixelWidth (which downsamples during decode rather than after), and re-saved as PNG. Otherwise a 12MP phone photo ends up permanently in AppData for a box that renders at under an inch.

Wrapping up

Roughly speaking: the WPF/WinForms interop and the coordinate math were tedious but predictable, while the genuinely expensive bugs — the preview dialog’s hidden print button, the AddOnLicenses key format, the certificate subject mismatch — were all cases where an API failed silently and did something plausible instead of complaining. Printing and Store licensing are both areas where “it returned without throwing” means very little.

The app is on the Microsoft Store: five free prints to try it, then a one-time add-on unlocks unlimited printing. No subscription, and no Office required.

Posted on August 20, 2026 by

Getting Started with Poetry

I don’t have much experience with Python. A few years ago, I created a few fairly simple lead collector websites using Flask and that was pretty much it.

Then a few weeks ago I bought couple TP-Link smart light bulbs and quickly realized that Home Assistant’s built-in functionality does not serve my needs. I had to modify python-kasa Python library.

The library is using a poetry dependency management tool. It took me a few hours to understand the basics of it, so below I will summarize how to get started with it. The below guide is for Windows.

Prerequisites: Make sure you have Python installed and you checked during the installation checkbox for updating the path environment variable. This will enable Python and Pip to work from any Windows folder.

First, you need to install poetry. Run the following in Windows PowerShell:

pip install poetry

Then you need to initialize it:

poetry install

I assume you want to modify the source code, so open Visual Studio Code at the root of the project. Open any Python file to trigger an installation of the Python extension.

Then in the VS Code terminal (PowerShell) run:

poetry shell

In VS Code command prompt (Shift+Ctrl+p), type “Python: Select Interpreter”. Select the line which refers to Poetry at the right.

You are all set. Optionally you can open the Testing sidebar and configure the Python testing framework. Then run tests to confirm that your dev environment is fully functional.

Posted on July 7, 2023 by

Smart home automation

A lot of things changed since my last post about home automation. I moved to a single-family house. IFTTT decided to charge people for their service and limit the number of free integrations. Nobody uses Skype anymore for video calling. Harmony remotes are no longer supported by the manufacturer. So, what am I using in 2023 for home automation?

Hardware

The heart of everything is my old NUC from Google Home condo automation, Part 1 Hardware. I have Home Assistant running it on VM though VirtualBox.

Home Assistant

I still use google home for voice integrations. Now I have four of them plus two Chromecasts and several Android phones.

Google Home Devices

I have 20 Z-Wave devices mostly from Zooz brand. All of them talk to Home Assistant through ZST-10 S2 stick. Home Assistant exposes those to Google Assistant through local integration.

Zooz

The main garage door opens using Meross WiFi Garage Door Opener, which is connected to both Home Assistant and Google Assistant.

Merros Garage Door Opener

The shed door uses Genie Aladdin WiFi Smart Garage Door Opener, which also integrates with both Home Assistant and Google.

Genie Garage Door Opener

HVAC is controlled by Ecobee thermostat. I like that it has remote temperature sensors.

Ecobee Thermostat

For the home security system, I’ve chosen Ring Alarm Kit. Ring Alarm System is a DIY home security system that allows users to self-monitor their homes from anywhere. It includes a base station, keypad, door/window sensors, motion detector, and a range extender. Users can control their system through the Ring app, receive alerts, and customize their settings to fit their needs. The Ring Alarm System is easy to install and it is expandable, making it a popular choice for those who want a simple, affordable home security solution without monthly fees, although if you want there is an option for professional monitoring for the monthly fee.

Ring Alarm

Ring Alarm is integrated into Home Assistant and I am using its sensors for automation even when I am in the house. Ring Alarm is paired with a couple of Amcrest cameras and one Reolink camera. I integrated Amcrest cameras into Home Assistant through Frigate. The NUC is powerful enough to handle object recognition, so I can get notifications when there is somebody in my backyard. Reolink camera covers the front yard. It has built-in object recognition, so I don’t integrate it in Frigate and use as is, although it is still integrated with Home Assistant for eventing.

Also, I have a few z-wave First Alert smoke alarms. Those are easily integrated into Ring Alarm system, which is a huge bonus.

First Alert smoke alarm

I replaced Harmony remote with Broadlink WiFi Rm Mini 3. It has wide community support and easy integration with Home Assistant.

Broadlink Mini 3

One of my house windows faces the front yard, so I bought Zemismart WiFi Roller Shade Motor and ordered custom shades for that window. Zemismart is based on Tuya and easily integrated into Home Assistant. Simple automation closes the window shade at sunset.

I still own a few Lutron Caseta switches, although most junction boxes have neutral wire and Lutron switches are not a necessity.

Lawn watering is done with the help of couple B-Hyve hose bib controllers. Those are really difficult to setup, but I don’t there are any good budget friendly smart watering alternatives.

B-Hyve

I also have a few Kasa switches. I highly recommend those if you don’t want to invest a lot in home automation. This is a great way to start.

Door lock from Yale.

Monitor backlight based on ESP Home, has a motion sensor and a light sensor.

Some of my devices had Home Assistant integration that I was not even aware of when I bought them. For example, my LG OLED TV was automatically discovered by Home Assistant.

Automation scenarios

  1. When TV is powered on, switch the ambient light on and the soundbar to turn on as well. The soundbar has a standby mode, but that mode does not power off the wireless subwoofer. Since subwoofer, ends up being on all the time at some point it fails. My previous one failed in about a year of use. This is why it is beneficial to turn subwoofer off, when it is not used. When TV is powered off, the soundbar, subwoofer and ambient light powers off as well.

  2. I have several Zooz leak sensors. If a leak is detected then I get a voice notification on all Google devices as well as a push notification on the phone. About three months ago my sump pump failed and that notification alerted me just in time.

  3. The smart lock is on the door to the garage. That door automatically locks for the night. In the morning that door unlocks automatically when the first person goes out to the garage.

  4. The Ring arm mode automatically locks the door to the garage.

  5. The Ring motion sensor can be reused for automation. In my case, it turns a hall light on when motion is detected.

  6. The hall light turns on when the person arrives home after sunset.

  7. The garage door opens and closes when the car arrives and leaves.

  8. The shade for the front window opens with sunrise and closes with sunset.

  9. The monitor backlight turns on if it gets dark in the office, there is motion and the computer is turned on.

  10. The speakers around the house announce when Amazon or other shipping companies leave packages at the front door.

  11. The garage and shed light turn off if there is no movement for 10 minutes.

  12. The garage light turns off, when the internal door from the garage opens.

  13. The lawn gets watered when there were no rain and it is not expected in 12 hours.

  14. One of google homes plays the latest news at a certain time in the morning during work days.

  15. Get a phone reminder to close the garage or shed door.

  16. Most lights in the house can be turned on / off by voice or by phone. Some lights have a predefined schedule, e.g. the light in the kid’s bedroom room.

Posted on May 31, 2023 by