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 — itsextconf.rbwill 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
vcvarsto 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.