windraw

Headless 2D drawing for Windows — Direct2D + DirectWrite + WIC, straight to PNG.

windraw is a small Cairo-style 2D canvas built entirely on the graphics stack that already ships with Windows: Direct2D for vector shapes, DirectWrite for text, and WIC for PNG encoding. It renders into an off-screen bitmap — no window, no message loop — so it's perfect for generating images from scripts, jobs, and tests.

require "windraw"

Windraw.surface(800, 600) do |c|
  c.clear("#1e1e2e")
  c.rectangle(50, 50, 200, 120, fill: "#89b4fa", stroke: "#ffffff", width: 3)
  c.ellipse(400, 300, 120, 80, fill: "#a6e3a1aa")
  c.circle(650, 150, 60, fill: "#f38ba8")
  c.line(50, 550, 750, 450, color: "#fab387", width: 6)
  c.text("Hello, Direct2D!", 60, 220, font: "Segoe UI", size: 40, color: "#cdd6f4", bold: true)
end.save("hello.png")

Requirements

  • Windows with a native MSVC (mswin) Ruby (x64-mswin64). On a MinGW/UCRT Ruby this gem is not supported — its extconf.rb will say so.
  • Visual Studio 2017+ or the Build Tools with the Desktop development with C++ workload (for cl.exe + the Windows SDK headers/libs).

Building uses vcvars to load the MSVC toolchain automatically — no "Developer Command Prompt" needed.

Install

gem install windraw

API

Windraw.surface(width, height) { |c| ... } → Surface

Creates an off-screen surface (pixels; origin top-left). With a block, it yields the surface, finishes drawing when the block returns, and returns the surface so you can chain #save / #to_png. All drawing methods return self.

Drawing

c.clear("#1e1e2e")                                              # fill the whole canvas
c.rectangle(x, y, w, h, fill:, stroke:, width: 1.0, radius: nil) # alias: c.rect; radius rounds corners
c.ellipse(cx, cy, rx, ry, fill:, stroke:, width: 1.0)
c.circle(cx, cy, radius, fill:, stroke:, width: 1.0)
c.line(x1, y1, x2, y2, color: "#000000", width: 1.0)
c.polygon([[x, y], ...], fill:, stroke:, width: 1.0)            # closed
c.polyline([[x, y], ...], color: "#000000", width: 1.0)         # open
c.text(str, x, y, color:, font: "Segoe UI", size: 16, bold: false, italic: false)

radius: takes a single Numeric (rx == ry) or an [rx, ry] pair.

Output & lifecycle

surface.save("out.png")   # => "out.png"
surface.to_png            # => String (PNG bytes, ASCII-8BIT)
surface.size              # => [width, height]
surface.close             # release COM resources now (alias: dispose; GC also frees)

Roadmap (0.2): gradient brushes, arcs/bezier paths, and image compositing (load + draw existing PNGs).

fill: and stroke: take hex colors and are optional; passing both draws the fill first, then the stroke.

Colors

Any CSS-style hex string: #rgb, #rgba, #rrggbb, or #rrggbbaa (the # is optional). Alpha is supported everywhere.

Windraw::Color.parse("#ff8800")   # => [1.0, 0.533..., 0.0, 1.0]
Windraw::Color.parse("#0af5")     # => [0.0, 0.666..., 1.0, 0.333...]

Output

surface.save("out.png")   # => "out.png"  (writes a PNG via WIC)
surface.to_png            # => String     (PNG bytes, ASCII-8BIT)
surface.size              # => [width, height]

How it works

A Surface creates a WIC bitmap and a Direct2D WIC bitmap render target at 96 DPI (so 1 unit == 1 pixel). Drawing calls go straight to Direct2D / DirectWrite; save/to_png flush the render target (EndDraw) and encode the bitmap to PNG with WIC — to a file stream or an in-memory stream. COM is initialized per surface; drawing is intended to happen on the thread that created the surface.

License

MIT.